diff options
158 files changed, 20541 insertions, 3580 deletions
diff --git a/.agents/knowledge/agent-bridge.md b/.agents/knowledge/agent-bridge.md new file mode 100644 index 0000000..3791b3c --- /dev/null +++ b/.agents/knowledge/agent-bridge.md @@ -0,0 +1,73 @@ +# Tribal knowledge: agent bridge (`Jamstack::Bridge`) — eval-in-the-live-game + +## At a glance +- **What:** run Ruby in the **running** game (desktop now; web in R4), on the main + thread, via a frame-polled command queue. The substrate for hot-reload (R3), + logging (R2), `.live`/WS (R4), and the in-game console (R6). +- **Key files:** `src/main.c` (C: `js_cap_begin`/`js_cap_end` stdout capture, + `js_getenv`, `jamstack_bridge_init`); `mrbgems/raylib/mrblib/bridge.rb` (the + Bridge: TCP poll, queue, `eval_code`, JSON envelope); seam + `mrbgems/raylib/mrblib/raylib.rb` `while_window_open` (drains once per frame + before the game block). +- **Gate / transport:** env `JAMSTACK_BRIDGE=1` (localhost only, P7); + `JAMSTACK_BRIDGE_PORT` overrides the default **7621**. Desktop = TCP. +- **Cross-refs:** roadmap R1/R4, principles P6 (single-thread/frame-drain) & P7 + (dev-only); GLOSSARY "command queue", "the bridge". + +## How it works +`mrb_state` from `main.c` stays alive for the whole run. `while_window_open` calls +`Jamstack::Bridge.start` (if enabled) then `Bridge.drain` each frame **before** the +game block — so every command runs on the main thread (P6), never on a socket +callback. `drain` accepts new clients (`accept_nonblock`), reads pending data +(`recv_nonblock`), parses complete lines, and runs up to `MAX_PER_FRAME` (16) evals +to bound frame time. + +## Wire protocol (R1; R4 unifies to JSON both ways via the relay) +- **request:** one line `"<id> <code>"`. `<code>` is escaped: `\` → `\\`, newline → + `\n`, tab → `\t`. `<id>` is a space-free token. (Asymmetric on purpose — there is + no JSON *parser* in mruby, so the request side avoids needing one.) +- **response:** one line of JSON + `{"id","ok","result","stdout","error","backtrace"}`. `result` is the value's + `inspect`; `error` is `"Class: message"`; `backtrace` is an array or null. + +## Scar tissue (mruby ≠ CRuby; verified on this binary) +- **stdout capture must be done in C.** mruby's `puts`/`print`/`p` write straight + to C **fd 1** — NOT through `$stdout`. There is no `StringIO`, no `__printstr__`, + and overriding `STDOUT#write` does nothing. The ONLY working capture is the C + fd-redirect bracket: `js_cap_begin` (`dup`+`dup2` fd 1 → `tmpfile()`), + `js_cap_end` (restore, read back). `eval_code` wraps the eval in it. +- **No `JSON`, no `require`, no `ENV`** in the default gembox. → JSON response is + hand-encoded (byte-wise; bytes ≥0x20 pass through, so UTF-8 survives); the gate is + read via C `Jamstack.getenv` (not `ENV`). +- **`recv_nonblock`:** returns a String with data, raises `Errno::EAGAIN` when + empty, returns `""` on peer close (that's how we reap dead clients). +- **`-std=c11` hides POSIX** `fileno`/`dup`/`dup2`. `main.c` must + `#define _POSIX_C_SOURCE 200809L` before the includes. +- **eval context:** `eval(code)` runs with `self == Jamstack::Bridge`. Constants + (`Rl`, `Flecs`, …) and globals resolve fine; top-level **local** variables of the + game's `main.rb` are NOT visible. R3/R6 may want a dedicated top-level binding. +- **`window_should_close?`** is a sugar alias, not a generated binding — see + `raylib-binding.md`. The desktop seam needs it. +- **Reach live objects via `ObjectSpace`, don't rebuild for a global.** eval is *live* + but a top-level **local** (e.g. `player = world.character(...)`) isn't reachable from + an eval binding. Find the live object instead — `ObjectSpace.each_object(Jolt::Character){|c| ...}` + (`mruby-objectspace` is compiled in) — rather than editing the game to expose a global + and rebuilding. Game state already in globals (`$jolt`, `$flecs`, …) is directly + evaluable. + +## Run / verify +```sh +JAMSTACK_BRIDGE=1 ./zig-out/bin/game game/some_loop.rb # game must call while_window_open +# then, from another process, connect TCP 127.0.0.1:7621 and send "<id> <code>\n" +``` +A throwaway Python client lives in the R1 verification notes; sending `Rl.get_fps`, +a multi-line script, and a deliberate `raise` returns value / captured stdout / a +clean error+backtrace while the loop keeps running. + +## Not yet (later phases) +- ~~**Web** path (`EMSCRIPTEN_KEEPALIVE jamstack_eval` + `Module.ccall`): R4.~~ — **DONE.** + The web relay (`tools/agent-bridge/server.js`) bridges the browser game to the + host filesystem via a file-based poll loop. Same `bin/*` scripts (eval, snapshot, + query, tail-log, hot-reload) work on both targets. See `.agents/knowledge/live-mount.md`. +- ~~**`.live/` files, WS relay, line-JSON both ways:** R4.~~ — **DONE.** +- **Result routing to the log pipeline / NDJSON:** R2 — done (Log ring buffer). diff --git a/.agents/knowledge/build-system.md b/.agents/knowledge/build-system.md new file mode 100644 index 0000000..e1863d4 --- /dev/null +++ b/.agents/knowledge/build-system.md @@ -0,0 +1,54 @@ +# Tribal knowledge: build system + +## At a glance +- **Key files:** `build.zig` (desktop orchestrator), `build_config.rb` (mruby + the + 4 mrbgems), `rebuild.sh` (incremental), `build_web.sh` (web), each `mrbgems/*/mrbgem.rake`. +- **Commands:** `zig build` / `zig build run`; `./rebuild.sh`; + `EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh`; run `./zig-out/bin/game path.rb`. +- **Cross-refs:** rules `link-order`, `mruby-rebuild`, `lld-no-gcc-lto`, + `raylib-platform-objs`, `wsl-toolchain`; design `docs/BUILD_SYSTEM.md`; human steps + `BUILDING.md`; skill `build-and-verify`. + +## Topology +`Ruby game code → mruby VM → Rl::/Rml::/Flecs:: bindings → raylib/RmlUi/flecs`, +linked by **Zig** (desktop) or **emscripten** (web). `src/main.c` boots mruby and +runs a script (`argv[1]`, default `game/main.rb`). + +Four native libs are built separately and linked at the end: +- `build/desktop/libraylib.a` — raylib via `make` (guarded; built once). +- `vendor/rmlui/build-static/librmlui.a` — RmlUi via `cmake`, target `rmlui_core` ONLY. +- `build/desktop/libflecs.a` — flecs amalgamation, one `cc` object. +- `vendor/mruby/build/host/lib/libmruby.a` — mruby + our 3 mrbgems via `rake` + (rebuilt every `zig build`; rake is incremental). + +## Commands +- Desktop: `zig build` / `zig build run` (orchestrates all of the above). +- Incremental binding work: `./rebuild.sh` (rake libmruby + zig link). +- Web: `EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh` → `build/web/game.{html,js,wasm,data}`. +- Always export the cleaned PATH first (see rules/wsl-toolchain.md). + +## Why zig links GNU libstdc++ directly (build.zig) +zig 0.16's `linkSystemLibrary("stdc++")` hijacks to its own LLVM **libc++**, which +lacks the GNU libstdc++ ABI symbols RmlUi needs. So we `addObjectFile` +`/usr/lib/libstdc++.so` and `/usr/lib/libgcc_s.so.1` (the latter for +`_Unwind_Resume`: mruby is built with `MRB_USE_CXX_EXCEPTION` because a C++ +mrbgem, rmlui, is present). + +## mruby config (build_config.rb) +- `conf.disable_presym` — lets us add new binding method names without + regenerating the presym table (avoids stale-symbol errors on rebuild). +- One `MRuby::Build` (host) + one `MRuby::CrossBuild('web')` guarded by + `JAMSTACK_WEB`. Both list the same 3 gems (raylib, rmlui, flecs). +- `JAMSTACK_ROOT` is exported by the build scripts so mrbgem.rake/build_config + resolve paths. + +## Per-target raylib objects +raylib shares `.o` in `vendor/raylib/src` across platforms → see +rules/raylib-platform-objs.md. Output dirs are `build/desktop` and `build/web`. + +## Gotcha index (when something breaks) +- "multiple definition" at link → stale mruby objects: `rm -rf vendor/mruby/build`. +- "undefined reference" to ecs_/Rml/raylib syms → link order or a missing native lib. +- rake tries to build `mruby`/`mirb` and fails → you ran plain `rake`; target the + `libmruby.a` path instead. +- raylib symbols are wasm/desktop-mismatched → forgot `make clean` between targets. diff --git a/.agents/knowledge/console.md b/.agents/knowledge/console.md new file mode 100644 index 0000000..357b6d1 --- /dev/null +++ b/.agents/knowledge/console.md @@ -0,0 +1,65 @@ +# Tribal knowledge: in-game REPL console (`Jamstack::Console`) + +## At a glance +- **What:** a user-facing RmlUi panel that evaluates Ruby in the game script's + binding — full access to local variables and game state. Toggle with backslash + (`\`, `KEY_BACKSLASH` = 92; JIS-friendly). Enter to eval, Up/Down for history, + Tab for completion. +- **Key files:** `mrbgems/rmlui/mrblib/console.rb` (the class, compiled into the + gem); `game/ui/console.rml` + `console.rcss` (markup/styles); + `mrbgems/rmlui/src/rml_bindings.cpp` (C++: `rml_el_select`, + `rml_el_set_selection_range` for caret control); `game/console_demo.rb` + (usage example). +- **API:** `Jamstack::Console.new(ctx, binding: binding)` — pass the game + script's binding so eval sees local variables. `console.update` (before + `ctx.process_input`) handles the toggle key. `console.open?` lets the game + skip gameplay input when the console is visible. +- **Cross-refs:** `rmlui-binding.md` (keyboard input, element API), `agent-bridge.md` + (the eval queue — the console is a UI front-end for the same eval), + roadmap R6. + +## How it works + +### Toggle key +`console.update` is called **before** `ctx.process_input` each frame. It checks +`Rl.key_pressed?(KEY_BACKSLASH)`. When the toggle fires, it drains both +`GetKeyPressed()` and `GetCharPressed()` queues in Ruby so the backslash isn't +forwarded to RmlUi as text input. Then `show`/`hide` toggles the document. + +### Variable assignment propagation (mruby gotcha) +mruby's `eval` creates a **new local variable scope** — assignments like +`player_color = Rl::RED` do NOT write back to the game loop's closure. The +`eval_line` method detects simple `var = expr` assignments (without Regexp — +mruby has no `Regexp` class) and uses `binding.local_variable_set` which writes +to the binding's env (shared with the closure). Compound assignments (`+=`, etc.) +and non-identifier LHS fall through to plain `eval`. + +**Only variables that existed before the binding was captured can be modified +this way.** New variables created in the console are only visible to subsequent +`eval` calls, not to the game loop's closure. + +### Tab completion +- **No separator** → local vars (`binding.local_variables`) + methods + (`binding.receiver.public_methods`) + `Object.constants` +- **After `.`** → `receiver.public_methods` +- **After `::`** → `receiver.constants` +- Single match: completes inline. Multiple: completes common prefix + lists + candidates in scrollback. `KI_TAB` `stop_propagation` prevents RmlUi's default + focus navigation. + +### Caret control +After tab completion and history navigation, `@input.caret_end` moves the caret +to the end of the text. This calls `SetSelectionRange(len, len)` via the C++ +binding (`dynamic_cast<ElementFormControlInput*>`). + +### Key identifiers (RmlUi) +The keydown event carries `key_identifier` as an int parameter. The Ruby +`Event#[]` accessor reads it as a float (via `GetParameter<float>`), so call +`.to_i` to compare: `KI_RETURN` = 72, `KI_TAB` = 70, `KI_UP` = 91, `KI_DOWN` = +93. See `vendor/rmlui/Include/RmlUi/Core/Input.h` for the full enum. + +### HTML escaping in scrollback +`inner_rml=` parses the string as RML, so `<`, `>`, `&` must be escaped. The +`escape_html` helper also converts `\n` → `<br/>` for multi-line output. A +sentinel `<div id="scroll_end"></div>` + `scroll_into_view(false)` provides +auto-scroll. diff --git a/.agents/knowledge/environment.md b/.agents/knowledge/environment.md new file mode 100644 index 0000000..f130a40 --- /dev/null +++ b/.agents/knowledge/environment.md @@ -0,0 +1,24 @@ +# Tribal knowledge: environment (WSL / WSLg) + +This repo is developed under **WSL** (Linux on Windows). Two hard-won facts: + +## PATH: Windows shadows Linux +A Windows Ruby/rake on `/mnt/c/...` appears first in PATH and shadows the Linux +toolchain, producing baffling failures. Strip it before every build / ruby / +rake / generator invocation (see rules/wsl-toolchain.md). The build scripts +(`rebuild.sh`, `build_web.sh`) already do this and also prepend the user gem bin +(`$(ruby -e 'puts Gem.user_dir')/bin`) so the Linux `rake` gem resolves. + +## Graphics: use Wayland, not X11 (WSLg) +WSLg's X11/GLX path **segfaults inside Mesa** (`dri2GalliumConfigQueryb`). raylib +is therefore built with the **Wayland** GLFW backend: +`make ... GLFW_LINUX_ENABLE_WAYLAND=TRUE GLFW_LINUX_ENABLE_X11=FALSE`, and +`build.zig` links the wayland-* libs (`wayland-client/cursor/egl`, `xkbcommon`) +plus `EGL`. For a normal X11 desktop, swap those back to `X11` in both places. + +Expect harmless Mesa/EGL/zink warnings on stderr in this environment +(`libEGL warning: ... zink ...`, `Wayland: The platform does not provide the +window position`); they are not errors. + +## Toolchain versions (pinned) +raylib 5.5, mruby 3.3.0, RmlUi 6.1, flecs v4.1.1, Zig 0.16.0, emcc 6.0.0 (emsdk). diff --git a/.agents/knowledge/flecs-binding.md b/.agents/knowledge/flecs-binding.md new file mode 100644 index 0000000..2a3582b --- /dev/null +++ b/.agents/knowledge/flecs-binding.md @@ -0,0 +1,74 @@ +# Tribal knowledge: flecs / ECS bindings (`Flecs::`) + +Hand-written C (`mrbgems/flecs/src/flecs_bindings.c`) + Ruby sugar +(`mrblib/flecs.rb`), modeled on flecs' Lua binding. Full spec: +`docs/API_SPEC_FLECS.md`. + +## At a glance +- **Key files:** `mrbgems/flecs/src/flecs_bindings.c`; sugar `mrblib/flecs.rb`; + `mrbgem.rake`; amalgamation `vendor/flecs/distr/flecs.{c,h}` → `libflecs.a`. +- **Ruby API:** `Flecs::World` (`entity`/`struct`/`tag`/`query`/`system`/`progress`), + `Entity`/`Component`/`Query`. Phases `ON_LOAD → PRE_UPDATE → ON_UPDATE → ON_START`. + Spec `docs/API_SPEC_FLECS.md`. +- **Cross-refs:** skill `add-flecs-system`; the substrate for the agentic runtime + (roadmap Part B). No game uses it yet; REST is compiled in but not startable from + Ruby (R5). + +## Component model (the key design) +Components are real C structs declared at runtime via the **meta/reflection +addon**: `world.struct("Position", "{float x; float y;}")`. Values are +(de)serialized between the C memory and Ruby **Hashes**. We use flecs' **public** +meta API only — `ecs_meta_cursor` for writes, `EcsStruct`/`EcsPrimitive` +reflection (direct offset reads) for reads — deliberately NOT the semi-private +serialized-ops the Lua binding uses (forward-compat). + +## flecs v4 API specifics (bit me during binding) +- `ecs_ensure_id(world, e, id, size)` — v4 added the trailing `size` arg (pass the + component's `EcsComponent.size`). +- `ecs_entity_desc_t.add` is a **0-terminated `ecs_id_t*` array**, not inline. +- `ecs_ctx_free_t` is `void(*)(void*)` (no world arg); `callback_ctx`/ + `callback_ctx_free` are the per-callback slots on systems and `ecs_iter_t`. +- Field indices are **0-based** in v4 (`ecs_field_w_size(it, size, 0)`). + +## Web (wasm) — the one real gotcha +The full amalgamation (incl. meta) is emscripten-aware and links cleanly. BUT +flecs' init/meta uses more stack than emscripten's 64 KB default → a too-small +stack shows up as a wasm **"memory access out of bounds"** trap. The web link +uses `-sSTACK_SIZE=4MB`. Don't try `-DFLECS_NO_HTTP/REST` — REST depends on HTTP +and it `#error`s; just build the whole amalgamation. + +## API shape / limits +`Flecs::World` (entity/struct/tag/lookup/query/system/progress), `Flecs::Entity` +(set/get/add/remove/has?/...), `Flecs::Component`, `Flecs::Query` (Enumerable). +Systems/queries yield `|entity_id, *component_hashes|` (raw Integer id for speed; +`world.entity_for(id)` to wrap); mutations to the hashes are written back. +Single-threaded `progress` only (also the only mode valid on wasm). Not exposed: +relationships/pairs, prefabs, observers, query operators, multithreading. + +## Entity delete leak (FIXED) +**Symptom (was):** after summon→delete→summon cycles, `world.query(comp)` yielded +entity ids whose `world.entity_for(id).alive?` was false, and `entity_for(id).delete` +did NOT reduce the query count. First-ever (generation-0) entities deleted fine — +only recycled ones leaked. + +**Root cause:** flecs recycles entity ids with a bumped **generation** in the high 32 +bits of the 64-bit `ecs_entity_t` (`ECS_ENTITY_MASK` is `0xFFFFFFFF`; generation lives +above it). On the **web/wasm32** build, `mrbconf.h` auto-detected `MRB_32BIT` (because +`SIZE_MAX` is 32-bit on wasm32) → `MRB_INT32` → `mrb_int` is `int32_t`, which truncated +the generation bits when ids round-tripped through `fl_yield_iter`/`fl_w_delete`/ +`fl_w_alive` via `mrb_get_args("i",...)` / `mrb_int_value`. `ecs_delete`/`ecs_is_alive` +then saw a stale generation (0) and no-op'd. Desktop (x86-64) auto-detected `MRB_INT64` +so it was unaffected, but the same binding code would break on any 32-bit target. + +**Fix:** force `-DMRB_INT64` in both mruby build configs (`build_config.rb`) AND on +`src/main.c` compilation (`build.zig` flags + `build_web.sh` emcc line) so `mrb_int` is +`int64_t` on **every** target — the full 64-bit entity id (generation + index) now +round-trips through the mruby boundary unchanged. This is an ABI change: `rm -rf +vendor/mruby/build` is required when first applying it (stale 32-bit objects cause +"multiple definition" / signature-mismatch link errors). Verified with a +summon/delete/summon recycle test on desktop + web (node headless). + +## Build wiring +Amalgamation `vendor/flecs/distr/flecs.{c,h}` → `libflecs.a` (`cc` desktop / +`emcc` web). The gem only needs `vendor/flecs/distr` on its include path; the lib +links at the final step. diff --git a/.agents/knowledge/flecs-observability.md b/.agents/knowledge/flecs-observability.md new file mode 100644 index 0000000..c67efc7 --- /dev/null +++ b/.agents/knowledge/flecs-observability.md @@ -0,0 +1,80 @@ +# Tribal knowledge: flecs observability (REST / Explorer / stats) — R5 + +> Status: **desktop IMPLEMENTED + verified** (R5). Web (R5a) follows in the web +> phase. `world.enable_rest` / `world.enable_stats` in `flecs_bindings.c` + `flecs.rb`. + +## At a glance +- **What:** surface flecs's built-in remote API so a human/agent gets a full ECS + view — entities, components, queries, per-system timing — with **zero UI code on + our side** (the hosted Flecs Explorer connects remotely). +- **Key files:** `mrbgems/flecs/src/flecs_bindings.c` (add `_enable_rest`/ + `_enable_stats`); sugar `mrbgems/flecs/mrblib/flecs.rb` (`enable_rest`, + `enable_stats`). Amalgamation already built **with** `FLECS_REST`/HTTP/STATS. +- **Cross-refs:** `flecs-binding.md`, `agent-bridge.md` (web ships REST JSON over the + bridge), roadmap R5/R5a; principle P5 (observe). + +## Desktop mechanism (the whole thing) +```c +FlecsRestImport(world); // register REST module +ecs_set(world, EcsWorld, EcsRest, {.port = port}); // -> starts HTTP server +FlecsStatsImport(world); // per-system timing/world stats +``` +The REST HTTP server runs on `:27750` (flecs manages its own thread); it's serviced +during `world.progress`, which the game already calls each frame. Then point the +**hosted Explorer** at it remotely — no local UI: +`https://www.flecs.dev/explorer/?host=localhost:27750`. +Quick check without a browser: `curl http://localhost:27750/world` (or +`/entity/<name>`) returns JSON. + +## Ruby API (planned) +```ruby +world.enable_rest(27750) # default ECS_REST_DEFAULT_PORT +world.enable_stats # FLECS_STATS for per-system timing +``` +Gate behind dev/JAMSTACK_BRIDGE in game code (it opens a local port — P7). + +## Web (R5a) — flecs already did most of it +`ecs_http` can't bind a listening socket in the browser, BUT flecs ships, under +`ECS_TARGET_EM`, an `EMSCRIPTEN_KEEPALIVE char* flecs_explorer_request(method, req, +body)` that runs `ecs_http_server_request` against a socketless +`flecs_wasm_rest_server` (a non-static global). Plan for the web phase: +- init it once: `extern ecs_http_server_t *flecs_wasm_rest_server; + flecs_wasm_rest_server = ecs_rest_server_init(world, NULL);` +- `flecs_explorer_request` is already exported — call it from JS / the bridge and + ship the JSON over the agent channel; feed `.live/state.json`. +This is the R5a spike's happy path: the request handler is reachable **without** the +socket server thread. + +## Acceptance +Desktop: `curl :27750/world` returns JSON and the hosted Explorer shows the live +world; per-system timing visible with `enable_stats`. (Web: covered in R5a.) + +## Scar tissue (verified desktop) +- **Works as a 3-liner:** `FlecsRestImport(w)` + `ecs_set(w, EcsWorld, EcsRest, + {.port})` + `FlecsStatsImport(w)`. No `ECS_IMPORT` macro needed — call the import + functions directly (avoids a local-var declaration). `ecs_id(EcsRest)` is a fixed + compile-time id, but you still must import the module so its system/observers run. +- **Verified endpoints** (game looping `progress`, `curl`/urllib on :27750): + `/world` → 200; `/entity/player?values=true` → + `{"components":{"Position":{"x":1,"y":2},"Velocity":{...}}}`; + `/query?expr=Position&values=true` → all matches with values. These are exactly + what the hosted Explorer drives (`flecs.dev/explorer?host=localhost:27750`). +- **Threading:** the REST HTTP server runs on flecs's own thread (accepts + connections), but requests are *processed* during `world.progress` — so the game + must keep ticking for responses (it does, each frame). +- **Gotcha (not REST):** the **base** `world.system(with: [...])` expects component + **ids/objects** — it does `.to_i`, so string names silently become `0` and the + system matches nothing. Use `Flecs::Hot.define_system` (resolves names via lookup) + or pass `Component` objects. +- **In-process REST requests (`rest_request`):** `world.rest_request("GET", "/world")` + calls `ecs_http_server_request` on the `fl_rest_server` handle (set by + `enable_rest`). No socket needed — works on desktop AND web identically. This is + what `bin/snapshot` and `bin/query` use to dump ECS state JSON through the `.live/` + mount. On desktop, `enable_rest` creates TWO server objects: one with a port (HTTP + listener for the hosted Explorer) and one socketless (for in-process requests). + Both share the same world. On web, only the socketless one exists (shared with + `flecs_wasm_rest_server` for the JS C export). Key endpoints: `/world` (full world + state incl. entities + components), `/query?expr=<ComponentName>&values=true` + (matching entities with component values), `/entity/::<name>?values=true` (single + entity — note the `::` scope separator; may not find entities created without a + scope path — use `/query` as the reliable fallback). diff --git a/.agents/knowledge/fx-pipeline.md b/.agents/knowledge/fx-pipeline.md new file mode 100644 index 0000000..40c4d16 --- /dev/null +++ b/.agents/knowledge/fx-pipeline.md @@ -0,0 +1,213 @@ +# FX pipeline — `Jamstack::FX` + +> **At a glance** — a layered, two-stage, runtime-toggleable post-processing +> shader pipeline. Pure Ruby over the bound raylib shader API; **no C**. Each +> effect chooses whether it touches only the game world (+ in-world UI) or the +> whole frame (game + overlay HUD). Toggling never recompiles a shader. +> +> **Key files** +> - `mrbgems/raylib/mrblib/fx.rb` — `Jamstack::FX` module: `header`, `Pass`, +> `Pipeline`, `Frame`, and the shader-body constants (`SCANLINES`, `VIGNETTE`, +> `FXAA`, …). +> - `game/fx_demo.rb` — the reference scene: builds the pipeline, wires the +> overlay-HUD checkboxes/slider to the passes. +> - `game/ui/fx_overlay.rml` / `.rcss` — the overlay HUD (toggles + slider). +> +> **API/spec pointer** — `docs/API_SPEC_RAYLIB.md` (shader fns: +> `load_shader_from_memory`, `get_shader_location`, `set_shader_value`, +> `set_texture_filter`, `load_render_texture`, `texture_mode`, `shader_mode`). +> +> **Cross-refs** — the GLSL/header/macro-shim detail lives in +> `raylib-binding.md` ("Custom shaders"); the WebGL2/ES3 web specifics in +> `web-target.md` ("FX shader pipeline on web"); rendering RmlUi into the FBO +> (context dims, the two contexts) in `rmlui-binding.md` ("Rendering RmlUi into +> a RenderTexture"). This doc holds only the FX-architecture scar tissue that +> lives nowhere else. + +## The two-stage model (why it exists) + +Three render layers, two shader chains, one screen blit: + +``` + GAME LAYER 3D world + in-world RmlUi -> RenderTexture G + | + GAME SHADERS ping-pong chain on G (world only; NOT the overlay HUD) + | + OVERLAY LAYER processed-game quad + overlay RmlUi HUD -> RenderTexture C + | + TOP SHADERS ping-pong chain on C (whole frame; over EVERYTHING incl HUD) + | + screen +``` + +**Why two stages:** so a gameplay effect can transform the world without wrecking +the overlay HUD. Warp/scanlines/aberration would smear HUD text into illegibility +→ they belong in the **game** stage (the HUD is composited *after*, so it stays +crisp). Vignette/grayscale/FXAA-of-everything belong in the **top** stage +(intentionally affect the HUD too). The overlay HUD is drawn into `C` *in the same +`texture_mode(C)` block* as the composited game quad, so top shaders filter both. + +`Pipeline#w/h` defines the render-target size; `Frame#game_layer` / `#overlay_layer` +are the per-frame builder methods (see the usage block at the top of `fx.rb`). + +## Runtime toggle — zero recompilation + +`Pass` compiles its shader **once at construction** and caches uniform locations. +`Pass#enabled = false` just removes it from the per-frame chain (`apply_chain` +selects `enabled && !suppress`). Toggling from the eval bridge or the in-game +console is free — never reload/rebuild. An optional `uniform float intensity` +(0..1) lets an effect *fade* rather than snap (bound only if the body declares it). + +## `extra_uniforms` — runtime knobs without recompilation + +A `Pass` takes `extra_uniforms: {name => value}` (floats). Locations are cached at +construction; values are set per frame in `apply`. Use this for multi-knob shaders +(FXAA's `subpix` / `edgeThreshold` / `edgeThresholdMin`): set the values at runtime +from a slider → the running pass picks them up, **no shader recompilation**. Names +are fixed at construction; values are mutable. + +## `Pass#suppress` — in-chain but skipped this frame + +A pass can sit in a shader list yet be skipped per-frame via `suppress` (true). +`apply_chain` selects `enabled && !suppress`. Distinct from `enabled`: +- `enabled` = user toggle ("this FX is on/off"). +- `suppress` = programmatic skip ("redundant this frame because another pass + already covers it"). + +**The use case — two-layer FXAA, no double-blur:** `fx_demo.rb` runs FXAA in *both* +stages (`fxaa_game` world-only, `fxaa_ui` whole-frame). When `fxaa_ui` is on it +already AA's the whole frame *including the world*; letting `fxaa_game` also run +would double-AA the world (extra blur). So `fxaa_game.suppress = fxaa_ui.enabled`: +both-on ⇒ the game pass is suppressed ⇒ exactly the whole-frame behaviour, no +double-blur. (world-on/ui-off ⇒ only the world is AA'd, HUD stays crisp — the mode +that justifies the split.) If you see a pass "not running despite enabled=true," +check `suppress` first — it is *not* a user-facing toggle. + +## FXAA requires BILINEAR on the render textures + +`Pipeline` ctor sets `TEXTURE_FILTER_BILINEAR` on **every** render texture. This is +not cosmetic: **FXAA needs sub-pixel bilinear sampling to blend edges.** With the +default point sampling, the FXAA edge-search samples identical texel values → +detects no contrast gradient → applies no AA (looks like a no-op). It is set once on +all RTs (`@g + @c`) because the top stage may run FXAA on any of them; harmless to +the non-FXAA passes. **Any new AA pass that samples neighbours** (SMAA, SSAA +downscale, CAS) inherits this correctly — do not "fix" the bilinear back to point. + +## FXAA `GREEN_AS_LUMA` + the missing-luma caveat (open option B) + +The FXAA body uses **green as luma** (`FxaaLuma(rgba) = rgba.g`). Reason: our RGBA8 +render targets carry **uniform alpha = 1**, so luma-from-alpha would detect no +edges at all. Green is a reasonable perceptual-luma proxy. + +**Caveat:** pure red/blue edges with **no green component** get little/no AA (their +luma delta is small). The escape hatch is **option B — a luma-pack pre-pass**: a +cheap pass that computes perceptual luma `(0.299R + 0.587G + 0.114B)` and writes it +into the alpha channel, after which FXAA reads alpha-as-luma and catches every +edge. Cost: one extra full-screen pass. **Not implemented** — green-as-luma is +good enough for the current demo; revisit if red/blue aliasing shows. (This was +previously referenced from `fx.rb` as "roadmap option B" — but the roadmap is a +*harness* roadmap with no FX section, so that pointer was dangling; the option +lives here now.) + +## Which shader goes in which stage + +- **Game stage** (world + in-world UI; leaves overlay HUD crisp): `WARP`, + `ABERRATION`/`ABERRATION_CMY`, `SCANLINES`, `CRT` (all-in-one), `fxaa_game`. + Rule of thumb: *anything that would smear text or warp geometry*. +- **Top stage** (whole frame incl HUD): `VIGNETTE`, `GRAYSCALE`, `COLORGRADE`, + `fxaa_ui`. Rule of thumb: *gentle, whole-frame tonal/AA effects*. + +## Wiring a HUD control to a pass (the demo pattern) + +The overlay HUD drives passes via RmlUi. The non-obvious bits (RmlUi-binding has +the context/FBO detail; this is the control-wiring detail): + +- **Checkbox:** `<input type="checkbox" id="chk-x" checked/>` → + `el = doc.element("chk-x"); el.on(:change) { pass.enabled = el["checked"] }`. + Read `el["checked"]` (truthy/falsy string). +- **Range slider:** `<input type="range" id="rng-q" min="0" max="1" step="0.05" value="0.6"/>` + → `el.on(:change) { q = el["value"].to_f; pass.extra_uniforms[:knob] = q }`. + **The value is a String — `.to_f` it.** The `:change` event fires on both + checkbox toggles and slider drags. Setting an `extra_uniforms` value mutates the + live pass; no rebuild. +- **Shared control over multiple passes:** just call both in the handler + (`fxaa_quality(fxaa_game, q); fxaa_quality(fxaa_ui, q)`). Whichever actually runs + (per `enabled`/`suppress`) uses the latest value. + +## SMAA 1x — DONE (alongside FXAA, for A/B testing) + +`Jamstack::FX::Smaa` (mrblib/smaa.rb) is a **composite** 3-pass effect (edge +detect → blend weights → neighbourhood blend) that ducks as a `Pass` for +`Pipeline#apply_chain` (responds to `enabled`/`suppress`/`extra_uniforms` + +`apply(src, dst, t, scene)`), running its own internal ping-pong over two +intermediate render textures (`edge_rt`, `blend_rt`). The shaders are the +canonical iryoku/smaa GLSL, preprocessed from `SMAA.hlsl` with `cpp +-DSMAA_GLSL_3 -DSMAA_PRESET_HIGH -DSMAA_DISABLE_DIAG_DETECTION …` (diag compiled +out) — saved at `mrbgems/raylib/tools/smaa_canonical.glsl`. The `fx_demo.rb` +wires two instances (game + ui) + a threshold slider, exactly like FXAA. + +Scar tissue (all non-obvious, hard-won): + +- **Multi-texture binding needs no rlgl.** SMAA passes 2 & 3 sample several + textures in one shader (edges+area+search; image+blend). raylib's + `DrawTexturePro` only auto-binds the *drawn* texture to unit 0 (`texture0`); + the extra samplers are bound via **`Rl.set_shader_value_texture`** — raylib's + `rlSetUniformSampler` registers the id + sets the sampler uniform, and the + actual GL bind is **deferred to the batch flush** (the `DrawTexturePro` draw). + So call `set_shader_value_texture` for each extra sampler inside `shader_mode`, + then `draw_texture_pro` the main texture. (No `rlActiveTexture`/`rlEnableTexture` + is exposed in this binding — and none is needed.) + +- **Lookup textures live in C, not Ruby.** The `areaTex` (160×560 RGBA8, + 358 KB) + `searchTex` (64×16, 4 KB) are baked canonical bytes (`src/ + smaa_tex_data.c`, generated by `tools/gen_smaa_tex.rb` from the real + iryoku/smaa `Scripts/*.py` — ortho region + search R channel byte-exact) and + exposed to Ruby as Strings via `Rl.smaa_area_bytes` / `Rl.smaa_search_bytes` + (`mrb_str_new` at runtime), then uploaded with `Rl.update_texture`. They are + NOT Ruby literals because mruby (a) caps each string literal at + `MRB_PARSER_TOKBUF_MAX` = **65534** bytes and (b) a ~358 KB string constant + **hangs the irep loader at boot**. Runtime generation was also ruled out + (~12 s in mruby for the closed-form area math; even offset-0-only). A C const + array has none of these limits; `mrb_str_new` at runtime has none either. + `areaTex` filter = **BILINEAR** (the shader interpolates the area LUT), + `searchTex` = **POINT** (it's an index — must not interpolate). + +- **SMAA 1x samples only offset-row 0.** `subsampleIndices = 0` for 1x (the SMAA + comment says so), so the areaTex `texcoord.y += SUBTEX_SIZE*offset` stays in the + first 1/7 block. We bake the full canonical area (all 7 rows) anyway; the diag + half is zeroed (`SMAA_DISABLE_DIAG_DETECTION` — the shader never reads it). + +- **The SMAA PS functions take `sampler2D` args + `float4 offset[3]`.** SMAA's + own vertex shader computes `offset[3]`; raylib's default VS can't, so each + pass's `main()` **inlines the VS offset math** from `fragTexCoord` + + `rtMetrics` (`uniform vec4 rtMetrics; #define SMAA_RT_METRICS rtMetrics`). + `SMAA_MAX_SEARCH_STEPS` = 16 (PRESET_HIGH) is used only in the blend pass's + `offset[2]`. The threshold is a `uniform float smaaThreshold` (swapped into + the preprocessed LumaEdgeDetectionPS via `.sub`, only in the edge pass) so the + slider tunes it without recompiling. + +- **Intermediate RTs are BILINEAR-filtered (NOT POINT).** The chain's color RTs + are BILINEAR (Pipeline sets that for FXAA); SMAA samples the color input softly + as a result — acceptable, slightly soft. **`edge_rt`/`blend_rt` SMAA owns MUST + be BILINEAR too** — this is critical and was previously wrong (POINT). SMAA's + `SMAAArea` reads the crossing edges `e1`/`e2` via a sub-texel sample of `edge_rt`; + POINT makes them binary `{0,1}` → `SMAAArea` samples the areaTex's zero corner + regions → **zero weights → no AA**. BILINEAR blends the sub-texel sample → + `e1/e2 ∈ {0,0.25,0.75,1.0}` → reads the real area data → AA works (matches + three.js's LINEAR edgesRT/weightsRT). See `smaa-root-cause.md` for the full + diagnosis. `areaTex` stays BILINEAR; `searchTex` stays POINT (it's an index). + +## Open options (the AA upgrade path) + +- **Option B — luma-pack pre-pass** (above): fixes FXAA's red/blue blind spot, ~1 pass. +- **TAA** — temporal: needs motion vectors + a history RT + a resolve with + neighbourhood clamping. Best on moving cameras; most work. Pair with a *sharpen* + pass (CAS), not with another spatial AA. +- **SSAA** — render the game pass at 2× and downsample (bilinear, already enabled): + trivial to add as one Pass; catches everything but costs fill-rate + memory. +- **SMAA diagonal detection** — currently disabled (`SMAA_DISABLE_DIAG_DETECTION`); + enabling needs the diagonal `areaTex`/`searchTex` (brute-force generated) + + porting `SMAACalculateDiagWeights`/`SMAASearchDiag*` back in. Ortho-only is the + LOW/MEDIUM-preset config. + +See `roadmap.md` only for harness milestones — FX feature options live here. diff --git a/.agents/knowledge/hot-reload.md b/.agents/knowledge/hot-reload.md new file mode 100644 index 0000000..b389327 --- /dev/null +++ b/.agents/knowledge/hot-reload.md @@ -0,0 +1,84 @@ +# Tribal knowledge: hot-reloadable Flecs systems (`Flecs::Hot`) — R3 + +> Status: **IMPLEMENTED + verified** (R3) — `mrbgems/flecs/mrblib/hot.rb`. Verified +> desktop, both inline and live over the bridge. + +## At a glance +- **Goal (the core ask):** edit a system's logic — and add new systems — on the + **running** game without resetting; entities and component data survive, only + behavior swaps. Drives the agentic dev loop (with R1 eval + R2 logs). +- **Key files:** `mrbgems/flecs/mrblib/hot.rb` (the registry; **new**), built on the + existing `Flecs::World#system` (`flecs_bindings.c` `fl_w_system`/`fl_system_cb`). +- **Cross-refs:** `flecs-binding.md` (system dispatch), `agent-bridge.md` (reload + driven live over the bridge), roadmap R3; principle P5 (mutate via reload). + +## The design decision: pure-Ruby system registry (NO C change) +The flecs binding stores the Ruby block in the system's `callback_ctx` +(`fl_cb_t.blk`) and `fl_system_cb` yields the iterator to it. The block is baked in +at `ecs_system_init` time. **So instead of re-registering on reload** (which would +lose the system id and re-match tables), we register **once** with a *stable +dispatcher* block that looks up the *current* proc by name, and on reload we just +**replace the proc** in a Ruby Hash. The C system never changes — no trampoline, +no binding edit. + +``` +define_system("Move", with:, phase:, &blk) + first call : @systems["Move"] = {id, with, phase, proc: blk} + world.system("Move", with:, phase:) { |e,*c| @systems["Move"][:proc].call(e,*c) } + reload : @systems["Move"][:proc] = blk # same id, same tables, same state +``` + +## Reload semantics +- **Same name + same `with:` + same `phase:`** → **swap the proc** (the hot path): + identical system id, matched tables, and all entity/component data untouched. +- **`with:`/`phase:` changed** → delete the old system entity (`world._delete`) and + re-register (new id). **Entity state still survives** — systems don't own entity + data, so even this path keeps positions/components. +- `define_system` is **idempotent**, so re-running a `game/systems/*.rb` file + top-to-bottom just swaps procs. + +## API (planned) +```ruby +Flecs::Hot.world = world # once, after creating the world +Flecs::Hot.define_system("Move", with: ["Position","Velocity"]) { |e,p,v| ... } +Flecs::Hot.reload_file("game/systems/move.rb") # re-eval -> swaps procs +Flecs::Hot.reload_string(code) # for the bridge +Flecs::Hot.id_for("Move"); Flecs::Hot.systems # introspection +``` +`with:` accepts component ids OR string names (resolved via `world.lookup`, so reload +doesn't need to re-create components). + +## What survives vs what needs a reboot +| Change | Hot-reload? | Why | +|---|---|---| +| Edit a system body / add / remove a system | ✅ swap proc | state untouched | +| Add a **new** component (new meta struct) + entities | ✅ additive | new id, no layout change | +| Change an existing component's struct **layout** | ⚠️ reboot (or migrate) | existing entities hold old layout | +| Change a system's `with:`/`phase:` | ✅ delete+recreate that system | cheap; entity data survives | +| Change C/C++ binding, generator, build flags | ❌ reboot | native ABI / relink | +| raylib/native resource re-init (window, GPU) | ❌ reboot | native lifetime | + +## Acceptance +With a running world: change a movement system's speed AND add a brand-new system, +while entities keep their positions (state continues, not reset); the same works +driven live over the bridge (`Flecs::Hot.reload_string`). + +## Scar tissue (verified desktop — inline + live over the bridge) +- **Same system id across a swap** — `id_for("Move")` stayed `544` before and after a + live reload. The dispatcher block (held in flecs `callback_ctx`, kept alive by + `mrb_gc_register` in `fl_w_system`) never changes; only the registry proc does, so + matched tables and all entity data are untouched. +- **State continues, not reset** — live x went `80 → 6442` after swapping Move to + `+=100/frame` (continued from 80, not 0), and a freshly-added `Tagger` set `y=42` + on the same live entities; the loop survived. +- **Swap vs re-register** keys on `name + with + phase`. Changing `with:`/`phase:` + deletes the old system entity and registers a new one (new id) — entity data still + survives because systems don't own it. +- **Driving reload:** over the bridge, call `define_system` directly (it's just Ruby + on the main thread); `reload_string`/`reload_file` are for re-evaling a whole + systems unit. Component **names** in `with:` resolve via `world.lookup` at each + (re)register, so reload needn't re-create components. +- **Timing:** a `define_system` sent over the bridge swaps the proc during the + frame's drain, which runs *before* `world.progress` — so it takes effect from the + next progress onward (same frame). +- **No C change** to the flecs binding was required — the whole point of the design. diff --git a/.agents/knowledge/jolt-binding.md b/.agents/knowledge/jolt-binding.md new file mode 100644 index 0000000..964757b --- /dev/null +++ b/.agents/knowledge/jolt-binding.md @@ -0,0 +1,193 @@ +# Tribal knowledge: Jolt Physics bindings (`Jolt::`) + +3D physics. Hand-written C (`mrbgems/jolt/src/jolt_bindings.c`) + Ruby sugar +(`mrblib/jolt.rb`) over the **joltc** C API (Amer Koleci's C wrapper around +JoltPhysics). Full spec: `docs/API_SPEC_JOLT.md`. + +## At a glance +- **Key files:** `mrbgems/jolt/src/jolt_bindings.c`; sugar `mrblib/jolt.rb`; + `mrbgem.rake`; `vendor/joltc` + `vendor/JoltPhysics` → merged `libjoltphysics.a`. +- **Ruby API:** shapes `Jolt.box/sphere/capsule/...`; `Jolt::World` (`body`, `step`, + `raycast`, constraints, `character`, `ragdoll`), `Body`/`Character`/`Ragdoll`/`Constraint`. + Spec `docs/API_SPEC_JOLT.md`. +- **Cross-refs:** rule `lld-no-gcc-lto`; demos `game/ballpit_demo.rb`, + `ragdoll_demo.rb`, `physics_playground.rb`. (This doc is long for a reason — read it.) + +## Vendoring + build (two repos, CMake) +- `vendor/joltc` (the C API) and `vendor/JoltPhysics` (v5.5.0, side-by-side so + joltc finds it locally — no CMake FetchContent network pull). +- CMake builds `libjoltc.a` + `libJolt.a`, which we **merge into one archive** + `build/{desktop,web}/libjoltphysics.a` (extract both with `ar x`, re-`ar rcs`). + The merge matters: `libmruby -> libjoltc -> libJolt` is a 3-archive chain and a + single merged archive (like libflecs.a) resolves cleanly. +- Build flags: `-DINTERPROCEDURAL_OPTIMIZATION=OFF` (REQUIRED — see below), + `-DJPH_SAMPLES=OFF -DJPH_TESTS=OFF`, profiler + debug-renderer OFF (smaller). + +## THE big gotcha: GCC LTO vs lld (cost ~hours) +Jolt enables `INTERPROCEDURAL_OPTIMIZATION` (GCC `-flto`) by default. zig's lld +**cannot link GCC GIMPLE-LTO objects** → every `JPH_*` symbol shows "undefined" +at link, even though `nm` says they're defined. Tell: `readelf -s joltc.cpp.o` +shows ~5 symbols and `.gnu.lto_*` sections. Fix: `-DINTERPROCEDURAL_OPTIMIZATION=OFF`. +(Captured as `.agents/rules/lld-no-gcc-lto.md`.) GNU `ld` links GCC-LTO fine, which +is why a manual `gcc` link works but `zig build` doesn't — a useful bisection. + +## Single-threaded (wasm-safe) +The job system is created with `JobSystemThreadPoolConfig{ ..., numThreads=0 }` +so jobs run inline on the calling thread. There is no single-threaded job-system +symbol in joltc; `numThreads=0` is the way. This is the only mode valid on the +wasm build (no pthreads) and behaves identically on desktop. Don't expose +multithreaded systems. + +## Web +Same merge, built with `emcmake`/`emar`. Adds ~1.14 MB to `game.wasm` +(uncompressed; ~300-400 KB gzipped). Uses the existing `-sSTACK_SIZE=4MB`. +LLVM/emcc LTO would be fine here, but we keep IPO OFF for consistency. + +## API design notes +- Fixed 2-layer setup: 0=STATIC (non-moving), 1=MOVING. A body's layer is derived + from its motion type (static -> STATIC, else MOVING). No custom layers exposed. + The ObjectLayerPairFilter MUST enable STATIC<->MOVING **and MOVING<->MOVING** — + forgetting MOVING<->MOVING means dynamic bodies fall through each other (they + still hit static floors). STATIC<->STATIC stays disabled. +- C primitives are numeric; `mrblib/jolt.rb` does Array/`Rl::Vector3` coercion + (in) and returns `Rl::Vector3`/`Vector4` when raylib is present (lazily checked + via `const_defined?`, since mruby's `defined?` doesn't parse in endless-method + form). Positions/rotations cross the boundary as plain float arrays. +- `mrb_get_args` format must match exactly: `_add_body` is `"offfffffiffb"` + (shape, 7 floats, **int motion**, 2 floats, bool) — an `i` in the wrong slot + silently corrupts the motion type (body won't simulate). +- Determinism is available but OFF: add `-DCROSS_PLATFORM_DETERMINISTIC=ON` to the + CMake configs when you need cross-platform reproducibility (perf cost). + +## Contact events +`world.contacts` returns collisions that *began* this step (OnContactAdded only — +not Persisted, so the list stays small). Implementation: flecs-style — a GLOBAL +`JPH_ContactListener_Procs` (set once via `JPH_ContactListener_SetProcs`), plus a +per-world `JPH_ContactListener` whose `userData` is the `jolt_world_t*`, so the +proc routes each event to that world's buffer. Safe to write the buffer from the +proc because Update runs single-threaded (numThreads=0). Buffer is reset at the +start of `_step` and capped at 4096/step. Bridge contacts to game objects via +`body.user_data` (a uint64 — store a flecs entity id). + +## Character controller (Jolt::Character) +Wraps `JPH_CharacterVirtual` (kinematic player capsule). `update` calls +`JPH_CharacterVirtual_ExtendedUpdate` (NOT basic `_Update`) — basic Update only +slides; ExtendedUpdate adds stair-stepping + stick-to-floor. There is no +`ExtendedUpdateSettings_Init`, so we fill Jolt's documented defaults by hand +(walkStairsStepUp.y = 0.4 = max step height). Init copies base defaults but we +still set `base.up = (0,1,0)` and `base.supportingVolume = {{0,1,0}, -1e10}` +(accept all contacts geometrically; the slope angle then classifies ground vs +steep). The character holds the `JPH_PhysicsSystem*` for Update; the Ruby wrapper +sets `@world` so the world can't be GC'd out from under it. Gravity is NOT +auto-applied — the game adds it to `velocity` each frame. `mruby/variable.h` is +required for `mrb_iv_set`. + +**Pushing dynamic props:** ExtendedUpdate already pushes the dynamic bodies the +character walks into, BUT Jolt's default `maxStrength` (100 N) is far too weak to +move default-density props — bodies have density 1000 kg/m³, so a 0.5 m sphere is +~520 kg and barely budges. To make the player shove balls/crates around, raise +`char.max_strength = ` (exposed via `_set_max_strength` → +`JPH_CharacterVirtual_SetMaxStrength`; getter `max_strength`, plus `mass=` → +`SetMass`). A kinematic body always pushes the character regardless (penetration +recovery), and the character never pushes a kinematic body back — that asymmetry +(player shoves balls, an orbiting kinematic sphere shoves the player) is exactly +how `game/ballpit_demo.rb` is built. There is still no per-body density/mass +override binding, so tune ball *radius* if you need lighter props without code. + +## Constraints, sensors, queries (high-value batch) +- **Constraints** (`world.weld/ball_joint/distance_joint/hinge/slider/cone`): + joltc's `*Constraint_Create` take `JPH_Body*`, not body ids. Get a stable + pointer with `jolt_body_for(w, id)` — `JPH_PhysicsSystem_GetBodyLockInterfaceNoLock` + + `LockRead`/`UnlockRead`; the body pointer is stable single-threaded (bodies + don't move in memory), so lock→get→unlock→use is safe. Settings use + `JPH_ConstraintSpace_WorldSpace` with `point1==point2==anchor`; hinge/slider need + a `normalAxis` perpendicular to the axis (`jolt_perp`). The Ruby `Jolt::Constraint` + keeps its `@world` alive and `RemoveConstraint`+`Destroy` on GC or `.remove`. +- **Sensors** = `sensor: true` body. The SAME contact listener reports sensor + overlaps, so `world.contacts` (enter) + `world.contacts_ended` (leave) give + trigger-volume enter/leave with no binding-specific work. +- **Contact-removed**: `OnContactRemoved` gives a `JPH_SubShapeIDPair` (has + `Body1ID`/`Body2ID`), buffered into `ended` (reset each step like `contacts`). +- **Raycast normal**: `JPH_Body_GetWorldSpaceSurfaceNormal(body, subShapeID, &pos, + &n)` — needs `jolt_body_for` again. `RayCastResult.subShapeID2` is the sub-shape. +- **Point overlap**: `JPH_NarrowPhaseQuery_CollidePoint` with a float-returning + collector callback (`return 1e30` = keep collecting), collecting body ids. +- **Body props at creation**: mass via `SetOverrideMassProperties(CalculateInertia)` + + `MassPropertiesOverride.mass`; damping via `SetLinear/AngularDamping`; CCD via + `SetMotionQuality(LinearCast)`; sensor via `SetIsSensor`. `_add_body` is now a + 17-arg `"offfffffiffbfffbb"` — keep the format string in lockstep with the call. + +## Ragdolls + character platform-riding +- **Ragdoll** (`world.ragdoll(parts:)`): built in ONE C call from a packed array + (`_ragdoll(packed, user_data)`) — mixed-type per-part arrays (string/shape/floats) + are parsed with `mrb_ary_ref` + `mrb_as_*`. Needs `#include <mruby/string.h>` + for `mrb_str_to_cstr` (else implicit-decl error → int→ptr). Build order that + matters: Skeleton (AddJoint2 parent-first) → CalculateParentJointIndices → + RagdollSettings SetSkeleton → ResizeParts → per-part Set* + SetPartToParent + (SwingTwist, WorldSpace, position1==position2==joint) → Stabilize → + DisableParentChildCollisions(NULL,0) → CalculateBodyIndexToConstraintIndex → + CreateRagdoll → AddToPhysicsSystem. **Parts must be listed parents-before-children** + or the skeleton is mis-ordered. Part object layer = motion→L_STATIC/L_MOVING. +- **Refcount lifetime**: Skeleton/RagdollSettings/Ragdoll are all RefCounted; + joltc `*_Destroy` = Release (decrement), not free. CreateRagdoll makes the + ragdoll hold refs to settings (which holds the skeleton), so we `Destroy` our + build-time skeleton+settings refs IMMEDIATELY after CreateRagdoll — the ragdoll + keeps them alive; ragdoll free does RemoveFromPhysicsSystem + Destroy. +- Ragdoll part bodies are normal bodies: `JPH_Ragdoll_GetBodyID(i)` works with all + the existing `world._position/_rotation/...` methods. Capsule local axis = Y; + draw endpoints via `Rl.vector3_rotate_by_quaternion([0,hh,0], rotation)`. +- **Platform riding**: `JPH_CharacterBase_GetGroundVelocity` + `GetGroundBodyId` + (note joltc spelling `...BodyId`, lowercase d). CharacterVirtual does NOT add + ground velocity itself — `ch.ride(dt)` adds `ground_velocity` to the character's + velocity before ExtendedUpdate so kinematic platforms carry the player. Ground + body id is invalid when airborne; Ruby `ground_body` returns nil unless `supported?`. + +## Constraints/ragdolls MUST be retained (GC footgun — fixed in the binding) +- A `Jolt::Constraint`/`Jolt::Ragdoll` finalizer calls `RemoveConstraint`/ + `RemoveFromPhysicsSystem` — so if the Ruby handle is GC'd, the joint silently + **detaches mid-simulation** (bodies fall apart nondeterministically, whenever a + GC happens to run). This bit the playground demo: joints created fire-and-forget + (`world.hinge(...)` with no assignment) broke after a few seconds. +- Fix lives in `mrblib/jolt.rb`: `World` keeps `@joints` / `@ragdolls` arrays and + every `world.weld/ball_joint/distance_joint/hinge/slider/cone` + `world.ragdoll` + pushes its result there (`_retain_joint`). `#remove` calls `_forget_joint`/ + `_forget_ragdoll` to drop the ref. So game code never needs to hold joint handles. +- If you add a NEW constraint-returning method, wrap its result in `_retain_joint` + or it will inherit the original bug. Verify with a LONG run (600+ steps) — the + bug only shows once a GC cycle fires, not in the first frames. + +## Character#ride only inherits NON-dynamic ground (else it launches you) +- `ride(dt)` adds `ground_velocity` to the character before ExtendedUpdate so + kinematic platforms carry it. But a DYNAMIC ground body (a ball you stand on, a + constrained/swinging pendulum) reports its *reaction to your weight* + its own + motion as `ground_velocity`; inheriting that is a positive-feedback launch + (repro: standing on a swinging bob shot the character to y≈92, peak vy≈61). +- Fix: `ride` gates on `ground_body.motion_type != Jolt::DYNAMIC` — only + static/kinematic platforms are inherited; dynamic ground is just stood on. + `ground_velocity` itself still returns the raw value (don't "fix" it there). + +## Finalizer ORDER at shutdown — the "world freed first" crash (fixed) +- `@world`/`@joints` retention fixes *runtime* GC ordering, but **`mrb_close` + frees every object in arbitrary order, ignoring references**. So at process exit + a `Ragdoll`/`Constraint` finalizer can run AFTER its `World`'s + `JPH_PhysicsSystem` is already destroyed. Calling `RemoveFromPhysicsSystem`/ + `RemoveConstraint` then → segfault; calling `JPH_*_Destroy` then → "double free + or corruption" (the system already freed those bodies/constraints). +- Symptom: a heisenbug — adding `puts`/`$stdout.flush` changed allocation and hid + it; only a `gdb` backtrace (`...->jolt_ragdoll_free -> RemoveFromPhysicsSystem` + under `mrb_close -> free_heap`) pinned it. It only shows with enough live + ragdolls/constraints that the world happens to be freed first. +- Fix: a shared refcounted **liveness token** (`jolt_token_t {alive, refs}`, libc + malloc/free, independent of the mruby heap). `jolt_world_t` owns one; every + ragdoll/constraint `jolt_token_acquire`s it. `jolt_world_free` sets `alive = 0` + before destroying the system. Each dependent's free skips **all** Jolt calls + when `!alive` (leak is fine — the process is exiting). Last owner frees the token. +- RULE: any future object that calls into the `JPH_PhysicsSystem` from its + finalizer (vehicles, soft bodies, …) MUST take a token and gate on `alive`. + Test by building a scene with many such objects and letting it exit (EXIT=0). + +## Known limitations / leaks (first cut) +Shapes (`Jolt.box/sphere/...`) are Jolt ref-counted; we don't Release them on GC +(bounded leak; shapes are usually long-lived). The world's layer-filter tables +also aren't freed per world. Not yet exposed: constraints/joints, characters, +mesh/convex-hull shapes, contact callbacks, custom collision layers. diff --git a/.agents/knowledge/linting.md b/.agents/knowledge/linting.md new file mode 100644 index 0000000..8325c1f --- /dev/null +++ b/.agents/knowledge/linting.md @@ -0,0 +1,115 @@ +# Tribal knowledge: linting (RuboCop + clang-format + clang-tidy) + +Style linting is **manual** (`bin/lint`), not automatic. This is deliberate: +syntax errors are caught at **build time** (mruby-compiler gem — `rake` fails on +parse errors); type errors by **Steep** per-edit (RBS type checker, see +`opencode.json`); linting is a separate, end-of-implementation-pass step that +catches style inconsistencies and potential bugs without flooding the editor. + +## At a glance +- **Run:** `bin/lint` (report) / `bin/lint --fix` (safe autocorrect) / `bin/lint --ruby` / `bin/lint --c` +- **Ruby config:** `.rubocop.yml` — `DisabledByDefault: true` + Layout + Lint departments + safe Style cops +- **C/C++ config:** `.clang-format` (Allman, 2-space, 100-col, return-type-on-own-line) +- **C/C++ analysis:** clang-tidy with `bugprone-*,cert-*,clang-analyzer-*` (report only, never auto-fixed) +- **Excludes:** `vendor/**`, `mrbgems/raylib/src/raylib_gen.c` (generated), `build/**`, `zig-out/**`, `.live/**` +- **Cross-refs:** `.agents/knowledge/ruby-lsp.md` (LSP setup), `opencode.json` (LSP config) + +## Why RuboCop works for mruby (the non-obvious part) + +The existing `opencode.json` comment says ruby-lsp's `linters: []` because they'd +"false-positive on mruby." This is true for ruby-lsp's *live* diagnostics, but +**RuboCop itself works fine** — and the upstream **mruby repo uses it** (via +pre-commit, `.github/linters/.rubocop.yml`). + +The key facts (from RuboCop's compat docs + mruby's own config): +1. RuboCop **runs ON MRI** (we have 3.4.8) but **analyzes code targeting any + Ruby version** — `TargetRubyVersion` controls what *syntax* the parser + accepts, not the runtime. +2. The false-positive risk is **narrow**: only cops that suggest MRI-only stdlib + methods (e.g. "use `Array#sum`") would break on mruby. **Layout/Lint cops + are pure syntax** — no runtime assumptions, safe for mruby. +3. mruby upstream uses `DisabledByDefault: true` + only 3 layout cops. We enable + more (whole Layout + Lint departments + safe Style subset) but the principle + is identical: **opt-in, never the full default set**. + +### TargetRubyVersion: 3.4 (critical — without it, 70+ false syntax errors) + +Without `TargetRubyVersion: 3.4`, RuboCop defaults to the **Ruby 2.7 parser**, +which doesn't understand **endless method definitions** (`def foo = expr`, +Ruby 3.0+ syntax). This repo uses them extensively (raylib.rb, jolt.rb, rmlui.rb, +bridge.rb). The 2.7 parser emits `Lint/Syntax: unexpected token tEQL` and +spurious "class definition in method body" / "dynamic constant assignment" +errors — ~70 false positives that disappear with `TargetRubyVersion: 3.4`. + +mruby 3.3's `MRUBY_RUBY_VERSION` is "3.3", but mruby upstream targets 3.4 in +their own RuboCop config (matches the host MRI running RuboCop). We do the same. + +### The "too many lines" cops (explicitly disabled) + +`Metrics/MethodLength`, `Metrics/BlockLength`, `Metrics/ModuleLength`, +`Metrics/ClassLength` complain about methods/blocks/modules/classes being too +long. Game code + mrbgem sugar intentionally has long methods and large files. +These are explicitly `Enabled: false` (DisabledByDefault already leaves them off, +but explicit means they stay off even if that flag is flipped). + +Note: `Metrics/FileLength` does **not exist** in RuboCop 1.88 (it was removed) — +don't add it (RuboCop errors on unrecognized cops). + +### Lint/RescueException (intentionally disabled) + +The game loop and bridge intentionally `rescue Exception` (not `StandardError`) +to keep the game running / log exceptions instead of crashing. This is a +deliberate pattern across `mrbgems/*/mrblib/` (raylib.rb `while_window_open`, +live.rb, hot.rb). `Lint/RescueException` is `Enabled: false`. + +## Why clang-format needs a config (and what it codifies) + +There was **no `.clang-format`** before. The hand-written C/C++ follows a +dominant style I reverse-engineered (Allman braces, 2-space indent, ~100-col, +return type on its own line for top-level defs). With this config, +`raylib_bindings.c` produces **zero diff** — it's the most consistent file. +Other files have **real inconsistencies** (single-line function defs, semicolon- +chained statements, comment alignment) that `clang-format -i` will normalize. + +Key settings and *why*: +- `SortIncludes: Never` — the code groups includes with comments + (`#include <string.h> /* memcpy... */`); LLVM's default sort would destroy + these. `ReflowComments: false` for the same reason. +- `AlwaysBreakAfterReturnType: TopLevelDefinitions` + + `AlwaysBreakAfterDefinitionReturnType: TopLevel` — return type on its own + line. ⚠️ Note the enum values differ between the two options: + `AlwaysBreakAfterReturnType` takes `TopLevelDefinitions`; the *Definition* + variant takes `TopLevel` (NOT `TopLevelDefinitions` — clang-format errors). +- `BreakBeforeBraces: Allman` — `{` on its own line. +- `PointerAlignment: Right` — `mrb_state *mrb` (not `mrb_state* mrb`). + +## Why clang-tidy is report-only (and scoped) + +clangd's `--clang-tidy` is **OFF** in `opencode.json` (intentionally — it would +flag macro-heavy vendored code live). The manual `bin/lint` path runs clang-tidy +with `--header-filter='^mrbgems/.*/src/.*|^src/.*'` so only **our** headers' +diagnostics are displayed (vendored mruby/raylib/rmlui/flecs/joltc diagnostics +are suppressed — there are ~106,000 of them). Currently produces **zero findings** +in hand-written code. + +clang-tidy is **never auto-fixed** (`bin/lint --fix` only touches RuboCop + clang- +format). clang-tidy's `-fix` can introduce subtle behavior changes; manual review +only. + +## How the pieces fit together (the three layers) + +| Layer | Tool | When | What it catches | +|-------|------|------|-----------------| +| **Syntax** | mruby-compiler gem | build time (`rake`) | parse errors | +| **Types** | Steep (RBS) | per-edit (LSP) | wrong arg type/arity to typed bindings | +| **Style/best-practice** | RuboCop + clang-format + clang-tidy | manual (`bin/lint`) | style inconsistencies, lint bugs | + +## Adding/removing cops + +- **Ruby:** edit `.rubocop.yml`. With `DisabledByDefault: true`, add + `CopName: Enabled: true` to opt in. Verify with `rubocop --show-cops CopName` + that the cop exists in your version (cop names drift across RuboCop versions — + listing ~300 individual cops is fragile, which is why we use department-level + `Layout: Enabled: true` / `Lint: Enabled: true` instead). +- **C/C++:** edit `.clang-format` (formatting) or the `--checks` list in + `bin/lint` (clang-tidy). Verify clang-format with `clang-format --dump-config`. diff --git a/.agents/knowledge/live-mount.md b/.agents/knowledge/live-mount.md new file mode 100644 index 0000000..c709b5a --- /dev/null +++ b/.agents/knowledge/live-mount.md @@ -0,0 +1,129 @@ +# Tribal knowledge: the `.live/` mount (`Jamstack::Live`) — R4 (desktop slice) + +> Status: **IMPLEMENTED + verified** (R4 desktop) — `mrbgems/raylib/mrblib/live.rb`. +> The Node WS relay + web side are **deferred** (R4b); this slice is the file-based +> mount the game writes itself — no relay, no WS, no extra runtime. + +## At a glance +- **What:** a read-mostly `.live/<token>/` surface a file-based agent uses without + speaking a socket: `status.json` (heartbeat), `game-console` (NDJSON log), and a + `.agent/cmd-* → result-*.json` command protocol drained **in-frame** (P6). Plus + `bin/*` helper scripts. This is the elegant desktop path (no FUSE — see the + `.live` mechanism in roadmap R4): the native game has `mruby-io`/`mruby-dir`, so it + writes/polls real files directly. +- **Key files (planned):** `mrbgems/raylib/mrblib/live.rb` (`Jamstack::Live`); seam + `raylib.rb` (`Live.start` + `Live.poll` per frame); reuses `Bridge.eval_code` (R1) + and `Jamstack::JSON`/`Log` (R2). +- **Gate:** same `JAMSTACK_BRIDGE=1`. Token via `JAMSTACK_LIVE` (default `dev`), root + via `JAMSTACK_LIVE_ROOT` (default `.live`) → `.live/dev/`. +- **Cross-refs:** `agent-bridge.md` (R1 eval, shares the queue/drain), `logging.md` + (game-console), roadmap R4; principle P5 (observe vs the one write path), P7 (dev). + +## The protocol (no JSON parser needed) +mruby has **no JSON parser**, so the **command** files carry *raw Ruby*; the id is +the filename. Only the **result** is JSON (written via `Jamstack::JSON`). +``` +.live/dev/ + status.json # heartbeat: connected,target,token,frame,fps,ts (throttled, atomic) + state.json # flecs world snapshot (bin/snapshot writes this; atomic) + game-console # Log NDJSON file sink (R2) + .agent/ + cmd-<id>.rb # AGENT WRITES raw Ruby (atomic: write .tmp then rename) + result-<id>.json # GAME WRITES the {id,ok,result,stdout,error,backtrace} envelope + bin/ # tiny shell wrappers (run via `sh bin/eval` if not +x) + eval tail-log hot-reload snapshot query +``` +**Per-frame drain (`Live.poll`, after `Bridge.drain`):** `Dir.entries(.agent)` → +select `cmd-*` → for each: read code, **delete the cmd file**, `Bridge.eval_code`, +write `result-<id>.json` atomically. `status.json` rewritten throttled (~every 30 +frames). The only agent-writable path is `.agent/cmd-*` (P5). + +## mruby FS constraints (probed) +- **No `Dir.glob`/`Dir[]`** → list with `Dir.entries(dir)` and filter + (`start_with?("cmd-")`). +- **No `File.write`** class method → `File.open(path,"w") { |f| f.write(s) }`. +- Have: `Dir.mkdir`/`entries`/`foreach`, `File.read`/`rename`/`delete`/`unlink`/ + `exist?`/`directory?`/`basename`/`join`. **Atomic write = temp + `File.rename`**. +- No recursive mkdir → walk path components with `Dir.mkdir` (ignore "exists"). +- `.live/` is runtime state → **gitignored**, never committed. + +## Acceptance +`sh .live/dev/bin/eval 'Rl.get_fps'` returns the JSON envelope; `bin/tail-log` +streams `game-console`; `status.json` updates while the game runs; the loop +survives. Works against a running desktop game with no relay. + +## Web relay (W2 / R4b) — IMPLEMENTED + browser-verified +Confirmed in a real browser tab: `sh .live/web/bin/eval 'Rl.get_fps'` → live fps; +`Rl.platform` → `:web`; multiline+`puts` returns `result` **and** captured `stdout` +(the C fd-redirect works under emscripten MEMFS); `raise` returns a backtrace into +`game/physics_playground.rb`; forwarded browser console (incl. the Ruby `Log` NDJSON) +lands in `game-console`. The desktop `bin/eval` drives a browser game unchanged. +`tools/agent-bridge/server.js` + `web/agent-bridge.js`. **Use it:** +```sh +EMSDK_ENV=/path/to/emsdk_env.sh ./build_web.sh # if not already built +node tools/agent-bridge/server.js # serves http://localhost:8080 +# open http://localhost:8080 in a browser (the game runs), then from a shell: +sh .live/web/bin/eval 'Rl.get_fps' # -> JSON envelope from the live tab +sh .live/web/bin/tail-log # stream the browser console +``` +Verified headlessly with a simulated-browser node poller: `bin/eval` round-trips +(`EVAL[...]`), `status.json` + `game-console` populate, `agent-bridge.js` is injected +into `game.html` and served (200). The real `Module.jamstack` leg is browser-verified +(W1 already proved `eval_json` works on wasm in node). + +A **dependency-free Node HTTP relay** (`tools/agent-bridge/server.js`) gives the +browser tab the *same* `.live/<token>/` interface as desktop, so `bin/eval` etc. +work identically against a browser game: +- The relay **serves `build/web/`** (same-origin → no CORS, no `ws` dep) and injects + `<script src="/agent-bridge.js">` into `game.html` on the fly (no rebuild). +- It owns `.live/<token>/` (default token `web`): `.agent/`, `bin/*`, `game-console`, + `status.json`. It **bridges files ↔ browser**: + - `GET /jamstack/poll` → relay scans `.agent/` for the oldest `cmd-*.rb`, reads + + deletes it, returns `{id, code}` (or `{}`). + - browser runs `Module.jamstack(code)` → `POST /jamstack/result {id, result}` → + relay writes `result-<id>.json`. `bin/eval` (unchanged) round-trips. + - browser `POST /jamstack/console` (forwarded `console.*`, incl. the Ruby `Log` + stream) → appended to `game-console`; `POST /jamstack/status` → `status.json`. +- `web/agent-bridge.js`: ~poll client; waits for `Module.jamstack`, forwards + console, heartbeats status; **no-ops quietly if no relay** (e.g. page served by + `python3 -m http.server`). +- **Verification:** relay routing + `.live` bridging are verified headlessly with a + simulated-browser node poller; the real `Module.jamstack` leg is browser-verified. + +## Still deferred +- (none currently — `bin/snapshot`/`query` shipped, see below) + +## Scar tissue (verified desktop) +- **Round-trips:** `sh .live/dev/bin/eval 'Rl.get_fps'` returned the JSON envelope + (`result:"600"`); a live hot-reload through the same channel swapped Move (id + stayed 544) and added Tagger, taking the entity `{x:79} → {x:2081, y:42}` (state + continued). `status.json` heartbeat advanced (frame 60→90); `game-console` is clean + NDJSON. +- **Run via `sh bin/<name>`** — `File.chmod(0755, …)` is attempted but not relied on; + the scripts work invoked through `sh` regardless. +- **log-during-eval lands in captured stdout:** a `.live` eval that emits `Log` + lines (e.g. `define_system` logs "system swap") sees them in the result's `stdout` + field, because the log sink writes to the fd being captured. They are still + recorded in the ring buffer + `game-console`. Same interaction as the TCP path + (`agent-bridge.md`); harmless. +- **`clean_agent` on start** deletes stale `cmd-`/`result-`/`.tmp` so a new run + doesn't replay a previous session's commands. +- **Atomic everywhere:** `status.json` and `result-*.json` are written to `*.tmp` + then `File.rename`d; a `cmd-*` file is `File.read` then immediately deleted + (consumed once). Writers (`bin/*`, raw agents) must `mv` the cmd into place too. +- **mruby FS:** no `Dir.glob` (used `Dir.entries` + `start_with?`); no `File.write` + (used `File.open(…,'w')`); recursive mkdir hand-rolled. +- **`bin/snapshot` + `bin/query`** (wire flecs REST JSON through the bridge): these + call `Flecs::World#rest_request` (an in-process `ecs_http_server_request` — no + socket, works on desktop AND web). `bin/snapshot` → `/world` endpoint → writes + `.live/<token>/state.json` + prints JSON; `bin/query <expr>` → + `/query?expr=<expr>&values=true` → prints JSON. Both require `enable_rest` on the + flecs world in game code (and `Flecs::Hot.world = world` so the bin scripts can + find the world). The REST handle is a C static `fl_rest_server` set by + `_enable_rest` on both targets; on desktop the HTTP listener (for the hosted + Explorer) is a separate server object started by `ecs_set(EcsWorld, EcsRest)`. + **Web:** `bin/snapshot` uses a relay route (`GET /jamstack/snapshot`) that calls + `relayEval` (writes `cmd-*.rb` → polls for `result-*.json`) and writes `state.json` + to the HOST filesystem (not MEMFS). `bin/query` uses `bin/eval` directly (the REST + JSON is in the result envelope's `result` field). Both work identically on desktop + and web — all 5 `bin/*` scripts are now available on both targets. diff --git a/.agents/knowledge/logging.md b/.agents/knowledge/logging.md new file mode 100644 index 0000000..a79ad9e --- /dev/null +++ b/.agents/knowledge/logging.md @@ -0,0 +1,62 @@ +# Tribal knowledge: structured logging (`Jamstack::Log`) — the game-console + +## At a glance +- **What:** leveled, structured (NDJSON) logging with a monotonic frame counter, an + in-memory **ring buffer** the agent queries over the bridge, and stdout + optional + file sinks. This is the **game-console** stream (Ruby/engine intent); the + browser-console (web platform console) is **deferred** (roadmap R2/R4). +- **Key files:** `mrbgems/raylib/mrblib/log.rb` (the `Log` module); + `mrbgems/raylib/mrblib/jamstack_json.rb` (shared `Jamstack::JSON.generate`, used by + Log AND the bridge); seam `raylib.rb` `while_window_open` (calls `Log.setup` + + `Log.tick!` per frame, and logs loop exceptions). +- **Env:** `JAMSTACK_LOG=<path>` (append NDJSON file sink), `JAMSTACK_LOG_LEVEL=` + `debug|info|warn|error` (min level). Read once via C `Jamstack.getenv` at setup. +- **Cross-refs:** `agent-bridge.md` (the eval channel that exposes `Log.tail/grep`); + roadmap R2; principle P5 (observe vs mutate). + +## API +```ruby +Jamstack::Log.info("spawned", tag: "spawn", entity: id) # msg + structured fields +Jamstack::Log.warn("low hp", entity: id, hp: 2) +Jamstack::Log.error("bad state", entity: id) +Jamstack::Log.exception(e, tag: "physics") # class + message + backtrace +# agent query surface (over the bridge): +Jamstack::Log.tail(50) # last N records (Array<Hash>) +Jamstack::Log.grep("physics") # SUBSTRING match (no Regexp here), returns records +Jamstack::Log.tail_ndjson(50) # last N as an NDJSON string +``` +Each record carries `ts` (epoch float), `frame`, `level`, optional `msg`, then any +structured fields. The "what errored in the last 5s and on which entity" query is +just `tail(50).select { |r| r["level"]=="error" && Time.now.to_f-r["ts"]<5 }`. + +## Scar tissue (mruby ≠ CRuby; verified) +- **No `Regexp`** in this gembox → `Log.grep` is **substring**, not regex. (Adding + mruby-regexp-pcre is a rebuild-class change; not done.) +- **NaN/Infinity are invalid JSON.** Float math yields them (`0.0/0.0`→NaN, + `1.0/0.0`→Infinity); `Jamstack::JSON` emits `null` for non-finite floats. Don't + "fix" by printing them raw — it produces unparseable lines. +- **`Time.now.to_f`** is available (mruby-time) — used for `ts`. +- **No `**kwargs` reliance:** the API takes an explicit trailing `fields = {}` Hash + (callers still write `tag: "x", entity: id` — Ruby collects the trailing pairs). +- **stdout-during-eval interaction:** the stdout sink writes to C fd 1. If a log is + emitted *while* a bridge eval is capturing (fd 1 redirected to a tmpfile), that + line lands in the eval's captured stdout instead of the console. The **ring buffer + still records it** (the canonical query path), and the bridge logs eval errors + *after* `__cap_end` to avoid this. See `agent-bridge.md`. + +## Frame counter & loop exceptions +`while_window_open` calls `Log.tick!` once per frame and wraps the game block: an +uncaught exception is `Log.exception`'d (tag `loop`) then **re-raised** (preserves +the existing crash/`mrb_print_error` behavior — just adds a log line). A +"log-and-continue" resilience mode is a possible future opt-in, not the default. + +## Deferred (not yet wired) +- **Flecs log funnel:** `ecs_log_set_level` + an OS-API log callback to interleave + flecs's own tracing into this pipeline (roadmap R2). It's C work in the flecs + mrbgem and no game uses flecs yet — do it when the first flecs game lands. +- **`.live/console` path + browser-console:** R4 (relay) / deferred. + +## Verify +`JAMSTACK_BRIDGE=1 JAMSTACK_LOG=/tmp/x.ndjson ./zig-out/bin/game game/loop.rb`, +then over the bridge eval `Jamstack::Log.tail(5)`, `grep(...)`, and the +errored-in-last-5s query; confirm `/tmp/x.ndjson` is one valid JSON object per line. diff --git a/.agents/knowledge/raylib-binding.md b/.agents/knowledge/raylib-binding.md new file mode 100644 index 0000000..9db9a7c --- /dev/null +++ b/.agents/knowledge/raylib-binding.md @@ -0,0 +1,87 @@ +# Tribal knowledge: raylib + raymath bindings (`Rl::`) + +## At a glance +- **Key files:** generator `mrbgems/raylib/tools/gen_raylib.rb` (edit this — NOT the + generated `src/raylib_gen.c`); hand-written entry `src/raylib_bindings.c`; sugar + `mrblib/raylib.rb`; `mrbgem.rake`; AI-reference generator `tools/gen_ai_reference.rb`. +- **Ruby API:** generated positional surface + sugar (`while_window_open`, block + Begin/End pairs, symbol keys, kwarg helpers). Full typed API: `docs/AI_REFERENCE.md`; + spec `docs/API_SPEC.md`. +- **Cross-refs:** rules `dont-edit-generated`, `raylib-platform-objs`; skill `add-binding-fn`. + +## How it's built (generated, not hand-written) +`mrbgems/raylib/tools/gen_raylib.rb` reads raylib's official `raylib_api.json` +(and `raymath_api.json`) and emits `mrbgems/raylib/src/raylib_gen.c` at build +time (git-ignored). To change bindings, **edit the generator**, not the output. +`mrbgem.rake` runs the generator (regen only when inputs are newer) before the +gem globs its sources. `raylib_bindings.c` is the small hand-written entry point +(platform detection + the web main-loop seam). + +`raymath_api.json` is NOT shipped by raylib; `mrbgem.rake` builds raylib's +`raylib_parser` and generates it from `raymath.h` if missing. + +## Naming rules (must match between generator and any docs) +- `PascalCase` → `snake_case`; `IsXxx` → `xxx?` predicate. +- snake_case splits a **digit followed by a Word**: `Vector2Add → vector2_add`, + but keeps `Mode2D → mode2d` (digit+Upper+end, no split). This rule is subtle; + if you touch `snake`, re-test both forms. +- String params marshal with `z!` so Ruby `nil` → C `NULL` (e.g. + `load_shader_from_memory(nil, fs)` uses the default vertex shader). +- Structs marshal by value; a single `T*` param is in/out (pass the struct). +- **Only `Is*` gets the `?` suffix.** Other boolean predicates bind WITHOUT `?`: + `WindowShouldClose → window_should_close` (not `window_should_close?`). `API_SPEC.md` + documents the `?` form, so the sugar adds `alias_method :window_should_close?, + :window_should_close` in `mrblib/raylib.rb`. The desktop seam (`while_window_open`) + relies on that alias — without it, **desktop** loops crash with NoMethodError while + web is unaffected (web uses `_run_web_loop`, never the `until` branch). + +## ~72 unbound functions +Skipped: callbacks, raw pointer/buffer params, varargs, array/string returns. +Listed in the generated file's header comment and in `docs/AI_REFERENCE.md`. + +## Hand-bound exceptions inside the generated TU +`SetShaderValue`/`SetShaderValueV` take a typeless `const void *value` + a +`SHADER_UNIFORM_*` tag, so they're hand-written and **emitted into the generated +.c** (after the struct helpers) so they can reuse the `static rl_ptr_Shader`. +They accept a Numeric or (nested) Array and pack by uniform type. + +## Ruby sugar (mrblib/raylib.rb) +Layered over the positional generated API: `while_window_open` (the only loop; +web-safe seam), block-scoped Begin/End pairs (`draw`, `mode_2d`, `shader_mode`, +…, all `ensure`-safe), symbol keys (`Rl.key_down?(:w)`), kwarg helpers +(`draw_text`, `draw_texture_pro`), `Rl.platform`/`web?`/`desktop?`, aliases. + +## Verifying changes +`./zig-out/bin/game some_test.rb` (main.c takes the script as argv[1]). For +visual checks, render offscreen to a PNG (see knowledge/testing.md). + +## Custom shaders (post-processing / `Jamstack::FX`) +raylib does NOT prepend a `#version` line to user fragment shaders — it compiles +your string as-is (`rlLoadShader`, vendor rlgl.h ~4205). So every fragment source +must begin with the right `#version` for the backend: desktop GL33 → `#version 330`, +web ES3 → `#version 300 es` (both share `in`/`out`/`texture()` syntax, so one +shader body serves both — only the header `#version`+`precision` line differs). + +When you pass `vs = nil` to `load_shader_from_memory`, raylib uses its **internal +default vertex shader**, which outputs the varying **`fragTexCoord`** (NOT +`vTexCoord`) and binds the input texture to sampler **`texture0`** — on BOTH +targets (GLSL 330: `in vec2 fragTexCoord`; GLSL 100: `varying vec2 fragTexCoord`; +verified in vendor rlgl.h `rlLoadShaderDefault` ~5000). So a fragment shader +paired with the default vertex must declare `fragTexCoord` and sample `texture0`. + +The 330 (desktop) and 300 es (web) dialects share `in`/`out`, `texture()`, and +`out vec4` output — so `Jamstack::FX` (mrblib/fx.rb) uses a **macro shim**: a +per-target `HEADER` (differing only in `#version`+`precision`) defines +`TEXTURE(s,uv)`→`texture()` and `FRAG`→`fragColor`, so ONE shader body serves both +targets. (Pre-upgrade, web was `#version 100` with `varying`/`texture2D()`/ +`gl_FragColor` — a separate, more divergent dialect.) + +mrblib **load order** pitfall: gem mrblib files are globbed alphabetically, so +`fx.rb` loads BEFORE `raylib.rb`. The `Rl.web?` sugar (defined in raylib.rb) is +NOT yet defined at fx.rb's load time -> `HEADER = Rl.web? ? ...` raises +`NoMethodError: undefined method 'web?'`. Fix: use the underlying C fn +`Rl._is_web` (registered at gem init, always available) OR make the value lazy +(memoized on first `Pass` construction, at game runtime when all sugar is loaded). + +Y-flip is mandatory on every `draw_texture_pro` of a `RenderTexture` texture +(OpenGL bottom-left origin): `source = Rectangle.new(0, 0, w, -h)`. diff --git a/.agents/knowledge/rmlui-binding.md b/.agents/knowledge/rmlui-binding.md new file mode 100644 index 0000000..bac6d38 --- /dev/null +++ b/.agents/knowledge/rmlui-binding.md @@ -0,0 +1,99 @@ +# Tribal knowledge: RmlUi bindings (`Rml::`) + +Hand-written C++ (`mrbgems/rmlui/src/rml_bindings.cpp`) + Ruby sugar +(`mrblib/rmlui.rb`), modeled on RmlUi's Lua bindings. RmlUi is the **only** UI +layer (no raygui), rendered over the game through raylib's GL context. + +## At a glance +- **Key files:** `mrbgems/rmlui/src/rml_bindings.cpp` (bindings + the rlgl render + backend); sugar `mrblib/rmlui.rb`; `mrbgem.rake`; documents/styles `game/ui/*.{rml,rcss}`. +- **Ruby API:** `Rml.init`/`load_font`, `Rml::Context` (`data_model`, `load_document`, + `frame`), `Document`/`Element`/`Event`/`DataModel`. Spec `docs/API_SPEC_RMLUI.md`. +- **Cross-refs:** rules `mruby-rebuild`, `link-order`. Keyboard/text input gap + CLOSED (see "Known gaps" below) — unblocks the in-game console (roadmap R6). + +## Build +`cmake` with target `rmlui_core` ONLY. The `rmlui_debugger` module fails to +compile with GCC 16 (bundled `robin_hood.h`) and we don't need it. Flags: +`-DBUILD_SHARED_LIBS=OFF -DRMLUI_SAMPLES=OFF -DRMLUI_LUA_BINDINGS=OFF +-DRMLUI_FONT_ENGINE=freetype`. Being a C++ gem, it flips mruby to C++-exception +ABI (see rules/mruby-rebuild.md). + +## The render backend (rlgl) — three fixes that were painful to find +The RmlUi render interface is implemented against raylib's **rlgl** (so the same +code works on desktop GL and WebGL). Three non-obvious correctness fixes: +1. **`rlSetTexture` must come AFTER `rlBegin(mode)`** — `rlBegin` resets the + current draw-group texture on a mode change, so setting it before is lost. +2. **Premultiplied alpha** — render with `RL_BLEND_ALPHA_PREMULTIPLY`; RmlUi 6.x + already premultiplies its font atlas. Using normal alpha gives dark fringes. +3. **Flush the batch per geometry** — call the batch flush for each geometry so + textures/scissor don't bleed across draws. + +## API surface +`Rml::Context`, `Rml::Document < Element`, `Rml::Element` (attributes, classes, +style properties, queries `query_selector`/`get_element_by_id`/`elements_by_tag`, +traversal, geometry, `el.on(:click) { |event| ... }`), `Rml::Event`, and the MVC +**data model** (`m.bind`/`m.value`/`m.event`, `model.dirty`). Init AFTER +`Rl.init_window` (needs the GL context). `ctx.frame { }` does +process_input→(block)→update+render. + +## In-game REPL console (`Jamstack::Console`) +- **File:** `mrbgems/rmlui/mrblib/console.rb` (Ruby sugar, compiled into the gem). +- **Assets:** `game/ui/console.rml` + `game/ui/console.rcss`. +- **Usage:** `console = Jamstack::Console.new(ctx, binding: binding)` — pass the + game script's binding so `eval` sees local variables (`score`, `world`, etc.). +- **Toggle:** backtick (`KEY_GRAVE`, 96). `console.update` (call before + `ctx.process_input`) checks `IsKeyPressed` and drains both `GetKeyPressed` and + `GetCharPressed` queues on the toggle frame so the backtick isn't forwarded to + RmlUi as text input. +- **Enter/Up/Down:** handled via `input.on(:keydown)` — `KI_RETURN` (72) evals, + `KI_UP` (91)/`KI_DOWN` (93) navigate history. `event.stop_propagation` prevents + RmlUi's default `LineBreak` on Enter. +- **Scrollback:** `inner_rml=` with HTML-escaped text; `<div id="scroll_end">` + sentinel + `scroll_into_view(false)` for auto-scroll. +- **Game input gating:** game scripts check `console.open?` to skip WASD/mouse + input when the console is visible (see `game/console_demo.rb`). + +## Rendering RmlUi into a RenderTexture (FBO) — the FX pipeline +The RmlUi render backend (`RaylibRenderInterface` in `rml_bindings.cpp`) draws +through rlgl, so `Rl.texture_mode(target) { ctx.update; ctx.render }` renders a +context into an offscreen FBO (used by the `Jamstack::FX` two-stage pipeline: +in-world UI into the game layer, overlay HUD into the composite layer). Two +non-obvious requirements: + +1. **Render-texture size MUST equal the window size.** `SetScissorRegion` computes + the GL scissor Y as `GetScreenHeight() - (top + h)` — it uses the **window** + height, not the bound FBO's height. If the FBO differs from the window, every + RmlUi scissor is misaligned (clips/shifts the element). Keep them equal + (e.g. 720×720). Verified: a 720×720 context into a 720×720 FBO renders correct. +2. **Use `left:` not `right:` for `position: absolute`.** RmlUi MISCOMPUTES + `right:` — a panel `right: 60px; width: 240px` in a 720px context resolves to + `absolute_left = -300` (off-screen) instead of the expected 420. `left:` + resolves correctly. (Caught live via the eval bridge reading + `element.absolute_left`.) This is an RmlUi layout-engine quirk, not a + binding bug. +3. No projection/viewport override: the render interface uses `rlVertex2f` + against raylib's current 2D ortho (set by `begin_texture_mode`), so RmlUi + draws in the FBO's coordinate space as long as the context dims match (1). + +## Known gaps +~~Input routing forwards mouse only (keyboard/text TODO).~~ **CLOSED.** +`rml_context_process_input` now forwards keyboard + text input: +- **Key map:** `rl_key_to_rml(int raylib_key) -> Input::KeyIdentifier` maps raylib + keys to RmlUi's KI_* codes (letters, digits, punctuation OEM keys, arrows, + backspace/enter/tab/escape, F1-F12, modifiers). OEM punctuation (`;'`,./etc.) + returns `KI_UNKNOWN` — those arrive via the text-input path (the Unicode + codepoint from `GetCharPressed`), exactly like RmlUi's own GLFW backend. +- **Press/release:** raylib's `GetKeyPressed()` drains a queue of press-edges; + `IsKeyReleased(k)` is true for one frame on release. We track a `std::set<int> + g_rml_down_keys` to know which keys to check for release (raylib has no + "what was released?" queue). +- **Text input:** `GetCharPressed()` polled in a loop; each codepoint ≥32 (and + ≠127 DEL) forwarded via `ctx->ProcessTextInput((Character)c)`. This already + respects shift/capslock (raylib applies them to the codepoint). +- **Modifiers:** `rl_key_modifiers()` computes the `KM_CTRL|KM_SHIFT|KM_ALT|KM_META` + bitmask from `IsKeyDown` on the left/right modifier keys each frame. +- **Verified:** desktop (offscreen render + focus a text `<input>` — no crash, + pipeline runs). Web compiles cleanly (wasm); interactive typing needs a real + browser tab (the `glfwInit` "window is not defined" boundary in node is + expected, not a regression). diff --git a/.agents/knowledge/ruby-lsp.md b/.agents/knowledge/ruby-lsp.md new file mode 100644 index 0000000..e77b980 --- /dev/null +++ b/.agents/knowledge/ruby-lsp.md @@ -0,0 +1,130 @@ +# Tribal knowledge: LSP (ruby-lsp + clangd) under Dispatch + +ruby-lsp targets **MRI** Ruby, but our game code is **mruby**. The LSP still works +well for navigation + RBS-backed intelligence, with formatter/linters disabled so +they don't false-positive on mruby. **clangd** serves the C/C++ mrbgem bindings +(`mrbgems/*/src/*`, `src/main.c`). This doc captures the non-obvious setup facts. + +## Config source: `opencode.json`, NOT `dispatch.toml` + +The **installed** Dispatch harness (`/usr/bin/dispatch-server`, the arch-rewrite +build) reads LSP config in this precedence (decompiled from the binary): + +1. **`.dispatch/lsp.json`** — `{"servers": {<id>: {...}}}` format. Read FIRST and + takes precedence. Machine-local (untracked). +2. **`opencode.json`** — `{"lsp": {<id>: {...}}}` format. Read ONLY when + `.dispatch/lsp.json` yields no servers. **This is the tracked, template config.** +3. built-in TypeScript server (fallback if neither exists). + +It does **NOT** read `dispatch.toml`'s `[lsp.*]` block for LSP — that's the *old* +dispatch-source architecture. If you see a `dispatch.toml` with `[lsp]`, it's dead +weight for the installed harness; the canonical config is `opencode.json`. + +⚠️ If a broken `.dispatch/lsp.json` exists, it **shadows** the correct +`opencode.json`. Delete/fix `.dispatch/lsp.json` so `opencode.json` takes effect. + +### Entry shape (the B6 parser fields) +Both formats use the same parser, which reads these keys (NOT the editor-style +`enabled`/`settings`): +`command` (array), `extensions` (array, with dot), `env` (object), `initialization` +(object), optional `name`, `rootMarkers`. So `opencode.json`'s `lsp.<id>` must +carry `command`+`extensions`+`env`+`initialization`. + +## Why the env block is mandatory (two traps) + +ruby-lsp composes a private bundle under `.ruby-lsp/Gemfile` (auto-created, gitignored) +and shells out to `bundle` on startup to install its own deps (ruby-lsp + `debug`, +which compiles a native ext). On this Arch box: + +1. **`bundle` is not on the default PATH.** It lives in the user gem bin dir + (`~/.local/share/gem/ruby/3.4.0/bin`); only the `ruby-lsp`/`rbs` *symlinks* are in + `~/.local/bin`. The `env.PATH` MUST include the gem bin dir or ruby-lsp crashes + with `Errno::ENOENT - bundle`. +2. **Without `GEM_HOME`, bundler installs into the root-owned system gem dir** + (`/usr/lib/ruby/gems/3.4.0`) → `Bundler::PermissionError`. Set `GEM_HOME` (and + `GEM_PATH`) to the user gem dir so installs are writable. + +`env` REPLACES the inherited PATH (it merges onto `process.env`), so include the +full desired PATH. First-run does a one-time `bundle install` (compiles `debug`); +subsequent starts do `bundle check` (fast). The 4-hour `bundle update` check is +self-throttling. + +## mruby-safe `initialization` + +- `formatter: "none"`, `linters: []` — RuboCop/Syntax Tree target MRI and would + false-positive on mruby. Diagnostics are off via `enabledFeatures.diagnostics`. +- Navigation + RBS intelligence ON: `hover`, `completion`, `definition`, + `signatureHelp`, `documentSymbols`, etc. +- RBS lives in `sig/*.rbs` (raylib/rmlui/flecs/jolt/jamstack) — hand-written, gives + hover/completion for the C/C++ mrbgem bindings. ruby-lsp auto-loads them. +- `rubyVersion: "3.4.0"` matches the host MRI (3.4.8) that runs ruby-lsp; mruby is a + subset so 3.4 parsing won't choke on valid mruby. + +## Sticky "broken" state (why a fixed config may still say `error`) + +The harness's LSP manager keeps a `broken` set keyed by `<serverId>:<root>`. Once a +spawn fails, that id+root is marked broken for the **process lifetime** and never +retried — even after you fix the config. It only clears on server restart +(`shutdownAll`, on extension unload). So: + +- If the *old* (broken, no-GEM_HOME) config poisoned `ruby-lsp:<root>`, fixing the + config alone won't clear it in the running server — the lsp tool keeps reporting + `state: error` ("Previously failed to start"). +- **To verify a config fix in an already-running session without restarting**, + temporarily add a server entry with a *different id* (e.g. `ruby-lsp-verify`); a + fresh id → fresh key → clean spawn. Remove it once confirmed. The canonical id + (`ruby-lsp`) recovers on the next server launch. + +## How the harness drives ruby-lsp (verification cheatsheet) + +- The harness sends `languageId: "unknown"` on didOpen — ruby-lsp maps that to + `:ruby` (its `else` branch), so that's fine. +- The harness does **not** send `initializationOptions` in `initialize`; it pushes + config via `workspace/didChangeConfiguration` + answers `workspace/configuration`. + ruby-lsp's `enabledFeatures` then fall back to **all-enabled** defaults (fine). +- `documentSymbol` works immediately (pure parse). `hover` needs the index warm + (loading the 72KB `sig/raylib.rbs` takes a few seconds after first spawn) — a + fresh-spawn hover may return null until indexing settles; retry. +- Hover on stdlib (`Array#each`) resolves to `rbs` core + `vendor/mruby/mrblib/`; + hover on the `Rl` module resolves to `mrbgems/raylib/mrblib/raylib.rb` + its docs. + +## clangd for C/C++ (the mrbgem bindings) + +`clangd` is at **`/usr/lib/llvm21/bin/clangd`** (not on PATH — use the absolute +path in `opencode.json`). The `opencode.json` `clangd` entry uses +`--background-index` (persistent cross-file index for definition/refs across the +6 C/C++ units) and `--log=error`. `--clang-tidy` is intentionally OFF (it would +flag the macro-heavy vendored code). + +### compile_commands.json is REQUIRED (a `.clangd` alone is not enough) + +clangd needs to know the include roots + `-DMRB_INT64`. The build is +Zig-orchestrated (`build.zig`) + mruby rake (`build_config.rb`) — neither emits +`compile_commands.json`. A `.clangd` `CompileFlags.Add` with relative `-Ivendor/...` +paths does **NOT** work: clangd resolves them against the compile working dir, +which for the no-compile-db fallback is the **file's own directory** (e.g. `src/`), +so `<mruby.h>` isn't found. (clangd does not resolve `.clangd` Add paths relative to +the config file in this version.) + +Fix: **`tools/gen_compile_commands.rb`** emits `compile_commands.json` with +`directory` = the project root (absolute), so the relative `-I` resolve correctly. +One entry per source; C files `-std=c11`, the one C++ file (`rml_bindings.cpp`) +`-std=c++17`; all get `-DMRB_INT64` + the 5 include roots: +`vendor/{mruby/include, raylib/src, rmlui/Include, flecs/distr, joltc/include}`. +It also indexes the generated `raylib_gen.c` (for definition nav into the binding +surface — never hand-edit it). + +- `compile_commands.json` is **gitignored** (it holds the absolute project root). +- **`rebuild.sh` regenerates it** after the build (so it picks up `raylib_gen.c`, + which the mruby build generates). After a fresh clone, run `./rebuild.sh` (or + `ruby tools/gen_compile_commands.rb`) once before opening C/C++ files. +- `.clangd` holds only `Index: StandardLibrary: No` (skip indexing the C++ stdlib + to stay lean) + docs. +- Targets the **desktop** build. Web-only headers (`<emscripten.h>`) are guarded + by `#ifdef __EMSCRIPTEN__` (not defined here), so clangd skips them. + +### clangd does NOT suffer the sticky-broken issue here +clangd is a fresh server id (`clangd:<root>`), never poisoned by an old broken +config, so it connects immediately on first `.c`/`.cpp` open. Verified end-to-end +through the harness: `diagnostics` (0 errors), `hover` (`mrb_state` typedef), +`definition` (jumps to `vendor/mruby/include/mruby.h`), `documentSymbol` (full fn/var tree). diff --git a/.agents/knowledge/steep.md b/.agents/knowledge/steep.md new file mode 100644 index 0000000..b1f632a --- /dev/null +++ b/.agents/knowledge/steep.md @@ -0,0 +1,89 @@ +# Tribal knowledge: Steep — the RBS type checker + +[Steep](https://github.com/soutaro/steep) is the **RBS type checker** (ruby-lsp is +NOT — it consumes RBS for hover/completion only; type *checking* is Steep's job). +It type-checks `game/**` against `sig/*.rbs` so real binding misuse (wrong arg +type/arity to a typed `Rl::`/`Rml::`/`Flecs::`/`Jolt::` call, calling a nonexistent +method) gets caught at dev time. Compatible with the installed `rbs` 4.0.3 +(`steep 2.0.0` requires `rbs ~> 4.0`). + +## At a glance +- **Key files:** `Steepfile` (project + `lenient` config); `opencode.json` `lsp.steep` + (the LSP wiring); `sig/*.rbs` (the signatures); `tools/check-types.sh` (the gate); + `mrbgems/raylib/tools/gen_rbs.rb` (generates `sig/raylib.rbs`). +- **Commands:** `./tools/check-types.sh` (gate: `rbs validate` + `steep check`); + `steep check --severity-level=information` (see the info-level typos). +- **Cross-refs:** `.agents/knowledge/ruby-lsp.md` (shares the gem env + LSP + harness), `testing.md`, `build-and-verify` skill, `HANDOFF-per-edit-diagnostics.md`. + +## Install +`gem install steep` (into the user gem dir `~/.local/share/gem/ruby/3.4.0`, where +`rbs` 4.0.3 already lives). No Arch package; pure-Ruby deps, no native ext. The +`steep` binary shebang is `#!/usr/bin/ruby`, so it needs `GEM_HOME`/`GEM_PATH` set +to the user gem dir to load `steep` + `rbs` — `tools/check-types.sh` and the +`opencode.json` env both set this. + +## Why `lenient` (the Steepfile config) +Game code is un-annotated mruby *scripts* (top-level constants/helpers/globals) + +mruby's auto-coercing numerics vs RBS's strict numeric tower. Under +`D::Ruby.default`/`strict`, EVERY script constant/global/helper-def is an error +(first run: 442 diagnostics, 78 errors — all false positives; the code is correct). +`configure_code_diagnostics(Steep::Diagnostic::Ruby.lenient)` downgrades that noise +to `:information`/`:hint` so `steep check` is **GREEN (exit 0)** on correct code, +while real binding misuse still surfaces as `:information`/`:warning` (visible, +non-failing). The dynamic Flecs component values are `untyped` in `sig/flecs.rbs`, +so they never error. Tighten to `D::Ruby.default` once game code gains its own RBS. + +## Editor vs gate (the split) +- **Editor (live):** Steep is wired as a 3rd LSP (`opencode.json` `lsp.steep`, + `steep langserver --steepfile=<abs>`). On a `.rb` edit, `publishDiagnostics` + (PUSH model — Steep does NOT implement `diagnosticProvider`/pull) surfaces real + typos live, tagged `[steep]`. ruby-lsp (`diagnostics:false`) gives + hover/completion; Steep gives type diagnostics. The harness must **aggregate + `publishDiagnostics` across all matching servers** (ruby-lsp + steep both claim + `.rb`) — see `HANDOFF-per-edit-diagnostics.md`. +- **Gate (`tools/check-types.sh`):** `rbs validate` (sig consistency, HARD FAIL) + + `steep check` (project loads clean, no `:error`). It is GREEN on correct code and + does NOT hard-fail on game-code typos (those are `:information`, editor-visible) — + so it catches signature/structural breakage, not gameplay typos. + +## The numeric-type wart (decision) +`gen_rbs.rb` maps C `float`/`double` *inputs* (params, struct fields) → `Float | Integer` +(returns stay `Float`). Why: mruby's `f` (`mrb_float`) format auto-converts +`Integer`→`Float`, so `Vector3.new(18,14,18)` is idiomatic and fine; `Float` alone +would reject int literals. Alternatives considered: `Numeric` (breaks arithmetic — +RBS core `Numeric` doesn't declare `*`/`/`/`**`); `Float` (rejects int literals). +**Wart:** RBS's numeric tower widens `**`/some `/` and camera-math (`Math.sin` → …) +to `Complex`, which then won't fit `Float | Integer` params — surfacing as +`:information` `ArgumentTypeMismatch` (17 on the current game code, non-failing under +lenient). mruby's auto-coercing numerics don't map cleanly to RBS's strict tower; +`Float | Integer` is the best fit and the wart is accepted (documented in +`gen_rbs.rb`'s `rtype` comment). + +## `.rbs` signature files get no live diagnostics +Steep's langserver only `publishDiagnostics` for files in the `check` target +(`.rb` under `game/`), NOT for signature files — editing `sig/*.rbs` shows nothing +live (sigs are type-environment inputs, not checked documents). Catch sig errors via +`rbs validate` / `steep check` (CLI) instead. (CLI `steep check`/`rbs validate` DO +report sig errors — e.g. the duplicate `key_pressed?` below was found that way.) + +## Scar tissue (bugs found + fixed while setting this up) +- **Duplicate `key_pressed?` in the generated `sig/raylib.rbs`** (`RBS::DuplicatedMethodDefinition`): + the JSON emitted `IsKeyPressed`→`key_pressed?` `(Integer key)` AND the hand-written + sugar emitted `key_pressed?` `(untyped key)`. The sugar is correct (mrblib's + `resolve_key` accepts Symbol keys). Fix: added `IsKeyDown/IsKeyPressed/ + IsKeyReleased/IsKeyUp` to `SUGAR_OVERRIDE` in `gen_rbs.rb` so only the sugar survives. + (`rbs validate` did NOT catch this; `steep check` did — Steep is stricter on dups.) +- **`ModuleSelfTypeError` in `sig/flecs.rbs`** from `include Enumerable[untyped]`: + `Query#each` yields `(entity_id, *component_hashes)` — a multi-arg yield that can't + satisfy Enumerable's single-`Elem` contract. Fix: removed `include Enumerable` + from the RBS (the Ruby class still mixes it in at runtime; only `each` is documented). +- **Warm-up latency:** the first `lsp diagnostics`/edit after a fresh spawn is slow + while Steep indexes `sig/*.rbs` (incl. the 1328-line `raylib.rbs`) and forks its ~10 + worker processes. Subsequent calls are faster. Don't report "no diagnostics" + prematurely on a fresh spawn — it may still be indexing. + +## Verify +`./tools/check-types.sh` — green on correct code. To SEE the info-level typos: +`steep check --severity-level=information` (note: that flag makes steep exit 1 +because info-noise always exists on un-annotated scripts — it's for visibility, not a gate). diff --git a/.agents/knowledge/testing.md b/.agents/knowledge/testing.md new file mode 100644 index 0000000..a8c446f --- /dev/null +++ b/.agents/knowledge/testing.md @@ -0,0 +1,37 @@ +# Tribal knowledge: testing / verifying changes + +There is no unit-test harness; verification is by running scripts through the +built binary. `src/main.c` runs `argv[1]` (default `game/main.rb`), so: + +```sh +./zig-out/bin/game /tmp/opencode/smoke.rb # desktop +node build/web/game.js /game/smoke.rb # web (windowless scripts; see web-target.md) +``` + +## Smoke tests +Write a small `.rb` that exercises the change and `puts` results, then diff the +output against expected. Filter the noisy raylib/Mesa banner with +`grep -vE '^(INFO|WARNING|MESA|libEGL)'`. + +## Visual verification (offscreen render → PNG) +To check rendering without a visible window, use the hidden-window + screenshot +pattern: set `Rl::FLAG_WINDOW_HIDDEN`, draw one frame, then read pixels back +(`load_image_from_screen` / `get_image_color`, or `export_image`/`take_screenshot` +to a PNG) and assert on pixel values. This is how the rlgl/shader fixes were +verified (e.g. a vec4 tint on a white quad must read back as the tint × 255). +Clean up the PNG afterward (don't commit it; `*.png` is git-ignored). + +## When verifying flecs +The binding logic is identical on desktop and web, so validate on desktop first +(fast), then confirm the same script in node against the wasm to catch +wasm-specific issues (stack, alignment). + +## Type checking (Ruby / RBS) +`./tools/check-types.sh` runs `rbs validate` (sig/*.rbs consistency) + `steep +check` (Steep project loads clean). It's a cheap, build-free gate that +hard-fails on a broken signature or a Steep project-load error and is green on +correct code. Game-code type *typos* (wrong arg type/arity to a typed `Rl::` / +`Jolt::` / `Flecs::` / `Rml::` call) are `:information` under the Steepfile's +`lenient` config — they show up live in the editor (the Steep LSP) and via +`steep check --severity-level=information`, not as a `check-types.sh` failure. +See `.agents/knowledge/steep.md` for the setup + rationale. diff --git a/.agents/knowledge/web-target.md b/.agents/knowledge/web-target.md new file mode 100644 index 0000000..d1cdf2a --- /dev/null +++ b/.agents/knowledge/web-target.md @@ -0,0 +1,182 @@ +# Tribal knowledge: web (Emscripten / WASM) target + +`build_web.sh` builds raylib (`PLATFORM_WEB`), RmlUi (emcc + freetype port), +flecs (amalgamation), and a wasm mruby cross-build, then `emcc`-links them and +preloads `game/`. Output: `build/web/game.{html,js,wasm,data}`. + +## At a glance +- **Key files:** `build_web.sh`; shell `web/shell.html` (entry script in + `Module.arguments`); the `web` `MRuby::CrossBuild` in `build_config.rb`; web loop + seam in `mrbgems/raylib/src/raylib_bindings.c` (`_run_web_loop`). +- **Run:** build with `build_web.sh`; serve `cd build/web && python3 -m http.server 8000`; + headless test `node build/web/game.js /game/script.rb`. +- **Cross-refs:** knowledge `flecs-binding` (the 4 MB stack), `build-system`; deploy + `docs/DEPLOY_CLOUDFLARE.md`. No dev server / live reload yet (roadmap R4). + +## The platform seam +The ONLY thing that differs between desktop and web at the Ruby level is the main +loop: `Rl.while_window_open` uses `emscripten_set_main_loop` on web and a plain +`until window_should_close?` on desktop (implemented in `raylib_bindings.c`). +Game code is identical. `Rl.platform`/`web?`/`desktop?` are available. + +## emcc link flags that matter +`-sUSE_GLFW=3 -sUSE_FREETYPE=1 -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2 +-sALLOW_MEMORY_GROWTH=1 -sSTACK_SIZE=4MB --preload-file game@/game --shell-file +web/shell.html`. +- **WebGL2 / OpenGL ES 3.0** (upgraded from WebGL1/ES2). raylib is built with + `GRAPHICS=GRAPHICS_API_OPENGL_ES3` (the PLATFORM_WEB Makefile uses `GRAPHICS ?=` + so the `build_web.sh` make-line override wins). `GRAPHICS_API_OPENGL_ES3` auto- + defines `GRAPHICS_API_OPENGL_ES2` (superset), so all ES2 blocks compile too; + VAO is core in ES3 so `rlDrawRenderBatch` always takes the VAO branch (never + the client-array `else` that needed `-sFULL_ES2=1`, which was DROPPED). + `-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2` = WebGL2-only (no fallback). + Do NOT use `-sFULL_ES3=1` (client-array emulation — orthogonca, breaks the + context; see research doc / raylib #4330). +- `-sSTACK_SIZE=4MB` is REQUIRED since flecs (see flecs-binding.md); without it + the wasm traps with "memory access out of bounds". +- Emscripten freetype/harfbuzz ports are prebuilt via `embuilder`. +- **raylib 6.0:** PLATFORM_WEB emits `libraylib.web.a` (NOT `libraylib.a`). + `build_web.sh` references `build/web/libraylib.web.a` (both the guard and the + emcc link line). Desktop still produces `libraylib.a` (unchanged). +- **Switching the GL backend (ES2<->ES3) invalidates `libraylib.web.a`** — + delete it and let `build_web.sh` rebuild (its `make clean` guard clears the + shared in-place `.o` per rules/raylib-platform-objs). + +## raylib 6.0 upgrade (5.5 -> 6.0) — what changed on our side +Upgrading the vendored raylib (`vendor/raylib` is a full git clone on a detached +tag) required THREE fixes in our build code (NOT vendor edits) + one vendored +patch for an upstream regression: + +1. **API parser relocated.** 6.0 moved `parser/` -> `tools/rlparser/` and renamed + the source `raylib_parser.c` -> `rlparser.c`. `mrbgem.rake` points at + `tools/rlparser/output/{raylib,raymath}_api.json` and builds + `tools/rlparser/rlparser.c`. (`raymath_api.json` is still generated from + `src/raymath.h` via the parser; `raylib_api.json` is shipped pre-generated but + is malformed — see #3.) +2. **Malformed `raylib_api.json` (upstream bug).** The shipped 6.0 + `tools/rlparser/output/raylib_api.json` has invalid JSON: the + `LoadDirectoryFilesEx` description contains literal unescaped double-quotes + (`"*.*"`, `"FILES*"`, `"DIRS*"`), copied verbatim from `raylib.h`. Both Ruby + and Python reject it. Regenerating from `raylib.h` reproduces the same + malformation (same source). Fix: `gen_raylib.rb` (and `gen_ai_reference.rb`) + have a `load_api` helper that tries strict parse, then escapes those three + tokens and retries. Safe on already-valid json (the escaped form doesn't + contain the unescaped substring). Do NOT regenerate `raylib_api.json` from + `raylib.h` with `-d RMAPI` — raylib.h uses the `RLAPI` decorator (raymath uses + `RMAPI`); `-d RLAPI` is correct but still yields the malformed quotes, so the + tolerant loader is the real fix. +3. The high-level shader API (`LoadShader`/`SetShaderValue*`/`GetShaderLocation`/ + `UnloadShader`) is **unchanged** in 6.0. The "REDESIGNED shader loading API" + (#5631) was `rlgl`-internal only (`rl*` functions), not `raylib.h`. Bindings + regenerated clean: 671/746 functions, 75 unbound (was 72 on 5.5). + +To rebuild raylib after switching the tag: `make -C vendor/raylib/src clean` +(raylib shares `.o` in `src/` across targets — see rules/raylib-platform-objs), +delete the stale `build/{desktop,web}/lib*raylib*.a`, then `zig build` / `build_web.sh`. + +## raylib 6.0 regression: IsCursorHidden() on web (pointer lock) +**Symptom:** after the 6.0 upgrade, mouse-look broke in `game/physics_playground.rb` +(uses `disable_cursor` + `get_mouse_delta`). `cursor_hidden?` stayed `false` and +`get_mouse_delta` returned `0.0` even after clicking; `get_mouse_x/y` worked fine. +**Cause:** 6.0 refactored cursor state in `vendor/raylib/src/platforms/rcore_web.c` +into `cursorHidden` (HideCursor) vs `cursorLocked` (DisableCursor/pointer-lock), +but `EmscriptenPointerlockCallback` now sets ONLY `cursorLocked` — it stopped +setting `cursorHidden` (5.5 set `cursorHidden` there). `IsCursorHidden()` reads +`cursorHidden`, so it never returns true on web after `DisableCursor()`. Games +that gate repeated `disable_cursor` calls on `!cursor_hidden?` (like +physics_playground) then spam `emscripten_request_pointerlock` every frame; the +browser rejects that (pointer lock must come from a single user gesture) → pointer +lock never stably engages → `get_mouse_delta` dead. +**Fix (vendored patch, documented inline):** `EmscriptenPointerlockCallback` now +also does `cursorHidden = cursorLocked` (restoring 5.5 semantics). This is a +genuine upstream regression — report it / re-check on the next raylib pull. +`EmscriptenMouseMoveCallback` already branches on `cursorLocked` (so deltas flow +once lock engages); the patch just makes `IsCursorHidden()` reflect it. + +## Testing the wasm without a browser +`node build/web/game.js [/game/script.rb]` boots the wasm and runs mruby. A +script that opens a window stops at `glfwInit` with `window is not defined` — +that's the expected browser-only boundary, not a failure. To actually exercise +logic (e.g. flecs) on wasm, run a **windowless** script: drop it in `game/`, +relink (`build_web.sh` is incremental once libs exist), and +`node game.js /game/yourscript.rb`. main.c takes the script path as argv[1]. + +## Serving +Browsers won't run `file://` wasm. To view the game: `cd build/web && python3 -m +http.server 8000` (static files only — no eval bridge). For the eval bridge + +`.live/web/bin/*` scripts, use the relay instead (see "Starting the relay" +below). `web/shell.html` is a responsive canvas shell (viewport-fit, +aspect-ratio, touch-action). + +## FX shader pipeline on web (WebGL2/ES3) — DONE + browser-verified +`Jamstack::FX` (mrblib/fx.rb) is a two-stage post-processing pipeline that runs +on WebGL2/ES3 (`#version 300 es` fragment shaders — upgraded from WebGL1). All +6 effects (warp, aberration RGB, aberration CMY, scanlines = game stage; +vignette, grayscale = top stage) compile + run in the browser under GLSL ES 3.00, +and every effect in BOTH stages toggles on/off at runtime via the eval bridge +(`fx.game_shaders[0].enabled = false`) or the overlay-HUD checkboxes, with zero +recompilation. (WebGL1's NPOT warning is gone under WebGL2.) To switch which +demo the browser runs, edit `web/shell.html` -> `Module.arguments` then rebuild +(shell.html is baked in). See `raylib-binding.md` "Custom shaders". + +**Web game-file edit requires a rebuild:** on WEB the game files are packaged +into `game.data` at build time (MEMFS), so ANY game/*.rb or game/ui/*.{rml,rcss} +change needs `./build_web.sh` then a hard-refresh (bypass cache). The relay +serves `build/web/` statically; it does NOT live-serve the repo `game/` dir. + +## Agentic runtime on web (R1–R5 web) — DONE + browser-verified +W1 verified on wasm via `node game.js /game/<windowless>.rb` (`eval_json`, `Log`, +`Flecs::Hot`, R5a `enable_rest`), and the JS→C path **browser-verified** through the +W2 relay: `sh .live/web/bin/eval 'Rl.get_fps'` → live fps, `Rl.platform` → `:web`, +multiline+`puts` returns result **and** captured stdout (C fd-redirect works under +emscripten MEMFS), `raise` returns a backtrace into the running game, and the +forwarded browser console (incl. the Ruby `Log` NDJSON) lands in `game-console`. +`_jamstack_eval`/`_flecs_explorer_request` are exported; `shell.html` exposes +`Module.jamstack`/`Module.flecsRequest`. W2 (`tools/agent-bridge/server.js`) is the +host relay that lets the **desktop `bin/eval` drive a browser tab** — see +`live-mount.md`. (Still TODO: exercise `Module.flecsRequest`/the Explorer against a +flecs web game; ship `bin/snapshot` from that JSON.) + +### Starting the relay (and stopping it safely) +The regular way — just run it (this serves `build/web/` and writes the +`.live/web/bin/*` scripts): +```sh +node tools/agent-bridge/server.js # foreground; Ctrl-C to stop +``` +From a non-interactive agent shell, background it and save the PID: +```sh +nohup node tools/agent-bridge/server.js > /tmp/relay.log 2>&1 & echo $! > /tmp/relay.pid +kill "$(cat /tmp/relay.pid)" # stop it later +``` +Then open `http://localhost:8080` in a browser. Poll `.live/web/status.json` +for `"connected":true` (set once a tab is open) before running `bin/eval`. + +**Gotcha — don't `pkill -f` to stop it.** `pkill -f 'agent-bridge/server.js'` +matches the agent's *own* shell command line (it contains that string) and kills +the shell → "no output, hung till timeout". Stop by PID +(`kill "$(cat /tmp/relay.pid)"`) or `Ctrl-C` the foreground process — never by +`-f` self-matching. (For just viewing the game with no eval bridge, a plain +`cd build/web && python3 -m http.server` also works — `agent-bridge.js` no-ops +quietly when there's no relay.) + +The desktop bridge uses TCP, which the browser can't do; the web channel is **JS→C**: +- **Eval:** `jamstack_eval(code) -> char* json` (`EMSCRIPTEN_KEEPALIVE`, in `main.c`) + calls `Jamstack::Bridge.eval_json` on the persistent `g_mrb`. Safe to call directly + from JS between frames (wasm is single-threaded; no queue needed). `main` never + returns on web (`set_main_loop` unwinds), so `g_mrb` stays alive past `main.c`'s + `mrb_close`. +- **Loop:** the web seam (`_run_web_loop`) now also `Log.tick!`s and logs loop + exceptions, matching desktop. The TCP `Bridge.start` fails gracefully on web (no + sockets) → `Bridge.drain` is a no-op; eval arrives via `jamstack_eval`. +- **flecs REST (R5a):** `world.enable_rest` on web does `flecs_wasm_rest_server = + ecs_rest_server_init(world, NULL)` (socketless); flecs's own + `flecs_explorer_request(method,req,body)` (already `EMSCRIPTEN_KEEPALIVE`) serves + the JSON — ship it over the JS channel. +- **Link flags:** `-sEXPORTED_FUNCTIONS=_main,_jamstack_eval,_flecs_explorer_request` + + `-sEXPORTED_RUNTIME_METHODS=ccall,cwrap`. +- **shell.html:** exposes `Module.jamstack(code)` and tees `console`. +- **Relay (W2 / R4b):** a host WS relay so a *file-based* agent reaches the browser + tab — separate slice (assessed after W1). +- **Verification limits:** `node game.js /game/x.rb` exercises **windowless** runtime + logic on wasm (eval_code, Log, Flecs::Hot, flecs); the JS→C eval + the Explorer + need a **browser** (the window keeps `mrb` alive), so those are browser-verified. diff --git a/.agents/rules/dont-edit-generated.md b/.agents/rules/dont-edit-generated.md new file mode 100644 index 0000000..42c087b --- /dev/null +++ b/.agents/rules/dont-edit-generated.md @@ -0,0 +1,10 @@ +# RULE: never hand-edit generated files + +These are produced at build time and git-ignored — edits are overwritten: + +- `mrbgems/raylib/src/raylib_gen.c` → edit `mrbgems/raylib/tools/gen_raylib.rb` +- `vendor/raylib/parser/output/raymath_api.json` → regenerated from `raymath.h` +- `docs/AI_REFERENCE.md` → edit `mrbgems/raylib/tools/gen_ai_reference.rb`, then rerun it +- anything under `vendor/`, `build/`, `zig-out/`, `.zig-cache/` + +To change raylib bindings, change the generator and rebuild. diff --git a/.agents/rules/link-order.md b/.agents/rules/link-order.md new file mode 100644 index 0000000..466f6b3 --- /dev/null +++ b/.agents/rules/link-order.md @@ -0,0 +1,10 @@ +# RULE: link order — libmruby.a first + +`libmruby.a` contains the `Rl::`/`Rml::`/`Flecs::` binding objects, which +reference symbols in libraylib.a, librmlui.a, and libflecs.a. So libmruby.a must +come **before** those native libs on the link line (it does, in `build.zig` and +`build_web.sh`). Getting this wrong = "undefined reference" at final link. + +C++/unwinder note: link `/usr/lib/libstdc++.so` and `/usr/lib/libgcc_s.so.1` +directly. Do NOT use `linkSystemLibrary("stdc++")` — zig 0.16 substitutes its own +LLVM libc++ (wrong ABI; RmlUi/mruby-cxx-exceptions need GNU libstdc++). diff --git a/.agents/rules/lld-no-gcc-lto.md b/.agents/rules/lld-no-gcc-lto.md new file mode 100644 index 0000000..2f20ec0 --- /dev/null +++ b/.agents/rules/lld-no-gcc-lto.md @@ -0,0 +1,12 @@ +# RULE: vendored C/C++ libs must NOT use GCC LTO + +zig's linker is **lld**, which cannot link **GCC `-flto`** objects (GIMPLE +bytecode in `.gnu.lto_*` sections). Symptom: every symbol from the lib is +"undefined" at the final link, even though `nm` shows it as defined `T` (nm uses +the LTO plugin; `readelf -s`/`objdump -t` reveal the object has almost no real +symbols). + +When a CMake dependency enables interprocedural optimization, turn it OFF: +`-DINTERPROCEDURAL_OPTIMIZATION=OFF` (Jolt) or `-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF`. +(LLVM/emcc LTO is fine for the web build — this is specifically a GCC-LTO + lld +incompatibility.) diff --git a/.agents/rules/main-thread-eval.md b/.agents/rules/main-thread-eval.md new file mode 100644 index 0000000..e8aaf9a --- /dev/null +++ b/.agents/rules/main-thread-eval.md @@ -0,0 +1,11 @@ +# Rule: eval/console/bridge commands run on the main thread + +All Ruby `eval` — whether from the agent bridge (TCP/WS), the in-game console, +or hot-reload — runs on the **main thread** via the frame-polled command queue +(`Jamstack::Bridge.drain`, called inside `while_window_open` before the game +block). Never call `mrb_funcall` or `eval` from a socket callback, JS callback, +or any thread other than the main loop. The mruby VM is not thread-safe. + +The in-game console (`Jamstack::Console`) calls `eval` directly (not through the +queue), but it's safe because `console.update` runs on the main thread, before +`ctx.process_input`. The console and the bridge can coexist. diff --git a/.agents/rules/mruby-rebuild.md b/.agents/rules/mruby-rebuild.md new file mode 100644 index 0000000..f00e813 --- /dev/null +++ b/.agents/rules/mruby-rebuild.md @@ -0,0 +1,11 @@ +# RULE: rebuilding mruby + the bindings + +- Drive rake at the **specific lib path**, never plain `rake` (plain rake also + builds mruby's CLI tools `mruby`/`mirb`/`mrdb`, which fail to link without + raylib/rmlui/flecs): + `rake "$JAMSTACK_ROOT/vendor/mruby/build/host/lib/libmruby.a"` — or just run + `./rebuild.sh`. +- After **adding/removing a gem** in `build_config.rb`, or any change that flips + the C/C++ ABI, `rm -rf vendor/mruby/build` first — otherwise stale objects + cause "multiple definition" link errors. The rmlui gem is C++ and holds mruby + in `MRB_USE_CXX_EXCEPTION` mode for the whole VM. diff --git a/.agents/rules/raylib-platform-objs.md b/.agents/rules/raylib-platform-objs.md new file mode 100644 index 0000000..2ea9f13 --- /dev/null +++ b/.agents/rules/raylib-platform-objs.md @@ -0,0 +1,7 @@ +# RULE: raylib shares .o files across platforms + +raylib compiles its objects in-place under `vendor/raylib/src/`, so desktop and +web builds collide. Each target's lib lives in its own dir +(`build/desktop/libraylib.a`, `build/web/libraylib.a`) and `make clean` MUST run +when switching targets. `build.zig` and `build_web.sh` already do this — don't +build raylib by hand in `vendor/raylib/src` without cleaning between targets. diff --git a/.agents/rules/wsl-toolchain.md b/.agents/rules/wsl-toolchain.md new file mode 100644 index 0000000..948f414 --- /dev/null +++ b/.agents/rules/wsl-toolchain.md @@ -0,0 +1,11 @@ +# RULE: use the Linux toolchain (WSL) + +A Windows Ruby on `/mnt/c/...` shadows the Linux `ruby`/`rake`. Before ANY build +or `ruby`/`rake`/generator command, strip `/mnt/c` from PATH: + +```sh +CLEANPATH=$(echo "$PATH" | tr ':' '\n' | grep -v '^/mnt/c' | paste -sd:); export PATH=$CLEANPATH +``` + +`vendor/mruby/minirake` is just `exec "rake"` — a real `rake` gem must be +installed for the Linux Ruby (`gem install rake`). diff --git a/.agents/settings.json b/.agents/settings.json new file mode 100644 index 0000000..1ac1cd3 --- /dev/null +++ b/.agents/settings.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "//": "Tool-agnostic harness settings. Reachable as .claude/settings.json via the .claude -> .agents symlink (the 'symlink trick', roadmap H0). Permission allowlist for this repo's known-safe build/run commands and the docs domains we actually fetch. Keep in sync with AGENTS.md 'Commands' and .agents/rules/wsl-toolchain.md.", + "permissions": { + "allow": [ + "Bash(zig build:*)", + "Bash(./rebuild.sh:*)", + "Bash(./build_web.sh:*)", + "Bash(EMSDK_ENV=*)", + "Bash(rake:*)", + "Bash(./zig-out/bin/game:*)", + "Bash(node build/web/game.js:*)", + "Bash(make clean:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "WebFetch(domain:raylib.com)", + "WebFetch(domain:www.raylib.com)", + "WebFetch(domain:flecs.dev)", + "WebFetch(domain:mikke89.github.io)", + "WebFetch(domain:emscripten.org)", + "WebFetch(domain:github.com)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.agents/skills/add-binding-fn/SKILL.md b/.agents/skills/add-binding-fn/SKILL.md new file mode 100644 index 0000000..47f8809 --- /dev/null +++ b/.agents/skills/add-binding-fn/SKILL.md @@ -0,0 +1,43 @@ +--- +name: add-binding-fn +description: Use when adding, changing, or exposing a raylib/raymath function to Ruby (the Rl:: binding). Covers editing the generator (never raylib_gen.c), the snake_case naming rule, typeless/unbound cases, the rebuild order, and regenerating the typed reference. +--- + +# Add a raylib binding function + +The raylib binding is **generated**. You change it by editing the generator, not +the emitted C. Read `.agents/knowledge/raylib-binding.md` first. + +## Steps + +1. **Strip `/mnt/c` from PATH** (`.agents/rules/wsl-toolchain.md`) — or just use the + build scripts, which do it. +2. **Find the function** in `vendor/raylib/parser/output/raylib_api.json` (or + `raymath_api.json`). If it's a callback / raw pointer/buffer / varargs / + array-or-string return, it's in the ~72 deliberately **unbound** set — binding + it needs a hand-written case, not the generic path. +3. **Edit the generator** `mrbgems/raylib/tools/gen_raylib.rb`. **Never edit + `mrbgems/raylib/src/raylib_gen.c`** — it's git-ignored and overwritten + (`.agents/rules/dont-edit-generated.md`). + - Naming: PascalCase → snake_case; `IsXxx` → `xxx?`. The **digit-split** rule is + subtle: `Vector2Add → vector2_add` but `Mode2D → mode2d`. If you touch `snake`, + re-test BOTH forms. + - Strings marshal with `z!` (Ruby `nil` → C `NULL`); structs marshal by value; a + lone `T*` param is in/out (pass the struct). + - Typeless `void*` value params (like `SetShaderValue`) are **hand-written and + emitted into the generated TU** after the struct helpers — follow that pattern. +4. **Rebuild:** `./rebuild.sh`. `mrbgem.rake` regenerates `raylib_gen.c` when the + generator/JSON inputs are newer, then zig links. If you also touch the web + target, `make clean` between targets (`.agents/rules/raylib-platform-objs.md`). +5. **Regenerate the typed references** (only if the public surface changed): after + `raylib_gen.c` is rebuilt, run both: + - `ruby mrbgems/raylib/tools/gen_ai_reference.rb` → `docs/AI_REFERENCE.md` + - `ruby mrbgems/raylib/tools/gen_rbs.rb` → `sig/raylib.rbs` (RBS signatures for + ruby-lsp hover/completion/signature-help). +6. **Verify:** write a smoke `.rb`, run `./zig-out/bin/game /tmp/opencode/x.rb`; for + anything visual, offscreen-render to a PNG (`.agents/knowledge/testing.md`). + +## Cross-refs +- Knowledge: `.agents/knowledge/raylib-binding.md` +- Rules: `dont-edit-generated`, `raylib-platform-objs`, `wsl-toolchain` +- Verify: skill `build-and-verify` diff --git a/.agents/skills/add-flecs-system/SKILL.md b/.agents/skills/add-flecs-system/SKILL.md new file mode 100644 index 0000000..2a7b563 --- /dev/null +++ b/.agents/skills/add-flecs-system/SKILL.md @@ -0,0 +1,43 @@ +--- +name: add-flecs-system +description: Use when adding or editing a Flecs ECS component or system in game Ruby code. Covers declaring components as runtime meta structs, registering systems with the right phase, the raw-id/writeback semantics, what survives a hot-reload, and (once Part B R3 lands) the hot-reloadable system registry and live-mount verification. +--- + +# Add a Flecs component / system + +Read `.agents/knowledge/flecs-binding.md` first. Components are **real C structs +declared at runtime** via the meta addon; values cross as Ruby Hashes. + +## Steps + +1. **Declare components** as meta structs: + `pos = world.struct("Position", "{float x; float y;}")`. + - Adding a **new** component is additive and **hot-reload safe**. + - Changing an **existing** component's struct **layout** needs a **reboot** + (existing entities hold the old layout) — see the hot-reload table (R3). +2. **Register a system:** + ```ruby + world.system("Move", with: [pos, vel], phase: Flecs::ON_UPDATE) do |id, p, v| + p[:x] += v[:x]; p[:y] += v[:y] # mutations to p/v are written back + end + ``` + - The yielded entity is a **raw Integer id** (no per-entity wrapper alloc); use + `world.entity_for(id)` when you need entity methods. A `tag` term yields `nil`. +3. **Pick a phase** (run order per `progress`): + `ON_LOAD → PRE_UPDATE → ON_UPDATE` (default) `→ ON_START`. +4. **Queries** for ad-hoc iteration: `world.query(pos).each { |id, p| … }` (cached, + `Enumerable`, same writeback semantics). Build once, reuse across frames. +5. **(Part B / R3 — planned)** Define systems through the **hot-reload registry** + (`define_system(name, with:, phase:, &blk)`) so re-running the file **swaps the + proc** in place — same system id, same matched tables, entity/component state + preserved. Organize reloadable units as `game/components.rb`, + `game/systems/*.rb` re-runnable top-to-bottom. +6. **Verify:** `./zig-out/bin/game game/<scene>.rb`; confirm the same script under + `node build/web/game.js /game/<scene>.rb` (flecs logic is identical across + targets — validate desktop first, it's faster). (Part B) inspect entity/component + state via the live mount. + +## Cross-refs +- Knowledge: `.agents/knowledge/flecs-binding.md` · Spec: `docs/API_SPEC_FLECS.md` +- Glossary: World/Entity/Component/tag/System/phase/query +- Roadmap R3 (hot-reload) for the registry mechanism. diff --git a/.agents/skills/add-knowledge/SKILL.md b/.agents/skills/add-knowledge/SKILL.md new file mode 100644 index 0000000..deabda3 --- /dev/null +++ b/.agents/skills/add-knowledge/SKILL.md @@ -0,0 +1,39 @@ +--- +name: add-knowledge +description: Use right after discovering a new gotcha, ABI trap, naming rule, or repeatable workflow in this repo, to decide WHERE to crystallize it (rule vs knowledge doc vs glossary vs skill) so the scar tissue is captured, not lost. Invoke whenever you think "I should write this down." +--- + +# Crystallize new tribal knowledge + +The harness only stays valuable if discoveries get written down in the right +layer. Pick the destination by what kind of thing you learned. + +## Where it goes + +- **A short, always-true safety reflex** that prevents an expensive mistake + (build-breaker, data/ABI corruption, lost work) → a tiny new file in + `.agents/rules/<name>.md`. Rules are skimmed **every session**, so keep them to a + few lines: the rule, the symptom, the fix. +- **Anything about ONE area — what it is and its key files/API surface, OR the deep + "why X breaks" detail** → the matching `.agents/knowledge/<area>.md` (build-system, + environment, raylib/rmlui/flecs/jolt-binding, web-target, testing). It is the + **single** per-area home: an "At a glance" orientation header up top, the tribal + gotchas below. If the area has no doc yet, write it **first** — it doubles as the + Plan-Mode brief. Loaded only when touching that area. +- **A new term, or a synonym that keeps drifting** → add a row to `GLOSSARY.md` + (Term | Meaning | Aliases to avoid). +- **A repeatable multi-step procedure** you'll forget → a new + `.agents/skills/<name>/SKILL.md`. + +## Rules of thumb + +- **Document only the non-inferable (P2).** If a frontier model could infer it from + the code, leave it out. Tribal traps are gold; generic advice is noise. +- Keep entries **short and THIS-repo-specific**. Cross-link between layers; never + duplicate (a doc points to the spec/rule, it doesn't copy it). +- Prefer the smallest always-loaded layer that fits: a one-line rule beats a buried + paragraph for anything that breaks builds. + +## Cross-refs +- `AGENTS.md` ("READ THE TRIBAL KNOWLEDGE FIRST") · `roadmap.md` (principles P1–P7) +- Layer definitions: glossary section "Harness layers" diff --git a/.agents/skills/build-and-verify/SKILL.md b/.agents/skills/build-and-verify/SKILL.md new file mode 100644 index 0000000..f23b6aa --- /dev/null +++ b/.agents/skills/build-and-verify/SKILL.md @@ -0,0 +1,43 @@ +--- +name: build-and-verify +description: Use to build and verify a change end-to-end. The canonical sequence for this repo — PATH strip, incremental desktop build (with the mruby-rebuild caveat), smoke test, offscreen-render visual check, web build, and node headless smoke test. There is no unit-test harness; verification is by running scripts. +--- + +# Build and verify a change + +Mirrors `.agents/knowledge/testing.md`. There is no unit-test harness — you verify +by running `.rb` scripts through the built binary (`main.c` runs `argv[1]`). + +## Steps + +1. **PATH:** strip `/mnt/c` (`.agents/rules/wsl-toolchain.md`). The build scripts + already do this; if you call `ruby`/`rake` directly, do it yourself. +2. **Type check (Ruby/sig changes):** `./tools/check-types.sh` — runs `rbs + validate` (sig consistency) + `steep check` (Steep project loads clean, no + :error). Cheap, needs no build. Green on correct code; hard-fails on a broken + `sig/*.rbs` or a Steep project-load error. (Game-code type *typos* surface as + :information in the editor / `steep check --severity-level=information`, not + here — see `.agents/knowledge/steep.md`.) Skip if you only touched C/C++. +3. **Desktop build:** `./rebuild.sh` (rake `libmruby.a` → `zig build`). Full build: + `zig build`. **After adding/removing an mrbgem or flipping the C/C++ ABI:** + `rm -rf vendor/mruby/build` first (`.agents/rules/mruby-rebuild.md`), else + "multiple definition" link errors. +4. **Smoke test:** write a small `.rb` that `puts` results to + `/tmp/opencode/smoke.rb`, run `./zig-out/bin/game /tmp/opencode/smoke.rb`, and + filter the banner: pipe through `grep -vE '^(INFO|WARNING|MESA|libEGL)'`. Diff + output against expected. +5. **Visual check (offscreen):** set `Rl::FLAG_WINDOW_HIDDEN`, draw one frame, read + pixels back (`load_image_from_screen` / `get_image_color`, or `export_image` / + `take_screenshot`) and assert on values (e.g. a tint × 255). Delete the PNG + after — `*.png` is git-ignored, don't commit it. +6. **Web build:** `EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh`. Switching + desktop↔web rebuilds raylib and `make clean`s its shared `.o` files; if you ever + build raylib by hand, clean between targets (`.agents/rules/raylib-platform-objs.md`). +7. **Web smoke:** `node build/web/game.js /game/script.rb` (windowless logic). A + **windowed** script stopping at `glfwInit` ("window is not defined") is the + expected browser-only boundary, not a failure. Validate flecs/logic on desktop + first (fast), then confirm on wasm to catch stack/alignment issues. + +## Cross-refs +- Knowledge: `.agents/knowledge/testing.md`, `build-system.md`, `environment.md`, `web-target.md` +- Rules: all of `.agents/rules/` diff --git a/.agents/skills/new-demo/SKILL.md b/.agents/skills/new-demo/SKILL.md new file mode 100644 index 0000000..39af245 --- /dev/null +++ b/.agents/skills/new-demo/SKILL.md @@ -0,0 +1,44 @@ +--- +name: new-demo +description: Use when scaffolding a new game scene or demo as a game/*.rb script that runs identically on desktop and web. Covers window init, the mandatory platform-seam loop (never a raw while/loop), input/timing, optional ECS/physics/UI setup, and running it on both targets. +--- + +# Scaffold a new demo scene + +Game code is identical across desktop and web **except** it must go through the +platform seam. Model after `game/main.rb` and the physics demos. + +## Steps + +1. Create `game/<name>.rb`. +2. **Init (once, before the loop):** + ```ruby + Rl.init_window(800, 450, "my demo") + Rl.target_fps = 60 + # Rml.init # only if you use UI — MUST be after init_window + ``` +3. **Use the seam — the ONLY loop.** Never write a bare `while`/`loop` (it breaks + the web target, which drives the body via `emscripten_set_main_loop`): + ```ruby + Rl.while_window_open do + # update... + Rl.draw(clear_color: Rl::BLACK) do + # draw... + end + end + ``` +4. **Input/timing:** symbol keys `Rl.key_down?(:w)` / `Rl.key_pressed?(:space)`; + delta time `Rl.frame_time`. Use the exception-safe block pairs (`mode_2d`, + `mode_3d`, `shader_mode`, …) instead of raw Begin/End. +5. **ECS / physics (optional):** create `Flecs::World` / `Jolt::World` **outside** the + loop; call `world.progress(Rl.frame_time)` / `world.step(dt)` **inside** it. + (See skills `add-flecs-system`; features `flecs`, `jolt`.) +6. **Run desktop:** `./zig-out/bin/game game/<name>.rb`. + **Run web:** set the entry in `web/shell.html` `Module.arguments`, rebuild + (`build_web.sh`), serve; or `node build/web/game.js /game/<name>.rb` for headless + logic. (`main.c` takes the script path as `argv[1]`.) +7. Don't commit screenshots — `*.png` is git-ignored for a reason. + +## Cross-refs +- Knowledge: `.agents/knowledge/raylib-binding.md`, `web-target.md` · Example: `game/main.rb` +- Verify: skill `build-and-verify` diff --git a/.agents/skills/ruby-to-native/SKILL.md b/.agents/skills/ruby-to-native/SKILL.md new file mode 100644 index 0000000..8a79910 --- /dev/null +++ b/.agents/skills/ruby-to-native/SKILL.md @@ -0,0 +1,130 @@ +# Skill: Ruby → Native C/C++ migration + +# Migrate slow Ruby game logic to C/C++ for performance + +Use when a Ruby hot loop or per-frame computation is too slow (frame drops, +measurable via `bin/eval` timing or visible jank). The goal is a native +function that's ergonomic from Ruby — the call site should look almost +identical, just faster. + +## When to migrate +- A per-frame loop over many entities/pixels/voxels in pure Ruby. +- Math-heavy computation (matrix ops, procedural gen, image processing). +- String/array manipulation in a tight loop that `Array#map!` can't fix. +- Anything where the Ruby overhead (method dispatch, GC) dominates. + +## When NOT to migrate +- The code runs once at startup (load time is fine). +- It's I/O bound, not CPU bound. +- The Ruby version is fast enough (measure first — don't guess). +- The logic changes frequently (Ruby is easier to iterate on; keep it Ruby + until the API stabilizes, then migrate the stable version). + +## Steps + +### 1. Profile — confirm the bottleneck +```sh +sh .live/web/bin/eval 't = Rl.time; 1000.times { your_hot_code }; Rl.time - t' +``` +Or check if frame rate drops when the code runs. If the Ruby version is fast +enough, **stop** — don't migrate. + +### 2. Decide where the binding lives +- **Raylib function?** → add to the generator (`gen_raylib.rb`), following the + `add-binding-fn` skill. +- **New standalone module?** → create a new mrbgem or add to an existing one. + Put C in `mrbgems/<gem>/src/`, Ruby sugar in `mrbgems/<gem>/mrblib/`. +- **Game-specific native helper?** → create a new mrbgem (e.g. `mrbgems/gameutils/`) + or add to an existing gem. Keep it separate from the library bindings. + +### 3. Write the C function +- Match the mruby calling convention: `mrb_state*, mrb_value self, mrb_value args`. +- Use `mrb_get_args` for parameters; `mrb_float_value`/`mrb_fixnum_value`/ + `mrb_str_new_cstr` for returns. +- For arrays/structs, use the existing wrapping patterns (see `raylib_gen.c` + for struct wrappers, `rml_bindings.cpp` for class wrapping). +- **GC:** if you allocate mruby objects, use `mrb_malloc` (GC-managed). If you + hold references across calls, register them with `mrb_gc_register`. + +### 4. Write the Ruby sugar (mrblib) +Keep the Ruby API clean. The call site should read naturally: +```ruby +# Before (slow Ruby): +particles.each { |p| p[:x] += p[:vx] * dt; p[:y] += p[:vy] * dt } + +# After (fast C, same ergonomics): +ParticleSystem.integrate(particles, dt) +``` +The sugar wraps the raw `_fn` call, handles nil defaults, and adds `?` predicates. + +### 5. Register the function +In the C init function (`mrb_define_module_function` or +`mrb_define_method`), following the existing patterns in the gem. + +### 6. Build +```sh +./rebuild.sh # desktop +EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh # web (both targets!) +``` +If adding a NEW gem: add it to `build_config.rb` (both desktop + web sections), +then `rm -rf vendor/mruby/build` (ABI change). + +### 7. Swap the call site +Replace the Ruby hot loop with the native call. This is the only `game/*.rb` +edit — coordinate with the gameplay-ruby agent if needed. + +### 8. Verify via the web bridge +```sh +node tools/agent-bridge/server.js +# → open browser, then: +sh .live/web/bin/eval 't = Rl.time; 1000.times { YourModule.fn(...) }; Rl.time - t' +# Compare to the Ruby timing from step 1. +``` +Also verify the game looks/plays identically (no visual regression). + +### 9. Update types +If you changed the public API surface: +- `ruby mrbgems/raylib/tools/gen_ai_reference.rb` (if raylib) +- `ruby mrbgems/raylib/tools/gen_rbs.rb` (if raylib) +- Hand-edit `sig/*.rbs` for non-raylib gems. + +### 10. Crystallize +If the migration revealed a new gotcha or pattern, add it to the relevant +`.agents/knowledge/` doc or this skill. + +## Patterns + +### Bulk array processing (most common) +Ruby passes an Array of Hashes (flecs components) or an Array of structs. The +C function iterates once, avoiding per-element Ruby method dispatch: +```c +// C: read Array, iterate, mutate in place or return new Array +static mrb_value bulk_integrate(mrb_state *mrb, mrb_value self) { + mrb_value arr; mrb_float dt; + mrb_get_args(mrb, "Af", &arr, &dt); + mrb_int n = RARRAY_LEN(arr); + for (mrb_int i = 0; i < n; i++) { + mrb_value h = mrb_ary_ref(mrb, arr, i); + // read hash fields, compute, write back + mrb_hash_set(mrb, h, mrb_symbol_value(mrb_intern_lit(mrb,"x")), + mrb_float_value(mrb, new_x)); + } + return arr; +} +``` + +### Struct batch ops +If the data is in Rl:: structs (Vector2, etc), pass the Array of struct +wrappers and access the C struct pointers directly (see `rl_ptr_Vector2` +pattern in `raylib_gen.c`). + +### Keeping Ruby flexibility +Don't over-migrate. Leave high-level game logic in Ruby; only move the +inner loop to C. The Ruby wrapper can still handle edge cases, defaults, +and validation that would be tedious in C. + +## Cross-refs +- Skill: `add-binding-fn` (for raylib-specific binding patterns) +- Skill: `build-and-verify` (for the build + verify sequence) +- Knowledge: `build-system.md`, `raylib-binding.md` +- Rules: `mruby-rebuild`, `link-order`, `dont-edit-generated` diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..9385a58 --- /dev/null +++ b/.clang-format @@ -0,0 +1,78 @@ +# clang-format config for raylib-jamstack C/C++ bindings. +# +# Codifies the dominant style of the hand-written C/C++ mrbgem sources +# (mrbgems/*/src/* + src/main.c): Allman braces, 2-space indent, ~100-col limit, +# return type on its own line for top-level function definitions. Verified to +# produce ZERO diff on raylib_bindings.c (the most style-consistent file); other +# files have real inconsistencies (single-line function defs, semicolon-chained +# statements) that `clang-format -i` will normalize. +# +# Scope: hand-written sources only. raylib_gen.c (generated) and vendor/ are +# excluded (see bin/lint). Never hand-edit raylib_gen.c. +# +# Run manually: bin/lint --check-c (report / dry-run) +# bin/lint --fix-c (rewrite in place) +# Run directly: clang-format -i <files> (fix in place) +# clang-format --dry-run --Werror <files> (report) +# +# See .agents/knowledge/linting.md for the rationale + how it fits with the +# LSP setup (clangd's --clang-tidy stays OFF for the live-edit path; manual +# clang-format/clang-tidy are deliberate, separate steps). + +BasedOnStyle: LLVM + +# --- Indentation --- +IndentWidth: 2 +TabWidth: 2 +UseTab: Never +ContinuationIndentWidth: 2 +IndentCaseLabels: false +IndentGotoLabels: false +NamespaceIndentation: None +IndentWrappedFunctionNames: false + +# --- Braces: Allman (open brace on its own line) --- +BreakBeforeBraces: Allman +AllowShortBlocksOnASingleLine: Empty +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: WithoutElse +AllowShortLoopsOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortEnumsOnASingleLine: false + +# --- Return type on its own line for top-level function definitions --- +# This matches raylib_bindings.c's style (return type, then name+args, then {). +AlwaysBreakAfterReturnType: TopLevelDefinitions +AlwaysBreakAfterDefinitionReturnType: TopLevel + +# --- Column limit --- +ColumnLimit: 100 +ReflowComments: false # don't rewrap hand-written /* ... */ comments + +# --- Includes --- +SortIncludes: Never # preserve the commented, grouped include order +IncludeBlocks: Preserve + +# --- Alignment --- +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignConsecutiveMacros: true +AlignEscapedNewlines: Left +AlignOperands: Align +AlignTrailingComments: true + +# --- Spacing --- +SpaceBeforeParens: ControlStatements +SpaceBeforeSquareBrackets: false +SpaceAroundPointerQualifiers: Default +BitFieldColonSpacing: Both +SpacesInParentheses: false +SpacesInSquareBrackets: false +SpacesInContainerLiterals: false + +# --- Pointers --- +PointerAlignment: Right # mrb_state *mrb (not mrb_state* mrb) + +# --- Misc --- +Cpp11BracedListStyle: true # {.name = name} not { .name = name } (no inner spaces) @@ -0,0 +1,20 @@ +# clangd config for raylib-jamstack. +# +# Compile flags (include roots + MRB_INT64) live in compile_commands.json, which +# is GENERATED by `ruby tools/gen_compile_commands.rb` (rebuild.sh regenerates it). +# That file sets `directory` = the project root so the relative `-Ivendor/...` +# paths resolve correctly — clangd's fallback (no compile db) resolves them +# against the file's own dir and breaks, hence the generator. +# +# compile_commands.json is gitignored (it holds absolute paths). After a fresh +# clone, run the generator (or rebuild.sh) once before opening C/C++ files. +# +# This targets the DESKTOP build. Web-only headers (<emscripten.h>) are guarded +# by #ifdef __EMSCRIPTEN__ (not defined here), so clangd skips them. +# Sources: mrbgems/{raylib,rmlui,flecs,jolt}/src/* + src/main.c +# (raylib_gen.c is generated — index it for definition nav, never hand-edit it.) + +Index: + # mruby/raylib/etc. are full of macros; skip indexing the C++ standard library + # to keep the index lean and avoid churn on vendored system headers. + StandardLibrary: No @@ -0,0 +1 @@ +.agents
\ No newline at end of file diff --git a/.dispatch/build-agent.md b/.dispatch/build-agent.md deleted file mode 100644 index 1a6ed43..0000000 --- a/.dispatch/build-agent.md +++ /dev/null @@ -1,57 +0,0 @@ -# Build system agent brief - -You are the **build system owner-agent** for this raylib project. You own the -build wiring: the Makefile, `bin/*` scripts, and any build configuration. - -## Your scope -You MAY read ANY file in the project — `.h` headers, `.c` implementations, -build scripts, existing Makefile, deps structure, font data, everything. You -need full visibility to understand what to compile and how to link it. - -You MAY write ONLY: -- `Makefile` -- `bin/build`, `bin/build-web`, `bin/clean`, `bin/serve` - -You MUST NOT write to `src/*` or any other source files. - -## Engineering standard -- The Makefile must support: - - **Default target (`make`):** Linux native build via `gcc`, produce - `build/study-player`. Defines: `-DPLATFORM_DESKTOP -DPLATFORM_LINUX - -D_GLFW_X11`. Link: `-lm -lrt -ldl -lpthread -lX11`. - - **Windows target (`make windows`):** Cross-compile via - `x86_64-w64-mingw32-gcc`, produce `build/study-player.exe`. Defines: - `-DPLATFORM_DESKTOP -D_GLFW_WIN32`. Link: `-lgdi32 -lwinmm -lcomdlg32 - -lole32`. - - **Clean target (`make clean`):** remove `build/`. - - **Individual `.o` compilation:** each `src/*.c` → `build/<name>.o` with - `-std=c99 -Wall -Wextra`. -- Raylib is built as a static library from `deps/raylib/src/*.c`. Use options - that suppress warnings on third-party code (`-w` for raylib objects). -- Font header generation: `build/font_data.h` is a prerequisite built by - running `xxd -i` on the font file in `resources/`. If no font file exists, - the build should still work (font_data.h just won't define `FONT_EMBEDDED`). - **`font_data.h` is an array definition, not a declaration** — it must be - included in exactly ONE `.c` file (currently `ui.c`). Multiple includes cause - multiple-definition link errors. -- The `bin/build-web` script builds for Web/WASM via `emcc` + `emar`. Keep it - functional. -- All SRCS should be `$(wildcard src/*.c)` so new modules auto-compile. -- Use `-j$(nproc)` in any make invocation inside scripts for parallelism. - -## bin/ script conventions -- Every script starts with `#!/usr/bin/env bash` and `set -euo pipefail`. -- Every script derives `SCRIPT_DIR` and `PROJECT_DIR` and `cd "$PROJECT_DIR"`. -- Every script forwards extra args via `"$@"`. -- One script per operation — the filename describes what it does. - -## Verification -1. `make clean && make -j$(nproc)` — exits 0, zero warnings from project code -2. `make windows -j$(nproc)` — exits 0, zero warnings from project code -3. `bin/build-web` still works (even if run separately) - -## Report -Write `reports/build-system.md`: -1. **Files touched** -2. **What you changed** (bullet list) -3. **Build result** for both Linux and Windows targets diff --git a/.dispatch/package-agent.md b/.dispatch/package-agent.md deleted file mode 100644 index fecd7b8..0000000 --- a/.dispatch/package-agent.md +++ /dev/null @@ -1,53 +0,0 @@ -# Package owner-agent brief (C/Raylib) - -You are the **exclusive owner-agent** for a C module in this raylib project. - -## Your scope -You own the module's `.h` + `.c` pair. You may read and edit ONLY those two -files. No other agent may touch them. This project follows a **single-writer -rule**. - -## Visibility: contracts vs implementation -- You MAY read the `.h` header files of OTHER modules (their contracts). -- You MUST NOT read the `.c` implementation files of ANY other module. -- If you think you need a change in another module's `.h` contract, REPORT it - in your final report — do NOT edit it yourself. -- If you think you need to read another module's `.c` to understand its - behavior, STOP — the `.h` contract is underspecified. REPORT this. - -## Engineering standard -This is a C99 project. Your code must: -- Compile with `-Wall -Wextra` producing ZERO warnings. -- Use `#pragma once` as the include guard in every `.h` file. -- Put NO global mutable variables — all shared state through `PlayerState*`. -- Prefix public functions with your module name (`player_`, `study_`, `ui_`). -- Use `static` for module-internal helper functions. -- Mark pointer parameters `const` when the function does not mutate them. -- Use `raylib.h` for ALL platform/windowing/audio/input APIs — never include - glfw, miniaudio, or stb headers directly. -- Pair every dynamic allocation with a corresponding free in the same module's - cleanup path. No leaks. - -## Build -Run `make` from the repo root to build the full project. Your module's `.c` -file will be compiled to `.o` and linked into the final executable. - -## Verification -Before writing your report, you MUST: -1. Run `make` from the repo root. It must exit 0 with ZERO warnings. -2. If `make` reports errors OUTSIDE your module, those are from concurrent - sibling agents still working — focus on YOUR module being clean. - -## Report -After completing your work, write exactly one file: `reports/<module>.md`: -1. **Files touched** (list paths) -2. **What you implemented** (bullet list of functions/changes — use the exact - function names from your `.h` contract) -3. **Build result** (`make` exit code + copy-paste any warnings/errors from - YOUR files) -4. **Contract gaps or issues** discovered (e.g. missing declarations in - another module's `.h`) -5. **Changes needed in other modules** (e.g. "`types.h` needs `MAX_FOO`") - -The orchestrator will read your report — not your `.c` file. Be precise about -what you built and what still needs attention. diff --git a/.dispatch/rules/contracts-are-h.md b/.dispatch/rules/contracts-are-h.md deleted file mode 100644 index 29f9ce3..0000000 --- a/.dispatch/rules/contracts-are-h.md +++ /dev/null @@ -1,17 +0,0 @@ -# Contracts are header files - -- The `.h` file IS the contract between modules. Other agents read ONLY your - `.h` — never your `.c`. -- Every `.h` must be **self-contained**: it includes all types it references. - A consumer should be able to `#include "your_module.h"` and nothing else. -- Prefer **forward declarations** over full includes when only a pointer is - needed. Example: `typedef struct PlayerState PlayerState;` avoids including - `types.h`. -- A `.h` file must NOT include any `.c` file. Ever. -- If you expose a function, its full signature (return type, name, parameter - types and names) must be in the `.h`. The documentation of what it DOES - (preconditions, postconditions, side effects) goes in a comment in the `.h` - — that is the contract's behavioral specification, not just its type - signature. -- If an agent NEEDS to read your `.c` to understand what your module does, - your `.h` contract is UNDESPECIFIED. Report this as a contract gap. diff --git a/.dispatch/rules/one-owner.md b/.dispatch/rules/one-owner.md deleted file mode 100644 index 15ef1a8..0000000 --- a/.dispatch/rules/one-owner.md +++ /dev/null @@ -1,8 +0,0 @@ -# One-owner - -- You are the EXCLUSIVE writer for the files assigned in the TASK block. -- No other agent may write those files — ever, under any circumstances. -- If another module needs a change in your file, the orchestrator summons YOU - to make it. -- Check your work: `git status` should show changes ONLY in your assigned files. - If you accidentally touch something else, revert it. diff --git a/.dispatch/rules/zero-warnings.md b/.dispatch/rules/zero-warnings.md deleted file mode 100644 index a6239d9..0000000 --- a/.dispatch/rules/zero-warnings.md +++ /dev/null @@ -1,11 +0,0 @@ -# Zero warnings - -- Your code must compile with `-Wall -Wextra` producing EXACTLY ZERO warnings. -- No `-w` suppression. No `(void)` casts to silence legitimate warnings unless - you have a real reason (e.g. an unused parameter that must exist for a - callback signature). -- The orchestrator will re-run `make` after you and will REJECT any warning — - even ones from other files your code includes. If `raylib.h` or a system - header triggers a warning, isolate it with platform guards. -- Run `make` yourself before reporting. The exit code must be 0. -- The build output is your trust signal. A clean build = a clean module. @@ -1,21 +1,35 @@ -# Build artifacts -build/ -build-web/ +# Build outputs +/zig-out/ +/.zig-cache/ +/build/ +*.o +*.a +*.png -# External dependencies (cloned separately) -deps/ +# Vendored third-party sources (fetched separately; see BUILDING.md) +/vendor/ -# Runtime assets (fonts — embedded at build time) -resources/ +# Generated bindings (produced from vendor/raylib/parser/output/raylib_api.json) +/mrbgems/raylib/src/raylib_gen.c -# Runtime config -study-player.cfg +# clangd compile database (generated by tools/gen_compile_commands.rb; holds +# absolute paths so the project root is machine-specific — do not commit). +/compile_commands.json -# Agent harness scratch (orchestrator→agent prompts, agent→orchestrator reports) -prompts/ -reports/ +# clangd background-index cache (machine-local; generated by --background-index) +/.cache/ -# OS / editor -.DS_Store +# Vendored gem install artifact (steep 2.0.0 lives in the user gem dir, not here) +/steep-*.gem + +# Agent runtime mount (dev-only; status/console/cmd-result, see live-mount.md) +/.live/ + +# Node tooling (dev-only; puppeteer for headless web screenshot capture) +/node_modules/ +/.puppeteer-cache/ + +# Editor / OS *.swp -*~ +.DS_Store +mrbgems/raylib/src/smaa_tex_data.c diff --git a/.opencode/agent/backend-engineer.md b/.opencode/agent/backend-engineer.md new file mode 100644 index 0000000..7b11b9c --- /dev/null +++ b/.opencode/agent/backend-engineer.md @@ -0,0 +1,81 @@ +--- +description: Backend C/C++ developer — builds native features, bindings, and performance migrations from Ruby to C. Owns mrbgems src, generators, build system, and new native modules. +mode: subagent +permission: + edit: allow + bash: allow +--- + +You are the **backend-engineer** for the raylib-jamstack project. You own the +C/C++ layer and the bridge between Ruby and native code. Your work falls into +three categories: + +## 1. New native features ("backend") +Build new C/C++ functionality that doesn't exist in Ruby yet — new physics +queries, procedural generation, audio processing, custom shaders, data +structures, etc. You create the binding, the Ruby sugar, and any supporting +native code. + +## 2. Binding fixes / additions +Add or fix raylib, RmlUi, flecs, or Jolt binding functions. Edit generators +(never generated files). Fix build/link issues (ABI, link order, platform +objects). + +## 3. Performance migrations (Ruby → C) +When Ruby game logic is too slow, migrate it to C/C++ following the +**Ruby-to-native migration skill** (`.agents/skills/ruby-to-native/SKILL.md`). +The pattern: +1. Identify the hot loop (profile via `bin/eval` timing or frame-rate impact). +2. Write the C function + binding (a new mrbgem fn or a standalone module). +3. Keep the Ruby API ergonomic — the Ruby call site should look almost + identical to the original Ruby, just faster. +4. Replace the Ruby call site with the native call. +5. Verify via the web bridge that the game still works identically. +6. If the migrated code is a new reusable pattern, crystallize it in a skill. + +## What you do NOT do +- Edit `game/**/*.rb` game scene code (that's the gameplay-ruby agent's job). + Exception: during a performance migration you touch the call site to swap + the Ruby method for the native one — coordinate with gameplay-ruby. +- Edit RML/RCSS UI assets. + +## Rules (non-negotiable) +1. **Strip `/mnt/c` from PATH** before any ruby/rake/build command. +2. **Never hand-edit** generated files (`raylib_gen.c`, `vendor/`, `build/`, + `sig/raylib.rbs`, `docs/AI_REFERENCE.md`) — edit the generator, then regenerate. +3. **`rm -rf vendor/mruby/build`** after adding/removing a gem or flipping C/C++ ABI. +4. **`make clean`** in vendor/raylib when switching desktop/web (shared `.o` files). +5. **Link order:** `libmruby.a` before native libs; GNU libstdc++ linked directly. +6. All eval/console/bridge commands run on the main thread. + +## Build commands +``` +./rebuild.sh # incremental desktop (rake + zig) +EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh # web build +./zig-out/bin/game game/foo.rb # run desktop +``` + +## After changing the public API surface +Run ALL that apply: +``` +ruby mrbgems/raylib/tools/gen_ai_reference.rb # → docs/AI_REFERENCE.md +ruby mrbgems/raylib/tools/gen_rbs.rb # → sig/raylib.rbs (raylib only) +# For RmlUi/Flecs/Jolt RBS: hand-edit sig/*.rbs (they're hand-maintained) +``` + +## Web-first verification +After building, the web bridge is the primary verification tool: +```sh +node tools/agent-bridge/server.js # start relay +# → open http://<hostname>:8080 in a browser +sh .live/web/bin/eval 'YourModule.do_thing(x)' # test the new binding live +``` + +## Read first +- `.agents/rules/*` (all 7) +- `.agents/knowledge/build-system.md` +- `.agents/knowledge/raylib-binding.md` (if touching Rl::) +- `.agents/knowledge/rmlui-binding.md` (if touching Rml::) +- `.agents/skills/add-binding-fn/SKILL.md` (if adding a raylib fn) +- `.agents/skills/ruby-to-native/SKILL.md` (if migrating Ruby → C) +- `.agents/skills/build-and-verify/SKILL.md` (to verify) diff --git a/.opencode/agent/gameplay-ruby.md b/.opencode/agent/gameplay-ruby.md new file mode 100644 index 0000000..d577ca5 --- /dev/null +++ b/.opencode/agent/gameplay-ruby.md @@ -0,0 +1,84 @@ +--- +description: Gameplay Ruby developer — writes game/*.rb code, uses the live web bridge to test and debug. Never touches C/C++ bindings or generators. +mode: subagent +permission: + edit: allow + bash: allow +--- + +You are the **gameplay-ruby** developer for the raylib-jamstack project. You +write game code in Ruby (`game/**/*.rb`, `game/ui/*.rml`, `game/ui/*.rcss`) +and the Ruby sugar in `mrbgems/*/mrblib/*.rb`. You use the live web bridge to +test your changes without rebuilding. + +## Web-first development workflow + +**Web is the primary target.** The game runs in a browser, served by the relay. + +### Starting the relay + browser +```sh +# Build web (first time or after C/C++ changes): +EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh + +# Start the relay: +node tools/agent-bridge/server.js +# → open http://<hostname>:8080 in a browser +``` +The relay serves the game and bridges `bin/*` scripts to the browser. + +### Testing changes +- **`game/*.rb` edits** → just refresh the browser page. No rebuild needed. +- **`mrblib/*.rb` edits** → `./rebuild.sh` (picks up mrblib changes), then + `EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh` for web, then refresh. + +### Debugging the live game (from the shell) +```sh +sh .live/web/bin/eval 'Rl.get_fps' # eval any Ruby +sh .live/web/bin/eval 'Rl.platform' # → :web +sh .live/web/bin/eval 'puts "hello"; 42' # stdout captured +sh .live/web/bin/eval 'c = nil; ObjectSpace.each_object(Jamstack::Console) { |o| c = o }; c.open?' +sh .live/web/bin/tail-log 20 # recent log lines +sh .live/web/bin/snapshot # flecs state.json (needs flecs game) +sh .live/web/bin/query 'Position' # flecs query (needs flecs game) +sh .live/web/bin/hot-reload game/systems/move.rb # hot-reload a flecs system +``` + +### Reaching live game objects +Bridge eval runs in `Jamstack::Bridge`'s context, NOT the game's binding. Local +variables (`score`, `player_color`) are NOT directly accessible. Use +`ObjectSpace` to find live objects: +```ruby +# Find the console: +c = nil; ObjectSpace.each_object(Jamstack::Console) { |o| c = o } +c.show; c.input["value"] = "player_color = Rl::RED"; c.send(:submit); c.hide + +# Find a Jolt world: +w = nil; ObjectSpace.each_object(Jolt::World) { |o| w = o } +w.bodies.first.position +``` + +### The in-game console (user-facing) +Press `\` (backslash) in the game to toggle the REPL. It has tab completion, +command history, and variable propagation via `binding.local_variable_set`. +The console's binding IS the game script's binding — it can read/write local +variables directly. + +## What you do NOT do +- Edit `mrbgems/*/src/*.cpp` or `*.c` (that's the binding-engineer's job). +- Edit `mrbgems/*/tools/gen_*.rb` (generators). +- Edit `build.zig`, `build_config.rb`, `build_web.sh`, or `src/main.c`. + +## Rules +1. Use `Rl.while_window_open` as the ONLY main loop (never raw `while`/`loop`). +2. `Rml.init` AFTER `Rl.init_window` (needs GL context). +3. Gate gameplay input behind `console.open?` if a console is used. +4. Strip `/mnt/c` from PATH before any ruby/rake/build command. + +## Read first +- `.agents/knowledge/raylib-binding.md` (Rl:: API) +- `.agents/knowledge/rmlui-binding.md` (Rml:: API, console) +- `.agents/knowledge/console.md` (the REPL console) +- `.agents/knowledge/agent-bridge.md` (the eval bridge) +- `.agents/knowledge/jolt-binding.md` (physics) +- `.agents/knowledge/flecs-binding.md` (ECS) +- `.agents/skills/new-demo/SKILL.md` (scaffolding a new game scene) diff --git a/.opencode/agent/reviewer.md b/.opencode/agent/reviewer.md new file mode 100644 index 0000000..c631be6 --- /dev/null +++ b/.opencode/agent/reviewer.md @@ -0,0 +1,40 @@ +--- +description: Read-only code reviewer — checks against rules, glossary, API specs, and conventions. Never edits files. +mode: subagent +permission: + edit: deny + bash: ask +--- + +You are the **reviewer** for the raylib-jamstack project. You are read-only. +You review changes against the repo's rules, glossary, API specs, and +conventions, and report issues concisely. + +## What you check +1. **Generated files** — no hand-edits to `raylib_gen.c`, `vendor/`, `build/`, + `sig/raylib.rbs`, `docs/AI_REFERENCE.md`. If a generated file was edited, + the generator (`gen_raylib.rb` / `gen_ai_reference.rb` / `gen_rbs.rb`) must + have been changed instead. +2. **Rules** (`.agents/rules/*`): + - `/mnt/c` stripped from PATH in build scripts? + - `rm -rf vendor/mruby/build` after gem/ABI changes? + - `make clean` when switching desktop/web? + - Link order correct? + - Eval/console/bridge on main thread? +3. **Naming conventions** — PascalCase → snake_case; `IsXxx` → `xxx?`; + digit-split rule (`Vector2Add` → `vector2_add` but `Mode2D` → `mode2d`). +4. **Platform seam** — `Rl.while_window_open` is the ONLY main loop. +5. **Init order** — `Rml.init` AFTER `Rl.init_window`. +6. **Console input gating** — gameplay input gated behind `console.open?`. +7. **Glossary** — terms match `GLOSSARY.md`. + +## What you do NOT do +- Edit any file. +- Run commands that modify files (build, rebuild, etc.). +- Suggest changes — only report issues. + +## Read first +- `.agents/rules/*` (all 7) +- `GLOSSARY.md` +- `docs/API_SPEC*.md` (if reviewing API changes) +- `.agents/knowledge/*` (the area being changed) diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..29ea431 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,361 @@ +# RuboCop config for raylib-jamstack. +# +# Game code is mruby 3.3 (a subset of Ruby 3.3); the mrbgem mrblib/ sugar and +# the tools/ generators run on the host MRI 3.4.8 that runs RuboCop itself. +# RuboCop runs ON MRI but ANALYZES code targeting any Ruby version — setting +# TargetRubyVersion: 3.4 makes the parser accept mruby's endless methods +# (`def foo = expr`, Ruby 3.0+ syntax) without false "unexpected token tEQL" +# syntax errors. (The upstream mruby repo uses the same TargetRubyVersion: 3.4 +# in its own .rubocop.yml.) +# +# Pattern: DisabledByDefault + enable whole departments (Layout, Lint) + +# a curated Style subset. This mirrors the upstream mruby repo's approach +# (DisabledByDefault: true — they enable only 3 layout cops; we enable more but +# the principle is identical: opt-in, never the full default set). Cops that +# suggest MRI-only stdlib methods (Performance/*, some Style/* like FetchEnvVar) +# are NOT enabled — they'd false-positive on mruby's smaller stdlib. +# +# Run manually: bin/lint (report only) +# bin/lint --fix (safe autocorrect layout/lint) +# Run directly: rubocop (report) / rubocop -a (safe autocorrect) +# +# See .agents/knowledge/linting.md for the full rationale + how it fits with +# the LSP/Steep setup (linting is MANUAL; syntax errors are caught at build time +# by the mruby-compiler gem; type errors by Steep per-edit). + +AllCops: + DisabledByDefault: true + TargetRubyVersion: 3.4 + NewCops: disable + Exclude: + - 'vendor/**/*' # vendored mruby/raylib/rmlui/flecs/joltc sources + - 'build/**/*' # mruby build outputs + - 'zig-out/**/*' # zig build outputs + - '.cache/**/*' # transient caches + - '.live/**/*' # runtime agent-bridge mount (dev-only) + - '.ruby-lsp/**/*' # ruby-lsp's private bundle + - 'steep-2.0.0.gem' # vendored steep gem (not Ruby source) + +# ─── Layout: whole department — pure syntax (spacing/indentation), mruby-safe ─ +# Layout cops carry NO runtime assumptions; they're safe for mruby. Aligned with +# the existing 2-space-indent, ~100-col style. +Layout: + Enabled: true + +Layout/EndOfLine: + EnforcedStyle: lf +Layout/IndentationStyle: + EnforcedStyle: spaces +Layout/IndentationWidth: + Width: 2 +Layout/LineLength: + Max: 100 + AllowedPatterns: ['\A\s*#'] # long comment lines OK + +# ─── Lint: whole department — real bug-finders, runtime-safe ────────────────── +# Catches duplicated hash keys, useless assignments, ambiguous operators, etc. +# These are valuable and don't assume MRI stdlib. +Lint: + Enabled: true + +# The game loop and bridge intentionally rescue Exception (not StandardError) to +# keep the game running / log exceptions instead of crashing. This is a +# deliberate pattern across mrbgems/*/mrblib/ (raylib.rb, live.rb, hot.rb). +Lint/RescueException: + Enabled: false + +# ─── Style: curated safe subset (no MRI-stdlib suggestions) ─────────────────── +# DisabledByDefault means Style is off entirely; we opt into specific cops that +# are purely syntactic / conventional. Deliberately NOT enabled: cops that +# suggest MRI-only behavior — Style/FetchEnvVar, Style/EnvHome, Style/OpenStructUse, +# Style/Documentation (game scripts are un-annotated), +# Style/FrozenStringLiteralComment (mruby ignores it; noise). + +Style/AndOr: + Enabled: true +Style/BlockDelimiters: + Enabled: true +Style/ClassCheck: + Enabled: true +Style/ColonMethodCall: + Enabled: true +Style/ColonMethodDefinition: + Enabled: true +Style/CommandLiteral: + Enabled: true +Style/CommentAnnotation: + Enabled: true +Style/DefWithParentheses: + Enabled: true +Style/DoubleNegation: + Enabled: true +Style/EachForSimpleLoop: + Enabled: true +Style/EmptyElse: + Enabled: true +Style/EmptyLiteral: + Enabled: true +Style/EmptyMethod: + Enabled: true +Style/EndlessMethod: + Enabled: true + EnforcedStyle: allow_single_line +Style/EvalWithLocation: + Enabled: true +Style/EvenOdd: + Enabled: true +Style/For: + Enabled: true +Style/FormatString: + Enabled: true +Style/FormatStringToken: + Enabled: true +Style/GuardClause: + Enabled: true +Style/HashSyntax: + Enabled: true +Style/IdenticalConditionalBranches: + Enabled: true +Style/IfInsideElse: + Enabled: true +Style/IfUnlessModifier: + Enabled: true +Style/IfUnlessModifierOfIfUnless: + Enabled: true +Style/InfiniteLoop: + Enabled: true +Style/KeywordParametersOrder: + Enabled: true +Style/Lambda: + Enabled: true +Style/LambdaCall: + Enabled: true +Style/LineEndConcatenation: + Enabled: true +Style/MethodCallWithoutArgsParentheses: + Enabled: true +Style/MethodCalledOnDoEndBlock: + Enabled: true +Style/MethodDefParentheses: + Enabled: true +Style/MinMax: + Enabled: true +Style/MultilineBlockChain: + Enabled: true +Style/MultilineIfModifier: + Enabled: true +Style/MultilineIfThen: + Enabled: true +Style/MultilineMemoization: + Enabled: true +Style/MultilineMethodSignature: + Enabled: true +Style/MultilineTernaryOperator: + Enabled: true +Style/MultilineWhenThen: + Enabled: true +Style/MultipleComparison: + Enabled: true +Style/MutableConstant: + Enabled: true +Style/NegatedIf: + Enabled: true +Style/NegatedUnless: + Enabled: true +Style/NegatedWhile: + Enabled: true +Style/NestedModifier: + Enabled: true +Style/NestedParenthesizedCalls: + Enabled: true +Style/NestedTernaryOperator: + Enabled: true +Style/Next: + Enabled: true +Style/NilComparison: + Enabled: true +Style/NonNilCheck: + Enabled: true +Style/Not: + Enabled: true +Style/NumericLiteralPrefix: + Enabled: true +Style/NumericLiterals: + Enabled: true +Style/NumericPredicate: + Enabled: true +Style/OneLineConditional: + Enabled: true +Style/OptionalArguments: + Enabled: true +Style/OrAssignment: + Enabled: true +Style/ParallelAssignment: + Enabled: true +Style/ParenthesesAroundCondition: + Enabled: true +Style/PercentLiteralDelimiters: + Enabled: true +Style/PercentQLiterals: + Enabled: true +Style/PerlBackrefs: + Enabled: true +Style/PreferredHashMethods: + Enabled: false # suggests MRI Hash methods — mruby-safe to leave off +Style/Proc: + Enabled: true +Style/QuotedSymbols: + Enabled: true +Style/RaiseArgs: + Enabled: true +Style/RandomWithOffset: + Enabled: true +Style/RedundantBegin: + Enabled: true +Style/RedundantConditional: + Enabled: true +Style/RedundantException: + Enabled: true +Style/RedundantFetchBlock: + Enabled: true +Style/RedundantFreeze: + Enabled: true +Style/RedundantInterpolation: + Enabled: true +Style/RedundantParentheses: + Enabled: true +Style/RedundantRegexpCharacterClass: + Enabled: true +Style/RedundantRegexpEscape: + Enabled: true +Style/RedundantReturn: + Enabled: true +Style/RedundantSelf: + Enabled: true +Style/RedundantSelfAssignment: + Enabled: true +Style/RedundantSelfAssignmentBranch: + Enabled: true +Style/RedundantSort: + Enabled: true +Style/RedundantSortBy: + Enabled: true +Style/RedundantStringEscape: + Enabled: true +Style/RegexpLiteral: + Enabled: true +Style/RescueModifier: + Enabled: true +Style/RescueStandardError: + Enabled: true +Style/SafeNavigation: + Enabled: true +Style/Sample: + Enabled: true +Style/SelfAssignment: + Enabled: true +Style/Semicolon: + Enabled: true +Style/SendWithLiteralMethodName: + Enabled: true +Style/SignalException: + Enabled: true +Style/SingleLineMethods: + Enabled: true +Style/SlicingWithRange: + Enabled: true +Style/SoleNestedConditional: + Enabled: true +Style/SpecialGlobalVars: + Enabled: true +Style/StabbyLambdaParentheses: + Enabled: true +Style/StderrPuts: + Enabled: true +Style/StringChars: + Enabled: true +Style/StringConcatenation: + Enabled: true +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes +Style/StringLiteralsInInterpolation: + Enabled: true + EnforcedStyle: double_quotes +Style/StructInheritance: + Enabled: true +Style/SuperArguments: + Enabled: true +Style/SwapValues: + Enabled: true +Style/SymbolArray: + Enabled: true +Style/SymbolLiteral: + Enabled: true +Style/SymbolProc: + Enabled: true +Style/TernaryParentheses: + Enabled: true +Style/TrailingBodyOnClass: + Enabled: true +Style/TrailingBodyOnMethodDefinition: + Enabled: true +Style/TrailingBodyOnModule: + Enabled: true +Style/TrailingCommaInArguments: + Enabled: true +Style/TrailingCommaInArrayLiteral: + Enabled: true +Style/TrailingCommaInHashLiteral: + Enabled: true +Style/TrailingMethodEndStatement: + Enabled: true +Style/TrivialAccessors: + Enabled: true +Style/UnlessElse: + Enabled: true +Style/VariableInterpolation: + Enabled: true +Style/WhenThen: + Enabled: true +Style/WhileUntilDo: + Enabled: true +Style/WhileUntilModifier: + Enabled: true +Style/WordArray: + Enabled: true +Style/YodaCondition: + Enabled: true +Style/ZeroLengthPredicate: + Enabled: true + +# ─── Explicitly DISABLED: "too many lines" / size cops ──────────────────────── +# These complain about methods/blocks/modules/classes being too long. Game code +# + mrbgem sugar intentionally has long methods and large files; these cops +# would generate noise without catching real bugs. DisabledByDefault already +# leaves Metrics off; these are explicit so they never fire even if that flag +# is later flipped. (Note: Metrics/FileLength does not exist in RuboCop 1.88.) + +Metrics/MethodLength: + Enabled: false +Metrics/BlockLength: + Enabled: false +Metrics/ModuleLength: + Enabled: false +Metrics/ClassLength: + Enabled: false + +# Other Metrics cops (complexity, not strictly "lines") — disabled too, since +# they're in the same "your code is too big/complex" family. +Metrics/AbcSize: + Enabled: false +Metrics/CyclomaticComplexity: + Enabled: false +Metrics/PerceivedComplexity: + Enabled: false +Metrics/BlockNesting: + Enabled: false +Metrics/ParameterLists: + Enabled: false +Metrics/CollectionLiteralLength: + Enabled: false diff --git a/.rules/ideas/wasm-web-port.md b/.rules/ideas/wasm-web-port.md deleted file mode 100644 index 6ae5959..0000000 --- a/.rules/ideas/wasm-web-port.md +++ /dev/null @@ -1,60 +0,0 @@ -# WASM / Web Port - -## Overview - -Port the audio engine and study mode logic to WASM, exposing a JavaScript API so a web frontend can drive it. - -Raylib has built-in Emscripten support, so two approaches are possible: - -1. **Full raylib WASM port** — keep the raylib-rendered UI, compile everything to WASM with Emscripten. -2. **Headless WASM module + JS frontend** — strip rendering, export a C API, build a custom HTML/CSS/JS UI. - -## What ports easily - -- **Silence detection** — pure C math on WAV sample data, no platform dependencies. -- **Study mode state machine** — segment navigation, auto-pause logic, padding zones. -- **Raylib rendering** — built-in `emscripten_set_main_loop` support. - -## What needs adaptation - -### File loading -No native drag-and-drop. Options: -- JavaScript `FileReader` API → pass bytes into WASM memory via `EM_ASM` or exported function. -- Fetch from URL → Emscripten's async file fetching. -- Requires ~50-100 lines of JS↔C glue. - -### Main loop -Replace `while (!WindowShouldClose())` with `emscripten_set_main_loop(update_frame, 0, 1)`. Extract the loop body into a single `update_frame()` function. - -### JavaScript API (if using custom web frontend) -Export functions via `EMSCRIPTEN_KEEPALIVE`: -- `load_track(uint8_t *data, int len)` — load audio from buffer -- `play()`, `pause()`, `resume()` -- `seek(float seconds)` -- `get_progress()` — returns current time -- `get_duration()` -- `get_segment_count()` -- `get_current_segment()` -- `get_segments()` — returns silence region data -- `set_study_mode(bool enabled)` -- `is_playing()` - -~10-15 wrapper functions total. - -## Effort estimates - -| Task | Effort | -|------|--------| -| Emscripten build setup (Makefile target, flags) | ~1 day | -| Main loop adaptation (`emscripten_set_main_loop`) | ~2 hours | -| File loading JS↔C bridge | ~half day | -| C API exports for JS | ~half day | -| Full raylib-rendered WASM port (approach 1) | **~2 days total** | -| Custom JS/HTML/CSS frontend (approach 2) | **~4-5 days total** | - -## Notes - -- The core audio logic (silence detection, segment navigation, study mode) requires zero changes. -- Raylib's audio uses miniaudio internally, which has Emscripten support via Web Audio API. -- Consider using Emscripten's `-s ALLOW_MEMORY_GROWTH=1` since audio files can be large. -- File size limit may be a concern — browsers typically handle files up to a few hundred MB. diff --git a/.rules/plan/phase1.md b/.rules/plan/phase1.md deleted file mode 100644 index d1a1a55..0000000 --- a/.rules/plan/phase1.md +++ /dev/null @@ -1,40 +0,0 @@ -# Phase 1 — Audio Playback & Controls - -Core audio functionality. After this phase the app is usable as a keyboard-driven player with no visual progress display. - ---- - -## 1.1 Drag & drop file loading - -- Use `IsFileDropped()` / `LoadDroppedFiles()` / `UnloadDroppedFiles()`. -- Validate file extension is `.mp3` (case-insensitive). -- Unload previous `MusicStream` if one is already loaded. -- `LoadMusicStream()`, `PlayMusicStream()`, set `state.loaded = true`, `state.playing = true`. -- Store duration via `GetMusicTimeLength()`. -- Extract basename from path for `state.filename`. - -## 1.2 Play/pause with rewind - -- `Space` toggles play/pause. -- On pause: capture current position via `GetMusicTimePlayed()`, call `PauseMusicStream()`, then `SeekMusicStream()` back 1 second (clamped to 0). -- On resume: `ResumeMusicStream()`. - -## 1.3 Arrow key seeking - -- `Left Arrow`: seek backward 5 seconds (clamped to 0). -- `Right Arrow`: seek forward 5 seconds (clamped to duration). -- Works in both playing and paused states. - -## 1.4 Music stream update - -- Call `UpdateMusicStream()` every frame when `state.loaded` is true. -- This is required for Raylib's streaming audio to function. - ---- - -## Acceptance criteria - -- Can drag an MP3 onto the window and hear it play immediately. -- Space pauses (with 1s rewind) and resumes. -- Arrow keys seek ±5s with no audible delay. -- Dropping a new file replaces the current one cleanly (no audio glitches). diff --git a/.rules/plan/phase2.md b/.rules/plan/phase2.md deleted file mode 100644 index eeb0eff..0000000 --- a/.rules/plan/phase2.md +++ /dev/null @@ -1,34 +0,0 @@ -# Phase 2 — Progress Bar & Seeking UI - -Visual progress display and mouse-based seeking. - ---- - -## 2.1 Progress bar rendering - -- Horizontal bar centered at ~Y=360, width = 80% of window (1024px), height = 20px. -- Background: dark gray rectangle via `DrawRectangleRec()`. -- Fill: accent color rectangle, width proportional to `currentTime / duration`. -- Only drawn when `state.loaded` is true. - -## 2.2 Time labels - -- Left of bar: current time formatted as `MM:SS` or `HH:MM:SS` (if ≥ 3600s). -- Right of bar: total duration in the same format. -- Helper function: `void format_time(float seconds, char *buf, int bufsize)`. - -## 2.3 Click-to-seek - -- On `IsMouseButtonPressed(MOUSE_BUTTON_LEFT)`, check if click is within progress bar bounding box. -- Compute target time: `(mouseX - barX) / barWidth * duration`. -- Call `SeekMusicStream()` to that position. -- Works in both playing and paused states. - ---- - -## Acceptance criteria - -- Progress bar visually tracks playback position in real time. -- Time labels update every frame and format correctly (MM:SS vs HH:MM:SS). -- Clicking anywhere on the bar seeks to the corresponding position instantly. -- Clicking outside the bar does nothing. diff --git a/.rules/plan/phase3.md b/.rules/plan/phase3.md deleted file mode 100644 index ead88b4..0000000 --- a/.rules/plan/phase3.md +++ /dev/null @@ -1,44 +0,0 @@ -# Phase 3 — Polish & Status Display - -Final UI elements and visual polish. - ---- - -## 3.1 Title text - -- Draw "Study Player" centered near top of window (large font size, ~30px). - -## 3.2 Filename display - -- Below title: show loaded filename, or "Drag an MP3 file here" when nothing is loaded. -- Centered horizontally. - -## 3.3 Playback status - -- Below progress bar: display "PLAYING" or "PAUSED" centered. -- Show nothing when no file is loaded. - -## 3.4 Help text - -- Bottom area of window: "Space: play/pause ←/→: seek 5s Click bar: seek". -- Smaller font, muted color. - -## 3.5 Window title update - -- Call `SetWindowTitle()` to include the filename when a file is loaded (e.g., "Study Player - chapter1.mp3"). -- Reset to "Study Player" if relevant. - -## 3.6 Visual refinements - -- Consistent font sizes and vertical spacing across all text elements. -- Color scheme: dark background (#1a1a2e or similar), light text (#eaeaea), accent color for progress fill (#e94560 or similar). -- Ensure all text is readable and well-positioned at 1280×720. - ---- - -## Acceptance criteria - -- All text elements are visible and properly centered. -- Status reflects actual playback state and updates immediately on play/pause. -- Window title bar shows the current filename. -- The app looks clean and intentional — no overlapping elements, no clipping. diff --git a/.rules/plan/plan.md b/.rules/plan/plan.md deleted file mode 100644 index d8a94c0..0000000 --- a/.rules/plan/plan.md +++ /dev/null @@ -1,138 +0,0 @@ -# Study Player — Implementation Plan - -## Overview - -A single-file C application using Raylib that plays MP3 audiobooks with minimal UI focused on keyboard-driven study workflow. - ---- - -## Build - -- **Build system:** Single `Makefile` at project root with cross-platform support. -- **Linux:** Compile with gcc, link against raylib, `-lm -lpthread -ldl -lrt -lX11`. -- **Windows:** Cross-compile with `x86_64-w64-mingw32-gcc`, link against raylib, `-lgdi32 -lwinmm`. Alternatively native MSVC or MinGW on Windows. -- **Makefile targets:** `make` (Linux default), `make PLATFORM=WINDOWS` (cross-compile for Windows). -- **Dependencies:** `deps/raylib` (already present), `deps/raygui` (already present — optional). -- **Source:** `src/main.c` (single file). -- **Output:** `build/study-player` (Linux), `build/study-player.exe` (Windows). -- **Compile raylib as static library** first (`deps/raylib/src/`), then link. -- **bin/build:** Shell script wrapping `make`. -- **bin/run:** Shell script wrapping `make && ./build/study-player`. - ---- - -## Application State - -```c -typedef struct { - Music music; // Raylib Music stream (MP3) - bool loaded; // Whether a file is loaded - bool playing; // Whether audio is currently playing - char filepath[512]; // Path of loaded file - char filename[256]; // Display name (basename) - float duration; // Total length in seconds -} AppState; -``` - -No persistence. All state is in-memory only. - ---- - -## Window - -- **Size:** 1920×1080, not resizable. -- **Title:** "Study Player" (update to include filename when loaded). -- **Target FPS:** 60. -- **Background:** Dark solid color. - ---- - -## Audio Approach — Instant Seek - -Raylib's `Music` type is a streaming audio type. Key functions: - -- `LoadMusicStream(path)` — load MP3. -- `PlayMusicStream(music)` / `PauseMusicStream(music)` / `ResumeMusicStream(music)`. -- `SeekMusicStream(music, positionInSeconds)` — instant seek. -- `GetMusicTimePlayed(music)` — current position. -- `GetMusicTimeLength(music)` — total duration. -- `UpdateMusicStream(music)` — **must be called every frame** to feed audio buffer. - -These provide instant play/pause/seek with no delay. - ---- - -## UI Layout (1920×1080) - -``` -+--------------------------------------------------+ -| | -| "Study Player" (title) | -| | -| [filename or "Drop an MP3 file"] | -| | -| | -| 03:42 [=======>-----------------] 58:31 | -| ^ progress bar (clickable) | -| | -| PLAYING / PAUSED / (empty) | -| | -| Space: play/pause ←→: seek 5s | -| | -+--------------------------------------------------+ -``` - ---- - -## Scaffolding (before phases) - -Set up before any feature work: - -1. **Makefile** — cross-platform (Linux + Windows cross-compile). Compile raylib as static lib, compile+link `src/main.c`. -2. **bin/build, bin/run** — convenience shell scripts. -3. **src/main.c skeleton** — `InitWindow`, `InitAudioDevice`, empty main loop with `ClearBackground`, `CloseWindow`. Confirms build pipeline works. -4. **build/ in .gitignore**. - ---- - -## Phases - -Implementation is split into three phases, each in its own file: - -- **[Phase 1 — Audio Playback & Controls](phase1.md):** Drag-and-drop loading, play/pause with 1s rewind, arrow key seeking. -- **[Phase 2 — Progress Bar & Seeking UI](phase2.md):** Progress bar rendering, time labels, click-to-seek. -- **[Phase 3 — Polish & Status Display](phase3.md):** Title, filename, status text, help text, window title, visual refinements. - ---- - -## File Structure - -``` -study-player/ -├── .rules/plan/ -│ ├── plan.md # This file (overview + shared context) -│ ├── phase1.md # Audio playback & controls -│ ├── phase2.md # Progress bar & seeking UI -│ └── phase3.md # Polish & status display -├── deps/ -│ ├── raylib/ # Already present -│ └── raygui/ # Already present (optional use) -├── src/ -│ └── main.c # All application code -├── Makefile # Build raylib + application (Linux & Windows) -├── bin/ -│ ├── build # make -│ └── run # make && ./build/study-player -└── build/ # Build artifacts (gitignored) -``` - ---- - -## Constraints - -- MP3 only. -- No playlist / queue — one file at a time. -- No persistence — position lost on close. -- No volume control (system volume is sufficient). -- Window fixed at 1920×1080. -- Must build for Windows (cross-compile from Linux with MinGW, or native MinGW on Windows). @@ -1,86 +1,179 @@ -# AGENTS.md — subagent constitution for C/Raylib project - -> **This is loaded by every agent.** It contains ONLY project-specific, -> non-obvious rules. Never restate what a frontier model already knows about C -> or raylib. - ---- - -## 1. C dialect & build rules - -- **C99** (`-std=c99`). No C11/C17 features. -- **Compile with `-Wall -Wextra`** — zero warnings. If you cannot silence a - warning without reducing correctness, flag it in your report. -- **Raylib is the ONLY external library.** Include `raylib.h` for all platform - APIs (windowing, audio, input, font loading, drawing). Never include glfw, - miniaudio, or stb headers directly. For web: `emscripten.h` is allowed behind - `#ifdef PLATFORM_WEB`. -- **`build/font_data.h`** (generated by `xxd` from `resources/`) is an - **array definition**, not a declaration. Include it in EXACTLY ONE `.c` file - (currently `ui.c`). Guard its use with `#if FONT_EMBEDDED`. Never include it - from a `.h` file or from multiple `.c` files — that causes multiple-definition - link errors. -- **No dynamic allocation** unless paired with an explicit free in the same - module's cleanup. No leaks. -- **No VLAs** (variable-length arrays on the stack). Use fixed-size buffers or - guard with `MAX_*` constants. - -## 2. Module boundaries (THE KEY RULE) - -- **Your `.h` file IS your contract.** It declares every public type, constant, - and function signature that other modules consume. Keep it minimal — - consumers should not see implementation details. -- **Your `.c` file IS your implementation.** It is PRIVATE. Static functions - are module-internal. No other module includes your `.c` file — ever. -- **Include guards:** Every `.h` file starts with `#pragma once`. -- **Self-contained headers:** A `.h` file must `#include` all types it - references (directly or via forward declaration). Consuming agents should - need to include ONLY your `.h`, not hunt for transitive includes. -- **Forward-declare when possible:** `typedef struct PlayerState PlayerState;` - in a header avoids pulling in the full `types.h` when only a pointer is - needed. - -## 3. State management - -- **No global mutable variables.** All shared state lives in `PlayerState` and - is passed by pointer. -- **File-scope statics** are allowed ONLY for module-private data (e.g. cached - fonts, colors, layout constants in the UI module). -- **Const correctness:** Mark pointers `const` when the function does not - mutate the data. Example: `const char *path` in functions that only read. - -## 4. Function naming - -- **Prefix public functions** with the module name or a clear namespace: - - `player_*` for player module - - `study_*` for study module - - `ui_*` for UI module -- **Static helpers** (module-internal) may omit the prefix. -- **Verb-first naming:** `player_load`, `player_seek`, `study_detect_silence`. - -## 5. Testing & verification - -- **Build is the primary test.** If `make` exits 0 with zero warnings, your - module compiles and links correctly. -- **If you add a new `.c` file**, note it in your report. The Makefile uses - `$(wildcard src/*.c)` so new modules are auto-discovered — no `SRCS` edit - needed. The orchestrator owns the Makefile for any structural changes. - -## 6. What you may read from other modules - -- **YES:** The `.h` header files of other modules (their contracts). -- **NO:** The `.c` implementation files of ANY other module. If you think you - need to read a sibling's `.c` to understand its behavior, STOP — that means - the `.h` contract is underspecified. Report this to the orchestrator. - -## 7. Report format - -After completing your work, write `reports/<module>.md` with: -1. **Files touched** (list paths) -2. **What you implemented** (bullet list of functions/changes) -3. **Build result** (`make` output — exit code + any warnings) -4. **Issues or contract gaps** discovered -5. **Any changes needed in other modules** (e.g. "study.h needs a new function - declaration") - -Keep the report concise — the orchestrator reads many of these per wave. +# AGENTS.md — raylib-jamstack + +Ruby (mruby) game stack: **raylib + raymath + RmlUi + flecs + Jolt**, one binary, +desktop (Zig) or web (Emscripten). Game code is Ruby; the bindings are mrbgems. + +## First steps for a new session +1. **Skim `.agents/rules/*`** (7 tiny files) — they prevent expensive mistakes. +2. **Check if the relay is running:** `curl -s http://localhost:8080/game.html | head -1` + — if it fails, start it: `node tools/agent-bridge/server.js` +3. **Check if a browser is connected:** `sh .live/web/bin/eval 'Rl.platform'` + — if it times out, no browser tab is open. Open `http://<hostname>:8080`. +4. **Which game is running?** Check `web/shell.html` → `Module.arguments`. +5. **Read the knowledge doc** for the area you're touching (table below). + +## READ THE TRIBAL KNOWLEDGE FIRST + +This repo has a lot of non-obvious, hard-won knowledge that you CANNOT infer from +the code (toolchain ABI quirks, WSL/Wayland, premultiplied-alpha rendering, flecs +wasm stack, generator naming rules). It lives in layered docs under `.agents/` +(`.claude/` is a symlink to it) — **read them before making changes, not after +something breaks**: + +- **`.agents/rules/`** — short safety reflexes. Skim ALL of them every session; + they prevent the most expensive mistakes (build-breakers, data/ABI traps). +- **`.agents/knowledge/`** — the single per-area tribal doc set (one per area). Each + opens with an "At a glance" orientation header (key files + API/spec pointer + + cross-refs), then the deep "why it broke" detail — so it's both the Plan-Mode brief + and the scar tissue. Read the one(s) for the area you're touching; read all for + cross-cutting changes. +- **`.agents/skills/`** — codified workflows; load the matching `SKILL.md` when you + start that task (adding a binding fn, a flecs system, a demo, build-and-verify, + ruby-to-native migration…). + +| File | Read when | +|------|-----------| +| `.agents/rules/*` | always (7 tiny files) | +| `.agents/skills/<task>/SKILL.md` | doing that recurring task | +| `.agents/knowledge/build-system.md` | any build/link/mruby change | +| `.agents/knowledge/environment.md` | running anything (WSL/Wayland/PATH) | +| `.agents/knowledge/raylib-binding.md` | touching `Rl::` / the generator | +| `.agents/knowledge/rmlui-binding.md` | touching `Rml::` / UI rendering | +| `.agents/knowledge/fx-pipeline.md` | touching `Jamstack::FX` / post-processing shaders | +| `.agents/knowledge/console.md` | touching `Jamstack::Console` / the REPL | +| `.agents/knowledge/agent-bridge.md` | using the eval bridge / `.live/` | +| `.agents/knowledge/hot-reload.md` | reloading flecs systems at runtime | +| `.agents/knowledge/logging.md` | the `Log` ring buffer / structured logs | +| `.agents/knowledge/flecs-binding.md` | touching `Flecs::` / ECS | +| `.agents/knowledge/jolt-binding.md` | touching `Jolt::` / 3D physics | +| `.agents/knowledge/web-target.md` | anything web/wasm | +| `.agents/knowledge/ruby-lsp.md` | Ruby LSP config / `opencode.json` / hover-diagnostics | +| `.agents/knowledge/linting.md` | linting / `bin/lint` / RuboCop / clang-format | +| `.agents/knowledge/steep.md` | the RBS type checker / `tools/check-types.sh` / `Steepfile` (lenient) / the Steep LSP | +| `.agents/knowledge/testing.md` | verifying any change | + +When you discover a new gotcha, **write it down** in the right file — that's how +this harness stays valuable (the article calls these files "crystallized scar +tissue"). Keep entries short and specific to THIS repo; omit generic advice. + +## Architecture (the non-obvious parts) +- Bindings are **mrbgems** under `mrbgems/{raylib,rmlui,flecs,jolt}/` (C/C++ in + `src/`, Ruby sugar in `mrblib/`); all 4 are listed in `build_config.rb` and compiled + into `libmruby.a`, then linked against the native libs by `build.zig` / + `build_web.sh`. `src/main.c` boots mruby and runs `argv[1]` (default + `game/main.rb`). +- raylib bindings are **generated** from `raylib_api.json` by + `mrbgems/raylib/tools/gen_raylib.rb`. Edit the generator, never `raylib_gen.c`. +- The single platform seam is `Rl.while_window_open` (desktop `while` vs web + `emscripten_set_main_loop`); game code is otherwise identical across targets. + +## Commands +- `zig build` / `zig build run` — desktop build (orchestrates everything). +- `./rebuild.sh` — fast incremental: rake `libmruby.a` + zig link. +- `EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh` — web build. +- Run a script: `./zig-out/bin/game path/to.rb`. +- `bin/lint` / `bin/lint --fix` — manual style linting (RuboCop + clang-format + clang-tidy; see `.agents/knowledge/linting.md`). +- `./tools/check-types.sh` — type-check gate: `rbs validate` (sig/*.rbs consistency) + `steep check` (Steep project loads clean, no `:error`). Cheap, build-free; green on correct code. Game-code type typos are `:information` (live in the Steep LSP / `steep check --severity-level=information`), not a gate failure — see `.agents/knowledge/steep.md`. +- **Always** strip `/mnt/c` from PATH first (see `.agents/rules/wsl-toolchain.md`). + +## Rules with teeth (full text in `.agents/rules/`) +- Never hand-edit generated/vendored files (`raylib_gen.c`, `vendor/`, `build/`, + `sig/raylib.rbs`, `docs/AI_REFERENCE.md`). +- `rm -rf vendor/mruby/build` after adding/removing a gem or flipping C/C++ ABI. +- raylib shares `.o` across platforms → `make clean` when switching desktop/web. +- Link order: `libmruby.a` before the native libs; GNU libstdc++ linked directly. +- Don't commit build artifacts or `*.png` screenshots. +- All eval/console/bridge commands run on the main thread (see `.agents/rules/main-thread-eval.md`). + +## Subagents +Scoped subagents live in `.opencode/agent/`. Load them via the task tool: +- **`backend-engineer`** — C/C++ native features, bindings, generators, build + system, and **performance migrations** (Ruby → C when Ruby is too slow). +- **`gameplay-ruby`** — `game/**` Ruby + `mrblib/*.rb` sugar. Uses the live web + bridge to test. Never touches C/C++ or generators. +- **`reviewer`** — read-only; checks against rules + glossary + API specs. + +When Ruby game logic is too slow, follow the **ruby-to-native** skill +(`.agents/skills/ruby-to-native/SKILL.md`): profile → write C → swap call site → +verify via the web bridge. + +## Agentic dev loop (web-first) + +**Web is the primary development target.** The game runs in a browser, served by +the relay. Use the bridge to eval Ruby in the live game from the shell. + +### Changing which game runs in the browser +The web entry script is set in `web/shell.html` → `Module.arguments`. To switch: +```sh +# Edit web/shell.html: arguments: ['game/ragdoll_demo.rb'] +EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh # rebuild (shell.html is baked in) +``` +After rebuilding, restart the relay and refresh the browser. + +### Starting a dev session +```sh +# 1. Build web (first time or after C/C++ changes): +EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh + +# 2. Start the relay (serves the game + creates .live/web/bin/ scripts): +node tools/agent-bridge/server.js +# → open http://<hostname>:8080 in a browser + +# 3. Now bin/eval works (it polls the browser via the relay): +sh .live/web/bin/eval 'Rl.get_fps' +``` +**Note:** `.live/` is gitignored — it's created at runtime by the relay (web) +or by `Jamstack::Live` (desktop with `JAMSTACK_BRIDGE=1`). The `bin/*` scripts +don't exist until the relay starts. `bin/eval` will timeout with "timeout (is +the tab open + connected to the relay?)" if the relay isn't running or no +browser tab is open. + +### Iterating +- **`game/*.rb` edits** → just refresh the browser. No rebuild needed. +- **`mrblib/*.rb` edits** → `./rebuild.sh` then `./build_web.sh`, then refresh. +- **C/C++ edits** → `./rebuild.sh` then `./build_web.sh`, then refresh. + +### Debugging the live game from the shell +```sh +sh .live/web/bin/eval 'Rl.get_fps' # eval any Ruby +sh .live/web/bin/eval 'Rl.platform' # → :web +sh .live/web/bin/tail-log 20 # recent log lines +sh .live/web/bin/snapshot # flecs state.json +sh .live/web/bin/query 'Position' # flecs query +sh .live/web/bin/hot-reload game/systems/move.rb # hot-reload +``` + +### Reaching live game objects +Bridge eval runs in `Jamstack::Bridge`'s context — game locals are NOT +directly accessible. Use `ObjectSpace` to find live objects: +```ruby +c = nil; ObjectSpace.each_object(Jamstack::Console) { |o| c = o } +c.show; c.input["value"] = "player_color = Rl::RED"; c.send(:submit); c.hide +``` + +### In-game console (user-facing) +Press `\` (backslash) in the game to toggle the REPL. It has tab completion, +history, and variable propagation. The console's binding IS the game's binding +— it can read/write local variables directly (unlike the bridge eval). + +### Other live-dev tools +- **Agent bridge** (`JAMSTACK_BRIDGE=1`, desktop): TCP eval — same `bin/*` scripts. +- **`.live/` mount**: `Jamstack::Live` writes state to `.live/<token>/`. +- **Hot-reload**: `Flecs::Hot` reloads ECS systems at runtime. + +## How we work here (plan-first + session hygiene) +- **Plan before code.** Non-trivial work: write a one-paragraph brief → Plan Mode + (review the *plan*, not the code) → execute via the matching skill. Fixing a wrong + plan is cheap; fixing wrong code is not. +- **Stop correction spirals.** Corrected on the same thing twice? Don't go for a + third — clear the session, fold the lesson into the prompt (or a rule/knowledge + doc), and restart. Correction loops poison context; compact long sessions. +- **The metric:** corrections-per-feature trending down — not "one prompt". +- **Hot-reload caveat (Part B):** a correction spiral *while hot-reloading* also + leaves the running game dirty — restart the loop AND reload the game when unsure. + +## Docs +- Canonical vocabulary: `GLOSSARY.md` (use these terms; avoid the listed aliases). +- Full typed API for agents: `docs/AI_REFERENCE.md` (generated; one file). +- Per-library specs: `docs/API_SPEC*.md`. Build design: `docs/BUILD_SYSTEM.md`. +- Human build steps: `BUILDING.md`. diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 0000000..b6daebb --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,182 @@ +# Building + +**Ruby game code → mruby → `Rl::` / `Rml::` / `Flecs::` bindings → raylib window**, +linked by Zig (desktop) or emscripten (web). raylib, RmlUi, flecs, and the web +target are all wired; see `docs/BUILD_SYSTEM.md` for the design. + +## Prerequisites (Linux) + +- Zig (tested with 0.16.0) +- Ruby + `rake` (host Ruby, to build mruby) — `gem install rake` +- A C compiler (gcc/clang) for the mruby + raylib builds +- OpenGL / windowing dev libs + +## Vendored sources + +These are fetched separately (git-ignored). The one-command way: + +```sh +./bin/bootstrap.sh # clones the 6 pinned vendors into vendor/ + applies patches/* +``` + +`bin/bootstrap.sh` is the single source of truth for the pinned versions — it +clones (skipping any present) and applies every patch in `patches/`. For +reference, the pinned versions are: + +```sh +git clone --depth 1 --branch 3.3.0 https://github.com/mruby/mruby vendor/mruby +git clone --depth 1 --branch 6.0 https://github.com/raysan5/raylib vendor/raylib +git clone --depth 1 --branch 6.1 https://github.com/mikke89/RmlUi vendor/rmlui +git clone --depth 1 --branch v4.1.1 https://github.com/SanderMertens/flecs vendor/flecs +git clone --depth 1 https://github.com/amerkoleci/joltc vendor/joltc +git clone --depth 1 --branch v5.5.0 https://github.com/jrouwe/JoltPhysics vendor/JoltPhysics +``` + +### Vendor patches + +Fixes we carry against vendored deps live in `patches/` (since `vendor/` is +git-ignored). `bin/bootstrap.sh` applies them automatically; to apply by hand: + +```sh +git -C vendor/raylib apply "$(pwd)/patches/raylib-6.0-web-cursorhidden.patch" +``` + +See `patches/README.md` for what each patch fixes. (Rebuild raylib from clean +after applying — `make -C vendor/raylib/src clean`.) + +## Build steps + +### 1. Build raylib (static lib) + +```sh +make -C vendor/raylib/src PLATFORM=PLATFORM_DESKTOP RAYLIB_LIBTYPE=STATIC -j4 +``` + +> **WSL / WSLg note:** the default X11 backend segfaults inside Mesa's GLX driver +> (`dri2GalliumConfigQueryb`) on WSLg. Build the **Wayland** backend instead: +> ```sh +> make -C vendor/raylib/src PLATFORM=PLATFORM_DESKTOP RAYLIB_LIBTYPE=STATIC \ +> GLFW_LINUX_ENABLE_WAYLAND=TRUE GLFW_LINUX_ENABLE_X11=FALSE -j4 +> ``` +> `build.zig` currently links the Wayland libs to match. For a normal X11 desktop, +> swap the Wayland `linkSystemLibrary` calls in `build.zig` back to `X11`. + +### 1b. Build RmlUi (static lib) + +```sh +cmake -S vendor/rmlui -B vendor/rmlui/build-static \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \ + -DRMLUI_SAMPLES=OFF -DRMLUI_LUA_BINDINGS=OFF -DRMLUI_FONT_ENGINE=freetype +cmake --build vendor/rmlui/build-static --target rmlui_core -j4 +# -> vendor/rmlui/build-static/librmlui.a +``` + +> Build only the `rmlui_core` target. The `rmlui_debugger` module fails to compile +> with GCC 16 (bundled `robin_hood.h`), and we don't need it. + +### 1c. Build flecs (static lib) + +The single-file amalgamation compiles to one object. `zig build` / `build_web.sh` +do this automatically; manually: + +```sh +mkdir -p build/desktop +cc -c -O2 -std=gnu99 -DNDEBUG -I vendor/flecs/distr vendor/flecs/distr/flecs.c \ + -o build/desktop/flecs.o +ar rcs build/desktop/libflecs.a build/desktop/flecs.o +``` + +### 1d. Build Jolt Physics (static lib) + +`zig build` / `build_web.sh` do this automatically via CMake (joltc with a local +side-by-side JoltPhysics), then merge `libjoltc.a` + `libJolt.a` into one +`build/<target>/libjoltphysics.a`. Manually (desktop): + +```sh +cmake -S vendor/joltc -B vendor/joltc/build-static \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DJPH_BUILD_SHARED=OFF \ + -DJPH_SAMPLES=OFF -DJPH_TESTS=OFF -DJPH_INSTALL=OFF \ + -DINTERPROCEDURAL_OPTIMIZATION=OFF \ + -DDEBUG_RENDERER_IN_DEBUG_AND_RELEASE=OFF -DDEBUG_RENDERER_IN_DISTRIBUTION=OFF \ + -DPROFILER_IN_DEBUG_AND_RELEASE=OFF +cmake --build vendor/joltc/build-static --target joltc -j4 +``` + +> **`-DINTERPROCEDURAL_OPTIMIZATION=OFF` is required.** Jolt defaults to GCC +> `-flto`, whose GIMPLE-bytecode objects zig's **lld cannot link** (every `JPH_*` +> symbol shows "undefined" despite `nm` listing them). See +> `.agents/knowledge/jolt-binding.md`. + +### 2. Build mruby + the `Rl::`/`Rml::`/`Flecs::`/`Jolt::` bindings mrbgems (-> libmruby.a) + +> All three mrbgems (`mrbgems/raylib`, `mrbgems/rmlui`, `mrbgems/flecs`) are listed +> in `build_config.rb`. The rmlui gem is C++, which flips mruby into C++-exception +> ABI mode — if you ever switch gems in/out, `rm -rf vendor/mruby/build` first to +> avoid stale-object "multiple definition" errors. (The flecs gem is plain C and +> only needs `vendor/flecs/distr` on its include path; `libflecs.a` is linked at +> the final step.) + +```sh +cd vendor/mruby +JAMSTACK_ROOT="$(cd ../.. && pwd)" \ +MRUBY_CONFIG="$(cd ../.. && pwd)/build_config.rb" \ +rake "$(cd ../.. && pwd)/vendor/mruby/build/host/lib/libmruby.a" +``` + +> Target the `libmruby.a` file specifically. Running plain `rake` also tries to +> build mruby's own CLI tools (`mruby`, `mirb`, `mrdb`), which fail to link because +> they don't pull in raylib — we don't need them. + +### 3. Build + link the game with Zig + +```sh +zig build # produces zig-out/bin/game +zig build run # build and run (loads game/main.rb) +``` + +## Run + +```sh +./zig-out/bin/game # runs game/main.rb +./zig-out/bin/game some.rb # run a different script (handy for smoke tests) +``` + +## Web build (Emscripten / WASM) + +Requires the Emscripten SDK. Point `EMSDK_ENV` at its `emsdk_env.sh`: + +```sh +EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh +# -> build/web/game.{html,js,wasm,data} +``` + +`build_web.sh` builds raylib (`PLATFORM_WEB`), RmlUi (emscripten + the freetype +port), flecs (the amalgamation, emscripten-aware), and a wasm mruby cross-build +(`MRuby::CrossBuild('web')` in `build_config.rb`), then links them with `emcc` +and preloads `game/`. + +> **flecs on web:** the meta/reflection addon (used for runtime component +> structs) compiles and runs fine under emscripten. The web link uses +> `-sSTACK_SIZE=4MB` because flecs' init/meta needs more than emscripten's 64 KB +> default stack (a too-small stack shows up as a wasm "memory access out of +> bounds" trap). Multithreaded systems are not used (single-threaded `progress`). + +Serve it (browsers won't run `file://` wasm): + +```sh +cd build/web && python3 -m http.server 8000 # then open http://localhost:8000/game.html +``` + +Notes: +- Desktop and web raylib builds share `.o` files in `vendor/raylib/src`, so each + target's lib lives in its own dir (`build/desktop`, `build/web`) and `make clean` + runs when (re)building a target. Switching targets recompiles raylib. +- The `Rl.while_window_open` loop is the platform seam: a `while` on desktop, and + `emscripten_set_main_loop` on web (same game code). See `mrbgems/raylib`. + +## WSL gotchas encountered + +- A Windows Ruby on `/mnt/c/...` shadows the Linux `ruby`/`rake`. Strip `/mnt/c` + entries from `PATH` when building so the Linux toolchain is used. +- `vendor/mruby/minirake` is just `exec "rake", *ARGV` — you need a real `rake` + gem installed for the Linux Ruby. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md
\ No newline at end of file diff --git a/GLOSSARY.md b/GLOSSARY.md index b78b66c..601f258 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -1,54 +1,97 @@ -# GLOSSARY.md — canonical vocabulary - -> **One term per concept. Never coin a synonym silently.** If you think a new -> term is needed, propose it and wait for approval before using it in code. - -## Core concepts - -| Term | Definition | Avoid calling it... | -|------|-----------|-------------------| -| **PlayerState** | The single struct holding all mutable runtime state: loaded audio, playback status, silence regions, study mode flag. Defined in `types.h`. | "app state", "context", "global state" | -| **UIState** | The struct holding UI-only state: fonts, colors, and interaction flags (smartPlayHeld). Defined in `ui.h`. | "ui context", "render state" | -| **UILayout** | Pixel positions for every on-screen element, persisted to `study-player.cfg`. Defined in `types.h`, loaded/saved by the config module. | "layout config", "positions" | -| **speaking portion** | A contiguous segment of audio between two silence regions (i.e. the "meaningful content"). 0-based index. | "section", "segment", "clip", "part" | -| **silence region** | A detected gap in audio where amplitude stays below threshold for ≥ minDuration. Normalized 0–1. | "pause", "gap", "quiet zone" | -| **study mode** | Boolean toggle (`PlayerState.studyMode`). When ON, auto-pauses at silence boundaries. | "auto-pause mode", "learning mode" | -| **seek** | Jump playback position to a specific time (seconds). `player_seek(PlayerState*, float)`. | "scrub", "jump", "skip" | -| **auto-pause** | The study-mode mechanism that pauses playback when entering a silence region. Implemented by `study_auto_pause_check`. | "auto-stop", "silence break" | -| **padding zone** | The 0.25s "breathing room" added around speaking portions (silence shrunk by 0.25s on each side) to avoid auto-pausing during natural speech pauses. | "grace period", "buffer zone" | -| **portion navigation** | Jumping between speaking portions via keys (V/B) or section buttons. | "chapter skip", "section nav" | -| **smart play** | A hold-to-override button that resumes playback and suppresses auto-pause while held. State tracked in `UIState.smartPlayHeld`. | "hold play", "override button" | -| **embedded font** | A `.otf`/`.ttf` font file converted to `build/font_data.h` via `xxd`, compiled into the binary. Guarded by `#if FONT_EMBEDDED`. Included in exactly one `.c` file (`ui.c`). | "baked font", "built-in font" | - -## Module names - -| Module | Files | Owns | -|--------|-------|------| -| **types** | `src/types.h` | `PlayerState`, `SilenceRegion`, `UILayout`, all constants (`SCREEN_W`, `SCREEN_H`, `MAX_*`). Pure header — no `.c`. | -| **player** | `src/player.h`, `src/player.c` | Audio loading, playback control, seek, music-stream update, time formatting | -| **study** | `src/study.h`, `src/study.c` | Silence detection, speaking-portion navigation, auto-pause logic | -| **ui** | `src/ui.h`, `src/ui.c` | Font/color initialization, all rendering, all input handling (player tab), `UIState` | -| **config** | `src/config.h`, `src/config.c` | `UILayout` persistence (load/save to `study-player.cfg`) | -| **layout_editor** | `src/layout_editor.h`, `src/layout_editor.c` | Layout editor tab (drag-to-reposition UI elements), raygui implementation | -| **main** | `src/main.c` | Entry point, main loop, tab switching, drag-drop/web file loading, platform glue | - -## Build terms - -| Term | Meaning | -|------|---------| -| **desktop build** | Host-native build via `gcc`, produces `build/study-player` (Linux) | -| **Windows build** | Cross-compile via `x86_64-w64-mingw32-gcc`, produces `build/study-player.exe` | -| **web build** | WASM via `emcc` + Emscripten, produces `build-web/index.{html,js,wasm}` | -| **font header** | `build/font_data.h` generated by `xxd -i` from a font in `resources/` | - -## Platform defines - -| Define | When set | -|--------|----------| -| `PLATFORM_DESKTOP` | Building for desktop — both Linux native and Windows cross-compile. Set in Makefile. | -| `PLATFORM_LINUX` | Defined by `-DPLATFORM_LINUX` when building for Linux. | -| `PLATFORM_WEB` | Building for web (Emscripten). Set in web build script. | -| `_GLFW_X11` | GLFW X11 backend (Linux). Set in Makefile for Linux builds. | -| `_GLFW_WIN32` | GLFW Windows backend. Set in Makefile for Windows cross-compile. | -| `GRAPHICS_API_OPENGL_ES2` | WebGL 2 backend. Set in web build script. | -| `FONT_EMBEDDED` | Defined in `font_data.h` by `xxd`. Guards `#if FONT_EMBEDDED` blocks. | +# GLOSSARY — raylib-jamstack + +One canonical vocabulary for this repo (harness principle P4). Every doc, skill, +rule, comment, and commit message should use the **Term** column and avoid the +**Aliases to avoid** column — synonym drift is what makes an ECS + multi-binding +codebase confusing to humans and agents alike. + +Terms marked **(planned)** are the agreed vocabulary for Part B of `roadmap.md` +(the agentic runtime); use them when writing that code so the names land +consistent on the first try. Everything else describes code that exists today. + +## ECS (Flecs) + +| Term | Meaning | Aliases to avoid | +|---|---|---| +| **World** | `Flecs::World`; owns all entities/components/systems. One per game (more allowed). | the ECS, registry, scene, container | +| **Entity** | An integer id, wrapped in `Flecs::Entity`. Systems/queries yield the **raw Integer id** for speed; `world.entity_for(id)` wraps it. | object, actor, node, "game object" (that's a higher-level gameplay concept, not the ECS id) | +| **Component** | A real C struct declared at runtime via the meta addon; values (de)serialized to/from a Ruby **Hash**. There is no per-component Ruby class. | struct (ambiguous — see *meta descriptor* and `world.struct`), class, model, data class | +| **tag** | A dataless entity used as an id for `add`/`remove`/`has?`. | flag, marker, label, boolean component | +| **meta descriptor** | The C-struct string passed to `world.struct`, e.g. `"{float x; float y;}"`. | schema, type string, layout string | +| **System** | A Ruby block registered with `world.system(name, with:, phase:)`, run once per matched entity every `progress`. Lowercase "system" means the same thing. | callback (reserve for the C function), update fn, behaviour, script | +| **phase** | When a system runs within one `progress`: `Flecs::ON_LOAD` → `PRE_UPDATE` → `ON_UPDATE` (default) → `ON_START`. | stage (that's flecs *staging*/deferred — different), tick, step, pipeline slot | +| **query** | `world.query(*components)` — cached, `Enumerable`, `|id, *comps|` with writeback. | filter (a flecs filter is the *uncached* variant), search, view | +| **progress** | `world.progress(dt)` — advance one step, run all systems on the calling thread. | tick, step, update (fine colloquially, but the method is `progress`) | +| **writeback** | Mutating a yielded component Hash writes back into component memory after the block returns. | sync, flush, commit | +| **pair / relationship** | flecs relationship `(Relation, Target)`. **Not exposed yet.** | edge, link, parent ref | + +## Bindings & build + +| Term | Meaning | Aliases to avoid | +|---|---|---| +| **binding** | The mruby-exposed API for a native lib: `Rl::`, `Rml::`, `Flecs::`, `Jolt::`. | wrapper, shim, API (too vague) | +| **mrbgem** | A packaging unit under `mrbgems/<name>/` (C/C++ in `src/`, Ruby sugar in `mrblib/`). The four are raylib, rmlui, flecs, jolt. | gem (ambiguous with RubyGems), plugin, module, library | +| **generator** | `mrbgems/raylib/tools/gen_raylib.rb`; emits `raylib_gen.c` from `raylib_api.json`. **Edit the generator, never `raylib_gen.c`.** | codegen, the script, the parser (`rlparser` is a *different* tool) | +| **amalgamation** | flecs' single-file source `vendor/flecs/distr/flecs.{c,h}` compiled to `libflecs.a`. | the flecs source, the bundle | +| **meta addon** | flecs' reflection feature enabling runtime struct declaration (powers Components). | reflection lib, RTTI | +| **gembox** | mruby's `conf.gembox 'default'` set of stock gems (gives us `mruby-eval`/`-socket`/`-io`). | gem set, bundle | +| **presym** | mruby preallocated symbols; we `disable_presym` so new binding method names don't need a regen. | symbol table | +| **libmruby.a** | The archive holding mruby **plus** all four mrbgems' objects; must link **before** the native libs. | the mruby lib (it also contains our bindings) | +| **platform seam** / **the seam** | `Rl.while_window_open` — the ONE place desktop (`until window_should_close?`) and web (`emscripten_set_main_loop`) differ. | main-loop wrapper, game loop (that's the *body* you pass it) | +| **joltc** | Amer Koleci's C wrapper around JoltPhysics that `Jolt::` binds (not JoltPhysics' C++ API directly). | jolt C API, the C++ API | +| **GCC LTO** | GIMPLE-bytecode objects that zig's **lld cannot link** (turn IPO off). Distinct from **LLVM/emcc LTO**, which is fine on web. | LTO (always say which — GCC vs LLVM) | + +## Code boundaries (game vs engine) + +The split is by **location + lifecycle**, NOT by language — there is engine Ruby +too. This boundary governs who-edits-what and how a change takes effect. + +| Term | Meaning | Aliases to avoid | +|---|---|---| +| **game code** | Everything under `game/**` — Ruby scripts **and** `*.rml`/`*.rcss`/assets. Loaded at runtime; **hot-reloadable** (or at worst a **reload**). Where gameplay work lives. | "the Ruby" (mrblib is Ruby too), scripts, content | +| **engine code** | The bindings (`mrbgems/**` — C/C++ **and** `mrblib/*.rb`), `src/`, the build (`build.zig`, `build_config.rb`, `build_web.sh`), `vendor/**`. Compiled into the binary; a change needs a **rebuild**. | "the C code" (it includes mrblib Ruby), the bindings (that's only a subset) | +| **mrblib** | The Ruby **sugar inside a mrbgem** (`mrbgems/*/mrblib/*.rb`). It is **engine code** — compiled into `libmruby.a`, so changing it needs a **rebuild**; it does **not** hot-reload. | game code, runtime Ruby | + +## Targets + +| Term | Meaning | Aliases to avoid | +|---|---|---| +| **desktop** | The Zig-linked native build (`zig build` → `zig-out/bin/game`). | native, host (host = mruby's build name, not the target) | +| **web** / **wasm** | The emscripten build (`build_web.sh` → `build/web/game.{html,js,wasm,data}`). | emscripten target (fine), browser build | + +## Change application (how an edit takes effect) + +Three levels, lightest → heaviest. Use the lightest that actually applies — making +the lighter levels reach more changes is the whole point of the runtime work. +**Don't say "reboot"** — it's ambiguous; say **reload** (no compile) or **rebuild** +(compile). + +| Term | Meaning | Aliases to avoid | +|---|---|---| +| **hot-reload** | Swap **game code** behaviour on the running game — replace a System's Ruby proc, or add systems/components **additively** — with entity/component state **preserved**. No restart, no compile. **(planned — Part B R3.)** | reload, rebuild, reboot, restart | +| **reload** | Restart the **process** (desktop re-exec) / reload the **page** (web): game code re-runs from scratch, **runtime state is lost**, but **nothing is recompiled**. The fallback when a game change isn't hot-reloadable (e.g. a component **layout** change, or a non-idempotent file). | reboot, restart (be specific), hot-reload, rebuild | +| **rebuild** | Recompile + relink the **engine** (`libmruby.a` + the native libs), then reload to pick it up. Required for any **engine code** change (C/C++, the generator, build flags, **mrblib** sugar). | reboot, recompile-only (it's compile+link+reload), hot-reload, reload | + +## Harness layers + +| Term | Meaning | Aliases to avoid | +|---|---|---| +| **rule** | A tiny always-read safety reflex in `.agents/rules/`. | guideline, convention doc | +| **knowledge doc** | The **single** per-area tribal doc in `.agents/knowledge/` (one per area). Opens with an **"At a glance"** orientation header (key files + API/spec pointer + cross-refs), then the deep "why it broke" detail. Read when touching that area; it doubles as the Plan-Mode brief. | feature doc (retired — folded into here), guide, README, wiki page, `docs/API_SPEC*` (that's the full spec) | +| **skill** | A codified, on-demand workflow under `.agents/skills/<name>/SKILL.md`. | macro, recipe, command (a *command* is a different tool concept) | +| **subagent** | A scoped agent (model pin + tool allowlist + brief) used to constrain risky work. | bot, worker, role | +| **the symlink trick** | `.claude → .agents` (and `CLAUDE.md → AGENTS.md`) so the harness is tool-agnostic with one source of truth. | mirror, copy | + +## Agentic runtime (planned — Part B of `roadmap.md`) + +| Term | Meaning | Aliases to avoid | +|---|---|---| +| **command queue** | The frame-polled queue; every agent/console/bridge command is enqueued off-frame and **drained on the main thread** before `world.progress` (P6). | task queue, event loop, message bus, job queue | +| **eval-in** / **`jamstack_eval`** | The C surface that runs queued Ruby on the **persistent** `mrb_state` and returns `{ok,result,stdout,error,backtrace}` JSON. | REPL, exec, run-string | +| **the bridge** | The dev-only eval channel (WS on web / TCP-or-WS on desktop) that the agent and console speak. | the server, the socket, the API | +| **the relay** | The Bun/Node WS hub (`tools/agent-bridge/server.js`) routing eval between the runtime and clients, and maintaining the live mount. | proxy, broker, gateway | +| **runtime** vs **client** | Over the bridge: the running game connects as the **runtime**; agents/CLI/console connect as **clients**. | host/peer, master/slave | +| **the live mount** / **`.live/`** | The read-mostly observation surface: `status.json`, `console`, `state.json`, `.agent/cmd-*/result-*`, `bin/*`. | the state dir, the API dir, the output dir | +| **NDJSON** | Newline-delimited JSON — one JSON object per line — the log/stream format (greppable, tailable). | JSON Lines (use NDJSON here), "log format" | +| **ring buffer** | In-memory last-N log entries, queryable via eval (`Log.tail`, `Log.grep`). | log cache, history buffer | +| **mutate vs observe** | P5: agents **mutate** behaviour by editing Ruby / hot-reloading (write path); they **observe** state via `.live/` + logs (read path). Keep the two paths distinct. | "read/write the game" (be specific which path) | diff --git a/Makefile b/Makefile deleted file mode 100644 index 71a058d..0000000 --- a/Makefile +++ /dev/null @@ -1,127 +0,0 @@ -# Study Player Makefile -# Default target: Linux native (gcc) -# make windows - Cross-compile for Windows (MinGW) -# make clean - Remove build artifacts -# -# Builds raylib from source as a static library, then links the application. - -# Directories -SRC_DIR := src -BUILD_DIR := build -RAYLIB_SRC := deps/raylib/src -RAYLIB_LIB := $(BUILD_DIR)/libraylib.a - -# Application source files (auto-discovered) -SRCS := $(wildcard $(SRC_DIR)/*.c) -APP_OBJS := $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS)) - -# Raylib source files -RAYLIB_SRCS := $(RAYLIB_SRC)/rcore.c $(RAYLIB_SRC)/rshapes.c $(RAYLIB_SRC)/rtextures.c \ - $(RAYLIB_SRC)/rtext.c $(RAYLIB_SRC)/rmodels.c $(RAYLIB_SRC)/raudio.c \ - $(RAYLIB_SRC)/rglfw.c -RAYLIB_OBJS := $(patsubst $(RAYLIB_SRC)/%.c,$(BUILD_DIR)/raylib/%.o,$(RAYLIB_SRCS)) - -# Common include paths -COMMON_INCS := -I$(BUILD_DIR) -I$(RAYLIB_SRC) -I$(RAYLIB_SRC)/external/glfw/include -Ideps/raygui/src - -# --------------------------------------------------------------------------- -# Linux native configuration (default target) -# --------------------------------------------------------------------------- -CC_LINUX := gcc -APP_OUT := $(BUILD_DIR)/study-player -DEFINES_LINUX := -DPLATFORM_DESKTOP -DPLATFORM_LINUX -D_GLFW_X11 -LDFLAGS_LINUX := -lm -lrt -ldl -lpthread -lX11 - -# --------------------------------------------------------------------------- -# Windows cross-compile configuration -# --------------------------------------------------------------------------- -CC_WIN := x86_64-w64-mingw32-gcc -APP_OUT_WIN := $(BUILD_DIR)/study-player.exe -DEFINES_WIN := -DPLATFORM_DESKTOP -D_GLFW_WIN32 -LDFLAGS_WIN := -lgdi32 -lwinmm -lcomdlg32 -lole32 - -# CFLAGS -CFLAGS_COMMON := -std=c99 -O2 $(COMMON_INCS) -CFLAGS_APP := $(CFLAGS_COMMON) -Wall -Wextra -CFLAGS_RAYLIB := $(CFLAGS_COMMON) -w - -# --------------------------------------------------------------------------- -# Font header -# --------------------------------------------------------------------------- -FONT_HEADER := $(BUILD_DIR)/font_data.h - -.PHONY: all windows clean - -# --------------------------------------------------------------------------- -# Default target: Linux native -# --------------------------------------------------------------------------- -all: CC := $(CC_LINUX) -all: AR := ar -all: DEFINES := $(DEFINES_LINUX) -all: LDFLAGS := $(LDFLAGS_LINUX) -all: $(FONT_HEADER) $(APP_OUT) - -# --------------------------------------------------------------------------- -# Windows cross-compile -# --------------------------------------------------------------------------- -windows: CC := $(CC_WIN) -windows: AR := x86_64-w64-mingw32-ar -windows: DEFINES := $(DEFINES_WIN) -windows: LDFLAGS := $(LDFLAGS_WIN) -windows: $(FONT_HEADER) $(APP_OUT_WIN) - -# --------------------------------------------------------------------------- -# Link rules -# --------------------------------------------------------------------------- -# Direct static link of raylib .a (no -lraylib to avoid picking up shared lib) -$(APP_OUT) $(APP_OUT_WIN): $(APP_OBJS) $(RAYLIB_LIB) | $(BUILD_DIR) - $(CC) $(CFLAGS_APP) $(DEFINES) -o $@ $(APP_OBJS) $(RAYLIB_LIB) $(LDFLAGS) - -$(RAYLIB_LIB): $(RAYLIB_OBJS) | $(BUILD_DIR) - $(AR) rcs $@ $^ - -# --------------------------------------------------------------------------- -# Compile rules -# --------------------------------------------------------------------------- -$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c $(FONT_HEADER) | $(BUILD_DIR) - $(CC) $(CFLAGS_APP) $(DEFINES) -c -o $@ $< - -$(BUILD_DIR)/raylib/%.o: $(RAYLIB_SRC)/%.c | $(BUILD_DIR)/raylib - $(CC) $(CFLAGS_RAYLIB) $(DEFINES) -c -o $@ $< - -# --------------------------------------------------------------------------- -# Font header generation (best-effort) -# --------------------------------------------------------------------------- -# Discovers the first .otf/.ttf in resources/ at shell-level to handle -# spaces in filenames. Uses xxd if available; otherwise creates a stub. -$(FONT_HEADER): | $(BUILD_DIR) - @FONT_FILE="$$(find resources -maxdepth 1 -type f \( -iname '*.otf' -o -iname '*.ttf' \) -print -quit 2>/dev/null)"; \ - if command -v xxd >/dev/null 2>&1 && [ -n "$$FONT_FILE" ]; then \ - echo " XXD $$(basename "$$FONT_FILE") -> $@"; \ - xxd -i "$$FONT_FILE" > "[email protected]"; \ - VARNAME=$$(grep -oP 'unsigned char \K[a-zA-Z0-9_]+' "[email protected]" | head -1); \ - sed -i "s/$${VARNAME}/embedded_font_data/g" "[email protected]"; \ - echo '#define FONT_EMBEDDED 1' >> "[email protected]"; \ - mv "[email protected]" "$@"; \ - elif [ -n "$$FONT_FILE" ]; then \ - echo " WARN xxd not available, using stub font header"; \ - printf '/* No font embedded - xxd not available */\nstatic unsigned char embedded_font_data[] = {0};\nstatic unsigned int embedded_font_data_len = 0;\n#define FONT_EMBEDDED 0\n' > "$@"; \ - else \ - echo " INFO No font file found, using default raylib font"; \ - printf '/* No font embedded - no font file */\nstatic unsigned char embedded_font_data[] = {0};\nstatic unsigned int embedded_font_data_len = 0;\n#define FONT_EMBEDDED 0\n' > "$@"; \ - fi - -# --------------------------------------------------------------------------- -# Directory creation -# --------------------------------------------------------------------------- -$(BUILD_DIR): - mkdir -p $(BUILD_DIR) - -$(BUILD_DIR)/raylib: - mkdir -p $(BUILD_DIR)/raylib - -# --------------------------------------------------------------------------- -# Clean -# --------------------------------------------------------------------------- -clean: - rm -rf $(BUILD_DIR) diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md deleted file mode 100644 index 4a41f68..0000000 --- a/ORCHESTRATOR.md +++ /dev/null @@ -1,407 +0,0 @@ -# ORCHESTRATOR.md — how to drive this project - -> **You are the orchestrator.** You do NOT write feature code yourself. You plan, -> summon owner-agents (one per module), verify their work, resolve errors, and keep -> the build green. This file is your complete operating manual. Read it fully -> before acting. Also read: `AGENTS.md` (the subagent constitution — you enforce -> it), `GLOSSARY.md`, `.dispatch/rules/`, `tasks.md` (live progress), and -> `notes/restructure-plan.md` (the full module design + rationale). - ---- - -## 0. Mental model (why this project is built this way) - -This is a **C/Raylib desktop application** built from composable modules. Each -module is a `.h` (contract) + `.c` (implementation) pair. The team structure is -**isomorphic to the module structure**: one owner-agent per module, and agents -communicate only through **header-file contracts** — exactly as the code does. -If an agent needs to read another module's `.c` file to understand its behavior, -the `.h` contract is underspecified — that is a bug, not normal. - -### The harness layers - -- **Constitution** (`AGENTS.md`) — loaded by every agent. C99 rules, raylib - conventions, zero-warning policy. -- **Safety reflexes** (`.dispatch/rules/*.md`) — tiny, crystallized scar tissue. -- **Glossary** (`GLOSSARY.md`) — one canonical name per concept. Prevents - synonym drift across modules. -- **This file** — the orchestrator's workflow (plan → summon → verify → commit). -- **Contracts** — `.h` files are the ONLY interface between modules. They - declare types, constants, and function signatures. `.c` files are private - implementation — never read by anyone but the owning agent. - -### C/Raylib-specific principles - -1. **Contracts are headers** — a `.h` file IS the boundary. It must be - self-contained (all types it uses are included within it). Prefer forward - declarations over pulling in heavy headers. -2. **No cross-module `.c` includes** — ever. If module A needs module B, A - includes `B.h`, never `B.c`. -3. **All shared mutable state goes through `PlayerState*`** (or `UIState*` for - UI-only state) — no global variables. File-scope statics are only for - module-private state (e.g. fonts in the ui module). -4. **One `.o` per module** — each `.c` compiles independently. The linker - resolves dependencies. This is what makes parallel-agent waves possible. -5. **Zero warnings on `-Wall -Wextra`** — the build is the trust signal. If - `make` barks, the wave is not green. -6. **Raylib is the only external dependency** — no pulling in new libraries - without a design decision. -7. **`font_data.h` is a definition, not a declaration** — include it in exactly - ONE `.c` file (currently `ui.c`). Including it from multiple translation - units causes multiple-definition link errors. - ---- - -## 1. The golden workflow (build/modify a feature) - -1. **Plan.** Decide the module(s); split into dependency-topological **waves** of - disjoint modules, and WIDEN each wave where you can (§2a). -2. **Overlap check FIRST.** Before creating anything new, check `GLOSSARY.md` + - existing `*.h` files. If the request *describes* an existing concept under a - new name, steer to the canonical term. New term? Propose the - standard/training-baked name and **ask the user** before adding it to the - glossary. -3. **Boundary decision is the USER's.** "New module vs. extend an existing one?" - — surface it; never decide granularity silently. -4. **Write the prompt** to `prompts/<module>.md` (gitignored). See §3 for the - prompt recipe. -5. **Summon the wave** via `opencode run` (see §2); disjoint modules run in - PARALLEL (§2a). RE-READ `.dispatch/rules/` + the §3 scoping map before each - wave — assemble from the files, not from memory. -6. **Verify** the reports + independently re-run checks (see §4). Trust nothing - until you've re-run `make` yourself and it exits 0 with zero warnings. -7. **Resolve** any contract gaps / errors (see §5). -8. **Commit** the milestone with a clear message. Update `tasks.md`. - ---- - -## 2. Summoning agents via `opencode run` (the harness) - -OpenCode CLI is the summon mechanism. The orchestrator assembles each agent's -prompt by concatenating standardized briefs + scoped rules + the TASK block. - -**Working dir:** always the repo root, `/home/tradam/projects/study-player`. - -**Two agent types:** - -| Agent type | Brief | Reads | Writes | -|---|---|---|---| -| **Module agent** | `.dispatch/package-agent.md` | Only other `.h` files | Own `.h` + `.c` pair | -| **Build system agent** | `.dispatch/build-agent.md` | ANY file | `Makefile`, `bin/*` only | - -**Module agent canonical invocation** — the invariant guardrails live ONCE in -the brief, so `prompts/<module>.md` is JUST the TASK block (§3). Do NOT use -`-f` (see gotcha); ALWAYS redirect output to a file. - -```bash -cd /home/tradam/projects/study-player && \ -opencode run --dir /home/tradam/projects/study-player \ - "$(cat .dispatch/package-agent.md) -$(cat .dispatch/rules/one-owner.md .dispatch/rules/zero-warnings.md .dispatch/rules/contracts-are-h.md) - -## TASK -$(cat prompts/<module>.md)" \ - > reports/<module>.run.log 2>&1 -``` - -**Build system agent canonical invocation:** - -```bash -cd /home/tradam/projects/study-player && \ -opencode run --dir /home/tradam/projects/study-player \ - "$(cat .dispatch/build-agent.md) -$(cat .dispatch/rules/one-owner.md .dispatch/rules/zero-warnings.md) - -## TASK -$(cat prompts/build-system.md)" \ - > reports/build-system.run.log 2>&1 -``` - -**Assembly order is fixed: agent brief → scoped rules → TASK.** - -**Scoping map** — include ONLY the rules matching the agent type: -- **Every module agent:** `one-owner.md`, `zero-warnings.md`, `contracts-are-h.md`. -- **Build system agent:** `one-owner.md`, `zero-warnings.md` (it reads any file - so `contracts-are-h.md` doesn't apply). - -`AGENTS.md` is auto-loaded by opencode — never `cat` it. - -**MANDATORY — capture output to a file, never display it.** The agent's streamed -output is enormous and will overwhelm context if it lands in your terminal. -ALWAYS redirect the summon's stdout+stderr to a log file (e.g. -`> reports/<module>.run.log 2>&1`) and do NOT echo/`cat` that log back. Read -the agent's `reports/<module>.md` report (and, if necessary, `grep`/`tail` the -log for a specific error). Dumping a full run log into context is a hard -failure. - -**Run discipline:** -- **Do NOT background it. Use a large timeout** (e.g. 1800000 ms = 30 min). -- One summon per tool call. For PARALLEL agents on disjoint files, launch - multiple summons as concurrent tool calls — but ONLY when their file sets do - not overlap (single-writer rule). -- Log parallel runs in `tasks.md`. - -**GOTCHAS:** -- `-f/--file` is an ARRAY flag and greedily eats your trailing message as - another filename → "File not found". **Inline with `"$(cat prompts/X.md)"` - instead.** -- A quick smoke test: `opencode run "Reply with exactly SMOKE_OK"` should print - `SMOKE_OK`. -- `opencode agent list` lists agent profiles; `opencode run --help` for flags. - ---- - -## 2a. Parallel execution — WAVES - -Throughput comes from running disjoint modules at once. Organise it as waves: -- **A wave = modules that (a) touch DISJOINT files and (b) have no dependency - on each other's `.c` files** (each includes only already-authored `.h` - contracts). Launch a wave by emitting one summon per module as CONCURRENT tool - calls. The composition root (`main.c`) is almost always the LAST wave. -- **Pre-author the seam to widen the wave.** Because the orchestrator OWNS - contracts (§6), write ALL `.h` contracts FIRST (WAVE 0), then summon the - implementors in the SAME wave against those fixed contracts — no module needs - another's implementation. Authoring the contracts up front turns a sequential - chain into one parallel wave. -- **One writer per file, always** — even across waves. If two units would edit - the same file, they are NOT separable; merge them into one module or sequence - them. -- **After a wave:** read every report, run `make` ONCE for the whole wave, - commit the milestone (update `tasks.md`), then start the next wave. Don't open - a new wave before the prior one is green. - ---- - -## 3. The per-summon `prompts/<module>.md` is JUST the TASK block - -The invariant guardrails — single-writer ownership, visibility, zero warnings, -contract discipline, and the report format — live ONCE in the standardized -briefs the summon concatenates (§2). `prompts/<module>.md` contains ONLY: - -1. **Your module files:** e.g. `src/player.h` and `src/player.c` — name the - FILES the agent may edit (it owns them exclusively). -2. **The job + algorithm**, naming specific functions and their signatures from - the pre-authored `.h` contract. -3. **The specific `.h` contract file(s)** to read (e.g. `src/types.h`, - `src/study.h`) — the agent reads ONLY these headers, never `.c` files. -4. **Any build instructions** (e.g. "run `make` from repo root"). - -Keep it scoped: state only the project-specific, non-inferable task — the briefs -carry the rest. - -**Make agents IMPLEMENT, not deliberate.** A summoned owner must edit files + -run `make` + write its report in one run. If a summon returns only a plan, -re-summon (§5a). - ---- - -## 4. Verification (the orchestrator's trust protocol) - -The orchestrator confirms work from **contracts (.h files) + build output** — -that is the designed trust mechanism. The header files ARE how you trust a -module without depending on its internals. - -**Stay out of implementation files (§6 Visibility).** Your trust signals are the -agent's report, the `.h` contract/surface it exposes, and the `make` output you -re-run yourself — NOT its `.c` implementation. Do NOT open a module's `.c` file -— not even to "skim", double-check, or diagnose a bug. You diagnose from the -`make` output + the `.h` contract + the agent's report, then **summon the owning -agent** (or a temporary multi-knowledge agent, §5) to read its own code and fix -it. - -After every agent, independently: -```bash -cd /home/tradam/projects/study-player -make clean && make -j$(nproc) 2>&1 # must exit 0 with zero warnings -git status --short # confirm agent stayed in its lane -``` - -- **Read ONLY the `.h` files** the unit exposes (its contract), not its `.c` - file. The contract plus a green build is enough to trust a module; subtle - mistakes show up as link errors or undefined symbols, which `make` catches. -- Confirm the agent touched ONLY its assigned files (one-owner rule). - -**Concurrency caveat (parallel waves):** `make` is whole-project, so an agent's -OWN mid-wave check can transiently see a sibling's half-written `.c` file. Don't -act on a report's out-of-module compile errors; YOUR post-wave `make` run is -authoritative. - ---- - -## 5. Resolving errors & contract changes - -- **A module needs something from another module's contract:** that's a CONTRACT - CHANGE. The owner of the `.h` makes it. To find every consumer, grep for - `#include "<header.h>"` across `src/`. Then summon the affected module owners - to update. The orchestrator dispatches this fan-out; agents don't reach - across. -- **Link error or undefined symbol (X and Y each compile but don't link):** no - single file owns it. Summon a **temporary multi-knowledge agent** with - read/write to the 2–3 relevant files (it MAY see `.c` files — exception to - the visibility rule), as their temporary exclusive owner. -- **Multiple-definition link error on `embedded_font_data`:** `font_data.h` is - an `xxd`-generated array definition, not a declaration. It must be included - in exactly ONE `.c` file (currently `ui.c`). If a new module needs the font - data, either route it through `ui.h` functions or move the include to a - single owner and expose `extern` declarations. -- **CR (change-request) in a report:** if it's **build/config** (`Makefile`, - `.gitignore`, `deps/` reference) the orchestrator edits it directly, then - re-verifies with `make`. If it's **implementation** (a `.c` file), the - orchestrator **summons the owning agent** — it does NOT edit `.c` files - itself. -- **Makefile changes:** the Makefile is orchestrator-owned (it's build wiring, - §6). It uses `$(wildcard src/*.c)` so new modules auto-compile. Structural - changes (new targets, new platforms) are orchestrator-owned. - ---- - -## 5a. Agent-failure recovery patterns - -- **Plan-only / "shall I proceed?" agent.** A summon sometimes returns a PLAN - and STOPS without editing (no diff, no `reports/<module>.md`). Detect via - `git status` + the missing report. Re-summon the SAME TASK prefixed: - "IMPLEMENT THIS NOW — make all edits, run `make`, write the report; do not - stop to plan or ask." -- **Agent strayed out of its lane.** `git status --short` after every wave; if - an agent touched a file outside its assigned set, keep it ONLY if it's - legitimately the orchestrator's lane (contracts / Makefile / harness / docs, - §6) — otherwise revert + re-summon with a tighter scope. -- **Flaky green.** A module that compiles once but relies on stale `.o` files - might pass for the wrong reason; always `make clean && make` before - committing. - ---- - -## 6. Restrictions & invariants (NEVER violate) - -- **Single-writer:** never let two agents edit the same file concurrently. -- **Visibility rule:** agents see only other modules' `.h` contracts, NEVER - their `.c` implementation. An agent *needing* to read another module's `.c` - code is a signal that the `.h` contract is underspecified — fix the contract, - don't grant code access. (Exception: the temporary multi-knowledge integration - agent, §5.) -- **The orchestrator NEVER reads or edits `.c` implementation files.** You read - ONLY `.h` files (contracts) + `make` output + agent reports. Do NOT open - `.c` files — not even during a bug. Clean context = level-headed decisions; - the subagents do the implementation. -- **What the orchestrator MAY edit directly:** - (a) **Contracts** — any `.h` header file, especially `types.h` (pure shared - types with no .c file) and other `.h` files when pre-authoring contracts - or resolving gaps. - (b) **Build wiring + config** — `Makefile`, `.gitignore`, `deps/` - structure. (Note: the build system agent also owns `Makefile` and - `bin/*` — coordinate, don't conflict.) - (c) **Harness/docs** — `ORCHESTRATOR.md`, `AGENTS.md`, `GLOSSARY.md`, - `.dispatch/`, `notes/`, `tasks.md`, `prompts/`, `reports/`. - Everything else — all `.c` implementation files — changes ONLY by summoning - the owning agent. -- **Roadblock → surface to the user.** If a needed change doesn't fit the above - (ambiguous ownership, a design question, a stuck agent), stop and ask rather - than reaching into implementation. -- **Subagents inherit this restriction.** Every prompt you write must instruct - the agent to read ONLY the `.h` files of OTHER modules, with the sole - exception that it MAY read the `.c` files of the module it is assigned to. -- **Linux native + Windows cross-compile** — `make` builds for Linux; `make - windows` cross-compiles for Windows via MinGW. Both platforms must work. - Use `#ifdef PLATFORM_LINUX` / `_GLFW_X11` vs `_GLFW_WIN32` guards where - platform differences exist. -- **No global mutable state.** All shared state passes through `PlayerState*` - (or `UIState*` for UI-only state). File-scope statics are for module-private - data only. -- **C99 only.** No C11/C17 features the compiler doesn't support. No C++ - in `.c` files. -- **Raylib is the only external library.** No SDL, no GLFW standalone, no - third-party UI — everything goes through raylib's API. - ---- - -## 7. Repo geography - -``` -/home/tradam/projects/study-player - - AGENTS.md the subagent constitution (auto-loaded by opencode; you enforce it) - ORCHESTRATOR.md the orchestrator's operating manual (this file) - GLOSSARY.md canonical vocabulary + aliases-to-avoid - tasks.md live progress checklist / milestone log - Makefile build — orchestrator-owned, never touched by module agents - README.md project overview, build instructions, usage guide - - .dispatch/ - package-agent.md base owner-agent brief (module agents) - build-agent.md build system agent brief (Makefile, bin/*) - rules/ safety reflexes — tiny crystallized scar tissue - one-owner.md - zero-warnings.md - contracts-are-h.md - - notes/ - restructure-plan.md the full module split design + rationale + wave plan - - prompts/ (gitignored — orchestrator→agent TASK blocks) - reports/ (gitignored — agent→orchestrator reports) - - src/ - types.h CONTRACT — shared types, enums, constants (PlayerState, SilenceRegion, UILayout) - player.h CONTRACT — audio playback: load, seek, play, pause, update, format_time - player.c IMPL - study.h CONTRACT — study mode: detect_silence, portion navigation, auto-pause - study.c IMPL - ui.h CONTRACT — rendering + input: init, destroy, handle_input, render_player, render_empty - ui.c IMPL (owns font_data.h include) - config.h CONTRACT — UILayout persistence: config_load, config_save - config.c IMPL - layout_editor.h CONTRACT — layout editor tab: init, draw - layout_editor.c IMPL (owns raygui implementation) - main.c COMPOSITION ROOT — entry point + main loop + platform glue - - deps/ - raylib/ raylib library (built as static lib) - raygui/ raygui library (single-header, implemented in layout_editor.c) - - bin/ - build build script for desktop (Linux native / Windows cross-compile) - build-web build script for WASM/web - clean clean build artifacts - serve serve web build locally - - resources/ font files, assets (gitignored) - build/ desktop build artifacts (gitignored) - build-web/ web build artifacts (gitignored) - web/ shell.html for emscripten -``` - ---- - -## 8. Current status & how to run - -See `tasks.md` for the live checklist. The module split is **complete** — the -single-file `src/main.c` (778 lines) has been decomposed into 7 modules: -`types.h`, `player`, `study`, `ui`, `config`, `layout_editor`, `main`. - -**Desktop build:** -```bash -cd /home/tradam/projects/study-player -make -j$(nproc) # native Linux build → build/study-player -# or for cross-compile: -make windows -j$(nproc) # Windows cross-compile → build/study-player.exe -``` - -**Web build:** -```bash -bin/build-web # emscripten → build-web/index.html -bin/serve # serve on port 8080 -``` - -**Manual make:** -```bash -make clean && make -j$(nproc) -``` - -**Clean:** -```bash -bin/clean # removes build/ and build-web/ -``` - -The font header generation is a make prerequisite — `build/font_data.h` is -generated by `xxd -i` from the first `.otf`/`.ttf` in `resources/`. @@ -1,133 +1,163 @@ -# Study Player - -A keyboard-driven MP3 player built with [raylib](https://github.com/raysan5/raylib), cross-compiled for Windows from Linux using MinGW. - -<img width="1923" height="1125" alt="image" src="https://github.com/user-attachments/assets/a2e2363e-cc0a-4936-8b04-0f32886ff9d8" /> - -## Architecture - -The project is built from composable modules, each a `.h` contract + `.c` -implementation pair. This separation enables parallel agent development — -modules communicate only through header-file contracts. - -``` -src/types.h shared types: PlayerState, SilenceRegion, UILayout, constants -src/player.{h,c} audio playback: load, play, pause, seek, update, format_time -src/study.{h,c} study mode: silence detection, portion navigation, auto-pause -src/ui.{h,c} rendering + input: fonts, colors, drawing, keyboard/mouse -src/config.{h,c} UI layout persistence (study-player.cfg) -src/layout_editor.{h,c} layout editor tab (drag-to-reposition) -src/main.c composition root: main loop, tab switching, platform glue +# raylib-jamstack + +A stack for building **raylib gamejam games in Ruby**. Three core parts: + +| Part | Role | In Ruby | +|------|------|---------| +| **[raylib](https://github.com/raysan5/raylib)** | graphics / audio / input | `Rl::` | +| **[mruby](https://github.com/mruby/mruby)** | the embedded Ruby that runs your game | — | +| **[RmlUi](https://github.com/mikke89/RmlUi)** | HTML/CSS UI with data binding | `Rml::` | + +Write your game in Ruby; it compiles to a native desktop binary **and** to +WebAssembly for the browser, from one codebase. + +```ruby +Rl.init_window(800, 450, "my game") +Rl.target_fps = 60 + +ui = Rml::Context.new("main") +model = ui.data_model("hud") do |m| + m.bind(:score) { $score } # Ruby state -> {{score}} in the RML + m.event(:reset) { $score = 0 } # <button data-event-click="reset()"> +end +ui.load_document("game/ui/hud.rml").show + +Rl.while_window_open do # desktop: while loop / web: emscripten main loop + ui.process_input + $score += 1 + model.dirty(:score) + Rl.draw(clear_color: Rl::BLACK) do + Rl.draw_text(text: "score #{$score}", x: 10, y: 10, font_size: 20, color: Rl::WHITE) + ui.update + ui.render # UI composited over the game + end +end ``` -See `GLOSSARY.md` for the canonical vocabulary and `ORCHESTRATOR.md` for the -multi-agent development workflow. +## Prerequisites -### Dependencies +Install these first (the bootstrap script fetches dependencies, **not** the toolchain): -Clone the following repositories into the `deps/` directory: +- **Zig** (tested with 0.16.0) — drives the desktop build / link +- **Ruby + `rake`** (host Ruby, builds mruby) — `gem install rake` +- **A C compiler** (gcc or clang) — compiles mruby + raylib +- **OpenGL / windowing dev libs** — for the desktop GL context (on Linux: X11 and/or Wayland) +- **Emscripten SDK** — only for the web build; point `build_web.sh` at it via `EMSDK_ENV=~/emsdk/emsdk_env.sh` -```bash -mkdir -p deps -git clone https://github.com/raysan5/raylib.git deps/raylib -git clone https://github.com/raysan5/raygui.git deps/raygui -``` +> **WSL / Linux note:** strip `/mnt/c` from `PATH` first (the Windows toolchain on +> the PATH breaks native builds). See `BUILDING.md` for OS-specific detail +> (Wayland, package names) and `.agents/rules/wsl-toolchain.md`. -You also need the MinGW cross-compiler and `xxd` installed: +## Quick start -```bash -# Arch -sudo pacman -S mingw-w64-gcc vim # xxd is part of vim -``` +`vendor/` is git-ignored, so a fresh `git clone` ships none of the native deps. +`bin/bootstrap.sh` fetches them — it's the one-command setup (clones the 6 +pinned vendors into `vendor/` + applies every `patches/*.patch`; idempotent, +safe to re-run). Then build — see **[BUILDING.md](BUILDING.md)** for full steps: -### Custom Fonts +```sh +./bin/bootstrap.sh # clones the 6 git-ignored vendored deps + applies patches/* -Place a `.otf` or `.ttf` font file in the `resources/` directory. The build script automatically finds the first font file and embeds it into the executable — no external font files are needed at runtime. +# desktop (raylib + RmlUi + flecs + Jolt + mruby all built and linked by zig) +zig build run # runs game/main.rb (RmlUi data-binding HUD) +./zig-out/bin/game game/physics_demo.rb # 3D Jolt physics demo (SPACE: shoot, R: reset) -```bash -mkdir -p resources -cp /path/to/your/font.otf resources/ +# web (needs the Emscripten SDK) +EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh +cd build/web && python3 -m http.server 8000 # open http://localhost:8000/game.html ``` -If no font file is present, the app falls back to the built-in raylib default font. +## What works + +- **Comprehensive raylib + raymath bindings** — ~650 functions, all 34 structs + (as classes with field accessors + constructors), all enums and color/numeric + defines as constants. Generated from raylib's official `raylib_api.json` (and + `raymath.h`) by `mrbgems/raylib/tools/gen_raylib.rb` (`Is*` -> Ruby predicates, + structs marshal by value, single struct pointers pass inout). Vector/matrix math + included: `Rl.vector2_add`, `Rl.vector2_normalize`, `Rl.matrix_identity`, etc. +- **Comprehensive RmlUi bindings** — `Rml::Context`, `Rml::Document`, + `Rml::Element` (attributes, classes, style properties, queries + `query_selector`/`get_element_by_id`/`elements_by_tag`, traversal, geometry, + `el.on(:click) { |event| ... }`), `Rml::Event`, and the MVC **data model** + (`m.bind`/`m.value`/`m.event`, `model.dirty`). Rendered over the game via an + `rlgl` backend. +- **Flecs (ECS) bindings** — `Flecs::World`, runtime components from a meta + descriptor (`world.struct("Position", "{float x; float y;}")`) (de)serialized + to/from Ruby Hashes, entities/tags (`set`/`get`/`add`/`remove`/`has?`), + cached `world.query(...)`, and `world.system(name, with:) { |id, *comps| ... }` + driven by `world.progress`. Modeled on flecs' Lua binding; meta works on web too. +- **Jolt 3D physics bindings** — `Jolt::World`, reusable `Jolt::Shape` + (box/sphere/capsule/cylinder), `Jolt::Body` (motion types, forces/impulses, + velocities, `set_transform`), and `world.raycast`. Positions/rotations are + `Rl::Vector3`/`Vector4` for direct use in raylib draw calls. Hand-written over + the joltc C API; single-threaded `step` works identically on desktop and web + (~1.1 MB added to the wasm). +- **Web**: the same game cross-compiles to wasm; the `while_window_open` loop + becomes `emscripten_set_main_loop` transparently. +- **One build command per target**: `zig build` orchestrates the desktop build + (raylib `make`, RmlUi `cmake`, mruby `rake`, then link); `build_web.sh` does the + emscripten equivalent. + +## How the Ruby API looks + +The bindings are idiomatic Ruby, not a 1:1 C mirror: `snake_case`, `?` predicates, +`=` setters, block-scoped `Begin/End` pairs, keyword args for many-arg calls, and +C structs as classes. Full contract in **[docs/API_SPEC.md](docs/API_SPEC.md)** +(raylib), **[docs/API_SPEC_RMLUI.md](docs/API_SPEC_RMLUI.md)** (RmlUi), +**[docs/API_SPEC_FLECS.md](docs/API_SPEC_FLECS.md)** (flecs / ECS), and +**[docs/API_SPEC_JOLT.md](docs/API_SPEC_JOLT.md)** (Jolt / 3D physics). + +For a complete listing of every bound call (raylib + raymath + RmlUi + flecs + Jolt) +with typed signatures, all structs / enums / constants, and an explicit list of +*unbound* functions, see **[docs/AI_REFERENCE.md](docs/AI_REFERENCE.md)** — one +self-contained file (handy for feeding to an AI agent), generated from the same +JSON/headers by `mrbgems/raylib/tools/gen_ai_reference.rb`. + +## Layout -### Building - -```bash -# Linux native -make -j$(nproc) - -# Windows cross-compile -make windows -j$(nproc) ``` - -The output binary is `build/study-player` (Linux) or `build/study-player.exe` (Windows). - -Alternatively, use the convenience scripts: - -```bash -bin/build # desktop build (Linux native + Windows cross-compile) -bin/clean # remove build artifacts -bin/build-web # WASM/web build via emscripten -bin/serve # serve web build on port 8080 +AGENTS.md AI-agent constitution (read first; CLAUDE.md is a symlink) +.agents/ AI harness: rules/ (safety reflexes) + knowledge/ (tribal docs) +build.zig desktop build (orchestrates everything) +build_web.sh web build (emscripten) +build_config.rb mruby build (+ web CrossBuild) with our mrbgems +src/main.c host: boots mruby, runs game/main.rb +mrbgems/raylib/ Rl:: bindings (C primitives + mrblib Ruby sugar) +mrbgems/rmlui/ Rml:: bindings (rlgl render backend + data binding, C++) +mrbgems/flecs/ Flecs:: bindings (ECS; runtime meta components, C) +mrbgems/jolt/ Jolt:: bindings (3D physics over the joltc C API) +game/ main.rb, ui/*.rml + *.rcss, assets +web/shell.html browser canvas shell +docs/ API specs + build-system design +vendor/ raylib, mruby, RmlUi, flecs, joltc + JoltPhysics (fetched separately) ``` -### Usage - -Run the binary on Linux (or the `.exe` on Windows / under Wine). Drag an `.mp3` file onto the window to load it. - -| Key | Action | -|---|---| -| **Drag & drop** | Load and play an `.mp3` file | -| **C** | Pause (does nothing if already paused) | -| **N** | Play from start of current section (does nothing if already playing) | -| **Space (hold)** | Resume/override study mode auto-pause (never pauses) | -| **Up Arrow** | Play | -| **Down Arrow** | Pause (rewinds 1s) | -| **Left Arrow** | Seek backward 5 seconds | -| **Right Arrow** | Seek forward 5 seconds | -| **V** | Jump to start of current section (if in padding/silence, jumps to previous) | -| **B** | Jump to start of next section | -| **0–9** | Jump to 0%–90% of the track | -| **Click progress bar** | Seek to position | - -### Study Mode - -Study Mode is designed for audiobooks and language learning. It is **enabled by default** and can be toggled with the checkbox in the bottom-right corner. - -When the audio is loaded, the app analyzes it to detect silence gaps between spoken sections. A section counter (e.g. "5/70") shows which speaking section you're in. - -**How it works:** - -The app detects silence gaps in the audio and adds a 0.25-second padding zone between silence and speech. Each speaking section is separated by these gaps. - -- **Auto-pause at silence entry** — When playback crosses from the padding zone into a silence gap, it automatically pauses and jumps the playhead forward to the start of the next speaking section (just inside the padding). -- **Auto-pause at silence exit** — If audio plays through a silence gap (e.g. via Space override), it auto-pauses when exiting the silence into the next padding/speech zone. -- **C** — Pauses playback at the current position. Does nothing if already paused. -- **N** — Seeks to the start of the current speaking section and resumes playback. Does nothing if already playing. -- **Space (hold)** — Overrides study mode auto-pauses while held. If paused, pressing Space resumes playback. Space never pauses — it only resumes/overrides. -- **V** — Jumps to the start of the current speaking section. If the playhead is in the padding zone or in silence, jumps to the previous section instead. -- **B** — Jumps to the start of the next speaking section. - -Section navigation buttons (◀ ▶) are also available next to the section counter. - -**Visual indicators:** - -- The "PLAYING"/"PAUSED" text and play button turn a darker red during silence sections. -- The section counter below the buttons shows your current position (e.g. "12/70"). - -When Study Mode is **off**, C pauses and N resumes without any section-seeking behavior. Space still only resumes (never pauses). - -### Project Structure - -``` -src/ modular C source (one .h/.c pair per module) -deps/raylib/ raylib (cloned separately) -deps/raygui/ raygui (cloned separately) -build/ Build output (gitignored) -resources/ Font files (gitignored) -bin/ Build and utility scripts -AGENTS.md Subagent constitution (C99 rules, module boundaries) -ORCHESTRATOR.md Multi-agent orchestration workflow -GLOSSARY.md Canonical vocabulary +This repo carries an **AI harness** (see [AGENTS.md](AGENTS.md)): a short +always-loaded constitution plus `.agents/rules/` (tiny safety reflexes) and +`.agents/knowledge/` (per-area "tribal knowledge" — the toolchain ABI quirks, +WSL/Wayland, premultiplied-alpha rendering, flecs wasm stack, etc. that you can't +infer from the code). Read the relevant files before changing the build or +bindings, and add new gotchas as you find them. + +## Status / not yet done + +The raylib, raymath, and RmlUi surfaces are comprehensively bound. The ~72 skipped +functions are the ones needing callbacks, raw data buffers, string/array returns, +or varargs (listed in the generated `raylib_gen.c` header); rlgl is not yet +generated. RmlUi input routing forwards mouse (keyboard/text TODO). These are the +natural next steps. + +Shader uniforms are hand-bound on top of the generated surface: + +```ruby +sh = Rl.load_shader_from_memory(nil, frag_src) # nil vs -> default vertex shader +loc = Rl.get_shader_location(sh, "tint") +Rl.set_shader_value(sh, loc, [1.0, 0.5, 0.2, 1.0], Rl::SHADER_UNIFORM_VEC4) +Rl.set_shader_value(sh, Rl.get_shader_location(sh, "gain"), 2.0, Rl::SHADER_UNIFORM_FLOAT) +# arrays of vectors for the *V form: +Rl.set_shader_value_v(sh, loc2, [[1,0,0],[0,1,0]], Rl::SHADER_UNIFORM_VEC3, 2) ``` + +`set_shader_value` accepts a Numeric or Array (flat or nested) and packs it into +the right C buffer based on the `SHADER_UNIFORM_*` type, raising `ArgumentError` +on component-count mismatch. diff --git a/SHADER_PIPELINE_RESEARCH.md b/SHADER_PIPELINE_RESEARCH.md new file mode 100644 index 0000000..a926fca --- /dev/null +++ b/SHADER_PIPELINE_RESEARCH.md @@ -0,0 +1,722 @@ +# Research: Dynamic Shader Pipeline for raylib-jamstack (web-first) + +**Goal:** a realtime-toggleable post-processing effect chain. Effects can be +turned on/off at runtime. Web (Emscripten/WebGL) is the highest-priority target; +desktop (Zig/OpenGL 3.3) must keep working. + +> ### ⚠ Critical requirements (non-negotiable — read before any design tradeoff) +> 1. **Web is the primary target, not desktop.** The pipeline MUST build and run on +> the web build (`build_web.sh` → `game.html`). A design that only works on desktop +> is rejected. Desktop must keep working too (no regression), but web is the bar. +> 2. **Runtime toggling MUST work on web** — every effect, in **both** the GAME stage +> (`fx.game_shaders`) and the TOP stage (`fx.top_shaders`), toggles on/off at +> runtime on the web build, with zero shader recompilation. Verified vector: the +> eval bridge (`sh .live/web/bin/eval 'fx.game_shaders[0].enabled = false'`), which +> is **browser-verified** on web today (it already returns live `Rl.get_fps`). The +> in-game console (`\`) uses the same main-thread eval path and should toggle too +> (verify in the browser — see Verification plan). +> 3. No C/binding changes that risk the web build. The pipeline is pure Ruby over +> the already-bound shader API; the only web build-system change is the optional +> ES3/WebGL2 upgrade (blocker-analyzed separately, no hard blockers). + +**Status:** research complete, no code written. Recommendation at the bottom. + +--- + +## TL;DR / Recommendation + +1. **Build the pipeline as a layered, two-stage Ruby ping-pong stack** — a GAME + stage (gameplay FX affecting the game world + in-world UI, but NOT the overlay + HUD) and a TOP stage (complete FX affecting everything, including the HUD), + with two RmlUi contexts (in-world UI rendered into the game layer; overlay HUD + composited between the stages). Flow: `game+game-rmlui → game shaders → top-rmlui → + top shaders → screen`. **No C/binding changes needed** — every required function is + already bound (`load_shader_from_memory`, `load_render_texture`, `texture_mode`/ + `shader_mode` blocks, `set_shader_value*`, `get_shader_location`, `draw_texture_pro`, + blend modes). Details in "Recommended architecture (Ruby)". + +2. **Upgrade the web build to WebGL2 (ES3).** You said you're open to it, and it is + the right call: it unlocks HDR/float textures (proper bloom), `#version 300 es` + (cleaner shaders, integer/uint, loops without limits), and matches the desktop + `#version 330` dialect closely — so a **single shader source** can serve both + targets with only the `#version` line differing. Cost is small: recompile raylib + with `GRAPHICS=GRAPHICS_API_OPENGL_ES3` + add `-sMAX_WEBGL_VERSION=2` to the link. + WebGL2 is ~98% of browsers (caniuse), so dropping WebGL1 is low-risk. + +3. **Toggle = skip the pass.** An effect toggled off is simply removed from (or + short-circuited in) the per-frame chain. No shader recompilation, no GPU state + churn beyond an FBO bind swap. Optional: a `lerp`/`mix` uniform lets an effect + *fade* in/out rather than snap. + +--- + +## Blockers analysis: is the WebGL2/ES3 upgrade safe for raylib? + +I dug into the one known scary issue and traced it through the **local vendor +source** (raylib 5.5.0). Short answer: **no hard blockers; the upgrade is safe.** +One important flag pitfall to avoid (already flagged above) and a couple of +soft caveats. + +### The known scary issue: raylib #4330 — RESOLVED, not a blocker for us +[Issue #4330](https://github.com/raysan5/raylib/issues/4330): `glVertexAttribPointer() +error client-side with WebGL 2.0 (OpenGL ES 3.0)`. Reported Sep 2024; people hit +`Cannot set properties of undefined (setting 'clientside')` and, after removing +`FULL_ES*`, `WebGL: INVALID_VALUE: vertexAttribPointer: index out of range`. + +**Root cause (traced in vendor `vendor/raylib/src/rlgl.h`):** the failure is in +`rlDrawRenderBatch()` (6.0: VAO bind ~line 2992, client-array `else` ~line 3090): +```c +if (RLGL.State.ExtSupported.vao) glBindVertexArray(...); // GOOD path +else { /* client-side glVertexAttribPointer, NO bound VAO */ } // BROKEN on WebGL w/o FULL_ES* +``` +On **WebGL1/ES2 without the `GL_OES_vertex_array_object` extension**, raylib falls +to the `else` branch — client-side vertex arrays, which WebGL forbids unless +`-sFULL_ES2=1`/`-sFULL_ES3=1` (the emulation flag) is on. That branch is what +throws. **This is exactly the `FULL_ES*` trap**, not an ES3 bug. + +**Why ES3 is immune:** under `GRAPHICS_API_OPENGL_ES3`, raylib sets +`RLGL.ExtSupported.vao = true` unconditionally (vendor rlgl.h, 6.0 ~line 2438, +"OpenGL ES 3.0 extensions supported by default (or it should be)") — VAO is core +in ES3/WebGL2, no extension needed. So the code **always takes the `glBindVertexArray` +branch**, never the client-array `else`. That is why the maintainer (raysan5) +**could not reproduce** when compiling raylib with `PLATFORM_WEB` + +`GRAPHICS_API_OPENGL_ES_30` and linking `-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2` +(**no** `FULL_ES*`). He closed the issue and committed +`22c77d1 "REVIEWED: WebGL2 (OpenGL ES 3.0) backend flags (PLATFORM_WEB)"` +(Oct 25 2024). That commit predates **raylib 5.5 (Nov 18 2024)** — our vendor +version — so **the fix is present**. [Source: raylib #4330 + release dates] + +### "But ES3 has only 17 code paths vs 118 ES2" — not a problem +Counting `#if defined(...)` guards in the vendor rlgl.h: ES3=17, ES2=118, GL33=131. +Looks thin, but it's the correct superset pattern: +```c +// vendor rlgl.h lines 191-192 (6.0; was 188-191 in 5.5): +// OpenGL ES 3.0 uses OpenGL ES 2.0 functionality (and more) +#if defined(GRAPHICS_API_OPENGL_ES3) + #define GRAPHICS_API_OPENGL_ES2 // <-- ES3 auto-defines ES2 +#endif +``` +So building with `GRAPHICS=GRAPHICS_API_OPENGL_ES3` compiles **all 118 ES2 blocks +PLUS the 17 ES3 enhancements** (float textures, MRT/blit, `<GLES3/gl3.h>`). No +function becomes a no-op. (I checked the scariest one — `rlEnableShader` is gated +`GL33 || ES2`; since ES3→ES2, it compiles fine.) + +**Exact define name is `GRAPHICS_API_OPENGL_ES3`** (vendor Makefile line 260). The +issue #4330 thread references `GRAPHICS_API_OPENGL_ES_30` — that's a typo/shorthand +in the discussion; the real symbol is `GRAPHICS_API_OPENGL_ES3`. Our +`build_web.sh` override must use the latter. + +### RmlUi is NOT a blocker (important — this project uses RmlUi) +The project's RmlUi render interface is `class RaylibRlgl` in +`mrbgems/rmlui/src/rml_bindings.cpp`, implemented **entirely against rlgl** +(`rlBegin(RL_TRIANGLES)` / `rlVertex2f` / `rlColor4ub` / `rlSetTexture` / `rlEnd` — +confirmed in the source). It does **not** use raw GL or its own shaders. Therefore: +- It draws through raylib's **default shader**, which is correctly `#version`'d per + backend (verified: rlgl.h 6.0 lines 5012/5021/5029). +- It goes through `rlDrawRenderBatch` → the **VAO branch** under ES3 (not the + client-array `else` that breaks). + +So RmlUi is fully insulated from the ES2→ES3 switch. No RmlUi-side shader changes, +no separate `#version` handling for the HUD. This also means **the HUD will render +correctly under WebGL2 with no extra work** — it's the post-processing *fragment +shaders we write* that must carry their own `#version 300 es` (see the +"you must supply the `#version` line" section). + +### HDR bloom is genuinely available (bonus confirmation) +Under ES3 the vendor sets `texFloat32 = true` and `texFloat16 = true` +(rlgl.h 6.0 ~lines 2441–2442), and `rlGetGlTextureFormats()` was adapted for ES3 +float formats (per PR #3107 "Continuation of support for ES3/WebGL2"). So +`RGBA16F`/`RGBA32F` render targets work → real HDR bloom (bright-pass can exceed +1.0). This is the main capability win over WebGL1, and it's real, not theoretical. + +### Soft caveats (not blockers; things to watch during verification) +1. **"Has not been widely tested"** — the maintainer said this verbatim about the + ES3 backend in issue #4330 (Sep 2024). It's newer and less exercised than ES2/GL33. + Two `// TODO` markers remain in the vendor (rlgl.h ~line 2411 "Check for + additional OpenGL ES 3.0 supported extensions" and ~line 2424 "Support GLAD + loader for OpenGL ES 3.0") — both about extension-checking/loader plumbing, not + core rendering. Implication: **verify on the browser early** (see verification + plan) rather than assuming desktop behavior carries over. +2. **Shader `#version` still your responsibility** — unchanged by the upgrade. + raylib does not inject `#version` into user fragment shaders (rlgl.h + `rlLoadShaderProgram` ~line 4265, which calls `rlLoadShader` ~4205 to compile your + string as-is). Under WebGL2 you must + write `#version 300 es\nprecision mediump float;\n`. Under desktop `#version 330`. + (The post-pro fragment shaders we author, NOT the default shader RmlUi uses.) +3. **Don't keep `-sFULL_ES2=1`** on the upgraded link line. It's currently in + `build_web.sh` (line 87). With ES3 it's unnecessary and, combined with confusion, + is the exact footgun that broke #4330. Replace with `-sMIN_WEBGL_VERSION=2 + -sMAX_WEBGL_VERSION=2`. (Verify nothing in rmlui/raylib relies on client-side + vertex arrays — unlikely, since the project uses VAO-capable paths, but check + the first web build's console for GL errors.) +4. **Stale `libraylib.a`** — switching ES2→ES3 changes the GL backend, so + `build/web/libraylib.a` is invalid. Delete it and let `build_web.sh` rebuild + (its existing `make clean` guard handles the in-place `.o` collision per repo + rule `.agents/rules/raylib-platform-objs.md`). + +### Verdict +**No hard blockers.** The only way to hit #4330 is to use `-sFULL_ES3=1` (which we +won't) or to run ES2 *without* VAO support *without* `FULL_ES2` (which we're leaving +behind). The correct flag combination — `GRAPHICS=GRAPHICS_API_OPENGL_ES3` raylib + +`-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2` link, no `FULL_ES*` — is the +maintainer-blessed path and is verified present in our vendor (5.5). RmlUi rides +along for free. Recommend proceeding, with an early browser smoke test to honor the +"not widely tested" caveat. + +--- + +## What the codebase looks like today + +- **No shaders, no `RenderTexture`, no post-pro anywhere.** Confirmed by searching + all `game/*.rb` and `mrblib/*.rb`. Every demo renders directly to the screen + inside `Rl.draw(clear_color:) { ... }` (see `game/main.rb`, `game/physics_playground.rb`). +- **raylib 5.5.0** (vendor), built via its Makefile. +- **Desktop GL backend:** `GRAPHICS_API_OPENGL_33` → GLSL `#version 330` + (`vendor/raylib/src/Makefile` line 237: `GRAPHICS ?= GRAPHICS_API_OPENGL_33`). +- **Web GL backend (current):** `GRAPHICS_API_OPENGL_ES2` → GLSL `#version 100` + → **WebGL 1** (`vendor/raylib/src/Makefile` lines 257–260). + The ES3 line is right there, commented out: + ``` + ifeq ($(TARGET_PLATFORM),PLATFORM_WEB) + GRAPHICS = GRAPHICS_API_OPENGL_ES2 + #GRAPHICS = GRAPHICS_API_OPENGL_ES3 # <-- opt-in for WebGL2 + endif + ``` +- **Current web link flags** (`build_web.sh` line 87): `-sFULL_ES2=1` (the ES2 + client-array *emulation* flag, not the WebGL-version selector). No `-sMAX_WEBGL_VERSION`. +- **Render loop seam:** `Rl.while_window_open do ... Rl.draw(clear_color:) { ...game + drawing... ui.update; ui.render } end` (`game/main.rb`). The RmlUi HUD is drawn + *inside* `Rl.draw`, after game content. (See "HUD ordering" below.) + +### The shader API already exposed by the bindings (from `docs/AI_REFERENCE.md`) + +| Need | Ruby call | Notes | +|------|-----------|-------| +| Load fragment shader (default vertex) | `Rl.load_shader_from_memory(nil, fs)` | `nil` vs → raylib's internal default vertex shader (correct `#version` per backend) | +| Load from file | `Rl.load_shader(vs_file, fs_file)` | files preloaded via `--preload-file game@/game` on web | +| Create framebuffer | `Rl.load_render_texture(w, h)` → `Rl::RenderTexture` | | +| Render to texture | `Rl.texture_mode(target) { ... }` | block, `ensure`-safe (mrblib) | +| Apply shader | `Rl.shader_mode(shader) { ... }` | block, `ensure`-safe (mrblib) | +| Uniform location | `Rl.get_shader_location(shader, "name")` → int | | +| Set uniform | `Rl.set_shader_value(shader, loc, value, TYPE)` | `value` = Numeric or Array; packed by `SHADER_UNIFORM_*` | +| Set sampler | `Rl.set_shader_value_texture(shader, loc, texture)` | | +| Set matrix | `Rl.set_shader_value_matrix(shader, loc, mat)` | | +| Draw textured quad | `Rl.draw_texture_pro(texture:, source:, dest:, ...)` | negative `source.height` = y-flip | +| Blend | `Rl.blend_mode(Rl::BLEND_*) { ... }` | `BLEND_ADDITIVE`, `BLEND_ALPHA_PREMULTIPLY`, etc. | +| Constants | `SHADER_UNIFORM_FLOAT/VEC2/...`, `SHADER_LOC_*`, `BLEND_*` | all in `AI_REFERENCE.md` | + +**Bottom line: the entire pipeline can be implemented in Ruby (mrblib sugar + game +code). No generator edit, no C, no `rm -rf vendor/mruby/build`.** + +--- + +## Critical raylib behavior: you must supply the `#version` line + +Verified in the vendor source (`vendor/raylib/src/rlgl.h`, **raylib 6.0**). PR #5631 +renamed the rlgl shader-loading functions in 6.0 (`rlLoadShaderCode`→ +`rlLoadShaderProgram`, `rlCompileShader`→`rlLoadShader`) — the high-level +`raylib.h` API (`LoadShader`/`LoadShaderFromMemory`/`UnloadShader`, which our +bindings use) is **unchanged**. + +- `vsCode == NULL` → `rlLoadShaderProgram()` (6.0, ~line 4265) uses raylib's + **internal default vertex shader**, which is pre-`#version`'d for the active + backend (`#version 330` / `#version 300 es` / `#version 100`; 6.0 lines + 5012 / 5021 / 5029). +- `fsCode != NULL` → `rlLoadShader(fsCode, GL_FRAGMENT_SHADER)` (6.0, ~line 4205) + does `glShaderSource(id,1,&code,NULL); glCompileShader(id)` — compiles **your + string as-is**. **raylib does NOT prepend a `#version` line** to user fragment + shaders. + +**Implication:** every fragment shader source must begin with a `#version` matching +the running backend. The Ruby loader must select the right source per target: + +```ruby +GLSL_VS = Rl.web? ? "#version 300 es\n" : "#version 330\n" # after a WebGL2 upgrade +# (current web would be "#version 100\n") +``` + +This is exactly why the official raylib `shaders_postprocessing.c` example keeps +parallel `glsl100/` and `glsl330/` shader folders and picks one at compile time. + +--- + +## Update: raylib upgraded 5.5 → 6.0 (done) + +The repo is now on **raylib 6.0** (vendor checkout + build fixes verified on both +desktop and web). This *strengthens* the WebGL2/ES3 recommendation above: + +- **The high-level shader API is unchanged.** `LoadShader` / + `LoadShaderFromMemory` / `SetShaderValue`(+`V`,`Matrix`,`Texture`) / + `GetShaderLocation` / `UnloadShader` all survived the 6.0 "REDESIGNED shader + loading API" (#5631) — that refactor was `rlgl`-internal (`rl*` functions) + only. Bindings regenerated clean (671/746 fns bound, 75 unbound). So the + pipeline plan needs no change for 6.0. +- **6.0 ships the WebGL2/ES3 bug fixes we wanted.** The changelog lands: + `[rlgl] REVIEWED: rlActiveDrawBuffers, fix for OpenGL ES 3.0 (#4605)` (MRT on + ES3) and `[rlgl] REVIEWED: rlLoadTextureDepth(), address inconsistencies with + WebGL 2.0 for sized depth formats (#5500)` (depth textures on WebGL2). These + make the ES3/WebGL2 upgrade in the "WebGL1 vs WebGL2" section **more robust** + than it would have been on 5.5. The 5.5 `RLGL_RENDER_TEXTURES_HINT` define is + also gone (FBOs always-on now) — one less thing to set. +- **`GRAPHICS_API_OPENGL_ES3` + `GRAPHICS_API_OPENGL_ES2` superset still holds.** + 6.0's `rlgl.h` still does `#if defined(ES3) #define ES2 #endif`, so defining + `GRAPHICS=GRAPHICS_API_OPENGL_ES3` still compiles all ES2 blocks + the ES3 + extras (float textures, MRT/blit). The PLATFORM_WEB `GRAPHICS` Makefile line is + now `?=` (conditional) — the ES3 override is even cleaner than 5.5's `=`. + +Upgrade scar tissue (what broke + how we fixed it) is in +`.agents/knowledge/web-target.md` ("raylib 6.0 upgrade"). Short version: three +build-code fixes (parser relocated `parser/`→`tools/rlparser/`; `libraylib.a`→ +`libraylib.web.a`; tolerant loader for 6.0's malformed shipped `raylib_api.json`) +plus one vendored patch for a 6.0 web regression (`IsCursorHidden()` stopped +reflecting pointer-lock — broke mouse-look). None of these affect the shader +pipeline; they're build/input-layer only. + +--- + +## The canonical pattern: ping-pong render-to-texture + +From the official raylib `examples/shaders/shaders_postprocessing.c` (single-effect +variant) and the Meatcorps/nCine write-ups (multi-effect stack variant): + +**Single pass (raylib official):** +``` +BeginTextureMode(target); // render scene → RenderTexture + ClearBackground(...); BeginMode3D(cam); <draw scene>; EndMode3D(); +EndTextureMode(); +BeginDrawing(); + BeginShaderMode(shaders[current]); + DrawTextureRec(target.texture, {0,0,w,-h}, {0,0}, WHITE); // NOTE the -h: y-flip + EndShaderMode(); + <draw HUD/text>; +EndDrawing(); +``` + +**Multi-pass stack (toggleable chain) — the architecture we want:** +1. Render the **scene** into `targetA` (`BeginTextureMode(targetA) ... EndTextureMode`). +2. For each **enabled** effect shader `E_i` (in order): + - `BeginTextureMode(targetB)`; `BeginShaderMode(E_i)`; + set E_i's uniforms (resolution, time, intensity, the *previous* texture as + `texture0`, the *original* scene texture if E_i needs it — e.g. bloom composite); + `DrawTextureRec(prev.texture, {0,0,w,-h}, {0,0}, WHITE)`; + `EndShaderMode`; `EndTextureMode`. + - Swap `targetA ↔ targetB`; `prev = targetB`. +3. Draw `prev.texture` to the **screen** (y-flipped) — the final composited image. +4. Draw the HUD (RmlUi) **on top**, un-post-processed. + +Key facts confirmed across sources: +- **Y-flip is mandatory** on every `DrawTextureRec`/`draw_texture_pro` of a + `RenderTexture` (OpenGL bottom-left origin). Use negative `source.height` + (`{0, 0, w, -h}`). [Source: raylib official example + Meatcorps] +- **Two `RenderTexture`s are enough** for any-length chain (ping-pong). Allocate + once; do NOT create/destroy per frame. [Source: Meatcorps `PostProcessingRenderer`] +- **Some effects need the original scene** (not just the current ping-pong + result) — e.g. bloom *composite* blends blurred-bright over the original image. + Meatcorps models this with an `INeedsCurrentViewTexture` interface. In Ruby this + is just "pass the original `scene` texture as a second sampler". [Source: Meatcorps] +- **Resolution:** render targets should match the window/internal resolution. + For a pixel-perfect/retro look, render to a fixed small target (e.g. 640×360) + and upscale — Meatcorps recommends this. [Source: Meatcorps] + +--- + +## WebGL1 vs WebGL2 — the decision + +### Current state (WebGL1 / ES2 / GLSL `#version 100`) +What WebGL1 gives you: the basics. `texture2D`, `varying`/`attribute`, `gl_FragColor`, +no `in`/`out`, limited loop bounds, **no float render targets** (no `EXT_color_buffer_float`; +half-float is patchy), **no MRT** (no deferred shading / multi-output G-buffer), no +3D textures, no transform feedback. raylib's default-webgl target. + +What that means for the pipeline: a toggleable FX chain **works fine** on WebGL1. +Grayscale, scanlines, blur, CRT, fisheye, posterize, sobel — all run on `#version 100` +(raylib ships `glsl100/` versions of all of them). The one notable casualty is +**true HDR bloom**: WebGL1 can't render to a float/half-float buffer, so bright-pass +accumulation clamps to [0,1] — bloom still *looks* okay but can't exceed white. + +### Upgraded state (WebGL2 / ES3 / GLSL `#version 300 es`) +What WebGL2 adds that matters for shaders: +- **Float/half-float render targets** (`RGBA16F`/`RGBA32F` color-buffer-float is core) → + real HDR pipeline, bloom that can exceed 1.0. **This is the main win.** +- **Multiple Render Targets (MRT)** (`glDrawBuffers`, up to 4) → deferred rendering + G-buffer possible. Not needed for a post-pro *stack*, but nice if you ever want + deferred lights. +- **GLSL ES 3.00**: `in`/`out`, `texture()`/`textureGrad()`, integers, uint, uniform + blocks, `flat`/`smooth` interpolation, **loop bounds are not limited** (WebGL1 + requires constant-foldable loop bounds). Syntax is a near-subset of desktop GLSL 330. +- **3D textures**, instancing, transform feedback (compute-via-SSBO still needs + WebGL2 *compute*, which is separate; raylib has a `rlgl_compute` example but it's + GL4.3-only, not web). + +WebGL2 browser support: ~98% globally (caniuse "webgl2"). Dropping WebGL1 is low-risk +in 2026. The main exception is very old mobile Safari (<15) and some legacy enterprise +edge cases. + +### The honest engineering call +- A toggleable post-pro stack is **not blocked** by WebGL1. You can ship it today. +- But since you're open to the upgrade and care about "capable shaders": **WebGL2 is + worth it** for HDR bloom alone, and it makes the desktop/web shader dialect + gap smaller (`300 es` vs `330` differ mainly in the `#version` line + `precision` + qualifier). One near-shared source per effect instead of a `glsl100`↔`glsl330` chasm. + +### Exactly how to upgrade the web build (verified) +Two changes, both in `build_web.sh`: + +**1. Recompile raylib for ES3** — override `GRAPHICS` on the raylib `make` line +(raylib's Makefile uses a simple `=` assignment for `PLATFORM_WEB`, so a make +command-line var overrides it): +```sh +emmake make -C "$ROOT/vendor/raylib/src" PLATFORM=PLATFORM_WEB \ + GRAPHICS=GRAPHICS_API_OPENGL_ES3 \ + RAYLIB_RELEASE_PATH="$ROOT/build/web" +``` +**You MUST `make clean` first** — raylib shares `.o` across platforms +(repo rule `.agents/rules/raylib-platform-objs.md`; the existing `build_web.sh` +already does `make clean` before the first web build). Because the GL backend +changed, the cached `libraylib.a` is invalid: delete `build/web/libraylib.a` +and let it rebuild. + +**2. Link for WebGL2** — replace `-sFULL_ES2=1` with the WebGL-version selectors. +raylib's own `examples/Makefile.Web` uses (for `BUILD_WEB_WEBGL2=TRUE`): +```sh +-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2 +``` +(`-sMAX_WEBGL_VERSION=2` alone = allow WebGL2 but fall back to WebGL1; + both = WebGL2-only, smaller code, no fallback.) + +**DO NOT use `-sFULL_ES3=1`.** That flag enables Emscripten's *client-side array +emulation* for ES3 — an orthogonal feature to the WebGL version. Mixing +`-sFULL_ES3=1` with a raylib that isn't ES3-compiled leaves the GL context +uninitialized at draw time (confirmed by Wavedash's raylib guide). The +WebGL-friendly subset (no `FULL_ES*` flags) is what Emscripten recommends and +what raylib expects. + +### Where this intersects repo rules +- `.agents/rules/raylib-platform-objs.md`: `make clean` between desktop/web already + happens; after switching ES2→ES3 you must also delete the stale `build/web/libraylib.a`. +- `.agents/knowledge/web-target.md`: the `-sFULL_ES2=1` line is documented there as a + current emcc flag — that knowledge doc will need updating post-upgrade. +- Desktop is unaffected: it stays `GRAPHICS_API_OPENGL_33` (`#version 330`). + +--- + +## Recommended architecture (Ruby) — layered, two-stage pipeline + +A small `Jamstack::FX` module in `mrblib/` (pure Ruby, no C) plus a `game/shaders/` +folder of `.fs` sources. + +### The layering model (the key design decision) + +The pipeline is **two shader stages over three render layers**, so you can choose +*per effect* whether it touches only the game world or the whole frame (game + UI): + +``` + ┌─ GAME LAYER ──────────────────────────────────────────────┐ + │ 3D world + in-world RmlUi (3D / CSS-3D panels) │ → RenderTexture G + └───────────────────────────┬──────────────────────────────┘ + ▼ + ◆ GAME SHADERS (gameplay FX — bloom/CRT/scanlines…) ◆ affects game + + ping-pong chain on G ◆ in-world UI, NOT the overlay + ▼ + ┌─ OVERLAY LAYER ───────────────────────────────────────────┐ + │ processed-game quad + overlay RmlUi HUD (screen-space) │ → RenderTexture C + └───────────────────────────┬──────────────────────────────┘ + ▼ + ◆ TOP SHADERS (complete FX — final color grade, vignette, ◆ affects EVERYTHING + ping-pong chain on C film grain, letterbox…) ◆ (game + all UI) + ▼ + screen +``` + +This is exactly the flow requested: `game + game-rmlui → game shader → top-rmlui → top shaders`. +- **Game shaders** = "gameplay" effects that must NOT touch the overlay HUD (e.g. a + bloom you only want on the world; a CRT/scanline effect that would wreck HUD text). + They run on the game layer *before* the overlay is composited. +- **Top shaders** = "complete" effects that affect both game and UI (e.g. a final + color grade, vignette, film grain, or letterbox you want over the whole frame + including HUD). They run *after* the overlay is composited. +- Assign each effect to a stage: `fx.game_shaders << …` vs `fx.top_shaders << …`. + +> Supersedes the earlier simpler "scene → one chain → screen, HUD on top unfiltered" +> sketch. That's now just the degenerate case (no game shaders, no top shaders, HUD in +> the overlay layer). Degenerate cases are handled: no game shaders ⇒ game layer +> composites straight through; no top shaders ⇒ composite blits straight to screen. + +### Render targets & FBO flow (per frame, all allocated once) +Four `RenderTexture`s: `G_a`/`G_b` (game-stage ping-pong) and `C_a`/`C_b` +(composite/top-stage ping-pong). `G_a` needs a depth attachment (3D world uses +depth); `load_render_texture` creates color+depth by default, so that's automatic. + +``` + 1. GAME RENDER → texture_mode(G_a){ clear; begin_mode3d(cam){world} end; + game_ui.update; game_ui.render } # in-world UI INTO G_a + 2. GAME SHADERS → ping-pong enabled game_shaders over G_a↔G_b → G_final + 3. COMPOSITE → texture_mode(C_a){ clear; draw_texture_pro(G_final, y-flip); + top_ui.update; top_ui.render } # overlay HUD INTO C_a + 4. TOP SHADERS → ping-pong enabled top_shaders over C_a↔C_b → C_final + 5. SCREEN BLIT → draw{ draw_texture_pro(C_final, y-flip) } +``` + +Step 3 is the load-bearing one: the overlay HUD is rendered **into the same FBO as +the processed-game quad** (one `texture_mode` block — draw the game quad first, then +the HUD on top, in screen-space 2D) so the top shaders can filter both together. + +### Two RmlUi contexts (in-world UI vs overlay) +RmlUi supports multiple named contexts, so model the two UI layers directly: +```ruby +game_ui = Rml::Context.new("game") # in-world UI → rendered into the GAME layer (G) +top_ui = Rml::Context.new("overlay") # screen-space HUD → rendered into the OVERLAY layer (C) +``` +- **In-world UI (`game_ui`)** is part of the GAME layer, so it catches game shaders. + Two sub-cases the pipeline must allow (the `game_layer` block is just "draw into + `G_a`", so the game code picks): + - *CSS-3D panels* (the existing `physics_playground` style: `transform: perspective() + rotate3d()` on RmlUi elements) — render `game_ui` straight into `G_a` after the 3D pass. + - *True world-space UI* — render `game_ui` to its own offscreen texture, then draw that + texture on a 3D quad inside `begin_mode3d(cam)`. The pipeline doesn't special-case + this; the game code does it inside the `game_layer` block. +- **Overlay UI (`top_ui`)** is screen-space, composited in step 3, catches only top shaders. +- **Input routing** (which context gets mouse/keys) is a game concern, not the pipeline's + — typically route to `top_ui` first (topmost), then `game_ui`. The pipeline only owns + *where each context renders*, not input. + +### API sketch (block idiom, matches the codebase's `Rl.draw { }` / `Rl.texture_mode { }`) +```ruby +module Jamstack + module FX + class Pass # unchanged: name + frag src + enabled/intensity/uniforms + attr_accessor :enabled, :intensity + def initialize(name, frag_src); ...; end # load_shader_from_memory(nil, HDR+frag_src) + def apply(src_tex, dst_tex, t); end # texture_mode(dst){ shader_mode(self){ set uniforms; + # draw_texture_pro(src_tex, y-flip) } } + end + + class Pipeline + attr_reader :game_shaders, :top_shaders + def initialize(w, h) + @w,@h = w,h + @g = [Rl.load_render_texture(w,h), Rl.load_render_texture(w,h)] # game ping-pong + @c = [Rl.load_render_texture(w,h), Rl.load_render_texture(w,h)] # composite ping-pong + @game_shaders, @top_shaders = [], [] + end + def frame(t) + yield Frame.new(self, t) # user fills game_layer{ } + overlay_layer{ } + blit_to_screen # top stage result → screen, y-flipped + end + end + + class Frame # a per-frame builder the block receives + def game_layer # → render 3D world + in-world UI here + Rl.texture_mode(@p.g[0]) { yield } # user draws game+game_rmlui into G_a + @g_final = apply_chain(@p.game_shaders, @p.g) # ping-pong → G_final + # composite G_final into C_a, ready for the overlay: + Rl.begin_texture_mode(@p.c[0]) + Rl.clear_background(Rl::BLACK) + Rl.draw_texture_pro(texture: @g_final.texture, + source: Rl::Rectangle.new(0,0,@p.w,[email protected]), dest: FULLSCREEN, tint: Rl::WHITE) + end + def overlay_layer # → render overlay HUD here (G_final already composited) + yield # top_ui.update; top_ui.render (still inside texture_mode(C_a)) + Rl.end_texture_mode + @c_final = apply_chain(@p.top_shaders, @p.c) # ping-pong → C_final + end + end + + # shared: run enabled passes ping-pong over a [a,b] pair, return the last-written target + def self.apply_chain(passes, pair) + prev = pair[0] + cur = pair[1] + done = passes.select(&:enabled) + return prev if done.empty? # no enabled passes: pass-through (no copy) + done.each do |p| + p.apply(prev.texture, cur, @t) + prev, cur = cur, prev # swap + end + prev # last target written + end + end +end +``` + +Usage in a game: +```ruby +fx = Jamstack::FX::Pipeline.new(720, 720) +fx.game_shaders << Jamstack::FX::Pass.new("bloom", BLOOM_FRAG) # gameplay: game+in-world UI only +fx.game_shaders << Jamstack::FX::Pass.new("crt", CRT_FRAG) +fx.top_shaders << Jamstack::FX::Pass.new("grade", GRADE_FRAG) # complete: over everything +fx.top_shaders << Jamstack::FX::Pass.new("vignette",VIGNETTE_FRAG) + +Rl.while_window_open do + # ... update (route input to top_ui then game_ui) ... + fx.frame(Rl.time) do |f| + f.game_layer do + Rl.clear_background(Rl::BLACK) + Rl.begin_mode3d(cam) { <draw world> } + Rl.end_mode3d + game_ui.update; game_ui.render # in-world UI → into the game layer (catches game FX) + end + f.overlay_layer do + top_ui.update; top_ui.render # overlay HUD → catches only top FX + end + end +end +``` + +(Toggle per effect at runtime: `fx.game_shaders[0].enabled = !…` or `fx.top_shaders[1].enabled` +from the in-game console (`\`) / eval bridge; animate `intensity` 0↔1 for a fade.) + +### Possible extension (not in the requested diagram) +A third, *world-only* stage (shaders that affect the 3D world but NOT the in-world UI — +e.g. blur the world but keep UI text crisp) would need an extra target: render world → +world-shaders → then composite in-world UI on top → game-shaders. Adds one render-texture +pair + one chain. Deliberately NOT in the design above (the requested flow groups +`game+game-rmlui` before `game shader`); add it only if a concrete effect needs it. + +### Shader source strategy (one of) +- **A. Per-target version folders** (`game/shaders/glsl330/`, `glsl300es/`) like + raylib; pick at load time via `Rl.web?`. Most explicit, most duplicated. +- **B. One source + a tiny version shim**: store the *body* (no `#version`/`precision`) + and prepend the right header in Ruby: + ```ruby + HDR = Rl.web? ? "#version 300 es\nprecision mediump float;\n" : "#version 330\n" + shader = Rl.load_shader_from_memory(nil, HDR + BODY) + ``` + After a WebGL2 upgrade, `300 es` and `330` share enough syntax (both `in`/`out`, + `texture()`, integers) that **one BODY** usually serves both. This is the + lowest-maintenance option and pairs naturally with inline shader strings (no + file I/O, trivially hot-reloadable). +- **C. Inline strings** (no files at all) — simplest for a dynamic toggleable system, + works on web without `--preload-file` changes, easy to reload via the bridge. + +**Recommend B + C combined**: keep effect bodies as Ruby heredoc constants, prepend +the version header per target. Keeps the pipeline in one language, no new asset +pipeline. + +### Layer & HUD ordering (important, easy to get wrong) +The layering model above replaces the old "HUD on top, unfiltered" rule. With two +stages you now choose, per effect, where the HUD sits relative to it: +- **In-world UI** renders into the **game layer** (`G_a`, inside `game_layer`), so it + is composited *before* game shaders and catches them. (The existing `physics_playground` + CSS-3D HUD panels become this layer.) +- **Overlay HUD** renders into the **overlay layer** (`C_a`, inside `overlay_layer`), + composited *after* game shaders but *before* top shaders — so it's shielded from + game shaders (text stays crisp through bloom/CRT) but still catches top shaders + (color grade/vignette apply over it). +- The single FBO invariant for compositing: draw the processed-game quad into `C_a` + **first**, then the overlay HUD on top, in the same `texture_mode(C_a)` block — + top shaders can then filter both together. Do NOT render the HUD to the screen + directly (it would bypass the top-shader stage). +- RmlUi renders via rlgl into whatever FBO is bound, so `texture_mode { rmlui.render }` + works in both layers — but the RmlUi **context dimensions must equal the render-texture + size** (viewport/scissor are set from them), and a 3D-world target needs the + depth attachment `load_render_texture` provides by default. + +### Performance notes (web) +- Allocate the four `RenderTexture`s (`G_a/G_b`, `C_a/C_b`) **once**; never in the + loop (FBO creation is expensive and leaks). [Emscripten WebGL best practices: + "prefer multiple immutable/static FBOs"] +- Minimize `glBindFramebuffer` switches — each ping-pong pass is one bind; the + composite is one extra. [Emscripten] +- `SetTextureFilter(target.texture, TEXTURE_FILTER_BILINEAR)` if you upscale a + small internal-res target to a bigger window. +- Render-target format: `RGBA8` for LDR; `RGBA16F` (WebGL2 only) for HDR bloom. +- Each pass is one fullscreen textured quad → one draw call. A 4–6 effect chain is + cheap; the cost is the extra texture samples (blur is the heaviest). +- Cost of the two-stage split vs one chain: one extra fullscreen draw (the composite + quad) + one extra FBO pair. Negligible next to the shader passes themselves. +- `BeginShaderMode`/`EndShaderMode` set shader uniform state each pass — cache + `get_shader_location` results at load time (the `Pass` ctor), don't query per frame. + +--- + +## Verification plan (how to prove it works) — web is the bar +> The critical requirements (top of doc) gate this: the pipeline + runtime +> toggling MUST run on the web build. Verify web explicitly, not just desktop. + +1. **Desktop first** (fastest iteration, `#version 330`): + `./rebuild.sh && ./zig-out/bin/game game/fx_demo.rb`. Proves the layered + two-stage flow + both shader stages render. (Desktop is a sanity proxy; it + does NOT satisfy the web requirement.) +2. **Offscreen PNG** render of a post-pro'd frame (per `.agents/knowledge/testing.md`) + to diff before/after an effect — useful for the GAME vs TOP stage split. +3. **Web build (the critical path):** `EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh`, + serve, open in a browser. Confirm: + - `Rl.platform == :web`, and `#version 300 es` fragment shaders compile (after the + ES3 upgrade) — check the browser console for GLSL errors; raylib logs + "SHADER: Failed to load custom shader code, using default shader" on failure + (`rlgl.h` ~line 4310 in 6.0). + - The GAME stage affects the game world + in-world UI but **not** the overlay HUD; + the TOP stage affects everything. (Visual diff: toggle a game shader — HUD must + stay crisp; toggle a top shader — HUD must change.) +4. **Runtime toggling on web (critical):** with the game running in the browser, flip + effects live via the eval bridge and confirm each change lands next frame: + ```sh + sh .live/web/bin/eval 'fx.game_shaders[0].enabled = false' # game FX off — HUD unaffected + sh .live/web/bin/eval 'fx.top_shaders[1].enabled = true' # top FX on — whole frame incl. HUD + sh .live/web/bin/eval 'fx.game_shaders.map { |p| p.enabled }' # read back state + ``` + The eval bridge is browser-verified on web today (`sh .live/web/bin/eval 'Rl.get_fps'` + returns live fps). Also toggle via the in-game console (`\`) — same main-thread eval + path — and confirm it works in the browser (verify; not yet browser-confirmed). + +--- + +## Source list + +| # | Source | Type | Used for | +|---|--------|------|----------| +| 1 | [raylib `shaders_postprocessing.c` (official example)](https://github.com/raysan5/raylib/blob/master/examples/shaders/shaders_postprocessing.c) | official | canonical single-pass pattern, y-flip, `GLSL_VERSION` per-platform | +| 2 | [raylib `examples/Makefile.Web`](https://github.com/raysan5/raylib/blob/master/examples/Makefile.Web) | official | exact WebGL2 link flags `-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2`, "Requires raylib compiled with GRAPHICS_API_OPENGL_ES3" | +| 3 | [Emscripten: OpenGL support](https://emscripten.org/docs/porting/multimedia_and_graphics/OpenGL-support.html) | official | `-sMAX_WEBGL_VERSION=2` selects WebGL2; `-sFULL_ES3` is client-array *emulation*, orthogonal; WebGL-friendly subset recommended | +| 4 | [Wavedash: raylib](https://docs.wavedash.com/engines/raylib) | third-party guide | "Don't add `-s FULL_ES3=1` — raylib's CMake Web build defaults to OpenGL ES 2, and mixing ES3 client-array emulation with an ES2 library leaves the GL context uninitialized at draw time" | +| 5 | [Meatcorps: Post-processing stack in Raylib-cs](https://docs.meatcorps.nl/raylibcs/postprocessing/) | third-party guide | ping-pong multi-pass stack, `BaseShader`/`PostProcessingRenderer`, y-flip, `INeedsCurrentViewTexture` (original-scene access), fixed-internal-res upscaling | +| 6 | [nCine 14-year presentation](https://encelo.github.io/nCine_14Years_Presentation/) | third-party | "Can be chained together for multi-pass techniques... ping-pong technique" | +| 7 | [shadergif: WebGL2 vs WebGL1 for Shaders (GLSL 3.00)](https://shadergif.com/guides/webgl2-glsl-300-es/) | third-party | GLSL ES 3.00 syntax changes vs 1.00 | +| 8 | [Unity Graphics Emulation docs](https://docs.unity3d.com/550/Documentation/Manual/GraphicsEmulation.html) | official | WebGL1 caps: max 4 render targets, max 16 textures/shader, max tex 4096 | +| 9 | [Emscripten: Optimizing WebGL](https://emscripten.org/docs/optimizing/OptimizingWebGL.html) | official | "use multiple FBOs... switching render targets only requires a single glBindFramebuffer()... avoid mutating FBO state" | +| 10 | vendor `vendor/raylib/src/rlgl.h` (6.0: `rlLoadShaderProgram` ~4265 + `rlLoadShader` ~4205; `rlLoadShaderDefault` ~4995) | local source | raylib does NOT prepend `#version` to user shaders; default vertex shader per backend | +| 11 | vendor `vendor/raylib/src/Makefile` (lines 234–265) | local source | desktop=GL33, web=ES2 (ES3 commented), `GRAPHICS` var overridable | +| 12 | vendor `vendor/raylib/src/rcore.c` (`LoadShader` 1295, `LoadShaderFromMemory` 1314) | local source | NULL vertex → default shader; user fs compiled as-is | + +## Verbatim quotes +- "To target WebGL 2, pass the linker flag `-sMAX_WEBGL_VERSION=2`." — [Emscripten OpenGL support](https://emscripten.org/docs/porting/multimedia_and_graphics/OpenGL-support.html) +- "Don't add `-s FULL_ES3=1` — raylib's CMake Web build defaults to OpenGL ES 2, and mixing ES3 client-array emulation with an ES2 library leaves the GL context uninitialized at draw time." — [Wavedash raylib](https://docs.wavedash.com/engines/raylib) +- "You render your game content into a RenderTexture. You apply shaders to that texture. You ping-pong between render textures so each shader pass can write to a new target. You render the final result to the screen buffer." — [Meatcorps](https://docs.meatcorps.nl/raylibcs/postprocessing/) +- "NOTE: Render texture must be y-flipped due to default OpenGL coordinates (left-bottom) DrawTextureRec(target.texture, (Rectangle){ 0, 0, w, (float)-target.texture.height } ...)" — [raylib shaders_postprocessing.c](https://github.com/raysan5/raylib/blob/master/examples/shaders/shaders_postprocessing.c) +- "# NOTE: Flags required for WebGL 2.0 (OpenGL ES 3.0) # WARNING: Requires raylib compiled with GRAPHICS_API_OPENGL_ES3 ... LDFLAGS += -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2" — [raylib Makefile.Web](https://github.com/raysan5/raylib/blob/master/examples/Makefile.Web) +- "When rendering to offscreen render targets, use multiple FBOs so that switching render targets only requires a single glBindFramebuffer() call... prefer to set up multiple immutable/static FBOs, which do not change state." — [Emscripten Optimizing WebGL](https://emscripten.org/docs/optimizing/OptimizingWebGL.html) + +## Source quality flags +- Meatcorps (#5): a personal blog/tutorial, but technically detailed, code-backed, and + corroborated by the official raylib example (#1) and nCine (#6). Treat the + *pattern* as reliable; the specific C# class names are illustrative only. +- shadergif (#7), Unity (#8): used only for the WebGL1-vs-2 capability diff; both + consistent with the Emscripten/Khronos specs. + +## Confidence: high +The pipeline pattern (ping-pong render-to-texture) is the documented raylib +canonical approach and is corroborated by 3 independent sources. The WebGL2 upgrade +mechanism is confirmed by raylib's own Makefile + Emscripten's official docs + a +third-party warning. The `#version`-not-injected behavior is verified in the local +vendor source. The only soft spot is the exact Ruby swap-aliasing ergonomics in the +sketch, which is an implementation detail to nail down during the build-and-verify +step, not a research gap. + +## Gaps / open questions (to resolve during implementation, not blocking) +- Whether to keep a WebGL1 fallback (`-sMAX_WEBGL_VERSION=2` only, no `MIN`) vs + go WebGL2-only (`-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2`). Recommend + WebGL2-only for simplicity unless a target device list says otherwise. +- Whether the existing `-sFULL_ES2=1` can be fully dropped on upgrade (yes — it's + the client-array emulation; the WebGL-friendly subset doesn't need it; but verify + nothing in rmlui/raylib relies on client-side arrays, which would be unusual). + NOTE (post-6.0): the ES3 path sets `ExtSupported.vao=true` unconditionally, so + the VAO branch is always taken (never the client-array `else` that needs + `FULL_ES*`). Dropping `-sFULL_ES2=1` is safe on ES3. +- Exact shader-source sharing ratio between `300 es` and `330`: most post-pro + fragment shaders (blur, grayscale, CRT) are identical modulo the header; confirm + per-effect during implementation. +- The earlier "ES3 not widely tested" caveat (raylib #4330) is now substantially + mitigated on **6.0**: the MRT (`rlActiveDrawBuffers` #4605) and depth-texture + (`rlLoadTextureDepth` #5500) WebGL2 fixes landed. Still: verify on the browser + early (desktop GL behavior does not always carry over to the ES3 path). + per-effect during implementation. diff --git a/Steepfile b/Steepfile new file mode 100644 index 0000000..7678cda --- /dev/null +++ b/Steepfile @@ -0,0 +1,30 @@ +# Steepfile — type-check the mruby game code (game/**) against the binding RBS +# signatures (sig/*.rbs), so real type errors (wrong arg type/arity to a typed +# Rl::/Rml::/Flecs::/Jolt:: call, calling a nonexistent method) get caught at +# dev time — without the editor noise ruby-lsp would make (ruby-lsp targets MRI +# and is not a type checker; Steep is the RBS type checker). +# +# The dynamic component values (Flecs structs <-> Ruby Hashes) are intentionally +# typed `untyped` in sig/flecs.rbs, so they flow without errors — only real +# mismatches against the typed binding surface are reported. +# +# Run: steep check + +target :game do + # Our binding signatures (raylib.rbs generated; rmlui/flecs/jolt/jamstack.rbs + # hand-written). RBS core (stdlib) is provided automatically by the rbs gem. + signature "sig" + + # Type-check the game Ruby. Game code is un-annotated mruby *scripts* (top-level + # constants/helpers/globals) — like test code. `lenient` downgrades the noise + # that's inherent to un-annotated scripts + mruby's loose numeric tower + # (UnknownConstant, NoMethod on top-level helpers, int-vs-Float, splats) to + # :information/:hint so `steep check` is GREEN on correct code, while REAL + # binding misuse still surfaces (as information/warning, non-failing). The + # dynamic Flecs component values are `untyped` in sig/flecs.rbs, so they never + # error. (default/strict would flood: every script constant/global/helper def is + # an error.) Tighten to D::Ruby.default once game code gains RBS. + configure_code_diagnostics(Steep::Diagnostic::Ruby.lenient) + + check "game" +end diff --git a/bin/bootstrap.sh b/bin/bootstrap.sh new file mode 100755 index 0000000..ff118c4 --- /dev/null +++ b/bin/bootstrap.sh @@ -0,0 +1,102 @@ +#!/bin/sh +# bin/bootstrap — clone the vendored dependencies + apply vendor patches. +# +# `vendor/` is git-ignored, so a fresh `git clone` of this repo has none of the +# native libs the build needs. This script fetches each pinned dependency into +# `vendor/<name>` (skipping ones already present) and applies every patch in +# `patches/` that targets a vendored repo. Run once after cloning this repo: +# +# git clone <this-repo> raylib-jamstack +# cd raylib-jamstack +# ./bin/bootstrap.sh +# zig build # desktop (or: ./build_web.sh for web) +# +# This is the single source of truth for the pinned vendor versions — BUILDING.md +# points here. It does NOT install the toolchain: you still need Zig, Ruby+rake, +# a C compiler, and (for web) the Emscripten SDK — see BUILDING.md "Prerequisites". +# +# Idempotent: safe to re-run. Existing vendor dirs are left untouched; patches +# already applied are skipped. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# <name> <git-url> <branch-or-tag-or-''(for default)> +clone_dep() { + name="$1"; url="$2"; ref="$3" + dest="vendor/$name" + if [ -d "$dest/.git" ]; then + echo " [skip] $dest already present" + else + mkdir -p vendor + if [ -n "$ref" ]; then + echo " [clone] $name @ $ref -> $dest" + git clone --depth 1 --branch "$ref" "$url" "$dest" + else + echo " [clone] $name @ default -> $dest" + git clone --depth 1 "$url" "$dest" + fi + fi +} + +echo "==> cloning vendored dependencies into vendor/ (skipping any present)" +# Pinned versions — keep in sync with BUILDING.md. +clone_dep mruby https://github.com/mruby/mruby 3.3.0 +clone_dep raylib https://github.com/raysan5/raylib 6.0 +clone_dep rmlui https://github.com/mikke89/RmlUi 6.1 +clone_dep flecs https://github.com/SanderMertens/flecs v4.1.1 +clone_dep joltc https://github.com/amerkoleci/joltc "" # no pinned tag (main) +clone_dep JoltPhysics https://github.com/jrouwe/JoltPhysics v5.5.0 +echo + +# Apply every vendor patch in patches/. Each .patch is a `git diff` whose paths +# are relative to the relevant vendor dir (e.g. src/platforms/rcore_web.c is +# relative to vendor/raylib). We detect which vendor a patch targets from its +# first hunk path, and apply from that vendor dir so paths resolve. +echo "==> applying vendor patches from patches/ (skipping any already applied)" +applied=0; skipped=0; failed=0 +for patch in patches/*.patch; do + [ -e "$patch" ] || continue + patch_abs="$(cd "$(dirname "$patch")" && pwd)/$(basename "$patch")" + # Find the vendor the patch targets: first "--- a/<path>" line -> first path segment. + first_path=$(sed -n 's|^--- a/||p' "$patch" | head -1) + if [ -z "$first_path" ]; then + echo " [WARN] $patch: no '--- a/' path found, cannot determine target vendor; skipping" + failed=$((failed + 1)); continue + fi + # The patch path is relative to the vendor root (e.g. src/platforms/...). + # We try each vendor dir until `git apply --check` (forward or reverse) matches. + target="" + for v in vendor/*; do + [ -d "$v/.git" ] || continue + # reverse-check: patch already applied? + if git -C "$v" apply --check --reverse "$patch_abs" >/dev/null 2>&1; then + target="$v" + echo " [skip] $patch — already applied to $target" + skipped=$((skipped + 1)); break + fi + # forward-check: applies cleanly to current tree? + if git -C "$v" apply --check "$patch_abs" >/dev/null 2>&1; then + target="$v" + git -C "$v" apply "$patch_abs" + echo " [apply] $patch -> $target" + applied=$((applied + 1)); break + fi + done + if [ -z "$target" ]; then + echo " [FAIL] $patch: does not apply (forward or reverse) to any vendor/*" + failed=$((failed + 1)) + fi +done +echo " patches: $applied applied, $skipped already-applied, $failed failed" +echo + +if [ "$failed" -ne 0 ]; then + echo "==> bootstrap finished with $failed patch failure(s) — see above" + exit 1 +fi + +echo "==> bootstrap done. Next:" +echo " zig build # desktop build (or: ./zig-out/bin/game game/main.rb)" +echo " EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh # web build" diff --git a/bin/build b/bin/build deleted file mode 100755 index d27d6f6..0000000 --- a/bin/build +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# bin/build — desktop build (Linux native + Windows cross-compile) -# -# Usage: -# bin/build # Linux native build -# bin/build windows # Windows cross-compile -# bin/build clean # clean + build -# -# Any args after the target are forwarded to make. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" - -cd "$PROJECT_DIR" - -TARGET="${1:-all}" -shift || true - -make "$TARGET" -j"$(nproc)" "$@" diff --git a/bin/build-web b/bin/build-web deleted file mode 100755 index 453ee92..0000000 --- a/bin/build-web +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -# bin/build-web — WASM/web build via Emscripten -# -# Produces build-web/index.{html,js,wasm} from src/ + web/shell.html. -# Requires emcc + emar (Emscripten SDK) on PATH. -# -# Usage: -# bin/build-web - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" - -cd "$PROJECT_DIR" - -SRC_DIR="src" -BUILD_DIR="build-web" -RAYLIB_SRC="deps/raylib/src" -RAYLIB_LIB="$BUILD_DIR/libraylib.a" - -COMMON_INCS="-I$BUILD_DIR -I$RAYLIB_SRC -I$RAYLIB_SRC/external/glfw/include -Ideps/raygui/src" -DEFINES="-DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2" -CFLAGS="-std=c99 -O2 $COMMON_INCS $DEFINES" - -mkdir -p "$BUILD_DIR" - -# --- Font header (same logic as Makefile) --- -FONT_HEADER="$BUILD_DIR/font_data.h" -FONT_FILE="$(find resources -maxdepth 1 -type f \( -iname '*.otf' -o -iname '*.ttf' \) -print -quit 2>/dev/null || true)" - -if [ -n "$FONT_FILE" ] && command -v xxd >/dev/null 2>&1; then - echo " XXD $(basename "$FONT_FILE") -> $FONT_HEADER" - xxd -i "$FONT_FILE" > "$FONT_HEADER.tmp" - VARNAME="$(grep -oP 'unsigned char \K[a-zA-Z0-9_]+' "$FONT_HEADER.tmp" | head -1)" - sed -i "s/${VARNAME}/embedded_font_data/g" "$FONT_HEADER.tmp" - echo '#define FONT_EMBEDDED 1' >> "$FONT_HEADER.tmp" - mv "$FONT_HEADER.tmp" "$FONT_HEADER" -else - echo " INFO No font file or xxd unavailable — using default font" - printf '/* No font embedded */\nstatic unsigned char embedded_font_data[] = {0};\nstatic unsigned int embedded_font_data_len = 0;\n#define FONT_EMBEDDED 0\n' > "$FONT_HEADER" -fi - -# --- Build raylib for web --- -RAYLIB_SRCS="$RAYLIB_SRC/rcore.c $RAYLIB_SRC/rshapes.c $RAYLIB_SRC/rtextures.c $RAYLIB_SRC/rtext.c $RAYLIB_SRC/rmodels.c $RAYLIB_SRC/raudio.c" -RAYLIB_OBJS="" -for src in $RAYLIB_SRCS; do - obj="$BUILD_DIR/raylib_$(basename "${src%.c}").o" - echo " CC $src" - emcc $CFLAGS -w -c -o "$obj" "$src" - RAYLIB_OBJS="$RAYLIB_OBJS $obj" -done - -echo " AR $RAYLIB_LIB" -emar rcs "$RAYLIB_LIB" $RAYLIB_OBJS - -# --- Build application --- -APP_SRCS="$(find "$SRC_DIR" -name '*.c')" -APP_OBJS="" -for src in $APP_SRCS; do - obj="$BUILD_DIR/$(basename "${src%.c}").o" - echo " CC $src" - emcc $CFLAGS -Wall -Wextra -c -o "$obj" "$src" - APP_OBJS="$APP_OBJS $obj" -done - -# --- Link --- -SHELL_HTML="web/shell.html" -if [ ! -f "$SHELL_HTML" ]; then - echo "Error: $SHELL_HTML not found" >&2 - exit 1 -fi - -echo " LINK $BUILD_DIR/index.html" -emcc $CFLAGS -o "$BUILD_DIR/index.html" $APP_OBJS "$RAYLIB_LIB" \ - -s USE_GLFW=3 \ - -s WASM=1 \ - -s ASYNCIFY \ - -s SHELL_FILE="$PWD/$SHELL_HTML" \ - -s EXPORTED_FUNCTIONS='["_main","_load_file_web"]' \ - -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]' \ - --preload-file resources \ - "$@" - -echo "Build complete: $BUILD_DIR/index.html" diff --git a/bin/clean b/bin/clean deleted file mode 100755 index 202ee9d..0000000 --- a/bin/clean +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# bin/clean — remove all build artifacts -# -# Usage: -# bin/clean - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" - -cd "$PROJECT_DIR" - -rm -rf build build-web -echo "Cleaned build/ and build-web/" diff --git a/bin/lint b/bin/lint new file mode 100755 index 0000000..1ac4f6a --- /dev/null +++ b/bin/lint @@ -0,0 +1,132 @@ +#!/bin/sh +# bin/lint — manual linting for raylib-jamstack (Ruby + C/C++). +# +# Runs RuboCop (Ruby), clang-format (C/C++ formatting), and clang-tidy +# (C/C++ static analysis) on the project's hand-written sources. NOT automatic +# — run it deliberately, at the end of an implementation pass. +# +# Usage: +# bin/lint # report only (exit 1 if any issues) +# bin/lint --fix # safe autocorrect: RuboCop -a + clang-format -i +# # (clang-tidy is NEVER auto-fixed — it only reports) +# bin/lint --ruby # Ruby only (RuboCop) +# bin/lint --c # C/C++ only (clang-format + clang-tidy) +# +# What this checks: +# Ruby (.rb): RuboCop — style/layout/lint (.rubocop.yml: DisabledByDefault, +# opt-in to Layout + Lint + safe Style; excludes Metrics/length +# cops; TargetRubyVersion 3.4 for mruby endless-method support) +# C/C++: clang-format — formatting consistency (.clang-format: Allman, +# 2-space, 100-col, return-type-on-own-line) +# clang-tidy — bug-finding checks (bugprone-*, cert-*, +# clang-analyzer-*), scoped to hand-written files only +# +# What this does NOT check (handled elsewhere): +# - Syntax errors: caught at build time by the mruby-compiler gem (rake fails) +# - Type errors: Steep per-edit (RBS type checker, see opencode.json) +# +# Excludes generated/vendored code: +# - mrbgems/raylib/src/raylib_gen.c (generated by tools/gen_raylib.rb) +# - vendor/** (vendored mruby/raylib/rmlui/flecs/joltc) +# - build/**, zig-out/**, .cache/**, .live/** (build/runtime artifacts) +# +# See .agents/knowledge/linting.md for the full rationale. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# --- PATH setup (mirror rebuild.sh: strip /mnt/c, add gem bin dir) --- +CLEANPATH=$(echo "$PATH" | tr ':' '\n' | grep -v '^/mnt/c' | paste -sd:) +export PATH="/home/tradam/.local/share/gem/ruby/3.4.0/bin:$CLEANPATH" + +CLANG_FORMAT="/usr/lib/llvm21/bin/clang-format" +CLANG_TIDY="/usr/lib/llvm21/bin/clang-tidy" + +# Hand-written C/C++ sources (NOT raylib_gen.c which is generated, NOT vendor/). +C_SOURCES="src/main.c mrbgems/raylib/src/raylib_bindings.c mrbgems/flecs/src/flecs_bindings.c mrbgems/jolt/src/jolt_bindings.c mrbgems/rmlui/src/rml_bindings.cpp" + +FIX=0 +RUN_RUBY=1 +RUN_C=1 + +for arg in "$@"; do + case "$arg" in + --fix) FIX=1 ;; + --ruby) RUN_C=0 ;; + --c) RUN_RUBY=0 ;; + -h|--help) + sed -n '2,30p' "$0" + exit 0 + ;; + *) + echo "unknown flag: $arg (try --help)" >&2 + exit 2 + ;; + esac +done + +EXIT_CODE=0 + +# ─── Ruby: RuboCop ─────────────────────────────────────────────────────────── +if [ "$RUN_RUBY" = 1 ]; then + echo "── RuboCop (Ruby) ──────────────────────────────────────────────────────" + if [ "$FIX" = 1 ]; then + echo " running with safe autocorrect (-a)..." + rubocop -a || EXIT_CODE=1 + else + rubocop || EXIT_CODE=1 + fi + echo "" +fi + +# ─── C/C++: clang-format ───────────────────────────────────────────────────── +if [ "$RUN_C" = 1 ]; then + echo "── clang-format (C/C++ formatting) ──────────────────────────────────────" + if [ ! -x "$CLANG_FORMAT" ]; then + echo " SKIP: $CLANG_FORMAT not found (install llvm/clang-tools)" >&2 + elif [ "$FIX" = 1 ]; then + echo " rewriting in place (-i)..." + "$CLANG_FORMAT" -i $C_SOURCES + echo " done (clang-format applied)" + else + echo " dry-run (reporting violations)..." + # --dry-run --Werror makes it exit non-zero if ANY file needs formatting. + if ! "$CLANG_FORMAT" --dry-run --Werror $C_SOURCES 2>&1; then + EXIT_CODE=1 + echo " (run 'bin/lint --fix' to auto-format)" + else + echo " all C/C++ files are clang-format clean" + fi + fi + echo "" +fi + +# ─── C/C++: clang-tidy (report only — NEVER auto-fix) ─────────────────────── +if [ "$RUN_C" = 1 ]; then + echo "── clang-tidy (C/C++ static analysis) ───────────────────────────────────" + if [ ! -x "$CLANG_TIDY" ]; then + echo " SKIP: $CLANG_TIDY not found (install llvm/clang-tools)" >&2 + elif [ ! -f "compile_commands.json" ]; then + echo " SKIP: compile_commands.json not found (run ./rebuild.sh first)" >&2 + else + echo " running bug-finding checks (bugprone-*, cert-*, clang-analyzer-*)..." + # --header-filter restricts diagnostics to OUR headers (not vendored). + # compile_commands.json (-p .) provides the include roots + -DMRB_INT64. + "$CLANG_TIDY" -p . \ + --checks='-*,bugprone-*,cert-*,clang-analyzer-*' \ + --header-filter='^mrbgems/.*/src/.*|^src/.*' \ + $C_SOURCES 2>&1 || true # clang-tidy exits non-zero on warnings; that's OK + echo " done (clang-tidy reports only — no auto-fix)" + fi + echo "" +fi + +# ─── Summary ───────────────────────────────────────────────────────────────── +if [ "$EXIT_CODE" = 0 ]; then + echo "✓ lint clean" +else + echo "" + echo "✗ lint found issues (run 'bin/lint --fix' for safe autocorrects)" +fi +exit "$EXIT_CODE" diff --git a/bin/screenshot b/bin/screenshot new file mode 100755 index 0000000..6a35132 --- /dev/null +++ b/bin/screenshot @@ -0,0 +1,245 @@ +#!/bin/sh +# bin/screenshot — capture a game frame to a PNG, generically (web + desktop). +# +# This repo is WEB-FIRST: the relay serves the running game at +# http://localhost:8080/game.html (start it: `node tools/agent-bridge/server.js`), +# and the SAME shaders (SMAA/FXAA/CRT) run there. The web capture path works +# headlessly under WSL via a headless browser. The desktop path uses raylib's own +# TakeScreenshot framebuffer capture and needs a real display (see WSLg gotcha). +# +# Usage: +# bin/screenshot <game-script.rb> <output.png> [options] +# +# <game-script.rb> which game to capture. For --target=web this MUST match the +# game currently set in web/shell.html (Module.arguments) — +# the relay serves whatever the web build was built with. The +# arg is used only to label output + for --target=desktop. +# <output.png> output path. Put it in /tmp/ or .live/ — do NOT commit PNGs. +# +# Options: +# --target web|desktop capture target (default: web). web=headless browser +# of the relay-served game; desktop=raylib framebuffer. +# --frames N web: ms to wait after load before capture (default 3000). +# desktop: frames to render before capture (default 30). +# --delay SECS desktop only: extra wall-clock seconds before capture (0). +# --timeout SECS hard timeout for the whole capture (default 30). +# --win WxH viewport size (default 1280x720 web, 720x720 desktop). +# --url URL web only: relay URL (default http://localhost:8080/game.html). +# --browser chrome|puppeteer web only: capture backend. 'chrome' uses a system +# google-chrome/chromium --headless --screenshot if present; +# 'puppeteer' uses the project-local puppeteer (downloads a +# headless Chromium on first use). Default: try chrome, else +# puppeteer. +# --keep-running desktop only: capture but do NOT exit the game (default exit). +# --game-bin PATH desktop only: game binary (default zig-out/bin/game). +# --ffmpeg desktop only: force the ffmpeg x11grab fallback. +# -h, --help show this help. +# +# Exit 0 + prints the absolute PNG path on success; non-zero on failure. PNGs are +# validated (file(1) checks the PNG signature + dimensions). See +# tools/SCREENSHOT.md for the full guide, gotchas, and the WSLg display limit. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# --- PATH setup (mirror bin/lint + rebuild.sh: strip /mnt/c) --- +CLEANPATH=$(echo "$PATH" | tr ':' '\n' | grep -v '^/mnt/c' | paste -sd:) +export PATH="$CLEANPATH" + +TARGET="web" +FRAMES="" # filled per-target default below +DELAY=0 +TIMEOUT=30 +KEEP_RUNNING=0 +FORCE_FFMPEG=0 +WIN_W=0; WIN_H=0 # 0 => per-target default +URL="http://localhost:8080/game.html" +BROWSER="" +GAME_BIN="zig-out/bin/game" +GAME_SCRIPT="" +OUT_PNG="" + +show_help() { sed -n '2,40p' "$0"; } + +while [ $# -gt 0 ]; do + case "$1" in + --target) TARGET="$2"; shift 2 ;; + --frames) FRAMES="$2"; shift 2 ;; + --delay) DELAY="$2"; shift 2 ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --keep-running) KEEP_RUNNING=1; shift ;; + --ffmpeg) FORCE_FFMPEG=1; shift ;; + --win) WIN_W="${2%x*}"; WIN_H="${2#*x}"; shift 2 ;; + --url) URL="$2"; shift 2 ;; + --browser) BROWSER="$2"; shift 2 ;; + --game-bin) GAME_BIN="$2"; shift 2 ;; + -h|--help) show_help; exit 0 ;; + --) shift; break ;; + -*) echo "unknown flag: $1 (try --help)" >&2; exit 2 ;; + *) + if [ -z "$GAME_SCRIPT" ]; then GAME_SCRIPT="$1" + elif [ -z "$OUT_PNG" ]; then OUT_PNG="$1" + else echo "unexpected extra arg: $1" >&2; exit 2; fi + shift ;; + esac +done + +if [ -z "$GAME_SCRIPT" ] || [ -z "$OUT_PNG" ]; then + echo "usage: bin/screenshot <game-script.rb> <output.png> [options]" >&2 + echo " (run 'bin/screenshot --help' for details)" >&2 + exit 2 +fi + +case "$TARGET" in + web|desktop) : ;; + *) echo "error: --target must be web or desktop (got: $TARGET)" >&2; exit 2 ;; +esac + +# Absolute output path. +case "$OUT_PNG" in + /*) ABS_OUT="$OUT_PNG" ;; + *) ABS_OUT="$(pwd)/$OUT_PNG" ;; +esac +mkdir -p "$(dirname "$ABS_OUT")" 2>/dev/null || true +rm -f "$ABS_OUT" + +valid_png() { + [ -f "$1" ] || return 1 + case "$(file -b "$1" 2>/dev/null || true)" in + *PNG*image*) return 0 ;; + *) return 1 ;; + esac +} + +# ─── WEB target: headless browser of the relay-served game ────────────────── +capture_web() { + [ "$WIN_W" -gt 0 ] 2>/dev/null || WIN_W=1280 + [ "$WIN_H" -gt 0 ] 2>/dev/null || WIN_H=720 + [ -n "$FRAMES" ] || FRAMES=3000 # ms to wait after load for shaders to settle + echo "── web capture (relay) ─────────────────────────────────────────────" + echo " url: $URL" + echo " output: $ABS_OUT" + echo " size: ${WIN_W}x${WIN_H} settle: ${FRAMES}ms timeout: ${TIMEOUT}s" + + if ! curl -s -o /dev/null -m 5 "$URL"; then + echo " error: relay not serving $URL (start it: node tools/agent-bridge/server.js)" >&2 + return 1 + fi + + # Pick backend: explicit --browser, else chrome if a system binary exists, + # else puppeteer (downloads a headless Chromium into the project cache). + use_chrome=0; use_puppeteer=0 + if [ -n "$BROWSER" ]; then + case "$BROWSER" in chrome) use_chrome=1 ;; puppeteer) use_puppeteer=1 ;; + *) echo "error: --browser must be chrome or puppeteer" >&2; return 1 ;; esac + else + for b in google-chrome google-chrome-stable chromium chromium-browser chrome; do + if command -v "$b" >/dev/null 2>&1; then CHROME_BIN="$b"; use_chrome=1; break; fi + done + [ "$use_chrome" = 1 ] || use_puppeteer=1 + fi + + if [ "$use_chrome" = 1 ]; then + echo " backend: system chrome ($CHROME_BIN)" + # --headless=new + --screenshot writes a PNG of the viewport after load. + # --run-all-compositor-stages-before-draw + --virtual-time-budget let the + # WebGL canvas render frames before the screenshot is taken. + "$CHROME_BIN" --headless=new --disable-gpu --no-sandbox \ + --hide-scrollbars --force-device-scale-factor=1 \ + --window-size="${WIN_W},${WIN_H}" --virtual-time-budget="$FRAMES" \ + --run-all-compositor-stages-before-draw \ + --screenshot="$ABS_OUT" "$URL" >/tmp/ss.chrome.log 2>&1 || true + fi + + if [ "$use_puppeteer" = 1 ]; then + echo " backend: puppeteer (project-local)" + if [ ! -d node_modules/puppeteer ]; then + echo " error: puppeteer not installed. One-time setup (downloads ~180MB Chromium):" >&2 + echo " npm install puppeteer # then re-run" >&2 + return 1 + fi + PUPPETEER_CACHE_DIR="$ROOT/.puppeteer-cache" \ + node tools/web_screenshot.js "$URL" "$ABS_OUT" "$FRAMES" "$WIN_W" "$WIN_H" \ + >/tmp/ss.puppeteer.log 2>&1 || { cat /tmp/ss.puppeteer.log >&2; return 1; } + fi +} + +# ─── DESKTOP target: raylib framebuffer capture (env hook) + ffmpeg fallback ─ +capture_desktop() { + [ "$WIN_W" -gt 0 ] 2>/dev/null || WIN_W=720 + [ "$WIN_H" -gt 0 ] 2>/dev/null || WIN_H=720 + [ -n "$FRAMES" ] || FRAMES=30 + if [ ! -f "$GAME_BIN" ]; then + echo "error: game binary not found at $GAME_BIN (run ./rebuild.sh first)" >&2; return 1 + fi + if [ ! -f "$GAME_SCRIPT" ]; then + echo "error: game script not found: $GAME_SCRIPT" >&2; return 1 + fi + # WSLg: prefer the Wayland backend (raylib is built Wayland-only — see + # .agents/knowledge/environment.md). FALL BACK to X11 if the caller set DISPLAY. + : "${XDG_RUNTIME_DIR:=/mnt/wslg/runtime-dir}"; export XDG_RUNTIME_DIR + : "${WAYLAND_DISPLAY:=wayland-0}"; export WAYLAND_DISPLAY + echo "── desktop capture (raylib framebuffer) ──────────────────────────────" + echo " game: $GAME_SCRIPT output: $ABS_OUT" + echo " frames: $FRAMES delay: ${DELAY}s timeout: ${TIMEOUT}s" + echo " (WSLg note: this needs a driven display — see tools/SCREENSHOT.md)" + + raylib_capture() { + export JAMSTACK_SCREENSHOT="$ABS_OUT" + export JAMSTACK_SCREENSHOT_FRAMES="$FRAMES" + export JAMSTACK_SCREENSHOT_DELAY="$DELAY" + if [ "$KEEP_RUNNING" = 1 ]; then export JAMSTACK_SCREENSHOT_ONCE=0 + else unset JAMSTACK_SCREENSHOT_ONCE; fi + if command -v timeout >/dev/null 2>&1; then + timeout "${TIMEOUT}s" "$GAME_BIN" "$GAME_SCRIPT" >"$ABS_OUT.game.log" 2>&1 & + else + "$GAME_BIN" "$GAME_SCRIPT" >"$ABS_OUT.game.log" 2>&1 & + fi + GAME_PID=$! + deadline=$(( $(date +%s) + TIMEOUT )) + while [ "$(date +%s)" -lt "$deadline" ]; do + valid_png "$ABS_OUT" && break + if ! kill -0 "$GAME_PID" 2>/dev/null; then + valid_png "$ABS_OUT" || { echo " game exited w/o PNG; log tail:" >&2; tail -20 "$ABS_OUT.game.log" >&2 || true; } + break + fi + sleep 0.25 + done + if kill -0 "$GAME_PID" 2>/dev/null; then kill "$GAME_PID" 2>/dev/null || true; sleep 0.5; kill -9 "$GAME_PID" 2>/dev/null || true; fi + wait "$GAME_PID" 2>/dev/null || true + rm -f "$ABS_OUT.game.log" + } + + ffmpeg_capture() { + echo "── ffmpeg x11grab fallback ─────────────────────────────────────────" + : "${DISPLAY:=:0}"; export DISPLAY + "$GAME_BIN" "$GAME_SCRIPT" >"$ABS_OUT.game.log" 2>&1 & GAME_PID=$! + sleep "$(awk -v f="$FRAMES" 'BEGIN{ printf "%.2f", f/60.0 }')" + ffmpeg -y -f x11grab -video_size "${WIN_W}x${WIN_H}" -framedrop \ + -i "$DISPLAY" -frames:v 1 "$ABS_OUT" >"$ABS_OUT.ff.log" 2>&1 || true + if kill -0 "$GAME_PID" 2>/dev/null; then kill "$GAME_PID" 2>/dev/null || true; sleep 0.3; kill -9 "$GAME_PID" 2>/dev/null || true; fi + wait "$GAME_PID" 2>/dev/null || true + rm -f "$ABS_OUT.game.log" "$ABS_OUT.ff.log" + } + + [ "$FORCE_FFMPEG" != 1 ] && { raylib_capture || true; } + if { [ "$FORCE_FFMPEG" = 1 ] || ! valid_png "$ABS_OUT"; }; then + [ "$FORCE_FFMPEG" != 1 ] && echo " raylib path produced no PNG — trying ffmpeg fallback" >&2 + ffmpeg_capture || true + fi +} + +case "$TARGET" in + web) capture_web || true ;; + desktop) capture_desktop || true ;; +esac + +if valid_png "$ABS_OUT"; then + echo "✓ captured: $ABS_OUT" + echo " $(file -b "$ABS_OUT")" + exit 0 +else + echo "✗ failed to capture a valid PNG at $ABS_OUT" >&2 + exit 1 +fi diff --git a/bin/serve b/bin/serve deleted file mode 100755 index cb6fb06..0000000 --- a/bin/serve +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# bin/serve — serve the web build locally -# -# Usage: -# bin/serve # serve on port 8080 -# bin/serve 9000 # serve on port 9000 - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" - -cd "$PROJECT_DIR" - -BUILD_DIR="build-web" - -if [ ! -f "$BUILD_DIR/index.html" ]; then - echo "Error: Web build not found. Run 'bin/build-web' first." >&2 - exit 1 -fi - -PORT="${1:-8080}" - -echo "Serving study-player at http://localhost:$PORT" -echo "Press Ctrl+C to stop." -python3 -m http.server "$PORT" -d "$BUILD_DIR" diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..26891df --- /dev/null +++ b/build.zig @@ -0,0 +1,164 @@ +const std = @import("std"); + +// Unified build: `zig build` orchestrates the whole desktop stack. +// 1. raylib -> vendor/raylib/src/libraylib.a (make, once) +// 2. RmlUi -> vendor/rmlui/build-static/librmlui.a (cmake, once) +// 3. mruby -> vendor/mruby/build/host/lib/libmruby.a (rake, every build: +// embeds our Rl::/Rml:: bindings, so it tracks binding changes) +// 4. compile src/main.c and link everything. +// +// The vendored libs (1,2) are guarded so they only build when missing; mruby (3) +// runs every time (rake is itself incremental). See BUILDING.md. +// +// Wayland backend is used because WSLg's X11/GLX path segfaults in Mesa; for a +// normal X11 desktop, swap the wayland-* libs for "X11" and rebuild raylib +// without the GLFW_LINUX_ENABLE_WAYLAND flags. + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // --- dependency build steps (system commands) ------------------------------ + // Desktop raylib -> build/desktop/libraylib.a. raylib shares .o files in src/ + // across platforms, so `make clean` first to avoid picking up wasm objects + // from a prior web build. Guarded on the desktop lib so it only builds once. + const raylib_lib = b.addSystemCommand(&.{ + "sh", "-c", + "r=\"$PWD\"; mkdir -p \"$r/build/desktop\"; " ++ + "[ -f \"$r/build/desktop/libraylib.a\" ] || (" ++ + "cd vendor/raylib/src && make clean >/dev/null 2>&1; " ++ + "make PLATFORM=PLATFORM_DESKTOP RAYLIB_LIBTYPE=STATIC " ++ + "GLFW_LINUX_ENABLE_WAYLAND=TRUE GLFW_LINUX_ENABLE_X11=FALSE -j4 " ++ + "RAYLIB_RELEASE_PATH=\"$r/build/desktop\")", + }); + + // flecs (ECS): compile the single-file amalgamation to a static lib once. + const flecs_lib = b.addSystemCommand(&.{ + "sh", "-c", + "r=\"$PWD\"; mkdir -p \"$r/build/desktop\"; " ++ + "[ -f \"$r/build/desktop/libflecs.a\" ] || (" ++ + "cc -c -O2 -std=gnu99 -DNDEBUG -I vendor/flecs/distr " ++ + "vendor/flecs/distr/flecs.c -o build/desktop/flecs.o && " ++ + "ar rcs build/desktop/libflecs.a build/desktop/flecs.o)", + }); + + // Jolt Physics via joltc (C API). CMake builds libjoltc.a + libJolt.a, which + // we then MERGE into a single build/desktop/libjoltphysics.a. The merge is + // important: libmruby (Jolt:: bindings) -> libjoltc -> libJolt is a 3-archive + // chain that lld's single pass won't resolve; one combined archive (like + // libflecs.a) resolves all cross-references. Jolt v5.5.0 is taken locally from + // vendor/JoltPhysics; profiler/debug-renderer are disabled to stay lean. + const jolt_lib = b.addSystemCommand(&.{ + "sh", "-c", + "mkdir -p build/desktop; [ -f build/desktop/libjoltphysics.a ] || (" ++ + "[ -f vendor/joltc/build-static/lib/libjoltc.a ] || (" ++ + "cmake -S vendor/joltc -B vendor/joltc/build-static " ++ + "-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DJPH_BUILD_SHARED=OFF " ++ + "-DJPH_SAMPLES=OFF -DJPH_TESTS=OFF -DJPH_INSTALL=OFF " ++ + // LTO OFF: Jolt defaults to GCC -flto, whose GIMPLE-bytecode objects + // lld (zig's linker) cannot link. OFF produces native objects. + "-DINTERPROCEDURAL_OPTIMIZATION=OFF " ++ + "-DDEBUG_RENDERER_IN_DEBUG_AND_RELEASE=OFF -DDEBUG_RENDERER_IN_DISTRIBUTION=OFF " ++ + "-DPROFILER_IN_DEBUG_AND_RELEASE=OFF && " ++ + "cmake --build vendor/joltc/build-static --target joltc -j4); " ++ + "rm -rf build/desktop/jolt_obj && mkdir -p build/desktop/jolt_obj && " ++ + "(cd build/desktop/jolt_obj && " ++ + "ar x ../../../vendor/joltc/build-static/lib/libjoltc.a && " ++ + "ar x ../../../vendor/joltc/build-static/lib/libJolt.a && " ++ + "ar rcs ../libjoltphysics.a *.o) && rm -rf build/desktop/jolt_obj)", + }); + + const rmlui_lib = b.addSystemCommand(&.{ + "sh", "-c", + "[ -f vendor/rmlui/build-static/librmlui.a ] || (" ++ + "cmake -S vendor/rmlui -B vendor/rmlui/build-static " ++ + "-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF " ++ + "-DRMLUI_SAMPLES=OFF -DRMLUI_LUA_BINDINGS=OFF -DRMLUI_FONT_ENGINE=freetype && " ++ + "cmake --build vendor/rmlui/build-static --target rmlui_core -j4)", + }); + + // mruby (with our mrbgems). Prepend the user gem bin so `rake` resolves to the + // Linux gem (not a Windows rake on /mnt/c under WSL). Target the .a path so + // mruby's own CLI tools (which don't link raylib) aren't built. + const mruby_lib = b.addSystemCommand(&.{ + "sh", "-c", + "export PATH=\"$(ruby -e 'puts Gem.user_dir')/bin:$PATH\"; " ++ + "export JAMSTACK_ROOT=\"$PWD\"; " ++ + "export MRUBY_CONFIG=\"$PWD/build_config.rb\"; " ++ + "cd vendor/mruby && rake \"$JAMSTACK_ROOT/vendor/mruby/build/host/lib/libmruby.a\"", + }); + + // --- the game executable --------------------------------------------------- + const exe = b.addExecutable(.{ + .name = "game", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + + exe.root_module.addCSourceFile(.{ + .file = b.path("src/main.c"), + .flags = &.{ "-std=c11", "-Wall", "-DMRB_INT64" }, + }); + + // mruby headers + static lib (embeds the Rl::/Rml:: bindings mrbgems) + exe.root_module.addIncludePath(b.path("vendor/mruby/include")); + exe.root_module.addObjectFile(b.path("vendor/mruby/build/host/lib/libmruby.a")); + + // raylib static lib (desktop) + exe.root_module.addIncludePath(b.path("vendor/raylib/src")); + exe.root_module.addObjectFile(b.path("build/desktop/libraylib.a")); + + // flecs static lib (desktop). libmruby.a holds the Flecs:: bindings that + // reference these symbols, so it must precede this in link order (it does). + exe.root_module.addIncludePath(b.path("vendor/flecs/distr")); + exe.root_module.addObjectFile(b.path("build/desktop/libflecs.a")); + + // Jolt Physics (C++): single merged archive (joltc C API + Jolt impl). + exe.root_module.addIncludePath(b.path("vendor/joltc/include")); + exe.root_module.addObjectFile(b.path("build/desktop/libjoltphysics.a")); + + // RmlUi static lib (C++) + its deps. libmruby.a (above) holds the Rml:: + // bindings that reference these symbols, so it must precede this in link order. + exe.root_module.addObjectFile(b.path("vendor/rmlui/build-static/librmlui.a")); + exe.root_module.linkSystemLibrary("freetype", .{}); + // RmlUi is built with GNU libstdc++; link it directly. (Do NOT use + // linkSystemLibrary("stdc++") — zig 0.16 substitutes its own LLVM libc++, + // which lacks the libstdc++ ABI symbols RmlUi needs.) + exe.root_module.addObjectFile(.{ .cwd_relative = "/usr/lib/libstdc++.so" }); + // GCC unwinder (_Unwind_Resume) — mruby is built with C++ exceptions enabled + // (MRB_USE_CXX_EXCEPTION) because a C++ mrbgem (rmlui) is present. + exe.root_module.addObjectFile(.{ .cwd_relative = "/usr/lib/libgcc_s.so.1" }); + + // system libraries needed by raylib (desktop GLFW/Wayland) and mruby + exe.root_module.linkSystemLibrary("GL", .{}); + exe.root_module.linkSystemLibrary("EGL", .{}); + exe.root_module.linkSystemLibrary("m", .{}); + exe.root_module.linkSystemLibrary("pthread", .{}); + exe.root_module.linkSystemLibrary("dl", .{}); + exe.root_module.linkSystemLibrary("rt", .{}); + // Wayland backend (WSLg-friendly; avoids the broken Mesa GLX path) + exe.root_module.linkSystemLibrary("wayland-client", .{}); + exe.root_module.linkSystemLibrary("wayland-cursor", .{}); + exe.root_module.linkSystemLibrary("wayland-egl", .{}); + exe.root_module.linkSystemLibrary("xkbcommon", .{}); + + // link only after the dependency libs are built + exe.step.dependOn(&raylib_lib.step); + exe.step.dependOn(&flecs_lib.step); + exe.step.dependOn(&jolt_lib.step); + exe.step.dependOn(&rmlui_lib.step); + exe.step.dependOn(&mruby_lib.step); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + run_cmd.setCwd(b.path(".")); // so game/main.rb resolves + if (b.args) |args| run_cmd.addArgs(args); + + const run_step = b.step("run", "Build and run the game"); + run_step.dependOn(&run_cmd.step); +} diff --git a/build_config.rb b/build_config.rb new file mode 100644 index 0000000..74289da --- /dev/null +++ b/build_config.rb @@ -0,0 +1,65 @@ +# mruby build configuration for raylib-jamstack. +# +# Produces vendor/mruby/build/host/lib/libmruby.a containing the default gembox +# plus our raylib + rmlui bindings mrbgems. The final link (raylib + RmlUi + +# system GL) is done by build.zig. +# +# build.zig invokes rake with JAMSTACK_ROOT set to the project root. +STACK_ROOT = ENV['JAMSTACK_ROOT'] || File.expand_path('..', __dir__) + +MRuby::Build.new do |conf| + toolchain :gcc + + conf.enable_debug + # Disable preallocated symbols: lets us add new binding method names without + # regenerating the presym table (avoids stale-symbol build errors on rebuild). + conf.disable_presym + # Force 64-bit mrb_int on ALL targets. flecs entity ids (ecs_entity_t) carry a + # generation in the high 32 bits; with the default 32-bit mrb_int (wasm32), the + # generation is truncated across the mruby boundary and recycled entities leak + # (ecs_is_alive/ecs_delete see a stale generation). See .agents/knowledge/flecs-binding.md. + conf.cc.defines << 'MRB_INT64' + conf.cxx.defines << 'MRB_INT64' + conf.gembox 'default' + + # Our raylib bindings (C + mrblib Ruby sugar). + conf.gem File.join(STACK_ROOT, 'mrbgems', 'raylib') + # Our RmlUi bindings (C++ + mrblib Ruby sugar). + conf.gem File.join(STACK_ROOT, 'mrbgems', 'rmlui') + # Our flecs (ECS) bindings (C + mrblib Ruby sugar). + conf.gem File.join(STACK_ROOT, 'mrbgems', 'flecs') + # Our Jolt (3D physics) bindings (C over the joltc C API + mrblib Ruby sugar). + conf.gem File.join(STACK_ROOT, 'mrbgems', 'jolt') + + conf.enable_test if ENV['JAMSTACK_TEST'] +end + +# Web (Emscripten/WASM) cross build. Defined only when JAMSTACK_WEB is set so the +# desktop build doesn't require emcc. Produces build/web/lib/libmruby.a (wasm), +# linked by build_web.sh with the wasm raylib + RmlUi. +if ENV['JAMSTACK_WEB'] + MRuby::CrossBuild.new('web') do |conf| + toolchain :clang + + conf.cc.command = 'emcc' + conf.cxx.command = 'em++' + conf.linker.command = 'emcc' + conf.archiver.command = 'emar' + + # C++ exceptions: mruby is built with MRB_USE_CXX_EXCEPTION (a C++ mrbgem is + # present), and RmlUi uses exceptions; enable them for the wasm target. + conf.cc.flags << '-fexceptions' + conf.cxx.flags << '-fexceptions' + + conf.enable_debug + conf.disable_presym + conf.cc.defines << 'MRB_INT64' + conf.cxx.defines << 'MRB_INT64' + conf.gembox 'default' + + conf.gem File.join(STACK_ROOT, 'mrbgems', 'raylib') + conf.gem File.join(STACK_ROOT, 'mrbgems', 'rmlui') + conf.gem File.join(STACK_ROOT, 'mrbgems', 'flecs') + conf.gem File.join(STACK_ROOT, 'mrbgems', 'jolt') + end +end diff --git a/build_web.sh b/build_web.sh new file mode 100755 index 0000000..7bc22b1 --- /dev/null +++ b/build_web.sh @@ -0,0 +1,101 @@ +#!/bin/sh +# Web (Emscripten/WASM) build: produces build/web/game.{html,js,wasm,data}. +# +# Requires the Emscripten SDK. Point EMSDK_ENV at its emsdk_env.sh, e.g.: +# EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh +set -e +ROOT="$(cd "$(dirname "$0")" && pwd)" + +# Linux toolchain + user gem rake first (avoid Windows rake on /mnt/c under WSL) +CLEANPATH=$(echo "$PATH" | tr ':' '\n' | grep -v '^/mnt/c' | paste -sd:) +export PATH="$(ruby -e 'puts Gem.user_dir')/bin:$CLEANPATH" + +# Activate Emscripten +. "${EMSDK_ENV:-$HOME/emsdk/emsdk_env.sh}" >/dev/null 2>&1 +command -v emcc >/dev/null || { echo "emcc not found; set EMSDK_ENV"; exit 1; } + +export JAMSTACK_ROOT="$ROOT" +export JAMSTACK_WEB=1 +mkdir -p "$ROOT/build/web" + +# Emscripten ports needed by RmlUi (freetype) and raylib fonts. +embuilder build freetype harfbuzz >/dev/null 2>&1 || true + +# 1. raylib (wasm) -> build/web/libraylib.web.a +# `make clean` first: raylib shares .o files in src/ across platforms, so clear +# any desktop objects before building the wasm objects. +# raylib 6.0: PLATFORM_WEB emits libraylib.web.a (not libraylib.a). +# GL backend = OpenGL ES 3.0 (WebGL2). GRAPHICS_API_OPENGL_ES3 auto-defines ES2 +# (superset), so all ES2 blocks compile too; VAO is core in ES3 so the +# rlDrawRenderBatch VAO branch is always taken (never the client-array else). +# The PLATFORM_WEB Makefile uses `GRAPHICS ?=` so this override wins. +if [ ! -f "$ROOT/build/web/libraylib.web.a" ]; then + make -C "$ROOT/vendor/raylib/src" clean >/dev/null 2>&1 || true + emmake make -C "$ROOT/vendor/raylib/src" PLATFORM=PLATFORM_WEB -j4 \ + GRAPHICS=GRAPHICS_API_OPENGL_ES3 \ + RAYLIB_RELEASE_PATH="$ROOT/build/web" +fi + +# 2. RmlUi (wasm) -> vendor/rmlui/build-web/librmlui.a +if [ ! -f "$ROOT/vendor/rmlui/build-web/librmlui.a" ]; then + emcmake cmake -S "$ROOT/vendor/rmlui" -B "$ROOT/vendor/rmlui/build-web" \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \ + -DRMLUI_SAMPLES=OFF -DRMLUI_LUA_BINDINGS=OFF -DRMLUI_FONT_ENGINE=freetype \ + -DCMAKE_C_FLAGS="-sUSE_FREETYPE=1" -DCMAKE_CXX_FLAGS="-sUSE_FREETYPE=1" + cmake --build "$ROOT/vendor/rmlui/build-web" --target rmlui_core -j4 +fi + +# 2b. flecs (wasm) -> build/web/libflecs.a. The full amalgamation (incl. meta, +# needed for runtime component structs) is emscripten-aware; its socket code is +# guarded for wasm, so no addons need disabling. +if [ ! -f "$ROOT/build/web/libflecs.a" ]; then + emcc -c -O2 -std=gnu99 -DNDEBUG \ + -I "$ROOT/vendor/flecs/distr" "$ROOT/vendor/flecs/distr/flecs.c" \ + -o "$ROOT/build/web/flecs.o" + emar rcs "$ROOT/build/web/libflecs.a" "$ROOT/build/web/flecs.o" +fi + +# 2c. Jolt Physics via joltc (wasm) -> build/web/libjoltphysics.a (joltc + Jolt +# merged into one archive, as for desktop). INTERPROCEDURAL_OPTIMIZATION=OFF and +# single-threaded (no -pthread) so it runs in the browser sandbox. +if [ ! -f "$ROOT/build/web/libjoltphysics.a" ]; then + if [ ! -f "$ROOT/vendor/joltc/build-web/lib/libjoltc.a" ]; then + emcmake cmake -S "$ROOT/vendor/joltc" -B "$ROOT/vendor/joltc/build-web" \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DJPH_BUILD_SHARED=OFF \ + -DJPH_SAMPLES=OFF -DJPH_TESTS=OFF -DJPH_INSTALL=OFF \ + -DINTERPROCEDURAL_OPTIMIZATION=OFF \ + -DDEBUG_RENDERER_IN_DEBUG_AND_RELEASE=OFF -DDEBUG_RENDERER_IN_DISTRIBUTION=OFF \ + -DPROFILER_IN_DEBUG_AND_RELEASE=OFF + cmake --build "$ROOT/vendor/joltc/build-web" --target joltc -j4 + fi + rm -rf "$ROOT/build/web/jolt_obj" && mkdir -p "$ROOT/build/web/jolt_obj" + ( cd "$ROOT/build/web/jolt_obj" && \ + emar x "$ROOT/vendor/joltc/build-web/lib/libjoltc.a" && \ + emar x "$ROOT/vendor/joltc/build-web/lib/libJolt.a" && \ + emar rcs "$ROOT/build/web/libjoltphysics.a" *.o ) + rm -rf "$ROOT/build/web/jolt_obj" +fi + +# 3. mruby (wasm, embeds our mrbgems) -> build/web/lib/libmruby.a +export MRUBY_CONFIG="$ROOT/build_config.rb" +( cd "$ROOT/vendor/mruby" && rake "$ROOT/vendor/mruby/build/web/lib/libmruby.a" ) + +# 4. Link everything into game.html +emcc "$ROOT/src/main.c" \ + "$ROOT/vendor/mruby/build/web/lib/libmruby.a" \ + "$ROOT/build/web/libraylib.web.a" \ + "$ROOT/build/web/libflecs.a" \ + "$ROOT/build/web/libjoltphysics.a" \ + "$ROOT/vendor/rmlui/build-web/librmlui.a" \ + -I "$ROOT/vendor/mruby/include" -I "$ROOT/vendor/raylib/src" \ + -I "$ROOT/vendor/flecs/distr" -I "$ROOT/vendor/joltc/include" \ + -DMRB_INT64 -fexceptions \ + -sUSE_GLFW=3 -sUSE_FREETYPE=1 -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2 \ + -sALLOW_MEMORY_GROWTH=1 -sSTACK_SIZE=4MB \ + -sEXPORTED_FUNCTIONS=_main,_malloc,_free,_jamstack_eval,_flecs_explorer_request \ + -sEXPORTED_RUNTIME_METHODS=ccall,cwrap \ + --preload-file "$ROOT/game"@/game \ + --shell-file "$ROOT/web/shell.html" \ + -o "$ROOT/build/web/game.html" + +echo "OK -> build/web/game.html" diff --git a/docs/AI_REFERENCE.md b/docs/AI_REFERENCE.md new file mode 100644 index 0000000..8651519 --- /dev/null +++ b/docs/AI_REFERENCE.md @@ -0,0 +1,1075 @@ +# raylib-jamstack — complete API reference (for AI agents) + +Single-file description of the **entire** Ruby (mruby) API of this stack: +raylib 6.0 + raymath + RmlUi 6.x + flecs 4 (ECS) + Jolt 5 (3D physics). Everything an agent needs to write correct +game code without reading the bindings source. Auto-generated from +`raylib_api.json` / `raymath_api.json` by `mrbgems/raylib/tools/gen_ai_reference.rb`. + +## Conventions (read first) +- C `PascalCase` -> Ruby `snake_case`. `IsXxx(...)` -> `xxx?` predicate. +- All raylib structs are classes under `Rl::` with a **positional** constructor + in field order and `obj.field` / `obj.field=` accessors (see Structs). +- Enum values and color/numeric `#define`s are constants under `Rl::` + (e.g. `Rl::KEY_SPACE`, `Rl::MOUSE_BUTTON_LEFT`, `Rl::GOLD`, `Rl::PI`). +- Signatures below are `Rl.name(arg:Type, ...) -> ReturnType`. **No `-> ` means + the call returns `nil`.** `Boolean` = true/false. Struct types are `Rl::X`. +- A struct passed where C takes a single `T*` is **in/out**: pass an `Rl::T` + instance; the call may mutate it. +- String args accept `nil` (becomes C `NULL`), e.g. + `Rl.load_shader_from_memory(nil, fs)` for the default vertex shader. +- Symbol keys work anywhere a keycode is expected via the input predicates: + `:a`..`:z`, `:0`..`:9`, `:space :enter :escape :tab :backspace :up :down + :left :right :left_shift :left_control` — or use `Rl::KEY_*` ints. +- There is no global state you must thread; raylib is a global singleton. + +## Idiomatic helpers (defined in Ruby, not 1:1 C) +```ruby +Rl.while_window_open { ... } # the ONLY main loop. web-safe (emscripten + # main loop on web; `until close?` on desktop, + # auto-calls close_window on desktop exit). +Rl.draw(clear_color: Rl::RAYWHITE) { ... } # begin_drawing+clear+end_drawing (ensure) +Rl.mode_2d(camera) { ... } # begin/end_mode2d (exception-safe) +Rl.mode_3d(camera) { ... } # begin/end_mode3d +Rl.texture_mode(render_texture) { ... } +Rl.blend_mode(mode) { ... } # mode = Rl::BLEND_* +Rl.shader_mode(shader) { ... } +Rl.scissor_mode(x:, y:, width:, height:) { ... } +Rl.draw_text(text:, x:, y:, font_size:, color:) # kwarg form +Rl.draw_texture_pro(texture:, source:, dest:, origin: Rl::Vector2.new(0,0), + rotation: 0, tint: Rl::WHITE) # kwarg form +Rl.platform # :web|:desktop ; Rl.web? ; Rl.desktop? +# aliases: Rl.target_fps= , Rl.master_volume= , Rl.frame_time, Rl.time, Rl.fps, +# Rl.screen_width, Rl.screen_height, Rl.mouse_x, Rl.mouse_y, +# Rl.mouse_position, Rl.mouse_wheel +``` +NOTE: `draw_text` and `draw_texture_pro` are the keyword forms above (they +override the positional generated versions). All other calls are positional. + +## Minimal program +```ruby +Rl.init_window(800, 450, "demo") +Rl.target_fps = 60 +Rl.while_window_open do + Rl.draw(clear_color: Rl::RAYWHITE) do + Rl.draw_text(text: "hello", x: 20, y: 20, font_size: 20, color: Rl::DARKGRAY) + Rl.draw_circle_v(Rl.mouse_position, 16, Rl::RED) if Rl.mouse_button_down?(Rl::MOUSE_BUTTON_LEFT) + end +end +``` + +## raylib functions (by module) + +### core +```ruby +Rl.init_window(width:Integer, height:Integer, title:String) # Initialize window and OpenGL context +Rl.close_window # Close window and unload OpenGL context +Rl.window_should_close -> Boolean # Check if application should close (KEY_ESCAPE pressed or windows close icon clicked) +Rl.window_ready? -> Boolean # Check if window has been initialized successfully +Rl.window_fullscreen? -> Boolean # Check if window is currently fullscreen +Rl.window_hidden? -> Boolean # Check if window is currently hidden +Rl.window_minimized? -> Boolean # Check if window is currently minimized +Rl.window_maximized? -> Boolean # Check if window is currently maximized +Rl.window_focused? -> Boolean # Check if window is currently focused +Rl.window_resized? -> Boolean # Check if window has been resized last frame +Rl.window_state?(flag:Integer) -> Boolean # Check if one specific window flag is enabled +Rl.set_window_state(flags:Integer) # Set window configuration state using flags +Rl.clear_window_state(flags:Integer) # Clear window configuration state flags +Rl.toggle_fullscreen # Toggle window state: fullscreen/windowed, resizes monitor to match window resolution +Rl.toggle_borderless_windowed # Toggle window state: borderless windowed, resizes window to match monitor resolution +Rl.maximize_window # Set window state: maximized, if resizable +Rl.minimize_window # Set window state: minimized, if resizable +Rl.restore_window # Restore window from being minimized/maximized +Rl.set_window_icon(image:Rl::Image) # Set icon for window (single image, RGBA 32bit) +Rl.set_window_icons(images:Rl::Image, count:Integer) # Set icon for window (multiple images, RGBA 32bit) +Rl.set_window_title(title:String) # Set title for window +Rl.set_window_position(x:Integer, y:Integer) # Set window position on screen +Rl.set_window_monitor(monitor:Integer) # Set monitor for the current window +Rl.set_window_min_size(width:Integer, height:Integer) # Set window minimum dimensions (for FLAG_WINDOW_RESIZABLE) +Rl.set_window_max_size(width:Integer, height:Integer) # Set window maximum dimensions (for FLAG_WINDOW_RESIZABLE) +Rl.set_window_size(width:Integer, height:Integer) # Set window dimensions +Rl.set_window_opacity(opacity:Float) # Set window opacity [0.0f..1.0f] +Rl.set_window_focused # Set window focused +Rl.get_screen_width -> Integer # Get current screen width +Rl.get_screen_height -> Integer # Get current screen height +Rl.get_render_width -> Integer # Get current render width (it considers HiDPI) +Rl.get_render_height -> Integer # Get current render height (it considers HiDPI) +Rl.get_monitor_count -> Integer # Get number of connected monitors +Rl.get_current_monitor -> Integer # Get current monitor where window is placed +Rl.get_monitor_position(monitor:Integer) -> Rl::Vector2 # Get specified monitor position +Rl.get_monitor_width(monitor:Integer) -> Integer # Get specified monitor width (current video mode used by monitor) +Rl.get_monitor_height(monitor:Integer) -> Integer # Get specified monitor height (current video mode used by monitor) +Rl.get_monitor_physical_width(monitor:Integer) -> Integer # Get specified monitor physical width in millimetres +Rl.get_monitor_physical_height(monitor:Integer) -> Integer # Get specified monitor physical height in millimetres +Rl.get_monitor_refresh_rate(monitor:Integer) -> Integer # Get specified monitor refresh rate +Rl.get_window_position -> Rl::Vector2 # Get window position XY on monitor +Rl.get_window_scale_dpi -> Rl::Vector2 # Get window scale DPI factor +Rl.get_monitor_name(monitor:Integer) -> String # Get the human-readable, UTF-8 encoded name of the specified monitor +Rl.set_clipboard_text(text:String) # Set clipboard text content +Rl.get_clipboard_text -> String # Get clipboard text content +Rl.enable_event_waiting # Enable waiting for events on EndDrawing(), no automatic event polling +Rl.disable_event_waiting # Disable waiting for events on EndDrawing(), automatic events polling +Rl.show_cursor # Shows cursor +Rl.hide_cursor # Hides cursor +Rl.cursor_hidden? -> Boolean # Check if cursor is not visible +Rl.enable_cursor # Enables cursor (unlock cursor) +Rl.disable_cursor # Disables cursor (lock cursor) +Rl.cursor_on_screen? -> Boolean # Check if cursor is on the screen +Rl.clear_background(color:Rl::Color) # Set background color (framebuffer clear color) +Rl.begin_drawing # Setup canvas (framebuffer) to start drawing +Rl.end_drawing # End canvas drawing and swap buffers (double buffering) +Rl.begin_mode2d(camera:Rl::Camera2D) # Begin 2D mode with custom camera (2D) +Rl.end_mode2d # Ends 2D mode with custom camera +Rl.begin_mode3d(camera:Rl::Camera3D) # Begin 3D mode with custom camera (3D) +Rl.end_mode3d # Ends 3D mode and returns to default 2D orthographic mode +Rl.begin_texture_mode(target:Rl::RenderTexture) # Begin drawing to render texture +Rl.end_texture_mode # Ends drawing to render texture +Rl.begin_shader_mode(shader:Rl::Shader) # Begin custom shader drawing +Rl.end_shader_mode # End custom shader drawing (use default shader) +Rl.begin_blend_mode(mode:Integer) # Begin blending mode (alpha, additive, multiplied, subtract, custom) +Rl.end_blend_mode # End blending mode (reset to default: alpha blending) +Rl.begin_scissor_mode(x:Integer, y:Integer, width:Integer, height:Integer) # Begin scissor mode (define screen area for following drawing) +Rl.end_scissor_mode # End scissor mode +Rl.begin_vr_stereo_mode(config:Rl::VrStereoConfig) # Begin stereo rendering (requires VR simulator) +Rl.end_vr_stereo_mode # End stereo rendering (requires VR simulator) +Rl.load_vr_stereo_config(device:Rl::VrDeviceInfo) -> Rl::VrStereoConfig # Load VR stereo config for VR simulator device parameters +Rl.unload_vr_stereo_config(config:Rl::VrStereoConfig) # Unload VR stereo config +Rl.load_shader(vs_file_name:String, fs_file_name:String) -> Rl::Shader # Load shader from files and bind default locations +Rl.load_shader_from_memory(vs_code:String, fs_code:String) -> Rl::Shader # Load shader from code strings and bind default locations +Rl.shader_valid?(shader:Rl::Shader) -> Boolean # Check if a shader is valid (loaded on GPU) +Rl.get_shader_location(shader:Rl::Shader, uniform_name:String) -> Integer # Get shader uniform location +Rl.get_shader_location_attrib(shader:Rl::Shader, attrib_name:String) -> Integer # Get shader attribute location +Rl.set_shader_value(shader:Rl::Shader, loc_index:Integer, value:Numeric|Array, uniform_type:Integer) # value packed per SHADER_UNIFORM_* type +Rl.set_shader_value_v(shader:Rl::Shader, loc_index:Integer, value:Array, uniform_type:Integer, count:Integer) +Rl.set_shader_value_matrix(shader:Rl::Shader, loc_index:Integer, mat:Rl::Matrix) # Set shader uniform value (matrix 4x4) +Rl.set_shader_value_texture(shader:Rl::Shader, loc_index:Integer, texture:Rl::Texture) # Set shader uniform value and bind the texture (sampler2d) +Rl.unload_shader(shader:Rl::Shader) # Unload shader from GPU memory (VRAM) +Rl.get_screen_to_world_ray(position:Rl::Vector2, camera:Rl::Camera3D) -> Rl::Ray # Get a ray trace from screen position (i.e mouse) +Rl.get_screen_to_world_ray_ex(position:Rl::Vector2, camera:Rl::Camera3D, width:Integer, height:Integer) -> Rl::Ray # Get a ray trace from screen position (i.e mouse) in a viewport +Rl.get_world_to_screen(position:Rl::Vector3, camera:Rl::Camera3D) -> Rl::Vector2 # Get the screen space position for a 3d world space position +Rl.get_world_to_screen_ex(position:Rl::Vector3, camera:Rl::Camera3D, width:Integer, height:Integer) -> Rl::Vector2 # Get size position for a 3d world space position +Rl.get_world_to_screen2d(position:Rl::Vector2, camera:Rl::Camera2D) -> Rl::Vector2 # Get the screen space position for a 2d camera world space position +Rl.get_screen_to_world2d(position:Rl::Vector2, camera:Rl::Camera2D) -> Rl::Vector2 # Get the world space position for a 2d camera screen space position +Rl.get_camera_matrix(camera:Rl::Camera3D) -> Rl::Matrix # Get camera transform matrix (view matrix) +Rl.get_camera_matrix2d(camera:Rl::Camera2D) -> Rl::Matrix # Get camera 2d transform matrix +Rl.set_target_fps(fps:Integer) # Set target FPS (maximum) +Rl.get_frame_time -> Float # Get time in seconds for last frame drawn (delta time) +Rl.get_time -> Float # Get elapsed time in seconds since InitWindow() +Rl.get_fps -> Integer # Get current FPS +Rl.swap_screen_buffer # Swap back buffer with front buffer (screen drawing) +Rl.poll_input_events # Register all input events +Rl.wait_time(seconds:Float) # Wait for some time (halt program execution) +Rl.set_random_seed(seed:Integer) # Set the seed for the random number generator +Rl.get_random_value(min:Integer, max:Integer) -> Integer # Get a random value between min and max (both included) +Rl.take_screenshot(file_name:String) # Takes a screenshot of current screen (filename extension defines format) +Rl.set_config_flags(flags:Integer) # Setup init configuration flags (view FLAGS) +Rl.open_url(url:String) # Open URL with default system browser (if available) +Rl.set_trace_log_level(log_level:Integer) # Set the current threshold (minimum) log level +Rl.load_file_text(file_name:String) -> String # Load text data from file (read), returns a '\0' terminated string +Rl.save_file_text(file_name:String, text:String) -> Boolean # Save text data to file (write), string must be '\0' terminated, returns true on success +Rl.file_rename(file_name:String, file_rename:String) -> Integer # Rename file (if exists) +Rl.file_remove(file_name:String) -> Integer # Remove file (if exists) +Rl.file_copy(src_path:String, dst_path:String) -> Integer # Copy file from one path to another, dstPath created if it doesn't exist +Rl.file_move(src_path:String, dst_path:String) -> Integer # Move file from one directory to another, dstPath created if it doesn't exist +Rl.file_text_replace(file_name:String, search:String, replacement:String) -> Integer # Replace text in an existing file +Rl.file_text_find_index(file_name:String, search:String) -> Integer # Find text in existing file +Rl.file_exists(file_name:String) -> Boolean # Check if file exists +Rl.directory_exists(dir_path:String) -> Boolean # Check if a directory path exists +Rl.file_extension?(file_name:String, ext:String) -> Boolean # Check file extension (recommended include point: .png, .wav) +Rl.get_file_length(file_name:String) -> Integer # Get file length in bytes (NOTE: GetFileSize() conflicts with windows.h) +Rl.get_file_mod_time(file_name:String) -> Integer # Get file modification time (last write time) +Rl.get_file_extension(file_name:String) -> String # Get pointer to extension for a filename string (includes dot: '.png') +Rl.get_file_name(file_path:String) -> String # Get pointer to filename for a path string +Rl.get_file_name_without_ext(file_path:String) -> String # Get filename string without extension (uses static string) +Rl.get_directory_path(file_path:String) -> String # Get full path for a given fileName with path (uses static string) +Rl.get_prev_directory_path(dir_path:String) -> String # Get previous directory path for a given path (uses static string) +Rl.get_working_directory -> String # Get current working directory (uses static string) +Rl.get_application_directory -> String # Get the directory of the running application (uses static string) +Rl.make_directory(dir_path:String) -> Integer # Create directories (including full path requested), returns 0 on success +Rl.change_directory(dir_path:String) -> Boolean # Change working directory, return true on success +Rl.path_file?(path:String) -> Boolean # Check if a given path is a file or a directory +Rl.file_name_valid?(file_name:String) -> Boolean # Check if fileName is valid for the platform/OS +Rl.load_directory_files(dir_path:String) -> Rl::FilePathList # Load directory filepaths, files and directories, no subdirs scan +Rl.load_directory_files_ex(base_path:String, filter:String, scan_subdirs:Boolean) -> Rl::FilePathList # Load directory filepaths with extension filtering and subdir scan; some filters available: "*.*", "FILES*", "DIRS*" +Rl.unload_directory_files(files:Rl::FilePathList) # Unload filepaths +Rl.file_dropped? -> Boolean # Check if a file has been dropped into window +Rl.load_dropped_files -> Rl::FilePathList # Load dropped filepaths +Rl.unload_dropped_files(files:Rl::FilePathList) # Unload dropped filepaths +Rl.get_directory_file_count(dir_path:String) -> Integer # Get the file count in a directory +Rl.get_directory_file_count_ex(base_path:String, filter:String, scan_subdirs:Boolean) -> Integer # Get the file count in a directory with extension filtering and recursive directory scan. Use 'DIR' in the filter string to include directories in the result +Rl.load_automation_event_list(file_name:String) -> Rl::AutomationEventList # Load automation events list from file, NULL for empty list, capacity = MAX_AUTOMATION_EVENTS +Rl.unload_automation_event_list(list:Rl::AutomationEventList) # Unload automation events list from file +Rl.export_automation_event_list(list:Rl::AutomationEventList, file_name:String) -> Boolean # Export automation events list as text file +Rl.set_automation_event_list(list:Rl::AutomationEventList) # Set automation event list to record to +Rl.set_automation_event_base_frame(frame:Integer) # Set automation event internal base frame to start recording +Rl.start_automation_event_recording # Start recording automation events (AutomationEventList must be set) +Rl.stop_automation_event_recording # Stop recording automation events +Rl.play_automation_event(event:Rl::AutomationEvent) # Play a recorded automation event +Rl.key_pressed?(key:Integer) -> Boolean # Check if a key has been pressed once +Rl.key_pressed_repeat?(key:Integer) -> Boolean # Check if a key has been pressed again +Rl.key_down?(key:Integer) -> Boolean # Check if a key is being pressed +Rl.key_released?(key:Integer) -> Boolean # Check if a key has been released once +Rl.key_up?(key:Integer) -> Boolean # Check if a key is NOT being pressed +Rl.get_key_pressed -> Integer # Get key pressed (keycode), call it multiple times for keys queued, returns 0 when the queue is empty +Rl.get_char_pressed -> Integer # Get char pressed (unicode), call it multiple times for chars queued, returns 0 when the queue is empty +Rl.get_key_name(key:Integer) -> String # Get name of a QWERTY key on the current keyboard layout (eg returns string 'q' for KEY_A on an AZERTY keyboard) +Rl.set_exit_key(key:Integer) # Set a custom key to exit program (default is ESC) +Rl.gamepad_available?(gamepad:Integer) -> Boolean # Check if a gamepad is available +Rl.get_gamepad_name(gamepad:Integer) -> String # Get gamepad internal name id +Rl.gamepad_button_pressed?(gamepad:Integer, button:Integer) -> Boolean # Check if a gamepad button has been pressed once +Rl.gamepad_button_down?(gamepad:Integer, button:Integer) -> Boolean # Check if a gamepad button is being pressed +Rl.gamepad_button_released?(gamepad:Integer, button:Integer) -> Boolean # Check if a gamepad button has been released once +Rl.gamepad_button_up?(gamepad:Integer, button:Integer) -> Boolean # Check if a gamepad button is NOT being pressed +Rl.get_gamepad_button_pressed -> Integer # Get the last gamepad button pressed +Rl.get_gamepad_axis_count(gamepad:Integer) -> Integer # Get axis count for a gamepad +Rl.get_gamepad_axis_movement(gamepad:Integer, axis:Integer) -> Float # Get movement value for a gamepad axis +Rl.set_gamepad_mappings(mappings:String) -> Integer # Set internal gamepad mappings (SDL_GameControllerDB) +Rl.set_gamepad_vibration(gamepad:Integer, left_motor:Float, right_motor:Float, duration:Float) # Set gamepad vibration for both motors (duration in seconds) +Rl.mouse_button_pressed?(button:Integer) -> Boolean # Check if a mouse button has been pressed once +Rl.mouse_button_down?(button:Integer) -> Boolean # Check if a mouse button is being pressed +Rl.mouse_button_released?(button:Integer) -> Boolean # Check if a mouse button has been released once +Rl.mouse_button_up?(button:Integer) -> Boolean # Check if a mouse button is NOT being pressed +Rl.get_mouse_x -> Integer # Get mouse position X +Rl.get_mouse_y -> Integer # Get mouse position Y +Rl.get_mouse_position -> Rl::Vector2 # Get mouse position XY +Rl.get_mouse_delta -> Rl::Vector2 # Get mouse delta between frames +Rl.set_mouse_position(x:Integer, y:Integer) # Set mouse position XY +Rl.set_mouse_offset(offset_x:Integer, offset_y:Integer) # Set mouse offset +Rl.set_mouse_scale(scale_x:Float, scale_y:Float) # Set mouse scaling +Rl.get_mouse_wheel_move -> Float # Get mouse wheel movement for X or Y, whichever is larger +Rl.get_mouse_wheel_move_v -> Rl::Vector2 # Get mouse wheel movement for both X and Y +Rl.set_mouse_cursor(cursor:Integer) # Set mouse cursor +Rl.get_touch_x -> Integer # Get touch position X for touch point 0 (relative to screen size) +Rl.get_touch_y -> Integer # Get touch position Y for touch point 0 (relative to screen size) +Rl.get_touch_position(index:Integer) -> Rl::Vector2 # Get touch position XY for a touch point index (relative to screen size) +Rl.get_touch_point_id(index:Integer) -> Integer # Get touch point identifier for given index +Rl.get_touch_point_count -> Integer # Get number of touch points +Rl.set_gestures_enabled(flags:Integer) # Enable a set of gestures using flags +Rl.gesture_detected?(gesture:Integer) -> Boolean # Check if a gesture have been detected +Rl.get_gesture_detected -> Integer # Get latest detected gesture +Rl.get_gesture_hold_duration -> Float # Get gesture hold time in seconds +Rl.get_gesture_drag_vector -> Rl::Vector2 # Get gesture drag vector +Rl.get_gesture_drag_angle -> Float # Get gesture drag angle +Rl.get_gesture_pinch_vector -> Rl::Vector2 # Get gesture pinch delta +Rl.get_gesture_pinch_angle -> Float # Get gesture pinch angle +Rl.update_camera(camera:Rl::Camera3D, mode:Integer) # Update camera position for selected mode +Rl.update_camera_pro(camera:Rl::Camera3D, movement:Rl::Vector3, rotation:Rl::Vector3, zoom:Float) # Update camera movement/rotation +``` + +### shapes +```ruby +Rl.set_shapes_texture(texture:Rl::Texture, source:Rl::Rectangle) # Set texture and rectangle to be used on shapes drawing +Rl.get_shapes_texture -> Rl::Texture # Get texture that is used for shapes drawing +Rl.get_shapes_texture_rectangle -> Rl::Rectangle # Get texture source rectangle that is used for shapes drawing +Rl.draw_pixel(pos_x:Integer, pos_y:Integer, color:Rl::Color) # Draw a pixel using geometry [Can be slow, use with care] +Rl.draw_pixel_v(position:Rl::Vector2, color:Rl::Color) # Draw a pixel using geometry (Vector version) [Can be slow, use with care] +Rl.draw_line(start_pos_x:Integer, start_pos_y:Integer, end_pos_x:Integer, end_pos_y:Integer, color:Rl::Color) # Draw a line +Rl.draw_line_v(start_pos:Rl::Vector2, end_pos:Rl::Vector2, color:Rl::Color) # Draw a line (using gl lines) +Rl.draw_line_ex(start_pos:Rl::Vector2, end_pos:Rl::Vector2, thick:Float, color:Rl::Color) # Draw a line (using triangles/quads) +Rl.draw_line_strip(points:Rl::Vector2, point_count:Integer, color:Rl::Color) # Draw lines sequence (using gl lines) +Rl.draw_line_bezier(start_pos:Rl::Vector2, end_pos:Rl::Vector2, thick:Float, color:Rl::Color) # Draw line segment cubic-bezier in-out interpolation +Rl.draw_line_dashed(start_pos:Rl::Vector2, end_pos:Rl::Vector2, dash_size:Integer, space_size:Integer, color:Rl::Color) # Draw a dashed line +Rl.draw_circle(center_x:Integer, center_y:Integer, radius:Float, color:Rl::Color) # Draw a color-filled circle +Rl.draw_circle_v(center:Rl::Vector2, radius:Float, color:Rl::Color) # Draw a color-filled circle (Vector version) +Rl.draw_circle_gradient(center:Rl::Vector2, radius:Float, inner:Rl::Color, outer:Rl::Color) # Draw a gradient-filled circle +Rl.draw_circle_sector(center:Rl::Vector2, radius:Float, start_angle:Float, end_angle:Float, segments:Integer, color:Rl::Color) # Draw a piece of a circle +Rl.draw_circle_sector_lines(center:Rl::Vector2, radius:Float, start_angle:Float, end_angle:Float, segments:Integer, color:Rl::Color) # Draw circle sector outline +Rl.draw_circle_lines(center_x:Integer, center_y:Integer, radius:Float, color:Rl::Color) # Draw circle outline +Rl.draw_circle_lines_v(center:Rl::Vector2, radius:Float, color:Rl::Color) # Draw circle outline (Vector version) +Rl.draw_ellipse(center_x:Integer, center_y:Integer, radius_h:Float, radius_v:Float, color:Rl::Color) # Draw ellipse +Rl.draw_ellipse_v(center:Rl::Vector2, radius_h:Float, radius_v:Float, color:Rl::Color) # Draw ellipse (Vector version) +Rl.draw_ellipse_lines(center_x:Integer, center_y:Integer, radius_h:Float, radius_v:Float, color:Rl::Color) # Draw ellipse outline +Rl.draw_ellipse_lines_v(center:Rl::Vector2, radius_h:Float, radius_v:Float, color:Rl::Color) # Draw ellipse outline (Vector version) +Rl.draw_ring(center:Rl::Vector2, inner_radius:Float, outer_radius:Float, start_angle:Float, end_angle:Float, segments:Integer, color:Rl::Color) # Draw ring +Rl.draw_ring_lines(center:Rl::Vector2, inner_radius:Float, outer_radius:Float, start_angle:Float, end_angle:Float, segments:Integer, color:Rl::Color) # Draw ring outline +Rl.draw_rectangle(pos_x:Integer, pos_y:Integer, width:Integer, height:Integer, color:Rl::Color) # Draw a color-filled rectangle +Rl.draw_rectangle_v(position:Rl::Vector2, size:Rl::Vector2, color:Rl::Color) # Draw a color-filled rectangle (Vector version) +Rl.draw_rectangle_rec(rec:Rl::Rectangle, color:Rl::Color) # Draw a color-filled rectangle +Rl.draw_rectangle_pro(rec:Rl::Rectangle, origin:Rl::Vector2, rotation:Float, color:Rl::Color) # Draw a color-filled rectangle with pro parameters +Rl.draw_rectangle_gradient_v(pos_x:Integer, pos_y:Integer, width:Integer, height:Integer, top:Rl::Color, bottom:Rl::Color) # Draw a vertical-gradient-filled rectangle +Rl.draw_rectangle_gradient_h(pos_x:Integer, pos_y:Integer, width:Integer, height:Integer, left:Rl::Color, right:Rl::Color) # Draw a horizontal-gradient-filled rectangle +Rl.draw_rectangle_gradient_ex(rec:Rl::Rectangle, top_left:Rl::Color, bottom_left:Rl::Color, bottom_right:Rl::Color, top_right:Rl::Color) # Draw a gradient-filled rectangle with custom vertex colors +Rl.draw_rectangle_lines(pos_x:Integer, pos_y:Integer, width:Integer, height:Integer, color:Rl::Color) # Draw rectangle outline +Rl.draw_rectangle_lines_ex(rec:Rl::Rectangle, line_thick:Float, color:Rl::Color) # Draw rectangle outline with extended parameters +Rl.draw_rectangle_rounded(rec:Rl::Rectangle, roundness:Float, segments:Integer, color:Rl::Color) # Draw rectangle with rounded edges +Rl.draw_rectangle_rounded_lines(rec:Rl::Rectangle, roundness:Float, segments:Integer, color:Rl::Color) # Draw rectangle lines with rounded edges +Rl.draw_rectangle_rounded_lines_ex(rec:Rl::Rectangle, roundness:Float, segments:Integer, line_thick:Float, color:Rl::Color) # Draw rectangle with rounded edges outline +Rl.draw_triangle(v1:Rl::Vector2, v2:Rl::Vector2, v3:Rl::Vector2, color:Rl::Color) # Draw a color-filled triangle (vertex in counter-clockwise order!) +Rl.draw_triangle_lines(v1:Rl::Vector2, v2:Rl::Vector2, v3:Rl::Vector2, color:Rl::Color) # Draw triangle outline (vertex in counter-clockwise order!) +Rl.draw_triangle_fan(points:Rl::Vector2, point_count:Integer, color:Rl::Color) # Draw a triangle fan defined by points (first vertex is the center) +Rl.draw_triangle_strip(points:Rl::Vector2, point_count:Integer, color:Rl::Color) # Draw a triangle strip defined by points +Rl.draw_poly(center:Rl::Vector2, sides:Integer, radius:Float, rotation:Float, color:Rl::Color) # Draw a regular polygon (Vector version) +Rl.draw_poly_lines(center:Rl::Vector2, sides:Integer, radius:Float, rotation:Float, color:Rl::Color) # Draw a polygon outline of n sides +Rl.draw_poly_lines_ex(center:Rl::Vector2, sides:Integer, radius:Float, rotation:Float, line_thick:Float, color:Rl::Color) # Draw a polygon outline of n sides with extended parameters +Rl.draw_spline_linear(points:Rl::Vector2, point_count:Integer, thick:Float, color:Rl::Color) # Draw spline: Linear, minimum 2 points +Rl.draw_spline_basis(points:Rl::Vector2, point_count:Integer, thick:Float, color:Rl::Color) # Draw spline: B-Spline, minimum 4 points +Rl.draw_spline_catmull_rom(points:Rl::Vector2, point_count:Integer, thick:Float, color:Rl::Color) # Draw spline: Catmull-Rom, minimum 4 points +Rl.draw_spline_bezier_quadratic(points:Rl::Vector2, point_count:Integer, thick:Float, color:Rl::Color) # Draw spline: Quadratic Bezier, minimum 3 points (1 control point): [p1, c2, p3, c4...] +Rl.draw_spline_bezier_cubic(points:Rl::Vector2, point_count:Integer, thick:Float, color:Rl::Color) # Draw spline: Cubic Bezier, minimum 4 points (2 control points): [p1, c2, c3, p4, c5, c6...] +Rl.draw_spline_segment_linear(p1:Rl::Vector2, p2:Rl::Vector2, thick:Float, color:Rl::Color) # Draw spline segment: Linear, 2 points +Rl.draw_spline_segment_basis(p1:Rl::Vector2, p2:Rl::Vector2, p3:Rl::Vector2, p4:Rl::Vector2, thick:Float, color:Rl::Color) # Draw spline segment: B-Spline, 4 points +Rl.draw_spline_segment_catmull_rom(p1:Rl::Vector2, p2:Rl::Vector2, p3:Rl::Vector2, p4:Rl::Vector2, thick:Float, color:Rl::Color) # Draw spline segment: Catmull-Rom, 4 points +Rl.draw_spline_segment_bezier_quadratic(p1:Rl::Vector2, c2:Rl::Vector2, p3:Rl::Vector2, thick:Float, color:Rl::Color) # Draw spline segment: Quadratic Bezier, 2 points, 1 control point +Rl.draw_spline_segment_bezier_cubic(p1:Rl::Vector2, c2:Rl::Vector2, c3:Rl::Vector2, p4:Rl::Vector2, thick:Float, color:Rl::Color) # Draw spline segment: Cubic Bezier, 2 points, 2 control points +Rl.get_spline_point_linear(start_pos:Rl::Vector2, end_pos:Rl::Vector2, t:Float) -> Rl::Vector2 # Get (evaluate) spline point: Linear +Rl.get_spline_point_basis(p1:Rl::Vector2, p2:Rl::Vector2, p3:Rl::Vector2, p4:Rl::Vector2, t:Float) -> Rl::Vector2 # Get (evaluate) spline point: B-Spline +Rl.get_spline_point_catmull_rom(p1:Rl::Vector2, p2:Rl::Vector2, p3:Rl::Vector2, p4:Rl::Vector2, t:Float) -> Rl::Vector2 # Get (evaluate) spline point: Catmull-Rom +Rl.get_spline_point_bezier_quad(p1:Rl::Vector2, c2:Rl::Vector2, p3:Rl::Vector2, t:Float) -> Rl::Vector2 # Get (evaluate) spline point: Quadratic Bezier +Rl.get_spline_point_bezier_cubic(p1:Rl::Vector2, c2:Rl::Vector2, c3:Rl::Vector2, p4:Rl::Vector2, t:Float) -> Rl::Vector2 # Get (evaluate) spline point: Cubic Bezier +Rl.check_collision_recs(rec1:Rl::Rectangle, rec2:Rl::Rectangle) -> Boolean # Check collision between two rectangles +Rl.check_collision_circles(center1:Rl::Vector2, radius1:Float, center2:Rl::Vector2, radius2:Float) -> Boolean # Check collision between two circles +Rl.check_collision_circle_rec(center:Rl::Vector2, radius:Float, rec:Rl::Rectangle) -> Boolean # Check collision between circle and rectangle +Rl.check_collision_circle_line(center:Rl::Vector2, radius:Float, p1:Rl::Vector2, p2:Rl::Vector2) -> Boolean # Check if circle collides with a line created betweeen two points [p1] and [p2] +Rl.check_collision_point_rec(point:Rl::Vector2, rec:Rl::Rectangle) -> Boolean # Check if point is inside rectangle +Rl.check_collision_point_circle(point:Rl::Vector2, center:Rl::Vector2, radius:Float) -> Boolean # Check if point is inside circle +Rl.check_collision_point_triangle(point:Rl::Vector2, p1:Rl::Vector2, p2:Rl::Vector2, p3:Rl::Vector2) -> Boolean # Check if point is inside a triangle +Rl.check_collision_point_line(point:Rl::Vector2, p1:Rl::Vector2, p2:Rl::Vector2, threshold:Integer) -> Boolean # Check if point belongs to line created between two points [p1] and [p2] with defined margin in pixels [threshold] +Rl.check_collision_point_poly(point:Rl::Vector2, points:Rl::Vector2, point_count:Integer) -> Boolean # Check if point is within a polygon described by array of vertices +Rl.check_collision_lines(start_pos1:Rl::Vector2, end_pos1:Rl::Vector2, start_pos2:Rl::Vector2, end_pos2:Rl::Vector2, collision_point:Rl::Vector2) -> Boolean # Check the collision between two lines defined by two points each, returns collision point by reference +Rl.get_collision_rec(rec1:Rl::Rectangle, rec2:Rl::Rectangle) -> Rl::Rectangle # Get collision rectangle for two rectangles collision +``` + +### textures +```ruby +Rl.load_image(file_name:String) -> Rl::Image # Load image from file into CPU memory (RAM) +Rl.load_image_raw(file_name:String, width:Integer, height:Integer, format:Integer, header_size:Integer) -> Rl::Image # Load image from RAW file data +Rl.load_image_from_texture(texture:Rl::Texture) -> Rl::Image # Load image from GPU texture data +Rl.load_image_from_screen -> Rl::Image # Load image from screen buffer and (screenshot) +Rl.image_valid?(image:Rl::Image) -> Boolean # Check if an image is valid (data and parameters) +Rl.unload_image(image:Rl::Image) # Unload image from CPU memory (RAM) +Rl.export_image(image:Rl::Image, file_name:String) -> Boolean # Export image data to file, returns true on success +Rl.export_image_as_code(image:Rl::Image, file_name:String) -> Boolean # Export image as code file defining an array of bytes, returns true on success +Rl.gen_image_color(width:Integer, height:Integer, color:Rl::Color) -> Rl::Image # Generate image: plain color +Rl.gen_image_gradient_linear(width:Integer, height:Integer, direction:Integer, start:Rl::Color, end:Rl::Color) -> Rl::Image # Generate image: linear gradient, direction in degrees [0..360], 0=Vertical gradient +Rl.gen_image_gradient_radial(width:Integer, height:Integer, density:Float, inner:Rl::Color, outer:Rl::Color) -> Rl::Image # Generate image: radial gradient +Rl.gen_image_gradient_square(width:Integer, height:Integer, density:Float, inner:Rl::Color, outer:Rl::Color) -> Rl::Image # Generate image: square gradient +Rl.gen_image_checked(width:Integer, height:Integer, checks_x:Integer, checks_y:Integer, col1:Rl::Color, col2:Rl::Color) -> Rl::Image # Generate image: checked +Rl.gen_image_white_noise(width:Integer, height:Integer, factor:Float) -> Rl::Image # Generate image: white noise +Rl.gen_image_perlin_noise(width:Integer, height:Integer, offset_x:Integer, offset_y:Integer, scale:Float) -> Rl::Image # Generate image: perlin noise +Rl.gen_image_cellular(width:Integer, height:Integer, tile_size:Integer) -> Rl::Image # Generate image: cellular algorithm, bigger tileSize means bigger cells +Rl.gen_image_text(width:Integer, height:Integer, text:String) -> Rl::Image # Generate image: grayscale image from text data +Rl.image_copy(image:Rl::Image) -> Rl::Image # Create an image duplicate (useful for transformations) +Rl.image_from_image(image:Rl::Image, rec:Rl::Rectangle) -> Rl::Image # Create an image from another image piece +Rl.image_from_channel(image:Rl::Image, selected_channel:Integer) -> Rl::Image # Create an image from a selected channel of another image (GRAYSCALE) +Rl.image_text(text:String, font_size:Integer, color:Rl::Color) -> Rl::Image # Create an image from text (default font) +Rl.image_text_ex(font:Rl::Font, text:String, font_size:Float, spacing:Float, tint:Rl::Color) -> Rl::Image # Create an image from text (custom sprite font) +Rl.image_format(image:Rl::Image, new_format:Integer) # Convert image data to desired format +Rl.image_to_pot(image:Rl::Image, fill:Rl::Color) # Convert image to POT (power-of-two) +Rl.image_crop(image:Rl::Image, crop:Rl::Rectangle) # Crop an image to a defined rectangle +Rl.image_alpha_crop(image:Rl::Image, threshold:Float) # Crop image depending on alpha value +Rl.image_alpha_clear(image:Rl::Image, color:Rl::Color, threshold:Float) # Clear alpha channel to desired color +Rl.image_alpha_mask(image:Rl::Image, alpha_mask:Rl::Image) # Apply alpha mask to image +Rl.image_alpha_premultiply(image:Rl::Image) # Premultiply alpha channel +Rl.image_blur_gaussian(image:Rl::Image, blur_size:Integer) # Apply Gaussian blur using a box blur approximation +Rl.image_resize(image:Rl::Image, new_width:Integer, new_height:Integer) # Resize image (Bicubic scaling algorithm) +Rl.image_resize_nn(image:Rl::Image, new_width:Integer, new_height:Integer) # Resize image (Nearest-Neighbor scaling algorithm) +Rl.image_resize_canvas(image:Rl::Image, new_width:Integer, new_height:Integer, offset_x:Integer, offset_y:Integer, fill:Rl::Color) # Resize canvas and fill with color +Rl.image_mipmaps(image:Rl::Image) # Compute all mipmap levels for a provided image +Rl.image_dither(image:Rl::Image, r_bpp:Integer, g_bpp:Integer, b_bpp:Integer, a_bpp:Integer) # Dither image data to 16bpp or lower (Floyd-Steinberg dithering) +Rl.image_flip_vertical(image:Rl::Image) # Flip image vertically +Rl.image_flip_horizontal(image:Rl::Image) # Flip image horizontally +Rl.image_rotate(image:Rl::Image, degrees:Integer) # Rotate image by input angle in degrees (-359 to 359) +Rl.image_rotate_cw(image:Rl::Image) # Rotate image clockwise 90deg +Rl.image_rotate_ccw(image:Rl::Image) # Rotate image counter-clockwise 90deg +Rl.image_color_tint(image:Rl::Image, color:Rl::Color) # Modify image color: tint +Rl.image_color_invert(image:Rl::Image) # Modify image color: invert +Rl.image_color_grayscale(image:Rl::Image) # Modify image color: grayscale +Rl.image_color_contrast(image:Rl::Image, contrast:Float) # Modify image color: contrast (-100 to 100) +Rl.image_color_brightness(image:Rl::Image, brightness:Integer) # Modify image color: brightness (-255 to 255) +Rl.image_color_replace(image:Rl::Image, color:Rl::Color, replace:Rl::Color) # Modify image color: replace color +Rl.unload_image_colors(colors:Rl::Color) # Unload color data loaded with LoadImageColors() +Rl.unload_image_palette(colors:Rl::Color) # Unload colors palette loaded with LoadImagePalette() +Rl.get_image_alpha_border(image:Rl::Image, threshold:Float) -> Rl::Rectangle # Get image alpha border rectangle +Rl.get_image_color(image:Rl::Image, x:Integer, y:Integer) -> Rl::Color # Get image pixel color at (x, y) position +Rl.image_clear_background(dst:Rl::Image, color:Rl::Color) # Clear image background with given color +Rl.image_draw_pixel(dst:Rl::Image, pos_x:Integer, pos_y:Integer, color:Rl::Color) # Draw pixel within an image +Rl.image_draw_pixel_v(dst:Rl::Image, position:Rl::Vector2, color:Rl::Color) # Draw pixel within an image (Vector version) +Rl.image_draw_line(dst:Rl::Image, start_pos_x:Integer, start_pos_y:Integer, end_pos_x:Integer, end_pos_y:Integer, color:Rl::Color) # Draw line within an image +Rl.image_draw_line_v(dst:Rl::Image, start:Rl::Vector2, end:Rl::Vector2, color:Rl::Color) # Draw line within an image (Vector version) +Rl.image_draw_line_ex(dst:Rl::Image, start:Rl::Vector2, end:Rl::Vector2, thick:Integer, color:Rl::Color) # Draw a line defining thickness within an image +Rl.image_draw_circle(dst:Rl::Image, center_x:Integer, center_y:Integer, radius:Integer, color:Rl::Color) # Draw a filled circle within an image +Rl.image_draw_circle_v(dst:Rl::Image, center:Rl::Vector2, radius:Integer, color:Rl::Color) # Draw a filled circle within an image (Vector version) +Rl.image_draw_circle_lines(dst:Rl::Image, center_x:Integer, center_y:Integer, radius:Integer, color:Rl::Color) # Draw circle outline within an image +Rl.image_draw_circle_lines_v(dst:Rl::Image, center:Rl::Vector2, radius:Integer, color:Rl::Color) # Draw circle outline within an image (Vector version) +Rl.image_draw_rectangle(dst:Rl::Image, pos_x:Integer, pos_y:Integer, width:Integer, height:Integer, color:Rl::Color) # Draw rectangle within an image +Rl.image_draw_rectangle_v(dst:Rl::Image, position:Rl::Vector2, size:Rl::Vector2, color:Rl::Color) # Draw rectangle within an image (Vector version) +Rl.image_draw_rectangle_rec(dst:Rl::Image, rec:Rl::Rectangle, color:Rl::Color) # Draw rectangle within an image +Rl.image_draw_rectangle_lines(dst:Rl::Image, rec:Rl::Rectangle, thick:Integer, color:Rl::Color) # Draw rectangle lines within an image +Rl.image_draw_triangle(dst:Rl::Image, v1:Rl::Vector2, v2:Rl::Vector2, v3:Rl::Vector2, color:Rl::Color) # Draw triangle within an image +Rl.image_draw_triangle_ex(dst:Rl::Image, v1:Rl::Vector2, v2:Rl::Vector2, v3:Rl::Vector2, c1:Rl::Color, c2:Rl::Color, c3:Rl::Color) # Draw triangle with interpolated colors within an image +Rl.image_draw_triangle_lines(dst:Rl::Image, v1:Rl::Vector2, v2:Rl::Vector2, v3:Rl::Vector2, color:Rl::Color) # Draw triangle outline within an image +Rl.image_draw_triangle_fan(dst:Rl::Image, points:Rl::Vector2, point_count:Integer, color:Rl::Color) # Draw a triangle fan defined by points within an image (first vertex is the center) +Rl.image_draw_triangle_strip(dst:Rl::Image, points:Rl::Vector2, point_count:Integer, color:Rl::Color) # Draw a triangle strip defined by points within an image +Rl.image_draw(dst:Rl::Image, src:Rl::Image, src_rec:Rl::Rectangle, dst_rec:Rl::Rectangle, tint:Rl::Color) # Draw a source image within a destination image (tint applied to source) +Rl.image_draw_text(dst:Rl::Image, text:String, pos_x:Integer, pos_y:Integer, font_size:Integer, color:Rl::Color) # Draw text (using default font) within an image (destination) +Rl.image_draw_text_ex(dst:Rl::Image, font:Rl::Font, text:String, position:Rl::Vector2, font_size:Float, spacing:Float, tint:Rl::Color) # Draw text (custom sprite font) within an image (destination) +Rl.load_texture(file_name:String) -> Rl::Texture # Load texture from file into GPU memory (VRAM) +Rl.load_texture_from_image(image:Rl::Image) -> Rl::Texture # Load texture from image data +Rl.load_texture_cubemap(image:Rl::Image, layout:Integer) -> Rl::Texture # Load cubemap from image, multiple image cubemap layouts supported +Rl.load_render_texture(width:Integer, height:Integer) -> Rl::RenderTexture # Load texture for rendering (framebuffer) +Rl.texture_valid?(texture:Rl::Texture) -> Boolean # Check if a texture is valid (loaded in GPU) +Rl.unload_texture(texture:Rl::Texture) # Unload texture from GPU memory (VRAM) +Rl.render_texture_valid?(target:Rl::RenderTexture) -> Boolean # Check if a render texture is valid (loaded in GPU) +Rl.unload_render_texture(target:Rl::RenderTexture) # Unload render texture from GPU memory (VRAM) +Rl.gen_texture_mipmaps(texture:Rl::Texture) # Generate GPU mipmaps for a texture +Rl.set_texture_filter(texture:Rl::Texture, filter:Integer) # Set texture scaling filter mode +Rl.set_texture_wrap(texture:Rl::Texture, wrap:Integer) # Set texture wrapping mode +Rl.draw_texture(texture:Rl::Texture, pos_x:Integer, pos_y:Integer, tint:Rl::Color) # Draw a Texture2D +Rl.draw_texture_v(texture:Rl::Texture, position:Rl::Vector2, tint:Rl::Color) # Draw a Texture2D with position defined as Vector2 +Rl.draw_texture_ex(texture:Rl::Texture, position:Rl::Vector2, rotation:Float, scale:Float, tint:Rl::Color) # Draw a Texture2D with extended parameters +Rl.draw_texture_rec(texture:Rl::Texture, source:Rl::Rectangle, position:Rl::Vector2, tint:Rl::Color) # Draw a part of a texture defined by a rectangle +Rl.draw_texture_pro(texture:Rl::Texture, source:Rl::Rectangle, dest:Rl::Rectangle, origin:Rl::Vector2, rotation:Float, tint:Rl::Color) # Draw a part of a texture defined by a rectangle with 'pro' parameters +Rl.draw_texture_n_patch(texture:Rl::Texture, n_patch_info:Rl::NPatchInfo, dest:Rl::Rectangle, origin:Rl::Vector2, rotation:Float, tint:Rl::Color) # Draws a texture (or part of it) that stretches or shrinks nicely +Rl.color_is_equal(col1:Rl::Color, col2:Rl::Color) -> Boolean # Check if two colors are equal +Rl.fade(color:Rl::Color, alpha:Float) -> Rl::Color # Get color with alpha applied, alpha goes from 0.0f to 1.0f +Rl.color_to_int(color:Rl::Color) -> Integer # Get hexadecimal value for a Color (0xRRGGBBAA) +Rl.color_normalize(color:Rl::Color) -> Rl::Vector4 # Get Color normalized as float [0..1] +Rl.color_from_normalized(normalized:Rl::Vector4) -> Rl::Color # Get Color from normalized values [0..1] +Rl.color_to_hsv(color:Rl::Color) -> Rl::Vector3 # Get HSV values for a Color, hue [0..360], saturation/value [0..1] +Rl.color_from_hsv(hue:Float, saturation:Float, value:Float) -> Rl::Color # Get a Color from HSV values, hue [0..360], saturation/value [0..1] +Rl.color_tint(color:Rl::Color, tint:Rl::Color) -> Rl::Color # Get color multiplied with another color +Rl.color_brightness(color:Rl::Color, factor:Float) -> Rl::Color # Get color with brightness correction, brightness factor goes from -1.0f to 1.0f +Rl.color_contrast(color:Rl::Color, contrast:Float) -> Rl::Color # Get color with contrast correction, contrast values between -1.0f and 1.0f +Rl.color_alpha(color:Rl::Color, alpha:Float) -> Rl::Color # Get color with alpha applied, alpha goes from 0.0f to 1.0f +Rl.color_alpha_blend(dst:Rl::Color, src:Rl::Color, tint:Rl::Color) -> Rl::Color # Get src alpha-blended into dst color with tint +Rl.color_lerp(color1:Rl::Color, color2:Rl::Color, factor:Float) -> Rl::Color # Get color lerp interpolation between two colors, factor [0.0f..1.0f] +Rl.get_color(hex_value:Integer) -> Rl::Color # Get Color structure from hexadecimal value +Rl.get_pixel_data_size(width:Integer, height:Integer, format:Integer) -> Integer # Get pixel data size in bytes for certain format +``` + +### text +```ruby +Rl.get_font_default -> Rl::Font # Get the default Font +Rl.load_font(file_name:String) -> Rl::Font # Load font from file into GPU memory (VRAM) +Rl.load_font_from_image(image:Rl::Image, key:Rl::Color, first_char:Integer) -> Rl::Font # Load font from Image (XNA style) +Rl.font_valid?(font:Rl::Font) -> Boolean # Check if a font is valid (font data loaded, WARNING: GPU texture not checked) +Rl.unload_font_data(glyphs:Rl::GlyphInfo, glyph_count:Integer) # Unload font chars info data (RAM) +Rl.unload_font(font:Rl::Font) # Unload font from GPU memory (VRAM) +Rl.export_font_as_code(font:Rl::Font, file_name:String) -> Boolean # Export font as code file, returns true on success +Rl.draw_fps(pos_x:Integer, pos_y:Integer) # Draw current FPS +Rl.draw_text(text:String, pos_x:Integer, pos_y:Integer, font_size:Integer, color:Rl::Color) # Draw text (using default font) +Rl.draw_text_ex(font:Rl::Font, text:String, position:Rl::Vector2, font_size:Float, spacing:Float, tint:Rl::Color) # Draw text using font and additional parameters +Rl.draw_text_pro(font:Rl::Font, text:String, position:Rl::Vector2, origin:Rl::Vector2, rotation:Float, font_size:Float, spacing:Float, tint:Rl::Color) # Draw text using Font and pro parameters (rotation) +Rl.draw_text_codepoint(font:Rl::Font, codepoint:Integer, position:Rl::Vector2, font_size:Float, tint:Rl::Color) # Draw one character (codepoint) +Rl.set_text_line_spacing(spacing:Integer) # Set vertical line spacing when drawing with line-breaks +Rl.measure_text(text:String, font_size:Integer) -> Integer # Measure string width for default font +Rl.measure_text_ex(font:Rl::Font, text:String, font_size:Float, spacing:Float) -> Rl::Vector2 # Measure string size for Font +Rl.get_glyph_index(font:Rl::Font, codepoint:Integer) -> Integer # Get glyph index position in font for a codepoint (unicode character), fallback to '?' if not found +Rl.get_glyph_info(font:Rl::Font, codepoint:Integer) -> Rl::GlyphInfo # Get glyph font info data for a codepoint (unicode character), fallback to '?' if not found +Rl.get_glyph_atlas_rec(font:Rl::Font, codepoint:Integer) -> Rl::Rectangle # Get glyph rectangle in font atlas for a codepoint (unicode character), fallback to '?' if not found +Rl.get_codepoint_count(text:String) -> Integer # Get total number of codepoints in a UTF-8 encoded string +Rl.text_is_equal(text1:String, text2:String) -> Boolean # Check if two text string are equal +Rl.text_length(text:String) -> Integer # Get text length, checks for '\0' ending +Rl.text_subtext(text:String, position:Integer, length:Integer) -> String # Get a piece of a text string +Rl.text_remove_spaces(text:String) -> String # Remove text spaces, concat words +Rl.get_text_between(text:String, begin:String, end:String) -> String # Get text between two strings +Rl.text_replace(text:String, search:String, replacement:String) -> String # Replace text string with new string +Rl.text_replace_alloc(text:String, search:String, replacement:String) -> String # Replace text string with new string, memory must be MemFree() +Rl.text_replace_between(text:String, begin:String, end:String, replacement:String) -> String # Replace text between two specific strings +Rl.text_replace_between_alloc(text:String, begin:String, end:String, replacement:String) -> String # Replace text between two specific strings, memory must be MemFree() +Rl.text_insert(text:String, insert:String, position:Integer) -> String # Insert text in a defined byte position +Rl.text_insert_alloc(text:String, insert:String, position:Integer) -> String # Insert text in a defined byte position, memory must be MemFree() +Rl.text_find_index(text:String, search:String) -> Integer # Find first text occurrence within a string, -1 if not found +Rl.text_to_upper(text:String) -> String # Get upper case version of provided string +Rl.text_to_lower(text:String) -> String # Get lower case version of provided string +Rl.text_to_pascal(text:String) -> String # Get Pascal case notation version of provided string +Rl.text_to_snake(text:String) -> String # Get Snake case notation version of provided string +Rl.text_to_camel(text:String) -> String # Get Camel case notation version of provided string +Rl.text_to_integer(text:String) -> Integer # Get integer value from text +Rl.text_to_float(text:String) -> Float # Get float value from text +``` + +### models +```ruby +Rl.draw_line3d(start_pos:Rl::Vector3, end_pos:Rl::Vector3, color:Rl::Color) # Draw a line in 3D world space +Rl.draw_point3d(position:Rl::Vector3, color:Rl::Color) # Draw a point in 3D space, actually a small line +Rl.draw_circle3d(center:Rl::Vector3, radius:Float, rotation_axis:Rl::Vector3, rotation_angle:Float, color:Rl::Color) # Draw a circle in 3D world space +Rl.draw_triangle3d(v1:Rl::Vector3, v2:Rl::Vector3, v3:Rl::Vector3, color:Rl::Color) # Draw a color-filled triangle (vertex in counter-clockwise order!) +Rl.draw_triangle_strip3d(points:Rl::Vector3, point_count:Integer, color:Rl::Color) # Draw a triangle strip defined by points +Rl.draw_cube(position:Rl::Vector3, width:Float, height:Float, length:Float, color:Rl::Color) # Draw cube +Rl.draw_cube_v(position:Rl::Vector3, size:Rl::Vector3, color:Rl::Color) # Draw cube (Vector version) +Rl.draw_cube_wires(position:Rl::Vector3, width:Float, height:Float, length:Float, color:Rl::Color) # Draw cube wires +Rl.draw_cube_wires_v(position:Rl::Vector3, size:Rl::Vector3, color:Rl::Color) # Draw cube wires (Vector version) +Rl.draw_sphere(center_pos:Rl::Vector3, radius:Float, color:Rl::Color) # Draw sphere +Rl.draw_sphere_ex(center_pos:Rl::Vector3, radius:Float, rings:Integer, slices:Integer, color:Rl::Color) # Draw sphere with extended parameters +Rl.draw_sphere_wires(center_pos:Rl::Vector3, radius:Float, rings:Integer, slices:Integer, color:Rl::Color) # Draw sphere wires +Rl.draw_cylinder(position:Rl::Vector3, radius_top:Float, radius_bottom:Float, height:Float, slices:Integer, color:Rl::Color) # Draw a cylinder/cone +Rl.draw_cylinder_ex(start_pos:Rl::Vector3, end_pos:Rl::Vector3, start_radius:Float, end_radius:Float, sides:Integer, color:Rl::Color) # Draw a cylinder with base at startPos and top at endPos +Rl.draw_cylinder_wires(position:Rl::Vector3, radius_top:Float, radius_bottom:Float, height:Float, slices:Integer, color:Rl::Color) # Draw a cylinder/cone wires +Rl.draw_cylinder_wires_ex(start_pos:Rl::Vector3, end_pos:Rl::Vector3, start_radius:Float, end_radius:Float, sides:Integer, color:Rl::Color) # Draw a cylinder wires with base at startPos and top at endPos +Rl.draw_capsule(start_pos:Rl::Vector3, end_pos:Rl::Vector3, radius:Float, slices:Integer, rings:Integer, color:Rl::Color) # Draw a capsule with the center of its sphere caps at startPos and endPos +Rl.draw_capsule_wires(start_pos:Rl::Vector3, end_pos:Rl::Vector3, radius:Float, slices:Integer, rings:Integer, color:Rl::Color) # Draw capsule wireframe with the center of its sphere caps at startPos and endPos +Rl.draw_plane(center_pos:Rl::Vector3, size:Rl::Vector2, color:Rl::Color) # Draw a plane XZ +Rl.draw_ray(ray:Rl::Ray, color:Rl::Color) # Draw a ray line +Rl.draw_grid(slices:Integer, spacing:Float) # Draw a grid (centered at (0, 0, 0)) +Rl.load_model(file_name:String) -> Rl::Model # Load model from files (meshes and materials) +Rl.load_model_from_mesh(mesh:Rl::Mesh) -> Rl::Model # Load model from generated mesh (default material) +Rl.model_valid?(model:Rl::Model) -> Boolean # Check if a model is valid (loaded in GPU, VAO/VBOs) +Rl.unload_model(model:Rl::Model) # Unload model (including meshes) from memory (RAM and/or VRAM) +Rl.get_model_bounding_box(model:Rl::Model) -> Rl::BoundingBox # Compute model bounding box limits (considers all meshes) +Rl.draw_model(model:Rl::Model, position:Rl::Vector3, scale:Float, tint:Rl::Color) # Draw a model (with texture if set) +Rl.draw_model_ex(model:Rl::Model, position:Rl::Vector3, rotation_axis:Rl::Vector3, rotation_angle:Float, scale:Rl::Vector3, tint:Rl::Color) # Draw a model with extended parameters +Rl.draw_model_wires(model:Rl::Model, position:Rl::Vector3, scale:Float, tint:Rl::Color) # Draw a model wires (with texture if set) +Rl.draw_model_wires_ex(model:Rl::Model, position:Rl::Vector3, rotation_axis:Rl::Vector3, rotation_angle:Float, scale:Rl::Vector3, tint:Rl::Color) # Draw a model wires (with texture if set) with extended parameters +Rl.draw_bounding_box(box:Rl::BoundingBox, color:Rl::Color) # Draw bounding box (wires) +Rl.draw_billboard(camera:Rl::Camera3D, texture:Rl::Texture, position:Rl::Vector3, scale:Float, tint:Rl::Color) # Draw a billboard texture +Rl.draw_billboard_rec(camera:Rl::Camera3D, texture:Rl::Texture, source:Rl::Rectangle, position:Rl::Vector3, size:Rl::Vector2, tint:Rl::Color) # Draw a billboard texture defined by source +Rl.draw_billboard_pro(camera:Rl::Camera3D, texture:Rl::Texture, source:Rl::Rectangle, position:Rl::Vector3, up:Rl::Vector3, size:Rl::Vector2, origin:Rl::Vector2, rotation:Float, tint:Rl::Color) # Draw a billboard texture defined by source and rotation +Rl.upload_mesh(mesh:Rl::Mesh, dynamic:Boolean) # Upload mesh vertex data in GPU and provide VAO/VBO ids +Rl.unload_mesh(mesh:Rl::Mesh) # Unload mesh data from CPU and GPU +Rl.draw_mesh(mesh:Rl::Mesh, material:Rl::Material, transform:Rl::Matrix) # Draw a 3d mesh with material and transform +Rl.draw_mesh_instanced(mesh:Rl::Mesh, material:Rl::Material, transforms:Rl::Matrix, instances:Integer) # Draw multiple mesh instances with material and different transforms +Rl.get_mesh_bounding_box(mesh:Rl::Mesh) -> Rl::BoundingBox # Compute mesh bounding box limits +Rl.gen_mesh_tangents(mesh:Rl::Mesh) # Compute mesh tangents +Rl.export_mesh(mesh:Rl::Mesh, file_name:String) -> Boolean # Export mesh data to file, returns true on success +Rl.export_mesh_as_code(mesh:Rl::Mesh, file_name:String) -> Boolean # Export mesh as code file (.h) defining multiple arrays of vertex attributes +Rl.gen_mesh_poly(sides:Integer, radius:Float) -> Rl::Mesh # Generate polygonal mesh +Rl.gen_mesh_plane(width:Float, length:Float, res_x:Integer, res_z:Integer) -> Rl::Mesh # Generate plane mesh (with subdivisions) +Rl.gen_mesh_cube(width:Float, height:Float, length:Float) -> Rl::Mesh # Generate cuboid mesh +Rl.gen_mesh_sphere(radius:Float, rings:Integer, slices:Integer) -> Rl::Mesh # Generate sphere mesh (standard sphere) +Rl.gen_mesh_hemi_sphere(radius:Float, rings:Integer, slices:Integer) -> Rl::Mesh # Generate half-sphere mesh (no bottom cap) +Rl.gen_mesh_cylinder(radius:Float, height:Float, slices:Integer) -> Rl::Mesh # Generate cylinder mesh +Rl.gen_mesh_cone(radius:Float, height:Float, slices:Integer) -> Rl::Mesh # Generate cone/pyramid mesh +Rl.gen_mesh_torus(radius:Float, size:Float, rad_seg:Integer, sides:Integer) -> Rl::Mesh # Generate torus mesh +Rl.gen_mesh_knot(radius:Float, size:Float, rad_seg:Integer, sides:Integer) -> Rl::Mesh # Generate trefoil knot mesh +Rl.gen_mesh_heightmap(heightmap:Rl::Image, size:Rl::Vector3) -> Rl::Mesh # Generate heightmap mesh from image data +Rl.gen_mesh_cubicmap(cubicmap:Rl::Image, cube_size:Rl::Vector3) -> Rl::Mesh # Generate cubes-based map mesh from image data +Rl.load_material_default -> Rl::Material # Load default material (Supports: DIFFUSE, SPECULAR, NORMAL maps) +Rl.material_valid?(material:Rl::Material) -> Boolean # Check if a material is valid (shader assigned, map textures loaded in GPU) +Rl.unload_material(material:Rl::Material) # Unload material from GPU memory (VRAM) +Rl.set_material_texture(material:Rl::Material, map_type:Integer, texture:Rl::Texture) # Set texture for a material map type (MATERIAL_MAP_DIFFUSE, MATERIAL_MAP_SPECULAR...) +Rl.set_model_mesh_material(model:Rl::Model, mesh_id:Integer, material_id:Integer) # Set material for a mesh +Rl.update_model_animation(model:Rl::Model, anim:Rl::ModelAnimation, frame:Float) # Update model animation pose (vertex buffers and bone matrices) +Rl.update_model_animation_ex(model:Rl::Model, anim_a:Rl::ModelAnimation, frame_a:Float, anim_b:Rl::ModelAnimation, frame_b:Float, blend:Float) # Update model animation pose, blending two animations +Rl.unload_model_animations(animations:Rl::ModelAnimation, anim_count:Integer) # Unload animation array data +Rl.model_animation_valid?(model:Rl::Model, anim:Rl::ModelAnimation) -> Boolean # Check model animation skeleton match +Rl.check_collision_spheres(center1:Rl::Vector3, radius1:Float, center2:Rl::Vector3, radius2:Float) -> Boolean # Check collision between two spheres +Rl.check_collision_boxes(box1:Rl::BoundingBox, box2:Rl::BoundingBox) -> Boolean # Check collision between two bounding boxes +Rl.check_collision_box_sphere(box:Rl::BoundingBox, center:Rl::Vector3, radius:Float) -> Boolean # Check collision between box and sphere +Rl.get_ray_collision_sphere(ray:Rl::Ray, center:Rl::Vector3, radius:Float) -> Rl::RayCollision # Get collision info between ray and sphere +Rl.get_ray_collision_box(ray:Rl::Ray, box:Rl::BoundingBox) -> Rl::RayCollision # Get collision info between ray and box +Rl.get_ray_collision_mesh(ray:Rl::Ray, mesh:Rl::Mesh, transform:Rl::Matrix) -> Rl::RayCollision # Get collision info between ray and mesh +Rl.get_ray_collision_triangle(ray:Rl::Ray, p1:Rl::Vector3, p2:Rl::Vector3, p3:Rl::Vector3) -> Rl::RayCollision # Get collision info between ray and triangle +Rl.get_ray_collision_quad(ray:Rl::Ray, p1:Rl::Vector3, p2:Rl::Vector3, p3:Rl::Vector3, p4:Rl::Vector3) -> Rl::RayCollision # Get collision info between ray and quad +``` + +### audio +```ruby +Rl.init_audio_device # Initialize audio device and context +Rl.close_audio_device # Close the audio device and context +Rl.audio_device_ready? -> Boolean # Check if audio device has been initialized successfully +Rl.set_master_volume(volume:Float) # Set master volume (listener) +Rl.get_master_volume -> Float # Get master volume (listener) +Rl.load_wave(file_name:String) -> Rl::Wave # Load wave data from file +Rl.wave_valid?(wave:Rl::Wave) -> Boolean # Checks if wave data is valid (data loaded and parameters) +Rl.load_sound(file_name:String) -> Rl::Sound # Load sound from file +Rl.load_sound_from_wave(wave:Rl::Wave) -> Rl::Sound # Load sound from wave data +Rl.load_sound_alias(source:Rl::Sound) -> Rl::Sound # Create a new sound that shares the same sample data as the source sound, does not own the sound data +Rl.sound_valid?(sound:Rl::Sound) -> Boolean # Checks if a sound is valid (data loaded and buffers initialized) +Rl.unload_wave(wave:Rl::Wave) # Unload wave data +Rl.unload_sound(sound:Rl::Sound) # Unload sound +Rl.unload_sound_alias(alias:Rl::Sound) # Unload a sound alias (does not deallocate sample data) +Rl.export_wave(wave:Rl::Wave, file_name:String) -> Boolean # Export wave data to file, returns true on success +Rl.export_wave_as_code(wave:Rl::Wave, file_name:String) -> Boolean # Export wave sample data to code (.h), returns true on success +Rl.play_sound(sound:Rl::Sound) # Play a sound +Rl.stop_sound(sound:Rl::Sound) # Stop playing a sound +Rl.pause_sound(sound:Rl::Sound) # Pause a sound +Rl.resume_sound(sound:Rl::Sound) # Resume a paused sound +Rl.sound_playing?(sound:Rl::Sound) -> Boolean # Check if a sound is currently playing +Rl.set_sound_volume(sound:Rl::Sound, volume:Float) # Set volume for a sound (1.0 is max level) +Rl.set_sound_pitch(sound:Rl::Sound, pitch:Float) # Set pitch for a sound (1.0 is base level) +Rl.set_sound_pan(sound:Rl::Sound, pan:Float) # Set pan for a sound (-1.0 left, 0.0 center, 1.0 right) +Rl.wave_copy(wave:Rl::Wave) -> Rl::Wave # Copy a wave to a new wave +Rl.wave_crop(wave:Rl::Wave, init_frame:Integer, final_frame:Integer) # Crop a wave to defined frames range +Rl.wave_format(wave:Rl::Wave, sample_rate:Integer, sample_size:Integer, channels:Integer) # Convert wave data to desired format +Rl.load_music_stream(file_name:String) -> Rl::Music # Load music stream from file +Rl.music_valid?(music:Rl::Music) -> Boolean # Checks if a music stream is valid (context and buffers initialized) +Rl.unload_music_stream(music:Rl::Music) # Unload music stream +Rl.play_music_stream(music:Rl::Music) # Start music playing +Rl.music_stream_playing?(music:Rl::Music) -> Boolean # Check if music is playing +Rl.update_music_stream(music:Rl::Music) # Updates buffers for music streaming +Rl.stop_music_stream(music:Rl::Music) # Stop music playing +Rl.pause_music_stream(music:Rl::Music) # Pause music playing +Rl.resume_music_stream(music:Rl::Music) # Resume playing paused music +Rl.seek_music_stream(music:Rl::Music, position:Float) # Seek music to a position (in seconds) +Rl.set_music_volume(music:Rl::Music, volume:Float) # Set volume for music (1.0 is max level) +Rl.set_music_pitch(music:Rl::Music, pitch:Float) # Set pitch for a music (1.0 is base level) +Rl.set_music_pan(music:Rl::Music, pan:Float) # Set pan for a music (-1.0 left, 0.0 center, 1.0 right) +Rl.get_music_time_length(music:Rl::Music) -> Float # Get music time length (in seconds) +Rl.get_music_time_played(music:Rl::Music) -> Float # Get current music time played (in seconds) +Rl.load_audio_stream(sample_rate:Integer, sample_size:Integer, channels:Integer) -> Rl::AudioStream # Load audio stream (to stream raw audio pcm data) +Rl.audio_stream_valid?(stream:Rl::AudioStream) -> Boolean # Checks if an audio stream is valid (buffers initialized) +Rl.unload_audio_stream(stream:Rl::AudioStream) # Unload audio stream and free memory +Rl.audio_stream_processed?(stream:Rl::AudioStream) -> Boolean # Check if any audio stream buffers requires refill +Rl.play_audio_stream(stream:Rl::AudioStream) # Play audio stream +Rl.pause_audio_stream(stream:Rl::AudioStream) # Pause audio stream +Rl.resume_audio_stream(stream:Rl::AudioStream) # Resume audio stream +Rl.audio_stream_playing?(stream:Rl::AudioStream) -> Boolean # Check if audio stream is playing +Rl.stop_audio_stream(stream:Rl::AudioStream) # Stop audio stream +Rl.set_audio_stream_volume(stream:Rl::AudioStream, volume:Float) # Set volume for audio stream (1.0 is max level) +Rl.set_audio_stream_pitch(stream:Rl::AudioStream, pitch:Float) # Set pitch for audio stream (1.0 is base level) +Rl.set_audio_stream_pan(stream:Rl::AudioStream, pan:Float) # Set pan for audio stream (-1.0 to 1.0 range, 0.0 is centered) +Rl.set_audio_stream_buffer_size_default(size:Integer) # Default size for new audio streams +``` + +## raymath functions +```ruby +Rl.clamp(value:Float, min:Float, max:Float) -> Float +Rl.lerp(start:Float, end:Float, amount:Float) -> Float +Rl.normalize(value:Float, start:Float, end:Float) -> Float +Rl.remap(value:Float, input_start:Float, input_end:Float, output_start:Float, output_end:Float) -> Float +Rl.wrap(value:Float, min:Float, max:Float) -> Float +Rl.float_equals(x:Float, y:Float) -> Integer +Rl.vector2_zero -> Rl::Vector2 +Rl.vector2_one -> Rl::Vector2 +Rl.vector2_add(v1:Rl::Vector2, v2:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_add_value(v:Rl::Vector2, add:Float) -> Rl::Vector2 +Rl.vector2_subtract(v1:Rl::Vector2, v2:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_subtract_value(v:Rl::Vector2, sub:Float) -> Rl::Vector2 +Rl.vector2_length(v:Rl::Vector2) -> Float +Rl.vector2_length_sqr(v:Rl::Vector2) -> Float +Rl.vector2_dot_product(v1:Rl::Vector2, v2:Rl::Vector2) -> Float +Rl.vector2_cross_product(v1:Rl::Vector2, v2:Rl::Vector2) -> Float +Rl.vector2_distance(v1:Rl::Vector2, v2:Rl::Vector2) -> Float +Rl.vector2_distance_sqr(v1:Rl::Vector2, v2:Rl::Vector2) -> Float +Rl.vector2_angle(v1:Rl::Vector2, v2:Rl::Vector2) -> Float +Rl.vector2_line_angle(start:Rl::Vector2, end:Rl::Vector2) -> Float +Rl.vector2_scale(v:Rl::Vector2, scale:Float) -> Rl::Vector2 +Rl.vector2_multiply(v1:Rl::Vector2, v2:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_negate(v:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_divide(v1:Rl::Vector2, v2:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_normalize(v:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_transform(v:Rl::Vector2, mat:Rl::Matrix) -> Rl::Vector2 +Rl.vector2_lerp(v1:Rl::Vector2, v2:Rl::Vector2, amount:Float) -> Rl::Vector2 +Rl.vector2_reflect(v:Rl::Vector2, normal:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_min(v1:Rl::Vector2, v2:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_max(v1:Rl::Vector2, v2:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_rotate(v:Rl::Vector2, angle:Float) -> Rl::Vector2 +Rl.vector2_move_towards(v:Rl::Vector2, target:Rl::Vector2, max_distance:Float) -> Rl::Vector2 +Rl.vector2_invert(v:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_clamp(v:Rl::Vector2, min:Rl::Vector2, max:Rl::Vector2) -> Rl::Vector2 +Rl.vector2_clamp_value(v:Rl::Vector2, min:Float, max:Float) -> Rl::Vector2 +Rl.vector2_equals(p:Rl::Vector2, q:Rl::Vector2) -> Integer +Rl.vector2_refract(v:Rl::Vector2, n:Rl::Vector2, r:Float) -> Rl::Vector2 +Rl.vector3_zero -> Rl::Vector3 +Rl.vector3_one -> Rl::Vector3 +Rl.vector3_add(v1:Rl::Vector3, v2:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_add_value(v:Rl::Vector3, add:Float) -> Rl::Vector3 +Rl.vector3_subtract(v1:Rl::Vector3, v2:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_subtract_value(v:Rl::Vector3, sub:Float) -> Rl::Vector3 +Rl.vector3_scale(v:Rl::Vector3, scalar:Float) -> Rl::Vector3 +Rl.vector3_multiply(v1:Rl::Vector3, v2:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_cross_product(v1:Rl::Vector3, v2:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_perpendicular(v:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_length(v:Rl::Vector3) -> Float +Rl.vector3_length_sqr(v:Rl::Vector3) -> Float +Rl.vector3_dot_product(v1:Rl::Vector3, v2:Rl::Vector3) -> Float +Rl.vector3_distance(v1:Rl::Vector3, v2:Rl::Vector3) -> Float +Rl.vector3_distance_sqr(v1:Rl::Vector3, v2:Rl::Vector3) -> Float +Rl.vector3_angle(v1:Rl::Vector3, v2:Rl::Vector3) -> Float +Rl.vector3_negate(v:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_divide(v1:Rl::Vector3, v2:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_normalize(v:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_project(v1:Rl::Vector3, v2:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_reject(v1:Rl::Vector3, v2:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_ortho_normalize(v1:Rl::Vector3, v2:Rl::Vector3) +Rl.vector3_transform(v:Rl::Vector3, mat:Rl::Matrix) -> Rl::Vector3 +Rl.vector3_rotate_by_quaternion(v:Rl::Vector3, q:Rl::Vector4) -> Rl::Vector3 +Rl.vector3_rotate_by_axis_angle(v:Rl::Vector3, axis:Rl::Vector3, angle:Float) -> Rl::Vector3 +Rl.vector3_move_towards(v:Rl::Vector3, target:Rl::Vector3, max_distance:Float) -> Rl::Vector3 +Rl.vector3_lerp(v1:Rl::Vector3, v2:Rl::Vector3, amount:Float) -> Rl::Vector3 +Rl.vector3_cubic_hermite(v1:Rl::Vector3, tangent1:Rl::Vector3, v2:Rl::Vector3, tangent2:Rl::Vector3, amount:Float) -> Rl::Vector3 +Rl.vector3_reflect(v:Rl::Vector3, normal:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_min(v1:Rl::Vector3, v2:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_max(v1:Rl::Vector3, v2:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_barycenter(p:Rl::Vector3, a:Rl::Vector3, b:Rl::Vector3, c:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_unproject(source:Rl::Vector3, projection:Rl::Matrix, view:Rl::Matrix) -> Rl::Vector3 +Rl.vector3_invert(v:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_clamp(v:Rl::Vector3, min:Rl::Vector3, max:Rl::Vector3) -> Rl::Vector3 +Rl.vector3_clamp_value(v:Rl::Vector3, min:Float, max:Float) -> Rl::Vector3 +Rl.vector3_equals(p:Rl::Vector3, q:Rl::Vector3) -> Integer +Rl.vector3_refract(v:Rl::Vector3, n:Rl::Vector3, r:Float) -> Rl::Vector3 +Rl.vector4_zero -> Rl::Vector4 +Rl.vector4_one -> Rl::Vector4 +Rl.vector4_add(v1:Rl::Vector4, v2:Rl::Vector4) -> Rl::Vector4 +Rl.vector4_add_value(v:Rl::Vector4, add:Float) -> Rl::Vector4 +Rl.vector4_subtract(v1:Rl::Vector4, v2:Rl::Vector4) -> Rl::Vector4 +Rl.vector4_subtract_value(v:Rl::Vector4, add:Float) -> Rl::Vector4 +Rl.vector4_length(v:Rl::Vector4) -> Float +Rl.vector4_length_sqr(v:Rl::Vector4) -> Float +Rl.vector4_dot_product(v1:Rl::Vector4, v2:Rl::Vector4) -> Float +Rl.vector4_distance(v1:Rl::Vector4, v2:Rl::Vector4) -> Float +Rl.vector4_distance_sqr(v1:Rl::Vector4, v2:Rl::Vector4) -> Float +Rl.vector4_scale(v:Rl::Vector4, scale:Float) -> Rl::Vector4 +Rl.vector4_multiply(v1:Rl::Vector4, v2:Rl::Vector4) -> Rl::Vector4 +Rl.vector4_negate(v:Rl::Vector4) -> Rl::Vector4 +Rl.vector4_divide(v1:Rl::Vector4, v2:Rl::Vector4) -> Rl::Vector4 +Rl.vector4_normalize(v:Rl::Vector4) -> Rl::Vector4 +Rl.vector4_min(v1:Rl::Vector4, v2:Rl::Vector4) -> Rl::Vector4 +Rl.vector4_max(v1:Rl::Vector4, v2:Rl::Vector4) -> Rl::Vector4 +Rl.vector4_lerp(v1:Rl::Vector4, v2:Rl::Vector4, amount:Float) -> Rl::Vector4 +Rl.vector4_move_towards(v:Rl::Vector4, target:Rl::Vector4, max_distance:Float) -> Rl::Vector4 +Rl.vector4_invert(v:Rl::Vector4) -> Rl::Vector4 +Rl.vector4_equals(p:Rl::Vector4, q:Rl::Vector4) -> Integer +Rl.matrix_determinant(mat:Rl::Matrix) -> Float +Rl.matrix_trace(mat:Rl::Matrix) -> Float +Rl.matrix_transpose(mat:Rl::Matrix) -> Rl::Matrix +Rl.matrix_invert(mat:Rl::Matrix) -> Rl::Matrix +Rl.matrix_identity -> Rl::Matrix +Rl.matrix_add(left:Rl::Matrix, right:Rl::Matrix) -> Rl::Matrix +Rl.matrix_subtract(left:Rl::Matrix, right:Rl::Matrix) -> Rl::Matrix +Rl.matrix_multiply(left:Rl::Matrix, right:Rl::Matrix) -> Rl::Matrix +Rl.matrix_multiply_value(left:Rl::Matrix, value:Float) -> Rl::Matrix +Rl.matrix_translate(x:Float, y:Float, z:Float) -> Rl::Matrix +Rl.matrix_rotate(axis:Rl::Vector3, angle:Float) -> Rl::Matrix +Rl.matrix_rotate_x(angle:Float) -> Rl::Matrix +Rl.matrix_rotate_y(angle:Float) -> Rl::Matrix +Rl.matrix_rotate_z(angle:Float) -> Rl::Matrix +Rl.matrix_rotate_xyz(angle:Rl::Vector3) -> Rl::Matrix +Rl.matrix_rotate_zyx(angle:Rl::Vector3) -> Rl::Matrix +Rl.matrix_scale(x:Float, y:Float, z:Float) -> Rl::Matrix +Rl.matrix_frustum(left:Float, right:Float, bottom:Float, top:Float, near_plane:Float, far_plane:Float) -> Rl::Matrix +Rl.matrix_perspective(fov_y:Float, aspect:Float, near_plane:Float, far_plane:Float) -> Rl::Matrix +Rl.matrix_ortho(left:Float, right:Float, bottom:Float, top:Float, near_plane:Float, far_plane:Float) -> Rl::Matrix +Rl.matrix_look_at(eye:Rl::Vector3, target:Rl::Vector3, up:Rl::Vector3) -> Rl::Matrix +Rl.quaternion_add(q1:Rl::Vector4, q2:Rl::Vector4) -> Rl::Vector4 +Rl.quaternion_add_value(q:Rl::Vector4, add:Float) -> Rl::Vector4 +Rl.quaternion_subtract(q1:Rl::Vector4, q2:Rl::Vector4) -> Rl::Vector4 +Rl.quaternion_subtract_value(q:Rl::Vector4, sub:Float) -> Rl::Vector4 +Rl.quaternion_identity -> Rl::Vector4 +Rl.quaternion_length(q:Rl::Vector4) -> Float +Rl.quaternion_normalize(q:Rl::Vector4) -> Rl::Vector4 +Rl.quaternion_invert(q:Rl::Vector4) -> Rl::Vector4 +Rl.quaternion_multiply(q1:Rl::Vector4, q2:Rl::Vector4) -> Rl::Vector4 +Rl.quaternion_scale(q:Rl::Vector4, mul:Float) -> Rl::Vector4 +Rl.quaternion_divide(q1:Rl::Vector4, q2:Rl::Vector4) -> Rl::Vector4 +Rl.quaternion_lerp(q1:Rl::Vector4, q2:Rl::Vector4, amount:Float) -> Rl::Vector4 +Rl.quaternion_nlerp(q1:Rl::Vector4, q2:Rl::Vector4, amount:Float) -> Rl::Vector4 +Rl.quaternion_slerp(q1:Rl::Vector4, q2:Rl::Vector4, amount:Float) -> Rl::Vector4 +Rl.quaternion_cubic_hermite_spline(q1:Rl::Vector4, out_tangent1:Rl::Vector4, q2:Rl::Vector4, in_tangent2:Rl::Vector4, t:Float) -> Rl::Vector4 +Rl.quaternion_from_vector3_to_vector3(from:Rl::Vector3, to:Rl::Vector3) -> Rl::Vector4 +Rl.quaternion_from_matrix(mat:Rl::Matrix) -> Rl::Vector4 +Rl.quaternion_to_matrix(q:Rl::Vector4) -> Rl::Matrix +Rl.quaternion_from_axis_angle(axis:Rl::Vector3, angle:Float) -> Rl::Vector4 +Rl.quaternion_from_euler(pitch:Float, yaw:Float, roll:Float) -> Rl::Vector4 +Rl.quaternion_to_euler(q:Rl::Vector4) -> Rl::Vector3 +Rl.quaternion_transform(q:Rl::Vector4, mat:Rl::Matrix) -> Rl::Vector4 +Rl.quaternion_equals(p:Rl::Vector4, q:Rl::Vector4) -> Integer +Rl.matrix_compose(translation:Rl::Vector3, rotation:Rl::Vector4, scale:Rl::Vector3) -> Rl::Matrix +Rl.matrix_decompose(mat:Rl::Matrix, translation:Rl::Vector3, rotation:Rl::Vector4, scale:Rl::Vector3) +``` + +## Structs +Constructor args are positional in the order shown; every listed field has +`obj.field` (read) and `obj.field=` (write). Pointer/array fields (if any) +are omitted (not accessible). +```ruby +Rl::Vector2.new(x:Float, y:Float) # Vector2, 2 components +Rl::Vector3.new(x:Float, y:Float, z:Float) # Vector3, 3 components +Rl::Vector4.new(x:Float, y:Float, z:Float, w:Float) # Vector4, 4 components +Rl::Matrix.new(m0:Float, m4:Float, m8:Float, m12:Float, m1:Float, m5:Float, m9:Float, m13:Float, m2:Float, m6:Float, m10:Float, m14:Float, m3:Float, m7:Float, m11:Float, m15:Float) # Matrix, 4x4 components, column major, OpenGL style, right-handed +Rl::Color.new(r:Integer, g:Integer, b:Integer, a:Integer) # Color, 4 components, R8G8B8A8 (32bit) +Rl::Rectangle.new(x:Float, y:Float, width:Float, height:Float) # Rectangle, 4 components +Rl::Image.new(width:Integer, height:Integer, mipmaps:Integer, format:Integer) # Image, pixel data stored in CPU memory (RAM) +Rl::Texture.new(id:Integer, width:Integer, height:Integer, mipmaps:Integer, format:Integer) # Texture, tex data stored in GPU memory (VRAM) +Rl::RenderTexture.new(id:Integer, texture:Rl::Texture, depth:Rl::Texture) # RenderTexture, fbo for texture rendering +Rl::NPatchInfo.new(source:Rl::Rectangle, left:Integer, top:Integer, right:Integer, bottom:Integer, layout:Integer) # NPatchInfo, n-patch layout info +Rl::GlyphInfo.new(value:Integer, offsetX:Integer, offsetY:Integer, advanceX:Integer, image:Rl::Image) # GlyphInfo, font characters glyphs info +Rl::Font.new(baseSize:Integer, glyphCount:Integer, glyphPadding:Integer, texture:Rl::Texture, recs:Rl::Rectangle, glyphs:Rl::GlyphInfo) # Font, font texture and GlyphInfo array data +Rl::Camera3D.new(position:Rl::Vector3, target:Rl::Vector3, up:Rl::Vector3, fovy:Float, projection:Integer) # Camera, defines position/orientation in 3d space +Rl::Camera2D.new(offset:Rl::Vector2, target:Rl::Vector2, rotation:Float, zoom:Float) # Camera2D, defines position/orientation in 2d space +Rl::Mesh.new(vertexCount:Integer, triangleCount:Integer, boneCount:Integer, vaoId:Integer) # Mesh, vertex data and vao/vbo +Rl::Shader.new(id:Integer) # Shader +Rl::MaterialMap.new(texture:Rl::Texture, color:Rl::Color, value:Float) # MaterialMap +Rl::Material.new(shader:Rl::Shader, maps:Rl::MaterialMap, params:Integer) # Material, includes shader and maps +Rl::Transform.new(translation:Rl::Vector3, rotation:Rl::Vector4, scale:Rl::Vector3) # Transform, vertex transformation data +Rl::BoneInfo.new(name:Integer, parent:Integer) # Bone, skeletal animation bone +Rl::ModelSkeleton.new(boneCount:Integer, bones:Rl::BoneInfo, bindPose:Integer) # Skeleton, animation bones hierarchy +Rl::Model.new(transform:Rl::Matrix, meshCount:Integer, materialCount:Integer, meshes:Rl::Mesh, materials:Rl::Material, skeleton:Rl::ModelSkeleton, currentPose:Integer, boneMatrices:Rl::Matrix) # Model, meshes, materials and animation data +Rl::ModelAnimation.new(name:Integer, boneCount:Integer, keyframeCount:Integer) # ModelAnimation, contains a full animation sequence +Rl::Ray.new(position:Rl::Vector3, direction:Rl::Vector3) # Ray, ray for raycasting +Rl::RayCollision.new(hit:Boolean, distance:Float, point:Rl::Vector3, normal:Rl::Vector3) # RayCollision, ray hit information +Rl::BoundingBox.new(min:Rl::Vector3, max:Rl::Vector3) # BoundingBox +Rl::Wave.new(frameCount:Integer, sampleRate:Integer, sampleSize:Integer, channels:Integer) # Wave, audio wave data +Rl::AudioStream.new(sampleRate:Integer, sampleSize:Integer, channels:Integer) # AudioStream, custom audio stream +Rl::Sound.new(stream:Rl::AudioStream, frameCount:Integer) # Sound +Rl::Music.new(stream:Rl::AudioStream, frameCount:Integer, looping:Boolean, ctxType:Integer) # Music, audio stream, anything longer than ~10 seconds should be streamed +Rl::VrDeviceInfo.new(hResolution:Integer, vResolution:Integer, hScreenSize:Float, vScreenSize:Float, eyeToScreenDistance:Float, lensSeparationDistance:Float, interpupillaryDistance:Float, lensDistortionValues:Integer, chromaAbCorrection:Integer) # VrDeviceInfo, Head-Mounted-Display device parameters +Rl::VrStereoConfig.new(projection:Integer, viewOffset:Integer, leftLensCenter:Integer, rightLensCenter:Integer, leftScreenCenter:Integer, rightScreenCenter:Integer, scale:Integer, scaleIn:Integer) # VrStereoConfig, VR stereo rendering configuration for simulator +Rl::FilePathList.new(count:Integer) # File path list +Rl::AutomationEvent.new(frame:Integer, type:Integer, params:Integer) # Automation event +Rl::AutomationEventList.new(capacity:Integer, count:Integer, events:Rl::AutomationEvent) # Automation event list +``` +Aliases (same class): Quaternion=Vector4, Texture2D=Texture, TextureCubemap=Texture, RenderTexture2D=RenderTexture, Camera=Camera3D, ModelAnimPose=Transform + +## Enums (constants under Rl::) +``` +# ConfigFlags: System/Window config flags +FLAG_VSYNC_HINT=64 FLAG_FULLSCREEN_MODE=2 FLAG_WINDOW_RESIZABLE=4 FLAG_WINDOW_UNDECORATED=8 FLAG_WINDOW_HIDDEN=128 FLAG_WINDOW_MINIMIZED=512 FLAG_WINDOW_MAXIMIZED=1024 FLAG_WINDOW_UNFOCUSED=2048 FLAG_WINDOW_TOPMOST=4096 FLAG_WINDOW_ALWAYS_RUN=256 FLAG_WINDOW_TRANSPARENT=16 FLAG_WINDOW_HIGHDPI=8192 FLAG_WINDOW_MOUSE_PASSTHROUGH=16384 FLAG_BORDERLESS_WINDOWED_MODE=32768 FLAG_MSAA_4X_HINT=32 FLAG_INTERLACED_HINT=65536 +# TraceLogLevel: Trace log level +LOG_ALL=0 LOG_TRACE=1 LOG_DEBUG=2 LOG_INFO=3 LOG_WARNING=4 LOG_ERROR=5 LOG_FATAL=6 LOG_NONE=7 +# KeyboardKey: Keyboard keys (US keyboard layout) +KEY_NULL=0 KEY_APOSTROPHE=39 KEY_COMMA=44 KEY_MINUS=45 KEY_PERIOD=46 KEY_SLASH=47 KEY_ZERO=48 KEY_ONE=49 KEY_TWO=50 KEY_THREE=51 KEY_FOUR=52 KEY_FIVE=53 KEY_SIX=54 KEY_SEVEN=55 KEY_EIGHT=56 KEY_NINE=57 KEY_SEMICOLON=59 KEY_EQUAL=61 KEY_A=65 KEY_B=66 KEY_C=67 KEY_D=68 KEY_E=69 KEY_F=70 KEY_G=71 KEY_H=72 KEY_I=73 KEY_J=74 KEY_K=75 KEY_L=76 KEY_M=77 KEY_N=78 KEY_O=79 KEY_P=80 KEY_Q=81 KEY_R=82 KEY_S=83 KEY_T=84 KEY_U=85 KEY_V=86 KEY_W=87 KEY_X=88 KEY_Y=89 KEY_Z=90 KEY_LEFT_BRACKET=91 KEY_BACKSLASH=92 KEY_RIGHT_BRACKET=93 KEY_GRAVE=96 KEY_SPACE=32 KEY_ESCAPE=256 KEY_ENTER=257 KEY_TAB=258 KEY_BACKSPACE=259 KEY_INSERT=260 KEY_DELETE=261 KEY_RIGHT=262 KEY_LEFT=263 KEY_DOWN=264 KEY_UP=265 KEY_PAGE_UP=266 KEY_PAGE_DOWN=267 KEY_HOME=268 KEY_END=269 KEY_CAPS_LOCK=280 KEY_SCROLL_LOCK=281 KEY_NUM_LOCK=282 KEY_PRINT_SCREEN=283 KEY_PAUSE=284 KEY_F1=290 KEY_F2=291 KEY_F3=292 KEY_F4=293 KEY_F5=294 KEY_F6=295 KEY_F7=296 KEY_F8=297 KEY_F9=298 KEY_F10=299 KEY_F11=300 KEY_F12=301 KEY_LEFT_SHIFT=340 KEY_LEFT_CONTROL=341 KEY_LEFT_ALT=342 KEY_LEFT_SUPER=343 KEY_RIGHT_SHIFT=344 KEY_RIGHT_CONTROL=345 KEY_RIGHT_ALT=346 KEY_RIGHT_SUPER=347 KEY_KB_MENU=348 KEY_KP_0=320 KEY_KP_1=321 KEY_KP_2=322 KEY_KP_3=323 KEY_KP_4=324 KEY_KP_5=325 KEY_KP_6=326 KEY_KP_7=327 KEY_KP_8=328 KEY_KP_9=329 KEY_KP_DECIMAL=330 KEY_KP_DIVIDE=331 KEY_KP_MULTIPLY=332 KEY_KP_SUBTRACT=333 KEY_KP_ADD=334 KEY_KP_ENTER=335 KEY_KP_EQUAL=336 KEY_BACK=4 KEY_MENU=5 KEY_VOLUME_UP=24 KEY_VOLUME_DOWN=25 +# MouseButton: Mouse buttons +MOUSE_BUTTON_LEFT=0 MOUSE_BUTTON_RIGHT=1 MOUSE_BUTTON_MIDDLE=2 MOUSE_BUTTON_SIDE=3 MOUSE_BUTTON_EXTRA=4 MOUSE_BUTTON_FORWARD=5 MOUSE_BUTTON_BACK=6 +# MouseCursor: Mouse cursor +MOUSE_CURSOR_DEFAULT=0 MOUSE_CURSOR_ARROW=1 MOUSE_CURSOR_IBEAM=2 MOUSE_CURSOR_CROSSHAIR=3 MOUSE_CURSOR_POINTING_HAND=4 MOUSE_CURSOR_RESIZE_EW=5 MOUSE_CURSOR_RESIZE_NS=6 MOUSE_CURSOR_RESIZE_NWSE=7 MOUSE_CURSOR_RESIZE_NESW=8 MOUSE_CURSOR_RESIZE_ALL=9 MOUSE_CURSOR_NOT_ALLOWED=10 +# GamepadButton: Gamepad buttons +GAMEPAD_BUTTON_UNKNOWN=0 GAMEPAD_BUTTON_LEFT_FACE_UP=1 GAMEPAD_BUTTON_LEFT_FACE_RIGHT=2 GAMEPAD_BUTTON_LEFT_FACE_DOWN=3 GAMEPAD_BUTTON_LEFT_FACE_LEFT=4 GAMEPAD_BUTTON_RIGHT_FACE_UP=5 GAMEPAD_BUTTON_RIGHT_FACE_RIGHT=6 GAMEPAD_BUTTON_RIGHT_FACE_DOWN=7 GAMEPAD_BUTTON_RIGHT_FACE_LEFT=8 GAMEPAD_BUTTON_LEFT_TRIGGER_1=9 GAMEPAD_BUTTON_LEFT_TRIGGER_2=10 GAMEPAD_BUTTON_RIGHT_TRIGGER_1=11 GAMEPAD_BUTTON_RIGHT_TRIGGER_2=12 GAMEPAD_BUTTON_MIDDLE_LEFT=13 GAMEPAD_BUTTON_MIDDLE=14 GAMEPAD_BUTTON_MIDDLE_RIGHT=15 GAMEPAD_BUTTON_LEFT_THUMB=16 GAMEPAD_BUTTON_RIGHT_THUMB=17 +# GamepadAxis: Gamepad axes +GAMEPAD_AXIS_LEFT_X=0 GAMEPAD_AXIS_LEFT_Y=1 GAMEPAD_AXIS_RIGHT_X=2 GAMEPAD_AXIS_RIGHT_Y=3 GAMEPAD_AXIS_LEFT_TRIGGER=4 GAMEPAD_AXIS_RIGHT_TRIGGER=5 +# MaterialMapIndex: Material map index +MATERIAL_MAP_ALBEDO=0 MATERIAL_MAP_METALNESS=1 MATERIAL_MAP_NORMAL=2 MATERIAL_MAP_ROUGHNESS=3 MATERIAL_MAP_OCCLUSION=4 MATERIAL_MAP_EMISSION=5 MATERIAL_MAP_HEIGHT=6 MATERIAL_MAP_CUBEMAP=7 MATERIAL_MAP_IRRADIANCE=8 MATERIAL_MAP_PREFILTER=9 MATERIAL_MAP_BRDF=10 +# ShaderLocationIndex: Shader location index +SHADER_LOC_VERTEX_POSITION=0 SHADER_LOC_VERTEX_TEXCOORD01=1 SHADER_LOC_VERTEX_TEXCOORD02=2 SHADER_LOC_VERTEX_NORMAL=3 SHADER_LOC_VERTEX_TANGENT=4 SHADER_LOC_VERTEX_COLOR=5 SHADER_LOC_MATRIX_MVP=6 SHADER_LOC_MATRIX_VIEW=7 SHADER_LOC_MATRIX_PROJECTION=8 SHADER_LOC_MATRIX_MODEL=9 SHADER_LOC_MATRIX_NORMAL=10 SHADER_LOC_VECTOR_VIEW=11 SHADER_LOC_COLOR_DIFFUSE=12 SHADER_LOC_COLOR_SPECULAR=13 SHADER_LOC_COLOR_AMBIENT=14 SHADER_LOC_MAP_ALBEDO=15 SHADER_LOC_MAP_METALNESS=16 SHADER_LOC_MAP_NORMAL=17 SHADER_LOC_MAP_ROUGHNESS=18 SHADER_LOC_MAP_OCCLUSION=19 SHADER_LOC_MAP_EMISSION=20 SHADER_LOC_MAP_HEIGHT=21 SHADER_LOC_MAP_CUBEMAP=22 SHADER_LOC_MAP_IRRADIANCE=23 SHADER_LOC_MAP_PREFILTER=24 SHADER_LOC_MAP_BRDF=25 SHADER_LOC_VERTEX_BONEIDS=26 SHADER_LOC_VERTEX_BONEWEIGHTS=27 SHADER_LOC_MATRIX_BONETRANSFORMS=28 SHADER_LOC_VERTEX_INSTANCETRANSFORM=29 +# ShaderUniformDataType: Shader uniform data type +SHADER_UNIFORM_FLOAT=0 SHADER_UNIFORM_VEC2=1 SHADER_UNIFORM_VEC3=2 SHADER_UNIFORM_VEC4=3 SHADER_UNIFORM_INT=4 SHADER_UNIFORM_IVEC2=5 SHADER_UNIFORM_IVEC3=6 SHADER_UNIFORM_IVEC4=7 SHADER_UNIFORM_UINT=8 SHADER_UNIFORM_UIVEC2=9 SHADER_UNIFORM_UIVEC3=10 SHADER_UNIFORM_UIVEC4=11 SHADER_UNIFORM_SAMPLER2D=12 +# ShaderAttributeDataType: Shader attribute data types +SHADER_ATTRIB_FLOAT=0 SHADER_ATTRIB_VEC2=1 SHADER_ATTRIB_VEC3=2 SHADER_ATTRIB_VEC4=3 +# PixelFormat: Pixel formats +PIXELFORMAT_UNCOMPRESSED_GRAYSCALE=1 PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA=2 PIXELFORMAT_UNCOMPRESSED_R5G6B5=3 PIXELFORMAT_UNCOMPRESSED_R8G8B8=4 PIXELFORMAT_UNCOMPRESSED_R5G5B5A1=5 PIXELFORMAT_UNCOMPRESSED_R4G4B4A4=6 PIXELFORMAT_UNCOMPRESSED_R8G8B8A8=7 PIXELFORMAT_UNCOMPRESSED_R32=8 PIXELFORMAT_UNCOMPRESSED_R32G32B32=9 PIXELFORMAT_UNCOMPRESSED_R32G32B32A32=10 PIXELFORMAT_UNCOMPRESSED_R16=11 PIXELFORMAT_UNCOMPRESSED_R16G16B16=12 PIXELFORMAT_UNCOMPRESSED_R16G16B16A16=13 PIXELFORMAT_COMPRESSED_DXT1_RGB=14 PIXELFORMAT_COMPRESSED_DXT1_RGBA=15 PIXELFORMAT_COMPRESSED_DXT3_RGBA=16 PIXELFORMAT_COMPRESSED_DXT5_RGBA=17 PIXELFORMAT_COMPRESSED_ETC1_RGB=18 PIXELFORMAT_COMPRESSED_ETC2_RGB=19 PIXELFORMAT_COMPRESSED_ETC2_EAC_RGBA=20 PIXELFORMAT_COMPRESSED_PVRT_RGB=21 PIXELFORMAT_COMPRESSED_PVRT_RGBA=22 PIXELFORMAT_COMPRESSED_ASTC_4x4_RGBA=23 PIXELFORMAT_COMPRESSED_ASTC_8x8_RGBA=24 +# TextureFilter: Texture parameters: filter mode +TEXTURE_FILTER_POINT=0 TEXTURE_FILTER_BILINEAR=1 TEXTURE_FILTER_TRILINEAR=2 TEXTURE_FILTER_ANISOTROPIC_4X=3 TEXTURE_FILTER_ANISOTROPIC_8X=4 TEXTURE_FILTER_ANISOTROPIC_16X=5 +# TextureWrap: Texture parameters: wrap mode +TEXTURE_WRAP_REPEAT=0 TEXTURE_WRAP_CLAMP=1 TEXTURE_WRAP_MIRROR_REPEAT=2 TEXTURE_WRAP_MIRROR_CLAMP=3 +# CubemapLayout: Cubemap layouts +CUBEMAP_LAYOUT_AUTO_DETECT=0 CUBEMAP_LAYOUT_LINE_VERTICAL=1 CUBEMAP_LAYOUT_LINE_HORIZONTAL=2 CUBEMAP_LAYOUT_CROSS_THREE_BY_FOUR=3 CUBEMAP_LAYOUT_CROSS_FOUR_BY_THREE=4 +# FontType: Font type, defines generation method +FONT_DEFAULT=0 FONT_BITMAP=1 FONT_SDF=2 +# BlendMode: Color blending modes (pre-defined) +BLEND_ALPHA=0 BLEND_ADDITIVE=1 BLEND_MULTIPLIED=2 BLEND_ADD_COLORS=3 BLEND_SUBTRACT_COLORS=4 BLEND_ALPHA_PREMULTIPLY=5 BLEND_CUSTOM=6 BLEND_CUSTOM_SEPARATE=7 +# Gesture: Gesture +GESTURE_NONE=0 GESTURE_TAP=1 GESTURE_DOUBLETAP=2 GESTURE_HOLD=4 GESTURE_DRAG=8 GESTURE_SWIPE_RIGHT=16 GESTURE_SWIPE_LEFT=32 GESTURE_SWIPE_UP=64 GESTURE_SWIPE_DOWN=128 GESTURE_PINCH_IN=256 GESTURE_PINCH_OUT=512 +# CameraMode: Camera system modes +CAMERA_CUSTOM=0 CAMERA_FREE=1 CAMERA_ORBITAL=2 CAMERA_FIRST_PERSON=3 CAMERA_THIRD_PERSON=4 +# CameraProjection: Camera projection +CAMERA_PERSPECTIVE=0 CAMERA_ORTHOGRAPHIC=1 +# NPatchLayout: N-patch layout +NPATCH_NINE_PATCH=0 NPATCH_THREE_PATCH_VERTICAL=1 NPATCH_THREE_PATCH_HORIZONTAL=2 +``` + +## Other constants under Rl:: +``` +# Colors (Rl::Color constants) +LIGHTGRAY GRAY DARKGRAY YELLOW GOLD ORANGE PINK RED MAROON GREEN LIME DARKGREEN SKYBLUE BLUE DARKBLUE PURPLE VIOLET DARKPURPLE BEIGE BROWN DARKBROWN WHITE BLACK BLANK MAGENTA RAYWHITE +# Numeric +RAYLIB_VERSION_MAJOR=6 RAYLIB_VERSION_MINOR=0 RAYLIB_VERSION_PATCH=0 PI=3.141592653589793 +# String +RAYLIB_VERSION="6.0" +``` + +## RmlUi (HTML/CSS UI; call Rml.init AFTER Rl.init_window) +```ruby +# setup / lifecycle +Rml.init # -> nil (inits RmlUi + rlgl backend) +Rml.load_font(path:String, fallback:false) # register .ttf +Rml.shutdown +ctx = Rml::Context.new(name:String, width:Integer=screen_w, height:Integer=screen_h) +ctx.resize(width:Integer, height:Integer) +ctx.dimensions = Rl::Vector2 + +# per-frame: process_input before block, update+render after (exception-safe) +ctx.frame { ...mutate ui... } +ctx.process_input ; ctx.update ; ctx.render # manual equivalent + +# documents +doc = ctx.load_document(path:String) { |doc| ... } # -> Rml::Document +ctx.document(id:String) # -> Rml::Element (already-loaded lookup) | nil +ctx.num_documents # -> Integer +doc.show ; doc.hide ; doc.close ; doc.pull_to_front ; doc.push_to_back +doc.title ; doc.title = String + +# Rml::Element (Document is a subclass) +el[name] # get attribute -> String|nil ; el[name] = value +el.attribute(name) ; el.set_attribute(name, v) ; el.has_attribute?(name) ; el.remove_attribute(name) +el.id ; el.id = v ; el.tag_name +el.inner_rml ; el.inner_rml = html ; el.text ; el.text = s +el.add_class(c) ; el.remove_class(c) ; el.set_class(c, bool) ; el.class_set?(c) +el.set_property("color","red") ; el.property(name) ; el.remove_property(name) +el.focus ; el.blur ; el.click ; el.scroll_into_view(align_top=true) ; el.visible? +el.element(id) # alias get_element_by_id -> Element|nil +el.query_selector(sel) ; el.query_selector_all(sel) ; el.elements_by_tag(tag) +el.parent ; el.child_count ; el.child(i) ; el.children ; el.owner_document +el.client_width ; el.client_height ; el.offset_left ; el.offset_top ; el.absolute_left ; el.absolute_top +el.on(:click) { |event| ... } # event types: click, mouseover, change, submit, ... + +# Rml::Event (passed to el.on) +ev.type ; ev.target ; ev.current ; ev.stop_propagation ; ev.stop_immediate_propagation +ev[key] -> Float ; ev.param(key) -> Float ; ev.param_str(key) -> String ; ev.mouse_x ; ev.mouse_y + +# MVC data model (binds Ruby to {{vars}} / data-* in RML). Create BEFORE load_document. +m = ctx.data_model(name:String) do |m| + m.bind(:score) { game.score } # one-way computed (read each frame) + m.value(:hp, 100) # two-way scalar + m.event(:reset) { game.reset! } # controller: rml `data-event-click="reset()"` +end # block form finishes it automatically +m[:hp] ; m[:hp] = 80 # read / write+dirty +m.dirty(:score, ...) ; m.dirty_all # re-evaluate bound vars after state changes +``` + +## Flecs (ECS, module `Flecs::`) +Entity Component System. Components are real C structs declared at runtime from +a meta descriptor string and (de)serialized to/from Ruby Hashes. Works +identically on desktop and web. Entities/components are integer ids wrapped in +Flecs::Entity / Flecs::Component (use them anywhere an id is expected). +```ruby +world = Flecs::World.new # owns the ecs_world_t (freed by GC) + +# Components: a meta struct descriptor (C type syntax). Returns Flecs::Component. +pos = world.struct("Position", "{float x; float y;}") +vel = world.struct("Velocity", "{float x; float y;}") +# supported member types: bool, char, [iu]8/16/32/64, f32/f64, uptr/iptr, +# string (char*), entity, nested structs, inline arrays. +npc = world.tag("Npc") # dataless id -> Flecs::Component + +# Entities (Flecs::Entity) +e = world.entity("player") # name optional +e = world.entity # anonymous +world.lookup("player") # -> Flecs::Entity | nil +e.id ; e.to_i ; e.name ; e.name = "p2" ; e.alive? ; e.delete + +# Components on entities (Hash <-> struct) +e.set(pos, x: 1.0, y: 2.0) # kwargs or e.set(pos, {x:1,y:2}) +e.get(pos) # -> {x: 1.0, y: 2.0} | nil +e.add(npc) ; e.remove(npc) ; e.has?(npc) # tags or components +e.set(pos, x: 0, y: 0).add(npc) # chainable + +# Systems: run each progress() during a phase. Block gets |entity_id, *comp_hashes| +# in the order of `with:`; mutations to the component Hashes are written back. +world.system("Move", with: [pos, vel]) do |id, p, v| + p[:x] += v[:x]; p[:y] += v[:y] +end +world.progress(dt = 0.0) # -> Boolean (false = quit); runs all systems once + +# Ad-hoc queries (cached) -> Flecs::Query (Enumerable) +q = world.query(pos, vel) +q.each { |id, p, v| ... } # same writeback semantics + +# phases: Flecs::ON_LOAD, Flecs::PRE_UPDATE, Flecs::ON_UPDATE (default), Flecs::ON_START +``` +NOTE: the system/query block receives the entity as an **Integer id** (not a +Flecs::Entity) for speed; wrap with `world.entity_for(id)` if you need methods — +or just use ids. Component data is delivered as Hashes; mutate them in place. +Multithreaded systems are NOT exposed (single-threaded `progress` only; this is +also the only mode that works on the wasm/web build). + +## Jolt Physics (3D, module `Jolt::`) +Rigid-body 3D physics via the joltc C API. Vectors accept Arrays or Rl::Vector3 +and are returned as Rl::Vector3/Vector4. Single-threaded `step` (identical on +desktop and web). Full spec: docs/API_SPEC_JOLT.md. +```ruby +world = Jolt::World.new(gravity: [0, -9.81, 0], max_bodies: 10240) +world.gravity = [0, -20, 0] +world.step(dt = 1.0/60.0, collision_steps: 1) # advance; alias: update +world.optimize_broad_phase # once after bulk-adding bodies + +# shapes (reusable) -> Jolt::Shape +Jolt.box(width, height, depth) # FULL dimensions (not half-extents) +Jolt.sphere(radius) +Jolt.capsule(half_height, radius) # half-height of cylinder section +Jolt.cylinder(half_height, radius) +Jolt.convex_hull(points) # Array of [x,y,z] +Jolt.mesh(vertices) # triangle soup (3 verts/tri); STATIC bodies only + +# bodies -> Jolt::Body. motion: Jolt::STATIC | KINEMATIC | DYNAMIC +b = world.body(shape: Jolt.sphere(0.5), position: [0,10,0], rotation: [0,0,0,1], + motion: Jolt::DYNAMIC, restitution: 0.0, friction: 0.2, activate: true, + velocity: nil, user_data: nil, mass: nil, linear_damping: 0.05, + angular_damping: 0.05, ccd: false, sensor: false) # alias: add_body +b.sensor = true ; b.ccd = true # also settable at runtime +b.id ; b.position -> Rl::Vector3 ; b.center_of_mass ; b.rotation -> Rl::Vector4 +b.position = [x,y,z] +b.set_transform(position:, rotation: nil, activate: true) +b.linear_velocity ; b.linear_velocity = [x,y,z] +b.angular_velocity ; b.angular_velocity = [x,y,z] +b.apply_force(v) ; b.apply_impulse(v) ; b.apply_torque(v) # chainable +b.active? ; b.activate ; b.deactivate ; b.remove +b.user_data ; b.user_data = entity_id # 64-bit tag (map collisions -> game objs) +b.motion_type ; b.motion_type = Jolt::KINEMATIC ; b.set_motion_type(mt, activate: true) +b.friction = 0.8 ; b.restitution = 0.9 ; b.gravity_factor = 0.0 + +# queries +hit = world.raycast([0,10,0], [0,-20,0]) # -> Jolt::RayHit | nil +hit.body_id ; hit.body ; hit.fraction ; hit.point -> Rl::Vector3 ; hit.normal -> Rl::Vector3 +world.overlap_point([x,y,z]) -> Array<Jolt::Body> # bodies containing a point + +# collision events (began this step) -> Array<Jolt::Contact>; ended -> ContactEnd +world.contacts.each do |c| + c.body_a_id ; c.body_b_id ; c.body_a ; c.body_b + c.point -> Rl::Vector3 ; c.normal -> Rl::Vector3 + c.involves?(b) ; other = c.other(b) # the other body in the contact +end +world.contacts_ended.each { |c| c.involves?(zone) ; c.other(zone) } # stopped touching +# sensor bodies (sensor: true) + contacts/contacts_ended = trigger volumes (enter/leave) + +# constraints / joints (return Jolt::Constraint; joint.remove to detach). +# The WORLD retains constraints + ragdolls, so a dropped handle still stays +# alive (a GC'd Constraint/Ragdoll would otherwise detach itself). Use .remove. +world.weld(a, b) # rigid weld +world.ball_joint(a, b, point) # point-to-point +world.distance_joint(a, b, pa, pb, min: 0, max: 2) # rope/rod +world.hinge(a, b, point, axis, min_deg: -90, max_deg: 90) # door +world.slider(a, b, point, axis, min: -2, max: 2) # piston +world.cone(a, b, point, axis, half_angle_deg: 30) # swing/twist limit + +# character controller (kinematic capsule; stair-step + slope) -> Jolt::Character +ch = world.character(shape: Jolt.capsule(0.6, 0.3), position: [0,2,0], + max_slope_deg: 45, mass: 70) +# per frame: set velocity (apply gravity/jump yourself), then update + step +v = ch.velocity +vy = ch.on_ground? ? (jump ? 6.0 : 0.0) : v.y - 20.0 * dt +ch.velocity = [input_x * 5, vy, input_z * 5] +ch.update(dt) ; world.step(dt) +ch.position -> Rl::Vector3 ; ch.position = [x,y,z] ; ch.on_ground? +ch.ground_state # :on_ground|:on_steep|:not_supported|:in_air ; ch.ground_normal ; ch.supported? +ch.max_strength = 6000 ; ch.mass = 70 # push force vs dynamic bodies / collision mass +# ride moving platforms: a KINEMATIC body whose velocity the character inherits +ch.ground_velocity -> Rl::Vector3 # velocity of the surface underfoot (0 if airborne) +ch.ground_body -> Jolt::Body | nil # the body it stands on +ch.ride(dt) # = update(dt) + inherit a STATIC/KINEMATIC + # platform's velocity (DYNAMIC ground ignored, + # else its reaction to your weight flings you) + +# ragdoll: tree of dynamic bodies + swing-twist joints. Parts PARENTS-FIRST. +rd = world.ragdoll(parts: [ + { name: :torso, shape: Jolt.capsule(0.22,0.16), position: [0,4,0], mass: 20 }, + { name: :head, shape: Jolt.sphere(0.16), position: [0,4.45,0], parent: :torso, + joint: [0,4.24,0], twist_axis: [0,1,0], plane_axis: [1,0,0], + cone_deg: 25, plane_deg: 25, twist_min_deg: -25, twist_max_deg: 25 }, +], user_data: 0) +rd.body_count ; rd.bodies -> Array<Jolt::Body> ; rd[0] ; rd.activate +rd.bodies.each { |b| b.apply_impulse([fx,fy,fz]) } ; rd.remove +# capsule parts: local axis = Y; draw via +# Rl.vector3_rotate_by_quaternion([0, half_height, 0], body.rotation) +``` +NOTE: STATIC = never moves (floors/walls), KINEMATIC = you move it (infinite +mass), DYNAMIC = simulated; collision layer is derived from motion type. +Use body.user_data to bridge contacts back to game objects (e.g. flecs entity +ids). Not exposed: shape-cast queries, height-field/compound shapes, vehicles, +soft bodies, ragdoll pose/motor driving, custom layers, multithreading. +Determinism is OFF. + +## NOT bound (do not call — no Ruby method exists) +These raylib/raymath functions are intentionally unbound (callbacks, raw +pointers/buffers, varargs, or array/string returns). Use Ruby equivalents +(`File`, `format`, arrays, `puts`) or avoid. +``` +AttachAudioMixedProcessor, AttachAudioStreamProcessor, CodepointToUTF8, CompressData, +ComputeCRC32, ComputeMD5, ComputeSHA1, ComputeSHA256, +DecodeDataBase64, DecompressData, DetachAudioMixedProcessor, DetachAudioStreamProcessor, +DrawTextCodepoints, EncodeDataBase64, ExportDataAsCode, ExportImageToMemory, +GenImageFontAtlas, GetClipboardImage, GetCodepoint, GetCodepointNext, +GetCodepointPrevious, GetPixelColor, GetWindowHandle, ImageKernelConvolution, +LoadCodepoints, LoadFileData, LoadFontData, LoadFontEx, +LoadFontFromMemory, LoadImageAnim, LoadImageAnimFromMemory, LoadImageColors, +LoadImageFromMemory, LoadImagePalette, LoadMaterials, LoadModelAnimations, +LoadMusicStreamFromMemory, LoadRandomSequence, LoadTextLines, LoadUTF8, +LoadWaveFromMemory, LoadWaveSamples, MatrixToFloatV, MeasureTextCodepoints, +MemAlloc, MemFree, MemRealloc, QuaternionToAxisAngle, +SaveFileData, SetAudioStreamCallback, SetLoadFileDataCallback, SetLoadFileTextCallback, +SetPixelColor, SetSaveFileDataCallback, SetSaveFileTextCallback, SetTraceLogCallback, +TextAppend, TextCopy, TextFormat, TextJoin, +TextSplit, TraceLog, UnloadCodepoints, UnloadFileData, +UnloadFileText, UnloadRandomSequence, UnloadTextLines, UnloadUTF8, +UnloadWaveSamples, UpdateAudioStream, UpdateMeshBuffer, UpdateSound, +UpdateTexture, UpdateTextureRec, Vector3ToFloatV +``` diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md new file mode 100644 index 0000000..680152d --- /dev/null +++ b/docs/API_SPEC.md @@ -0,0 +1,382 @@ +# raylib-jamstack — Ruby API Specification + +A stack for building raylib gamejam games in Ruby (mruby). + +**Core parts** + +1. **Raylib** — graphics / audio / input, exposed to Ruby as `Rl::` +2. **MRuby** — the embedded Ruby interpreter that runs the game code +3. **RmlUi** — HTML/CSS-based UI layer, exposed to Ruby as `Rml::`, with Ruby data binding + +This document specifies *how the bindings look in Ruby*. It is the contract the C +binding layer must satisfy. Implementation uses **modern raylib + modern mruby**; +the `orc-arena-of-time` project is referenced only for *how the API feels*, not for +how its (old) bindings were implemented. + +--- + +## 0. Design principles + +These are distilled from the reference game and extended. Every binding decision +should be checkable against these rules. + +1. **snake_case** everything. `InitWindow` -> `Rl.init_window`. +2. **Predicates end in `?`** and return `true`/`false`. `IsKeyDown` -> `Rl.key_down?`. +3. **Setters use `=`.** `SetTargetFPS(60)` -> `Rl.target_fps = 60`. +4. **Begin/End pairs are blocks.** Anything that comes as `BeginX`/`EndX` in C is a + method that takes a block and guarantees the `EndX` runs (even on exception/return). +5. **Many-arg functions take keyword args with sensible defaults.** 2–3 obvious + positional args may stay positional (`init_window(w, h, title)`). +6. **C structs become classes** under `Rl::`. Constructors that load a resource + (`LoadTexture`) are `ClassName.new(path)`. Plain data structs (`Color`, + `Vector2`, `Rectangle`) are `ClassName.new(fields...)` with mutable accessors. +7. **Struct-first methods become instance methods.** `DrawRectangleRec(rec, c)` -> + `rec.draw(color: c)`; `CheckCollisionRecs(a, b)` -> `a.collide_with_rec?(b)`. +8. **Resources free themselves** via mruby GC finalizers, with an explicit + `#unload` escape hatch (see §6). + +--- + +## 1. Module-level: `Rl` (raylib core) + +### 1.1 Window & lifecycle + +```ruby +Rl.init_window(900, 675, "Orc: Arena of Time") # InitWindow +Rl.close_window # CloseWindow (auto on exit) +Rl.window_should_close? # WindowShouldClose +Rl.window_open? # !WindowShouldClose (convenience) + +# The main loop. Runs the block each frame until the window should close. +# On web (emscripten) this is wired to emscripten_set_main_loop instead of a +# real `while`, transparently. THIS IS WHY IT IS A BLOCK, NOT A RAW LOOP. +Rl.while_window_open do + # update + draw +end +``` + +> **Improvement over reference:** the reference used a `while` loop that does not +> map cleanly to emscripten's callback-driven loop. Keeping `while_window_open` as +> the *only* sanctioned loop lets the binding swap in `emscripten_set_main_loop` +> for web with zero game-code changes. + +### 1.2 Timing + +```ruby +Rl.target_fps = 60 # SetTargetFPS +Rl.frame_time # GetFrameTime (delta, seconds) -> Float +Rl.time # GetTime (since init) -> Float +Rl.fps # GetFPS -> Integer +``` + +### 1.3 Drawing scope (block-based Begin/End) + +```ruby +Rl.draw(clear_color: Rl::BLACK) do # BeginDrawing + ClearBackground + EndDrawing + ... +end + +Rl.scissor_mode(x:, y:, width:, height:) do # Begin/EndScissorMode + ... +end + +Rl.mode_2d(camera) do ... end # Begin/EndMode2D +Rl.texture_mode(render_texture) do ... end # Begin/EndTextureMode +Rl.blend_mode(Rl::BLEND_ADDITIVE) do ... end # Begin/EndBlendMode +``` + +> `draw`, `scissor_mode`, `mode_2d`, `texture_mode`, `blend_mode` all follow the +> same block rule (#4). `clear_color:` defaults to `Rl::RAYWHITE`. + +### 1.4 Text + +```ruby +Rl.draw_text(text: "fps: #{Rl.fps}", x: 10, y: 10, font_size: 30, color: Rl::WHITE) +Rl.draw_text(text:, x:, y:, font_size:, color:, font: Rl::Font.default, spacing: 1.0) +Rl.measure_text(text:, font_size:, font: Rl::Font.default) # -> Rl::Vector2 +``` + +### 1.5 Textures / images + +```ruby +Rl.draw_texture_pro( + texture:, + source: src_rec, # NOTE: renamed from reference's source_rec + dest: dst_rec, # NOTE: renamed from reference's dest_rec + origin: Rl::Vector2.new(0, 0), + rotation: 0, + tint: Rl::WHITE +) +Rl.draw_texture(texture:, x:, y:, tint: Rl::WHITE) +Rl.draw_texture_v(texture:, position:, tint: Rl::WHITE) +``` + +> **Naming decision needed (see §9):** the reference is inconsistent — it uses +> `source_rec:`/`dest_rec:` in calls but `source:`/`dest:` in a commented +> signature, and `Rl::Vector` vs `Rl::Vector2`. This spec standardizes on +> raylib's own names `source:`/`dest:` and the class name `Rl::Vector2`. + +### 1.6 Input — keyboard + +```ruby +Rl.key_down?(Rl::KEY_W) # IsKeyDown +Rl.key_down?(:w) # symbol alias +Rl.key_pressed?(Rl::KEY_R) # IsKeyPressed +Rl.key_released?(key) # IsKeyReleased +Rl.key_up?(key) # IsKeyUp +``` + +> **Improvement over reference:** the reference used magic numbers +> (`Rl.key_down? 87` for W). This spec uses `Rl::KEY_*` constants and also accepts +> symbols: `Rl.key_down?(:w)`. (Clean break — raw magic numbers are not a +> supported style.) + +### 1.7 Input — mouse + +```ruby +Rl.mouse_button_pressed?(Rl::MOUSE_BUTTON_LEFT) +Rl.mouse_button_down?(Rl::MOUSE_BUTTON_LEFT) +Rl.mouse_button_up?(Rl::MOUSE_BUTTON_LEFT) # 0/1/2 ok +Rl.mouse_button_released?(button) +Rl.mouse_position # GetMousePosition -> Rl::Vector2 +Rl.mouse_x # GetMouseX -> Integer +Rl.mouse_y # GetMouseY -> Integer +Rl.mouse_wheel # GetMouseWheelMove -> Float +``` + +### 1.8 Audio + +```ruby +Rl.init_audio_device # InitAudioDevice +Rl.audio_device_ready? # IsAudioDeviceReady +Rl.set_master_volume(0.5) # SetMasterVolume (also: Rl.master_volume = 0.5) +``` + +### 1.9 Platform helper (custom, not in raylib) + +```ruby +Rl.platform # => :web | :desktop (reference used the strings 'web'/'desktop') +Rl.web? # convenience +Rl.desktop? # convenience +``` + +> **Improvement:** return symbols and add predicates; the reference compared +> against the string `'web'` everywhere, which is error-prone. + +--- + +## 2. Data structs as classes + +### 2.1 `Rl::Color` + +```ruby +c = Rl::Color.new(255, 255, 255, 255) # r, g, b, a +c.r; c.g; c.b; c.a # readers +c.a = 150 # writers (mutable — reference relies on this) +``` + +Built-in constants (no more hand-defining WHITE/BLACK): `Rl::WHITE`, `Rl::BLACK`, +`Rl::BLANK`, `Rl::RAYWHITE`, `Rl::RED`, `Rl::GREEN`, `Rl::BLUE`, `Rl::YELLOW`, +`Rl::GRAY`, `Rl::DARKGRAY`, ... (full raylib palette). + +### 2.2 `Rl::Vector2` + +```ruby +v = Rl::Vector2.new(x, y) +v.x; v.y; v.x = ...; v.y = ... +``` + +> **Improvement (raymath):** operators + helpers so games stop hand-rolling +> `Math.sqrt(x**2 + y**2)` (the reference does this dozens of times): +> ```ruby +> a + b a - b a * scalar a / scalar +> v.length v.length_sqr v.normalize +> v.dot(other) v.distance(other) v.lerp(other, t) +> ``` + +### 2.3 `Rl::Rectangle` + +```ruby +r = Rl::Rectangle.new(x, y, width, height) +r.x; r.y; r.width; r.height # all mutable + +r.draw(color:) # DrawRectangleRec +r.draw_lines(line_thick:, color:) # DrawRectangleLinesEx +r.collide_with_point?(vec2) # CheckCollisionPointRec +r.collide_with_rec?(other) # CheckCollisionRecs +r.collision_rec(other) -> Rectangle # GetCollisionRec +# additions: +r.center -> Vector2 +r.contains?(vec2) # alias of collide_with_point? +``` + +### 2.4 `Rl::Texture` + +```ruby +tex = Rl::Texture.new("./assets/orc.png") # LoadTexture +tex.width; tex.height +tex.unload # UnloadTexture (also GC-finalized) +``` + +> **Improvement:** add `Rl::Texture.load("path")` that **caches** by path. The +> reference loads the same file repeatedly (e.g. on each level construct), leaking +> GPU memory. `Texture.new` = always fresh; `Texture.load` = cached. + +### 2.5 `Rl::Image` (CPU-side) vs `Rl::Texture` (GPU-side) + +```ruby +img = Rl::Image.new("./assets/orc.png") # LoadImage (stays in RAM, CPU editable) +tex = img.to_texture # LoadTextureFromImage +img.unload +``` + +### 2.6 `Rl::Sound` / `Rl::Music` + +```ruby +snd = Rl::Sound.new("./assets/hurt.wav") # LoadSound +snd.play # PlaySound +snd.stop +snd.playing? # IsSoundPlaying +snd.volume = 0.45 # SetSoundVolume + +mus = Rl::Music.new("./assets/music.ogg") # LoadMusicStream +mus.play; mus.playing?; mus.volume = 0.09 +mus.update # UpdateMusicStream (call each frame for streaming) +``` + +> **Note:** the reference loaded `music.ogg` as a `Sound`; long tracks should be +> `Music` (streamed). Both are provided. + +### 2.7 `Rl::Camera2D`, `Rl::RenderTexture`, `Rl::Font` + +```ruby +cam = Rl::Camera2D.new(target: Rl::Vector2.new(0,0), + offset: Rl::Vector2.new(0,0), + rotation: 0, zoom: 1.0) + +rt = Rl::RenderTexture.new(width, height) # LoadRenderTexture +font = Rl::Font.new("./assets/font.ttf", size: 32) +``` + +--- + +## 3. The canonical game shape + +Putting the idioms together (this should read like the reference game, cleaned up): + +```ruby +Rl.init_window(900, 675, "Orc: Arena of Time") +Rl.target_fps = 60 + +player = Rl::Texture.load("./assets/orc.png") +src = Rl::Rectangle.new(0, 0, 24, 24) +dest = Rl::Rectangle.new(100, 100, 48, 48) + +Rl.while_window_open do + dest.x += 100 * Rl.frame_time if Rl.key_down?(:d) + + Rl.draw(clear_color: Rl::BLACK) do + Rl.draw_texture_pro(texture: player, source: src, dest: dest) + Rl.draw_text(text: "fps: #{Rl.fps}", x: 10, y: 10, font_size: 20, color: Rl::WHITE) + end +end +``` + +--- + +## 4. RmlUi: `Rml::` (specified in detail in API_SPEC_RMLUI.md) + +Summary of the surface (full spec to follow as the next deliverable): + +```ruby +Rml.init(width: 900, height: 675) # backend wired to raylib's GL context +ctx = Rml::Context.new("main", 900, 675) + +# Data binding (MVC) — bind a Ruby object to a named data model +model = ctx.data_model("hud") do |m| + m.bind(:hp, -> { player.hp }) # one-way view + m.bind(:score, score) # two-way for plain values + m.event(:reset) { reset_game } # rml: data-event-click="reset()" +end + +doc = ctx.load_document("ui/hud.rml") +doc.show + +Rl.while_window_open do + Rl.draw(clear_color: Rl::BLACK) do + # game render ... + ctx.update # process data model changes + ctx.render # draw UI on top via raylib + end + ctx.process_input # feed raylib mouse/keyboard into RmlUi +end + +model.dirty(:hp) # notify UI that a bound variable changed (DataModelHandle::DirtyVariable) +``` + +This mirrors RmlUi's `DataModelHandle` MVC model: Ruby objects are the model, +`.rml`/`.rcss` files are the view, and `m.event` callbacks are controllers. + +--- + +## 5. Block-safety contract + +Every block-form method (`while_window_open`, `draw`, `scissor_mode`, `mode_2d`, +`texture_mode`, `blend_mode`, `data_model`) MUST run its matching `EndX` even if the +block raises or returns early. In C-binding terms: wrap the `mrb_yield` and always +emit the `EndX` call, re-raising any pending exception afterward. + +--- + +## 6. Resource lifetime + +- Resource classes (`Texture`, `Image`, `Sound`, `Music`, `Font`, `RenderTexture`, + `Camera`-no) hold a native handle and register an mruby **finalizer** that calls + the corresponding `UnloadX` when garbage-collected. +- All expose an explicit `#unload` for deterministic freeing (important on web, + where GC timing is unpredictable). Double-unload is a no-op. +- `Texture.load(path)` / `Image.load(path)` use a per-path cache; cached resources + are unloaded at `Rl.close_window` or via `Rl::Texture.clear_cache`. + +--- + +## 7. Constants + +- Keys: `Rl::KEY_A` .. `Rl::KEY_Z`, `Rl::KEY_SPACE`, `Rl::KEY_ENTER`, arrows, etc. + Symbol aliases accepted by all key predicates (`:w`, `:space`, `:enter`). +- Mouse: `Rl::MOUSE_BUTTON_LEFT/RIGHT/MIDDLE` (integers `0/1/2` still accepted). +- Colors: full raylib palette (§2.1). +- Blend modes, config flags, etc. as `Rl::*` integer constants. + +--- + +## 8. What changed vs. the orc-arena-of-time reference (summary) + +| Area | Reference | This spec | +|------|-----------|-----------| +| Loop | `Rl.while_window_open` (raw while) | same, but defined as the emscripten-safe seam | +| Keys | magic numbers (`87`) | `Rl::KEY_W` + symbols (`:w`), numbers still ok | +| Mouse btn | magic numbers (`0`) | `Rl::MOUSE_BUTTON_LEFT` (int `0/1/2` ok) | +| Colors | hand-defined WHITE/BLACK | built-in `Rl::WHITE`/`Rl::BLACK`/full palette | +| Vector math | manual `Math.sqrt(...)` | `Vector2` operators + raymath helpers | +| Texture args | `source_rec:` / `dest_rec:` | `source:` / `dest:` (matches raylib) | +| Vector class | mixed `Vector`/`Vector2` | always `Rl::Vector2` | +| Long audio | `Sound` for music | `Music` (streamed) + `Sound` (one-shot) | +| Texture reuse | reloaded each level (leak) | `Texture.load` path cache + `#unload` | +| Platform | string `'web'` | `Rl.platform` symbol + `Rl.web?`/`desktop?` | +| UI | (none / hand-drawn) | RmlUi `Rml::` with Ruby data binding | + +> The orc-arena-of-time project is a **style reference only**. This stack makes a +> clean break — there is no goal of running existing orc game code unmodified. + +--- + +## 9. Settled decisions + +- **Clean break** — no backward-compat with orc game code. +- **Symbol keys enabled** — `Rl.key_down?(:w)` alongside `Rl::KEY_W`. +- **ECS via flecs** — the stack ships optional `Flecs::` bindings (ECS) modeled on + flecs' Lua binding; see [API_SPEC_FLECS.md](API_SPEC_FLECS.md). Using it is + optional — game architecture is still up to the author. + (Originally the stack shipped no ECS; this was reversed when flecs was added.) +- **No raygui** — RmlUi is the sole UI layer. diff --git a/docs/API_SPEC_FLECS.md b/docs/API_SPEC_FLECS.md new file mode 100644 index 0000000..60a7360 --- /dev/null +++ b/docs/API_SPEC_FLECS.md @@ -0,0 +1,218 @@ +# raylib-jamstack — Flecs (ECS) Ruby API Specification (`Flecs::`) + +The optional **Entity Component System** layer. +[flecs](https://github.com/SanderMertens/flecs) is a fast C/C++ ECS. These +bindings are hand-written and modeled on flecs' official +[Lua binding](https://github.com/flecs-hub/flecs-lua): both embed flecs' C API in +a dynamically-typed scripting VM, so the same approach applies. + +This follows the same design rules as `API_SPEC.md` (snake_case, `?` predicates, +`=` setters, keyword args, blocks). Using the ECS is optional — game architecture +is up to the author. + +## Mental model + +- **World** (`Flecs::World`) owns everything; one per game (you may have more). +- **Components** are *real C structs* declared at runtime from a **meta + descriptor** string. Their values are (de)serialized to/from Ruby **Hashes** — + there is no per-component Ruby class. +- **Entities** are integer ids (wrapped in `Flecs::Entity`) with components/tags. +- **Systems** are Ruby blocks that run over matching entities every `progress`. + +The key design choice (from flecs-lua): components use flecs' **meta/reflection +addon**, so `world.struct("Position", "{float x; float y;}")` registers a struct +with a known memory layout, and the binding moves data between that C memory and +Ruby Hashes. This needs no C codegen per component and works identically on +desktop and web. + +--- + +## 1. World + +```ruby +world = Flecs::World.new # wraps an ecs_world_t (freed automatically by GC) +world.progress(dt = 0.0) # -> Boolean; advance one step, run all systems. + # returns false when the world wants to quit. +``` + +`progress` is the ECS analogue of a frame tick. Typical integration with the +raylib loop: + +```ruby +Rl.while_window_open do + world.progress(Rl.frame_time) + Rl.draw(clear_color: Rl::RAYWHITE) { ... } # systems may also do the drawing +end +``` + +> **Single-threaded only.** Multithreaded systems (`ecs_set_threads`) are not +> exposed; `progress` runs systems on the calling thread. This is also the only +> mode valid on the wasm/web build, so behaviour is identical across targets. + +--- + +## 2. Components (meta structs) + +Declare a component from a C-struct **descriptor string**. Returns a +`Flecs::Component` (usable anywhere an id is expected). + +```ruby +pos = world.struct("Position", "{float x; float y;}") +vel = world.struct("Velocity", "{float x; float y;}") +world.component(...) # alias of struct +``` + +Supported member types in the descriptor: + +| descriptor type | Ruby value | +|----------------------------|-----------------| +| `bool` | `true`/`false` | +| `char`, `i8/i16/i32/i64` | `Integer` | +| `u8/u16/u32/u64`, `byte` | `Integer` | +| `uptr`, `iptr` | `Integer` | +| `f32` (`float`), `f64` (`double`) | `Float` | +| `string` (`char*`) | `String` | +| `entity` | `Integer` (id) | +| nested `{...}` struct | nested `Hash` | +| inline array `T[N]` | `Array` | + +```ruby +# nested + arrays +tf = world.struct("Transform", "{ {float x; float y;} pos; float scale; }") +``` + +A **tag** is a dataless id (no struct): + +```ruby +npc = world.tag("Npc") # -> Flecs::Component (usable with add/remove/has?) +``` + +--- + +## 3. Entities + +```ruby +e = world.entity("player") # named (name optional) +e = world.entity # anonymous +world.lookup("player") # -> Flecs::Entity | nil +world.entity_for(id) # wrap a raw id (e.g. from a system block) +``` + +`Flecs::Entity`: + +```ruby +e.id ; e.to_i # the integer id +e.name ; e.name = "p2" # get/set name +e.alive? # still alive? +e.delete # destroy + +# components & tags +e.set(pos, x: 1.0, y: 2.0) # kwargs ... +e.set(pos, {x: 1, y: 2}) # ... or an explicit Hash +e.get(pos) # -> {x: 1.0, y: 2.0} | nil +e.add(npc) ; e.remove(npc) # tags or components (add with default value) +e.has?(npc) # -> Boolean +e.set(pos, x: 0, y: 0).add(npc) # chainable; returns self +``` + +> `set`/`get`/`add`/`remove`/`has?` all accept a `Flecs::Component`, a +> `Flecs::Entity`, or a raw integer id (anything responding to `to_i`). + +--- + +## 4. Systems + +Register a system that runs every `progress` during a **phase**. The block is +invoked once per matched entity with `|entity_id, *component_hashes|`, where the +component hashes are in the order of `with:`. **Mutations to those hashes are +written back** into component memory after the block returns. + +```ruby +world.system("Move", with: [pos, vel]) do |id, p, v| + p[:x] += v[:x] + p[:y] += v[:y] +end + +world.system("Despawn", with: [pos], phase: Flecs::PRE_UPDATE) do |id, p| + world.entity_for(id).delete if p[:y] > 1000 +end +``` + +Phases (run in this order each `progress`): + +| constant | flecs phase | +|---------------------|---------------| +| `Flecs::ON_LOAD` | `EcsOnLoad` | +| `Flecs::PRE_UPDATE` | `EcsPreUpdate`| +| `Flecs::ON_UPDATE` | `EcsOnUpdate` (default) | +| `Flecs::ON_START` | `EcsOnStart` | + +> The entity is passed as a raw **Integer id** (not a `Flecs::Entity`) to avoid +> allocating a wrapper per entity per frame. Wrap it with `world.entity_for(id)` +> when you need entity methods. Tag terms in `with:` yield `nil` for their slot. + +--- + +## 5. Queries + +Ad-hoc, cached queries over a set of components/tags. `Flecs::Query` is +`Enumerable`; `each` has the same `|id, *comps|` + writeback semantics as systems. + +```ruby +q = world.query(pos, vel) # build once, reuse across frames +q.each { |id, p, v| ... } + +world.query(pos).each { |id, p| puts "#{id}: #{p}" } +``` + +--- + +## 6. Worked example + +```ruby +world = Flecs::World.new +pos = world.struct("Position", "{float x; float y;}") +vel = world.struct("Velocity", "{float x; float y;}") + +100.times do |i| + world.entity.set(pos, x: i.to_f, y: 0.0).set(vel, x: 0.0, y: 1.0) +end + +world.system("Gravity", with: [vel]) { |id, v| v[:y] += 0.5 } +world.system("Move", with: [pos, vel]) do |id, p, v| + p[:x] += v[:x]; p[:y] += v[:y] +end + +Rl.init_window(800, 600, "ecs") +Rl.while_window_open do + world.progress(Rl.frame_time) + Rl.draw(clear_color: Rl::BLACK) do + world.query(pos).each { |id, p| Rl.draw_circle(p[:x].to_i, p[:y].to_i, 3, Rl::RAYWHITE) } + end +end +``` + +--- + +## 7. Web (wasm) + +flecs — including the meta/reflection addon used for components — compiles and +runs under emscripten unchanged. The only build difference is the wasm link uses +`-sSTACK_SIZE=4MB`, because flecs' init/meta needs more than emscripten's 64 KB +default stack (otherwise you get a wasm `memory access out of bounds` trap). The +API and behaviour are identical to desktop. + +--- + +## 8. Limitations / not yet exposed + +These are deliberately omitted from the first cut (the underlying C API supports +them; bind them as needed): + +- Multithreaded systems / staging (`ecs_set_threads`). +- Relationships / pairs, prefabs, hierarchies (`ChildOf`, `IsA`). +- Observers (event-driven callbacks), query filter operators (`Not`, `Or`, + optional, `inout` modifiers) — `with:` is plain "must have all" matching. +- The REST/Explorer remote UI. +- Enum/bitmask component members (structs, primitives, nested structs, and inline + arrays are supported). diff --git a/docs/API_SPEC_JOLT.md b/docs/API_SPEC_JOLT.md new file mode 100644 index 0000000..1cdfef0 --- /dev/null +++ b/docs/API_SPEC_JOLT.md @@ -0,0 +1,296 @@ +# raylib-jamstack — Jolt Physics Ruby API Specification (`Jolt::`) + +The optional **3D rigid-body physics** layer. +[Jolt Physics](https://github.com/jrouwe/JoltPhysics) is a fast, modern engine +(used in AAA titles). These bindings are hand-written over the +[joltc](https://github.com/amerkoleci/joltc) C API and follow the same design +rules as the other specs (snake_case, `?` predicates, `=` setters, keyword args). + +Vectors accept `Array`s or `Rl::Vector3`/`Vector4` and are returned as +`Rl::Vector3`/`Vector4` (so results drop straight into raylib draw calls). Runs +**single-threaded** `step` only — identical on desktop and the wasm/web build. + +## Mental model +- A `Jolt::World` owns the simulation (gravity, bodies). Step it each frame. +- A `Jolt::Shape` is a reusable collision volume (box, sphere, capsule, cylinder). +- A `Jolt::Body` is a rigid body (a body id bound to its world) with a shape, + a motion type (static / kinematic / dynamic), a transform, and velocities. + +--- + +## 1. World +```ruby +world = Jolt::World.new(gravity: [0, -9.81, 0], max_bodies: 10240) +world.gravity = [0, -20, 0] +world.step(dt = 1.0/60.0, collision_steps: 1) # advance the simulation +world.update(dt) # alias of step +world.optimize_broad_phase # call once after bulk-adding bodies +``` +Integrate with the raylib loop: +```ruby +Rl.while_window_open do + world.step(Rl.frame_time) + Rl.draw(clear_color: Rl::RAYWHITE) do + Rl.begin_mode3d(camera) + world.query ... # your own draw using body.position / body.rotation + end +end +``` +> Single-threaded only (no `set_threads`); `step` runs on the calling thread. +> This is also the only mode valid on wasm, so behaviour matches across targets. + +## 2. Shapes +```ruby +Jolt.box(width, height, depth) # full dimensions (not half-extents) +Jolt.sphere(radius) +Jolt.capsule(half_height, radius) # half-height of the cylindrical section +Jolt.cylinder(half_height, radius) +``` +Shapes are reusable across many bodies. + +## 3. Bodies +```ruby +body = world.body( + shape: Jolt.sphere(0.5), + position: [0, 10, 0], # Array or Rl::Vector3 + rotation: [0, 0, 0, 1], # quaternion x,y,z,w (or Rl::Vector4) + motion: Jolt::DYNAMIC, # Jolt::STATIC | KINEMATIC | DYNAMIC + restitution: 0.0, # bounciness 0..1 + friction: 0.2, + activate: true, + velocity: [0, 0, 0], # optional initial linear velocity + mass: nil, # kg; nil = derive from shape volume (density) + linear_damping: 0.05, # drag (slows linear motion) + angular_damping: 0.05, # drag (slows spin) + ccd: false, # continuous collision (fast bodies vs thin walls) + sensor: false) # trigger volume: detect overlap, no physical response +world.add_body(...) # alias of body +body.sensor = true ; body.ccd = true # also settable at runtime +``` +Shapes also include `Jolt.convex_hull(points)` (an `Array` of `[x,y,z]`) and +`Jolt.mesh(vertices)` (triangle soup, 3 verts per triangle — **static bodies +only**, for level geometry). + +`Jolt::Body`: +```ruby +body.id ; body.to_i +body.position # -> Rl::Vector3 ; body.position = [x,y,z] +body.center_of_mass # -> Rl::Vector3 +body.rotation # -> Rl::Vector4 (quaternion x,y,z,w) +body.set_transform(position:, rotation: nil, activate: true) +body.linear_velocity ; body.linear_velocity = [x,y,z] +body.angular_velocity ; body.angular_velocity = [x,y,z] +body.apply_force(v) ; body.apply_impulse(v) ; body.apply_torque(v) # chainable +body.active? ; body.activate ; body.deactivate # sleeping bodies are inactive +body.remove # remove + destroy +# tunable properties (get + set) +body.user_data ; body.user_data = entity_id # 64-bit tag for collision lookup +body.motion_type ; body.motion_type = Jolt::KINEMATIC ; body.set_motion_type(mt, activate: true) +body.friction = 0.8 ; body.restitution = 0.9 ; body.gravity_factor = 0.0 +``` +> Motion types: `STATIC` (never moves — floors/walls), `KINEMATIC` (moved by you +> via velocity/transform, infinite mass), `DYNAMIC` (simulated). A body's +> collision layer is derived automatically from its motion type. +> `user_data` is the bridge to your game: store a flecs entity id (or any 64-bit +> tag) so collision events can be mapped back to game objects. + +## 3a. Collision events +`world.contacts` returns the collisions that **began** during the last `step` as +`Jolt::Contact`s. Pair this with `body.user_data` to react in gameplay: +```ruby +world.step(dt) +world.contacts.each do |c| + c.body_a_id ; c.body_b_id # the two bodies' ids + c.body_a ; c.body_b # -> Jolt::Body + c.point # -> Rl::Vector3 (world-space) ; c.normal -> Rl::Vector3 + c.involves?(player) # is a given body/id in this contact? + hit = c.other(player) # the *other* body in the contact + damage!(hit.user_data) if c.involves?(player) +end +``` +> Only contacts that newly begin are reported (not every step they persist), so +> the list stays small. The buffer is capped (4096/step); excess is dropped. + +**Contacts that ENDED** this step (stopped touching) come from `world.contacts_ended` +(`Jolt::ContactEnd`: `body_a_id`/`body_b_id`/`body_a`/`body_b`/`involves?`/`other`, +no point/normal). Combine with a **sensor** body (`sensor: true`) for trigger +volumes — `contacts` = "entered the zone", `contacts_ended` = "left the zone": +```ruby +zone = world.body(shape: Jolt.box(4,4,4), position: [0,2,0], motion: Jolt::STATIC, sensor: true) +world.step(dt) +world.contacts.each { |c| on_enter(c.other(zone)) if c.involves?(zone) } +world.contacts_ended.each { |c| on_leave(c.other(zone)) if c.involves?(zone) } +``` + +## 3c. Constraints / joints +Connect two bodies (one may be `STATIC` to anchor to the world). Each returns a +`Jolt::Constraint`; call `joint.remove` to detach (also done on GC). +```ruby +world.weld(a, b) # fixed: rigid weld at current relative pose +world.ball_joint(a, b, point) # point-to-point (free rotation about a world point) +world.distance_joint(a, b, pa, pb, min: 0, max: 2.0) # rope/rod between two world points +world.hinge(a, b, point, axis, min_deg: -90, max_deg: 90) # door (rotate about axis) +world.slider(a, b, point, axis, min: -2, max: 2) # piston (slide along axis) +world.cone(a, b, point, axis, half_angle_deg: 30) # swing/twist limit about axis +``` +`point`/`axis` are world-space (`Array` or `Rl::Vector3`); hinge limits in degrees, +slider limits in metres. +> The **world retains** every constraint it creates, so a joint stays alive even +> if you don't keep the returned handle — call `joint.remove` to delete it (which +> also drops the world's reference). Likewise for ragdolls (§4b). + +## 3b. Character controller +A `Jolt::Character` is a kinematic capsule (Jolt `CharacterVirtual`) for players: +precise control, **stair-stepping** (up to ~0.4 m), slope handling, and ground +detection. It is not a rigid body — you set its velocity each frame (applying +gravity/jump yourself) and call `update`, which moves and slides it along the world. +```ruby +char = world.character(shape: Jolt.capsule(0.6, 0.3), position: [0, 2, 0], + max_slope_deg: 45, mass: 70) + +# canonical per-frame loop +dt = Rl.frame_time +v = char.velocity +vy = char.on_ground? ? (jump? ? 6.0 : 0.0) : v.y - 20.0 * dt # gravity / jump +char.velocity = [input_x * speed, vy, input_z * speed] +char.update(dt) # moves + collides + steps stairs + sticks to floor +world.step(dt) + +char.position # -> Rl::Vector3 ; char.position = [x,y,z] +char.velocity # -> Rl::Vector3 +char.on_ground? # standing on walkable ground? +char.ground_state # :on_ground | :on_steep | :not_supported | :in_air +char.ground_normal # -> Rl::Vector3 +char.supported? # touching anything that supports it? +char.max_strength # max force (N) it exerts on dynamic bodies it walks into +char.max_strength = 6000 # raise above the 100 N default to push heavy props +char.mass = 200 # effective mass vs. dynamic bodies (still kinematic to gravity) + +# --- riding moving platforms --- +char.ground_velocity # -> Rl::Vector3: velocity of the surface underfoot (0 if airborne) +char.ground_body # -> Jolt::Body the character stands on, or nil when airborne +char.ride(dt) # like update(dt), but first ADDS ground_velocity to your own + # velocity, so a KINEMATIC platform/elevator carries the player +``` +> `ride` only inherits the velocity of a **STATIC/KINEMATIC** ground body. A +> DYNAMIC ground body (a ball you stand on, a constrained pendulum) reports its +> *reaction to your weight* (and its own bounce/swing) as `ground_velocity` — +> inheriting that would fling the character — so dynamic ground is ignored and you +> simply stand/collide on it. (`ground_velocity` itself still returns the raw value.) +A moving platform is just a `KINEMATIC` body you drive each frame; `ride` makes +the character inherit its motion instead of being left behind: +```ruby +plat = world.body(shape: Jolt.box(4, 0.5, 4), position: [0,0,0], motion: Jolt::KINEMATIC) +# each frame: +plat.linear_velocity = [vx, 0, 0] # feeds the character's ground_velocity +char.velocity = [input_x*5, vy, input_z*5] # your own movement + gravity/jump +char.ride(dt) # inherits the platform's velocity +world.step(dt) +plat.set_transform(position: next_xyz) # pin the kinematic body (anti-drift) +``` +> By default the character can push the dynamic bodies it walks into, but the Jolt +> default `maxStrength` (100 N) is too weak to shove default-density props +> (density 1000 kg/m³ — a 0.5 m sphere is ~520 kg). Raise `max_strength` when you +> want the player to bowl props around; raise `mass` to make props shove the +> player less. +> The character keeps its `world` alive (GC) for its lifetime. `update` uses +> Jolt's `ExtendedUpdate` (stair-step height 0.4 m, stick-to-floor). It collides +> with static + dynamic bodies but is itself kinematic (infinite mass): it stops +> at obstacles rather than being pushed. + +## 4. Queries +```ruby +hit = world.raycast(origin, direction) # direction is the full ray vector +if hit + hit.body_id # the hit body's id ; hit.body -> Jolt::Body + hit.fraction # 0..1 along the ray + hit.point # -> Rl::Vector3 (world-space hit point) + hit.normal # -> Rl::Vector3 (world-space surface normal at the hit) +end + +# which bodies contain a point? (overlap test) -> Array<Jolt::Body> +world.overlap_point([x, y, z]).each { |b| ... } +``` + +## 4b. Ragdolls +A ragdoll is a tree of dynamic bodies (one per skeleton joint) wired with +**swing-twist** constraints (a cone swing limit + a twist range), so a humanoid +collapses believably. Build it from an Array of part Hashes, **parents before +children** (skeleton order). Each part's body is a normal `Jolt::Body`. +```ruby +rd = world.ragdoll(parts: [ + # the single root has no :parent + { name: :torso, shape: Jolt.capsule(0.22, 0.16), position: [0, 4.0, 0], mass: 20 }, + { name: :head, shape: Jolt.sphere(0.16), position: [0, 4.45, 0], parent: :torso, + joint: [0, 4.24, 0], # world-space pivot connecting to the parent + twist_axis: [0,1,0], plane_axis: [1,0,0], # bone axis + a perpendicular + cone_deg: 25, plane_deg: 25, # swing limits (normal/plane half-cone) + twist_min_deg: -25, twist_max_deg: 25 }, + { name: :lleg, shape: Jolt.capsule(0.20, 0.085), position: [-0.1, 3.4, 0], parent: :torso, + joint: [-0.1, 3.78, 0], cone_deg: 40, twist_min_deg: -10, twist_max_deg: 10, mass: 5 }, +], user_data: 0) + +rd.body_count # number of parts +rd.bodies # -> Array<Jolt::Body> in part order (read .position/.rotation to draw) +rd[0] # bodies[0] (the root) +rd.activate # wake all parts (e.g. before applying an impulse) +rd.bodies.each { |b| b.apply_impulse([fx, fy, fz]) } # fling it +rd.remove # take it out of the world (also done on GC) +``` +Per part — required: `name:`, `shape:`, `position:`. Optional: `rotation:` +(quaternion, default identity), `mass:` (kg, else shape-derived), `motion:` +(default `DYNAMIC`), and for non-root parts the joint to the parent: `parent:`, +`joint:` (default = `position`), `twist_axis:` (default `[0,1,0]`), `plane_axis:` +(default `[1,0,0]`), `cone_deg:`/`plane_deg:` (default 45), `twist_min_deg:`/ +`twist_max_deg:` (default ±45). +> Adjacent parts don't collide with each other (Jolt +> `DisableParentChildCollisions`); the ragdoll collides with the rest of the +> world on the `MOVING` layer. The ragdoll keeps its `world` alive (GC). +> Capsule parts: their local axis is **Y** — render the two endpoints with +> `Rl.vector3_rotate_by_quaternion([0, half_height, 0], body.rotation)`. + +## 5. Worked example (bouncing balls over raylib) +```ruby +world = Jolt::World.new(gravity: [0, -9.81, 0]) +ground = world.body(shape: Jolt.box(50, 1, 50), position: [0, -0.5, 0], + motion: Jolt::STATIC) +balls = (0...20).map do |i| + world.body(shape: Jolt.sphere(0.5), position: [i - 10, 8, 0], restitution: 0.6) +end +world.optimize_broad_phase + +Rl.init_window(960, 540, "physics") +cam = Rl::Camera3D.new(Rl::Vector3.new(0, 8, 20), Rl::Vector3.new(0, 2, 0), + Rl::Vector3.new(0, 1, 0), 45, Rl::CAMERA_PERSPECTIVE) +Rl.while_window_open do + world.step(Rl.frame_time) + Rl.draw(clear_color: Rl::RAYWHITE) do + Rl.begin_mode3d(cam) + balls.each { |b| p = b.position; Rl.draw_sphere(p, 0.5, Rl::RED) } + Rl.draw_grid(20, 1) + Rl.end_mode3d + end +end +``` + +--- + +## 6. Determinism +Off by default. Jolt is deterministic given the same binary + inputs; for +*cross-platform* determinism (replays/netcode) rebuild the joltc/Jolt CMake with +`-DCROSS_PLATFORM_DETERMINISTIC=ON` (small perf cost). No API change. + +## 7. Build / web +joltc + Jolt (v5.5.0) are vendored and compiled to a single merged +`libjoltphysics.a` (CMake; `-DINTERPROCEDURAL_OPTIMIZATION=OFF` because zig's lld +can't link GCC-LTO objects — see `.agents/knowledge/jolt-binding.md`). Adds +~1.14 MB to `game.wasm` (~300-400 KB gzipped). Single-threaded, so wasm works +unchanged. + +## 8. Not yet exposed +Shape-cast / shape-overlap queries (sweep/overlap an arbitrary shape), +height-field & compound shapes, vehicles, soft bodies, ragdoll pose/motor driving +(animated → physical blending; the bodies + joints are exposed, but not +`DriveToPose`), custom collision layers, multithreading. The joltc C API supports +all of these — bind as needed. Also a first-cut leak: shapes and per-world +layer-filter tables aren't freed (bounded; both are long-lived). diff --git a/docs/API_SPEC_RMLUI.md b/docs/API_SPEC_RMLUI.md new file mode 100644 index 0000000..1562071 --- /dev/null +++ b/docs/API_SPEC_RMLUI.md @@ -0,0 +1,290 @@ +# raylib-jamstack — RmlUi Ruby API Specification (`Rml::`) + +The UI layer. [RmlUi](https://github.com/mikke89/RmlUi) is an HTML/CSS-derived UI +library with a model-view-controller **data binding** system. In this stack it is +the *only* UI layer (no raygui), rendered on top of raylib through raylib's GL +context, and driven from Ruby. + +This follows the same design rules as `API_SPEC.md` (snake_case, `?` predicates, +`=` setters, Begin/End → blocks, keyword args, structs → classes, block-safety). + +The mental model mirrors RmlUi's own MVC: + +- **Model** = a Ruby object / hash whose fields are bound to the UI. +- **View** = `.rml` (markup) + `.rcss` (stylesheet) files. +- **Controller** = Ruby callbacks invoked from `data-event-*` attributes in the RML. + +--- + +## 1. Initialization & the raylib bridge + +RmlUi needs a render backend and a system interface (timing, input). In this stack +those are implemented in C against raylib (GL + `Rl.time` + raylib input), so the +Ruby author never sees them — they just call `Rml.init`. + +```ruby +Rml.init # wires RmlUi to raylib's GL context + system clock +Rml.load_font("ui/LatoLatin-Regular.ttf") +Rml.load_font("ui/font.ttf", fallback: true) +Rml.shutdown # auto on Rl.close_window +``` + +> `Rml.init` must be called **after** `Rl.init_window` (it needs the GL context). +> The binding asserts this and raises a clear Ruby error otherwise. + +--- + +## 2. `Rml::Context` + +A context owns documents and dispatches input. Most games need exactly one. + +```ruby +ctx = Rml::Context.new("main", width: 900, height: 675) +# width/height default to the current window size (Rl) when omitted: +ctx = Rml::Context.new("main") + +ctx.dimensions = Rl::Vector2.new(1280, 720) # on window resize +ctx.update # process data-model changes + layout (Context::Update) +ctx.render # draw the UI (Context::Render) +ctx.process_input # feed raylib mouse/keyboard/text into RmlUi this frame +``` + +### Where it goes in the frame + +`process_input` runs at the top of the frame (before game update). `update` + +`render` run *inside* the `Rl.draw` block so the UI composits over the game: + +```ruby +Rl.while_window_open do + ctx.process_input + # ... game update ... + Rl.draw(clear_color: Rl::BLACK) do + # ... game render ... + ctx.update + ctx.render # UI on top + end +end +``` + +> **Block-form convenience.** `ctx.frame { ... }` wraps `process_input` (pre) and +> `update`+`render` (post) around the block, enforcing correct ordering: +> ```ruby +> Rl.while_window_open do +> ctx.frame do +> Rl.draw(clear_color: Rl::BLACK) { game.render } +> end +> end +> ``` + +--- + +## 3. Documents — `Rml::Document` + +```ruby +doc = ctx.load_document("ui/hud.rml") # LoadDocument +doc.show # ElementDocument::Show +doc.hide +doc.close +doc.visible? +doc.title # from <title> in the RML +doc.reload # hot-reload markup+styles (dev convenience) +``` + +> **Improvement / jam-friendly:** `ctx.load_document` accepts a block that auto-shows +> and yields the document: +> ```ruby +> ctx.load_document("ui/menu.rml") { |d| d.show } +> ``` + +--- + +## 4. Data binding — the core feature + +This is the reason to use RmlUi over raygui. A **data model** binds Ruby state to +named variables referenced in the RML via `data-*` attributes. + +### 4.1 Defining a model + +```ruby +model = ctx.data_model("hud") do |m| + # one-way (view): a getter lambda, re-read whenever the model is updated + m.bind(:hp) { player.hp } + m.bind(:max_hp) { player.max_hp } + m.bind(:score) { game.score } + + # two-way: bind a plain mutable value; UI inputs write back into Ruby + m.value(:volume, 0.5) # <input type="range" data-value="volume"/> + m.value(:player_name, "Orc") + + # arrays / lists (RmlUi data-for) + m.bind(:invaders) { game.invaders } # each element exposes its own fields + + # controller callbacks: invoked from data-event-* in the RML + m.event(:reset) { game.reset } + m.event(:launch) { |ev| game.launch(ev["mouse_x"], ev["mouse_y"]) } +end +``` + +Corresponding RML: + +```html +<div>HP: {{hp}} / {{max_hp}}</div> +<div>Score: {{score}}</div> +<input type="range" min="0" max="1" step="0.05" data-value="volume"/> +<button data-event-click="reset()">Reset</button> +<ul> + <li data-for="inv : invaders">{{inv.name}} — {{inv.danger}}</li> +</ul> +``` + +### 4.2 Notifying the UI of changes (`DirtyVariable`) + +RmlUi does not poll Ruby every frame; you tell it what changed. + +```ruby +model.dirty(:hp) # DataModelHandle::DirtyVariable("hp") +model.dirty(:hp, :score) # several at once +model.dirty_all # DataModelHandle::DirtyAllVariables +``` + +> **Improvement over raw RmlUi ergonomics:** `model.dirty(:hp)` returns the model +> so calls chain, and `ctx.update` is what actually flushes them to the view. +> One-way `bind` getters are only re-invoked for variables marked dirty (or after +> `dirty_all`), so binding to expensive getters is cheap. + +### 4.3 Reading back two-way values + +```ruby +model[:volume] # => current Float, reflecting any UI edits +model[:volume] = 0.8 # set from Ruby; auto-marks dirty +``` + +### 4.4 Mapping Ruby types to RmlUi variants + +| Ruby | RmlUi data variable | +|-----------------|---------------------| +| `Integer`/`Float` | numeric scalar | +| `String`/`Symbol` | string scalar | +| `true`/`false` | bool scalar | +| `Array` | data array (`data-for`) | +| `Hash` / object responding to readers | struct (`x.field`) | +| `Proc`/lambda (in `bind`) | computed one-way scalar | + +For `Hash`/object structs, the binding exposes keys / public reader methods as +`{{inv.field}}`. Nested arrays/structs are supported. + +--- + +## 5. Events from RML → Ruby + +Two ways UI interaction reaches Ruby: + +1. **Data events** (preferred, MVC): `data-event-click="reset()"` → the `:reset` + callback registered with `m.event`. The callback receives an event object: + ```ruby + m.event(:launch) do |ev| + ev.type # => "click" + ev["mouse_x"] # event parameters (GetParameter) + ev.target # => Rml::Element that fired it + end + ``` +2. **Direct element listeners** (escape hatch): + ```ruby + btn = doc.element("#start") # GetElementById + btn.on(:click) { game.start } + ``` + +--- + +## 6. Elements — `Rml::Element` (escape hatch) + +Most UI is declarative, but direct manipulation is available: + +```ruby +el = doc.element("#hp_bar") # by id (GetElementById) +els = doc.elements(".enemy") # by selector (GetElementsByTagName/QSA) + +el.text = "Game Over" # inner RML/text +el.set_attribute("class", "dead") +el.get_attribute("class") +el.add_class("hidden"); el.remove_class("hidden") +el.style["width"] = "200px" # inline style property +el.visible = false +el.on(:click) { ... } # AddEventListener +``` + +--- + +## 7. Input routing detail + +`ctx.process_input` translates raylib input into RmlUi each frame: + +- `Rl.mouse_position` → `Context::ProcessMouseMove` +- mouse buttons → `ProcessMouseButtonDown/Up` +- `Rl.mouse_wheel` → `ProcessMouseWheel` +- key events + Unicode text → `ProcessKeyDown/Up` + `ProcessTextInput` + +```ruby +ctx.input_enabled = false # let the game swallow input (e.g. gameplay vs menu) +ctx.hovered? # true if pointer is over a non-transparent UI element +``` + +> **`ctx.hovered?` is the key jam helper:** gate gameplay clicks behind +> `next if ui.hovered?` so clicking a button doesn't also fire in the game world. + +--- + +## 8. Canonical UI shape (full example) + +```ruby +Rl.init_window(900, 675, "Orc") +Rml.init +Rml.load_font("ui/Lato-Regular.ttf") + +ui = Rml::Context.new("main") +model = ui.data_model("hud") do |m| + m.bind(:hp) { Player.hp } + m.value(:volume, 0.5) + m.event(:reset) { Game.reset } +end +hud = ui.load_document("ui/hud.rml") +hud.show + +Rl.while_window_open do + ui.process_input + Game.update unless ui.hovered? + Rl.set_master_volume(model[:volume]) + model.dirty(:hp) + + Rl.draw(clear_color: Rl::BLACK) do + Game.render + ui.update + ui.render + end +end +``` + +--- + +## 9. Block-safety & lifetime (consistent with core spec) + +- `ctx.frame`, `ctx.data_model`, `ctx.load_document {…}` are block forms and honor + the §5 block-safety contract from `API_SPEC.md`. +- `Rml::Context`, `Rml::Document`, fonts hold native handles; they are GC-finalized + and also expose explicit teardown (`ctx.close`, `doc.close`). `Rml.shutdown` runs + automatically at `Rl.close_window`. + +--- + +## 10. Settled decisions + +- RmlUi is the **sole** UI layer (no raygui). +- The render/system backend is C-against-raylib and hidden from Ruby. +- Data binding (MVC) is the primary interaction model; direct element access is the + documented escape hatch. + +## 11. Open implementation notes (not Ruby-facing) + +- RmlUi's GL2/GL3 sample backend can be adapted, but raylib owns the GL context, so + the render interface must use `rlgl` (raylib's GL abstraction) to stay compatible + with both desktop GL and WebGL under emscripten. Flagged here for the build phase. diff --git a/docs/BUILD_SYSTEM.md b/docs/BUILD_SYSTEM.md new file mode 100644 index 0000000..b4fbc8c --- /dev/null +++ b/docs/BUILD_SYSTEM.md @@ -0,0 +1,271 @@ +# raylib-jamstack — Build System Design + +Goal: **one command per target**, desktop + web, orchestrated by a single +`build.zig`, with mruby running the game code and raylib + RmlUi linked in. + +``` +zig build run # build + run native desktop +zig build -Dtarget=wasm32-emscripten # build web (html/js/wasm) +zig build serve # build web + local http server +``` + +--- + +## 1. Can Zig do this? (the user's question) + +**Yes, for orchestration — with one caveat for web.** + +- **Native desktop:** Zig fully self-hosts. `zig cc` compiles all C/C++ (raylib, + RmlUi, mruby, the bindings) and `zig build-exe` links the final executable. No + external toolchain needed beyond Zig itself. Cross-compiling desktop↔desktop + (linux/win/mac) is free. +- **Web (wasm):** Zig can target `wasm32-emscripten`, **but it still needs the + Emscripten SDK present.** Zig does *not* reimplement emscripten's libc, the GL→ + WebGL shim, asyncify, or the HTML/JS shell. What Zig does is **drive `emcc`** for + the final link step. raylib's ecosystem already provides this glue: raylib-zig + exposes an `emsdk` module (`emccStep`, `emccDefaultFlags`, `emccDefaultSettings`) + that the `build.zig` calls. So the build is "unified under Zig," but EMSDK is a + build dependency on the web path. + +**Conclusion:** target a single `build.zig` as the entry point for both. Desktop is +pure Zig; web is Zig-orchestrated-emscripten. + +> **Version pinning is mandatory.** Zig pre-1.0 breaks `build.zig` APIs between +> minors, and the working emscripten version is coupled to the raylib/zig combo +> (community reports: emsdk ~3.1.7x with zig 0.14.x). Pin Zig and emsdk versions in +> the repo and CI. Treat a known-good triple (zig, emsdk, raylib) as one unit. + +--- + +## 2. Component build strategy + +Several C/C++ bodies of code must end up in one binary, plus the Ruby game code. + +| Component | Language | How it's built | Native | Web | +|-----------|----------|----------------|--------|-----| +| **raylib** | C | `make` (Wayland desktop / `PLATFORM_WEB`) | static lib | static lib, emcc-linked | +| **RmlUi** | C++ | `cmake` (`rmlui_core`); render backend via `rlgl` | static lib | static lib (emcc) | +| **flecs** | C | the single-file amalgamation, one `cc`/`emcc` object | `libflecs.a` | `libflecs.a` (emcc) | +| **Jolt** | C++ | `cmake` (joltc + JoltPhysics), merged into one archive | `libjoltphysics.a` | `libjoltphysics.a` (emcc) | +| **mruby** | C | its own `rake` + `build_config.rb` | `libmruby.a` | `libmruby.a` (emcc) | +| **bindings** (`Rl::`, `Rml::`, `Flecs::`, `Jolt::`) | C/C++ | compiled inside libmruby as mrbgems | objects | objects | +| **game** | Ruby | loaded as source by `src/main.c` (bytecode for release) | preloaded | preloaded | + +> **flecs** is the easiest dependency: the `vendor/flecs/distr/flecs.c` +> amalgamation compiles to a single object (`cc` desktop / `emcc` web) and is +> linked at the final step. The whole amalgamation (incl. the meta/reflection +> addon used for runtime components) is emscripten-aware. The web link needs +> `-sSTACK_SIZE=4MB` (flecs init/meta exceeds emscripten's 64 KB default stack). + +### 2.1 mruby is the awkward one + +mruby builds via its own Rake-driven `build_config.rb`, not Zig. Two integration +options: + +- **(A) Drive mruby's rake from a `build.zig` system-command step.** The + `build_config.rb` sets the compiler: + ```ruby + # desktop cross-build using zig as the C compiler + MRuby::Build.new do |conf| + conf.cc.command = "zig cc" # (+ -target for cross) + conf.linker.command = "zig cc" + conf.gembox "default" + # our mrbgems: raylib bindings, rmlui bindings + conf.gem File.expand_path("../mrbgems/raylib", __dir__) + conf.gem File.expand_path("../mrbgems/rmlui", __dir__) + end + + # web build using emscripten's compiler + MRuby::CrossBuild.new("web") do |conf| + conf.cc.command = "emcc" + conf.linker.command = "emcc" + conf.host_target = nil + # ...same gems... + end + ``` + `build.zig` invokes `rake` to produce `libmruby.a`, then links it. +- **(B) Vendor mruby and feed its source list to `zig build` directly.** More work + (mruby's build generates C from Ruby/`mrbgem` rakefiles first), but removes the + Ruby/rake dependency from the build. **Recommend (A) for the jam** — it's the + documented path and mrbgems are how bindings get registered. + +### 2.2 Bindings as mrbgems + +The `Rl::`, `Rml::`, and `Flecs::` bindings are packaged as **mrbgems** +(`mrbgem.rake` + `src/*.c` + optional `mrblib/*.rb` for the Ruby-side sugar). This +is the standard mruby extension mechanism and keeps native + Ruby halves of each +binding together. + +``` +mrbgems/ + raylib/ + mrbgem.rake + tools/gen_raylib.rb # generates src/raylib_gen.c from raylib_api.json + src/raylib_bindings.c # hand-written entry (platform/web-loop seam) + mrblib/raylib.rb # Ruby: while_window_open, key sym map, blocks + rmlui/ + mrbgem.rake + src/rml_bindings.cpp # C++: context/element/event/data-model + rlgl backend + mrblib/rmlui.rb + flecs/ + mrbgem.rake # just puts vendor/flecs/distr on the include path + src/flecs_bindings.c # C: World/Entity/Query, meta (de)serialization + mrblib/flecs.rb # Ruby: World/Entity/Component/Query sugar + jolt/ + mrbgem.rake # puts vendor/joltc/include on the include path + src/jolt_bindings.c # C over the joltc C API: World/Body/Shape/raycast + mrblib/jolt.rb # Ruby: World/Body/Shape sugar (Rl::Vector3 in/out) +``` + +> **flecs and Jolt as static libs:** both are built outside mruby and linked at +> the final step (flecs = one amalgamation object; Jolt = joltc + JoltPhysics via +> CMake, **merged into one `libjoltphysics.a`** because lld won't resolve the +> `libmruby -> libjoltc -> libJolt` 3-archive chain). Jolt must be built with +> `-DINTERPROCEDURAL_OPTIMIZATION=OFF` — its default GCC `-flto` objects are +> GIMPLE bytecode that zig's lld cannot link. + +> Pure-Ruby sugar (block-form `draw`/`scissor_mode`, `:w`→keycode, `Vector2#+`, +> `Texture.load` cache) lives in `mrblib/` so it's written in Ruby, not C — far less +> binding code to maintain. + +--- + +## 3. Directory layout + +``` +raylib-jamstack/ + build.zig # single entry point (native + web) + build.zig.zon # pins: zig deps incl. raylib(-zig), emsdk version + build_config.rb # mruby Build + CrossBuild("web") + docs/ + API_SPEC.md + API_SPEC_RMLUI.md + API_SPEC_FLECS.md + AI_REFERENCE.md # whole API in one file (generated) + BUILD_SYSTEM.md + mrbgems/ + raylib/ rmlui/ flecs/ + vendor/ # raylib, RmlUi, flecs, mruby (git-ignored clones) + game/ + main.rb # entry point run by mruby + ui/ # .rml / .rcss / fonts + assets/ # textures, audio + build/ + desktop/ web/ +``` + +--- + +## 4. Asset & game-code packaging + +- **Desktop:** assets shipped alongside the binary (or embedded). `main.rb` is + compiled to bytecode with `mrbc` and either embedded in the exe or loaded at start. +- **Web:** emscripten `--preload-file game/assets@assets` packs assets into the + `.data` file; the mruby bytecode is embedded in the wasm. The HTML shell is a + customizable template (itch.io-ready, fixed canvas, no default emscripten UI). + +--- + +## 5. The web main-loop seam + +emscripten cannot use a blocking `while`. This is why `Rl.while_window_open` (and +`ctx.frame`) are **blocks** (API_SPEC §1.1): the binding registers the block as the +emscripten main-loop callback via `emscripten_set_main_loop_arg`, while on desktop +it's a plain `while`. Game code is identical across targets. Audio on web also needs +a user gesture before `InitAudioDevice` — surface that via `Rl.audio_device_ready?` +rather than a custom platform hack. + +--- + +## 6. RmlUi rendering under emscripten + +RmlUi ships GL2/GL3 sample backends, but **raylib owns the GL context**, and on web +that context is WebGL. The render interface must be implemented against **`rlgl`** +(raylib's GL abstraction) instead of raw GL calls, so the same backend code works on +desktop GL and WebGL. This is the main bespoke C++ in the stack and the biggest +build-phase risk; prototype it early. + +--- + +## 7. Recommended build order (implementation phase) + +1. `build.zig` that compiles + links **raylib + mruby + a hello-window** (`Rl` + only), desktop. Proves the mruby↔zig↔raylib spine. +2. Add the **web target** (emsdk via raylib-zig's `emccStep`); get the same hello + window in a browser. Locks the hardest part (toolchain triple) early. +3. Flesh out `Rl::` mrbgem to cover API_SPEC. +4. Add **RmlUi** + the `rlgl` render backend; get a static `.rml` rendering over the + game on both targets. +5. `Rml::` data binding + input routing. +6. Asset packaging, itch.io HTML shell, `zig build serve`. +7. Add **flecs** (`Flecs::`) — vendor the amalgamation, link `libflecs.a`, + runtime meta components; verify on desktop + web. +8. Add **Jolt** (`Jolt::`) — vendor joltc + JoltPhysics, build/merge + `libjoltphysics.a` (LTO off), 3D rigid bodies; verify on desktop + web. + +--- + +## 7a. Status: web target builds and boots ✅ + +The Emscripten/WASM target is implemented (`build_web.sh` + `MRuby::CrossBuild('web')`): + +- raylib `PLATFORM_WEB`, RmlUi (emscripten + freetype port), and a wasm mruby + cross-build (embedding our mrbgems) link via `emcc` into + `build/web/game.{html,js,wasm,data}` (~3.9 MB wasm), with `game/` preloaded. +- Verified under node: the wasm loads, mruby boots, runs `game/main.rb`, calls the + `Rl` bindings, and raylib reports `Platform backend: WEB (HTML5)` with all modules + loaded — stopping only at `glfwInit` (`window is not defined`), the browser-only + boundary. Visual confirmation requires an actual browser (`python3 -m http.server`). +- The main-loop seam works: `Rl.while_window_open` uses `emscripten_set_main_loop` + on web vs. a `while` on desktop, with identical game code. + +Remaining web polish: a custom itch.io shell exists (`web/shell.html`); audio needs +a user-gesture before `InitAudioDevice` (surface via `Rl.audio_device_ready?`). + +## 7b. Status: spine is built and runs ✅ + +The minimal vertical slice (steps 1–3 above, minus web/RmlUi) is implemented and +verified end-to-end: + +- `zig build` links `src/main.c` + `libmruby.a` (with the `Rl::` bindings mrbgem) + + `libraylib.a` + system GL. +- `game/main.rb` opens a window via `Rl.init_window`, runs `Rl.while_window_open`, + draws text, and reads `Rl.key_down?(:a/:d)` — all the implemented API spec surface. +- Confirmed: mruby boots, C bindings register, raylib reports + `PLATFORM: DESKTOP (GLFW - Wayland): Initialized successfully` at 60 fps. + +See `BUILDING.md` for exact commands. + +**RmlUi render-interface findings (rlgl):** getting RmlUi's indexed-triangle +geometry to render correctly through rlgl required three non-obvious fixes: + +1. **Texture must be set AFTER `rlBegin`.** `rlBegin(mode)` resets the draw group's + `textureId` to the default texture whenever the draw mode changes. Calling + `rlSetTexture()` before `rlBegin(RL_TRIANGLES)` (the natural order, and what + raylib's own `DrawTexture*` uses) gets wiped on the first mode switch → glyphs + sampled the 1×1 white texture and rendered as **solid squares**. Order must be + `rlBegin` → `rlSetTexture` → vertices → `rlEnd`. +2. **Premultiplied alpha.** RmlUi 6.x emits premultiplied-alpha vertex colours and + textures; render with `RL_BLEND_ALPHA_PREMULTIPLY`. Do NOT premultiply the + atlas yourself — `GenerateTexture` already supplies premultiplied RGBA. +3. **Flush per geometry.** rlgl's batch is quad-centric and pads `RL_TRIANGLES` + runs for quad-index alignment; letting multiple glyph runs (different textures) + accumulate corrupts geometry across draw groups (garbled/overlapping text, + diagonal streaks). Call `rlDrawRenderBatchActive()` after each `RenderGeometry`. + +A retained-mode VAO/VBO/EBO per compiled geometry would avoid #3 entirely and is +the better long-term path, but per-geometry flushing is correct and fine for a HUD. + +**WSLg finding:** raylib's default **X11** GLFW backend segfaults inside Mesa's GLX +driver (`dri2GalliumConfigQueryb`) under WSLg. The fix is the **Wayland** backend +(`-D_GLFW_WAYLAND`), which initializes cleanly (llvmpipe GL 4.6). This is purely an +environment quirk; the binding chain itself was correct from the first build. + +## 8. Risks / unknowns to validate + +- **Zig+emsdk version drift** — pin and CI both targets (§1). +- **RmlUi-on-rlgl for WebGL** — unproven glue, prototype first (§6). +- **mruby rake invoked from build.zig** — cache `libmruby.a` so it doesn't rebuild + every `zig build`; make it a tracked artifact with proper deps. +- **C++ (RmlUi) + emscripten exceptions/RTTI** — RmlUi may need `-fexceptions`/ + `-frtti` flags carried into the emcc link; confirm against current RmlUi. diff --git a/docs/DEPLOY_CLOUDFLARE.md b/docs/DEPLOY_CLOUDFLARE.md new file mode 100644 index 0000000..be54674 --- /dev/null +++ b/docs/DEPLOY_CLOUDFLARE.md @@ -0,0 +1,63 @@ +# Deploying to Cloudflare Pages (read only when asked to deploy) + +> This file is intentionally NOT referenced from `AGENTS.md` or any always-loaded +> doc. Don't act on it unless the user explicitly asks to deploy the site. + +The site is hosted on **Cloudflare Pages**, served from an **orphan `static` +branch** that contains only the prebuilt web files at its root. Cloudflare is +connected to the GitHub repo and auto-deploys whenever `static` changes — there is +**no build step on Cloudflare's side**. Deploying = build locally, then replace the +`static` branch with the fresh files. + +## Deploy (the whole procedure) + +1. Build the web output (from the repo root): + ```sh + EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh + ``` + Confirm it ends with `OK -> build/web/game.html` and that these exist: + `build/web/game.html`, `build/web/game.js`, `build/web/game.wasm`, + `build/web/game.data`. (PATH note: strip `/mnt/c` first under WSL — see + `.agents/knowledge/environment.md`.) + +2. Replace the `static` branch with the new files and push. This uses a throwaway + repo + force-push, so `static` stays a single clean commit (no history bloat) + and your working tree / `main` are never touched: + ```sh + WT=$(mktemp -d) + cp build/web/game.js build/web/game.wasm build/web/game.data "$WT"/ + cp build/web/game.html "$WT"/index.html # Pages serves /index.html at / + cp web/_headers "$WT"/_headers # cache headers (optional) + git -C "$WT" init -q + git -C "$WT" checkout -q -b static + git -C "$WT" add -A + git -C "$WT" -c user.email=deploy@local -c user.name=deploy \ + commit -q -m "Static build $(date -u +%FT%TZ)" + git -C "$WT" push -f [email protected]:realtradam/raylib-jamstack.git static + rm -rf "$WT" + ``` + +3. Cloudflare Pages picks up the push and deploys in ~1 minute. Done. + +The web entry script (which `.rb` demo runs) is set in `web/shell.html` +(`Module.arguments`); change it and rebuild before deploying if needed. + +## One-time Cloudflare setup (already connected to GitHub) + +In the Cloudflare dashboard → Workers & Pages → your Pages project (or create one +from the connected `raylib-jamstack` repo): + +- **Production branch:** `static` +- **Framework preset:** None +- **Build command:** *(leave empty)* +- **Build output directory:** `/` (root — the files live at the branch root) +- (Optional) Disable preview deployments for other branches so pushes to `main` + don't trigger empty builds: Settings → Builds & deployments → Branch control. + +Because the build command is empty, Cloudflare just uploads the branch root as-is. + +## Notes +- Single-threaded build (no pthreads/SharedArrayBuffer) → **no COOP/COEP headers + needed**. `.wasm` is served as `application/wasm` automatically. +- `game.wasm` is ~7 MB (well under Pages' 25 MiB/file limit). +- Force-pushing `static` is expected and safe: it holds only generated artifacts. diff --git a/game/ballpit_demo.rb b/game/ballpit_demo.rb new file mode 100644 index 0000000..1c1b3d5 --- /dev/null +++ b/game/ballpit_demo.rb @@ -0,0 +1,296 @@ +# raylib-jamstack: playable 3D character demo — raylib (render) + Jolt (physics). +# +# ./zig-out/bin/game game/ballpit_demo.rb (desktop) +# (web: preloaded; runnable as /game/ballpit_demo.rb) +# +# A third-person character walks on a big flat plane with a square PIT cut into +# the middle. Inside the pit are dynamic balls the player can shove around (but +# the balls can't push the player back — the player is a kinematic +# CharacterVirtual). A short stair-ramp on one side of the pit lets the player +# climb back out. A big KINEMATIC sphere orbits the pit on the plane and bowls +# the player out of its way (the player can't push it back). +# +# Controls: WASD move (camera-relative) SPACE jump Mouse look +# hold SHIFT to sprint ESC quit +# +# Layout (top-down, +x right, +z toward camera-back; y is up): +# - ground top surface at y = 0 +# - square pit opening of side PIT_SIZE centred at origin, floor at y = -PIT_DEPTH +# - stairs descend into the pit from the +x edge + +# ----------------------------------------------------------------- constants -- +GROUND_EXTENT = 24.0 # half-size of the whole plane +PIT_SIZE = 9.0 # full side length of the square pit opening +PIT_HALF = PIT_SIZE / 2.0 +PIT_DEPTH = 3.0 # how far below the top surface the pit floor sits +GROUND_THICK = 1.0 # thickness of the top ground slabs +WALL_THICK = 0.6 + +PLAYER_RADIUS = 0.4 +PLAYER_HALFH = 0.5 # capsule cylinder half-height +PLAYER_FOOT_TO_CENTRE = PLAYER_HALFH + PLAYER_RADIUS # capsule centre above feet + +MOVE_SPEED = 6.0 +SPRINT_MULT = 1.7 +JUMP_SPEED = 7.5 +GRAVITY = 22.0 + +# ----------------------------------------------------------------- window ------ +Rl.init_window(1024, 600, "raylib-jamstack: ball pit character (Jolt)") +Rl.target_fps = 60 +Rl.disable_cursor # lock + hide the mouse for FPS-style look + +# ----------------------------------------------------------------- physics ----- +world = Jolt::World.new(gravity: [0, -GRAVITY, 0]) + +# Boxes we want to draw later: each entry is [centre(Array), size(Array), color]. +boxes = [] + +def static_box(world, boxes, cx, cy, cz, sx, sy, sz, color, friction: 0.8) + world.body(shape: Jolt.box(sx, sy, sz), position: [cx, cy, cz], + motion: Jolt::STATIC, friction: friction) + boxes << [[cx, cy, cz], [sx, sy, sz], color] +end + +GROUND_COL = Rl::Color.new(70, 80, 70, 255) +PIT_COL = Rl::Color.new(55, 52, 64, 255) +WALL_COL = Rl::Color.new(90, 86, 70, 255) +STAIR_COL = Rl::Color.new(120, 100, 70, 255) + +# --- top ground built as a FRAME of 4 slabs around the square pit opening --- +# Each slab spans the full width in one axis and fills the margin on the other. +# Top surface sits at y = 0, so slab centre y = -GROUND_THICK/2. +gy = -GROUND_THICK / 2.0 +side = GROUND_EXTENT - PIT_HALF # depth of each frame slab +off = PIT_HALF + side / 2.0 # centre offset of each frame slab +full = GROUND_EXTENT * 2.0 +# +z and -z slabs (full width in x, "side" deep in z) +static_box(world, boxes, 0.0, gy, off, full, GROUND_THICK, side, GROUND_COL) +static_box(world, boxes, 0.0, gy, -off, full, GROUND_THICK, side, GROUND_COL) +# +x and -x slabs (only the pit width in z so they don't overlap the others) +static_box(world, boxes, off, gy, 0.0, side, GROUND_THICK, PIT_SIZE, GROUND_COL) +static_box(world, boxes, -off, gy, 0.0, side, GROUND_THICK, PIT_SIZE, GROUND_COL) + +# --- pit floor (lowered) --- +pit_floor_y = -PIT_DEPTH - GROUND_THICK / 2.0 +static_box(world, boxes, 0.0, pit_floor_y, 0.0, PIT_SIZE, GROUND_THICK, PIT_SIZE, PIT_COL, friction: 0.9) + +# --- pit walls (vertical slabs lining the inside of the opening) --- +# The +x wall is split into two short segments leaving a gap where the staircase +# joins the pit, so the player can walk straight off the bottom step. +wall_h = PIT_DEPTH +wall_cy = -PIT_DEPTH / 2.0 +STAIR_W = 3.4 # width (z-span) of the staircase strip +gap_half = STAIR_W / 2.0 +seg_len = (PIT_SIZE - STAIR_W) / 2.0 +seg_off = gap_half + seg_len / 2.0 +# -z and +z walls (run along x), full width +static_box(world, boxes, 0.0, wall_cy, PIT_HALF, PIT_SIZE, wall_h, WALL_THICK, WALL_COL) +static_box(world, boxes, 0.0, wall_cy, -PIT_HALF, PIT_SIZE, wall_h, WALL_THICK, WALL_COL) +# -x wall (run along z), full inner width +static_box(world, boxes, -PIT_HALF, wall_cy, 0.0, WALL_THICK, wall_h, PIT_SIZE - 2 * WALL_THICK, WALL_COL) +# +x wall split around the staircase gap +static_box(world, boxes, PIT_HALF, wall_cy, seg_off, WALL_THICK, wall_h, seg_len, WALL_COL) +static_box(world, boxes, PIT_HALF, wall_cy, -seg_off, WALL_THICK, wall_h, seg_len, WALL_COL) + +# --- staircase on the +x side: a compact flight from pit floor up to the surface --- +# Steps are axis-aligned boxes with rise <= 0.4 m, which the CharacterVirtual +# climbs via stair-stepping (walkStairsStepUp = 0.4 m). Each step is a box from +# the pit floor up to its top surface; they march in +x and only span STAIR_W in +# z, so the rest of the pit floor stays open for the balls. +STEP_RISE = 0.375 +STEP_RUN = 0.55 +n_steps = (PIT_DEPTH / STEP_RISE).ceil # enough steps to reach the surface +floor_top = pit_floor_y + GROUND_THICK / 2.0 +n_steps.times do |i| + top_y = -PIT_DEPTH + (i + 1) * STEP_RISE # this step's top surface + top_y = 0.0 if top_y > 0.0 + height = top_y - floor_top # from pit floor up to step top + cy = floor_top + height / 2.0 + # innermost (lowest) step nearest the pit centre; flight climbs toward +x edge + cx = (PIT_HALF - STEP_RUN / 2.0) - (n_steps - 1 - i) * STEP_RUN + static_box(world, boxes, cx, cy, 0.0, STEP_RUN, height, STAIR_W, STAIR_COL, friction: 0.95) +end + +# ----------------------------------------------------------------- balls -------- +PALETTE = [Rl::RED, Rl::ORANGE, Rl::GOLD, Rl::LIME, Rl::SKYBLUE, + Rl::BLUE, Rl::VIOLET, Rl::PINK] +balls = [] # each: [body, radius, color] +def spawn_ball(world, balls, pos, radius, color) + b = world.body(shape: Jolt.sphere(radius), position: pos, + motion: Jolt::DYNAMIC, restitution: 0.25, friction: 0.5) + balls << [b, radius, color] +end + +# A loose grid of balls resting on the -x half of the pit floor, clear of the +# staircase (which occupies the +x half). +ci = 0 +ball_top = pit_floor_y + GROUND_THICK / 2.0 +[-3.4, -2.4, -1.4, -0.4].each do |bx| + [-3.0, -1.5, 0.0, 1.5, 3.0].each do |bz| + r = 0.45 + spawn_ball(world, balls, [bx, ball_top + r + 0.05, bz], r, PALETTE[ci % PALETTE.length]) + ci += 1 + end +end + +# ----------------------------------------------------------------- orbiter ------ +# A big KINEMATIC sphere circling on the flat plane around the pit. It pushes the +# player (kinematic vs character penetration recovery) but the player can't push +# it. We drive it by setting linear_velocity to the circle tangent each frame. +ORBIT_RADIUS = GROUND_EXTENT - 6.0 +ORBIT_SPEED = 5.0 # m/s along the circle +ORBIT_R = 1.6 # sphere radius +orbit_y = ORBIT_R - 0.1 # rolls along on the top surface (y=0) +orbit_angle = 0.0 +orbiter = world.body(shape: Jolt.sphere(ORBIT_R), + position: [ORBIT_RADIUS, orbit_y, 0.0], + motion: Jolt::KINEMATIC, friction: 0.3) + +# ----------------------------------------------------------------- player ------- +player = world.character(shape: Jolt.capsule(PLAYER_HALFH, PLAYER_RADIUS), + position: [0.0, PLAYER_FOOT_TO_CENTRE + 0.2, GROUND_EXTENT - 5.0], + max_slope_deg: 50.0, mass: 80.0) +player.max_strength = 6000.0 # strong enough to shove the heavy default-density balls + +world.optimize_broad_phase + +# ----------------------------------------------------------------- camera ------- +cam = Rl::Camera3D.new( + Rl::Vector3.new(0, 6, 12), + Rl::Vector3.new(0, 1, 0), + Rl::Vector3.new(0, 1, 0), + 55.0, + Rl::CAMERA_PERSPECTIVE +) +cam_yaw = Math::PI # look toward -z initially (toward the pit) +cam_pitch = -0.35 +CAM_DIST = 7.5 +MOUSE_SENS = 0.0032 +PITCH_MIN = -1.3 +PITCH_MAX = 0.4 + +# ----------------------------------------------------------------- helpers ------ +def clampf(v, lo, hi); v < lo ? lo : (v > hi ? hi : v); end + +# ----------------------------------------------------------------- main loop ---- +Rl.while_window_open do + dt = Rl.frame_time + dt = 1.0 / 60.0 if dt <= 0.0 || dt > 0.1 # clamp for stability / first frame + + # --- mouse look --- + # Browsers only grant pointer lock from a user gesture, so (re)lock on click; + # on desktop disable_cursor at startup already locked it. Only rotate the + # camera while actually locked, so the view doesn't jump before the first click + # or after ESC releases the lock. + Rl.disable_cursor if Rl.mouse_button_pressed?(Rl::MOUSE_BUTTON_LEFT) && !Rl.cursor_hidden? + if Rl.cursor_hidden? + md = Rl.get_mouse_delta + cam_yaw -= md.x * MOUSE_SENS # mouse-right looks right + cam_pitch = clampf(cam_pitch - md.y * MOUSE_SENS, PITCH_MIN, PITCH_MAX) + end + + # camera-relative ground basis from yaw. right = cross(forward, up), matching + # raylib's own camera-right convention so D strafes screen-right. + fwd_x = Math.sin(cam_yaw) + fwd_z = Math.cos(cam_yaw) + right_x = -Math.cos(cam_yaw) + right_z = Math.sin(cam_yaw) + + # --- WASD movement intent (camera-relative, on the ground plane) --- + # NOTE: a trailing `if` only guards the LAST statement on the line, so each key + # must gate BOTH axis components explicitly (else the X component never applies). + mx = 0.0 + mz = 0.0 + if Rl.key_down?(:w) then mx += fwd_x; mz += fwd_z; end + if Rl.key_down?(:s) then mx -= fwd_x; mz -= fwd_z; end + if Rl.key_down?(:a) then mx -= right_x; mz -= right_z; end + if Rl.key_down?(:d) then mx += right_x; mz += right_z; end + len = Math.sqrt(mx * mx + mz * mz) + if len > 0.0001 + mx /= len + mz /= len + end + speed = MOVE_SPEED * (Rl.key_down?(:left_shift) ? SPRINT_MULT : 1.0) + + # --- vertical velocity: jump if grounded, else integrate gravity --- + v = player.velocity + vy = if player.on_ground? + Rl.key_pressed?(:space) ? JUMP_SPEED : 0.0 + else + v.y - GRAVITY * dt + end + player.velocity = [mx * speed, vy, mz * speed] + player.update(dt) + + # --- drive the orbiting kinematic sphere along its circle (tangent velocity) --- + orbit_angle += (ORBIT_SPEED / ORBIT_RADIUS) * dt + tang_x = -Math.sin(orbit_angle) + tang_z = Math.cos(orbit_angle) + orbiter.linear_velocity = [tang_x * ORBIT_SPEED, 0.0, tang_z * ORBIT_SPEED] + + # --- advance physics --- + world.step(dt) + + # keep the orbiter pinned to its circle + height (kinematic drift correction) + oa = orbit_angle + orbiter.set_transform(position: [Math.cos(oa) * ORBIT_RADIUS, orbit_y, + Math.sin(oa) * ORBIT_RADIUS]) + + # --- third-person follow camera --- + p = player.position + cp = Math.cos(cam_pitch) + eye_x = p.x - fwd_x * CAM_DIST * cp + eye_z = p.z - fwd_z * CAM_DIST * cp + eye_y = p.y + 2.2 - Math.sin(cam_pitch) * CAM_DIST + cam.position = Rl::Vector3.new(eye_x, eye_y, eye_z) + cam.target = Rl::Vector3.new(p.x, p.y + 1.0, p.z) + + # --- render --- + Rl.draw(clear_color: Rl::Color.new(135, 160, 190, 255)) do + Rl.begin_mode3d(cam) + + # static level geometry (frame, pit, walls, stairs) + boxes.each do |centre, size, color| + Rl.draw_cube_v(Rl::Vector3.new(centre[0], centre[1], centre[2]), + Rl::Vector3.new(size[0], size[1], size[2]), color) + Rl.draw_cube_wires_v(Rl::Vector3.new(centre[0], centre[1], centre[2]), + Rl::Vector3.new(size[0], size[1], size[2]), + Rl::Color.new(30, 30, 30, 120)) + end + + # balls + balls.each do |body, radius, color| + Rl.draw_sphere(body.position, radius, color) + end + + # orbiting sphere + Rl.draw_sphere(orbiter.position, ORBIT_R, Rl::Color.new(220, 60, 60, 255)) + Rl.draw_sphere_wires(orbiter.position, ORBIT_R, 10, 10, Rl::Color.new(60, 0, 0, 180)) + + # player capsule (feet at position - PLAYER_FOOT_TO_CENTRE..., caps offset by radius) + pc = player.position + bottom = Rl::Vector3.new(pc.x, pc.y - PLAYER_HALFH, pc.z) + top = Rl::Vector3.new(pc.x, pc.y + PLAYER_HALFH, pc.z) + pcol = player.on_ground? ? Rl::Color.new(60, 200, 240, 255) : Rl::Color.new(240, 200, 60, 255) + Rl.draw_capsule(bottom, top, PLAYER_RADIUS, 12, 8, pcol) + Rl.draw_capsule_wires(bottom, top, PLAYER_RADIUS, 12, 8, Rl::Color.new(20, 40, 50, 200)) + + Rl.end_mode3d + + # --- HUD --- + Rl.draw_text(text: "WASD move SPACE jump SHIFT sprint Mouse look", + x: 12, y: 12, font_size: 20, color: Rl::RAYWHITE) + Rl.draw_text(text: "Push the balls in the pit. Dodge the rolling sphere. Use the stairs to climb out.", + x: 12, y: 38, font_size: 16, color: Rl::Color.new(230, 230, 230, 255)) + gs = player.ground_state.to_s + Rl.draw_text(text: "ground: #{gs}", x: 12, y: 62, font_size: 16, color: Rl::LIGHTGRAY) + Rl.draw_fps(Rl.screen_width - 95, 12) + + # prompt to engage pointer lock (mainly for the browser, which needs a click) + unless Rl.cursor_hidden? + Rl.draw_text(text: "Click to look around", x: Rl.screen_width / 2 - 120, + y: Rl.screen_height / 2, font_size: 26, color: Rl::RAYWHITE) + end + end +end diff --git a/game/console_demo.rb b/game/console_demo.rb new file mode 100644 index 0000000..2face40 --- /dev/null +++ b/game/console_demo.rb @@ -0,0 +1,54 @@ +# raylib-jamstack: in-game REPL console demo (R6). +# +# ./zig-out/bin/game game/console_demo.rb (desktop) +# (web: preloaded; runnable as /game/console_demo.rb) +# +# A simple scene with a moving "player" square. Press \ or ` to open the +# REPL console. Type Ruby expressions to inspect/modify game state live. +# +# Controls: A/D move left/right, SPACE score++, \ or ` toggle console, ESC quit + +Rl.init_window(720, 720, "raylib-jamstack: REPL console") +Rl.target_fps = 60 + +Rml.init +Rml.load_font("game/ui/LatoLatin-Regular.ttf") +Rml.load_font("game/ui/LatoLatin-Bold.ttf") +Rml.load_font("game/ui/MononokiNerdFontMono-Regular.ttf") + +ui = Rml::Context.new("main") + +score = 0 +player_x = 360 +player_y = 360 +player_color = Rl::SKYBLUE + +console = Jamstack::Console.new(ui, binding: binding) +# Expose these locals to the HTML REPL (web/shell.html) and the agent bridge +# (bin/eval) so they can be read/written live, just like the in-game console. +Jamstack::Bridge.set_binding(binding) + +Rl.while_window_open do + console.update + ui.process_input + + unless console.open? + player_x -= 300 * Rl.frame_time if Rl.key_down?(:a) && player_x > 20 + player_x += 300 * Rl.frame_time if Rl.key_down?(:d) && player_x < 700 + score += 1 if Rl.key_pressed?(:space) + end + + player_y = 360 + Math.sin(Rl.time * 2) * 20 + + Rl.draw(clear_color: Rl::Color.new(30, 30, 46, 255)) do + Rl.draw_text(text: "A/D move SPACE score++ \\ open console", + x: 12, y: 12, font_size: 18, color: Rl::GRAY) + Rl.draw_text(text: "score: #{score}", + x: 12, y: 38, font_size: 20, color: Rl::YELLOW) + + Rl.draw_rectangle(player_x.to_i - 20, player_y.to_i - 20, 40, 40, player_color) + + ui.update + ui.render + end +end diff --git a/game/fx_demo.rb b/game/fx_demo.rb new file mode 100644 index 0000000..92cc1a9 --- /dev/null +++ b/game/fx_demo.rb @@ -0,0 +1,192 @@ +# raylib-jamstack: FX PIPELINE DEMO — layered two-stage post-processing. +# +# ./zig-out/bin/game game/fx_demo.rb (desktop) +# web: set web/shell.html Module.arguments to ['game/fx_demo.rb'], rebuild. +# +# Demonstrates Jamstack::FX: a GAME stage (gameplay FX on the 3D world + in-world +# UI, NOT the overlay HUD) and a TOP stage (complete FX over everything incl HUD). +# +# game + game-rmlui -> game shaders -> top-rmlui -> top shaders -> screen +# +# Runtime toggling — click the checkboxes in the overlay HUD, OR via the eval +# bridge / in-game console (`\`): +# fx.game_shaders[0].enabled = false # warp off -> world un-warps, HUD stays crisp +# fx.game_shaders[1].enabled = false # aberration off (colour shift) +# fx.top_shaders[0].enabled = false # vignette off -> whole frame incl HUD +# The checkboxes mirror the live `enabled` state each frame (so bridge toggles +# also flip the boxes), and clicking a box sets `enabled` — both paths stay in +# sync. CRT is split into warp + aberration + scanlines (each its own pass). + +# --------------------------------------------------------------- window + fonts -- +W = 720 +H = 720 +Rl.init_window(W, H, "raylib-jamstack: FX pipeline") +Rl.target_fps = 60 + +Rml.init +Rml.load_font("game/ui/LatoLatin-Regular.ttf") +Rml.load_font("game/ui/LatoLatin-Bold.ttf") +Rml.load_font("game/ui/MononokiNerdFontMono-Regular.ttf") + +# Two RmlUi contexts: in-world UI (game layer) vs screen-space overlay HUD. +# Context dimensions default to the window size (720x720), which MUST equal the +# render-texture size (the RmlUi scissor uses GetScreenHeight() — see fx.rb). +game_ui = Rml::Context.new("game") +top_ui = Rml::Context.new("overlay") +game_doc = game_ui.load_document("game/ui/fx_game.rml").show +top_doc = top_ui.load_document("game/ui/fx_overlay.rml").show +game_rot = game_doc.element("game-rot") +ovr_fps = top_doc.element("ovr-fps") + +# Expose the game binding so the eval bridge / in-game console can reach `fx`. +Jamstack::Bridge.set_binding(binding) + +# --------------------------------------------------------- the FX pipeline -- +fx = Jamstack::FX::Pipeline.new(W, H) +# GAME stage: gameplay FX on the world + in-world UI, NOT the overlay HUD. +# CRT is split into independent components so each can be toggled separately; +# they chain as ping-pong passes (warp -> aberration -> aberration_cmy -> scanlines). +fx.game_shaders << Jamstack::FX::Pass.new("warp", Jamstack::FX::WARP, intensity: 1.0) +fx.game_shaders << Jamstack::FX::Pass.new("aberration", Jamstack::FX::ABERRATION, intensity: 0.8) +fx.game_shaders << Jamstack::FX::Pass.new("aberration_cmy", Jamstack::FX::ABERRATION_CMY, intensity: 0.8) +fx.game_shaders << Jamstack::FX::Pass.new("scanlines", Jamstack::FX::SCANLINES, intensity: 0.6) +# TOP stage: complete FX over everything (incl the overlay HUD). +fx.top_shaders << Jamstack::FX::Pass.new("vignette", Jamstack::FX::VIGNETTE, intensity: 1.0) +fx.top_shaders << Jamstack::FX::Pass.new("grayscale", Jamstack::FX::GRAYSCALE, intensity: 0.65) +# FXAA anti-aliasing — split into two independent toggles, one per layer: +# fxaa_game (GAME stage): AA the 3D world + in-world UI, leaves the overlay HUD +# crisp. Suppressed when fxaa_ui is enabled (the top pass already covers the +# whole frame, so running both would double-AA the world). REQUIRES bilinear +# input (Pipeline sets BILINEAR on all render textures). +# fxaa_ui (TOP stage): AA the whole composited frame (incl the overlay HUD). +# Both share one quality slider (0..1 -> subpix / edgeThreshold / edgeThresholdMin). +# State: game-on + ui-off = world AA'd, HUD crisp. ui-on = whole frame AA'd +# (game pass suppressed) = exactly the previous single-FXAA behavior. +def fxaa_quality(pass, q) + pass.extra_uniforms[:subpix] = 0.25 + q * 0.75 + pass.extra_uniforms[:edgeThreshold] = 0.333 - q * 0.270 # 0.333 -> 0.063 + pass.extra_uniforms[:edgeThresholdMin] = 0.0833 - q * 0.0521 # 0.0833 -> 0.0312 +end +fxaa_game = Jamstack::FX::Pass.new("fxaa_game", Jamstack::FX::FXAA, + extra_uniforms: { subpix: 0.75, edgeThreshold: 0.166, edgeThresholdMin: 0.0833 }) +fxaa_ui = Jamstack::FX::Pass.new("fxaa_ui", Jamstack::FX::FXAA, + extra_uniforms: { subpix: 0.75, edgeThreshold: 0.166, edgeThresholdMin: 0.0833 }) +fxaa_quality(fxaa_game, 0.6) +fxaa_quality(fxaa_ui, 0.6) +fx.game_shaders << fxaa_game # runs LAST in the game chain (AA the final world) +fx.top_shaders << fxaa_ui # runs LAST in the top chain (AA the whole frame) + +# SMAA 1x (Enhanced Subpixel Morphological AA) — mirrors the FXAA split so you +# can A/B them. Two independent toggles (game world / whole frame) + a threshold +# slider. NO suppress / no FXAA-collision check by design — this is for testing +# how SMAA looks alongside FXAA (stacking two spatial AAs is usually redundant, +# but we leave it to the user to compare). SMAA is a 3-pass COMPOSITE effect +# (edge detect -> blend weights -> neighborhood blend) that ducks as a Pass for +# apply_chain; it owns two intermediate render textures + the area/search LUTs. +smaa_game = Jamstack::FX::Smaa.new("smaa_game", W, H, threshold: 0.1) +smaa_ui = Jamstack::FX::Smaa.new("smaa_ui", W, H, threshold: 0.1) +fx.game_shaders << smaa_game # after fxaa_game +fx.top_shaders << smaa_ui # after fxaa_ui + +# ---- clickable checklist: each checkbox toggles its shader's `enabled`. +# RmlUi checkboxes carry state in the `checked` ATTRIBUTE (vendor +# InputTypeCheckbox.cpp): click toggles it and fires `change`. We read it in the +# handler to set `enabled`; the per-frame sync below writes it back from `enabled` +# so eval-bridge toggles flip the boxes too. Pairs of [checkbox, pass]. +chk_warp = top_doc.element("chk-warp") +chk_aber = top_doc.element("chk-aber") +chk_cmy = top_doc.element("chk-cmy") +chk_scan = top_doc.element("chk-scan") +chk_vig = top_doc.element("chk-vig") +chk_gray = top_doc.element("chk-gray") +chk_fxg = top_doc.element("chk-fxaa-game") +chk_fxu = top_doc.element("chk-fxaa-ui") +chk_sg = top_doc.element("chk-smaa-game") +chk_su = top_doc.element("chk-smaa-ui") +rng_qual = top_doc.element("rng-quality") # FXAA quality slider (input range) +rng_smaa = top_doc.element("rng-smaa") # SMAA threshold slider (input range) +chk_pairs = [ + [chk_warp, fx.game_shaders[0]], # warp (game stage) + [chk_aber, fx.game_shaders[1]], # aberration RGB (game stage) + [chk_cmy, fx.game_shaders[2]], # aberration CMY (game stage) + [chk_scan, fx.game_shaders[3]], # scanlines (game stage) + [chk_fxg, fx.game_shaders[4]], # fxaa (world) (game stage) + [chk_sg, fx.game_shaders[5]], # smaa (world) (game stage) + [chk_vig, fx.top_shaders[0]], # vignette (top stage) + [chk_gray, fx.top_shaders[1]], # grayscale (top stage) + [chk_fxu, fx.top_shaders[2]], # fxaa (ui/all) (top stage) + [chk_su, fx.top_shaders[3]], # smaa (ui/all) (top stage) +] +# the shared FXAA quality slider sets BOTH passes' quality (0..1). RmlUi +# form-control value is read via the [] attribute accessor. +rng_qual.on(:change) { q = rng_qual["value"].to_f; fxaa_quality(fxaa_game, q); fxaa_quality(fxaa_ui, q) } +# SMAA threshold slider (0..1 -> 0.01..0.3). Lower = more edges detected = more +# AA (but more blur). Sets both SMAA passes' threshold uniform (no recompile). +rng_smaa.on(:change) do + t = 0.01 + rng_smaa["value"].to_f * 0.29 + smaa_game.extra_uniforms[:threshold] = t + smaa_ui.extra_uniforms[:threshold] = t +end +chk_pairs.each do |chk, pass| + chk.on(:change) { pass.enabled = chk.has_attribute?("checked") } +end + +# ----------------------------------------------------------- 3D world content -- +cam = Rl::Camera3D.new(Rl::Vector3.new(6, 6, 8), Rl::Vector3.new(0, 0, 0), + Rl::Vector3.new(0, 1, 0), 50.0, Rl::CAMERA_PERSPECTIVE) +CUBE = Rl.load_model_from_mesh(Rl.gen_mesh_cube(2.0, 2.0, 2.0)) +rot = 0.0 + +# distinct cube faces so FX (CRT warp / scanlines / grayscale) are easy to see +face_colors = [Rl::RED, Rl::GREEN, Rl::BLUE, Rl::GOLD, Rl::VIOLET, Rl::ORANGE] + +Rl.while_window_open do + dt = Rl.frame_time + dt = 1.0 / 60.0 if dt <= 0.0 || dt > 0.1 + rot += 0.6 * dt + + top_ui.process_input # overlay HUD is the interactive context + + # If the UI FXAA is enabled it covers the whole frame (incl the world), so the + # game FXAA would double-AA the world -> suppress it. (UI-off + game-on = the + # world-only AA mode, overlay HUD stays crisp.) + fxaa_game.suppress = fxaa_ui.enabled + + # --------------------------------------- the layered two-stage frame ------ + fx.frame(Rl.time) do |f| + # ---- GAME LAYER: 3D world + in-world RmlUi, into G_a (catches game FX) ---- + f.game_layer do + Rl.clear_background(Rl::Color.new(16, 16, 28, 255)) + Rl.mode_3d(cam) do + Rl.draw_grid(20, 2) + # a spinning cube — clearly the "game world"; CRT warps it, scanlines line it + axis = Rl::Vector3.new(0, 1, 0) + Rl.draw_model_ex(CUBE, Rl::Vector3.new(0, 1, 0), axis, rot * 57.2958, + Rl::Vector3.new(1, 1, 1), Rl::WHITE) + face_colors.each_with_index do |col, i| + ang = (i * 60 + rot * 90) * 0.0174533 + x = Math.cos(ang) * 5.0 + z = Math.sin(ang) * 5.0 + Rl.draw_cube_v(Rl::Vector3.new(x, 0.5, z), Rl::Vector3.new(0.8, 1.0, 0.8), col) + end + end + game_rot.inner_rml = "rot #{format("%.1f", rot * 57.2958 % 360)}" + game_ui.update + game_ui.render # in-world UI -> into G_a (catches game shaders) + end + + # ---- OVERLAY LAYER: overlay HUD, into C_a (composited after game shaders) ---- + f.overlay_layer do + ovr_fps.inner_rml = "FPS: #{Rl.get_fps}" + # sync checkboxes FROM the live enabled state (only touch the attribute on + # change, to avoid per-frame dirty / re-firing `change`). + chk_pairs.each do |chk, pass| + if pass.enabled != chk.has_attribute?("checked") + pass.enabled ? chk.set_attribute("checked", "") : chk.remove_attribute("checked") + end + end + top_ui.update + top_ui.render # overlay HUD -> into C_a (crisp through game FX; catches top FX) + end + end +end diff --git a/game/main.rb b/game/main.rb new file mode 100644 index 0000000..581e813 --- /dev/null +++ b/game/main.rb @@ -0,0 +1,46 @@ +# raylib-jamstack: Raylib + MRuby + RmlUi with Ruby data binding. + +Rl.init_window(720, 720, "raylib-jamstack: data binding") +Rl.target_fps = 60 + +Rml.init +Rml.load_font("game/ui/LatoLatin-Regular.ttf") +Rml.load_font("game/ui/LatoLatin-Bold.ttf") + +ui = Rml::Context.new("main") + +# --- game state --- +score = 0 +hp = 3 +x = 350 + +# --- data model: binds Ruby state to the RML view (must precede load_document) --- +model = ui.data_model("hud") do |m| + m.bind(:score) { score } + m.bind(:hp) { hp } + m.bind(:bar_width) { "#{(hp / 3.0 * 100).to_i}%" } + m.event(:reset) { score = 0 } +end + +ui.load_document("game/ui/hud.rml").show + +Rl.while_window_open do + ui.process_input + + score += 1 + hp -= 1 if Rl.key_pressed?(:h) && hp > 0 + x += 200 * Rl.frame_time if Rl.key_down?(:d) + x -= 200 * Rl.frame_time if Rl.key_down?(:a) + + # tell the view what changed this frame + model.dirty(:score, :hp, :bar_width) + + Rl.draw(clear_color: Rl::BLACK) do + Rl.draw_text(text: "game layer: A/D move, H damage, click Reset", + x: 200, y: 410, font_size: 16, color: Rl::GRAY) + Rl.draw_text(text: "@", x: x.to_i, y: 300, font_size: 40, color: Rl::RED) + + ui.update + ui.render + end +end diff --git a/game/physics_demo.rb b/game/physics_demo.rb new file mode 100644 index 0000000..5e3119d --- /dev/null +++ b/game/physics_demo.rb @@ -0,0 +1,117 @@ +# raylib-jamstack: 3D physics demo — raylib (render) + Jolt (physics). +# +# ./zig-out/bin/game game/physics_demo.rb (desktop) +# (web: preloaded; runnable as /game/physics_demo.rb) +# +# A ball pit: colourful spheres drop into a box and bounce. SPACE shoots a ball +# from the camera into the pile; R resets. The camera orbits automatically. + +Rl.init_window(960, 540, "raylib-jamstack: 3D physics (Jolt)") +Rl.target_fps = 60 + +# --- camera (auto-orbiting) --- +camera = Rl::Camera3D.new( + Rl::Vector3.new(18, 14, 18), # position + Rl::Vector3.new(0, 3, 0), # target + Rl::Vector3.new(0, 1, 0), # up + 45.0, # fovy + Rl::CAMERA_PERSPECTIVE +) + +# --- physics world --- +world = Jolt::World.new(gravity: [0, -18, 0]) + +GROUND_HALF = 10.0 +# A static floor + four low walls to keep the balls in the pit. +world.body(shape: Jolt.box(GROUND_HALF * 2, 1, GROUND_HALF * 2), + position: [0, -0.5, 0], motion: Jolt::STATIC, friction: 0.6) +[[GROUND_HALF, 0], [-GROUND_HALF, 0], [0, GROUND_HALF], [0, -GROUND_HALF]].each do |wx, wz| + sx = wz == 0 ? 1.0 : GROUND_HALF * 2 + sz = wz == 0 ? GROUND_HALF * 2 : 1.0 + world.body(shape: Jolt.box(sx, 6, sz), position: [wx, 3, wz], + motion: Jolt::STATIC, restitution: 0.3) +end + +PALETTE = [Rl::RED, Rl::ORANGE, Rl::GOLD, Rl::LIME, Rl::SKYBLUE, + Rl::BLUE, Rl::VIOLET, Rl::PINK] +MAX_BALLS = 200 + +# Each ball: [body, radius, color] +balls = [] + +def spawn_ball(world, balls, pos, radius, velocity = nil) + body = world.body(shape: Jolt.sphere(radius), position: pos, + motion: Jolt::DYNAMIC, restitution: 0.55, friction: 0.4, + velocity: velocity) + balls << [body, radius, PALETTE[rand(PALETTE.length)]] + # keep the body count bounded — retire the oldest ball + if balls.length > MAX_BALLS + old = balls.shift + old[0].remove + end +end + +def reset!(world, balls) + balls.each { |b, _| b.remove } + balls.clear + # a loose 5x5x3 stack of spheres above the pit + 5.times do |ix| + 3.times do |iy| + 5.times do |iz| + spawn_ball(world, balls, + [ix * 1.2 - 2.4, 4 + iy * 1.3, iz * 1.2 - 2.4], 0.5) + end + end + end +end + +reset!(world, balls) +world.optimize_broad_phase + +drip = 0 + +Rl.while_window_open do + # --- input --- + Rl.update_camera(camera, Rl::CAMERA_ORBITAL) + + if Rl.key_pressed?(:space) + # shoot a ball toward what the camera is looking at. The camera sits OUTSIDE + # the pit walls, so spawn the projectile past the walls (inside the pit) — + # otherwise it just bounces off the outer face of a wall. + fwd = Rl.vector3_normalize(Rl.vector3_subtract(camera.target, camera.position)) + dist = Rl.vector3_length(Rl.vector3_subtract(camera.target, camera.position)) + d = [dist - 8.0, 1.0].max + speed = 35.0 + spawn_ball(world, balls, + [camera.position.x + fwd.x * d, + camera.position.y + fwd.y * d, + camera.position.z + fwd.z * d], 0.6, + [fwd.x * speed, fwd.y * speed, fwd.z * speed]) + end + reset!(world, balls) if Rl.key_pressed?(:r) + + # gentle rain of new balls + drip += 1 + if drip >= 20 + drip = 0 + spawn_ball(world, balls, [rand * 6 - 3, 12, rand * 6 - 3], 0.4 + rand * 0.3) + end + + # --- step physics (fixed timestep for stability) --- + world.step(1.0 / 60.0) + + # --- render --- + Rl.draw(clear_color: Rl::Color.new(28, 28, 38, 255)) do + Rl.begin_mode3d(camera) + Rl.draw_grid(GROUND_HALF.to_i * 2, 1.0) + balls.each do |body, radius, color| + Rl.draw_sphere(body.position, radius, color) + end + Rl.end_mode3d + + Rl.draw_text(text: "balls: #{balls.length}", x: 12, y: 12, font_size: 22, color: Rl::RAYWHITE) + Rl.draw_text(text: "SPACE: shoot R: reset", x: 12, y: 40, font_size: 18, color: Rl::LIGHTGRAY) + # FPS counter in the top-right corner + Rl.draw_fps(Rl.screen_width - 95, 12) + end +end diff --git a/game/physics_playground.rb b/game/physics_playground.rb new file mode 100644 index 0000000..2f5aa7f --- /dev/null +++ b/game/physics_playground.rb @@ -0,0 +1,529 @@ +# raylib-jamstack: Jolt PHYSICS PLAYGROUND — a guided tour of every feature added +# since the ball-pit demo, in one playable scene. raylib (render) + Jolt (physics). +# +# ./zig-out/bin/game game/physics_playground.rb (desktop) +# (web: preloaded; runnable as /game/physics_playground.rb) +# +# Walk a third-person character around a ring of "stations", each demonstrating +# one feature: +# 1. MOVING PLATFORM — step on the glider; you RIDE it (character ground velocity) +# 2. HINGE door — push the swinging door open (hinge joint) +# 3. PENDULUM + SENSOR — a ball on a ball-joint swings through a glowing +# trigger volume that blinks + counts (ball joint, +# sensor enter/leave) +# 4. ROPE — a hanging chain of links (distance joints) +# 5. SLIDER piston — a block sliding on a rail between stops (slider joint) +# 6. TETHERBALL — a ball swinging inside a cone limit (cone joint) +# 7. WELD — two boxes fused rigid; topple them as one (weld/fixed) +# 8. CCD WALL — press F to fire a fast bullet at a paper-thin +# wall; CCD stops it tunnelling through (ccd + impulse) +# 9. RAYCAST — a laser from your crosshair paints the hit point and its +# surface NORMAL; shows the body under the crosshair +# (raycast normal, overlap_point) +# 10. RAGDOLL — press R to drop a floppy humanoid (max 25; oldest is +# recycled past the cap) (ragdoll) +# 11. TANDEM WALLS — two red walls 10 player-widths apart slide left/right in +# tandem, bulldozing ragdolls/objects caught between them +# + BALL PIT — a corral of 25 dynamic balls to wade through +# +# Controls: WASD move SPACE jump Mouse look SHIFT sprint +# R drop ragdoll F fire CCD bullet ESC quit + +# ------------------------------------------------------------------ constants -- +GRAVITY = 22.0 +MOVE_SPEED = 6.0 +SPRINT_MULT = 1.7 +JUMP_SPEED = 8.0 +PLAYER_RADIUS = 0.4 +PLAYER_HALFH = 0.5 +PLAYER_FOOT_TO_CENTRE = PLAYER_HALFH + PLAYER_RADIUS +PLAYER_WIDTH = PLAYER_RADIUS * 2.0 # 0.8 m — used to size the moving walls +MAX_RAGDOLLS = 25 + +Rl.init_window(720, 720, "raylib-jamstack: Jolt physics playground") +Rl.target_fps = 60 +Rl.disable_cursor + +# ---- RmlUi HUD (translucent 3D-angled panels, drawn over the scene) ---- +Rml.init +Rml.load_font("game/ui/LatoLatin-Regular.ttf") +Rml.load_font("game/ui/LatoLatin-Bold.ttf") +Rml.load_font("game/ui/MononokiNerdFontMono-Regular.ttf") +ui = Rml::Context.new("main") +hud = ui.load_document("game/ui/hud.rml") +hud.show +hud_left = hud.element("hud-left") +hud_right = hud.element("hud-right") +hud_controls = hud.element("hud-controls") +hud_ride = hud.element("hud-ride") +hud_sensor = hud.element("hud-sensor") +hud_ray = hud.element("hud-ray") +hud_counts = hud.element("hud-counts") +hud_fps = hud.element("hud-fps") +hud_controls.inner_rml = "WASD move SPACE jump SHIFT sprint<br/>R ragdoll F fire CCD bullet" + +hud_angle_x = 0.0 +hud_angle_y = 25.0 +hud_r = 17; hud_g = 17; hud_b = 27; hud_a = 0.8 +hud_font_size = 14 + +Jamstack::Bridge.set_binding(binding) + +world = Jolt::World.new(gravity: [0, -GRAVITY, 0]) + +# unit cube model, scaled per-draw so we can render ROTATED dynamic boxes +# (draw_cube_v ignores rotation; draw_model_ex takes an axis+angle). +CUBE = Rl.load_model_from_mesh(Rl.gen_mesh_cube(1.0, 1.0, 1.0)) + +# -------------------------------------------------------------------- helpers -- +def clampf(v, lo, hi); v < lo ? lo : (v > hi ? hi : v); end + +# quaternion -> (axis Vector3, angle degrees) for draw_model_ex +def quat_axis_angle(q) + w = clampf(q.w, -1.0, 1.0) + s = Math.sqrt(1.0 - w * w) + return [Rl::Vector3.new(0, 1, 0), 0.0] if s < 1.0e-4 + [Rl::Vector3.new(q.x / s, q.y / s, q.z / s), 2.0 * Math.acos(w) * 180.0 / Math::PI] +end + +# draw a (possibly rotated) box body +def draw_box_body(body, size, color) + ax, ang = quat_axis_angle(body.rotation) + Rl.draw_model_ex(CUBE, body.position, ax, ang, Rl::Vector3.new(*size), color) +end + +# draw a capsule body (local axis = Y) using its orientation +def draw_capsule_body(body, half_height, radius, color) + c = body.position + axis = Rl.vector3_rotate_by_quaternion(Rl::Vector3.new(0, half_height, 0), body.rotation) + a = Rl::Vector3.new(c.x - axis.x, c.y - axis.y, c.z - axis.z) + b = Rl::Vector3.new(c.x + axis.x, c.y + axis.y, c.z + axis.z) + Rl.draw_capsule(a, b, radius, 10, 6, color) +end + +static_boxes = [] # [centre, size, color] +def static_box(world, list, c, s, color, friction: 0.9) + world.body(shape: Jolt.box(s[0], s[1], s[2]), position: c, motion: Jolt::STATIC, friction: friction) + list << [c, s, color] +end + +GROUND_COL = Rl::Color.new(68, 78, 68, 255) +POST_COL = Rl::Color.new(110, 100, 84, 255) + +# ------------------------------------------------------------------- ground ---- +static_box(world, static_boxes, [0, -0.5, 0], [70, 1, 70], GROUND_COL) + +# === 1. MOVING PLATFORM (ride) ================================================ +PLAT_SIZE = [5.0, 0.3, 4.0] +PLAT_SPAN = 8.0 +PLAT_SPEED = 3.0 +platform = world.body(shape: Jolt.box(*PLAT_SIZE), position: [-PLAT_SPAN, 0.15, -12.0], + motion: Jolt::KINEMATIC, friction: 1.0) +plat_t = 0.0 + +# === 2. HINGE DOOR ============================================================ +DOOR_AT = [7.0, 0.0, 11.0] +# frame posts (static) + lintel +static_box(world, static_boxes, [DOOR_AT[0] - 1.1, 1.5, DOOR_AT[2]], [0.3, 3.0, 0.3], POST_COL) +static_box(world, static_boxes, [DOOR_AT[0] + 1.1, 1.5, DOOR_AT[2]], [0.3, 3.0, 0.3], POST_COL) +static_box(world, static_boxes, [DOOR_AT[0], 3.1, DOOR_AT[2]], [2.5, 0.3, 0.3], POST_COL) +door_hinge_x = DOOR_AT[0] - 1.0 +door = world.body(shape: Jolt.box(1.8, 2.6, 0.12), + position: [door_hinge_x + 0.9, 1.5, DOOR_AT[2]], motion: Jolt::DYNAMIC, + mass: 8.0, friction: 0.4) +world.hinge(world.body(shape: Jolt.box(0.1, 0.1, 0.1), position: [door_hinge_x, 1.5, DOOR_AT[2]], + motion: Jolt::STATIC), + door, [door_hinge_x, 1.5, DOOR_AT[2]], [0, 1, 0], min_deg: -100, max_deg: 100) + +# === 3. PENDULUM (distance-joint rod) + SENSOR ================================ +PEND_AT = [-8.0, 0, 11.0] +pend_top = 4.8 +pivot = [PEND_AT[0], pend_top, PEND_AT[2]] +anchor = world.body(shape: Jolt.box(0.3, 0.3, 0.3), position: pivot, motion: Jolt::STATIC) +# released from the side at radius ~2.0 -> swings down through the bottom +bob_start = [PEND_AT[0] + 1.45, pend_top - 1.45, PEND_AT[2]] +bob = world.body(shape: Jolt.sphere(0.45), position: bob_start, motion: Jolt::DYNAMIC, mass: 6.0) +world.distance_joint(anchor, bob, pivot, bob_start, min: 0.0, max: 2.05) # fixed-length pendulum rod +# a glowing trigger volume at the bottom of the swing (sensor enter/leave) +sensor_c = [PEND_AT[0], pend_top - 2.05, PEND_AT[2]] +sensor = world.body(shape: Jolt.box(1.3, 1.3, 1.3), position: sensor_c, + motion: Jolt::STATIC, sensor: true) +sensor_inside = 0 +sensor_total = 0 + +# === 3b. BALL JOINT (hanging sign: free swing + twist) ======================== +SIGN_AT = [2.5, 0, 12.0] +sign_top = 4.3 +sign_anchor = world.body(shape: Jolt.box(0.3, 0.3, 0.3), position: [SIGN_AT[0], sign_top, SIGN_AT[2]], + motion: Jolt::STATIC) +sign = world.body(shape: Jolt.box(1.5, 1.0, 0.12), position: [SIGN_AT[0], sign_top - 0.5, SIGN_AT[2]], + motion: Jolt::DYNAMIC, mass: 4.0) +# pivot is the sign's own TOP-CENTRE -> a real ball-and-socket (point coincident) +world.ball_joint(sign_anchor, sign, [SIGN_AT[0], sign_top, SIGN_AT[2]]) +sign.linear_velocity = [2.0, 0, 1.5] # nudge so it swings AND twists + +# === 4. ROPE (chain of distance joints) ======================================= +ROPE_AT = [-3.0, 0, 12.0] +rope_top = 4.8 +rope_links = [] # [body, radius] +rope_prev = world.body(shape: Jolt.box(0.2, 0.2, 0.2), position: [ROPE_AT[0], rope_top, ROPE_AT[2]], + motion: Jolt::STATIC) +prev_pos = [ROPE_AT[0], rope_top, ROPE_AT[2]] +LINK = 0.45 +6.times do |i| + pos = [ROPE_AT[0], rope_top - LINK * (i + 1), ROPE_AT[2]] + last = (i == 5) + link = world.body(shape: Jolt.sphere(last ? 0.32 : 0.13), position: pos, + motion: Jolt::DYNAMIC, mass: last ? 8.0 : 1.0, linear_damping: 0.2) + world.distance_joint(rope_prev, link, prev_pos, pos, min: 0.0, max: LINK) + rope_links << [link, last ? 0.32 : 0.13] + rope_prev = link + prev_pos = pos +end + +# === 5. SLIDER PISTON ========================================================= +SLIDE_AT = [11.0, 0.7, 2.0] +slide_anchor = world.body(shape: Jolt.box(0.2, 0.2, 0.2), position: SLIDE_AT, motion: Jolt::STATIC) +slider_block = world.body(shape: Jolt.box(0.9, 0.9, 0.9), position: SLIDE_AT, + motion: Jolt::DYNAMIC, mass: 4.0) +slider_block.gravity_factor = 0.0 # ride the rail level instead of sagging to a stop +world.slider(slide_anchor, slider_block, SLIDE_AT, [0, 0, 1], min: -2.5, max: 2.5) +slider_block.linear_velocity = [0, 0, 4.0] # bounces between the stops + +# === 6. CONE LIMB (swing limited to a cone) =================================== +# A limb pinned at its TOP via a cone joint: it hangs, and a nudge swings it — +# but only up to half_angle_deg from vertical, then the cone stops it. +TETHER_AT = [11.0, 0, -3.0] +tether_top = 4.3 +TETHER_HH = 0.5 +TETHER_R = 0.16 +tether_pole = world.body(shape: Jolt.box(0.25, 0.25, 0.25), + position: [TETHER_AT[0], tether_top, TETHER_AT[2]], motion: Jolt::STATIC) +# capsule centre placed so its top sits at the pivot (pin at the limb's top) +tether_ball = world.body(shape: Jolt.capsule(TETHER_HH, TETHER_R), + position: [TETHER_AT[0], tether_top - (TETHER_HH + TETHER_R), TETHER_AT[2]], + motion: Jolt::DYNAMIC, mass: 4.0) +world.cone(tether_pole, tether_ball, [TETHER_AT[0], tether_top, TETHER_AT[2]], [0, 1, 0], + half_angle_deg: 40.0) +tether_ball.linear_velocity = [3.5, 0, 2.5] # swing within the cone + +# === 7. WELD ================================================================= +WELD_AT = [-11.0, 0, 0.0] +weld_a = world.body(shape: Jolt.box(1.4, 0.6, 1.4), position: [WELD_AT[0], 0.6, WELD_AT[2]], + motion: Jolt::DYNAMIC, mass: 6.0) +weld_b = world.body(shape: Jolt.box(0.6, 2.0, 0.6), position: [WELD_AT[0] + 0.4, 1.9, WELD_AT[2]], + motion: Jolt::DYNAMIC, mass: 3.0) +world.weld(weld_a, weld_b) # the two move as one rigid L-shape + +# === 7b. BALL PIT ============================================================= +# A square corral of low static walls holding 25 dynamic balls to wade through. +PIT_HALF = 3.0 +# nudged +z (away from the static CCD wall at z=-16) by 25% of the pit's size +PIT_AT = [0.0, 0.0, -8.0 + 0.25 * (2 * PIT_HALF)] # -> z = -6.5 +PIT_WALL_H = 1.2 +PIT_WALL_T = 0.4 +PIT_COL = Rl::Color.new(60, 70, 92, 255) +pwy = PIT_WALL_H / 2.0 +span = 2 * PIT_HALF + PIT_WALL_T +static_box(world, static_boxes, [PIT_AT[0] - PIT_HALF, pwy, PIT_AT[2]], [PIT_WALL_T, PIT_WALL_H, span], PIT_COL) +static_box(world, static_boxes, [PIT_AT[0] + PIT_HALF, pwy, PIT_AT[2]], [PIT_WALL_T, PIT_WALL_H, span], PIT_COL) +static_box(world, static_boxes, [PIT_AT[0], pwy, PIT_AT[2] - PIT_HALF], [span, PIT_WALL_H, PIT_WALL_T], PIT_COL) +static_box(world, static_boxes, [PIT_AT[0], pwy, PIT_AT[2] + PIT_HALF], [span, PIT_WALL_H, PIT_WALL_T], PIT_COL) +BALL_PALETTE = [Rl::RED, Rl::ORANGE, Rl::GOLD, Rl::LIME, Rl::SKYBLUE, Rl::BLUE, Rl::VIOLET, Rl::PINK] +balls = [] +bi = 0 +[-2.0, -1.0, 0.0, 1.0, 2.0].each do |bx| + [-2.0, -1.0, 0.0, 1.0, 2.0].each do |bz| + r = 0.35 + bb = world.body(shape: Jolt.sphere(r), + position: [PIT_AT[0] + bx, 0.6 + (bi % 3) * 0.12, PIT_AT[2] + bz], + motion: Jolt::DYNAMIC, restitution: 0.4, friction: 0.4, mass: 1.0) + balls << [bb, r, BALL_PALETTE[bi % BALL_PALETTE.length]] + bi += 1 + end +end + +# === 11. TANDEM MOVING WALLS ================================================== +# Two walls facing each other across X, 10 player-widths apart, gliding left/right +# IN TANDEM (same offset, constant gap) so they bulldoze whatever — ragdolls you +# spawn, stray balls — is caught between them. Sweep spans ~30 player-widths. +WALL_GAP_HALF = (10 * PLAYER_WIDTH) / 2.0 # 4.0 -> walls 8 m (10 widths) apart +WALL_SEP = 2 * WALL_GAP_HALF # 8.0 -> one wall-separation length +WALL_CENTRE_X = -WALL_SEP # shifted one separation to the LEFT +WALL_SWEEP = (30 * PLAYER_WIDTH) / 2.0 # 12.0 -> 24 m (30 widths) peak-to-peak +WALL_Z = 6.0 # a clear lane in front of the perimeter stations +WALL_SIZE = [0.5, 3.0, 6.0] +WALL_Y = WALL_SIZE[1] / 2.0 +WALL_OMEGA = 0.6 +wall_l = world.body(shape: Jolt.box(*WALL_SIZE), position: [WALL_CENTRE_X - WALL_GAP_HALF, WALL_Y, WALL_Z], + motion: Jolt::KINEMATIC, friction: 0.6) +wall_r = world.body(shape: Jolt.box(*WALL_SIZE), position: [WALL_CENTRE_X + WALL_GAP_HALF, WALL_Y, WALL_Z], + motion: Jolt::KINEMATIC, friction: 0.6) +wall_t = 0.0 + +# === 8. CCD WALL ============================================================== +CCD_WALL_AT = [0.0, 1.6, -16.0] +static_box(world, static_boxes, CCD_WALL_AT, [6.0, 3.2, 0.05], Rl::Color.new(150, 90, 90, 255)) +bullets = [] # [body, radius, born_frame] + +# === 10. RAGDOLL (factory) ==================================================== +SKIN = Rl::Color.new(214, 170, 130, 255) +SHIRT = Rl::Color.new(70, 120, 210, 255) +PANTS = Rl::Color.new(55, 55, 75, 255) +ragdolls = [] # [ragdoll, [draw,...]] +def spawn_ragdoll(world, ragdolls, x, y, z) + th_hh, th_r = 0.22, 0.16 + parts = [ + { name: :torso, shape: Jolt.capsule(th_hh, th_r), position: [x, y, z], mass: 20.0 }, + { name: :head, shape: Jolt.sphere(0.16), position: [x, y + th_hh + 0.22, z], parent: :torso, + joint: [x, y + th_hh + 0.02, z], cone_deg: 25, twist_min_deg: -25, twist_max_deg: 25, mass: 4.0 }, + { name: :larm, shape: Jolt.capsule(0.16, 0.065), position: [x - th_r - 0.1, y + 0.06, z], + rotation: [0, 0, 0.707, 0.707], parent: :torso, joint: [x - th_r, y + th_hh - 0.02, z], + twist_axis: [1, 0, 0], plane_axis: [0, 1, 0], cone_deg: 70, mass: 3.0 }, + { name: :rarm, shape: Jolt.capsule(0.16, 0.065), position: [x + th_r + 0.1, y + 0.06, z], + rotation: [0, 0, 0.707, 0.707], parent: :torso, joint: [x + th_r, y + th_hh - 0.02, z], + twist_axis: [1, 0, 0], plane_axis: [0, 1, 0], cone_deg: 70, mass: 3.0 }, + { name: :lleg, shape: Jolt.capsule(0.2, 0.085), position: [x - 0.1, y - th_hh - 0.26, z], + parent: :torso, joint: [x - 0.1, y - th_hh, z], cone_deg: 40, mass: 5.0 }, + { name: :rleg, shape: Jolt.capsule(0.2, 0.085), position: [x + 0.1, y - th_hh - 0.26, z], + parent: :torso, joint: [x + 0.1, y - th_hh, z], cone_deg: 40, mass: 5.0 }, + ] + draw = [[:capsule, th_hh, th_r, SHIRT], [:sphere, 0.16, SKIN], + [:capsule, 0.16, 0.065, SKIN], [:capsule, 0.16, 0.065, SKIN], + [:capsule, 0.2, 0.085, PANTS], [:capsule, 0.2, 0.085, PANTS]] + ragdolls << [world.ragdoll(parts: parts), draw] +end + +# -------------------------------------------------------------------- player --- +player = world.character(shape: Jolt.capsule(PLAYER_HALFH, PLAYER_RADIUS), + position: [0, PLAYER_FOOT_TO_CENTRE + 0.2, 4.0], + max_slope_deg: 50.0, mass: 90.0) +player.max_strength = 5000.0 +world.optimize_broad_phase + +# -------------------------------------------------------------------- camera --- +cam = Rl::Camera3D.new(Rl::Vector3.new(0, 6, 12), Rl::Vector3.new(0, 1, 0), + Rl::Vector3.new(0, 1, 0), 60.0, Rl::CAMERA_PERSPECTIVE) +cam_yaw = Math::PI +cam_pitch = -0.25 +CAM_DIST = 8.0 +MOUSE_SENS = 0.0032 + +frame = 0 + +Rl.while_window_open do + frame += 1 + dt = Rl.frame_time + dt = 1.0 / 60.0 if dt <= 0.0 || dt > 0.1 + + # ---- mouse look ---- + Rl.disable_cursor if Rl.mouse_button_pressed?(Rl::MOUSE_BUTTON_LEFT) && !Rl.cursor_hidden? + if Rl.cursor_hidden? + md = Rl.get_mouse_delta + cam_yaw -= md.x * MOUSE_SENS # mouse-right looks right + cam_pitch = clampf(cam_pitch - md.y * MOUSE_SENS, -1.2, 0.5) + end + fwd_x = Math.sin(cam_yaw); fwd_z = Math.cos(cam_yaw) + right_x = -Math.cos(cam_yaw); right_z = Math.sin(cam_yaw) + + # ---- movement intent (each key gates BOTH axes; trailing-if guards last stmt only) ---- + mx = 0.0; mz = 0.0 + if Rl.key_down?(:w) then mx += fwd_x; mz += fwd_z; end + if Rl.key_down?(:s) then mx -= fwd_x; mz -= fwd_z; end + if Rl.key_down?(:a) then mx -= right_x; mz -= right_z; end + if Rl.key_down?(:d) then mx += right_x; mz += right_z; end + len = Math.sqrt(mx * mx + mz * mz) + if len > 0.0001 then mx /= len; mz /= len; end + speed = MOVE_SPEED * (Rl.key_down?(:left_shift) ? SPRINT_MULT : 1.0) + + # ---- spawn ragdoll (FIFO: drop the oldest past the cap) / fire CCD bullet ---- + if Rl.key_pressed?(:r) + if ragdolls.length >= MAX_RAGDOLLS + old_rd, = ragdolls.shift + old_rd.remove + end + spawn_ragdoll(world, ragdolls, player.position.x + fwd_x * 2, 4.0, player.position.z + fwd_z * 2) + end + if Rl.key_pressed?(:f) + eye = cam.position + look = [cam.target.x - eye.x, cam.target.y - eye.y, cam.target.z - eye.z] + ll = Math.sqrt(look[0]**2 + look[1]**2 + look[2]**2); ll = 1.0 if ll < 1e-4 + dir = [look[0] / ll, look[1] / ll, look[2] / ll] + # Spawn the bullet just PAST the player capsule along the aim, not at the + # camera (which sits behind the player). Spawning at the camera made the + # bullet's path cross the player, and the fast CCD hit shoved the character + # forward — worst when looking up, where the follow-camera dips near/below the + # ground and the bullet rises up through the capsule. `clr` clears the capsule + # in any aim direction (Y-aligned capsule support = halfH*|dir.y| + radius). + pp = player.position + clr = PLAYER_HALFH * dir[1].abs + PLAYER_RADIUS + 0.12 + 0.2 + b = world.body(shape: Jolt.sphere(0.12), + position: [pp.x + dir[0] * clr, pp.y + dir[1] * clr, pp.z + dir[2] * clr], + motion: Jolt::DYNAMIC, mass: 2.0, ccd: true, restitution: 0.2) + b.linear_velocity = [dir[0] * 90, dir[1] * 90, dir[2] * 90] # fast: needs CCD to not tunnel + bullets << [b, 0.12, frame] + end + + # ---- vertical velocity, then RIDE (inherits platform velocity) ---- + v = player.velocity + vy = player.on_ground? ? (Rl.key_pressed?(:space) ? JUMP_SPEED : 0.0) : v.y - GRAVITY * dt + player.velocity = [mx * speed, vy, mz * speed] + player.ride(dt) + + # ---- drive kinematic stations ---- + plat_t += dt + platform.linear_velocity = [Math.cos(plat_t * PLAT_SPEED / PLAT_SPAN) * PLAT_SPEED, 0, 0] + # tandem moving walls: same velocity, constant gap, so they shove what's between + wall_t += dt + wall_vx = Math.cos(wall_t * WALL_OMEGA) * WALL_SWEEP * WALL_OMEGA + wall_xo = Math.sin(wall_t * WALL_OMEGA) * WALL_SWEEP + wall_l.linear_velocity = [wall_vx, 0, 0] + wall_r.linear_velocity = [wall_vx, 0, 0] + + world.step(dt) + + platform.set_transform(position: [Math.sin(plat_t * PLAT_SPEED / PLAT_SPAN) * PLAT_SPAN, 0.15, -12.0]) + wall_l.set_transform(position: [WALL_CENTRE_X + wall_xo - WALL_GAP_HALF, WALL_Y, WALL_Z]) + wall_r.set_transform(position: [WALL_CENTRE_X + wall_xo + WALL_GAP_HALF, WALL_Y, WALL_Z]) + + # ---- sensor enter/leave bookkeeping (trigger volume) ---- + world.contacts.each do |c| + next unless c.involves?(sensor) + sensor_inside += 1; sensor_total += 1 + end + world.contacts_ended.each { |c| sensor_inside -= 1 if c.involves?(sensor) } + sensor_inside = 0 if sensor_inside < 0 + + # ---- retire old bullets ---- + bullets.reject! do |bd, _, born| + dead = frame - born > 600 + bd.remove if dead + dead + end + + # ---- third-person follow camera ---- + p = player.position + cp = Math.cos(cam_pitch) + cam.position = Rl::Vector3.new(p.x - fwd_x * CAM_DIST * cp, p.y + 2.4 - Math.sin(cam_pitch) * CAM_DIST, + p.z - fwd_z * CAM_DIST * cp) + cam.target = Rl::Vector3.new(p.x, p.y + 1.0, p.z) + + # ---- raycast from the crosshair: hit point + surface NORMAL + body under it ---- + eye = cam.position + look = [cam.target.x - eye.x, cam.target.y - eye.y, cam.target.z - eye.z] + ll = Math.sqrt(look[0]**2 + look[1]**2 + look[2]**2); ll = 1.0 if ll < 1e-4 + rdir = [look[0] / ll * 40.0, look[1] / ll * 40.0, look[2] / ll * 40.0] + hit = world.raycast([eye.x, eye.y, eye.z], rdir) + overlap_n = hit ? world.overlap_point([hit.point.x, hit.point.y, hit.point.z]).length : 0 + + # ------------------------------------------------------------------ render --- + Rl.draw(clear_color: Rl::Color.new(140, 165, 195, 255)) do + Rl.begin_mode3d(cam) + Rl.draw_grid(70, 1) + + # static geometry (axis-aligned: cube_v is fine) + static_boxes.each do |c, s, color| + Rl.draw_cube_v(Rl::Vector3.new(*c), Rl::Vector3.new(*s), color) + Rl.draw_cube_wires_v(Rl::Vector3.new(*c), Rl::Vector3.new(*s), Rl::Color.new(25, 25, 25, 110)) + end + + # 1. moving platform + draw_box_body(platform, PLAT_SIZE, Rl::Color.new(150, 110, 70, 255)) + # 11. tandem moving walls + [wall_l, wall_r].each do |wl| + Rl.draw_cube_v(wl.position, Rl::Vector3.new(*WALL_SIZE), Rl::Color.new(190, 70, 70, 255)) + Rl.draw_cube_wires_v(wl.position, Rl::Vector3.new(*WALL_SIZE), Rl::Color.new(20, 20, 20, 200)) + end + # 7b. ball pit + balls.each { |bb, br, bc| Rl.draw_sphere(bb.position, br, bc) } + # 2. hinge door + draw_box_body(door, [1.8, 2.6, 0.12], Rl::Color.new(160, 120, 90, 255)) + # 3. pendulum + sensor (sensor glows brighter while occupied) + Rl.draw_sphere(bob.position, 0.45, Rl::Color.new(220, 80, 80, 255)) + Rl.draw_line3d(Rl::Vector3.new(*pivot), bob.position, Rl::DARKGRAY) + sa = sensor_inside > 0 ? 150 : 55 + sc = sensor_inside > 0 ? Rl::Color.new(120, 255, 140, sa) : Rl::Color.new(120, 220, 255, sa) + Rl.draw_cube_v(Rl::Vector3.new(*sensor_c), Rl::Vector3.new(1.3, 1.3, 1.3), sc) + Rl.draw_cube_wires_v(Rl::Vector3.new(*sensor_c), Rl::Vector3.new(1.3, 1.3, 1.3), Rl::GREEN) + # 3b. ball-joint sign + Rl.draw_line3d(Rl::Vector3.new(SIGN_AT[0], sign_top, SIGN_AT[2]), sign.position, Rl::DARKGRAY) + draw_box_body(sign, [1.5, 1.0, 0.12], Rl::Color.new(230, 180, 60, 255)) + # 4. rope + rprev = Rl::Vector3.new(ROPE_AT[0], rope_top, ROPE_AT[2]) + rope_links.each do |lb, lr| + Rl.draw_line3d(rprev, lb.position, Rl::DARKBROWN) + Rl.draw_sphere(lb.position, lr, Rl::Color.new(180, 140, 90, 255)) + rprev = lb.position + end + # 5. slider + draw_box_body(slider_block, [0.9, 0.9, 0.9], Rl::Color.new(90, 200, 160, 255)) + Rl.draw_line3d(Rl::Vector3.new(SLIDE_AT[0], SLIDE_AT[1], SLIDE_AT[2] - 2.5), + Rl::Vector3.new(SLIDE_AT[0], SLIDE_AT[1], SLIDE_AT[2] + 2.5), Rl::DARKGRAY) + # 6. cone limb + Rl.draw_line3d(Rl::Vector3.new(TETHER_AT[0], tether_top, TETHER_AT[2]), tether_ball.position, Rl::DARKGRAY) + draw_capsule_body(tether_ball, TETHER_HH, TETHER_R, Rl::Color.new(240, 200, 70, 255)) + # 7. weld (two boxes, one rigid body) + draw_box_body(weld_a, [1.4, 0.6, 1.4], Rl::Color.new(200, 120, 160, 255)) + draw_box_body(weld_b, [0.6, 2.0, 0.6], Rl::Color.new(160, 90, 200, 255)) + # 8. bullets + bullets.each { |bd, br, _| Rl.draw_sphere(bd.position, br, Rl::Color.new(40, 40, 40, 255)) } + # 10. ragdolls + ragdolls.each do |rd, draw| + rd.bodies.each_with_index do |bp, i| + d = draw[i] + d[0] == :sphere ? Rl.draw_sphere(bp.position, d[1], d[2]) : draw_capsule_body(bp, d[1], d[2], d[3]) + end + end + + # 9. raycast hit marker + surface normal line + if hit + hp = hit.point + Rl.draw_sphere(hp, 0.12, Rl::RED) + n = hit.normal + Rl.draw_line3d(hp, Rl::Vector3.new(hp.x + n.x * 1.2, hp.y + n.y * 1.2, hp.z + n.z * 1.2), Rl::YELLOW) + end + + # player capsule + pc = player.position + pcol = player.on_ground? ? Rl::Color.new(60, 200, 240, 255) : Rl::Color.new(240, 200, 60, 255) + Rl.draw_capsule(Rl::Vector3.new(pc.x, pc.y - PLAYER_HALFH, pc.z), + Rl::Vector3.new(pc.x, pc.y + PLAYER_HALFH, pc.z), PLAYER_RADIUS, 12, 8, pcol) + Rl.end_mode3d + + # crosshair + Rl.draw_circle(Rl.screen_width / 2, Rl.screen_height / 2, 3, Rl::Color.new(255, 255, 255, 200)) + + # ------------------------------------------------------------------- HUD (RmlUi) -- + hud_left.set_property("font-size", "#{hud_font_size}px") + hud_right.set_property("font-size", "#{hud_font_size}px") + hud_left.set_property("transform", + "perspective(1500px) rotate3d(1,0,0,#{hud_angle_x}deg) rotate3d(0,1,0,#{hud_angle_y}deg)") + hud_right.set_property("transform", + "perspective(1500px) rotate3d(1,0,0,#{hud_angle_x}deg) rotate3d(0,1,0,#{-hud_angle_y}deg)") + hud_left.set_property("background-color", "rgba(#{hud_r},#{hud_g},#{hud_b},#{hud_a})") + hud_right.set_property("background-color", "rgba(#{hud_r},#{hud_g},#{hud_b},#{hud_a})") + + riding = player.ground_body && player.ground_body.id == platform.id + hud_ride.inner_rml = riding ? "RIDING PLATFORM" : "find the moving platform ->" + hud_ride.set_property("color", riding ? "#a6e3a1" : "#bac2de") + hud_sensor.inner_rml = "sensor: #{sensor_inside > 0 ? 'OCCUPIED' : 'clear'} (passes: #{sensor_total})" + hud_sensor.set_property("color", sensor_inside > 0 ? "#a6e3a1" : "#bac2de") + if hit + n = hit.normal + hud_ray.inner_rml = format("raycast: body %d normal (%.2f, %.2f, %.2f) overlap=%d", + hit.body_id, n.x, n.y, n.z, overlap_n) + hud_ray.set_property("color", "#f9e2af") + else + hud_ray.inner_rml = "" + end + hud_counts.inner_rml = "ragdolls: #{ragdolls.length}/#{MAX_RAGDOLLS} bullets: #{bullets.length}" + hud_fps.inner_rml = "FPS: #{Rl.get_fps}" + ui.update + ui.render + + unless Rl.cursor_hidden? + Rl.draw_text(text: "Click to look around", x: Rl.screen_width / 2 - 120, + y: Rl.screen_height / 2 + 30, font_size: 26, color: Rl::RAYWHITE) + end + end +end diff --git a/game/ragdoll_demo.rb b/game/ragdoll_demo.rb new file mode 100644 index 0000000..a7b5472 --- /dev/null +++ b/game/ragdoll_demo.rb @@ -0,0 +1,258 @@ +# raylib-jamstack: ragdolls + flecs + a rideable platform — raylib + Jolt + flecs. +# +# ./zig-out/bin/game game/ragdoll_demo.rb (desktop) +# JAMSTACK_BRIDGE=1 ./zig-out/bin/game game/ragdoll_demo.rb (+ agent bridge) +# (web: preloaded; runnable as /game/ragdoll_demo.rb; bridge via the relay) +# +# A third-person character on a plane; a KINEMATIC platform glides across a gap you +# can RIDE. Press R to drop a humanoid ragdoll (capsule limbs + sphere head, wired +# with swing-twist joints); F flings the pile away from you. +# +# AGENTIC HOOKS (this is also the mruby<->flecs<->Jolt demo): +# * a flecs World tracks every ragdoll as an entity; a Flecs::Hot system syncs +# each Ragdoll{x,y,z} component from its Jolt torso body each frame. +# * summon a ragdoll AT A POINT OF YOUR CHOOSING from mruby, live over the bridge: +# sh .live/<token>/bin/eval 'summon_ragdoll(2, 9, 0)' +# * talk to flecs over the bridge: +# sh .live/<token>/bin/eval '$flecs.query($rag_comp).count' # how many +# sh .live/<token>/bin/eval '$flecs.entity_for(ID).get($rag_comp)' # live pos +# sh .live/<token>/bin/eval '$flecs.lookup("ragdoll_0").get($rag_comp)' +# Globals exposed for the bridge: $player $jolt $flecs $rag_comp $ragdolls $rag_by_entity +# e.g. rain 30 ragdolls on the player: +# p=$player.position; 30.times{|i| summon_ragdoll(p.x,(p.y+4+i*0.8),p.z)} +# +# Controls: WASD move (camera-relative) SPACE jump Mouse look +# R drop ragdoll F fling ragdolls hold SHIFT sprint ESC quit + +GRAVITY = 22.0 +MOVE_SPEED = 6.0 +SPRINT_MULT = 1.7 +JUMP_SPEED = 8.0 +PLAYER_RADIUS = 0.4 +PLAYER_HALFH = 0.5 +PLAYER_FOOT_TO_CENTRE = PLAYER_HALFH + PLAYER_RADIUS + +Rl.init_window(1024, 600, "raylib-jamstack: ragdolls + moving platform (Jolt)") +Rl.target_fps = 60 +Rl.disable_cursor + +world = Jolt::World.new(gravity: [0, -GRAVITY, 0]) + +# --- two ground slabs with a gap the platform bridges --- +boxes = [] +def static_box(world, boxes, cx, cy, cz, sx, sy, sz, color, friction: 0.9) + world.body(shape: Jolt.box(sx, sy, sz), position: [cx, cy, cz], motion: Jolt::STATIC, friction: friction) + boxes << [[cx, cy, cz], [sx, sy, sz], color] +end +GROUND_COL = Rl::Color.new(70, 80, 70, 255) +GAP_HALF = 5.0 # half-width of the gap (x) the platform crosses +SLAB = 14.0 # x-depth of each side slab +static_box(world, boxes, -(GAP_HALF + SLAB / 2.0), -0.5, 0.0, SLAB, 1.0, 30.0, GROUND_COL) +static_box(world, boxes, (GAP_HALF + SLAB / 2.0), -0.5, 0.0, SLAB, 1.0, 30.0, GROUND_COL) + +# --- the moving platform: KINEMATIC, glides across the gap on x --- +PLAT_SIZE = [4.0, 0.5, 5.0] +PLAT_SPAN = GAP_HALF + 1.0 # travels +/- this on x +PLAT_SPEED = 3.0 # m/s +plat = world.body(shape: Jolt.box(*PLAT_SIZE), position: [-PLAT_SPAN, 0.0, 0.0], + motion: Jolt::KINEMATIC, friction: 1.0) +plat_t = 0.0 + +# --- flecs: track each ragdoll as an entity. A Flecs::Hot system syncs the +# Ragdoll{x,y,z} component from the Jolt torso body every frame, so the live world +# is queryable over the agent bridge (and the sync system is hot-reloadable). +$jolt = world +$flecs = Flecs::World.new +$rag_comp = $flecs.struct("Ragdoll", "{float x; float y; float z;}") +$ragdolls = [] # [[ragdoll, [draw,...]], ...] (for rendering) +$rag_by_entity = {} # flecs entity id -> Jolt::Ragdoll +$rag_seq = 0 + +Flecs::Hot.world = $flecs +Flecs::Hot.define_system("TrackRagdolls", with: ["Ragdoll"]) do |eid, r| + rd = $rag_by_entity[eid] + if rd + p = rd.bodies[0].position + r[:x] = p.x; r[:y] = p.y; r[:z] = p.z + end +end + +# --- ragdoll factory: a humanoid of capsules + a sphere head --- +SKIN = Rl::Color.new(214, 170, 130, 255) +SHIRT = Rl::Color.new(70, 120, 210, 255) +PANTS = Rl::Color.new(60, 60, 80, 255) + +# Summon a ragdoll at (x, y, z). Spawns the Jolt humanoid AND a flecs entity that +# tracks it. Top-level method, so the agent bridge can call it live: +# sh .live/<token>/bin/eval 'summon_ragdoll(2, 9, 0)' -> returns the entity id +def summon_ragdoll(x, y, z) + th_hh, th_r = 0.22, 0.16 # torso + hd_r = 0.16 # head + ua_hh, ua_r = 0.16, 0.065 # upper arm + lg_hh, lg_r = 0.20, 0.085 # leg + parts = [ + { name: :torso, shape: Jolt.capsule(th_hh, th_r), position: [x, y, z], mass: 20.0 }, + { name: :head, shape: Jolt.sphere(hd_r), position: [x, y + th_hh + 0.22, z], parent: :torso, + joint: [x, y + th_hh + 0.02, z], twist_axis: [0, 1, 0], plane_axis: [1, 0, 0], + cone_deg: 25, plane_deg: 25, twist_min_deg: -25, twist_max_deg: 25, mass: 4.0 }, + { name: :larm, shape: Jolt.capsule(ua_hh, ua_r), position: [x - th_r - ua_r - 0.04, y + 0.06, z], + rotation: [0, 0, 0.707, 0.707], parent: :torso, # rotate capsule to horizontal + joint: [x - th_r, y + th_hh - 0.02, z], twist_axis: [1, 0, 0], plane_axis: [0, 1, 0], + cone_deg: 70, plane_deg: 45, twist_min_deg: -20, twist_max_deg: 20, mass: 3.0 }, + { name: :rarm, shape: Jolt.capsule(ua_hh, ua_r), position: [x + th_r + ua_r + 0.04, y + 0.06, z], + rotation: [0, 0, 0.707, 0.707], parent: :torso, + joint: [x + th_r, y + th_hh - 0.02, z], twist_axis: [1, 0, 0], plane_axis: [0, 1, 0], + cone_deg: 70, plane_deg: 45, twist_min_deg: -20, twist_max_deg: 20, mass: 3.0 }, + { name: :lleg, shape: Jolt.capsule(lg_hh, lg_r), position: [x - 0.10, y - th_hh - lg_hh - 0.06, z], + parent: :torso, joint: [x - 0.10, y - th_hh, z], twist_axis: [0, 1, 0], plane_axis: [1, 0, 0], + cone_deg: 40, plane_deg: 25, twist_min_deg: -10, twist_max_deg: 10, mass: 5.0 }, + { name: :rleg, shape: Jolt.capsule(lg_hh, lg_r), position: [x + 0.10, y - th_hh - lg_hh - 0.06, z], + parent: :torso, joint: [x + 0.10, y - th_hh, z], twist_axis: [0, 1, 0], plane_axis: [1, 0, 0], + cone_deg: 40, plane_deg: 25, twist_min_deg: -10, twist_max_deg: 10, mass: 5.0 }, + ] + draw = [ + [:capsule, th_hh, th_r, SHIRT], [:sphere, hd_r, SKIN], + [:capsule, ua_hh, ua_r, SKIN], [:capsule, ua_hh, ua_r, SKIN], + [:capsule, lg_hh, lg_r, PANTS], [:capsule, lg_hh, lg_r, PANTS], + ] + rd = $jolt.ragdoll(parts: parts) + $ragdolls << [rd, draw] + ent = $flecs.entity("ragdoll_#{$rag_seq}") + $rag_seq += 1 + ent.set($rag_comp, x: x, y: y, z: z) + $rag_by_entity[ent.id] = rd + ent.id +end + +# --- player --- +player = world.character(shape: Jolt.capsule(PLAYER_HALFH, PLAYER_RADIUS), + position: [-(GAP_HALF + 4.0), PLAYER_FOOT_TO_CENTRE + 0.2, 0.0], + max_slope_deg: 50.0, mass: 80.0) +player.max_strength = 4000.0 +$player = player # exposed so the bridge can summon "on me" (at $player.position) +world.optimize_broad_phase + +# --- camera --- +cam = Rl::Camera3D.new(Rl::Vector3.new(0, 6, 12), Rl::Vector3.new(0, 1, 0), + Rl::Vector3.new(0, 1, 0), 55.0, Rl::CAMERA_PERSPECTIVE) +cam_yaw = 0.0 +cam_pitch = -0.3 +CAM_DIST = 7.5 +MOUSE_SENS = 0.0032 +def clampf(v, lo, hi); v < lo ? lo : (v > hi ? hi : v); end + +# draw a capsule body using its position + orientation (local axis = Y) +def draw_capsule_body(body, half_height, radius, color) + c = body.position + q = body.rotation + axis = Rl.vector3_rotate_by_quaternion(Rl::Vector3.new(0, half_height, 0), q) + a = Rl::Vector3.new(c.x - axis.x, c.y - axis.y, c.z - axis.z) + b = Rl::Vector3.new(c.x + axis.x, c.y + axis.y, c.z + axis.z) + Rl.draw_capsule(a, b, radius, 10, 6, color) +end + +Rl.while_window_open do + dt = Rl.frame_time + dt = 1.0 / 60.0 if dt <= 0.0 || dt > 0.1 + + Rl.disable_cursor if Rl.mouse_button_pressed?(Rl::MOUSE_BUTTON_LEFT) && !Rl.cursor_hidden? + if Rl.cursor_hidden? + md = Rl.get_mouse_delta + cam_yaw -= md.x * MOUSE_SENS # mouse-right looks right + cam_pitch = clampf(cam_pitch - md.y * MOUSE_SENS, -1.3, 0.4) + end + + fwd_x = Math.sin(cam_yaw); fwd_z = Math.cos(cam_yaw) + right_x = -Math.cos(cam_yaw); right_z = Math.sin(cam_yaw) + + mx = 0.0; mz = 0.0 + if Rl.key_down?(:w) then mx += fwd_x; mz += fwd_z; end + if Rl.key_down?(:s) then mx -= fwd_x; mz -= fwd_z; end + if Rl.key_down?(:a) then mx -= right_x; mz -= right_z; end + if Rl.key_down?(:d) then mx += right_x; mz += right_z; end + len = Math.sqrt(mx * mx + mz * mz) + if len > 0.0001 then mx /= len; mz /= len; end + speed = MOVE_SPEED * (Rl.key_down?(:left_shift) ? SPRINT_MULT : 1.0) + + # spawn / fling ragdolls (R drops above the origin; the bridge can summon anywhere) + summon_ragdoll(0.0, 6.0, 0.0) if Rl.key_pressed?(:r) && $ragdolls.length < 40 + if Rl.key_pressed?(:f) + pp = player.position + $ragdolls.each do |rd, _| + t = rd.bodies[0] + d = t.position + dx = d.x - pp.x; dz = d.z - pp.z + n = Math.sqrt(dx * dx + dz * dz); n = 1.0 if n < 0.001 + rd.activate + rd.bodies.each { |bp| bp.apply_impulse([dx / n * 40.0, 60.0, dz / n * 40.0]) } + end + end + + # vertical velocity (jump/gravity); ride() then adds the platform velocity + v = player.velocity + vy = player.on_ground? ? (Rl.key_pressed?(:space) ? JUMP_SPEED : 0.0) : v.y - GRAVITY * dt + player.velocity = [mx * speed, vy, mz * speed] + player.ride(dt) # <-- moving-platform support: inherit ground velocity + + # drive the kinematic platform back and forth across the gap + plat_t += dt + plat_x = Math.sin(plat_t * PLAT_SPEED / PLAT_SPAN) * PLAT_SPAN + plat_vx = Math.cos(plat_t * PLAT_SPEED / PLAT_SPAN) * PLAT_SPEED + plat.linear_velocity = [plat_vx, 0, 0] # velocity feeds character ground_velocity + + world.step(dt) + plat.set_transform(position: [plat_x, 0.0, 0.0]) # pin against kinematic drift + $flecs.progress(dt) # run TrackRagdolls: sync each Ragdoll{x,y,z} from its torso + + # third-person follow camera + p = player.position + cp = Math.cos(cam_pitch) + cam.position = Rl::Vector3.new(p.x - fwd_x * CAM_DIST * cp, p.y + 2.2 - Math.sin(cam_pitch) * CAM_DIST, + p.z - fwd_z * CAM_DIST * cp) + cam.target = Rl::Vector3.new(p.x, p.y + 1.0, p.z) + + Rl.draw(clear_color: Rl::Color.new(135, 160, 190, 255)) do + Rl.begin_mode3d(cam) + boxes.each do |centre, size, color| + Rl.draw_cube_v(Rl::Vector3.new(*centre), Rl::Vector3.new(*size), color) + Rl.draw_cube_wires_v(Rl::Vector3.new(*centre), Rl::Vector3.new(*size), Rl::Color.new(30, 30, 30, 120)) + end + # platform + pcv = plat.position + Rl.draw_cube_v(pcv, Rl::Vector3.new(*PLAT_SIZE), Rl::Color.new(150, 110, 70, 255)) + Rl.draw_cube_wires_v(pcv, Rl::Vector3.new(*PLAT_SIZE), Rl::Color.new(40, 30, 20, 200)) + + # ragdolls + $ragdolls.each do |rd, draw| + rd.bodies.each_with_index do |bp, i| + d = draw[i] + if d[0] == :sphere + Rl.draw_sphere(bp.position, d[1], d[2]) + else + draw_capsule_body(bp, d[1], d[2], d[3]) + end + end + end + + # player + pc = player.position + bottom = Rl::Vector3.new(pc.x, pc.y - PLAYER_HALFH, pc.z) + top = Rl::Vector3.new(pc.x, pc.y + PLAYER_HALFH, pc.z) + pcol = player.on_ground? ? Rl::Color.new(60, 200, 240, 255) : Rl::Color.new(240, 200, 60, 255) + Rl.draw_capsule(bottom, top, PLAYER_RADIUS, 12, 8, pcol) + Rl.end_mode3d + + Rl.draw_text(text: "WASD move SPACE jump R drop ragdoll F fling SHIFT sprint", + x: 12, y: 12, font_size: 19, color: Rl::RAYWHITE) + Rl.draw_text(text: "flecs tracks each ragdoll. Bridge: summon_ragdoll(x,y,z) / $flecs.query($rag_comp).count", + x: 12, y: 36, font_size: 15, color: Rl::Color.new(230, 230, 230, 255)) + riding = player.ground_body && player.ground_body.id == plat.id + Rl.draw_text(text: "ragdolls: #{$ragdolls.length}/40 flecs entities: #{$rag_by_entity.size} #{riding ? 'RIDING' : ''}", + x: 12, y: 60, font_size: 16, color: riding ? Rl::LIME : Rl::LIGHTGRAY) + Rl.draw_fps(Rl.screen_width - 95, 12) + unless Rl.cursor_hidden? + Rl.draw_text(text: "Click to look around", x: Rl.screen_width / 2 - 120, + y: Rl.screen_height / 2, font_size: 26, color: Rl::RAYWHITE) + end + end +end diff --git a/game/touch_demo.rb b/game/touch_demo.rb new file mode 100644 index 0000000..d376969 --- /dev/null +++ b/game/touch_demo.rb @@ -0,0 +1,61 @@ +# raylib-jamstack: touch controls demo (virtual joystick + buttons). +# +# ./zig-out/bin/game game/touch_demo.rb (desktop) +# (web: preloaded; runnable as /game/touch_demo.rb) +# +# A movable character controlled by a virtual joystick (left) and action +# buttons (right). Works with mouse on desktop, touch on mobile/browser. +# +# Controls: joystick = move, A = jump, B = sprint + +Rl.init_window(720, 720, "raylib-jamstack: touch controls") +Rl.target_fps = 60 + +tc = Jamstack::TouchControls.new(joystick_x: 100, joystick_y: 620, joystick_radius: 70) +tc.add_button(:jump, x: 620, y: 580, radius: 45, label: "A") +tc.add_button(:sprint, x: 620, y: 680, radius: 35, label: "B") + +px = 360.0 +py = 360.0 +vy = 0.0 +color = Rl::SKYBLUE +SPEED = 300 +GRAVITY = 1200 +JUMP = 500 + +Rl.while_window_open do + tc.update + + j = tc.joystick + px += j.x * SPEED * Rl.frame_time + py += j.y * SPEED * Rl.frame_time * 0.5 + + if tc.button_pressed?(:jump) && py > 600 + vy = -JUMP + end + + vy += GRAVITY * Rl.frame_time + py += vy * Rl.frame_time + + if py > 660 + py = 660 + vy = 0 + end + + px = [[px, 20].max, 700].min + + c = tc.button_down?(:sprint) ? Rl::GOLD : Rl::SKYBLUE + + Rl.draw(clear_color: Rl::Color.new(30, 30, 46, 255)) do + Rl.draw_text(text: "Joystick: move A: jump B: sprint", + x: 12, y: 12, font_size: 16, color: Rl::GRAY) + Rl.draw_text(text: "touches: #{Rl.get_touch_point_count}", + x: 12, y: 34, font_size: 14, color: Rl::DARKGRAY) + + Rl.draw_rectangle(px.to_i - 20, py.to_i - 20, 40, 40, c) + + Rl.draw_rectangle(0, 660, 720, 60, Rl::Color.new(50, 50, 60, 255)) + + tc.draw + end +end diff --git a/game/ui/LatoLatin-Bold.ttf b/game/ui/LatoLatin-Bold.ttf Binary files differnew file mode 100644 index 0000000..c598c24 --- /dev/null +++ b/game/ui/LatoLatin-Bold.ttf diff --git a/game/ui/LatoLatin-Regular.ttf b/game/ui/LatoLatin-Regular.ttf Binary files differnew file mode 100644 index 0000000..bcc5778 --- /dev/null +++ b/game/ui/LatoLatin-Regular.ttf diff --git a/game/ui/MononokiNerdFontMono-Regular.ttf b/game/ui/MononokiNerdFontMono-Regular.ttf Binary files differnew file mode 100644 index 0000000..7f025c6 --- /dev/null +++ b/game/ui/MononokiNerdFontMono-Regular.ttf diff --git a/game/ui/console.rcss b/game/ui/console.rcss new file mode 100644 index 0000000..d0c63dd --- /dev/null +++ b/game/ui/console.rcss @@ -0,0 +1,70 @@ +body { + font-family: Mononoki Nerd Font Mono; + font-size: 14px; + color: #cdd6f4; +} + +#console_body { + display: block; +} + +#console_body.hidden { + display: none; +} + +#scrollback { + display: block; + overflow: auto; + width: 100%; + max-height: 280px; + padding: 8px 20px 8px 12px; + background-color: #11111bee; + border-width: 1px 1px 0 1px; + border-color: #45475a; + border-radius: 6px 6px 0 0; +} + +#input_row { + display: block; + width: 100%; + padding: 8px 12px; + background-color: #181825ee; + border-width: 1px; + border-color: #45475a; + border-radius: 0 0 6px 6px; +} + +#prompt { + display: inline-block; + color: #a6e3a1; + margin-right: 8px; +} + +#cmd_input { + display: inline-block; + width: 92%; + color: #cdd6f4; + caret-color: #f5e0dc; +} + +.line { + display: block; + padding: 1px 0; + white-space: pre; +} + +.line.cmd { + color: #89b4fa; +} + +.line.result { + color: #a6e3a1; +} + +.line.error { + color: #f38ba8; +} + +.line.info { + color: #9399b2; +} diff --git a/game/ui/console.rml b/game/ui/console.rml new file mode 100644 index 0000000..7417980 --- /dev/null +++ b/game/ui/console.rml @@ -0,0 +1,13 @@ +<rml> +<head> + <link type="text/rcss" href="console.rcss"/> + <title>Console</title> +</head> +<body id="console_body"> + <div id="scrollback"></div> + <div id="input_row"> + <span id="prompt">></span> + <input type="text" id="cmd_input" value=""/> + </div> +</body> +</rml> diff --git a/game/ui/fx_game.rcss b/game/ui/fx_game.rcss new file mode 100644 index 0000000..3920fce --- /dev/null +++ b/game/ui/fx_game.rcss @@ -0,0 +1,31 @@ +body { + font-family: Mononoki Nerd Font Mono; + font-size: 14px; + color: #cdd6f4; +} + +/* In-world panel: tilted in 3D toward the viewer (CSS-3D), so it sits in the + game layer and catches the GAME-stage shaders (scanlines/CRT warp it). */ +#game-panel { + position: absolute; + left: 60px; + top: 80px; + width: 220px; + padding: 10px 14px; + box-sizing: border-box; + border-width: 1px; + border-color: #89b4fa; + border-radius: 10px; + background-color: rgba(30, 30, 46, 0.78); + transform: perspective(900px) rotate3d(0, 1, 0, 22deg); + transform-origin-x: 0%; + transform-origin-y: 50%; +} +.title { + color: #89b4fa; + font-size: 16px; + font-weight: bold; + margin-bottom: 4px; +} +.note { color: #a6adc8; } +.value { color: #f9e2af; margin-top: 6px; } diff --git a/game/ui/fx_game.rml b/game/ui/fx_game.rml new file mode 100644 index 0000000..f85da27 --- /dev/null +++ b/game/ui/fx_game.rml @@ -0,0 +1,13 @@ +<rml> +<head> + <link type="text/rcss" href="fx_game.rcss"/> +</head> +<body> + <div id="game-panel"> + <div class="title">GAME LAYER</div> + <div class="note">in-world UI (3D-tilted)</div> + <div class="note">catches GAME shaders</div> + <div id="game-rot" class="value">rot 0.0</div> + </div> +</body> +</rml> diff --git a/game/ui/fx_overlay.rcss b/game/ui/fx_overlay.rcss new file mode 100644 index 0000000..8fa3df5 --- /dev/null +++ b/game/ui/fx_overlay.rcss @@ -0,0 +1,83 @@ +body { + font-family: Mononoki Nerd Font Mono; + font-size: 14px; + color: #cdd6f4; +} + +/* Screen-space overlay HUD: drawn into the OVERLAY layer (composited AFTER game + shaders), so game-stage FX (scanlines/CRT) do NOT touch it — text stays crisp. + Top-stage FX (vignette/grayscale) DO affect it (composited before top shaders). + NOTE: use `left:` not `right:` — RmlUi miscomputes `right:` (places the box + off-screen at x=-300); `left:` resolves correctly. */ +#ovr-panel { + position: absolute; + left: 420px; + top: 80px; + width: 240px; + padding: 10px 14px; + box-sizing: border-box; + border-width: 1px; + border-color: #a6e3a1; + border-radius: 10px; + background-color: rgba(30, 30, 46, 0.82); +} +.title { + color: #a6e3a1; + font-size: 16px; + font-weight: bold; + margin-bottom: 2px; +} +.note { color: #a6adc8; margin-bottom: 8px; } +.stage { color: #89b4fa; font-size: 12px; margin-top: 6px; margin-bottom: 2px; } +.value { color: #f9e2af; margin-top: 8px; } + +/* Clickable effect checklist rows. */ +.chk { + display: block; + padding: 2px 0; + color: #cdd6f4; +} +input[type="checkbox"] { + width: 14px; + height: 14px; + vertical-align: middle; + margin-right: 6px; + border-width: 1px; + border-color: #585b70; + background-color: #1e1e2e; +} +input[type="checkbox"]:checked { + background-color: #a6e3a1; + border-color: #a6e3a1; +} +.lbl { vertical-align: middle; } + +/* FXAA quality range slider. RmlUi builds <input type=range> from child + elements <slidertrack> + <sliderbar> (the draggable thumb) + <sliderprogress> + + arrow buttons — NOT ::-rml-slider pseudo-elements. The thumb (sliderbar) + MUST have explicit width + a background to be visible/grabbable. */ +.slider { display: block; padding: 5px 0 2px 0; } +input.range { + width: 150px; + height: 14px; + vertical-align: middle; + margin-right: 8px; +} +input.range slidertrack { + background-color: #313244; + height: 6px; + margin-top: 4px; +} +input.range sliderprogress { + background-color: #89b4fa; + height: 6px; +} +input.range sliderbar { + width: 14px; + height: 14px; + background-color: #89b4fa; + border-radius: 3px; +} +input.range sliderbar:hover { background-color: #b4befe; } +input.range sliderbar:active { background-color: #cdd6f4; } +input.range sliderarrowdec, input.range sliderarrowinc { display: none; } diff --git a/game/ui/fx_overlay.rml b/game/ui/fx_overlay.rml new file mode 100644 index 0000000..71cfca8 --- /dev/null +++ b/game/ui/fx_overlay.rml @@ -0,0 +1,26 @@ +<rml> +<head> + <link type="text/rcss" href="fx_overlay.rcss"/> +</head> +<body> + <div id="ovr-panel"> + <div class="title">OVERLAY HUD</div> + <div class="note">click to toggle FX</div> + <div class="stage">game FX (world only)</div> + <div class="chk"><input type="checkbox" id="chk-warp" checked/><span class="lbl">warp (barrel)</span></div> + <div class="chk"><input type="checkbox" id="chk-aber" checked/><span class="lbl">colour shift (CA)</span></div> + <div class="chk"><input type="checkbox" id="chk-cmy" checked/><span class="lbl">colour shift (CMY)</span></div> + <div class="chk"><input type="checkbox" id="chk-scan" checked/><span class="lbl">scanlines</span></div> + <div class="chk"><input type="checkbox" id="chk-fxaa-game" checked/><span class="lbl">FXAA (world)</span></div> + <div class="chk"><input type="checkbox" id="chk-smaa-game"/><span class="lbl">SMAA (world)</span></div> + <div class="stage">top FX (all + hud)</div> + <div class="chk"><input type="checkbox" id="chk-vig" checked/><span class="lbl">vignette</span></div> + <div class="chk"><input type="checkbox" id="chk-gray" checked/><span class="lbl">grayscale</span></div> + <div class="chk"><input type="checkbox" id="chk-fxaa-ui" checked/><span class="lbl">FXAA (UI / all)</span></div> + <div class="chk"><input type="checkbox" id="chk-smaa-ui"/><span class="lbl">SMAA (UI / all)</span></div> + <div class="slider"><input type="range" id="rng-quality" min="0" max="1" step="0.05" value="0.6"/><span class="lbl">FXAA quality</span></div> + <div class="slider"><input type="range" id="rng-smaa" min="0" max="1" step="0.02" value="0.31"/><span class="lbl">SMAA threshold</span></div> + <div id="ovr-fps" class="value">FPS: --</div> + </div> +</body> +</rml> diff --git a/game/ui/hud.rcss b/game/ui/hud.rcss new file mode 100644 index 0000000..b5b1586 --- /dev/null +++ b/game/ui/hud.rcss @@ -0,0 +1,42 @@ +body { + font-family: Mononoki Nerd Font Mono; + font-size: 14px; + color: #cdd6f4; +} + +/* Translucent rounded HUD panels, tilted in 3D toward the screen centre + (left panel pivots on its outer/left edge, right panel on its outer/right + edge, so the inner edges angle forward toward the viewer). */ +#hud-left, #hud-right { + position: absolute; + top: 14px; + padding: 10px 16px; + box-sizing: border-box; + border-width: 1px; + border-color: #45475a; + border-radius: 12px; +} + +#hud-left { + left: 14px; + width: 480px; + transform-origin-x: 0%; + transform-origin-y: 50%; +} + +#hud-right { + left: 630px; + width: 76px; + height: 22px; + transform-origin-x: 100%; + transform-origin-y: 50%; +} + +.line { + display: block; + margin-bottom: 3px; +} + +#hud-fps { + color: #a6e3a1; +} diff --git a/game/ui/hud.rml b/game/ui/hud.rml new file mode 100644 index 0000000..8e2eada --- /dev/null +++ b/game/ui/hud.rml @@ -0,0 +1,17 @@ +<rml> +<head> + <link type="text/rcss" href="hud.rcss"/> +</head> +<body> + <div id="hud-left"> + <div id="hud-controls" class="line"></div> + <div id="hud-ride" class="line"></div> + <div id="hud-sensor" class="line"></div> + <div id="hud-ray" class="line"></div> + <div id="hud-counts" class="line"></div> + </div> + <div id="hud-right"> + <div id="hud-fps"></div> + </div> +</body> +</rml> diff --git a/mrbgems/flecs/mrbgem.rake b/mrbgems/flecs/mrbgem.rake new file mode 100644 index 0000000..8828535 --- /dev/null +++ b/mrbgems/flecs/mrbgem.rake @@ -0,0 +1,14 @@ +stack_root = ENV['JAMSTACK_ROOT'] || File.expand_path('../../..', __dir__) +flecs_inc = File.join(stack_root, 'vendor', 'flecs', 'distr') + +MRuby::Gem::Specification.new('flecs') do |spec| + spec.license = 'MIT' + spec.authors = 'raylib-jamstack' + spec.summary = 'Ruby (Flecs::) bindings for the flecs ECS' + + # The flecs amalgamation (vendor/flecs/distr/flecs.c) is compiled separately + # into a static lib by build.zig / build_web.sh and linked at the final step; + # here we only need its header on the include path. + spec.cc.include_paths << flecs_inc + spec.cxx.include_paths << flecs_inc if spec.respond_to?(:cxx) +end diff --git a/mrbgems/flecs/mrblib/flecs.rb b/mrbgems/flecs/mrblib/flecs.rb new file mode 100644 index 0000000..bc7762a --- /dev/null +++ b/mrbgems/flecs/mrblib/flecs.rb @@ -0,0 +1,126 @@ +# Idiomatic Ruby surface for flecs, layered on the low-level Flecs::World#_* +# primitives (see src/flecs_bindings.c). Components are declared at runtime via +# the meta addon and (de)serialized to/from Ruby Hashes. +# +# world = Flecs::World.new +# pos = world.struct("Position", "{float x; float y;}") +# vel = world.struct("Velocity", "{float x; float y;}") +# e = world.entity("player").set(pos, x: 0, y: 0).set(vel, x: 1, y: 2) +# +# world.system("Move", with: [pos, vel]) do |id, p, v| +# p[:x] += v[:x]; p[:y] += v[:y] # mutations to p are written back +# end +# world.progress(1.0/60) + +module Flecs + class World + # Create an entity (optionally named). Returns a Flecs::Entity. + def entity(name = nil) + Entity.new(self, _entity(name && name.to_s)) + end + + # Declare a component as a C struct from a meta descriptor string, e.g. + # world.struct("Position", "{float x; float y;}") + # Returns a Flecs::Component (usable wherever an id is expected). + def struct(name, descriptor) + Component.new(self, _struct(name.to_s, descriptor)) + end + alias component struct + + # A tag is a dataless entity used as an id (add/remove/has). + def tag(name) + Component.new(self, _entity(name.to_s)) + end + + # Look up an entity/component by name -> Flecs::Entity | nil. + def lookup(name) + id = _lookup(name.to_s) + id && Entity.new(self, id) + end + + # Wrap a raw entity id (e.g. one yielded to a system/query block) in a + # Flecs::Entity so you can call set/get/add/... on it. + def entity_for(id) + Entity.new(self, id.to_i) + end + + # Cached query over the given component/tag ids -> Flecs::Query. + def query(*components) + _query(components.flatten.map(&:to_i)) + end + + # Register a system that runs each progress() during `phase`. + # Block receives |entity_id, *component_hashes| per matched entity; + # mutations to the component hashes are written back. + def system(name, with:, phase: Flecs::ON_UPDATE, &block) + _system(name.to_s, phase.to_i, Array(with).map(&:to_i), &block) + end + + # Advance the world by dt seconds, running all systems. Returns false when + # the world wants to quit. + def progress(dt = 0.0) + _progress(dt) + end + + # Observability (R5): start the flecs REST API so the hosted Flecs Explorer + # (flecs.dev/explorer?host=localhost:<port>) can inspect the live world. + # Served during progress. Dev-only (opens a local port). + def enable_rest(port = 27750) + _enable_rest(port.to_i); self + end + + # Per-system timing + world monitor stats (shown in the Explorer). + def enable_stats + _enable_stats; self + end + + # Query the flecs REST API in-process (no socket — works on desktop AND web). + # Requires enable_rest first. Returns raw JSON string. + # world.rest_request("GET", "/world") + # world.rest_request("GET", "/query?expr=Position&values=true") + def rest_request(method, path, body = "") + _rest_request(method.to_s, path.to_s, body.to_s) + end + end + + # Lightweight wrapper around an entity id bound to its world. + class Entity + attr_reader :id, :world + def initialize(world, id); @world = world; @id = id; end + def to_i; @id; end + def to_int; @id; end + + def name; @world._name(@id); end + def name=(n); @world._set_name(@id, n.to_s); end + def delete; @world._delete(@id); end + def alive?; @world._alive?(@id); end + + # set(comp, x: 1, y: 2) or set(comp, {x: 1, y: 2}) + def set(comp, fields = nil, **kw) + @world._set(@id, comp.to_i, fields || kw); self + end + def get(comp); @world._get(@id, comp.to_i); end # -> Hash | nil + def add(comp); @world._add(@id, comp.to_i); self; end + def remove(comp); @world._remove(@id, comp.to_i); self; end + def has?(comp); @world._has?(@id, comp.to_i); end + + def ==(other); other.respond_to?(:to_i) && other.to_i == @id; end + def inspect; "#<Flecs::Entity #{@id}#{name ? " #{name.inspect}" : ''}>"; end + end + + # Wrapper around a component/tag id (also just an entity under the hood). + class Component + attr_reader :id, :world + def initialize(world, id); @world = world; @id = id; end + def to_i; @id; end + def to_int; @id; end + def name; @world._name(@id); end + def inspect; "#<Flecs::Component #{@id} #{name.inspect}>"; end + end + + class Query + include Enumerable + # each { |entity_id, *component_hashes| ... } ; mutations written back. + def each(&block); _each(&block); self; end + end +end diff --git a/mrbgems/flecs/mrblib/hot.rb b/mrbgems/flecs/mrblib/hot.rb new file mode 100644 index 0000000..b615960 --- /dev/null +++ b/mrbgems/flecs/mrblib/hot.rb @@ -0,0 +1,89 @@ +# Flecs::Hot (R3): hot-reloadable systems. Register a system ONCE with a stable +# dispatcher block that looks up the current proc by name; on reload just replace +# the proc in the registry — same system id, same matched tables, same entity and +# component data, new logic. No C change to the flecs binding (it bakes the block +# into callback_ctx; we hand it a dispatcher). See .agents/knowledge/hot-reload.md. +# +# world = Flecs::World.new +# world.struct("Position", "{float x; float y;}") +# world.struct("Velocity", "{float x; float y;}") +# Flecs::Hot.world = world +# Flecs::Hot.define_system("Move", with: ["Position", "Velocity"]) do |e, p, v| +# p[:x] += v[:x]; p[:y] += v[:y] +# end +# world.progress(1.0/60) +# # ...edit + reload (over the bridge or from a file): swaps the proc, keeps state +# Flecs::Hot.reload_string('Flecs::Hot.define_system("Move", with: ["Position","Velocity"]){|e,p,v| p[:x]+=v[:x]*5 }') +module Flecs + module Hot + @world = nil + @systems = {} # name(String) => { id:, with:, phase:, proc: } + + class << self + attr_accessor :world + + # Register (first call) or hot-swap (reload) a system. Idempotent: re-running + # a systems file just replaces procs. + def define_system(name, with:, phase: Flecs::ON_UPDATE, &blk) + raise "Flecs::Hot.world not set" unless @world + raise ArgumentError, "define_system requires a block" unless blk + name = name.to_s + terms = Array(with) + cur = @systems[name] + + # Hot path: same shape -> swap the proc only (id/tables/state preserved). + if cur && cur[:with] == terms && cur[:phase] == phase + cur[:proc] = blk + Jamstack::Log.info("system swap", tag: "hot", system: name, id: cur[:id]) + return cur[:id] + end + + # Shape changed (or first time): (re-)register. Deleting the old system + # entity does NOT touch entity/component data. + @world.entity_for(cur[:id]).delete if cur + + rec = { with: terms, phase: phase, proc: blk } + @systems[name] = rec # set before register: the dispatcher reads it live + rec[:id] = @world.system(name, with: terms.map { |t| resolve(t) }, phase: phase) do |eid, *comps| + s = @systems[name] + s[:proc].call(eid, *comps) if s && s[:proc] + end + Jamstack::Log.info(cur ? "system reregister" : "system register", + tag: "hot", system: name, id: rec[:id]) + rec[:id] + end + + # Re-eval a chunk of systems Ruby (from the bridge). Never raises. + def reload_string(code) + eval(code) + true + rescue Exception => e + Jamstack::Log.exception(e, tag: "hot") + false + end + + # Re-eval a systems file (the reloadable unit). + def reload_file(path) + reload_string(File.read(path)) + rescue Exception => e + Jamstack::Log.exception(e, tag: "hot", path: path.to_s) + false + end + + def id_for(name); (s = @systems[name.to_s]) && s[:id]; end + def systems; @systems.keys; end + def reset!; @systems = {}; end # forget registrations (does not delete systems) + + private + + # Accept component ids (Component/Integer) or string names (looked up live, so + # reload need not re-create components). + def resolve(t) + return t.to_i unless t.is_a?(String) + c = @world.lookup(t) + raise ArgumentError, "unknown component #{t.inspect}" unless c + c.to_i + end + end + end +end diff --git a/mrbgems/flecs/src/flecs_bindings.c b/mrbgems/flecs/src/flecs_bindings.c new file mode 100644 index 0000000..617dc2d --- /dev/null +++ b/mrbgems/flecs/src/flecs_bindings.c @@ -0,0 +1,433 @@ +/* mruby bindings for flecs (ECS). + * + * Design mirrors flecs-lua: components are real C structs declared at runtime + * via the meta/reflection addon (`world.struct("Position","{float x; float y;}")`), + * and values are (de)serialized between the C memory and Ruby Hashes using the + * PUBLIC meta API (ecs_meta_cursor for writes; EcsStruct/EcsPrimitive reflection + * for reads) — no dependency on flecs' semi-private serialized-ops. + * + * Entities/components/tags are plain integer ids on the C side; the Ruby layer + * (mrblib/flecs.rb) wraps them in Flecs::Entity / Flecs::Component. This file + * exposes the low-level Flecs::World#_* primitives the Ruby layer builds on. + */ +#include <mruby.h> +#include <mruby/class.h> +#include <mruby/data.h> +#include <mruby/hash.h> +#include <mruby/array.h> +#include <mruby/string.h> +#include <mruby/variable.h> +#include <string.h> +#include "flecs.h" + +/* ------------------------------------------------------------------ world -- */ +static void fl_world_free(mrb_state *mrb, void *p) { + if (p) ecs_fini((ecs_world_t *)p); +} +static const mrb_data_type fl_world_type = { "Flecs::World", fl_world_free }; + +static ecs_world_t *fl_world(mrb_state *mrb, mrb_value self) { + ecs_world_t *w = (ecs_world_t *)mrb_data_get_ptr(mrb, self, &fl_world_type); + if (!w) mrb_raise(mrb, E_RUNTIME_ERROR, "flecs world is not initialized"); + return w; +} + +static mrb_value fl_world_init(mrb_state *mrb, mrb_value self) { + ecs_world_t *w = ecs_init(); + mrb_data_init(self, w, &fl_world_type); + return self; +} + +/* --------------------------------------------------------------- entities -- */ +/* optional string name arg -> entity id (Integer) */ +static mrb_value fl_w_entity(mrb_state *mrb, mrb_value self) { + const char *name = NULL; + mrb_get_args(mrb, "|z!", &name); + ecs_entity_t e = ecs_entity_init(fl_world(mrb, self), + &(ecs_entity_desc_t){ .name = name }); + return mrb_int_value(mrb, (mrb_int)e); +} + +static mrb_value fl_w_lookup(mrb_state *mrb, mrb_value self) { + const char *name; mrb_get_args(mrb, "z", &name); + ecs_entity_t e = ecs_lookup(fl_world(mrb, self), name); + return e ? mrb_int_value(mrb, (mrb_int)e) : mrb_nil_value(); +} + +static mrb_value fl_w_name(mrb_state *mrb, mrb_value self) { + mrb_int e; mrb_get_args(mrb, "i", &e); + const char *n = ecs_get_name(fl_world(mrb, self), (ecs_entity_t)e); + return n ? mrb_str_new_cstr(mrb, n) : mrb_nil_value(); +} + +static mrb_value fl_w_set_name(mrb_state *mrb, mrb_value self) { + mrb_int e; const char *n; mrb_get_args(mrb, "iz", &e, &n); + ecs_set_name(fl_world(mrb, self), (ecs_entity_t)e, n); + return mrb_nil_value(); +} + +static mrb_value fl_w_delete(mrb_state *mrb, mrb_value self) { + mrb_int e; mrb_get_args(mrb, "i", &e); + ecs_delete(fl_world(mrb, self), (ecs_entity_t)e); + return mrb_nil_value(); +} + +static mrb_value fl_w_alive(mrb_state *mrb, mrb_value self) { + mrb_int e; mrb_get_args(mrb, "i", &e); + return mrb_bool_value(ecs_is_alive(fl_world(mrb, self), (ecs_entity_t)e)); +} + +static mrb_value fl_w_add(mrb_state *mrb, mrb_value self) { + mrb_int e, id; mrb_get_args(mrb, "ii", &e, &id); + ecs_add_id(fl_world(mrb, self), (ecs_entity_t)e, (ecs_id_t)id); + return mrb_nil_value(); +} +static mrb_value fl_w_remove(mrb_state *mrb, mrb_value self) { + mrb_int e, id; mrb_get_args(mrb, "ii", &e, &id); + ecs_remove_id(fl_world(mrb, self), (ecs_entity_t)e, (ecs_id_t)id); + return mrb_nil_value(); +} +static mrb_value fl_w_has(mrb_state *mrb, mrb_value self) { + mrb_int e, id; mrb_get_args(mrb, "ii", &e, &id); + return mrb_bool_value(ecs_has_id(fl_world(mrb, self), (ecs_entity_t)e, (ecs_id_t)id)); +} + +/* --------------------------------------------------------------- meta i/o -- */ +/* key (Symbol or String) -> C string */ +static const char *fl_key_cstr(mrb_state *mrb, mrb_value k) { + if (mrb_symbol_p(k)) return mrb_sym_name(mrb, mrb_symbol(k)); + if (mrb_string_p(k)) return mrb_string_value_cstr(mrb, &k); + mrb_raise(mrb, E_TYPE_ERROR, "component field key must be a Symbol or String"); + return NULL; +} + +/* write one Ruby value into the cursor's current member */ +static void fl_cursor_set(mrb_state *mrb, ecs_meta_cursor_t *c, mrb_value v); + +/* write a Ruby Hash into a struct scope (cursor positioned at the struct) */ +static void fl_cursor_set_hash(mrb_state *mrb, ecs_meta_cursor_t *c, mrb_value hash) { + ecs_meta_push(c); + mrb_value keys = mrb_hash_keys(mrb, hash); + mrb_int n = RARRAY_LEN(keys); + for (mrb_int i = 0; i < n; i++) { + mrb_value k = mrb_ary_ref(mrb, keys, i); + if (ecs_meta_member(c, fl_key_cstr(mrb, k)) != 0) + mrb_raisef(mrb, E_ARGUMENT_ERROR, "no such component field: %S", k); + fl_cursor_set(mrb, c, mrb_hash_get(mrb, hash, k)); + } + ecs_meta_pop(c); +} + +static void fl_cursor_set(mrb_state *mrb, ecs_meta_cursor_t *c, mrb_value v) { + switch (mrb_type(v)) { + case MRB_TT_FLOAT: ecs_meta_set_float(c, mrb_float(v)); break; + case MRB_TT_INTEGER: ecs_meta_set_int(c, mrb_integer(v)); break; + case MRB_TT_TRUE: ecs_meta_set_bool(c, true); break; + case MRB_TT_FALSE: ecs_meta_set_bool(c, false); break; + case MRB_TT_STRING: ecs_meta_set_string(c, mrb_string_value_cstr(mrb, &v)); break; + case MRB_TT_HASH: fl_cursor_set_hash(mrb, c, v); break; + default: + mrb_raise(mrb, E_TYPE_ERROR, "unsupported component field value type"); + } +} + +/* read a struct's memory into a Ruby Hash (direct offset reads, public reflection) */ +static mrb_value fl_read_struct(mrb_state *mrb, ecs_world_t *w, + ecs_entity_t type, const void *base) { + const EcsStruct *st = ecs_get(w, type, EcsStruct); + if (!st) return mrb_nil_value(); + mrb_value hash = mrb_hash_new(mrb); + ecs_member_t *m = ecs_vec_first(&st->members); + int32_t count = ecs_vec_count(&st->members); + for (int32_t i = 0; i < count; i++) { + const void *mp = (const char *)base + m[i].offset; + mrb_value key = mrb_symbol_value(mrb_intern_cstr(mrb, m[i].name)); + mrb_value val; + const EcsPrimitive *prim = ecs_get(w, m[i].type, EcsPrimitive); + if (prim) { + switch (prim->kind) { + case EcsBool: val = mrb_bool_value(*(const bool *)mp); break; + case EcsChar: val = mrb_int_value(mrb, *(const char *)mp); break; + case EcsByte: + case EcsU8: val = mrb_int_value(mrb, *(const uint8_t *)mp); break; + case EcsU16: val = mrb_int_value(mrb, *(const uint16_t *)mp); break; + case EcsU32: val = mrb_int_value(mrb, *(const uint32_t *)mp); break; + case EcsU64: val = mrb_int_value(mrb, (mrb_int)*(const uint64_t *)mp); break; + case EcsI8: val = mrb_int_value(mrb, *(const int8_t *)mp); break; + case EcsI16: val = mrb_int_value(mrb, *(const int16_t *)mp); break; + case EcsI32: val = mrb_int_value(mrb, *(const int32_t *)mp); break; + case EcsI64: val = mrb_int_value(mrb, (mrb_int)*(const int64_t *)mp); break; + case EcsF32: val = mrb_float_value(mrb, *(const float *)mp); break; + case EcsF64: val = mrb_float_value(mrb, *(const double *)mp); break; + case EcsUPtr: val = mrb_int_value(mrb, (mrb_int)*(const uintptr_t *)mp); break; + case EcsIPtr: val = mrb_int_value(mrb, (mrb_int)*(const intptr_t *)mp); break; + case EcsEntity: + case EcsId: val = mrb_int_value(mrb, (mrb_int)*(const ecs_entity_t *)mp); break; + case EcsString: { + const char *s = *(const char *const *)mp; + val = s ? mrb_str_new_cstr(mrb, s) : mrb_nil_value(); + break; + } + default: val = mrb_nil_value(); + } + } else if (ecs_get(w, m[i].type, EcsStruct)) { + val = fl_read_struct(mrb, w, m[i].type, mp); /* nested struct */ + } else { + val = mrb_int_value(mrb, *(const int32_t *)mp); /* enum/bitmask fallback */ + } + mrb_hash_set(mrb, hash, key, val); + } + return hash; +} + +/* world._set(entity, comp, hash) */ +static mrb_value fl_w_set(mrb_state *mrb, mrb_value self) { + mrb_int e, comp; mrb_value hash; + mrb_get_args(mrb, "iiH", &e, &comp, &hash); + ecs_world_t *w = fl_world(mrb, self); + const EcsComponent *ci = ecs_get(w, (ecs_entity_t)comp, EcsComponent); + if (!ci) mrb_raise(mrb, E_ARGUMENT_ERROR, "not a component (a tag has no data to set)"); + void *ptr = ecs_ensure_id(w, (ecs_entity_t)e, (ecs_id_t)comp, (size_t)ci->size); + if (!ptr) mrb_raise(mrb, E_RUNTIME_ERROR, "ecs_ensure_id failed"); + ecs_meta_cursor_t c = ecs_meta_cursor(w, (ecs_entity_t)comp, ptr); + fl_cursor_set_hash(mrb, &c, hash); + ecs_modified_id(w, (ecs_entity_t)e, (ecs_id_t)comp); + return self; +} + +/* world._get(entity, comp) -> hash | nil */ +static mrb_value fl_w_get(mrb_state *mrb, mrb_value self) { + mrb_int e, comp; mrb_get_args(mrb, "ii", &e, &comp); + ecs_world_t *w = fl_world(mrb, self); + const void *ptr = ecs_get_id(w, (ecs_entity_t)e, (ecs_id_t)comp); + if (!ptr) return mrb_nil_value(); + return fl_read_struct(mrb, w, (ecs_entity_t)comp, ptr); +} + +/* ------------------------------------------------------------ components --- */ +/* world._struct(name, descriptor) -> component id */ +static mrb_value fl_w_struct(mrb_state *mrb, mrb_value self) { + const char *name, *desc; + mrb_get_args(mrb, "zz", &name, &desc); + ecs_world_t *w = fl_world(mrb, self); + ecs_entity_t c = ecs_entity_init(w, &(ecs_entity_desc_t){ .name = name }); + if (ecs_meta_from_desc(w, c, EcsStructType, desc) != 0) + mrb_raisef(mrb, E_ARGUMENT_ERROR, "invalid struct descriptor for %S", + mrb_str_new_cstr(mrb, name)); + return mrb_int_value(mrb, (mrb_int)c); +} + +/* ----------------------------------------------------------- query/system -- */ +typedef struct { mrb_state *mrb; mrb_value blk; } fl_cb_t; + +static void fl_cb_free(void *ctx) { + fl_cb_t *cb = ctx; + if (cb) { mrb_gc_unregister(cb->mrb, cb->blk); ecs_os_free(cb); } +} + +/* shared: run a Ruby block over an iterator, yielding (entity, *comp_hashes) + * and writing any mutated hashes back into component memory each row. */ +static void fl_yield_iter(mrb_state *mrb, mrb_value blk, ecs_iter_t *it) { + ecs_world_t *w = it->world; + int8_t fields = it->field_count; + for (int32_t row = 0; row < it->count; row++) { + mrb_value argv[1 + 16]; + int argc = 0; + argv[argc++] = mrb_int_value(mrb, (mrb_int)it->entities[row]); + void *ptrs[16]; + ecs_entity_t types[16]; + int nf = fields < 16 ? fields : 16; + for (int8_t f = 0; f < nf; f++) { + ecs_entity_t type = ecs_get_typeid(w, ecs_field_id(it, f)); + size_t size = ecs_field_size(it, f); + void *col = ecs_field_w_size(it, size, f); + void *cell = col ? (char *)col + size * row : NULL; + ptrs[f] = cell; types[f] = type; + argv[argc++] = (cell && ecs_has(w, type, EcsStruct)) + ? fl_read_struct(mrb, w, type, cell) : mrb_nil_value(); + } + mrb_value r = mrb_yield_argv(mrb, blk, argc, argv); + (void)r; + /* write back any hash args (mutations persist) */ + for (int8_t f = 0; f < nf; f++) { + if (!ptrs[f]) continue; + mrb_value hv = argv[1 + f]; + if (mrb_hash_p(hv)) { + ecs_meta_cursor_t c = ecs_meta_cursor(w, types[f], ptrs[f]); + fl_cursor_set_hash(mrb, &c, hv); + } + } + } +} + +/* build an ecs_query_desc_t from a Ruby Array of component ids */ +static void fl_fill_terms(mrb_state *mrb, mrb_value ids, ecs_query_desc_t *qd) { + mrb_int n = RARRAY_LEN(ids); + if (n > FLECS_TERM_COUNT_MAX) + mrb_raise(mrb, E_ARGUMENT_ERROR, "too many query terms"); + for (mrb_int i = 0; i < n; i++) + qd->terms[i].id = (ecs_id_t)mrb_as_int(mrb, mrb_ary_ref(mrb, ids, i)); +} + +/* --- cached query --- */ +static void fl_query_free(mrb_state *mrb, void *p) { + if (p) ecs_query_fini((ecs_query_t *)p); +} +static const mrb_data_type fl_query_type = { "Flecs::Query", fl_query_free }; + +/* world._query(ids_array) -> Flecs::Query */ +static mrb_value fl_w_query(mrb_state *mrb, mrb_value self) { + mrb_value ids; mrb_get_args(mrb, "A", &ids); + ecs_world_t *w = fl_world(mrb, self); + ecs_query_desc_t qd = (ecs_query_desc_t){0}; + fl_fill_terms(mrb, ids, &qd); + ecs_query_t *q = ecs_query_init(w, &qd); + if (!q) mrb_raise(mrb, E_ARGUMENT_ERROR, "failed to create query"); + struct RClass *m = mrb_module_get(mrb, "Flecs"); + struct RClass *cls = mrb_class_get_under(mrb, m, "Query"); + mrb_value obj = mrb_obj_value(mrb_data_object_alloc(mrb, cls, q, &fl_query_type)); + mrb_iv_set(mrb, obj, mrb_intern_lit(mrb, "@world"), self); + return obj; +} + +/* query._each { |e, *comps| } */ +static mrb_value fl_q_each(mrb_state *mrb, mrb_value self) { + mrb_value blk; mrb_get_args(mrb, "&", &blk); + if (mrb_nil_p(blk)) mrb_raise(mrb, E_ARGUMENT_ERROR, "block required"); + ecs_query_t *q = (ecs_query_t *)mrb_data_get_ptr(mrb, self, &fl_query_type); + mrb_value wv = mrb_iv_get(mrb, self, mrb_intern_lit(mrb, "@world")); + ecs_world_t *w = fl_world(mrb, wv); + ecs_iter_t it = ecs_query_iter(w, q); + while (ecs_query_next(&it)) fl_yield_iter(mrb, blk, &it); + return self; +} + +/* --- system --- */ +static void fl_system_cb(ecs_iter_t *it) { + fl_cb_t *cb = it->callback_ctx; + if (cb) fl_yield_iter(cb->mrb, cb->blk, it); +} + +/* world._system(name, phase, ids_array, &blk) -> system id */ +static mrb_value fl_w_system(mrb_state *mrb, mrb_value self) { + const char *name; mrb_int phase; mrb_value ids, blk; + mrb_get_args(mrb, "ziA&", &name, &phase, &ids, &blk); + if (mrb_nil_p(blk)) mrb_raise(mrb, E_ARGUMENT_ERROR, "system requires a block"); + ecs_world_t *w = fl_world(mrb, self); + + fl_cb_t *cb = ecs_os_malloc(sizeof(fl_cb_t)); + cb->mrb = mrb; cb->blk = blk; + mrb_gc_register(mrb, blk); /* keep the block alive for the world's lifetime */ + + ecs_entity_t phase_e = (ecs_entity_t)phase; + ecs_id_t add_ids[] = { phase_e ? ecs_dependson(phase_e) : 0, phase_e, 0 }; + ecs_entity_t se = ecs_entity_init(w, &(ecs_entity_desc_t){ + .name = name, + .add = add_ids, + }); + ecs_system_desc_t sd = (ecs_system_desc_t){0}; + sd.entity = se; + fl_fill_terms(mrb, ids, &sd.query); + sd.callback = fl_system_cb; + sd.callback_ctx = cb; + sd.callback_ctx_free = fl_cb_free; + ecs_entity_t s = ecs_system_init(w, &sd); + return mrb_int_value(mrb, (mrb_int)s); +} + +/* world._progress(dt) -> bool (false when world should quit) */ +static mrb_value fl_w_progress(mrb_state *mrb, mrb_value self) { + mrb_float dt = 0; mrb_get_args(mrb, "|f", &dt); + return mrb_bool_value(ecs_progress(fl_world(mrb, self), (ecs_ftime_t)dt)); +} + +/* --- observability (R5): flecs REST API + stats, for the hosted Explorer ---- */ +/* In-process REST server handle (set by _enable_rest). Used by _rest_request so + * bin/snapshot / bin/query get JSON without a socket — works on desktop AND web + * (ecs_http_server_request processes the request handler directly). */ +static ecs_http_server_t *fl_rest_server = NULL; + +/* world._enable_rest(port=27750) -> self. Starts the REST HTTP server (served + * during progress); connect the hosted Flecs Explorer remotely. Dev-only. */ +static mrb_value fl_w_enable_rest(mrb_state *mrb, mrb_value self) { + mrb_int port = ECS_REST_DEFAULT_PORT; + mrb_get_args(mrb, "|i", &port); + ecs_world_t *w = fl_world(mrb, self); + /* In-process REST handle for bin/snapshot / bin/query (both targets). */ + fl_rest_server = ecs_rest_server_init(w, NULL); +#ifdef __EMSCRIPTEN__ + /* No listening sockets in the browser (R5a). flecs serves REST from the wasm + * image via flecs_explorer_request() over this socketless server. */ + (void)port; + extern ecs_http_server_t *flecs_wasm_rest_server; + flecs_wasm_rest_server = fl_rest_server; +#else + /* Desktop: also start the HTTP listener for the hosted Flecs Explorer. + * (This creates a second server object with a port; both share the world.) */ + FlecsRestImport(w); + ecs_set(w, EcsWorld, EcsRest, {.port = (uint16_t)port}); +#endif + return self; +} + +/* world._rest_request(method, path, body="") -> String|nil (JSON from flecs REST). + * Requires _enable_rest first. In-process (no socket) — works on both targets. */ +static mrb_value fl_w_rest_request(mrb_state *mrb, mrb_value self) { + const char *method, *path; const char *body = ""; + mrb_get_args(mrb, "zz|z", &method, &path, &body); + if (!fl_rest_server) + mrb_raise(mrb, E_RUNTIME_ERROR, + "REST server not initialized (call enable_rest first)"); + ecs_http_reply_t reply = ECS_HTTP_REPLY_INIT; + ecs_http_server_request(fl_rest_server, method, path, body, &reply); + char *json = ecs_strbuf_get(&reply.body); + mrb_value result = json ? mrb_str_new_cstr(mrb, json) : mrb_nil_value(); + ecs_os_free(json); + return result; +} + +/* world._enable_stats -> self. Per-system timing + world monitor stats. */ +static mrb_value fl_w_enable_stats(mrb_state *mrb, mrb_value self) { + ecs_world_t *w = fl_world(mrb, self); + FlecsStatsImport(w); + return self; +} + +/* ------------------------------------------------------------------ init --- */ +void mrb_flecs_gem_init(mrb_state *mrb) { + struct RClass *m = mrb_define_module(mrb, "Flecs"); + + struct RClass *world = mrb_define_class_under(mrb, m, "World", mrb->object_class); + MRB_SET_INSTANCE_TT(world, MRB_TT_DATA); + mrb_define_method(mrb, world, "initialize", fl_world_init, MRB_ARGS_NONE()); + mrb_define_method(mrb, world, "_entity", fl_w_entity, MRB_ARGS_OPT(1)); + mrb_define_method(mrb, world, "_lookup", fl_w_lookup, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_name", fl_w_name, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_set_name", fl_w_set_name, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_delete", fl_w_delete, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_alive?", fl_w_alive, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_add", fl_w_add, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_remove", fl_w_remove, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_has?", fl_w_has, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_set", fl_w_set, MRB_ARGS_REQ(3)); + mrb_define_method(mrb, world, "_get", fl_w_get, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_struct", fl_w_struct, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_query", fl_w_query, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_system", fl_w_system, MRB_ARGS_REQ(3) | MRB_ARGS_BLOCK()); + mrb_define_method(mrb, world, "_progress", fl_w_progress, MRB_ARGS_OPT(1)); + mrb_define_method(mrb, world, "_enable_rest", fl_w_enable_rest, MRB_ARGS_OPT(1)); + mrb_define_method(mrb, world, "_rest_request", fl_w_rest_request, MRB_ARGS_ARG(2, 1)); + mrb_define_method(mrb, world, "_enable_stats", fl_w_enable_stats, MRB_ARGS_NONE()); + + struct RClass *query = mrb_define_class_under(mrb, m, "Query", mrb->object_class); + MRB_SET_INSTANCE_TT(query, MRB_TT_DATA); + mrb_define_method(mrb, query, "_each", fl_q_each, MRB_ARGS_BLOCK()); + + /* pipeline phase ids (constants on Flecs) */ + mrb_define_const(mrb, m, "ON_LOAD", mrb_int_value(mrb, (mrb_int)EcsOnLoad)); + mrb_define_const(mrb, m, "PRE_UPDATE", mrb_int_value(mrb, (mrb_int)EcsPreUpdate)); + mrb_define_const(mrb, m, "ON_UPDATE", mrb_int_value(mrb, (mrb_int)EcsOnUpdate)); + mrb_define_const(mrb, m, "ON_START", mrb_int_value(mrb, (mrb_int)EcsOnStart)); +} + +void mrb_flecs_gem_final(mrb_state *mrb) { (void)mrb; } diff --git a/mrbgems/jolt/mrbgem.rake b/mrbgems/jolt/mrbgem.rake new file mode 100644 index 0000000..d21d006 --- /dev/null +++ b/mrbgems/jolt/mrbgem.rake @@ -0,0 +1,13 @@ +stack_root = ENV['JAMSTACK_ROOT'] || File.expand_path('../../..', __dir__) +joltc_inc = File.join(stack_root, 'vendor', 'joltc', 'include') + +MRuby::Gem::Specification.new('jolt') do |spec| + spec.license = 'MIT' + spec.authors = 'raylib-jamstack' + spec.summary = 'Ruby (Jolt::) bindings for Jolt Physics via the joltc C API' + + # libjoltc.a + libJolt.a are built separately (CMake) and linked at the final + # step by build.zig / build_web.sh; here we only need joltc's header. + spec.cc.include_paths << joltc_inc + spec.cxx.include_paths << joltc_inc if spec.respond_to?(:cxx) +end diff --git a/mrbgems/jolt/mrblib/jolt.rb b/mrbgems/jolt/mrblib/jolt.rb new file mode 100644 index 0000000..95852bb --- /dev/null +++ b/mrbgems/jolt/mrblib/jolt.rb @@ -0,0 +1,363 @@ +# Idiomatic Ruby surface for Jolt Physics (3D), layered on the low-level +# Jolt::World#_* primitives (see src/jolt_bindings.c). Vectors accept Arrays or +# Rl::Vector3/Vector4 and are returned as Rl::Vector3/Vector4 when raylib is +# present (else plain Arrays). +# +# world = Jolt::World.new(gravity: [0, -9.81, 0]) +# floor = world.body(shape: Jolt.box(100, 1, 100), position: [0, -0.5, 0], motion: Jolt::STATIC) +# ball = world.body(shape: Jolt.sphere(0.5), position: [0, 10, 0], velocity: [0, 0, 0]) +# loop { world.step(1.0/60); puts ball.position.y } + +module Jolt + class << self + # --- shape factories --- + def box(width, height, depth) # full dimensions (converted to half-extents) + _box(width * 0.5, height * 0.5, depth * 0.5) + end + def sphere(radius) = _sphere(radius) + def capsule(half_height, radius) = _capsule(half_height, radius) # cylinder half-height + def cylinder(half_height, radius) = _cylinder(half_height, radius) + # convex hull from points (Array of [x,y,z] or a flat float Array) + def convex_hull(points) = _convex_hull(points.first.is_a?(Array) ? points.flatten : points) + # triangle mesh for STATIC bodies (Array of [x,y,z] triples, or flat; 3 verts/tri) + def mesh(vertices) = _mesh(vertices.first.is_a?(Array) ? vertices.flatten : vertices) + + # --- vector coercion (Array | Rl::Vector3/4 -> [floats]); out -> Rl type --- + def v3(v) = v.is_a?(Array) ? [v[0].to_f, v[1].to_f, v[2].to_f] : [v.x.to_f, v.y.to_f, v.z.to_f] + def v4(v) = v.is_a?(Array) ? [v[0].to_f, v[1].to_f, v[2].to_f, v[3].to_f] : [v.x.to_f, v.y.to_f, v.z.to_f, v.w.to_f] + # Return Rl::Vector3/4 when raylib is present, else a plain Array. The check + # is memoized lazily so gem load order doesn't matter. + def rl? + @rl = (Object.const_defined?(:Rl) && Rl.const_defined?(:Vector3)) if @rl.nil? + @rl + end + def out3(a) = rl? ? Rl::Vector3.new(a[0], a[1], a[2]) : a + def out4(a) = rl? ? Rl::Vector4.new(a[0], a[1], a[2], a[3]) : a + end + + class World + def initialize(gravity: [0.0, -9.81, 0.0], max_bodies: 10240) + g = Jolt.v3(gravity) + # The world OWNS its constraints/ragdolls: keep Ruby refs so they aren't + # garbage-collected (a Constraint/Ragdoll finalizer detaches it from the + # simulation, so a dropped handle would silently break the joint). They are + # released on #remove or when the world itself is collected. + @joints = [] + @ragdolls = [] + _setup(g[0], g[1], g[2], max_bodies) + end + + def _retain_joint(c); @joints << c; c; end # internal + def _forget_joint(c); @joints.delete(c); end # internal (called by #remove) + def _forget_ragdoll(r); @ragdolls.delete(r); end # internal + + def gravity=(v); g = Jolt.v3(v); _set_gravity(g[0], g[1], g[2]); v; end + def step(dt = 1.0 / 60.0, collision_steps: 1); _step(dt, collision_steps); self; end + alias update step + def optimize_broad_phase; _optimize; self; end + + # Create + add a body. shape: a Jolt::Shape; motion: Jolt::STATIC/DYNAMIC/KINEMATIC. + def body(shape:, position: [0, 0, 0], rotation: [0, 0, 0, 1], motion: Jolt::DYNAMIC, + restitution: 0.0, friction: 0.2, activate: true, velocity: nil, user_data: nil, + linear_damping: 0.05, angular_damping: 0.05, mass: nil, ccd: false, sensor: false) + p = Jolt.v3(position); q = Jolt.v4(rotation) + id = _add_body(shape, p[0], p[1], p[2], q[0], q[1], q[2], q[3], + motion.to_i, restitution.to_f, friction.to_f, activate, + linear_damping.to_f, angular_damping.to_f, (mass || 0.0).to_f, + ccd ? true : false, sensor ? true : false) + b = Body.new(self, id) + b.user_data = user_data if user_data + b.linear_velocity = velocity if velocity + b + end + alias add_body body + + # Bodies whose shape contains `point` -> Array<Jolt::Body> (overlap query). + def overlap_point(point) + p = Jolt.v3(point) + _overlap_point(p[0], p[1], p[2]).map { |id| Body.new(self, id) } + end + + # Contacts that ENDED (stopped touching) this step -> Array<Jolt::ContactEnd>. + # Pair with sensor bodies for trigger enter (contacts) / leave (contacts_ended). + def contacts_ended + _contacts_ended.map { |a, b| ContactEnd.new(self, a, b) } + end + + # --- constraints / joints (return Jolt::Constraint; call #remove to delete) --- + # The world retains each one (see initialize) so it survives GC; #remove drops it. + # weld two bodies rigidly at their current relative transform + def weld(a, b) = _retain_joint(_fixed(a.to_i, b.to_i)) + # ball / point joint at a world-space point (free rotation, fixed point) + def ball_joint(a, b, point) + p = Jolt.v3(point); _retain_joint(_point(a.to_i, b.to_i, p[0], p[1], p[2])) + end + # keep two world-space attach points within [min, max] metres (rope/rod) + def distance_joint(a, b, point_a, point_b, min: 0.0, max: nil) + pa = Jolt.v3(point_a); pb = Jolt.v3(point_b) + d = max || Math.sqrt((pa[0]-pb[0])**2 + (pa[1]-pb[1])**2 + (pa[2]-pb[2])**2) + _retain_joint(_distance(a.to_i, b.to_i, pa[0], pa[1], pa[2], pb[0], pb[1], pb[2], min.to_f, d.to_f)) + end + # hinge (door) about `axis` through world `point`; angle limits in DEGREES + def hinge(a, b, point, axis, min_deg: -180.0, max_deg: 180.0) + p = Jolt.v3(point); ax = Jolt.v3(axis) + _retain_joint(_hinge(a.to_i, b.to_i, p[0], p[1], p[2], ax[0], ax[1], ax[2], + min_deg * Math::PI / 180.0, max_deg * Math::PI / 180.0)) + end + # slider (piston) along `axis` through world `point`; limits in METRES + def slider(a, b, point, axis, min: -1.0e10, max: 1.0e10) + p = Jolt.v3(point); ax = Jolt.v3(axis) + _retain_joint(_slider(a.to_i, b.to_i, p[0], p[1], p[2], ax[0], ax[1], ax[2], min.to_f, max.to_f)) + end + # cone / swing limit about `axis` through world `point`; half-angle in DEGREES + def cone(a, b, point, axis, half_angle_deg: 45.0) + p = Jolt.v3(point); ax = Jolt.v3(axis) + _retain_joint(_cone(a.to_i, b.to_i, p[0], p[1], p[2], ax[0], ax[1], ax[2], + half_angle_deg * Math::PI / 180.0)) + end + + # Contacts that BEGAN during the last step -> Array<Jolt::Contact>. + # (Use a body's user_data to map ids back to your game objects.) + def contacts + _contacts.map do |a, b, px, py, pz, nx, ny, nz| + Contact.new(self, a, b, Jolt.out3([px, py, pz]), Jolt.out3([nx, ny, nz])) + end + end + + # Create a kinematic character controller (player capsule with stair/slope + # handling) -> Jolt::Character. `shape` is typically a Jolt.capsule. + def character(shape:, position: [0, 0, 0], max_slope_deg: 45.0, mass: 70.0) + p = Jolt.v3(position) + _character(shape, p[0], p[1], p[2], max_slope_deg.to_f, mass.to_f) + end + + # Build a ragdoll: a tree of dynamic bodies joined by swing-twist (cone + + # twist) constraints -> Jolt::Ragdoll. `parts` is an Array of Hashes, listed + # PARENTS BEFORE CHILDREN (skeleton order). Each part: + # name: unique String/Symbol (referenced by children's :parent) + # shape: a Jolt.capsule/box/sphere + # position:, rotation: world transform of the body (rotation default identity) + # parent: name of the parent part (omit/nil for the single root) + # joint: world-space pivot connecting to the parent (default: position) + # twist_axis: bone axis (default [0,1,0]); plane_axis: perpendicular ([1,0,0]) + # cone_deg:, plane_deg: swing limits; twist_min_deg:, twist_max_deg: twist range + # mass: kg (default: derived from shape); motion: (default DYNAMIC) + def ragdoll(parts:, user_data: 0) + names = parts.map { |p| (p[:name] || p["name"]).to_s } + packed = parts.map do |p| + pname = (p[:name] || p["name"]).to_s + parent = p[:parent] ? names.index(p[:parent].to_s) : -1 + raise ArgumentError, "ragdoll part #{pname.inspect} has unknown parent #{p[:parent].inspect}" \ + if p[:parent] && parent.nil? + pos = Jolt.v3(p[:position] || [0, 0, 0]) + rot = Jolt.v4(p[:rotation] || [0, 0, 0, 1]) + joint = Jolt.v3(p[:joint] || p[:position] || [0, 0, 0]) + twist = Jolt.v3(p[:twist_axis] || [0, 1, 0]) + plane = Jolt.v3(p[:plane_axis] || [1, 0, 0]) + [pname, parent.to_i, p[:shape], + pos[0], pos[1], pos[2], rot[0], rot[1], rot[2], rot[3], + (p[:motion] || Jolt::DYNAMIC).to_i, (p[:mass] || 0.0).to_f, + joint[0], joint[1], joint[2], twist[0], twist[1], twist[2], plane[0], plane[1], plane[2], + (p[:cone_deg] || 45.0) * Math::PI / 180.0, (p[:plane_deg] || 45.0) * Math::PI / 180.0, + (p[:twist_min_deg] || -45.0) * Math::PI / 180.0, (p[:twist_max_deg] || 45.0) * Math::PI / 180.0] + end + r = _ragdoll(packed, user_data.to_i) + @ragdolls << r # retain so it isn't GC'd out of the world (see initialize) + r + end + + # Cast a ray (direction is the full ray vector). -> Jolt::RayHit | nil. + def raycast(origin, direction) + o = Jolt.v3(origin); d = Jolt.v3(direction) + r = _raycast(o[0], o[1], o[2], d[0], d[1], d[2]) + r && RayHit.new(self, r[0], r[1], Jolt.out3(r[2..4]), Jolt.out3(r[5..7])) + end + end + + # A rigid body: a body id bound to its world. + class Body + attr_reader :id, :world + def initialize(world, id); @world = world; @id = id; end + def to_i; @id; end + def to_int; @id; end + + def position; Jolt.out3(@world._position(@id)); end + def center_of_mass; Jolt.out3(@world._com_position(@id)); end + def rotation; Jolt.out4(@world._rotation(@id)); end + def position=(v); set_transform(position: v); v; end + + def set_transform(position:, rotation: nil, activate: true) + p = Jolt.v3(position) + q = rotation ? Jolt.v4(rotation) : @world._rotation(@id) + @world._set_transform(@id, p[0], p[1], p[2], q[0], q[1], q[2], q[3], activate); self + end + + def linear_velocity; Jolt.out3(@world._linear_velocity(@id)); end + def angular_velocity; Jolt.out3(@world._angular_velocity(@id)); end + def linear_velocity=(v); a = Jolt.v3(v); @world._set_linear_velocity(@id, a[0], a[1], a[2]); v; end + def angular_velocity=(v); a = Jolt.v3(v); @world._set_angular_velocity(@id, a[0], a[1], a[2]); v; end + + def apply_force(v); a = Jolt.v3(v); @world._add_force(@id, a[0], a[1], a[2]); self; end + def apply_impulse(v); a = Jolt.v3(v); @world._add_impulse(@id, a[0], a[1], a[2]); self; end + def apply_torque(v); a = Jolt.v3(v); @world._add_torque(@id, a[0], a[1], a[2]); self; end + + def active?; @world._active?(@id); end + def activate; @world._activate(@id); self; end + def deactivate; @world._deactivate(@id); self; end + def remove; @world._remove_body(@id); end + + # arbitrary 64-bit tag (e.g. a flecs entity id or object id) for collision lookup + def user_data; @world._user_data(@id); end + def user_data=(v); @world._set_user_data(@id, v.to_i); v; end + + def motion_type; @world._motion_type(@id); end + def motion_type=(mt); @world._set_motion_type(@id, mt.to_i, true); mt; end + def set_motion_type(mt, activate: true); @world._set_motion_type(@id, mt.to_i, activate); self; end + + def friction; @world._friction(@id); end + def friction=(v); @world._set_friction(@id, v.to_f); v; end + def restitution; @world._restitution(@id); end + def restitution=(v); @world._set_restitution(@id, v.to_f); v; end + def gravity_factor; @world._gravity_factor(@id); end + def gravity_factor=(v); @world._set_gravity_factor(@id, v.to_f); v; end + # sensor: detects overlaps (contacts/contacts_ended) without a physical response + def sensor=(v); @world._set_sensor(@id, v ? true : false); v; end + # continuous collision detection (linear cast) — for fast bodies vs thin walls + def ccd=(v); @world._set_ccd(@id, v ? true : false); v; end + + def ==(other); other.respond_to?(:to_i) && other.to_i == @id; end + def inspect; "#<Jolt::Body #{@id}>"; end + end + + # Result of World#raycast. + class RayHit + attr_reader :body_id, :fraction, :point, :normal + def initialize(world, body_id, fraction, point, normal = nil) + @world = world; @body_id = body_id; @fraction = fraction + @point = point; @normal = normal + end + def body; Body.new(@world, @body_id); end + end + + # A constraint/joint (World#weld/ball_joint/distance_joint/hinge/slider/cone). + # The world retains it; you don't need to hold the handle to keep the joint alive. + class Constraint + def remove # detach + destroy now (also done on GC) + _remove + w = instance_variable_get(:@world) + w._forget_joint(self) if w + self + end + end + + # A collision that ENDED this step (from World#contacts_ended). No point/normal. + class ContactEnd + attr_reader :body_a_id, :body_b_id + def initialize(world, a, b); @world = world; @body_a_id = a; @body_b_id = b; end + def body_a; Body.new(@world, @body_a_id); end + def body_b; Body.new(@world, @body_b_id); end + def involves?(x); i = x.to_i; @body_a_id == i || @body_b_id == i; end + def other(x); i = x.to_i; @body_a_id == i ? body_b : body_a; end + end + + # Kinematic character controller (Jolt CharacterVirtual). You set its velocity + # each frame (applying gravity/jump yourself) and call update(dt); it moves and + # slides along the world, stepping stairs and handling slopes. + # + # ch = world.character(shape: Jolt.capsule(0.6, 0.3), position: [0, 2, 0]) + # loop do + # v = ch.velocity + # vy = ch.on_ground? ? (jump? ? 6.0 : 0.0) : v.y - 20.0 * dt + # ch.velocity = [input_x * 5, vy, input_z * 5] + # ch.update(dt) + # world.step(dt) + # end + class Character + GROUND = { 0 => :on_ground, 1 => :on_steep, 2 => :not_supported, 3 => :in_air }.freeze + + def update(dt = 1.0 / 60.0); _update(dt); self; end + def position; Jolt.out3(_position); end + def position=(v); a = Jolt.v3(v); _set_position(a[0], a[1], a[2]); v; end + def velocity; Jolt.out3(_velocity); end + def velocity=(v); a = Jolt.v3(v); _set_velocity(a[0], a[1], a[2]); v; end + + def ground_state; GROUND[_ground_state]; end # :on_ground|:on_steep|:not_supported|:in_air + def on_ground?; _ground_state == 0; end + def supported?; _supported?; end + def ground_normal; Jolt.out3(_ground_normal); end + + # Velocity of the surface under the character (moving platform / elevator); + # zero when airborne. Add it to your movement so the character rides along. + def ground_velocity; Jolt.out3(_ground_velocity); end + # The body the character is standing on, or nil when airborne. + def ground_body + return nil unless supported? + Body.new(@world, _ground_body_id) + end + + # Move with the platform under your feet, then update. Pass your own desired + # horizontal/vertical velocity (gravity/jump applied by you); the platform's + # velocity is added on top so the character isn't left behind. + # ch.velocity = [input_x*5, vy, input_z*5] + # ch.ride(dt) + # + # Only a STATIC/KINEMATIC platform's velocity is inherited. A DYNAMIC ground + # body (a ball you stand on, a constrained pendulum) reports its REACTION to + # your own weight (and its own bouncing/swinging) as ground_velocity — + # inheriting that launches the character — so dynamic ground is ignored here + # and you just stand/collide on it normally. + def ride(dt = 1.0 / 60.0) + gb = ground_body + if gb && gb.motion_type != Jolt::DYNAMIC + v = velocity; gv = ground_velocity + self.velocity = [v.x + gv.x, v.y + gv.y, v.z + gv.z] + end + _update(dt) + self + end + + # Max force (N) the character exerts on dynamic bodies it walks into. Raise + # it above the Jolt default (100 N) to push heavier props around. + def max_strength; _max_strength; end + def max_strength=(v); _set_max_strength(v.to_f); v; end + # Effective mass when dynamic bodies collide with the character (still + # kinematic to gravity); higher = harder for props to shove the player. + def mass=(v); _set_mass(v.to_f); v; end + end + + # A ragdoll: a tree of dynamic bodies wired with swing-twist joints (from + # World#ragdoll). Each body is a normal Jolt::Body — read position/rotation to + # render, apply impulses to fling it around. + # + # rd = world.ragdoll(parts: [ + # { name: :torso, shape: Jolt.capsule(0.25, 0.18), position: [0, 4.0, 0] }, + # { name: :head, shape: Jolt.sphere(0.16), position: [0, 4.5, 0], parent: :torso, + # joint: [0, 4.32, 0], twist_axis: [0,1,0], cone_deg: 25, twist_min_deg: -20, twist_max_deg: 20 }, + # ]) + # rd.bodies.each { |b| draw_capsule(b.position, b.rotation) } + class Ragdoll + # Array<Jolt::Body>, one per part, in skeleton order (memoized). + def bodies; @bodies ||= (0...body_count).map { |i| Body.new(@world, _body_id(i)) }; end + def body_count; _body_count; end + def [](i); bodies[i]; end + def activate; _activate; self; end # wake all parts + def remove # take out of the world (also on GC) + _remove + w = instance_variable_get(:@world) + w._forget_ragdoll(self) if w + self + end + end + + # A collision that began this step (from World#contacts). + class Contact + attr_reader :body_a_id, :body_b_id, :point, :normal + def initialize(world, a, b, point, normal) + @world = world; @body_a_id = a; @body_b_id = b; @point = point; @normal = normal + end + def body_a; Body.new(@world, @body_a_id); end + def body_b; Body.new(@world, @body_b_id); end + def involves?(x); i = x.to_i; @body_a_id == i || @body_b_id == i; end + def other(x); i = x.to_i; @body_a_id == i ? body_b : body_a; end + end +end diff --git a/mrbgems/jolt/src/jolt_bindings.c b/mrbgems/jolt/src/jolt_bindings.c new file mode 100644 index 0000000..30c823f --- /dev/null +++ b/mrbgems/jolt/src/jolt_bindings.c @@ -0,0 +1,1030 @@ +/* mruby bindings for Jolt Physics (3D) via the joltc C API. + * + * Exposes low-level Jolt::World#_* primitives + Jolt._box/_sphere/... shape + * factories. The Ruby layer (mrblib/jolt.rb) wraps these in a friendly API and + * converts to/from Rl::Vector3 / arrays. + * + * Single-threaded by design: the job system is created with numThreads=0 so jobs + * run inline on the calling thread — the only mode valid on the wasm/web build, + * and identical behaviour on desktop. + * + * Layers: a fixed 2-layer setup — 0 = STATIC (non-moving), 1 = MOVING. A body's + * layer is derived from its motion type (static -> STATIC, else MOVING). + */ +#include <mruby.h> +#include <mruby/class.h> +#include <mruby/data.h> +#include <mruby/array.h> +#include <mruby/string.h> +#include <mruby/variable.h> +#include <stdbool.h> +#include <stdlib.h> +#include <math.h> +#include "joltc.h" + +enum { L_STATIC = 0, L_MOVING = 1, L_NUM = 2 }; + +/* Shared "world is alive" token. mruby's mrb_close frees every object in an + * arbitrary order, ignoring references — so a Ragdoll/Constraint finalizer can + * run AFTER its World's JPH_PhysicsSystem is already destroyed, and calling + * RemoveFromPhysicsSystem/RemoveConstraint on a freed system segfaults. The + * world and each dependent share this refcounted token; the world clears `alive` + * when destroyed, and dependents skip system access once it's dead. Last owner + * frees it. Uses libc malloc/free (independent of the mruby heap). */ +typedef struct { int alive; int refs; } jolt_token_t; +static jolt_token_t *jolt_token_new(void) { + jolt_token_t *t = (jolt_token_t *)malloc(sizeof(jolt_token_t)); + if (t) { t->alive = 1; t->refs = 1; } + return t; +} +static jolt_token_t *jolt_token_acquire(jolt_token_t *t) { if (t) t->refs++; return t; } +static void jolt_token_release(jolt_token_t *t) { if (t && --t->refs == 0) free(t); } + +/* one "contact added" event collected during a step */ +typedef struct { uint64_t a, b; float px, py, pz, nx, ny, nz; } jolt_contact_t; +/* one "contact removed" (stopped touching) event */ +typedef struct { uint64_t a, b; } jolt_pair_t; + +/* a unit vector perpendicular to `a` (for hinge/slider normal axis) */ +static JPH_Vec3 jolt_perp(JPH_Vec3 a) { + JPH_Vec3 r; + if (fabsf(a.x) <= fabsf(a.y) && fabsf(a.x) <= fabsf(a.z)) r = (JPH_Vec3){ 1, 0, 0 }; + else if (fabsf(a.y) <= fabsf(a.z)) r = (JPH_Vec3){ 0, 1, 0 }; + else r = (JPH_Vec3){ 0, 0, 1 }; + JPH_Vec3 c = { a.y*r.z - a.z*r.y, a.z*r.x - a.x*r.z, a.x*r.y - a.y*r.x }; + float L = sqrtf(c.x*c.x + c.y*c.y + c.z*c.z); + if (L > 1e-6f) { c.x/=L; c.y/=L; c.z/=L; } + return c; +} + +/* ------------------------------------------------------------------ world -- */ +typedef struct { + JPH_PhysicsSystem *sys; + JPH_BodyInterface *bi; + JPH_JobSystem *jobs; + JPH_ContactListener *listener; + jolt_contact_t *contacts; /* new contacts this step */ + int contact_count; + int contact_cap; + jolt_pair_t *ended; /* contacts removed this step */ + int ended_count; + int ended_cap; + jolt_token_t *token; /* shared liveness, see jolt_token_t */ +} jolt_world_t; + +static void jolt_world_free(mrb_state *mrb, void *p) { + jolt_world_t *w = p; + if (w) { + if (w->token) w->token->alive = 0; /* tell dependents the system is gone */ + if (w->sys) JPH_PhysicsSystem_Destroy(w->sys); + if (w->listener) JPH_ContactListener_Destroy(w->listener); + if (w->jobs) JPH_JobSystem_Destroy(w->jobs); + if (w->contacts) mrb_free(mrb, w->contacts); + if (w->ended) mrb_free(mrb, w->ended); + jolt_token_release(w->token); + mrb_free(mrb, w); + } +} + +/* stable JPH_Body* for a body id (single-threaded: lock is a no-op, the pointer + * is stable because bodies don't move in memory). Needed for constraints and + * raycast surface normals, which take JPH_Body* rather than an id. */ +static JPH_Body *jolt_body_for(jolt_world_t *w, JPH_BodyID id) { + const JPH_BodyLockInterface *li = JPH_PhysicsSystem_GetBodyLockInterfaceNoLock(w->sys); + JPH_BodyLockRead lock; + JPH_BodyLockInterface_LockRead(li, id, &lock); + JPH_Body *b = (JPH_Body *)lock.body; + JPH_BodyLockInterface_UnlockRead(li, &lock); + return b; +} + +/* contact listener: global procs (shared by all worlds); each listener carries + * its world as userData so we can route the event to the right contact buffer. + * Runs inline during Update (single-threaded), so writing the buffer is safe. */ +static void JPH_API_CALL jolt_on_contact_added( + void *ud, const JPH_Body *b1, const JPH_Body *b2, + const JPH_ContactManifold *m, JPH_ContactSettings *settings) { + (void)settings; + jolt_world_t *w = (jolt_world_t *)ud; + if (!w || w->contact_count >= w->contact_cap) return; + jolt_contact_t *c = &w->contacts[w->contact_count++]; + c->a = JPH_Body_GetID(b1); + c->b = JPH_Body_GetID(b2); + JPH_Vec3 n; JPH_ContactManifold_GetWorldSpaceNormal(m, &n); + c->nx = n.x; c->ny = n.y; c->nz = n.z; + c->px = c->py = c->pz = 0; + if (JPH_ContactManifold_GetPointCount(m) > 0) { + JPH_RVec3 p; JPH_ContactManifold_GetWorldSpaceContactPointOn1(m, 0, &p); + c->px = p.x; c->py = p.y; c->pz = p.z; + } +} +static void JPH_API_CALL jolt_on_contact_removed(void *ud, const JPH_SubShapeIDPair *pair) { + jolt_world_t *w = (jolt_world_t *)ud; + if (!w || w->ended_count >= w->ended_cap) return; + jolt_pair_t *e = &w->ended[w->ended_count++]; + e->a = pair->Body1ID; e->b = pair->Body2ID; +} +static JPH_ContactListener_Procs g_contact_procs; +static const mrb_data_type jolt_world_type = { "Jolt::World", jolt_world_free }; + +static jolt_world_t *jolt_world(mrb_state *mrb, mrb_value self) { + jolt_world_t *w = mrb_data_get_ptr(mrb, self, &jolt_world_type); + if (!w) mrb_raise(mrb, E_RUNTIME_ERROR, "Jolt world not initialized"); + return w; +} + +/* _setup(gx, gy, gz, max_bodies) — called from the Ruby keyword initializer */ +static mrb_value jolt_world_init(mrb_state *mrb, mrb_value self) { + mrb_float gx, gy, gz; mrb_int max_bodies; + mrb_get_args(mrb, "fffi", &gx, &gy, &gz, &max_bodies); + + jolt_world_t *w = mrb_malloc(mrb, sizeof(jolt_world_t)); + + JobSystemThreadPoolConfig jc = { 2048, 16, 0 }; /* numThreads=0 -> inline */ + w->jobs = JPH_JobSystemThreadPool_Create(&jc); + + JPH_ObjectLayerPairFilter *opf = JPH_ObjectLayerPairFilterTable_Create(L_NUM); + JPH_ObjectLayerPairFilterTable_EnableCollision(opf, L_STATIC, L_MOVING); + JPH_ObjectLayerPairFilterTable_EnableCollision(opf, L_MOVING, L_STATIC); + /* dynamic-vs-dynamic: without this, moving bodies pass through each other */ + JPH_ObjectLayerPairFilterTable_EnableCollision(opf, L_MOVING, L_MOVING); + + JPH_BroadPhaseLayerInterface *bpi = JPH_BroadPhaseLayerInterfaceTable_Create(L_NUM, L_NUM); + JPH_BroadPhaseLayerInterfaceTable_MapObjectToBroadPhaseLayer(bpi, L_STATIC, 0); + JPH_BroadPhaseLayerInterfaceTable_MapObjectToBroadPhaseLayer(bpi, L_MOVING, 1); + + JPH_ObjectVsBroadPhaseLayerFilter *ovb = + JPH_ObjectVsBroadPhaseLayerFilterTable_Create(bpi, L_NUM, opf, L_NUM); + + JPH_PhysicsSystemSettings s = {0}; + s.maxBodies = (uint32_t)max_bodies; + s.maxBodyPairs = (uint32_t)max_bodies; + s.maxContactConstraints = (uint32_t)max_bodies; + s.broadPhaseLayerInterface = bpi; + s.objectLayerPairFilter = opf; + s.objectVsBroadPhaseLayerFilter = ovb; + w->sys = JPH_PhysicsSystem_Create(&s); + w->bi = JPH_PhysicsSystem_GetBodyInterface(w->sys); + + JPH_Vec3 g = { (float)gx, (float)gy, (float)gz }; + JPH_PhysicsSystem_SetGravity(w->sys, &g); + + /* contact events */ + w->contact_cap = 4096; + w->contact_count = 0; + w->contacts = mrb_malloc(mrb, sizeof(jolt_contact_t) * w->contact_cap); + w->ended_cap = 4096; + w->ended_count = 0; + w->ended = mrb_malloc(mrb, sizeof(jolt_pair_t) * w->ended_cap); + w->listener = JPH_ContactListener_Create(w); + JPH_PhysicsSystem_SetContactListener(w->sys, w->listener); + w->token = jolt_token_new(); + + mrb_data_init(self, w, &jolt_world_type); + return self; +} + +static mrb_value jolt_step(mrb_state *mrb, mrb_value self) { + mrb_float dt; mrb_int steps; + mrb_get_args(mrb, "fi", &dt, &steps); + jolt_world_t *w = jolt_world(mrb, self); + w->contact_count = 0; /* contacts reflect only this step */ + w->ended_count = 0; + JPH_PhysicsSystem_Update(w->sys, (float)dt, (int)steps, w->jobs); + return self; +} + +/* -> Array of [idA, idB] for contacts that ENDED (stopped touching) this step */ +static mrb_value jolt_contacts_ended(mrb_state *mrb, mrb_value self) { + jolt_world_t *w = jolt_world(mrb, self); + mrb_value arr = mrb_ary_new_capa(mrb, w->ended_count); + for (int i = 0; i < w->ended_count; i++) { + mrb_value e = mrb_ary_new_capa(mrb, 2); + mrb_ary_push(mrb, e, mrb_int_value(mrb, (mrb_int)w->ended[i].a)); + mrb_ary_push(mrb, e, mrb_int_value(mrb, (mrb_int)w->ended[i].b)); + mrb_ary_push(mrb, arr, e); + } + return arr; +} + +/* -> Array of [idA, idB, px,py,pz, nx,ny,nz] for contacts begun this step */ +static mrb_value jolt_contacts(mrb_state *mrb, mrb_value self) { + jolt_world_t *w = jolt_world(mrb, self); + mrb_value arr = mrb_ary_new_capa(mrb, w->contact_count); + for (int i = 0; i < w->contact_count; i++) { + jolt_contact_t *c = &w->contacts[i]; + mrb_value e = mrb_ary_new_capa(mrb, 8); + mrb_ary_push(mrb, e, mrb_int_value(mrb, (mrb_int)c->a)); + mrb_ary_push(mrb, e, mrb_int_value(mrb, (mrb_int)c->b)); + mrb_ary_push(mrb, e, mrb_float_value(mrb, c->px)); + mrb_ary_push(mrb, e, mrb_float_value(mrb, c->py)); + mrb_ary_push(mrb, e, mrb_float_value(mrb, c->pz)); + mrb_ary_push(mrb, e, mrb_float_value(mrb, c->nx)); + mrb_ary_push(mrb, e, mrb_float_value(mrb, c->ny)); + mrb_ary_push(mrb, e, mrb_float_value(mrb, c->nz)); + mrb_ary_push(mrb, arr, e); + } + return arr; +} + +static mrb_value jolt_optimize(mrb_state *mrb, mrb_value self) { + JPH_PhysicsSystem_OptimizeBroadPhase(jolt_world(mrb, self)->sys); + return self; +} + +static mrb_value jolt_set_gravity(mrb_state *mrb, mrb_value self) { + mrb_float x, y, z; mrb_get_args(mrb, "fff", &x, &y, &z); + JPH_Vec3 g = { (float)x, (float)y, (float)z }; + JPH_PhysicsSystem_SetGravity(jolt_world(mrb, self)->sys, &g); + return self; +} + +static mrb_value vec3_ary(mrb_state *mrb, float x, float y, float z) { + mrb_value a = mrb_ary_new_capa(mrb, 3); + mrb_ary_push(mrb, a, mrb_float_value(mrb, x)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, y)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, z)); + return a; +} + +/* ----------------------------------------------------------------- shapes -- */ +/* Shapes are reference-counted in Jolt; the body holds a ref. We don't release + * on GC (shapes are typically long-lived/shared) — a small, bounded leak. */ +static const mrb_data_type jolt_shape_type = { "Jolt::Shape", NULL }; + +static mrb_value wrap_shape(mrb_state *mrb, JPH_Shape *sh) { + struct RClass *m = mrb_module_get(mrb, "Jolt"); + struct RClass *c = mrb_class_get_under(mrb, m, "Shape"); + return mrb_obj_value(mrb_data_object_alloc(mrb, c, sh, &jolt_shape_type)); +} +static JPH_Shape *shape_ptr(mrb_state *mrb, mrb_value v) { + return mrb_data_get_ptr(mrb, v, &jolt_shape_type); +} + +static mrb_value jolt_box(mrb_state *mrb, mrb_value self) { + mrb_float hx, hy, hz; mrb_get_args(mrb, "fff", &hx, &hy, &hz); + JPH_Vec3 he = { (float)hx, (float)hy, (float)hz }; + return wrap_shape(mrb, (JPH_Shape*)JPH_BoxShape_Create(&he, JPH_DEFAULT_CONVEX_RADIUS)); +} +static mrb_value jolt_sphere(mrb_state *mrb, mrb_value self) { + mrb_float r; mrb_get_args(mrb, "f", &r); + return wrap_shape(mrb, (JPH_Shape*)JPH_SphereShape_Create((float)r)); +} +static mrb_value jolt_capsule(mrb_state *mrb, mrb_value self) { + mrb_float hh, r; mrb_get_args(mrb, "ff", &hh, &r); + return wrap_shape(mrb, (JPH_Shape*)JPH_CapsuleShape_Create((float)hh, (float)r)); +} +static mrb_value jolt_cylinder(mrb_state *mrb, mrb_value self) { + mrb_float hh, r; mrb_get_args(mrb, "ff", &hh, &r); + return wrap_shape(mrb, (JPH_Shape*)JPH_CylinderShape_Create((float)hh, (float)r)); +} +/* convex hull from a flat array of points [x,y,z, x,y,z, ...] */ +static mrb_value jolt_convex_hull(mrb_state *mrb, mrb_value self) { + mrb_value pts; mrb_get_args(mrb, "A", &pts); + mrb_int n = RARRAY_LEN(pts) / 3; + if (n < 3) mrb_raise(mrb, E_ARGUMENT_ERROR, "convex hull needs >= 3 points"); + JPH_Vec3 *v = mrb_malloc(mrb, sizeof(JPH_Vec3) * n); + for (mrb_int i = 0; i < n; i++) { + v[i].x = (float)mrb_as_float(mrb, mrb_ary_ref(mrb, pts, i*3)); + v[i].y = (float)mrb_as_float(mrb, mrb_ary_ref(mrb, pts, i*3+1)); + v[i].z = (float)mrb_as_float(mrb, mrb_ary_ref(mrb, pts, i*3+2)); + } + JPH_ConvexHullShapeSettings *st = + JPH_ConvexHullShapeSettings_Create(v, (uint32_t)n, JPH_DEFAULT_CONVEX_RADIUS); + JPH_Shape *sh = (JPH_Shape*)JPH_ConvexHullShapeSettings_CreateShape(st); + mrb_free(mrb, v); + return wrap_shape(mrb, sh); +} +/* triangle mesh (STATIC bodies only) from a flat array; 9 floats = 1 triangle */ +static mrb_value jolt_mesh(mrb_state *mrb, mrb_value self) { + mrb_value verts; mrb_get_args(mrb, "A", &verts); + mrb_int ntri = RARRAY_LEN(verts) / 9; + if (ntri < 1) mrb_raise(mrb, E_ARGUMENT_ERROR, "mesh needs >= 9 floats (one triangle)"); + JPH_Triangle *tris = mrb_malloc(mrb, sizeof(JPH_Triangle) * ntri); + for (mrb_int t = 0; t < ntri; t++) { + float f[9]; + for (int k = 0; k < 9; k++) f[k] = (float)mrb_as_float(mrb, mrb_ary_ref(mrb, verts, t*9+k)); + tris[t].v1.x=f[0]; tris[t].v1.y=f[1]; tris[t].v1.z=f[2]; + tris[t].v2.x=f[3]; tris[t].v2.y=f[4]; tris[t].v2.z=f[5]; + tris[t].v3.x=f[6]; tris[t].v3.y=f[7]; tris[t].v3.z=f[8]; + tris[t].materialIndex = 0; + } + JPH_MeshShapeSettings *st = JPH_MeshShapeSettings_Create(tris, (uint32_t)ntri); + JPH_Shape *sh = (JPH_Shape*)JPH_MeshShapeSettings_CreateShape(st); + mrb_free(mrb, tris); + return wrap_shape(mrb, sh); +} + +/* ------------------------------------------------------------------ bodies - */ +/* _add_body(shape, px,py,pz, qx,qy,qz,qw, motion, restitution, friction, activate) */ +static mrb_value jolt_add_body(mrb_state *mrb, mrb_value self) { + mrb_value shape; mrb_float px,py,pz, qx,qy,qz,qw, rest, fric, lin_damp, ang_damp, mass; + mrb_int motion; mrb_bool activate, ccd, sensor; + mrb_get_args(mrb, "offfffffiffbfffbb", &shape, &px,&py,&pz, &qx,&qy,&qz,&qw, + &motion, &rest, &fric, &activate, &lin_damp, &ang_damp, &mass, &ccd, &sensor); + jolt_world_t *w = jolt_world(mrb, self); + + JPH_ObjectLayer layer = (motion == JPH_MotionType_Static) ? L_STATIC : L_MOVING; + JPH_RVec3 pos = { (float)px, (float)py, (float)pz }; + JPH_Quat rot = { (float)qx, (float)qy, (float)qz, (float)qw }; + JPH_BodyCreationSettings *bcs = JPH_BodyCreationSettings_Create3( + shape_ptr(mrb, shape), &pos, &rot, (JPH_MotionType)motion, layer); + JPH_BodyCreationSettings_SetRestitution(bcs, (float)rest); + JPH_BodyCreationSettings_SetFriction(bcs, (float)fric); + JPH_BodyCreationSettings_SetLinearDamping(bcs, (float)lin_damp); + JPH_BodyCreationSettings_SetAngularDamping(bcs, (float)ang_damp); + JPH_BodyCreationSettings_SetIsSensor(bcs, sensor); + JPH_BodyCreationSettings_SetMotionQuality(bcs, + ccd ? JPH_MotionQuality_LinearCast : JPH_MotionQuality_Discrete); + if (mass > 0) { /* override the density-derived mass, keep shape-derived inertia */ + JPH_BodyCreationSettings_SetOverrideMassProperties(bcs, JPH_OverrideMassProperties_CalculateInertia); + JPH_MassProperties mp; JPH_BodyCreationSettings_GetMassPropertiesOverride(bcs, &mp); + mp.mass = (float)mass; + JPH_BodyCreationSettings_SetMassPropertiesOverride(bcs, &mp); + } + JPH_BodyID id = JPH_BodyInterface_CreateAndAddBody(w->bi, bcs, + activate ? JPH_Activation_Activate : JPH_Activation_DontActivate); + JPH_BodyCreationSettings_Destroy(bcs); + return mrb_int_value(mrb, (mrb_int)id); +} + +static mrb_value jolt_remove_body(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + JPH_BodyInterface_RemoveAndDestroyBody(jolt_world(mrb, self)->bi, (JPH_BodyID)id); + return self; +} + +static mrb_value jolt_position(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + JPH_RVec3 p; JPH_BodyInterface_GetPosition(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &p); + return vec3_ary(mrb, p.x, p.y, p.z); +} +static mrb_value jolt_com_position(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + JPH_RVec3 p; + JPH_BodyInterface_GetCenterOfMassPosition(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &p); + return vec3_ary(mrb, p.x, p.y, p.z); +} +static mrb_value jolt_rotation(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + JPH_Quat q; JPH_BodyInterface_GetRotation(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &q); + mrb_value a = mrb_ary_new_capa(mrb, 4); + mrb_ary_push(mrb, a, mrb_float_value(mrb, q.x)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, q.y)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, q.z)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, q.w)); + return a; +} + +/* _set_transform(id, px,py,pz, qx,qy,qz,qw, activate) */ +static mrb_value jolt_set_transform(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_float px,py,pz, qx,qy,qz,qw; mrb_bool act; + mrb_get_args(mrb, "ifffffffb", &id, &px,&py,&pz, &qx,&qy,&qz,&qw, &act); + JPH_RVec3 p = { (float)px,(float)py,(float)pz }; + JPH_Quat q = { (float)qx,(float)qy,(float)qz,(float)qw }; + JPH_BodyInterface_SetPositionAndRotation(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &p, &q, + act ? JPH_Activation_Activate : JPH_Activation_DontActivate); + return self; +} + +static mrb_value jolt_linear_velocity(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + JPH_Vec3 v; JPH_BodyInterface_GetLinearVelocity(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &v); + return vec3_ary(mrb, v.x, v.y, v.z); +} +static mrb_value jolt_set_linear_velocity(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_float x,y,z; mrb_get_args(mrb, "ifff", &id, &x,&y,&z); + JPH_Vec3 v = { (float)x,(float)y,(float)z }; + JPH_BodyInterface_SetLinearVelocity(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &v); + return self; +} +static mrb_value jolt_angular_velocity(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + JPH_Vec3 v; JPH_BodyInterface_GetAngularVelocity(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &v); + return vec3_ary(mrb, v.x, v.y, v.z); +} +static mrb_value jolt_set_angular_velocity(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_float x,y,z; mrb_get_args(mrb, "ifff", &id, &x,&y,&z); + JPH_Vec3 v = { (float)x,(float)y,(float)z }; + JPH_BodyInterface_SetAngularVelocity(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &v); + return self; +} + +static mrb_value jolt_add_force(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_float x,y,z; mrb_get_args(mrb, "ifff", &id, &x,&y,&z); + JPH_Vec3 v = { (float)x,(float)y,(float)z }; + JPH_BodyInterface_AddForce(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &v); + return self; +} +static mrb_value jolt_add_impulse(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_float x,y,z; mrb_get_args(mrb, "ifff", &id, &x,&y,&z); + JPH_Vec3 v = { (float)x,(float)y,(float)z }; + JPH_BodyInterface_AddImpulse(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &v); + return self; +} +static mrb_value jolt_add_torque(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_float x,y,z; mrb_get_args(mrb, "ifff", &id, &x,&y,&z); + JPH_Vec3 v = { (float)x,(float)y,(float)z }; + JPH_BodyInterface_AddTorque(jolt_world(mrb, self)->bi, (JPH_BodyID)id, &v); + return self; +} + +static mrb_value jolt_is_active(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + return mrb_bool_value(JPH_BodyInterface_IsActive(jolt_world(mrb, self)->bi, (JPH_BodyID)id)); +} +static mrb_value jolt_activate(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + JPH_BodyInterface_ActivateBody(jolt_world(mrb, self)->bi, (JPH_BodyID)id); + return self; +} +static mrb_value jolt_deactivate(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + JPH_BodyInterface_DeactivateBody(jolt_world(mrb, self)->bi, (JPH_BodyID)id); + return self; +} + +/* _raycast(ox,oy,oz, dx,dy,dz) -> [body_id, fraction, hx,hy,hz] | nil */ +static mrb_value jolt_raycast(mrb_state *mrb, mrb_value self) { + mrb_float ox,oy,oz, dx,dy,dz; + mrb_get_args(mrb, "ffffff", &ox,&oy,&oz, &dx,&dy,&dz); + jolt_world_t *w = jolt_world(mrb, self); + const JPH_NarrowPhaseQuery *q = JPH_PhysicsSystem_GetNarrowPhaseQuery(w->sys); + JPH_RVec3 origin = { (float)ox,(float)oy,(float)oz }; + JPH_Vec3 dir = { (float)dx,(float)dy,(float)dz }; + JPH_RayCastResult hit; + if (!JPH_NarrowPhaseQuery_CastRay(q, &origin, &dir, &hit, NULL, NULL, NULL)) + return mrb_nil_value(); + float hx = (float)(ox + dx * hit.fraction); + float hy = (float)(oy + dy * hit.fraction); + float hz = (float)(oz + dz * hit.fraction); + JPH_Vec3 n = { 0, 0, 0 }; /* surface normal at the hit point */ + JPH_Body *hb = jolt_body_for(w, hit.bodyID); + if (hb) { JPH_RVec3 hp = { hx, hy, hz }; + JPH_Body_GetWorldSpaceSurfaceNormal(hb, hit.subShapeID2, &hp, &n); } + mrb_value a = mrb_ary_new_capa(mrb, 8); + mrb_ary_push(mrb, a, mrb_int_value(mrb, (mrb_int)hit.bodyID)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, hit.fraction)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, hx)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, hy)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, hz)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, n.x)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, n.y)); + mrb_ary_push(mrb, a, mrb_float_value(mrb, n.z)); + return a; +} + +/* --- body properties (get/set) --- */ +static mrb_value jolt_user_data(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + return mrb_int_value(mrb, (mrb_int)JPH_BodyInterface_GetUserData(jolt_world(mrb, self)->bi, (JPH_BodyID)id)); +} +static mrb_value jolt_set_user_data(mrb_state *mrb, mrb_value self) { + mrb_int id, v; mrb_get_args(mrb, "ii", &id, &v); + JPH_BodyInterface_SetUserData(jolt_world(mrb, self)->bi, (JPH_BodyID)id, (uint64_t)v); + return self; +} +static mrb_value jolt_motion_type(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + return mrb_int_value(mrb, (mrb_int)JPH_BodyInterface_GetMotionType(jolt_world(mrb, self)->bi, (JPH_BodyID)id)); +} +static mrb_value jolt_set_motion_type(mrb_state *mrb, mrb_value self) { + mrb_int id, mt; mrb_bool act; mrb_get_args(mrb, "iib", &id, &mt, &act); + JPH_BodyInterface_SetMotionType(jolt_world(mrb, self)->bi, (JPH_BodyID)id, (JPH_MotionType)mt, + act ? JPH_Activation_Activate : JPH_Activation_DontActivate); + return self; +} +static mrb_value jolt_friction(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + return mrb_float_value(mrb, JPH_BodyInterface_GetFriction(jolt_world(mrb, self)->bi, (JPH_BodyID)id)); +} +static mrb_value jolt_set_friction(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_float v; mrb_get_args(mrb, "if", &id, &v); + JPH_BodyInterface_SetFriction(jolt_world(mrb, self)->bi, (JPH_BodyID)id, (float)v); + return self; +} +static mrb_value jolt_restitution(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + return mrb_float_value(mrb, JPH_BodyInterface_GetRestitution(jolt_world(mrb, self)->bi, (JPH_BodyID)id)); +} +static mrb_value jolt_set_restitution(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_float v; mrb_get_args(mrb, "if", &id, &v); + JPH_BodyInterface_SetRestitution(jolt_world(mrb, self)->bi, (JPH_BodyID)id, (float)v); + return self; +} +static mrb_value jolt_gravity_factor(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_get_args(mrb, "i", &id); + return mrb_float_value(mrb, JPH_BodyInterface_GetGravityFactor(jolt_world(mrb, self)->bi, (JPH_BodyID)id)); +} +static mrb_value jolt_set_gravity_factor(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_float v; mrb_get_args(mrb, "if", &id, &v); + JPH_BodyInterface_SetGravityFactor(jolt_world(mrb, self)->bi, (JPH_BodyID)id, (float)v); + return self; +} + +static mrb_value jolt_set_sensor(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_bool v; mrb_get_args(mrb, "ib", &id, &v); + JPH_BodyInterface_SetIsSensor(jolt_world(mrb, self)->bi, (JPH_BodyID)id, v); + return self; +} +static mrb_value jolt_set_ccd(mrb_state *mrb, mrb_value self) { + mrb_int id; mrb_bool v; mrb_get_args(mrb, "ib", &id, &v); + JPH_BodyInterface_SetMotionQuality(jolt_world(mrb, self)->bi, (JPH_BodyID)id, + v ? JPH_MotionQuality_LinearCast : JPH_MotionQuality_Discrete); + return self; +} + +/* --- point overlap query --- */ +typedef struct { mrb_state *mrb; mrb_value arr; } jolt_collect_t; +static float JPH_API_CALL jolt_collect_point(void *ud, const JPH_CollidePointResult *r) { + jolt_collect_t *c = (jolt_collect_t *)ud; + mrb_ary_push(c->mrb, c->arr, mrb_int_value(c->mrb, (mrb_int)r->bodyID)); + return 1.0e30f; /* keep collecting (no early-out) */ +} +/* _overlap_point(x,y,z) -> Array of body ids whose shape contains the point */ +static mrb_value jolt_overlap_point(mrb_state *mrb, mrb_value self) { + mrb_float x, y, z; mrb_get_args(mrb, "fff", &x, &y, &z); + jolt_world_t *w = jolt_world(mrb, self); + const JPH_NarrowPhaseQuery *q = JPH_PhysicsSystem_GetNarrowPhaseQuery(w->sys); + JPH_RVec3 p = { (float)x, (float)y, (float)z }; + jolt_collect_t ctx = { mrb, mrb_ary_new(mrb) }; + JPH_NarrowPhaseQuery_CollidePoint(q, &p, jolt_collect_point, &ctx, NULL, NULL, NULL, NULL); + return ctx.arr; +} + +/* ----------------------------------------------------------- constraints --- */ +typedef struct { JPH_Constraint *c; JPH_PhysicsSystem *sys; jolt_token_t *token; } jolt_constraint_t; +static void jolt_constraint_free(mrb_state *mrb, void *p) { + jolt_constraint_t *c = p; + if (c) { + /* skip ALL Jolt calls if the world is already gone (see jolt_ragdoll_free) */ + if (c->c && c->token && c->token->alive) { + if (c->sys) JPH_PhysicsSystem_RemoveConstraint(c->sys, c->c); + JPH_Constraint_Destroy(c->c); + } + jolt_token_release(c->token); + mrb_free(mrb, c); + } +} +static const mrb_data_type jolt_constraint_type = { "Jolt::Constraint", jolt_constraint_free }; + +static mrb_value wrap_constraint(mrb_state *mrb, mrb_value world, JPH_Constraint *con, jolt_world_t *w) { + JPH_PhysicsSystem_AddConstraint(w->sys, con); + jolt_constraint_t *c = mrb_malloc(mrb, sizeof(jolt_constraint_t)); + c->c = con; c->sys = w->sys; c->token = jolt_token_acquire(w->token); + struct RClass *m = mrb_module_get(mrb, "Jolt"); + struct RClass *cls = mrb_class_get_under(mrb, m, "Constraint"); + mrb_value obj = mrb_obj_value(mrb_data_object_alloc(mrb, cls, c, &jolt_constraint_type)); + mrb_iv_set(mrb, obj, mrb_intern_lit(mrb, "@world"), world); + return obj; +} +static JPH_Vec3 jolt_norm(JPH_Vec3 v) { + float L = sqrtf(v.x*v.x + v.y*v.y + v.z*v.z); + if (L > 1e-6f) { v.x/=L; v.y/=L; v.z/=L; } + return v; +} + +static mrb_value jolt_c_fixed(mrb_state *mrb, mrb_value self) { + mrb_int a, b; mrb_get_args(mrb, "ii", &a, &b); + jolt_world_t *w = jolt_world(mrb, self); + JPH_FixedConstraintSettings s; JPH_FixedConstraintSettings_Init(&s); + s.space = JPH_ConstraintSpace_WorldSpace; s.autoDetectPoint = true; + JPH_Constraint *con = (JPH_Constraint *)JPH_FixedConstraint_Create(&s, + jolt_body_for(w, (JPH_BodyID)a), jolt_body_for(w, (JPH_BodyID)b)); + return wrap_constraint(mrb, self, con, w); +} +static mrb_value jolt_c_point(mrb_state *mrb, mrb_value self) { + mrb_int a, b; mrb_float px, py, pz; mrb_get_args(mrb, "iifff", &a, &b, &px, &py, &pz); + jolt_world_t *w = jolt_world(mrb, self); + JPH_PointConstraintSettings s; JPH_PointConstraintSettings_Init(&s); + s.space = JPH_ConstraintSpace_WorldSpace; + s.point1 = (JPH_RVec3){ (float)px, (float)py, (float)pz }; + s.point2 = s.point1; + JPH_Constraint *con = (JPH_Constraint *)JPH_PointConstraint_Create(&s, + jolt_body_for(w, (JPH_BodyID)a), jolt_body_for(w, (JPH_BodyID)b)); + return wrap_constraint(mrb, self, con, w); +} +static mrb_value jolt_c_distance(mrb_state *mrb, mrb_value self) { + mrb_int a, b; mrb_float ax,ay,az, bx,by,bz, mn, mx; + mrb_get_args(mrb, "iiffffffff", &a, &b, &ax,&ay,&az, &bx,&by,&bz, &mn, &mx); + jolt_world_t *w = jolt_world(mrb, self); + JPH_DistanceConstraintSettings s; JPH_DistanceConstraintSettings_Init(&s); + s.space = JPH_ConstraintSpace_WorldSpace; + s.point1 = (JPH_RVec3){ (float)ax, (float)ay, (float)az }; + s.point2 = (JPH_RVec3){ (float)bx, (float)by, (float)bz }; + s.minDistance = (float)mn; s.maxDistance = (float)mx; + JPH_Constraint *con = (JPH_Constraint *)JPH_DistanceConstraint_Create(&s, + jolt_body_for(w, (JPH_BodyID)a), jolt_body_for(w, (JPH_BodyID)b)); + return wrap_constraint(mrb, self, con, w); +} +static mrb_value jolt_c_hinge(mrb_state *mrb, mrb_value self) { + mrb_int a, b; mrb_float px,py,pz, ax,ay,az, mn, mx; + mrb_get_args(mrb, "iiffffffff", &a, &b, &px,&py,&pz, &ax,&ay,&az, &mn, &mx); + jolt_world_t *w = jolt_world(mrb, self); + JPH_HingeConstraintSettings s; JPH_HingeConstraintSettings_Init(&s); + s.space = JPH_ConstraintSpace_WorldSpace; + JPH_Vec3 axis = jolt_norm((JPH_Vec3){ (float)ax, (float)ay, (float)az }); + JPH_Vec3 nrm = jolt_perp(axis); + s.point1 = (JPH_RVec3){ (float)px, (float)py, (float)pz }; s.point2 = s.point1; + s.hingeAxis1 = axis; s.hingeAxis2 = axis; + s.normalAxis1 = nrm; s.normalAxis2 = nrm; + s.limitsMin = (float)mn; s.limitsMax = (float)mx; + JPH_Constraint *con = (JPH_Constraint *)JPH_HingeConstraint_Create(&s, + jolt_body_for(w, (JPH_BodyID)a), jolt_body_for(w, (JPH_BodyID)b)); + return wrap_constraint(mrb, self, con, w); +} +static mrb_value jolt_c_slider(mrb_state *mrb, mrb_value self) { + mrb_int a, b; mrb_float px,py,pz, ax,ay,az, mn, mx; + mrb_get_args(mrb, "iiffffffff", &a, &b, &px,&py,&pz, &ax,&ay,&az, &mn, &mx); + jolt_world_t *w = jolt_world(mrb, self); + JPH_SliderConstraintSettings s; JPH_SliderConstraintSettings_Init(&s); + s.space = JPH_ConstraintSpace_WorldSpace; s.autoDetectPoint = false; + JPH_Vec3 axis = jolt_norm((JPH_Vec3){ (float)ax, (float)ay, (float)az }); + JPH_Vec3 nrm = jolt_perp(axis); + s.point1 = (JPH_RVec3){ (float)px, (float)py, (float)pz }; s.point2 = s.point1; + s.sliderAxis1 = axis; s.sliderAxis2 = axis; + s.normalAxis1 = nrm; s.normalAxis2 = nrm; + s.limitsMin = (float)mn; s.limitsMax = (float)mx; + JPH_Constraint *con = (JPH_Constraint *)JPH_SliderConstraint_Create(&s, + jolt_body_for(w, (JPH_BodyID)a), jolt_body_for(w, (JPH_BodyID)b)); + return wrap_constraint(mrb, self, con, w); +} +static mrb_value jolt_c_cone(mrb_state *mrb, mrb_value self) { + mrb_int a, b; mrb_float px,py,pz, ax,ay,az, half; + mrb_get_args(mrb, "iifffffff", &a, &b, &px,&py,&pz, &ax,&ay,&az, &half); + jolt_world_t *w = jolt_world(mrb, self); + JPH_ConeConstraintSettings s; JPH_ConeConstraintSettings_Init(&s); + s.space = JPH_ConstraintSpace_WorldSpace; + JPH_Vec3 axis = jolt_norm((JPH_Vec3){ (float)ax, (float)ay, (float)az }); + s.point1 = (JPH_RVec3){ (float)px, (float)py, (float)pz }; s.point2 = s.point1; + s.twistAxis1 = axis; s.twistAxis2 = axis; + s.halfConeAngle = (float)half; + JPH_Constraint *con = (JPH_Constraint *)JPH_ConeConstraint_Create(&s, + jolt_body_for(w, (JPH_BodyID)a), jolt_body_for(w, (JPH_BodyID)b)); + return wrap_constraint(mrb, self, con, w); +} +static mrb_value jolt_constraint_remove(mrb_state *mrb, mrb_value self) { + jolt_constraint_t *c = mrb_data_get_ptr(mrb, self, &jolt_constraint_type); + if (c && c->c) { + if (c->sys && c->token && c->token->alive) JPH_PhysicsSystem_RemoveConstraint(c->sys, c->c); + JPH_Constraint_Destroy(c->c); + c->c = NULL; + } + return self; +} + +/* -------------------------------------------------------------- ragdoll --- */ +/* A Ragdoll: a tree of dynamic bodies (one per skeleton joint) wired together + * with swing-twist (cone + twist limit) constraints, so a humanoid collapses + * believably. Built in one shot from a packed parts array (the Ruby layer turns + * friendly hashes into it). Each part array element is: + * [0]=name(str) [1]=parent_index(int,-1=root) [2]=shape + * [3..5]=position [6..9]=rotation quat [10]=motion(int) [11]=mass(<=0 derive) + * [12..14]=joint world point [15..17]=twist axis [18..20]=plane axis + * [21]=normal-half-cone(rad) [22]=plane-half-cone(rad) + * [23]=twist-min(rad) [24]=twist-max(rad) (12.. ignored for root) */ +typedef struct { JPH_Ragdoll *rd; JPH_PhysicsSystem *sys; bool in_system; jolt_token_t *token; } jolt_ragdoll_t; + +static void jolt_ragdoll_free(mrb_state *mrb, void *p) { + jolt_ragdoll_t *r = p; + if (r) { + /* Only touch Jolt while the world (and its system + bodies) still exists. If + * the world was already destroyed (shutdown, arbitrary free order), the + * ragdoll's bodies/constraints are gone too — calling Destroy would double- + * free, so skip and let the process reclaim it. */ + if (r->rd && r->token && r->token->alive) { + if (r->in_system) JPH_Ragdoll_RemoveFromPhysicsSystem(r->rd, true); + JPH_Ragdoll_Destroy(r->rd); + } + jolt_token_release(r->token); + mrb_free(mrb, r); + } +} +static const mrb_data_type jolt_ragdoll_type = { "Jolt::Ragdoll", jolt_ragdoll_free }; + +static jolt_ragdoll_t *jolt_ragdoll(mrb_state *mrb, mrb_value self) { + jolt_ragdoll_t *r = mrb_data_get_ptr(mrb, self, &jolt_ragdoll_type); + if (!r || !r->rd) mrb_raise(mrb, E_RUNTIME_ERROR, "ragdoll not initialized"); + return r; +} + +static float partf(mrb_state *mrb, mrb_value part, mrb_int k) { + return (float)mrb_as_float(mrb, mrb_ary_ref(mrb, part, k)); +} + +/* world._ragdoll(parts, user_data) -> Jolt::Ragdoll */ +static mrb_value jolt_world_ragdoll(mrb_state *mrb, mrb_value self) { + mrb_value parts; mrb_int user_data; + mrb_get_args(mrb, "Ai", &parts, &user_data); + jolt_world_t *w = jolt_world(mrb, self); + mrb_int n = RARRAY_LEN(parts); + if (n < 1) mrb_raise(mrb, E_ARGUMENT_ERROR, "ragdoll needs >= 1 part"); + + JPH_Skeleton *skel = JPH_Skeleton_Create(); + for (mrb_int i = 0; i < n; i++) { + mrb_value part = mrb_ary_ref(mrb, parts, i); + const char *name = mrb_str_to_cstr(mrb, mrb_ary_ref(mrb, part, 0)); + mrb_int parent = mrb_as_int(mrb, mrb_ary_ref(mrb, part, 1)); + JPH_Skeleton_AddJoint2(skel, name, (int)parent); + } + JPH_Skeleton_CalculateParentJointIndices(skel); + + JPH_RagdollSettings *rs = JPH_RagdollSettings_Create(); + JPH_RagdollSettings_SetSkeleton(rs, skel); + JPH_RagdollSettings_ResizeParts(rs, (int)n); + for (mrb_int i = 0; i < n; i++) { + mrb_value part = mrb_ary_ref(mrb, parts, i); + mrb_int parent = mrb_as_int(mrb, mrb_ary_ref(mrb, part, 1)); + mrb_int motion = mrb_as_int(mrb, mrb_ary_ref(mrb, part, 10)); + float mass = partf(mrb, part, 11); + JPH_RVec3 pos = { partf(mrb,part,3), partf(mrb,part,4), partf(mrb,part,5) }; + JPH_Quat rot = { partf(mrb,part,6), partf(mrb,part,7), partf(mrb,part,8), partf(mrb,part,9) }; + JPH_RagdollSettings_SetPartShape(rs, (int)i, shape_ptr(mrb, mrb_ary_ref(mrb, part, 2))); + JPH_RagdollSettings_SetPartPosition(rs, (int)i, &pos); + JPH_RagdollSettings_SetPartRotation(rs, (int)i, &rot); + JPH_RagdollSettings_SetPartMotionType(rs, (int)i, (JPH_MotionType)motion); + JPH_RagdollSettings_SetPartObjectLayer(rs, (int)i, + (motion == JPH_MotionType_Static) ? L_STATIC : L_MOVING); + if (mass > 0) JPH_RagdollSettings_SetPartMassProperties(rs, (int)i, mass); + if (parent >= 0) { + JPH_SwingTwistConstraintSettings s; JPH_SwingTwistConstraintSettings_Init(&s); + s.space = JPH_ConstraintSpace_WorldSpace; + JPH_RVec3 jp = { partf(mrb,part,12), partf(mrb,part,13), partf(mrb,part,14) }; + JPH_Vec3 tw = jolt_norm((JPH_Vec3){ partf(mrb,part,15), partf(mrb,part,16), partf(mrb,part,17) }); + JPH_Vec3 pl = jolt_norm((JPH_Vec3){ partf(mrb,part,18), partf(mrb,part,19), partf(mrb,part,20) }); + s.position1 = jp; s.position2 = jp; + s.twistAxis1 = tw; s.twistAxis2 = tw; + s.planeAxis1 = pl; s.planeAxis2 = pl; + s.normalHalfConeAngle = partf(mrb,part,21); + s.planeHalfConeAngle = partf(mrb,part,22); + s.twistMinAngle = partf(mrb,part,23); + s.twistMaxAngle = partf(mrb,part,24); + JPH_RagdollSettings_SetPartToParent(rs, (int)i, &s); + } + } + + JPH_RagdollSettings_Stabilize(rs); /* tune masses */ + JPH_RagdollSettings_DisableParentChildCollisions(rs, NULL, 0.0f); /* no self-collide adjacents */ + JPH_RagdollSettings_CalculateBodyIndexToConstraintIndex(rs); + JPH_Ragdoll *rd = JPH_RagdollSettings_CreateRagdoll(rs, w->sys, 0, (uint64_t)user_data); + /* the ragdoll holds refs to settings (which holds the skeleton); release ours */ + JPH_RagdollSettings_Destroy(rs); + JPH_Skeleton_Destroy(skel); + if (!rd) mrb_raise(mrb, E_RUNTIME_ERROR, "failed to create ragdoll"); + JPH_Ragdoll_AddToPhysicsSystem(rd, JPH_Activation_Activate, true); + + jolt_ragdoll_t *r = mrb_malloc(mrb, sizeof(jolt_ragdoll_t)); + r->rd = rd; r->sys = w->sys; r->in_system = true; r->token = jolt_token_acquire(w->token); + struct RClass *m = mrb_module_get(mrb, "Jolt"); + struct RClass *cls = mrb_class_get_under(mrb, m, "Ragdoll"); + mrb_value obj = mrb_obj_value(mrb_data_object_alloc(mrb, cls, r, &jolt_ragdoll_type)); + mrb_iv_set(mrb, obj, mrb_intern_lit(mrb, "@world"), self); /* keep the world alive */ + return obj; +} + +static mrb_value jolt_ragdoll_body_count(mrb_state *mrb, mrb_value self) { + return mrb_int_value(mrb, JPH_Ragdoll_GetBodyCount(jolt_ragdoll(mrb, self)->rd)); +} +static mrb_value jolt_ragdoll_body_id(mrb_state *mrb, mrb_value self) { + mrb_int i; mrb_get_args(mrb, "i", &i); + return mrb_int_value(mrb, (mrb_int)JPH_Ragdoll_GetBodyID(jolt_ragdoll(mrb, self)->rd, (int)i)); +} +static mrb_value jolt_ragdoll_activate(mrb_state *mrb, mrb_value self) { + JPH_Ragdoll_Activate(jolt_ragdoll(mrb, self)->rd, true); + return self; +} +static mrb_value jolt_ragdoll_remove(mrb_state *mrb, mrb_value self) { + jolt_ragdoll_t *r = mrb_data_get_ptr(mrb, self, &jolt_ragdoll_type); + if (r && r->rd && r->in_system) { + if (r->token && r->token->alive) JPH_Ragdoll_RemoveFromPhysicsSystem(r->rd, true); + r->in_system = false; + } + return self; +} + +/* ------------------------------------------------------------- character -- */ +/* A CharacterVirtual: a kinematic, fully-controlled player capsule with + * stair-stepping / slope handling. Holds the physics system so Update can run; + * the Ruby wrapper keeps its world alive via @world. */ +typedef struct { JPH_CharacterVirtual *ch; JPH_PhysicsSystem *sys; } jolt_char_t; + +static void jolt_char_free(mrb_state *mrb, void *p) { + jolt_char_t *c = p; + if (c) { + if (c->ch) JPH_CharacterBase_Destroy((JPH_CharacterBase *)c->ch); + mrb_free(mrb, c); + } +} +static const mrb_data_type jolt_char_type = { "Jolt::Character", jolt_char_free }; + +static jolt_char_t *jolt_char(mrb_state *mrb, mrb_value self) { + jolt_char_t *c = mrb_data_get_ptr(mrb, self, &jolt_char_type); + if (!c || !c->ch) mrb_raise(mrb, E_RUNTIME_ERROR, "character not initialized"); + return c; +} + +/* world._character(shape, px,py,pz, slope_deg, mass) -> Jolt::Character */ +static mrb_value jolt_world_character(mrb_state *mrb, mrb_value self) { + mrb_value shape; mrb_float px, py, pz, slope, mass; + mrb_get_args(mrb, "offfff", &shape, &px, &py, &pz, &slope, &mass); + jolt_world_t *w = jolt_world(mrb, self); + + JPH_CharacterVirtualSettings cs; + JPH_CharacterVirtualSettings_Init(&cs); + cs.base.shape = shape_ptr(mrb, shape); + cs.base.up = (JPH_Vec3){ 0, 1, 0 }; + cs.base.supportingVolume = (JPH_Plane){ { 0, 1, 0 }, -1.0e10f }; /* accept all; slope filters */ + cs.base.maxSlopeAngle = (float)(slope * 3.14159265358979 / 180.0); + cs.mass = (float)mass; + + JPH_RVec3 pos = { (float)px, (float)py, (float)pz }; + JPH_CharacterVirtual *ch = JPH_CharacterVirtual_Create(&cs, &pos, NULL, 0, w->sys); + if (!ch) mrb_raise(mrb, E_RUNTIME_ERROR, "failed to create character"); + + jolt_char_t *c = mrb_malloc(mrb, sizeof(jolt_char_t)); + c->ch = ch; c->sys = w->sys; + struct RClass *m = mrb_module_get(mrb, "Jolt"); + struct RClass *cls = mrb_class_get_under(mrb, m, "Character"); + mrb_value obj = mrb_obj_value(mrb_data_object_alloc(mrb, cls, c, &jolt_char_type)); + mrb_iv_set(mrb, obj, mrb_intern_lit(mrb, "@world"), self); /* keep the world alive */ + return obj; +} + +static mrb_value jolt_char_update(mrb_state *mrb, mrb_value self) { + mrb_float dt; mrb_get_args(mrb, "f", &dt); + jolt_char_t *c = jolt_char(mrb, self); + /* ExtendedUpdate (not basic Update) gives stair-stepping + stick-to-floor. + Jolt's documented defaults; step-up height = walkStairsStepUp.y (0.4m). */ + JPH_ExtendedUpdateSettings su; + su.stickToFloorStepDown = (JPH_Vec3){ 0, -0.5f, 0 }; + su.walkStairsStepUp = (JPH_Vec3){ 0, 0.4f, 0 }; + su.walkStairsMinStepForward = 0.02f; + su.walkStairsStepForwardTest = 0.15f; + su.walkStairsCosAngleForwardContact = 0.2588f; /* cos(75 deg) */ + su.walkStairsStepDownExtra = (JPH_Vec3){ 0, 0, 0 }; + JPH_CharacterVirtual_ExtendedUpdate(c->ch, (float)dt, &su, L_MOVING, c->sys, NULL, NULL); + return self; +} +static mrb_value jolt_char_position(mrb_state *mrb, mrb_value self) { + JPH_RVec3 p; JPH_CharacterVirtual_GetPosition(jolt_char(mrb, self)->ch, &p); + return vec3_ary(mrb, p.x, p.y, p.z); +} +static mrb_value jolt_char_set_position(mrb_state *mrb, mrb_value self) { + mrb_float x, y, z; mrb_get_args(mrb, "fff", &x, &y, &z); + JPH_RVec3 p = { (float)x, (float)y, (float)z }; + JPH_CharacterVirtual_SetPosition(jolt_char(mrb, self)->ch, &p); + return self; +} +static mrb_value jolt_char_velocity(mrb_state *mrb, mrb_value self) { + JPH_Vec3 v; JPH_CharacterVirtual_GetLinearVelocity(jolt_char(mrb, self)->ch, &v); + return vec3_ary(mrb, v.x, v.y, v.z); +} +static mrb_value jolt_char_set_velocity(mrb_state *mrb, mrb_value self) { + mrb_float x, y, z; mrb_get_args(mrb, "fff", &x, &y, &z); + JPH_Vec3 v = { (float)x, (float)y, (float)z }; + JPH_CharacterVirtual_SetLinearVelocity(jolt_char(mrb, self)->ch, &v); + return self; +} +static mrb_value jolt_char_ground_state(mrb_state *mrb, mrb_value self) { + return mrb_int_value(mrb, + (mrb_int)JPH_CharacterBase_GetGroundState((JPH_CharacterBase *)jolt_char(mrb, self)->ch)); +} +static mrb_value jolt_char_ground_normal(mrb_state *mrb, mrb_value self) { + JPH_Vec3 n; JPH_CharacterBase_GetGroundNormal((JPH_CharacterBase *)jolt_char(mrb, self)->ch, &n); + return vec3_ary(mrb, n.x, n.y, n.z); +} +static mrb_value jolt_char_supported(mrb_state *mrb, mrb_value self) { + return mrb_bool_value(JPH_CharacterBase_IsSupported((JPH_CharacterBase *)jolt_char(mrb, self)->ch)); +} +/* velocity of the surface the character stands on (a moving platform/elevator). + Add it to the character's velocity to ride along. Zero when not supported. */ +static mrb_value jolt_char_ground_velocity(mrb_state *mrb, mrb_value self) { + JPH_Vec3 v; JPH_CharacterBase_GetGroundVelocity((JPH_CharacterBase *)jolt_char(mrb, self)->ch, &v); + return vec3_ary(mrb, v.x, v.y, v.z); +} +/* body id of the surface under the character (the platform). Invalid id when + airborne — the Ruby layer maps that to nil via on_ground?. */ +static mrb_value jolt_char_ground_body_id(mrb_state *mrb, mrb_value self) { + return mrb_int_value(mrb, + (mrb_int)JPH_CharacterBase_GetGroundBodyId((JPH_CharacterBase *)jolt_char(mrb, self)->ch)); +} +/* max force (N) the character can exert on dynamic bodies it walks into. The + Jolt default (100 N) is too weak to shove heavy default-density spheres, so + games raise this when they want the player to push props around. */ +static mrb_value jolt_char_set_max_strength(mrb_state *mrb, mrb_value self) { + mrb_float v; mrb_get_args(mrb, "f", &v); + JPH_CharacterVirtual_SetMaxStrength(jolt_char(mrb, self)->ch, (float)v); + return self; +} +static mrb_value jolt_char_max_strength(mrb_state *mrb, mrb_value self) { + return mrb_float_value(mrb, JPH_CharacterVirtual_GetMaxStrength(jolt_char(mrb, self)->ch)); +} +/* effective mass used when dynamic bodies collide with the character (higher = + harder to shove the character; it is still kinematic / infinite-mass to gravity). */ +static mrb_value jolt_char_set_mass(mrb_state *mrb, mrb_value self) { + mrb_float v; mrb_get_args(mrb, "f", &v); + JPH_CharacterVirtual_SetMass(jolt_char(mrb, self)->ch, (float)v); + return self; +} + +/* ------------------------------------------------------------------- init -- */ +void mrb_jolt_gem_init(mrb_state *mrb) { + JPH_Init(); + g_contact_procs.OnContactAdded = jolt_on_contact_added; + g_contact_procs.OnContactRemoved = jolt_on_contact_removed; + JPH_ContactListener_SetProcs(&g_contact_procs); + + struct RClass *m = mrb_define_module(mrb, "Jolt"); + + /* motion type constants */ + mrb_define_const(mrb, m, "STATIC", mrb_int_value(mrb, JPH_MotionType_Static)); + mrb_define_const(mrb, m, "KINEMATIC", mrb_int_value(mrb, JPH_MotionType_Kinematic)); + mrb_define_const(mrb, m, "DYNAMIC", mrb_int_value(mrb, JPH_MotionType_Dynamic)); + + /* shape factories (module functions) */ + mrb_define_module_function(mrb, m, "_box", jolt_box, MRB_ARGS_REQ(3)); + mrb_define_module_function(mrb, m, "_sphere", jolt_sphere, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, m, "_capsule", jolt_capsule, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, m, "_cylinder", jolt_cylinder, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, m, "_convex_hull", jolt_convex_hull, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, m, "_mesh", jolt_mesh, MRB_ARGS_REQ(1)); + + struct RClass *shape = mrb_define_class_under(mrb, m, "Shape", mrb->object_class); + MRB_SET_INSTANCE_TT(shape, MRB_TT_DATA); + + struct RClass *world = mrb_define_class_under(mrb, m, "World", mrb->object_class); + MRB_SET_INSTANCE_TT(world, MRB_TT_DATA); + mrb_define_method(mrb, world, "_setup", jolt_world_init, MRB_ARGS_REQ(4)); + mrb_define_method(mrb, world, "_step", jolt_step, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_optimize", jolt_optimize, MRB_ARGS_NONE()); + mrb_define_method(mrb, world, "_set_gravity", jolt_set_gravity, MRB_ARGS_REQ(3)); + mrb_define_method(mrb, world, "_add_body", jolt_add_body, MRB_ARGS_REQ(12)); + mrb_define_method(mrb, world, "_remove_body", jolt_remove_body, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_position", jolt_position, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_com_position", jolt_com_position, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_rotation", jolt_rotation, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_set_transform", jolt_set_transform, MRB_ARGS_REQ(9)); + mrb_define_method(mrb, world, "_linear_velocity", jolt_linear_velocity, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_set_linear_velocity", jolt_set_linear_velocity, MRB_ARGS_REQ(4)); + mrb_define_method(mrb, world, "_angular_velocity", jolt_angular_velocity, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_set_angular_velocity", jolt_set_angular_velocity, MRB_ARGS_REQ(4)); + mrb_define_method(mrb, world, "_add_force", jolt_add_force, MRB_ARGS_REQ(4)); + mrb_define_method(mrb, world, "_add_impulse", jolt_add_impulse, MRB_ARGS_REQ(4)); + mrb_define_method(mrb, world, "_add_torque", jolt_add_torque, MRB_ARGS_REQ(4)); + mrb_define_method(mrb, world, "_active?", jolt_is_active, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_activate", jolt_activate, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_deactivate", jolt_deactivate, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_raycast", jolt_raycast, MRB_ARGS_REQ(6)); + mrb_define_method(mrb, world, "_contacts", jolt_contacts, MRB_ARGS_NONE()); + mrb_define_method(mrb, world, "_contacts_ended", jolt_contacts_ended, MRB_ARGS_NONE()); + mrb_define_method(mrb, world, "_overlap_point", jolt_overlap_point, MRB_ARGS_REQ(3)); + mrb_define_method(mrb, world, "_set_sensor", jolt_set_sensor, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_set_ccd", jolt_set_ccd, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_fixed", jolt_c_fixed, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_point", jolt_c_point, MRB_ARGS_REQ(5)); + mrb_define_method(mrb, world, "_distance", jolt_c_distance, MRB_ARGS_REQ(10)); + mrb_define_method(mrb, world, "_hinge", jolt_c_hinge, MRB_ARGS_REQ(10)); + mrb_define_method(mrb, world, "_slider", jolt_c_slider, MRB_ARGS_REQ(10)); + mrb_define_method(mrb, world, "_cone", jolt_c_cone, MRB_ARGS_REQ(9)); + mrb_define_method(mrb, world, "_user_data", jolt_user_data, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_set_user_data", jolt_set_user_data, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_motion_type", jolt_motion_type, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_set_motion_type", jolt_set_motion_type, MRB_ARGS_REQ(3)); + mrb_define_method(mrb, world, "_friction", jolt_friction, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_set_friction", jolt_set_friction, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_restitution", jolt_restitution, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_set_restitution", jolt_set_restitution, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_gravity_factor", jolt_gravity_factor, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, world, "_set_gravity_factor", jolt_set_gravity_factor, MRB_ARGS_REQ(2)); + mrb_define_method(mrb, world, "_character", jolt_world_character, MRB_ARGS_REQ(6)); + mrb_define_method(mrb, world, "_ragdoll", jolt_world_ragdoll, MRB_ARGS_REQ(2)); + + struct RClass *con = mrb_define_class_under(mrb, m, "Constraint", mrb->object_class); + MRB_SET_INSTANCE_TT(con, MRB_TT_DATA); + mrb_define_method(mrb, con, "_remove", jolt_constraint_remove, MRB_ARGS_NONE()); + + struct RClass *rag = mrb_define_class_under(mrb, m, "Ragdoll", mrb->object_class); + MRB_SET_INSTANCE_TT(rag, MRB_TT_DATA); + mrb_define_method(mrb, rag, "_body_count", jolt_ragdoll_body_count, MRB_ARGS_NONE()); + mrb_define_method(mrb, rag, "_body_id", jolt_ragdoll_body_id, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, rag, "_activate", jolt_ragdoll_activate, MRB_ARGS_NONE()); + mrb_define_method(mrb, rag, "_remove", jolt_ragdoll_remove, MRB_ARGS_NONE()); + + struct RClass *chr = mrb_define_class_under(mrb, m, "Character", mrb->object_class); + MRB_SET_INSTANCE_TT(chr, MRB_TT_DATA); + mrb_define_method(mrb, chr, "_update", jolt_char_update, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, chr, "_position", jolt_char_position, MRB_ARGS_NONE()); + mrb_define_method(mrb, chr, "_set_position", jolt_char_set_position, MRB_ARGS_REQ(3)); + mrb_define_method(mrb, chr, "_velocity", jolt_char_velocity, MRB_ARGS_NONE()); + mrb_define_method(mrb, chr, "_set_velocity", jolt_char_set_velocity, MRB_ARGS_REQ(3)); + mrb_define_method(mrb, chr, "_ground_state", jolt_char_ground_state, MRB_ARGS_NONE()); + mrb_define_method(mrb, chr, "_ground_normal", jolt_char_ground_normal, MRB_ARGS_NONE()); + mrb_define_method(mrb, chr, "_supported?", jolt_char_supported, MRB_ARGS_NONE()); + mrb_define_method(mrb, chr, "_ground_velocity", jolt_char_ground_velocity, MRB_ARGS_NONE()); + mrb_define_method(mrb, chr, "_ground_body_id", jolt_char_ground_body_id, MRB_ARGS_NONE()); + mrb_define_method(mrb, chr, "_max_strength", jolt_char_max_strength, MRB_ARGS_NONE()); + mrb_define_method(mrb, chr, "_set_max_strength", jolt_char_set_max_strength, MRB_ARGS_REQ(1)); + mrb_define_method(mrb, chr, "_set_mass", jolt_char_set_mass, MRB_ARGS_REQ(1)); +} + +void mrb_jolt_gem_final(mrb_state *mrb) { (void)mrb; JPH_Shutdown(); } diff --git a/mrbgems/raylib/mrbgem.rake b/mrbgems/raylib/mrbgem.rake new file mode 100644 index 0000000..318a1f0 --- /dev/null +++ b/mrbgems/raylib/mrbgem.rake @@ -0,0 +1,47 @@ +# Generate the full bindings (raylib_gen.c) from raylib's API description BEFORE +# the gem spec globs its source files. +stack_root = ENV['JAMSTACK_ROOT'] || File.expand_path('../../..', __dir__) +raylib_dir = File.join(stack_root, 'vendor', 'raylib') + gen = File.join(__dir__, 'tools', 'gen_raylib.rb') + # raylib 6.0 relocated the API parser: parser/ -> tools/rlparser/, and the + # source was renamed raylib_parser.c -> rlparser.c. raylib_api.json is shipped + # (pre-generated) at tools/rlparser/output/; raymath_api.json is NOT shipped + # and is generated below from src/raymath.h via the parser. + json = File.join(raylib_dir, 'tools', 'rlparser', 'output', 'raylib_api.json') + raymath = File.join(raylib_dir, 'tools', 'rlparser', 'output', 'raymath_api.json') + + parser_src = File.join(raylib_dir, 'tools', 'rlparser', 'rlparser.c') + parser_bin = File.join(raylib_dir, 'tools', 'rlparser', 'rlparser') + raymath_h = File.join(raylib_dir, 'src', 'raymath.h') + if !File.exist?(raymath) && File.exist?(parser_src) && File.exist?(raymath_h) + sh "cc -o #{parser_bin} #{parser_src}" unless File.exist?(parser_bin) + sh "#{parser_bin} -i #{raymath_h} -o #{raymath} -f JSON -d RMAPI" + end + genc = File.join(__dir__, 'src', 'raylib_gen.c') + inputs = [gen, json, raymath].select { |f| File.exist?(f) } + if File.exist?(json) && + (!File.exist?(genc) || inputs.any? { |f| File.mtime(f) > File.mtime(genc) }) + sh "ruby #{gen} #{json} #{genc} #{raymath if File.exist?(raymath)}".strip + end + + # Regenerate the baked SMAA lookup-texture data (src/smaa_tex_data.c) if stale. + # Ports iryoku/smaa Scripts/AreaTex.py + SearchTex.py to CRuby; output is the + # canonical 160x560 areaTex + 64x16 searchTex as a C const array (committed is + # gitignored -- regenerated like raylib_gen.c). Lives in C because mruby caps + # string literals at 65534 bytes AND a ~358KB string constant hangs its irep + # loader at boot (see .agents/knowledge/fx-pipeline.md "SMAA 1x"). + smaa_gen = File.join(__dir__, 'tools', 'gen_smaa_tex.rb') + smaa_c = File.join(__dir__, 'src', 'smaa_tex_data.c') + if File.exist?(smaa_gen) && (!File.exist?(smaa_c) || File.mtime(smaa_gen) > File.mtime(smaa_c)) + sh "ruby #{smaa_gen}" + end + +MRuby::Gem::Specification.new('raylib') do |spec| + spec.license = 'MIT' + spec.authors = 'raylib-jamstack' + spec.summary = 'Ruby (Rl::) bindings for raylib (generated from raylib_api.json)' + + raylib_inc = File.join(raylib_dir, 'src') + spec.cc.include_paths << raylib_inc + spec.cxx.include_paths << raylib_inc if spec.respond_to?(:cxx) +end diff --git a/mrbgems/raylib/mrblib/bridge.rb b/mrbgems/raylib/mrblib/bridge.rb new file mode 100644 index 0000000..58e26c7 --- /dev/null +++ b/mrbgems/raylib/mrblib/bridge.rb @@ -0,0 +1,332 @@ +# Jamstack agent bridge (R1): run Ruby in the LIVE game over a localhost TCP +# socket, drained ONCE PER FRAME on the main thread (P6 — never on a socket +# callback). Dev-only, gated by JAMSTACK_BRIDGE=1 (read via Jamstack.getenv; this +# mruby has no ENV). stdout is captured by the C fd-redirect helpers +# Jamstack.__cap_begin / __cap_end because mruby's puts/print bypass $stdout. +# +# Wire protocol (R1; R4 formalizes/unifies to JSON both ways via the relay): +# request : one line "<id> <code>" with <code> escaped (\\ -> \\\\, newline +# -> \n, tab -> \t). <id> is a token with no spaces. +# response : one line of JSON {"id","ok","result","stdout","error","backtrace"} +# +# See .agents/knowledge/agent-bridge.md. +module Jamstack + module Bridge + DEFAULT_PORT = 7621 + MAX_PER_FRAME = 16 # bound eval work per frame so the game keeps drawing + + @active = false + @server = nil + @clients = [] # [{ sock:, buf: }] + @queue = [] # [[id, code, sock], ...] + @binding = nil # optional game binding for HTML/agent-bridge REPLs + + class << self + def active? = @active + + def enabled? = !Jamstack.getenv('JAMSTACK_BRIDGE').nil? + + # Opt the running game into exposing its local-variable scope to the + # bridge eval (web HTML REPL, desktop bin/eval). Without this, eval runs + # at top level and cannot read/write game locals. Mirrors Jamstack::Console + # which is constructed with binding: binding(). + def set_binding(b) + @binding = b + end + + def binding + @binding + end + + def port + p = Jamstack.getenv('JAMSTACK_BRIDGE_PORT') + (p && !p.empty?) ? p.to_i : DEFAULT_PORT + end + + def start(p = port) + return if @active + @server = TCPServer.new('127.0.0.1', p) + @clients = [] + @queue = [] + @active = true + emit_log("listening on 127.0.0.1:#{p}") + rescue => e + @active = false + emit_log("failed to start on #{p}: #{e.class}: #{e.message}") + end + + # Called once per frame from Rl.while_window_open, BEFORE the game block. + def drain + return unless @active + accept_new + read_clients + n = 0 + while n < MAX_PER_FRAME && [email protected]? + id, code, sock = @queue.shift + send_line(sock, response_json(id, eval_code(code))) + n += 1 + end + end + + # Run one snippet, capturing value + stdout + error. Never raises. + # Uses the game's registered binding (set_binding) when present so HTML/agent + # REPLs can read/write game locals; otherwise top-level eval. + def eval_code(code) + Jamstack.__cap_begin + ok = true; result = nil; err = nil; bt = nil + begin + result = safe_inspect(@binding ? eval_in_binding(code, @binding, "(bridge)") : eval(code)) + rescue Exception => e + ok = false + err = "#{e.class}: #{e.message}" + bt = e.backtrace || [] + ensure + out = Jamstack.__cap_end + end + # Route failures into the log stream too (AFTER cap_end, so the log line + # isn't captured as this eval's stdout). + Jamstack::Log.error("eval error", error: err, backtrace: bt, tag: "bridge") unless ok + { ok: ok, result: result, stdout: out.to_s, error: err, backtrace: bt } + end + + # Eval in a binding with assignment write-back. mruby's eval opens a NEW + # local scope, so `var = expr` would not mutate the binding's existing + # locals — detect simple `ident = expr` assignments and route them through + # binding.local_variable_set so the game loop's closure sees the change. + # Compound ops (`+=`, `x.y =`, `a = b = 1`) and non-identifier LHS fall + # through to plain eval. Shared by the HTML/agent REPL and Jamstack::Console. + # Only locals that existed when the binding was captured can be modified. + def eval_in_binding(line, binding, file = "(repl)") + var_name, expr = parse_assignment(line) + if var_name + value = eval(expr, binding, file, 1) + binding.local_variable_set(var_name.to_sym, value) + value + else + eval(line, binding, file, 1) + end + end + + def parse_assignment(line) + eq_idx = line.index("=") + return nil unless eq_idx + + prev = eq_idx > 0 ? line[eq_idx - 1] : "" + nxt = line[eq_idx + 1] || "" + + return nil if nxt == "=" + return nil if nxt == ">" + return nil if "=<>!".include?(prev) + return nil if "+-*/%".include?(prev) + + var_name = line[0...eq_idx].strip + expr = line[(eq_idx + 1)..].strip + return nil unless valid_identifier?(var_name) + [var_name, expr] + end + + def valid_identifier?(s) + return false if s.empty? + first = s[0] + return false if first >= "0" && first <= "9" + s.each_char do |c| + return false unless ident_char?(c) + end + true + end + + def ident_char?(c) + (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || + (c >= "0" && c <= "9") || c == "_" + end + + # -- Tab completion (shared by HTML/agent REPL and Jamstack::Console) -- + # Splits text at the last `::` (constant) or `.` (method) separator; + # otherwise treats the whole text as a toplevel prefix. + def parse_completion(text) + if idx = text.rindex("::") + [text[0...idx], text[(idx + 2)..], :constant] + elsif idx = text.rindex(".") + [text[0...idx], text[(idx + 1)..], :method] + else + [nil, text, :toplevel] + end + end + + def gather_candidates(receiver_expr, prefix, kind, binding) + cands = + case kind + when :toplevel + locals = binding.local_variables.map(&:to_s) + meths = binding.receiver.public_methods.map(&:to_s) + consts = Object.constants.map(&:to_s) + locals + meths + consts + when :method + recv = eval(receiver_expr, binding) rescue nil + return [] if recv.nil? + recv.public_methods.map(&:to_s) + when :constant + recv = eval(receiver_expr, binding) rescue nil + return [] if recv.nil? || !recv.respond_to?(:constants) + recv.constants.map(&:to_s) + end + cands.uniq.select { |c| c.start_with?(prefix) }.sort + end + + def common_prefix(strings) + return "" if strings.empty? + prefix = strings[0] + i = 1 + while i < strings.length + s = strings[i] + while !s.start_with?(prefix) + prefix = prefix[0...-1] + return "" if prefix.empty? + end + i += 1 + end + prefix + end + + # Completion result as a Hash. `completed` is the value to write into the + # input on a unique match OR a common-prefix extension (nil = no change). + # `single` distinguishes the two. `candidates` is the full filtered list + # (the caller lists them when there's more than one). + def complete_hash(text) + return { ok: true, base: "", common: "", completed: nil, candidates: [], single: false } if text.to_s.empty? + return { ok: false, error: "no binding registered (call Jamstack::Bridge.set_binding)" } unless @binding + receiver_expr, prefix, kind = parse_completion(text) + candidates = gather_candidates(receiver_expr, prefix, kind, @binding) + sep = kind == :constant ? "::" : "." + base = receiver_expr ? receiver_expr + sep : "" + if candidates.empty? + { ok: true, base: base, common: prefix, completed: nil, candidates: [], single: false } + elsif candidates.length == 1 + { ok: true, base: base, common: candidates[0], completed: base + candidates[0], candidates: candidates, single: true } + else + common = common_prefix(candidates) + { ok: true, base: base, common: common, + completed: (common.length > prefix.length ? base + common : nil), + candidates: candidates, single: false } + end + end + + # JSON string for the HTML/agent REPL. Call via + # Module.jamstack('Jamstack::Bridge.complete_json(<single-quoted text>)') + # then double-parse: JSON.parse(env).result is this JSON string. + def complete_json(text) + Jamstack::JSON.generate(complete_hash(text)) + end + + # Eval and return the JSON response envelope as a String. The web jamstack_eval + # C export calls this directly (wasm is single-threaded, so no socket/queue). + def eval_json(code, id = nil) + response_json(id, eval_code(code)) + end + + private + + def safe_inspect(v) + v.inspect + rescue Exception => e + "#<uninspectable #{v.class}: #{e.class}>" + end + + def accept_new + loop do + begin + sock = @server.accept_nonblock + rescue + break # EAGAIN: nothing pending this frame + end + @clients << { sock: sock, buf: "" } + end + end + + def read_clients + dead = [] + @clients.each do |c| + loop do + begin + chunk = c[:sock].recv_nonblock(4096) + rescue + break # EAGAIN: no more data this frame + end + if chunk.nil? || chunk.empty? # peer closed + dead << c + break + end + c[:buf] << chunk + end + extract_lines(c) + end + dead.each { |c| close_client(c) } + end + + def extract_lines(c) + while (i = c[:buf].index("\n")) + line = c[:buf].slice!(0, i + 1).chomp + next if line.strip.empty? + enqueue_line(c[:sock], line) + end + end + + def enqueue_line(sock, line) + sp = line.index(' ') + if sp + id = line[0...sp] + code = unescape(line[(sp + 1)..-1]) + else + id = line + code = '' + end + @queue << [id, code, sock] + end + + def unescape(s) + out = "" + i = 0 + n = s.length + while i < n + ch = s[i] + if ch == "\\" && i + 1 < n + nx = s[i + 1] + out << (nx == 'n' ? "\n" : nx == 't' ? "\t" : nx == 'r' ? "\r" : nx) + i += 2 + else + out << ch + i += 1 + end + end + out + end + + def close_client(c) + @clients.delete(c) + begin; c[:sock].close; rescue; end + end + + def send_line(sock, str) + sock.write(str) + sock.write("\n") + rescue + # client vanished mid-write; reaped on next read + end + + def response_json(id, env) + Jamstack::JSON.generate( + "id" => id, + "ok" => env[:ok], + "result" => env[:result], + "stdout" => env[:stdout], + "error" => env[:error], + "backtrace" => env[:backtrace] + ) + end + + def emit_log(msg) + Jamstack::Log.info(msg, tag: "bridge") + end + end + end +end diff --git a/mrbgems/raylib/mrblib/fx.rb b/mrbgems/raylib/mrblib/fx.rb new file mode 100644 index 0000000..ea18e89 --- /dev/null +++ b/mrbgems/raylib/mrblib/fx.rb @@ -0,0 +1,569 @@ +# Jamstack::FX — a layered, two-stage, runtime-toggleable post-processing +# shader pipeline. Pure Ruby over the already-bound raylib shader API; no C. +# +# Two stages over three render layers, so each effect chooses whether it touches +# only the game world (+ in-world UI) or the whole frame (game + overlay HUD): +# +# GAME LAYER 3D world + in-world RmlUi -> RenderTexture G +# | +# GAME SHADERS ping-pong chain on G (gameplay FX; NOT the overlay HUD) +# | +# OVERLAY LAYER processed-game quad + overlay RmlUi HUD -> RenderTexture C +# | +# TOP SHADERS ping-pong chain on C (complete FX; over EVERYTHING incl HUD) +# | +# screen +# +# Flow: game + game-rmlui -> game shaders -> top-rmlui -> top shaders -> screen +# +# Toggle = skip the pass: Pass#enabled = false removes it from the per-frame +# chain. Zero shader recompilation (shaders load once at construction). An +# optional `intensity` uniform (0..1) lets an effect fade rather than snap. +# +# Usage: +# fx = Jamstack::FX::Pipeline.new(720, 720) +# fx.game_shaders << Jamstack::FX::Pass.new("scanlines", Jamstack::FX::SCANLINES) +# fx.top_shaders << Jamstack::FX::Pass.new("vignette", Jamstack::FX::VIGNETTE) +# Rl.while_window_open do +# fx.frame(Rl.time) do |f| +# f.game_layer { Rl.clear_background(Rl::BLACK); Rl.mode_3d(cam){...}; game_ui.update; game_ui.render } +# f.overlay_layer { top_ui.update; top_ui.render } +# end +# end +# # runtime toggle (eval bridge / in-game console): +# # fx.game_shaders[0].enabled = false # game FX off -> overlay HUD unaffected +# # fx.top_shaders[0].enabled = true # top FX on -> whole frame incl HUD +module Jamstack + module FX + # ---------------------------------------------------------------- version shim + # raylib does NOT prepend a #version to user fragment shaders (verified in + # vendor rlgl.h: rlLoadShader compiles your string as-is). The default vertex + # shader (used when vs == nil via load_shader_from_memory) outputs the varying + # `fragTexCoord` and binds the input texture to sampler `texture0` on BOTH + # targets (GLSL 330 desktop: `in vec2 fragTexCoord`; GLSL 100 web: `varying + # vec2 fragTexCoord`). So our fragment shader must declare fragTexCoord and + # sample texture0. The two dialects differ in syntax (varying/in, + # texture2D/texture, gl_FragColor/out), so a macro shim lets ONE body serve + # both: the header defines TEXTURE() and FRAG per target. + # + # WebGL1-first: the current web build is ES2 (#version 100). Desktop is GL33 + # (#version 330). After a future ES3/WebGL2 upgrade this becomes #version 300 + # es (which shares in/out/texture() syntax with 330), narrowing the gap to + # just the #version + precision line. + # Version header (lazy: computed on first use, not at file-load time — fx.rb + # is globbed before raylib.rb, so the Rl.web? sugar isn't defined yet at load). + # Uses the underlying Rl._is_web C function (registered at gem init, always + # available). raylib does NOT prepend #version to user fragment shaders + # (verified in vendor rlgl.h: rlLoadShader compiles your string as-is). The + # default vertex shader (vs == nil via load_shader_from_memory) outputs the + # varying `fragTexCoord` and binds the input texture to sampler `texture0` on + # BOTH targets (GLSL 330 desktop: `in vec2 fragTexCoord`; GLSL 100 web: + # `varying vec2 fragTexCoord`). The two dialects differ in syntax, so a macro + # shim lets ONE body serve both: the header defines TEXTURE()/FRAG per target. + # + # WebGL1-first: the current web build is ES2 (#version 100). Desktop is GL33 + # (#version 330). After a future ES3/WebGL2 upgrade this becomes #version 300 + # es (which shares in/out/texture() syntax with 330), narrowing the gap to + # just the #version + precision line. + def self.header + return @header if @header + + @header = if Rl._is_web + '#version 300 es +precision highp float; +in vec2 fragTexCoord; +uniform sampler2D texture0; +out vec4 fragColor; +#define FRAG fragColor +#define TEXTURE(s, uv) texture(s, uv) +' + else + '#version 330 +in vec2 fragTexCoord; +uniform sampler2D texture0; +out vec4 fragColor; +#define FRAG fragColor +#define TEXTURE(s, uv) texture(s, uv) +' + end + end + + # A single post-processing pass: name + fragment-shader body + toggle state. + # The shader is compiled ONCE at construction (uniform locations cached); + # toggling `enabled` only changes whether it runs in the per-frame chain. + class Pass + attr_accessor :enabled, :intensity, :extra_uniforms, :suppress + attr_reader :name, :shader + + # name — human label (introspection / eval-bridge access) + # body — GLSL fragment body (NO #version; uses TEXTURE()/FRAG macros) + # intensity — 0..1, bound to a `uniform float intensity` if the body has one + # extra_uniforms — optional {name => value} of extra FLOAT uniforms (cached at + # load, set per frame); used by multi-knob shaders like FXAA + # (subpix / edgeThreshold / edgeThresholdMin). Set values at + # runtime (e.g. from a slider) — no shader recompilation. + def initialize(name, body, intensity: 1.0, extra_uniforms: {}) + @name = name + @enabled = true + @suppress = false + @intensity = intensity + @extra_uniforms = extra_uniforms.dup + src = Jamstack::FX.header + body + @shader = Rl.load_shader_from_memory(nil, src) + # Cache uniform locations at load time (never query per frame). raylib + # returns -1 when the uniform is absent / optimized away; guard before set. + @loc_intensity = Rl.get_shader_location(@shader, "intensity") + @loc_time = Rl.get_shader_location(@shader, "time") + @loc_resolution = Rl.get_shader_location(@shader, "resolution") + # Cache extra-uniform locations once (names fixed at construction). + @extra_locs = {} + @extra_uniforms.each_key { |n| @extra_locs[n] = Rl.get_shader_location(@shader, n.to_s) } + end + + # Render `src_texture` (an Rl::Texture) into `dst_target` (an + # Rl::RenderTexture) through this pass's shader. Y-flip is mandatory on the + # source rect (OpenGL bottom-left origin). `scene_texture` is the original + # pre-chain render (for multi-pass effects that need the unfiltered scene, + # e.g. a bloom composite); nil when not needed. + def apply(src_texture, dst_target, t, _scene_texture = nil) + w = src_texture.width + h = src_texture.height + Rl.texture_mode(dst_target) do + Rl.clear_background(Rl::BLACK) + Rl.shader_mode(@shader) do + _set_uniform(@loc_intensity, @intensity, Rl::SHADER_UNIFORM_FLOAT) + _set_uniform(@loc_time, t, Rl::SHADER_UNIFORM_FLOAT) + _set_uniform(@loc_resolution, [w, h], Rl::SHADER_UNIFORM_VEC2) + @extra_uniforms.each { |n, v| _set_uniform(@extra_locs[n], v, Rl::SHADER_UNIFORM_FLOAT) } + Rl.draw_texture_pro( + texture: src_texture, + source: Rl::Rectangle.new(0, 0, w, -h), # negative height = y-flip + dest: Rl::Rectangle.new(0, 0, w, h), + tint: Rl::WHITE + ) + end + end + end + + def _set_uniform(loc, value, type) + return if loc.nil? || loc < 0 + + Rl.set_shader_value(@shader, loc, value, type) + end + end + + # Owns the four RenderTextures (game + composite ping-pong pairs) and the + # two shader lists. Render textures are allocated ONCE (FBO creation is + # expensive + leaks if per-frame); G_a carries a depth attachment by default + # (load_render_texture makes color+depth) for the 3D world. + class Pipeline + attr_reader :game_shaders, :top_shaders, :w, :h, :g, :c + + def initialize(w, h) + @w = w + @h = h + @g = [Rl.load_render_texture(w, h), Rl.load_render_texture(w, h)] # game ping-pong + @c = [Rl.load_render_texture(w, h), Rl.load_render_texture(w, h)] # composite ping-pong + # BILINEAR on every render texture: FXAA (top stage) REQUIRES sub-pixel + # bilinear sampling to blend edges; harmless to the other passes. Set once. + (@g + @c).each { |rt| Rl.set_texture_filter(rt.texture, Rl::TEXTURE_FILTER_BILINEAR) } + @game_shaders = [] + @top_shaders = [] + end + + # Per-frame entry: yields a Frame the block fills via game_layer/overlay_layer. + def frame(t) + yield Frame.new(self, t) + end + + # Run the enabled passes in `passes` ping-pong over the [a,b] pair, starting + # from `pair[0]` (already rendered into). Returns the last-written target. + # No enabled passes => pass-through (pair[0] itself, no extra copy). + # The pre-chain `pair[0].texture` is handed to each pass as the `scene` + # (original unfiltered render) for multi-pass effects that need it. + def apply_chain(passes, pair, t) + enabled = passes.select { |p| p.enabled && !p.suppress } + return pair[0] if enabled.empty? + + prev = pair[0] + cur = pair[1] + scene = pair[0].texture + enabled.each do |p| + p.apply(prev.texture, cur, t, scene) + prev, cur = cur, prev + end + prev + end + end + + # A per-frame builder the Pipeline#frame block receives. game_layer renders + # the game (+ in-world UI) into G_a and runs the game-shader chain; overlay_layer + # composites the processed game + overlay HUD into C_a (one FBO so top shaders + # filter both) and runs the top-shader chain, then blits to screen. + class Frame + attr_reader :g_final + + def initialize(pipeline, t) + @p = pipeline + @t = t + end + + # Render the 3D world + in-world RmlUi here (drawn into G_a). After the + # block, the game-shader chain ping-pongs over G_a<->G_b -> g_final. + def game_layer + Rl.texture_mode(@p.g[0]) { yield } + @g_final = @p.apply_chain(@p.game_shaders, @p.g, @t) + end + + # Render the overlay HUD here. The processed-game quad is composited into + # C_a FIRST (y-flipped), then this block draws the overlay HUD on top — + # both in the SAME texture_mode(C_a) block so top shaders filter both. + # After the block, the top-shader chain ping-pongs over C_a<->C_b -> c_final, + # which is blitted to the screen. + def overlay_layer + Rl.texture_mode(@p.c[0]) do + Rl.clear_background(Rl::BLACK) + Rl.draw_texture_pro( + texture: @g_final.texture, + source: Rl::Rectangle.new(0, 0, @p.w, [email protected]), # y-flip + dest: Rl::Rectangle.new(0, 0, @p.w, @p.h), + tint: Rl::WHITE + ) + yield # overlay HUD (top_ui.update; top_ui.render) + end + c_final = @p.apply_chain(@p.top_shaders, @p.c, @t) + Rl.draw(clear_color: Rl::BLACK) do + Rl.draw_texture_pro( + texture: c_final.texture, + source: Rl::Rectangle.new(0, 0, @p.w, [email protected]), # y-flip + dest: Rl::Rectangle.new(0, 0, @p.w, @p.h), + tint: Rl::WHITE + ) + end + end + end + + # ------------------------------------------------------------------ shader bodies + # Each body uses the TEXTURE(s,uv) / FRAG macros (see HEADER) so one source + # serves #version 100 (web) and #version 330 (desktop). `fragTexCoord` is + # 0..1 across the fullscreen quad. Declare `uniform float intensity;` to get + # a runtime-fadeable mix; `uniform float time;` / `uniform vec2 resolution;` + # are also auto-bound by Pass if present. + + GRAYSCALE = ' +uniform float intensity; +void main() { + vec4 c = TEXTURE(texture0, fragTexCoord); + float g = dot(c.rgb, vec3(0.299, 0.587, 0.114)); + FRAG = mix(c, vec4(g, g, g, c.a), intensity); +} +' + + INVERT = ' +uniform float intensity; +void main() { + vec4 c = TEXTURE(texture0, fragTexCoord); + FRAG = mix(c, vec4(1.0 - c.rgb, c.a), intensity); +} +' + + SCANLINES = ' +uniform float intensity; +uniform vec2 resolution; +void main() { + vec4 c = TEXTURE(texture0, fragTexCoord); + float lines = resolution.y * 0.75; + float s = 0.6 + 0.4 * step(0.5, fract(fragTexCoord.y * lines)); + FRAG = vec4(c.rgb * mix(1.0, s, intensity), c.a); +} +' + + VIGNETTE = ' +uniform float intensity; +void main() { + vec4 c = TEXTURE(texture0, fragTexCoord); + float d = distance(fragTexCoord, vec2(0.5, 0.5)); + float v = smoothstep(0.35, 0.75, d); + FRAG = vec4(c.rgb * (1.0 - v * intensity), c.a); +} +' + + # Warm cinematic color grade (lift shadows toward blue, push highlights warm). + COLORGRADE = ' +uniform float intensity; +void main() { + vec4 c = TEXTURE(texture0, fragTexCoord); + vec3 graded = c.rgb; + graded = mix(graded, graded * vec3(1.06, 1.02, 0.92) + vec3(0.02, 0.01, 0.0), intensity); + graded = mix(graded, graded + vec3(0.0, 0.0, 0.04) * (1.0 - c.rgb), intensity); + FRAG = vec4(graded, c.a); +} +' + + # CRT broken into independent, toggleable components (chain them as separate + # game-stage passes: warp -> aberration -> scanlines). Each is a standalone + # transform of its input, so any subset can be enabled. + + # Barrel distortion: pull corner texels toward the centre (classic CRT bulge). + # Out-of-bounds samples go black (the CRT bezel edge). + WARP = ' +uniform float intensity; +void main() { + vec2 uv = fragTexCoord; + vec2 cc = uv - vec2(0.5, 0.5); + float dist = dot(cc, cc); + uv += cc * dist * 0.22 * intensity; + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { + FRAG = vec4(0.0, 0.0, 0.0, 1.0); + } else { + FRAG = TEXTURE(texture0, uv); + } +} +' + + # Chromatic aberration ("colour shift"): sample R/G/B at horizontally offset + # uvs and blend toward the clean sample by intensity (0 = identity). + ABERRATION = ' +uniform float intensity; +void main() { + vec2 uv = fragTexCoord; + float ca = 0.006 * intensity; + float r = TEXTURE(texture0, uv + vec2(ca, 0.0)).r; + float g = TEXTURE(texture0, uv).g; + float b = TEXTURE(texture0, uv - vec2(ca, 0.0)).b; + vec4 c = TEXTURE(texture0, uv); + FRAG = mix(c, vec4(r, g, b, c.a), intensity); +} +' + + ABERRATION_CMY = ' +uniform float intensity; +void main() { + vec2 uv = fragTexCoord; + float ca = 0.006 * intensity; + vec3 cen = TEXTURE(texture0, uv).rgb; + vec3 sC = TEXTURE(texture0, uv + vec2(ca, 0.0)).rgb; // cyan plate (right) + vec3 sM = TEXTURE(texture0, uv + vec2(0.0, ca)).rgb; // magenta plate (down) + vec3 sY = TEXTURE(texture0, uv - vec2(ca, 0.0)).rgb; // yellow plate (left) + // RGB -> CMY (C=1-R, M=1-G, Y=1-B). Subtractive overprint: only the + // MISREGISTERED ink (centre minus offset plate, clamped >= 0) absorbs its + // complementary channel -- cyan eats red, magenta eats green, yellow eats + // blue. Flat regions are untouched; at edges the complements (cyan/magenta/ + // yellow) appear as DARK fringes (vs the additive RGB split BRIGHT red/blue + // fringes). This is a genuine subtractive colourspace op, not the no-op + // 1-R then 1-C=R complement round-trip. + float cy = clamp(cen.r - sC.r, 0.0, 1.0); + float mg = clamp(cen.g - sM.g, 0.0, 1.0); + float yl = clamp(cen.b - sY.b, 0.0, 1.0); + vec3 shifted = vec3(cen.r * (1.0 - cy), + cen.g * (1.0 - mg), + cen.b * (1.0 - yl)); + FRAG = mix(vec4(cen, 1.0), vec4(shifted, 1.0), intensity); +} +' + + # CRT (all-in-one): barrel distortion + chromatic aberration + scanlines. A + # clear gameplay effect (would wreck HUD text -> belongs in the GAME stage). + # Prefer the split WARP / ABERRATION / SCANLINES passes for per-component + # toggling; this kept as a one-shot convenience. + CRT = ' +uniform float intensity; +uniform vec2 resolution; +uniform float time; +void main() { + vec2 uv = fragTexCoord; + vec2 cc = uv - vec2(0.5, 0.5); + float dist = dot(cc, cc); + uv += cc * dist * 0.22 * intensity; // barrel distortion + vec4 c; + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { + c = vec4(0.0, 0.0, 0.0, 1.0); + } else { + float ca = 0.004 * intensity; // chromatic aberration + float r = TEXTURE(texture0, uv + vec2(ca, 0.0)).r; + float g = TEXTURE(texture0, uv).g; + float b = TEXTURE(texture0, uv - vec2(ca, 0.0)).b; + c = vec4(r, g, b, 1.0); + } + float scan = 0.88 + 0.12 * sin(fragTexCoord.y * resolution.y * 3.14159); + FRAG = vec4(c.rgb * mix(1.0, scan, intensity), 1.0); +} +' + # FXAA 3.11 Quality (NVIDIA / Timothy Lottes), preset 12 (5 edge-search + # samples — the recommended default: fast + good). Ported to the macro shim. + # TOP-stage effect (anti-aliases the whole frame incl HUD). + # + # Uses GREEN as luma (FXAA_GREEN_AS_LUMA): our RGBA8 render targets have + # uniform alpha=1, so luma-from-alpha would detect no edges. Caveat: pure + # red/blue edges (no green) get no AA — see .agents/knowledge/fx-pipeline.md + # ("GREEN_AS_LUMA + the missing-luma caveat", option B: luma-pack pre-pass). + # + # Three RUNTIME float quality knobs (no recompilation): bind via extra_uniforms. + # subpix 0.0..1.0 (0.75 default) subpixel AA amount + # edgeThreshold 0.063..0.333 (0.166 default) min contrast to count as an edge + # edgeThresholdMin 0.0312..0.0833 (0.0833 default) dark-trim + # REQUIRES bilinear filtering on the input (Pipeline sets BILINEAR on all RTs). + # rcpFrame is derived from the `resolution` uniform (no extra uniform needed). + FXAA = ' +uniform vec2 resolution; +uniform float subpix; +uniform float edgeThreshold; +uniform float edgeThresholdMin; + +float FxaaLuma(vec4 rgba) { return rgba.g; } // GREEN_AS_LUMA + +void main() { + vec2 pos = fragTexCoord; + vec2 rcpFrame = vec2(1.0) / resolution; + + vec4 rgbyM = TEXTURE(texture0, pos); + float lumaM = FxaaLuma(rgbyM); + float lumaS = FxaaLuma(TEXTURE(texture0, pos + vec2(0.0, rcpFrame.y))); + float lumaE = FxaaLuma(TEXTURE(texture0, pos + vec2( rcpFrame.x, 0.0))); + float lumaN = FxaaLuma(TEXTURE(texture0, pos + vec2(0.0, -rcpFrame.y))); + float lumaW = FxaaLuma(TEXTURE(texture0, pos + vec2(-rcpFrame.x, 0.0))); + + float maxSM = max(lumaS, lumaM); + float minSM = min(lumaS, lumaM); + float maxESM = max(lumaE, maxSM); + float minESM = min(lumaE, minSM); + float maxWN = max(lumaN, lumaW); + float minWN = min(lumaN, lumaW); + float rangeMax = max(maxWN, maxESM); + float rangeMin = min(minWN, minESM); + float rangeMaxScaled = rangeMax * edgeThreshold; + float range = rangeMax - rangeMin; + float rangeMaxClamped = max(edgeThresholdMin, rangeMaxScaled); + if (range < rangeMaxClamped) { FRAG = rgbyM; return; } // early exit (no edge) + + float lumaNW = FxaaLuma(TEXTURE(texture0, pos + vec2(-rcpFrame.x, -rcpFrame.y))); + float lumaSE = FxaaLuma(TEXTURE(texture0, pos + vec2( rcpFrame.x, rcpFrame.y))); + float lumaNE = FxaaLuma(TEXTURE(texture0, pos + vec2( rcpFrame.x, -rcpFrame.y))); + float lumaSW = FxaaLuma(TEXTURE(texture0, pos + vec2(-rcpFrame.x, rcpFrame.y))); + + float lumaNS = lumaN + lumaS; + float lumaWE = lumaW + lumaE; + float subpixRcpRange = 1.0 / range; + float subpixNSWE = lumaNS + lumaWE; + float edgeHorz1 = (-2.0 * lumaM) + lumaNS; + float edgeVert1 = (-2.0 * lumaM) + lumaWE; + float lumaNESE = lumaNE + lumaSE; + float lumaNWNE = lumaNW + lumaNE; + float edgeHorz2 = (-2.0 * lumaE) + lumaNESE; + float edgeVert2 = (-2.0 * lumaN) + lumaNWNE; + float lumaNWSW = lumaNW + lumaSW; + float lumaSWSE = lumaSW + lumaSE; + float edgeHorz4 = (abs(edgeHorz1) * 2.0) + abs(edgeHorz2); + float edgeVert4 = (abs(edgeVert1) * 2.0) + abs(edgeVert2); + float edgeHorz3 = (-2.0 * lumaW) + lumaNWSW; + float edgeVert3 = (-2.0 * lumaS) + lumaSWSE; + float edgeHorz = abs(edgeHorz3) + edgeHorz4; + float edgeVert = abs(edgeVert3) + edgeVert4; + float subpixNWSWNESE = lumaNWSW + lumaNESE; + float lengthSign = rcpFrame.x; + bool horzSpan = edgeHorz >= edgeVert; + float subpixA = subpixNSWE * 2.0 + subpixNWSWNESE; + if (!horzSpan) lumaN = lumaW; + if (!horzSpan) lumaS = lumaE; + if (horzSpan) lengthSign = rcpFrame.y; + float subpixB = (subpixA * (1.0 / 12.0)) - lumaM; + + float gradientN = lumaN - lumaM; + float gradientS = lumaS - lumaM; + float lumaNN = lumaN + lumaM; + bool pairN = abs(gradientN) >= abs(gradientS); + float gradient = max(abs(gradientN), abs(gradientS)); + if (pairN) lengthSign = -lengthSign; + float subpixC = clamp(abs(subpixB) * subpixRcpRange, 0.0, 1.0); + + vec2 posB = pos; + vec2 offNP; + offNP.x = (!horzSpan) ? 0.0 : rcpFrame.x; + offNP.y = ( horzSpan) ? 0.0 : rcpFrame.y; + if (!horzSpan) posB.x += lengthSign * 0.5; + if ( horzSpan) posB.y += lengthSign * 0.5; + + // preset 12 edge search: P0=1.0 P1=1.5 P2=2.0 P3=4.0 P4=12.0 + vec2 posN = posB - offNP * 1.0; + vec2 posP = posB + offNP * 1.0; + float subpixD = ((-2.0) * subpixC) + 3.0; + float lumaEndN = FxaaLuma(TEXTURE(texture0, posN)); + float subpixE = subpixC * subpixC; + float lumaEndP = FxaaLuma(TEXTURE(texture0, posP)); + + if (!pairN) lumaNN = lumaS + lumaM; + float gradientScaled = gradient * 1.0 / 4.0; + float lumaMM = lumaM - lumaNN * 0.5; + float subpixF = subpixD * subpixE; + bool lumaMLTZero = lumaMM < 0.0; + + lumaEndN -= lumaNN * 0.5; + lumaEndP -= lumaNN * 0.5; + bool doneN = abs(lumaEndN) >= gradientScaled; + bool doneP = abs(lumaEndP) >= gradientScaled; + if (!doneN) posN -= offNP * 1.5; + bool doneNP = (!doneN) || (!doneP); + if (!doneP) posP += offNP * 1.5; + + if (doneNP) { + if (!doneN) lumaEndN = FxaaLuma(TEXTURE(texture0, posN)); + if (!doneP) lumaEndP = FxaaLuma(TEXTURE(texture0, posP)); + if (!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if (!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if (!doneN) posN -= offNP * 2.0; + doneNP = (!doneN) || (!doneP); + if (!doneP) posP += offNP * 2.0; + + if (doneNP) { + if (!doneN) lumaEndN = FxaaLuma(TEXTURE(texture0, posN)); + if (!doneP) lumaEndP = FxaaLuma(TEXTURE(texture0, posP)); + if (!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if (!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if (!doneN) posN -= offNP * 4.0; + doneNP = (!doneN) || (!doneP); + if (!doneP) posP += offNP * 4.0; + + if (doneNP) { + if (!doneN) lumaEndN = FxaaLuma(TEXTURE(texture0, posN)); + if (!doneP) lumaEndP = FxaaLuma(TEXTURE(texture0, posP)); + if (!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if (!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if (!doneN) posN -= offNP * 12.0; + if (!doneP) posP += offNP * 12.0; + } + } + } + + float dstN = pos.x - posN.x; + float dstP = posP.x - pos.x; + if (!horzSpan) dstN = pos.y - posN.y; + if (!horzSpan) dstP = posP.y - pos.y; + + bool goodSpanN = (lumaEndN < 0.0) != lumaMLTZero; + float spanLength = (dstP + dstN); + bool goodSpanP = (lumaEndP < 0.0) != lumaMLTZero; + float spanLengthRcp = 1.0 / spanLength; + + bool directionN = dstN < dstP; + float dst = min(dstN, dstP); + bool goodSpan = directionN ? goodSpanN : goodSpanP; + float subpixG = subpixF * subpixF; + float pixelOffset = (dst * (-spanLengthRcp)) + 0.5; + float subpixH = subpixG * subpix; + + float pixelOffsetGood = goodSpan ? pixelOffset : 0.0; + float pixelOffsetSubpix = max(pixelOffsetGood, subpixH); + if (!horzSpan) pos.x += pixelOffsetSubpix * lengthSign; + if ( horzSpan) pos.y += pixelOffsetSubpix * lengthSign; + + FRAG = vec4(TEXTURE(texture0, pos).rgb, rgbyM.a); +} +' + end +end diff --git a/mrbgems/raylib/mrblib/html.rb b/mrbgems/raylib/mrblib/html.rb new file mode 100644 index 0000000..faac38e --- /dev/null +++ b/mrbgems/raylib/mrblib/html.rb @@ -0,0 +1,77 @@ +module Jamstack + module HTML + class << self + def scale=(mode) + mode = mode.to_sym if mode.is_a?(String) + raise ArgumentError, "scale must be :stretch or :native" unless [:stretch, :native].include?(mode) + Jamstack.eval_js("jamstackSetSize(\"#{mode}\")") + @scale = mode + end + + def scale + @scale || :stretch + end + + def render=(mode) + mode = mode.to_sym if mode.is_a?(String) + raise ArgumentError, "render must be :auto, :pixelated, or :crisp_edges" unless [:auto, :pixelated, :crisp_edges].include?(mode) + js_mode = mode == :crisp_edges ? "crisp-edges" : mode.to_s + Jamstack.eval_js("jamstackSetRendering(\"#{js_mode}\")") + @render = mode + end + + def render + @render || :auto + end + + def toggle_fullscreen + Jamstack.eval_js("if(document.fullscreenElement){document.exitFullscreen()}else{document.documentElement.requestFullscreen()}") + end + + def title=(t) + Jamstack.eval_js("document.title=#{t.to_s.inspect}") + end + end + end + + def self.fonttest + puts "=== Font Test ===" + puts "" + puts "--- ASCII ---" + puts "abcdefghijklmnopqrstuvwxyz" + puts "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + puts "0123456789 !@#$%^&*()_+-=[]{}|;:',.<>?/`~" + puts "" + puts "--- Box Drawing ---" + puts "+--+--+--+" + puts "| | | |" + puts "+--+--+--+" + puts "" + puts "--- Arrows ---" + puts "<- -> ^ v" + puts "" + puts "--- Nerd Font Icons ---" + puts "Powerline: \uE0B0 \uE0B2 \uE0B1 \uE0B3" + puts "Git: \uE0A0 \uE0A2 \uE0A3" + puts "Misc: \uF004 \uF005 \uF00C \uF00D \uF013 \uF017" + puts "OS: \uF179 \uF17C \uE70F" + puts "Devicons: \uE739 \uE74E \uE73C \uE7A8" + puts "" + puts "--- Standard Emoji ---" + puts "Faces: \U0001F602 \U0001F923 \U0001F604 \U0001F60D \U0001F622" + puts "Hands: \U0001F44D \U0001F44E \U0001F44C \U0001F450" + puts "Objects: \U0001F525 \U0001F4A5 \U0001F4A9 \U0001F389 \U0001F680" + puts "Animals: \U0001F431 \U0001F436 \U0001F98A \U0001F414" + puts "" + puts "--- Long line (wrapping test) ---" + puts "The quick brown fox jumps over the lazy dog. " * 4 + puts "" + puts "--- Scroll test (30 lines) ---" + 30.times do |i| + puts "Line #{i + 1}: The quick brown fox jumps over the lazy dog 1234567890" + end + puts "" + puts "=== End of Font Test ===" + "done" + end +end diff --git a/mrbgems/raylib/mrblib/jamstack_json.rb b/mrbgems/raylib/mrblib/jamstack_json.rb new file mode 100644 index 0000000..ff96e48 --- /dev/null +++ b/mrbgems/raylib/mrblib/jamstack_json.rb @@ -0,0 +1,65 @@ +# Minimal JSON encoder (this mruby gembox has no JSON gem). Shared by the agent +# bridge (R1) and the Log pipeline (R2). Handles the value types we actually emit: +# nil/true/false, Integer, Float (non-finite -> null, since NaN/Infinity are not +# valid JSON), String, Symbol, Array, Hash. Anything else falls back to its to_s. +# Byte-wise string escaping: bytes >= 0x20 pass through, so UTF-8 multibyte +# sequences (all bytes >= 0x80) are preserved. +module Jamstack + module JSON + class << self + def generate(o) + s = "" + emit(o, s) + s + end + + private + + def emit(o, s) + case o + when nil then s << 'null' + when true then s << 'true' + when false then s << 'false' + when Integer then s << o.to_s + when Float then s << (o.finite? ? o.to_s : 'null') + when String then estr(o, s) + when Symbol then estr(o.to_s, s) + when Array + s << '[' + first = true + o.each { |e| s << ',' unless first; first = false; emit(e, s) } + s << ']' + when Hash + s << '{' + first = true + o.each do |k, v| + s << ',' unless first + first = false + estr(k.to_s, s) + s << ':' + emit(v, s) + end + s << '}' + else + estr(o.to_s, s) + end + end + + def estr(str, s) + s << '"' + str.each_byte do |b| + case b + when 34 then s << '\\"' + when 92 then s << '\\\\' + when 10 then s << '\\n' + when 9 then s << '\\t' + when 13 then s << '\\r' + else + s << (b < 0x20 ? format('\\u%04x', b) : b.chr) + end + end + s << '"' + end + end + end +end diff --git a/mrbgems/raylib/mrblib/live.rb b/mrbgems/raylib/mrblib/live.rb new file mode 100644 index 0000000..01f1854 --- /dev/null +++ b/mrbgems/raylib/mrblib/live.rb @@ -0,0 +1,193 @@ +# Jamstack::Live (R4, desktop slice): a read-mostly .live/<token>/ file mount the +# game writes itself — no relay, no WS. A file-based agent drops raw-Ruby command +# files and reads JSON results; status + the game-console are written for tailing. +# Drained in-frame on the main thread (P6), reusing Bridge.eval_code (R1). +# Gate: JAMSTACK_BRIDGE=1. Token: JAMSTACK_LIVE (default "dev"), root: +# JAMSTACK_LIVE_ROOT (default ".live"). See .agents/knowledge/live-mount.md. +# +# Protocol (no JSON parser in mruby): cmd files carry RAW Ruby, id is the filename; +# only results are JSON. +# .live/dev/.agent/cmd-<id>.rb (agent writes; atomic: .tmp then rename) +# .live/dev/.agent/result-<id>.json (game writes {id,ok,result,stdout,error,backtrace}) +module Jamstack + module Live + STATUS_EVERY = 30 # frames between status.json rewrites (~0.5s @ 60fps) + + EVAL_SH = [ + '#!/bin/sh', + '# Run Ruby in the live game; prints the JSON result envelope.', + '# usage: sh bin/eval "Rl.get_fps"', + 'ag="$(cd "$(dirname "$0")/.." && pwd)/.agent"', + 'id="p$$_$(date +%s%N 2>/dev/null || date +%s)"', + 'printf "%s" "$1" > "$ag/.tmp-$id"', + 'mv "$ag/.tmp-$id" "$ag/cmd-$id.rb"', + 'i=0', + 'while [ $i -lt 250 ]; do', + ' if [ -f "$ag/result-$id.json" ]; then', + ' cat "$ag/result-$id.json"; echo; rm -f "$ag/result-$id.json"; exit 0', + ' fi', + ' i=$((i + 1)); sleep 0.02', + 'done', + 'echo "{\"ok\":false,\"error\":\"timeout waiting for result\"}" >&2; exit 1', + ].join("\n") + "\n" + + TAIL_SH = [ + '#!/bin/sh', + '# Stream the game-console (NDJSON). usage: sh bin/tail-log [N]', + 'd="$(cd "$(dirname "$0")/.." && pwd)"', + 'exec tail -n "${1:-40}" -f "$d/game-console"', + ].join("\n") + "\n" + + HOT_SH = [ + '#!/bin/sh', + '# Hot-reload a systems file. usage: sh bin/hot-reload game/systems/move.rb', + 'exec "$(dirname "$0")/eval" "Flecs::Hot.reload_file(\"$1\")"', + ].join("\n") + "\n" + + SNAP_SH = [ + '#!/bin/sh', + '# Dump the flecs world state to state.json and print the JSON.', + '# usage: sh bin/snapshot (requires $flecs.enable_rest in game code)', + 'exec "$(dirname "$0")/eval" \'Jamstack::Live.snapshot\'', + ].join("\n") + "\n" + + QUERY_SH = [ + '#!/bin/sh', + '# Query the flecs world. usage: sh bin/query Position', + '# (requires $flecs.enable_rest in game code; expr is a flecs query expr)', + 'exec "$(dirname "$0")/eval" "Flecs::Hot.world.rest_request(\"GET\",\"/query?expr=$1&values=true\",\"\")"', + ].join("\n") + "\n" + + @active = false + @dir = nil + @agent = nil + @throttle = 0 + + class << self + def active?; @active; end + def dir; @dir; end + + def start + return if @active + token = nonempty(Jamstack.getenv('JAMSTACK_LIVE'), 'dev') + root = nonempty(Jamstack.getenv('JAMSTACK_LIVE_ROOT'), '.live') + @dir = File.join(root, token) + @agent = File.join(@dir, '.agent') + mkdir_p(@agent) + mkdir_p(File.join(@dir, 'bin')) + clean_agent + write_bin_scripts + Jamstack::Log.open_file(File.join(@dir, 'game-console')) + @active = true + write_status + Jamstack::Log.info("live mount ready", tag: "live", dir: @dir) + rescue Exception => e + @active = false + Jamstack::Log.exception(e, tag: "live") + end + + # Per frame, after Bridge.drain: run any queued command files, refresh status. + def poll + return unless @active + process_commands + @throttle += 1 + if @throttle >= STATUS_EVERY + @throttle = 0 + write_status + end + end + + # Dump the flecs world state to state.json (called by bin/snapshot). + # Uses the flecs REST API in-process (requires enable_rest on the world). + def snapshot + world = Flecs::Hot.world + return nil unless world + json = world.rest_request("GET", "/world", "") + write_state(json) + json + rescue Exception => e + Jamstack::Log.exception(e, tag: "live") + nil + end + + private + + def nonempty(v, default); (v && !v.empty?) ? v : default; end + def now; Time.now.to_f; rescue StandardError; 0.0; end + + def process_commands + entries = (Dir.entries(@agent) rescue []) + entries.select { |f| f.start_with?("cmd-") }.sort.each do |f| + id = f[4..-1] # after "cmd-" + id = id[0...-3] if id.end_with?(".rb") + path = File.join(@agent, f) + code = (File.read(path) rescue nil) + delete(path) # consume once + next if code.nil? + env = Jamstack::Bridge.eval_code(code) + write_atomic(File.join(@agent, "result-#{id}.json"), + Jamstack::JSON.generate( + "id" => id, "ok" => env[:ok], "result" => env[:result], + "stdout" => env[:stdout], "error" => env[:error], + "backtrace" => env[:backtrace])) + end + end + + def write_status + write_atomic(File.join(@dir, 'status.json'), + Jamstack::JSON.generate( + "connected" => true, + "target" => (Rl.web? ? "web" : "desktop"), + "token" => File.basename(@dir), + "frame" => Jamstack::Log.frame, + "fps" => (Rl.get_fps rescue 0), + "ts" => now)) + end + + def write_state(json) + write_atomic(File.join(@dir, 'state.json'), json) + end + + def write_bin_scripts + bin = File.join(@dir, 'bin') + { 'eval' => EVAL_SH, 'tail-log' => TAIL_SH, 'hot-reload' => HOT_SH, + 'snapshot' => SNAP_SH, 'query' => QUERY_SH }.each do |name, body| + p = File.join(bin, name) + File.open(p, 'w') { |fh| fh.write(body) } + (File.chmod(0755, p) rescue nil) + end + end + + def clean_agent + (Dir.entries(@agent) rescue []).each do |f| + next if f == "." || f == ".." + delete(File.join(@agent, f)) if f.start_with?("cmd-") || f.start_with?("result-") || f.start_with?(".tmp") + end + end + + # recursive mkdir (mruby has no mkdir -p) + def mkdir_p(path) + acc = path.start_with?("/") ? "" : nil + path.split("/").each do |part| + next if part.empty? + acc = acc.nil? ? part : "#{acc}/#{part}" + (Dir.mkdir(acc) unless File.directory?(acc)) rescue nil + end + end + + def write_atomic(path, str) + tmp = "#{path}.tmp" + File.open(tmp, 'w') { |fh| fh.write(str) } + File.rename(tmp, path) + rescue Exception => e + Jamstack::Log.exception(e, tag: "live", path: path.to_s) rescue nil + end + + def delete(path) + File.delete(path) + rescue StandardError + (File.unlink(path) rescue nil) + end + end + end +end diff --git a/mrbgems/raylib/mrblib/log.rb b/mrbgems/raylib/mrblib/log.rb new file mode 100644 index 0000000..2bf6742 --- /dev/null +++ b/mrbgems/raylib/mrblib/log.rb @@ -0,0 +1,125 @@ +# Jamstack structured logging (R2): leveled, structured (NDJSON) records with a +# monotonic frame counter, an in-memory ring buffer the agent can query over the +# bridge (Log.tail / Log.grep), and stdout + optional file sinks. This is the +# "game-console" stream (Ruby/engine intent). The browser-console (web platform +# console) is deferred (see roadmap R2/R4). +# +# Usage: +# Jamstack::Log.info("spawned", tag: "spawn", entity: id) +# Jamstack::Log.error("bad state", entity: id) +# Jamstack::Log.exception(e, tag: "physics") +# Jamstack::Log.tail(50) # last N records (Array of Hashes) +# Jamstack::Log.grep("physics") # substring match (no Regexp in this gembox) +# Jamstack::Log.tail_ndjson(50) # last N as an NDJSON string (for the agent) +# +# Env (read at setup, via Jamstack.getenv): JAMSTACK_LOG=<path> (file sink), +# JAMSTACK_LOG_LEVEL=debug|info|warn|error (min level). +module Jamstack + module Log + LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } + + @level = :debug + @frame = 0 + @ring = [] + @ring_max = 1000 + @to_stdout = true + @file = nil + @setup_done = false + + class << self + attr_accessor :ring_max, :to_stdout + attr_reader :level, :frame + + def level=(sym) + @level = sym.to_sym if LEVELS.key?(sym.to_sym) + end + + def setup + return if @setup_done + @setup_done = true + lvl = Jamstack.getenv('JAMSTACK_LOG_LEVEL') + self.level = lvl if lvl && !lvl.empty? + path = Jamstack.getenv('JAMSTACK_LOG') + open_file(path) if path && !path.empty? + end + + def open_file(path) + @file = File.open(path, 'a') + rescue StandardError + @file = nil + end + + def tick!; @frame += 1; end + + def log(level, msg = nil, fields = {}) + return nil if LEVELS[level].to_i < LEVELS[@level].to_i + rec = build(level, msg, fields) + sink(Jamstack::JSON.generate(rec)) # render BEFORE pushing (cheap, ordered) + push(rec) + rec + end + + def debug(msg = nil, fields = {}); log(:debug, msg, fields); end + def info(msg = nil, fields = {}); log(:info, msg, fields); end + def warn(msg = nil, fields = {}); log(:warn, msg, fields); end + def error(msg = nil, fields = {}); log(:error, msg, fields); end + + # Capture an exception as an error record with class + backtrace. + def exception(e, fields = {}) + f = { error: "#{e.class}: #{e.message}", backtrace: (e.backtrace || []) } + f.merge!(fields) + log(:error, e.message, f) + end + + # --- agent query surface (read the ring without a file) --- + def tail(n = 50); @ring.last(n); end + + def grep(substr, n = 200) + s = substr.to_s + @ring.select { |r| Jamstack::JSON.generate(r).include?(s) }.last(n) + end + + def tail_ndjson(n = 50) + tail(n).map { |r| Jamstack::JSON.generate(r) }.join("\n") + end + + def clear!; @ring = []; end + + private + + def build(level, msg, fields) + rec = {} + rec["ts"] = now + rec["frame"] = @frame + rec["level"] = level.to_s + rec["msg"] = msg.to_s unless msg.nil? + fields.each { |k, v| rec[k.to_s] = v } + rec + end + + def now + Time.now.to_f + rescue StandardError + 0.0 + end + + def push(rec) + @ring << rec + @ring.shift while @ring.length > @ring_max + end + + # NOTE: stdout writes to C fd 1. If a log is emitted *during* a bridge eval + # (while fd 1 is redirected for stdout capture), the line lands in that + # eval's captured stdout instead of the console — the ring buffer still + # records it, which is the canonical query path. See agent-bridge.md. + def sink(line) + puts line if @to_stdout + if @file + @file.write(line) + @file.write("\n") + @file.flush + end + end + end + end +end diff --git a/mrbgems/raylib/mrblib/raylib.rb b/mrbgems/raylib/mrblib/raylib.rb new file mode 100644 index 0000000..fee49f5 --- /dev/null +++ b/mrbgems/raylib/mrblib/raylib.rb @@ -0,0 +1,173 @@ +# Friendly Ruby sugar layered over the generated raylib bindings (see +# tools/gen_raylib.rb). The generated API is the complete, positional surface +# (e.g. Rl.draw_text(text, x, y, size, color), Rl.init_window(w, h, title), all +# the Rl::Color / Rl::Vector2 / ... structs, Rl::KEY_* constants, etc.). +# +# This file adds the niceties from docs/API_SPEC.md: block-scoped Begin/End +# pairs, the web-safe main loop, keyword-arg helpers for a few common calls, +# symbol keys, and convenience aliases. + +module Rl + # Symbol -> keycode (letters/digits map to ASCII; named keys to KEY_* consts). + SYMBOL_KEYS = {} + ('a'..'z').each { |c| SYMBOL_KEYS[c.to_sym] = c.upcase.ord } + ('0'..'9').each { |c| SYMBOL_KEYS[c.to_sym] = c.ord } + { + space: :KEY_SPACE, enter: :KEY_ENTER, escape: :KEY_ESCAPE, tab: :KEY_TAB, + backspace: :KEY_BACKSPACE, up: :KEY_UP, down: :KEY_DOWN, left: :KEY_LEFT, + right: :KEY_RIGHT, left_shift: :KEY_LEFT_SHIFT, left_control: :KEY_LEFT_CONTROL + }.each { |sym, const| SYMBOL_KEYS[sym] = const_get(const) if const_defined?(const) } + + class << self + def resolve_key(key) + case key + when Integer then key + when Symbol then SYMBOL_KEYS.fetch(key) { raise ArgumentError, "unknown key #{key.inspect}" } + else raise ArgumentError, "key must be Integer or Symbol" + end + end + + # --- convenience aliases (spec-style names) --- + alias_method :target_fps=, :set_target_fps + alias_method :master_volume=, :set_master_volume + alias_method :frame_time, :get_frame_time + alias_method :time, :get_time + alias_method :fps, :get_fps + alias_method :screen_width, :get_screen_width + alias_method :screen_height, :get_screen_height + alias_method :mouse_x, :get_mouse_x + alias_method :mouse_y, :get_mouse_y + alias_method :mouse_position, :get_mouse_position + alias_method :mouse_wheel, :get_mouse_wheel_move + # The generator only suffixes `?` on Is* predicates; WindowShouldClose binds + # as `window_should_close`. Provide the spec's `?` form (API_SPEC.md) — the + # desktop seam below relies on it. + alias_method :window_should_close?, :window_should_close + + def platform = _is_web ? :web : :desktop + def web? = _is_web + def desktop? = !_is_web + + # --- input with symbol-key support (override the generated int-only ones) --- + alias_method :_c_key_down?, :key_down? + alias_method :_c_key_pressed?, :key_pressed? + alias_method :_c_key_released?, :key_released? + alias_method :_c_key_up?, :key_up? + def key_down?(k) = _c_key_down?(resolve_key(k)) + def key_pressed?(k) = _c_key_pressed?(resolve_key(k)) + def key_released?(k) = _c_key_released?(resolve_key(k)) + def key_up?(k) = _c_key_up?(resolve_key(k)) + + # --- the single sanctioned loop (web-safe seam) --- + # Also the bridge drain point: agent/console commands are queued off-frame + # and run here, on the main thread, before the game block (P6). Desktop only + # for R1; the web drain is wired in R4. + # + # Screenshot mode (desktop only): gate on JAMSTACK_SCREENSHOT=<path>. After + # JAMSTACK_SCREENSHOT_FRAMES frames (default 30) the game block has rendered + # and swapped, so the framebuffer holds a settled frame; take_screenshot writes + # a pixel-exact PNG to <path>, then the loop breaks + close_window exits clean. + # Generic — works on ANY game script with zero per-script changes (see + # bin/screenshot). JAMSTACK_SCREENSHOT_DELAY=<seconds> adds a wall-clock wait + # before the capture (for async/asset settle). Set JAMSTACK_SCREENSHOT_ONCE=0 + # to keep running after capture (default exits). + def while_window_open(&block) + ::Jamstack::Log.setup + if ::Jamstack::Bridge.enabled? + ::Jamstack::Bridge.start + ::Jamstack::Live.start + end + if _is_web + # Web: no TCP/.live (browser sandbox); eval arrives via the jamstack_eval + # C export. Still advance the frame counter and log loop exceptions. + _run_web_loop do + ::Jamstack::Log.tick! + begin + block.call + rescue Exception => e + ::Jamstack::Log.exception(e, tag: "loop") + raise + end + end + else + ss_path = ::Jamstack.getenv('JAMSTACK_SCREENSHOT') + ss_frames = 30 + ss_delay = 0.0 + ss_once = 1 + if (v = ::Jamstack.getenv('JAMSTACK_SCREENSHOT_FRAMES')); ss_frames = v.to_i; end + if (v = ::Jamstack.getenv('JAMSTACK_SCREENSHOT_DELAY')); ss_delay = v.to_f; end + if (v = ::Jamstack.getenv('JAMSTACK_SCREENSHOT_ONCE')); ss_once = v.to_i; end + ss_done = false + frame_no = 0 + until window_should_close? + ::Jamstack::Log.tick! + ::Jamstack::Bridge.drain + ::Jamstack::Live.poll + begin + block.call + rescue Exception => e + ::Jamstack::Log.exception(e, tag: "loop") + raise + end + frame_no += 1 + next unless ss_path && !ss_done && frame_no >= ss_frames + sleep(ss_delay) if ss_delay > 0.0 + take_screenshot(ss_path) + ss_done = true + ::Jamstack::Log.info("screenshot -> #{ss_path} (frame #{frame_no})") rescue nil + break if ss_once != 0 + end + close_window + end + end + + # --- block-scoped Begin/End pairs (API_SPEC 1.3), exception-safe --- + def draw(clear_color: RAYWHITE) + begin_drawing + clear_background(clear_color) + begin; yield; ensure end_drawing; end + end + + def scissor_mode(x:, y:, width:, height:) + begin_scissor_mode(x, y, width, height) + begin; yield; ensure end_scissor_mode; end + end + + def mode_2d(camera) + begin_mode2d(camera) + begin; yield; ensure end_mode2d; end + end + + def mode_3d(camera) + begin_mode3d(camera) + begin; yield; ensure end_mode3d; end + end + + def texture_mode(render_texture) + begin_texture_mode(render_texture) + begin; yield; ensure end_texture_mode; end + end + + def blend_mode(mode) + begin_blend_mode(mode) + begin; yield; ensure end_blend_mode; end + end + + def shader_mode(shader) + begin_shader_mode(shader) + begin; yield; ensure end_shader_mode; end + end + + # --- keyword-arg helpers for common many-arg calls (API_SPEC 1.4/1.5) --- + alias_method :_c_draw_text, :draw_text + def draw_text(text:, x:, y:, font_size:, color:) + _c_draw_text(text.to_s, x, y, font_size, color) + end + + alias_method :_c_draw_texture_pro, :draw_texture_pro + def draw_texture_pro(texture:, source:, dest:, origin: Vector2.new(0, 0), + rotation: 0, tint: WHITE) + _c_draw_texture_pro(texture, source, dest, origin, rotation, tint) + end + end +end diff --git a/mrbgems/raylib/mrblib/smaa.rb b/mrbgems/raylib/mrblib/smaa.rb new file mode 100644 index 0000000..4de4826 --- /dev/null +++ b/mrbgems/raylib/mrblib/smaa.rb @@ -0,0 +1,419 @@ +# Jamstack::FX::Smaa — SMAA 1x (Enhanced Subpixel Morphological Antialiasing), +# layered into the two-stage FX pipeline exactly like FXAA. Pure Ruby over the +# bound raylib shader API + one tiny native helper (Rl.update_texture) used to +# upload the generated area/search lookup textures. +# +# SMAA is THREE passes (edge detect -> blend weights -> neighborhood blend) that +# must run consecutively and share two intermediate render textures + two lookup +# textures (areaTex, searchTex). So unlike a single Pass, this is a COMPOSITE +# effect: Jamstack::FX::Smaa ducks as a Pass for Pipeline#apply_chain (responds to +# enabled/suppress/extra_uniforms + apply(src, dst, t, scene)) and runs its own +# 3-pass ping-pong internally, writing the final result into the chain's dst. +# +# Variant: SMAA 1x, PRESET_HIGH with SMAA_DISABLE_DIAG_DETECTION. For SMAA 1x the +# shader passes subsampleIndices = 0, so only offset-row 0 of the areaTex is ever +# sampled; we generate all 7 ortho offset rows anyway (cheap, canonical layout) +# and leave the diagonal (right half of the texture) zeroed, since diagonal +# processing is compiled out. Search texture is full (64x16). Both lookup textures +# are byte-exact with the canonical iryoku/smaa generators (verified). +# +# Multi-texture binding: raylib's DrawTexturePro auto-binds the DRAWN texture to +# unit 0 (texture0). The extra samplers (areaTex/searchTex in pass 2, blendTex in +# pass 3) are bound via Rl.set_shader_value_texture -- raylib's rlSetUniformSampler +# registers the texture id and sets the sampler uniform; the actual GL binding +# happens at the draw flush. So we set_shader_value_texture for each extra sampler +# inside shader_mode, then DrawTexturePro the main texture. areaTex = BILINEAR, +# searchTex = POINT (its own filter); edge/blend intermediate RTs = POINT. +module Jamstack + module FX + # The canonical SMAA GLSL (ES3), preprocessed from iryoku/smaa SMAA.hlsl with + # cpp -DSMAA_GLSL_3 -DSMAA_PRESET_HIGH -DSMAA_DISABLE_DIAG_DETECTION + # -DSMAA_INCLUDE_VS=0 -DSMAA_INCLUDE_PS=1. All SMAA_* macros expanded + # (texture(), vec2, mix, mad=a*b+c); diagonal code compiled out. SMAA_RT_METRICS + # is left as a placeholder -- each pass shader #defines it to a rtMetrics uniform. + # Reference copy: mrbgems/raylib/tools/smaa_canonical.glsl + SMAA_LIB = ' +vec3 SMAAGatherNeighbours(vec2 texcoord, vec4 offset[3], sampler2D tex) { + float P = texture(tex, texcoord).r; + float Pleft = texture(tex, offset[0].xy).r; + float Ptop = texture(tex, offset[0].zw).r; + return vec3(P, Pleft, Ptop); +} +void SMAAMovc(bvec2 cond, inout vec2 variable, vec2 value) { + if (cond.x) variable.x = value.x; + if (cond.y) variable.y = value.y; +} +void SMAAMovc(bvec4 cond, inout vec4 variable, vec4 value) { + SMAAMovc(cond.xy, variable.xy, value.xy); + SMAAMovc(cond.zw, variable.zw, value.zw); +} +vec2 SMAALumaEdgeDetectionPS(vec2 texcoord, vec4 offset[3], sampler2D colorTex) { + vec2 threshold = vec2(0.1, 0.1); + vec3 weights = vec3(0.2126, 0.7152, 0.0722); + float L = dot(texture(colorTex, texcoord).rgb, weights); + float Lleft = dot(texture(colorTex, offset[0].xy).rgb, weights); + float Ltop = dot(texture(colorTex, offset[0].zw).rgb, weights); + vec4 delta; + delta.xy = abs(L - vec2(Lleft, Ltop)); + vec2 edges = step(threshold, delta.xy); + if (dot(edges, vec2(1.0, 1.0)) == 0.0) discard; + float Lright = dot(texture(colorTex, offset[1].xy).rgb, weights); + float Lbottom = dot(texture(colorTex, offset[1].zw).rgb, weights); + delta.zw = abs(L - vec2(Lright, Lbottom)); + vec2 maxDelta = max(delta.xy, delta.zw); + float Lleftleft = dot(texture(colorTex, offset[2].xy).rgb, weights); + float Ltoptop = dot(texture(colorTex, offset[2].zw).rgb, weights); + delta.zw = abs(vec2(Lleft, Ltop) - vec2(Lleftleft, Ltoptop)); + maxDelta = max(maxDelta.xy, delta.zw); + float finalDelta = max(maxDelta.x, maxDelta.y); + edges.xy *= step(finalDelta, 2.0 * delta.xy); + return edges; +} +float SMAASearchLength(sampler2D searchTex, vec2 e, float offset) { + vec2 scale = vec2(66.0, 33.0) * vec2(0.5, -1.0); + vec2 bias = vec2(66.0, 33.0) * vec2(offset, 1.0); + scale += vec2(-1.0, 1.0); + bias += vec2( 0.5, -0.5); + scale *= 1.0 / vec2(64.0, 16.0); + bias *= 1.0 / vec2(64.0, 16.0); + return textureLod(searchTex, (scale * e + bias), 0.0).r; +} +float SMAASearchXLeft(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) { + vec2 e = vec2(0.0, 1.0); + while (texcoord.x > end && e.g > 0.8281 && e.r == 0.0) { + e = textureLod(edgesTex, texcoord, 0.0).rg; + texcoord = (-vec2(2.0, 0.0) * SMAA_RT_METRICS.xy + texcoord); + } + float offset = (-(255.0 / 127.0) * SMAASearchLength(searchTex, e, 0.0) + 3.25); + return (SMAA_RT_METRICS.x * offset + texcoord.x); +} +float SMAASearchXRight(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) { + vec2 e = vec2(0.0, 1.0); + while (texcoord.x < end && e.g > 0.8281 && e.r == 0.0) { + e = textureLod(edgesTex, texcoord, 0.0).rg; + texcoord = (vec2(2.0, 0.0) * SMAA_RT_METRICS.xy + texcoord); + } + float offset = (-(255.0 / 127.0) * SMAASearchLength(searchTex, e, 0.5) + 3.25); + return (-SMAA_RT_METRICS.x * offset + texcoord.x); +} +float SMAASearchYUp(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) { + vec2 e = vec2(1.0, 0.0); + while (texcoord.y > end && e.r > 0.8281 && e.g == 0.0) { + e = textureLod(edgesTex, texcoord, 0.0).rg; + texcoord = (-vec2(0.0, 2.0) * SMAA_RT_METRICS.xy + texcoord); + } + float offset = (-(255.0 / 127.0) * SMAASearchLength(searchTex, e.gr, 0.0) + 3.25); + return (SMAA_RT_METRICS.y * offset + texcoord.y); +} +float SMAASearchYDown(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) { + vec2 e = vec2(1.0, 0.0); + while (texcoord.y < end && e.r > 0.8281 && e.g == 0.0) { + e = textureLod(edgesTex, texcoord, 0.0).rg; + texcoord = (vec2(0.0, 2.0) * SMAA_RT_METRICS.xy + texcoord); + } + float offset = (-(255.0 / 127.0) * SMAASearchLength(searchTex, e.gr, 0.5) + 3.25); + return (-SMAA_RT_METRICS.y * offset + texcoord.y); +} +vec2 SMAAArea(sampler2D areaTex, vec2 dist, float e1, float e2, float offset) { + vec2 texcoord = (vec2(16.0, 16.0) * round(4.0 * vec2(e1, e2)) + dist); + texcoord = ((1.0 / vec2(160.0, 560.0)) * texcoord + 0.5 * (1.0 / vec2(160.0, 560.0))); + texcoord.y = ((1.0 / 7.0) * offset + texcoord.y); + return textureLod(areaTex, texcoord, 0.0).rg; +} +void SMAADetectHorizontalCornerPattern(sampler2D edgesTex, inout vec2 weights, vec4 texcoord, vec2 d) { + vec2 leftRight = step(d.xy, d.yx); + vec2 rounding = (1.0 - (25.0 / 100.0)) * leftRight; + rounding /= leftRight.x + leftRight.y; + vec2 factor = vec2(1.0, 1.0); + factor.x -= rounding.x * textureLodOffset(edgesTex, texcoord.xy, 0.0, ivec2(0, 1)).r; + factor.x -= rounding.y * textureLodOffset(edgesTex, texcoord.zw, 0.0, ivec2(1, 1)).r; + factor.y -= rounding.x * textureLodOffset(edgesTex, texcoord.xy, 0.0, ivec2(0, -2)).r; + factor.y -= rounding.y * textureLodOffset(edgesTex, texcoord.zw, 0.0, ivec2(1, -2)).r; + weights *= clamp(factor, 0.0, 1.0); +} +void SMAADetectVerticalCornerPattern(sampler2D edgesTex, inout vec2 weights, vec4 texcoord, vec2 d) { + vec2 leftRight = step(d.xy, d.yx); + vec2 rounding = (1.0 - (25.0 / 100.0)) * leftRight; + rounding /= leftRight.x + leftRight.y; + vec2 factor = vec2(1.0, 1.0); + factor.x -= rounding.x * textureLodOffset(edgesTex, texcoord.xy, 0.0, ivec2( 1, 0)).g; + factor.x -= rounding.y * textureLodOffset(edgesTex, texcoord.zw, 0.0, ivec2( 1, 1)).g; + factor.y -= rounding.x * textureLodOffset(edgesTex, texcoord.xy, 0.0, ivec2(-2, 0)).g; + factor.y -= rounding.y * textureLodOffset(edgesTex, texcoord.zw, 0.0, ivec2(-2, 1)).g; + weights *= clamp(factor, 0.0, 1.0); +} +vec4 SMAABlendingWeightCalculationPS(vec2 texcoord, vec2 pixcoord, vec4 offset[3], + sampler2D edgesTex, sampler2D areaTex, + sampler2D searchTex, vec4 subsampleIndices) { + vec4 weights = vec4(0.0, 0.0, 0.0, 0.0); + vec2 e = texture(edgesTex, texcoord).rg; + if (e.g > 0.0) { + vec2 d; vec3 coords; + coords.x = SMAASearchXLeft(edgesTex, searchTex, offset[0].xy, offset[2].x); + coords.y = offset[1].y; + d.x = coords.x; + float e1 = textureLod(edgesTex, coords.xy, 0.0).r; + coords.z = SMAASearchXRight(edgesTex, searchTex, offset[0].zw, offset[2].y); + d.y = coords.z; + d = abs(round((SMAA_RT_METRICS.zz * d + -pixcoord.xx))); + vec2 sqrt_d = sqrt(d); + float e2 = textureLodOffset(edgesTex, coords.zy, 0.0, ivec2(1, 0)).r; + weights.rg = SMAAArea(areaTex, sqrt_d, e1, e2, subsampleIndices.y); + coords.y = texcoord.y; + SMAADetectHorizontalCornerPattern(edgesTex, weights.rg, coords.xyzy, d); + } + if (e.r > 0.0) { + vec2 d; vec3 coords; + coords.y = SMAASearchYUp(edgesTex, searchTex, offset[1].xy, offset[2].z); + coords.x = offset[0].x; + d.x = coords.y; + float e1 = textureLod(edgesTex, coords.xy, 0.0).g; + coords.z = SMAASearchYDown(edgesTex, searchTex, offset[1].zw, offset[2].w); + d.y = coords.z; + d = abs(round((SMAA_RT_METRICS.ww * d + -pixcoord.yy))); + vec2 sqrt_d = sqrt(d); + float e2 = textureLodOffset(edgesTex, coords.xz, 0.0, ivec2(0, 1)).g; + weights.ba = SMAAArea(areaTex, sqrt_d, e1, e2, subsampleIndices.x); + coords.x = texcoord.x; + SMAADetectVerticalCornerPattern(edgesTex, weights.ba, coords.xyxz, d); + } + return weights; +} +vec4 SMAANeighborhoodBlendingPS(vec2 texcoord, vec4 offset, sampler2D colorTex, sampler2D blendTex) { + vec4 a; + a.x = texture(blendTex, offset.xy).a; + a.y = texture(blendTex, offset.zw).g; + a.wz = texture(blendTex, texcoord).xz; + if (dot(a, vec4(1.0, 1.0, 1.0, 1.0)) < 1e-5) { + return textureLod(colorTex, texcoord, 0.0); + } + bool h = max(a.x, a.z) > max(a.y, a.w); + vec4 blendingOffset = vec4(0.0, a.y, 0.0, a.w); + vec2 blendingWeight = a.yw; + SMAAMovc(bvec4(h, h, h, h), blendingOffset, vec4(a.x, 0.0, a.z, 0.0)); + SMAAMovc(bvec2(h, h), blendingWeight, a.xz); + blendingWeight /= dot(blendingWeight, vec2(1.0, 1.0)); + vec4 blendingCoord = (blendingOffset * vec4(SMAA_RT_METRICS.xy, -SMAA_RT_METRICS.xy) + texcoord.xyxy); + vec4 color = blendingWeight.x * textureLod(colorTex, blendingCoord.xy, 0.0); + color += blendingWeight.y * textureLod(colorTex, blendingCoord.zw, 0.0); + return color; +} +' + + # The three SMAA passes, built on the FX macro-shim header (TEXTURE/FRAG + + # texture0 + fragTexCoord). Each #defines SMAA_RT_METRICS to a rtMetrics + # uniform, inlines the offset[3] math the SMAA vertex shader would have done + # (raylib's default VS can't), and calls the canonical PS. SMAA_MAX_SEARCH_STEPS + # = 16 (PRESET_HIGH), used only in the blend pass offset[2]. + + # Edge pass uses a runtime `smaaThreshold` uniform (so the slider can tune + # it without recompiling). Only LumaEdgeDetectionPS uses the threshold, so + # swap its hardcoded `vec2(0.1,0.1)` for the uniform in the edge pass only. + SMAA_LIB_EDGE = SMAA_LIB.sub('vec2 threshold = vec2(0.1, 0.1);', + 'vec2 threshold = vec2(smaaThreshold);') + + SMAA_EDGE = ' +uniform vec4 rtMetrics; +#define SMAA_RT_METRICS rtMetrics +uniform float smaaThreshold; +' + SMAA_LIB_EDGE + ' +void main() { + vec4 offset[3]; + offset[0] = rtMetrics.xyxy * vec4(-1.0, 0.0, 0.0, -1.0) + fragTexCoord.xyxy; + offset[1] = rtMetrics.xyxy * vec4( 1.0, 0.0, 0.0, 1.0) + fragTexCoord.xyxy; + offset[2] = rtMetrics.xyxy * vec4(-2.0, 0.0, 0.0, -2.0) + fragTexCoord.xyxy; + vec2 edges = SMAALumaEdgeDetectionPS(fragTexCoord, offset, texture0); + FRAG = vec4(edges, 0.0, 0.0); +} +' + + SMAA_BLEND = ' +uniform vec4 rtMetrics; +#define SMAA_RT_METRICS rtMetrics +uniform sampler2D areaTex; +uniform sampler2D searchTex; +' + SMAA_LIB + ' +void main() { + vec2 pixcoord = fragTexCoord * rtMetrics.zw; + vec4 offset[3]; + offset[0] = rtMetrics.xyxy * vec4(-0.25, -0.125, 1.25, -0.125) + fragTexCoord.xyxy; + offset[1] = rtMetrics.xyxy * vec4(-0.125, -0.25, -0.125, 1.25) + fragTexCoord.xyxy; + offset[2] = rtMetrics.xxyy * vec4(-2.0, 2.0, -2.0, 2.0) * 16.0 + vec4(offset[0].xz, offset[1].yw); + vec4 weights = SMAABlendingWeightCalculationPS(fragTexCoord, pixcoord, offset, + texture0, areaTex, searchTex, vec4(0.0)); + FRAG = weights; +} +' + + SMAA_NEIGHBOR = ' +uniform vec4 rtMetrics; +#define SMAA_RT_METRICS rtMetrics +uniform sampler2D blendTex; +' + SMAA_LIB + ' +void main() { + vec4 offset = rtMetrics.xyxy * vec4(1.0, 0.0, 0.0, 1.0) + fragTexCoord.xyxy; + vec4 color = SMAANeighborhoodBlendingPS(fragTexCoord, offset, texture0, blendTex); + FRAG = color; +} +' + + # ---- lookup textures ---- + # The areaTex (160x560 RGBA8) and searchTex (64x16 RGBA8) live as baked + # canonical bytes in C (src/smaa_tex_data.c, AUTO-GENERATED by + # tools/gen_smaa_tex.rb from iryoku/smaa Scripts/*.py -- ortho region + search + # R channel byte-exact). Exposed to Ruby as Strings via Rl.smaa_area_bytes / + # Rl.smaa_search_bytes (mrb_str_new at runtime) and uploaded with + # Rl.update_texture. Runtime generation in mruby is too slow (~12s) and a + # ~358KB Ruby string literal hangs the irep loader, so the data lives in C. + # Area is ortho-only (diag half zeroed) since SMAA runs with + # SMAA_DISABLE_DIAG_DETECTION. + SMAA_BUILD_TAG = "flipv2-1757-pass2minusH" + AREATEX_W = 160 + AREATEX_H = 560 + SEARCHTEX_W = 64 + SEARCHTEX_H = 16 + + # ------------------------------------------------------------------ Smaa pass + # A composite SMAA 1x effect: ducks as a Pass for Pipeline#apply_chain + # (enabled/suppress/extra_uniforms + apply), runs 3 internal passes. + class Smaa + attr_accessor :enabled, :suppress, :extra_uniforms + attr_reader :name + + # name — human label + # w, h — render size (must match the Pipeline's RT size) + # threshold — SMAA edge-detection threshold (0.1 default). Slider knob. + def initialize(name, w, h, threshold: 0.1) + @name = name + @enabled = true + @suppress = false + @w = w + @h = h + @extra_uniforms = { threshold: threshold } + + hdr = Jamstack::FX.header + @edge_sh = Rl.load_shader_from_memory(nil, hdr + SMAA_EDGE) + @blend_sh = Rl.load_shader_from_memory(nil, hdr + SMAA_BLEND) + @neighbor_sh = Rl.load_shader_from_memory(nil, hdr + SMAA_NEIGHBOR) + + # uniform locations (cached once): + [@edge_sh, @blend_sh, @neighbor_sh].each { |s| s.freeze } + @loc_rt_edge = Rl.get_shader_location(@edge_sh, "rtMetrics") + @loc_thresh = Rl.get_shader_location(@edge_sh, "smaaThreshold") + @loc_rt_blend = Rl.get_shader_location(@blend_sh, "rtMetrics") + @loc_rt_neighbor = Rl.get_shader_location(@neighbor_sh, "rtMetrics") + @loc_area = Rl.get_shader_location(@blend_sh, "areaTex") + @loc_search = Rl.get_shader_location(@blend_sh, "searchTex") + @loc_blend = Rl.get_shader_location(@neighbor_sh, "blendTex") + + # intermediate render textures. BOTH MUST BE BILINEAR, not POINT: SMAA's + # areaTex lookup (SMAAArea) reads the crossing edges e1/e2 via + # textureLod(edgesTex, <sub-texel search coords>) — with POINT filtering + # e1/e2 are binary {0,1} -> round(4*e) in {0,4} -> SMAAArea samples the + # areaTex's ZERO corner regions -> ZERO blend weights -> NO AA. With + # BILINEAR, the sub-texel sample blends 4 edge texels -> e1/e2 in + # {0,0.25,0.75,1.0} -> round(4*e) in {0,1,3,4} -> reads the real area data + # (the areaTex is the iryoku gather layout, data at {1,3}^2). blend_rt + # likewise must be BILINEAR for pass 3's neighbourhood blend to interpolate. + # (Matches three.js: edgesRT/weightsRT are LINEAR+HalfFloat.) The earlier + # POINT setting was the root cause of "SMAA enabled but no anti-aliasing." + @edge_rt = Rl.load_render_texture(w, h) + @blend_rt = Rl.load_render_texture(w, h) + [@edge_rt, @blend_rt].each { |rt| Rl.set_texture_filter(rt.texture, Rl::TEXTURE_FILTER_BILINEAR) } + + # lookup textures: pull the baked C bytes (smaa_tex_data.c, generated by + # tools/gen_smaa_tex.rb) + upload via the native update_texture. + # areaTex = BILINEAR (the shader bilinearly interpolates the area LUT); + # searchTex = POINT (it's an index — must not interpolate). + area_img = Rl.gen_image_color(AREATEX_W, AREATEX_H, Rl::BLANK) + @area_tex = Rl.load_texture_from_image(area_img) + Rl.update_texture(@area_tex, Rl.smaa_area_bytes) + Rl.set_texture_filter(@area_tex, Rl::TEXTURE_FILTER_BILINEAR) + Rl.unload_image(area_img) + + search_img = Rl.gen_image_color(SEARCHTEX_W, SEARCHTEX_H, Rl::BLANK) + @search_tex = Rl.load_texture_from_image(search_img) + Rl.update_texture(@search_tex, Rl.smaa_search_bytes) + Rl.set_texture_filter(@search_tex, Rl::TEXTURE_FILTER_POINT) + Rl.unload_image(search_img) + end + + # Composite 3-pass SMAA. Reads `src_texture` (the chain input), writes the + # AA'd result into `dst_target`. _scene unused (SMAA pass 3 re-reads src, + # not the pre-chain scene). + def apply(src_texture, dst_target, _t, _scene_texture = nil) + w = src_texture.width + h = src_texture.height + rt = [1.0 / w, 1.0 / h, w.to_f, h.to_f] + + # pass 1 — luma edge detection: draw src -> edge_rt (texture0 = src). + Rl.texture_mode(@edge_rt) do + # BLEND_NONE isn't in this raylib version and rlSetBlendFactors isn't + # bound, so write data directly via BLEND_ALPHA_PREMULTIPLY + a BLANK + # clear: glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA) -> out = src + + # dst*(1-src.a); with dst=0 (BLANK) that's a direct write (correct + # alpha, no corruption). The old default BLEND_ALPHA + BLACK clear + # (alpha=1) left alpha=1 on the zero-alpha non-edge output, which + # pass 3 reads as the right blend weight a.x -> uniform left shift. + Rl.clear_background(Rl::BLANK) + Rl.blend_mode(Rl::BLEND_ALPHA_PREMULTIPLY) do + Rl.shader_mode(@edge_sh) do + _u(@edge_sh, @loc_rt_edge, rt) + _u(@edge_sh, @loc_thresh, @extra_uniforms[:threshold] || 0.1, Rl::SHADER_UNIFORM_FLOAT) + Rl.draw_texture_pro( + texture: src_texture, source: Rl::Rectangle.new(0, 0, w, -h), + dest: Rl::Rectangle.new(0, 0, w, h), origin: Rl::Vector2.new(0, 0), + rotation: 0.0, tint: Rl::WHITE + ) + end + end + end + + # pass 2 — blending weights: draw edge_rt -> blend_rt; bind area+search. + # edge_rt is drawn WITH the y-flip (source height -h), the SAME as passes + # 1 & 3, so all three passes share ONE orientation (blend_rt then matches + # src_texture's flipped parity, so pass 3's bound-sampler read of blend_rt + # at the (flipped) fragTexCoord aligns with the drawn color). The prior +h + # (commit 3f39485) broke this -> vertically-mirrored weights -> corrupted + # output. Verified: -h => clean AA; +h => corrupted shapes. + Rl.texture_mode(@blend_rt) do + # Same direct-write as pass 1 (PREMULTIPLY + BLANK): without it the + # BLACK clear's alpha=1 is preserved on zero-alpha non-edge output, + # so pass 3 reads a.x=1 everywhere and shifts the image 1px left. + Rl.clear_background(Rl::BLANK) + Rl.blend_mode(Rl::BLEND_ALPHA_PREMULTIPLY) do + Rl.shader_mode(@blend_sh) do + _u(@blend_sh, @loc_rt_blend, rt) + Rl.set_shader_value_texture(@blend_sh, @loc_area, @area_tex) + Rl.set_shader_value_texture(@blend_sh, @loc_search, @search_tex) + Rl.draw_texture_pro( + texture: @edge_rt.texture, source: Rl::Rectangle.new(0, 0, w, -h), + dest: Rl::Rectangle.new(0, 0, w, h), origin: Rl::Vector2.new(0, 0), + rotation: 0.0, tint: Rl::WHITE + ) + end + end + end + + # pass 3 — neighborhood blending: draw src -> dst; bind blend_rt. + Rl.texture_mode(dst_target) do + Rl.clear_background(Rl::BLACK) + Rl.shader_mode(@neighbor_sh) do + _u(@neighbor_sh, @loc_rt_neighbor, rt) + Rl.set_shader_value_texture(@neighbor_sh, @loc_blend, @blend_rt.texture) + Rl.draw_texture_pro( + texture: src_texture, source: Rl::Rectangle.new(0, 0, w, -h), + dest: Rl::Rectangle.new(0, 0, w, h), origin: Rl::Vector2.new(0, 0), + rotation: 0.0, tint: Rl::WHITE + ) + end + end + end + + def _u(shader, loc, value, type = Rl::SHADER_UNIFORM_VEC4) + return if loc.nil? || loc < 0 + Rl.set_shader_value(shader, loc, value, type) + end + end + end +end diff --git a/mrbgems/raylib/mrblib/touch_controls.rb b/mrbgems/raylib/mrblib/touch_controls.rb new file mode 100644 index 0000000..c83dfbd --- /dev/null +++ b/mrbgems/raylib/mrblib/touch_controls.rb @@ -0,0 +1,141 @@ +module Jamstack + class TouchControls + attr_reader :joystick_vector + + def initialize(joystick_x: 100, joystick_y: nil, joystick_radius: 60) + sw = Rl.screen_width + sh = Rl.screen_height + @jbx = joystick_x + @jby = joystick_y || (sh - joystick_x) + @jbr = joystick_radius + @jknob_x = 0 + @jknob_y = 0 + @jvec = Rl::Vector2.new(0, 0) + @jactive = false + @jtouch_id = -1 + @buttons = [] + end + + def add_button(name, x:, y:, radius: 40, label: nil) + @buttons << { + name: name, x: x, y: y, r: radius, + label: label || name.to_s.upcase, + touch_id: -1, held: false, prev: false + } + self + end + + def update + count = Rl.get_touch_point_count + ids = [] + count.times { |i| ids << [Rl.get_touch_point_id(i), Rl.get_touch_position(i)] } + + update_joystick(ids) + update_buttons(ids) + end + + def joystick + @jvec + end + + def button_down?(name) + b = find_button(name) + b && b[:held] + end + + def button_pressed?(name) + b = find_button(name) + b && b[:held] && !b[:prev] + end + + def draw + draw_joystick + @buttons.each { |b| draw_button(b) } + end + + private + + def update_joystick(touches) + if @jactive + match = touches.find { |id, _| id == @jtouch_id } + if match + _, pos = match + dx = pos.x - @jbx + dy = pos.y - @jby + dist = Math.sqrt(dx * dx + dy * dy) + if dist > @jbr + dx = dx * @jbr / dist + dy = dy * @jbr / dist + end + @jknob_x = dx + @jknob_y = dy + @jvec = Rl::Vector2.new(dx / @jbr, dy / @jbr) + else + reset_joystick + end + else + match = touches.find { |_, pos| dist(pos.x, pos.y, @jbx, @jby) <= @jbr } + if match + @jactive = true + @jtouch_id = match[0] + end + end + end + + def reset_joystick + @jactive = false + @jtouch_id = -1 + @jknob_x = 0 + @jknob_y = 0 + @jvec = Rl::Vector2.new(0, 0) + end + + def update_buttons(touches) + @buttons.each do |b| + b[:prev] = b[:held] + if b[:touch_id] >= 0 + if touches.any? { |id, _| id == b[:touch_id] } + b[:held] = true + else + b[:held] = false + b[:touch_id] = -1 + end + elsif !b[:held] + match = touches.find { |_, pos| dist(pos.x, pos.y, b[:x], b[:y]) <= b[:r] } + if match + b[:touch_id] = match[0] + b[:held] = true + end + end + end + end + + def find_button(name) + @buttons.find { |b| b[:name] == name } + end + + def dist(x1, y1, x2, y2) + dx = x1 - x2; dy = y1 - y2 + Math.sqrt(dx * dx + dy * dy) + end + + def draw_joystick + c_base = Rl::Color.new(255, 255, 255, 40) + c_ring = Rl::Color.new(255, 255, 255, 120) + c_knob = Rl::Color.new(200, 210, 240, 180) + Rl.draw_circle(@jbx, @jby, @jbr, c_base) + Rl.draw_circle_lines(@jbx, @jby, @jbr, c_ring) + Rl.draw_circle(@jbx + @jknob_x, @jby + @jknob_y, @jbr / 3, c_knob) + end + + def draw_button(b) + alpha = b[:held] ? 200 : 60 + fill = Rl::Color.new(150, 200, 255, alpha) + ring = Rl::Color.new(255, 255, 255, 120) + Rl.draw_circle(b[:x], b[:y], b[:r], fill) + Rl.draw_circle_lines(b[:x], b[:y], b[:r], ring) + Rl.draw_text(text: b[:label], x: b[:x] - 6, y: b[:y] - 8, + font_size: 16, color: Rl::Color.new(255, 255, 255, 200)) + end + end +end diff --git a/mrbgems/raylib/src/raylib_bindings.c b/mrbgems/raylib/src/raylib_bindings.c new file mode 100644 index 0000000..50ee180 --- /dev/null +++ b/mrbgems/raylib/src/raylib_bindings.c @@ -0,0 +1,126 @@ +/* raylib mrbgem entry point. + * + * The bulk of the bindings (all supported structs/enums/functions) are generated + * into raylib_gen.c by tools/gen_raylib.rb from raylib's official raylib_api.json. + * This file only holds the hand-written pieces the generator can't express: + * - platform detection (Rl._is_web) + * - the web main-loop seam (Rl._run_web_loop) + * - Rl.update_texture (UpdateTexture has a `const void *pixels` arg the + * generator skips; takes a Texture + a binary String of raw bytes) + * and calls the generated registrar. + */ +#include <mruby.h> +#include <mruby/data.h> /* DATA_PTR (unchecked struct accessor) */ +#include <mruby/string.h> /* RSTRING_PTR */ +#include <raylib.h> +#ifdef __EMSCRIPTEN__ +#include <emscripten/emscripten.h> +#endif + +/* defined in the generated raylib_gen.c */ +void rl_define_generated(mrb_state *mrb); + +/* + * SMAA lookup textures (baked canonical bytes from iryoku/smaa, generated into + * smaa_tex_data.c by tools/gen_smaa_tex.rb). Exposed to Ruby as Strings so the + * Jamstack::FX::Smaa composite (mrblib/smaa.rb) can upload them via + * Rl.update_texture. Lives in C (not a Ruby literal) because a ~358KB string + * literal both exceeds mruby's 65534-char literal cap AND hangs the irep loader + * at boot; mrb_str_new at runtime has neither limit. + */ +extern const unsigned char smaa_area_tex[]; +extern const unsigned char smaa_search_tex[]; +static const size_t smaa_area_tex_len = 358400; +static const size_t smaa_search_tex_len = 4096; + +static mrb_value +rl_smaa_area_bytes(mrb_state *mrb, mrb_value self) +{ + return mrb_str_new(mrb, (const char *)smaa_area_tex, smaa_area_tex_len); +} + +static mrb_value +rl_smaa_search_bytes(mrb_state *mrb, mrb_value self) +{ + return mrb_str_new(mrb, (const char *)smaa_search_tex, smaa_search_tex_len); +} + +/* + * Rl.update_texture(texture, bytes) -> nil + * + * Wraps raylib's UpdateTexture: uploads raw pixel bytes into an existing GPU + * Texture2D. The generator skips UpdateTexture (and LoadImageFromMemory) because + * their `const void *` args aren't auto-marshalable; this helper unpacks a Ruby + * String of raw bytes. Used by Jamstack::FX::Smaa to upload the generated + * area/search lookup textures (RGBA8). The texture's .format must match the byte + * layout (gen_image_color + load_texture_from_image yields RGBA8). Bytes may + * contain NULs — UpdateTexture reads width*height*bpp, not a C string. + * + * Texture2D is accessed via DATA_PTR (unchecked) rather than the static + * rl_ptr_Texture accessor in raylib_gen.c; the call site always passes a Texture. + */ +static mrb_value +rl_update_texture(mrb_state *mrb, mrb_value self) +{ + mrb_value tex_val, bytes_val; + mrb_get_args(mrb, "oS", &tex_val, &bytes_val); + UpdateTexture(*((Texture2D *)DATA_PTR(tex_val)), RSTRING_PTR(bytes_val)); + return mrb_nil_value(); +} + +static mrb_value +rl_is_web(mrb_state *mrb, mrb_value self) +{ +#ifdef __EMSCRIPTEN__ + return mrb_true_value(); +#else + return mrb_false_value(); +#endif +} + +#ifdef __EMSCRIPTEN__ +static mrb_state *g_loop_mrb = NULL; +static mrb_value g_loop_block; + +static void +web_frame(void) +{ + mrb_yield(g_loop_mrb, g_loop_block, mrb_nil_value()); +} + +static mrb_value +rl_run_web_loop(mrb_state *mrb, mrb_value self) +{ + mrb_value blk; + mrb_get_args(mrb, "&", &blk); + g_loop_mrb = mrb; + g_loop_block = blk; + mrb_gc_register(mrb, blk); + emscripten_set_main_loop(web_frame, 0, 1); /* unwinds; never returns */ + return mrb_nil_value(); +} +#else +static mrb_value +rl_run_web_loop(mrb_state *mrb, mrb_value self) +{ + return mrb_nil_value(); +} +#endif + +void +mrb_raylib_gem_init(mrb_state *mrb) +{ + rl_define_generated(mrb); + + struct RClass *rl = mrb_define_module(mrb, "Rl"); + mrb_define_module_function(mrb, rl, "_is_web", rl_is_web, MRB_ARGS_NONE()); + mrb_define_module_function(mrb, rl, "_run_web_loop", rl_run_web_loop, MRB_ARGS_BLOCK()); + mrb_define_module_function(mrb, rl, "update_texture", rl_update_texture, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rl, "smaa_area_bytes", rl_smaa_area_bytes, MRB_ARGS_NONE()); + mrb_define_module_function(mrb, rl, "smaa_search_bytes", rl_smaa_search_bytes, MRB_ARGS_NONE()); +} + +void +mrb_raylib_gem_final(mrb_state *mrb) +{ +} diff --git a/mrbgems/raylib/tools/gen_ai_reference.rb b/mrbgems/raylib/tools/gen_ai_reference.rb new file mode 100644 index 0000000..3e10c48 --- /dev/null +++ b/mrbgems/raylib/tools/gen_ai_reference.rb @@ -0,0 +1,429 @@ +#!/usr/bin/env ruby +# Generates docs/AI_REFERENCE.md — a single, dense, self-contained description of +# the ENTIRE Ruby API (raylib + raymath + RmlUi) for an LLM to consume without +# reading any source. Every call carries argument + return TYPES; every struct, +# enum value, and constant is listed; unbound functions are listed explicitly so +# the model does not invent them. +# +# ruby gen_ai_reference.rb +require 'json' + +ROOT = File.expand_path('../../..', __dir__) +RAYLIB = File.join(ROOT, 'vendor', 'raylib') +GEN_C = File.join(__dir__, '..', 'src', 'raylib_gen.c') +OUT = File.join(ROOT, 'docs', 'AI_REFERENCE.md') + +# raylib 6.0 relocated the parser: parser/output/ -> tools/rlparser/output/. +# Load a raylib API json, tolerating a known raylib 6.0 bug: the +# LoadDirectoryFilesEx description contains literal unescaped double-quotes +# ("*.*", "FILES*", "DIRS*") that break strict JSON. Escape + retry. (Kept in +# sync with gen_raylib.rb's load_api.) +def load_api(path) + raw = File.read(path) + return JSON.parse(raw) rescue JSON.parse(raw + .gsub('"*.*"', '\"*.*\"') + .gsub('"FILES*"', '\"FILES*\"') + .gsub('"DIRS*"', '\"DIRS*\"')) +end + +API = load_api(File.join(RAYLIB, 'tools/rlparser/output/raylib_api.json')) +RMATH = load_api(File.join(RAYLIB, 'tools/rlparser/output/raymath_api.json')) + +ALIASES = API['aliases'].to_h { |a| [a['name'], a['type']] } +STRUCTS = API['structs'].map { |s| s['name'] }.to_h { |n| [n, true] } + +def snake(n) + n.gsub(/(\d)([A-Z][a-z])/, '\1_\2').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z])([A-Z])/, '\1_\2').downcase +end +def ruby_method(n) = n =~ /\AIs([A-Z].*)\z/ ? snake($1) + '?' : snake(n) +def base_struct(t) + b = t.gsub('const', '').gsub('*', '').strip + b = ALIASES[b] || b + STRUCTS[b] ? b : nil +end +# Ruby type label for a C type, or nil if void / unsupported. +def rtype(t) + s = t.strip + return nil if s == 'void' + return 'Boolean' if s == 'bool' + return 'Float' if s == 'float' || s == 'double' + return 'String' if s == 'const char *' || s == 'char *' + if (b = base_struct(s)) then return "Rl::#{b}" end + return 'Integer' unless s.include?('*') + nil +end + +# --- bound vs unbound (parse skip list from generated raylib_gen.c) --- +skip_reason = {} +if File.exist?(GEN_C) && File.read(GEN_C, 4096) =~ /Skipped \(\d+\):\s*(.*?)\*\//m + $1.split(';').each do |e| + e = e.strip + if e =~ /\A([A-Za-z_]\w*)\s*\((.*)\)/ then skip_reason[$1] = $2 end + end +end +skip_reason.delete('SetShaderValue'); skip_reason.delete('SetShaderValueV') # hand-bound + +# Fully-typed signatures we write by hand (generic marshaller can't express them) +SPECIAL = { + 'SetShaderValue' => 'Rl.set_shader_value(shader:Rl::Shader, loc_index:Integer, value:Numeric|Array, uniform_type:Integer) # value packed per SHADER_UNIFORM_* type', + 'SetShaderValueV' => 'Rl.set_shader_value_v(shader:Rl::Shader, loc_index:Integer, value:Array, uniform_type:Integer, count:Integer)', +} + +def sig(fn) + return SPECIAL[fn['name']] if SPECIAL[fn['name']] + args = (fn['params'] || []).map { |p| "#{snake(p['name'])}:#{rtype(p['type']) || p['type']}" } + s = +"Rl.#{ruby_method(fn['name'])}" + s << "(#{args.join(', ')})" unless args.empty? + r = rtype(fn['returnType']); s << " -> #{r}" if r + d = fn['description'].to_s.strip + d.empty? ? s : "#{s} # #{d}" +end + +# module grouping from (Module: xxx) tags in raylib.h +MOD_ALIAS = { 'rgestures' => 'core', 'rcamera' => 'core' } +def sections(path, macro, re, default) + map = {}; mod = default + File.foreach(path) do |raw| + l = raw.chomp + if l =~ re + cap = $1; next if cap =~ /\ANOTE\b/i + mod = (cap =~ /\(Module:\s*([a-z]+)\)/) ? (MOD_ALIAS[$1] || $1) : mod + elsif l =~ /\A#{macro}\b.*?\s\*?([A-Za-z_]\w*)\s*\(/ + map[$1] = mod + end + end + map +end +RL_MOD = sections(File.join(RAYLIB, 'src/raylib.h'), 'RLAPI', %r{\A//\s*(.*?functions.*)\z}i, 'core') + +# --------------------------------------------------------------------------- +o = +"" +o << <<~HEAD + # raylib-jamstack — complete API reference (for AI agents) + + Single-file description of the **entire** Ruby (mruby) API of this stack: + raylib 6.0 + raymath + RmlUi 6.x + flecs 4 (ECS) + Jolt 5 (3D physics). Everything an agent needs to write correct + game code without reading the bindings source. Auto-generated from + `raylib_api.json` / `raymath_api.json` by `mrbgems/raylib/tools/gen_ai_reference.rb`. + + ## Conventions (read first) + - C `PascalCase` -> Ruby `snake_case`. `IsXxx(...)` -> `xxx?` predicate. + - All raylib structs are classes under `Rl::` with a **positional** constructor + in field order and `obj.field` / `obj.field=` accessors (see Structs). + - Enum values and color/numeric `#define`s are constants under `Rl::` + (e.g. `Rl::KEY_SPACE`, `Rl::MOUSE_BUTTON_LEFT`, `Rl::GOLD`, `Rl::PI`). + - Signatures below are `Rl.name(arg:Type, ...) -> ReturnType`. **No `-> ` means + the call returns `nil`.** `Boolean` = true/false. Struct types are `Rl::X`. + - A struct passed where C takes a single `T*` is **in/out**: pass an `Rl::T` + instance; the call may mutate it. + - String args accept `nil` (becomes C `NULL`), e.g. + `Rl.load_shader_from_memory(nil, fs)` for the default vertex shader. + - Symbol keys work anywhere a keycode is expected via the input predicates: + `:a`..`:z`, `:0`..`:9`, `:space :enter :escape :tab :backspace :up :down + :left :right :left_shift :left_control` — or use `Rl::KEY_*` ints. + - There is no global state you must thread; raylib is a global singleton. + + ## Idiomatic helpers (defined in Ruby, not 1:1 C) + ```ruby + Rl.while_window_open { ... } # the ONLY main loop. web-safe (emscripten + # main loop on web; `until close?` on desktop, + # auto-calls close_window on desktop exit). + Rl.draw(clear_color: Rl::RAYWHITE) { ... } # begin_drawing+clear+end_drawing (ensure) + Rl.mode_2d(camera) { ... } # begin/end_mode2d (exception-safe) + Rl.mode_3d(camera) { ... } # begin/end_mode3d + Rl.texture_mode(render_texture) { ... } + Rl.blend_mode(mode) { ... } # mode = Rl::BLEND_* + Rl.shader_mode(shader) { ... } + Rl.scissor_mode(x:, y:, width:, height:) { ... } + Rl.draw_text(text:, x:, y:, font_size:, color:) # kwarg form + Rl.draw_texture_pro(texture:, source:, dest:, origin: Rl::Vector2.new(0,0), + rotation: 0, tint: Rl::WHITE) # kwarg form + Rl.platform # :web|:desktop ; Rl.web? ; Rl.desktop? + # aliases: Rl.target_fps= , Rl.master_volume= , Rl.frame_time, Rl.time, Rl.fps, + # Rl.screen_width, Rl.screen_height, Rl.mouse_x, Rl.mouse_y, + # Rl.mouse_position, Rl.mouse_wheel + ``` + NOTE: `draw_text` and `draw_texture_pro` are the keyword forms above (they + override the positional generated versions). All other calls are positional. + + ## Minimal program + ```ruby + Rl.init_window(800, 450, "demo") + Rl.target_fps = 60 + Rl.while_window_open do + Rl.draw(clear_color: Rl::RAYWHITE) do + Rl.draw_text(text: "hello", x: 20, y: 20, font_size: 20, color: Rl::DARKGRAY) + Rl.draw_circle_v(Rl.mouse_position, 16, Rl::RED) if Rl.mouse_button_down?(Rl::MOUSE_BUTTON_LEFT) + end + end + ``` +HEAD + +# --- functions by module --- +o << "\n## raylib functions (by module)\n" +%w[core shapes textures text models audio].each do |mod| + fns = API['functions'].reject { |f| skip_reason[f['name']] } + .select { |f| (RL_MOD[f['name']] || 'core') == mod } + next if fns.empty? + o << "\n### #{mod}\n```ruby\n" + fns.each { |f| o << sig(f) << "\n" } + o << "```\n" +end + +o << "\n## raymath functions\n```ruby\n" +RMATH['functions'].reject { |f| skip_reason[f['name']] }.each { |f| o << sig(f) << "\n" } +o << "```\n" + +# --- structs (typed constructor + accessors) --- +o << "\n## Structs\n" +o << "Constructor args are positional in the order shown; every listed field has\n" +o << "`obj.field` (read) and `obj.field=` (write). Pointer/array fields (if any)\n" +o << "are omitted (not accessible).\n```ruby\n" +API['structs'].each do |st| + fields = st['fields'].map { |f| [f['name'], rtype(f['type'])] }.select { |_, t| t } + args = fields.map { |n, t| "#{n}:#{t}" }.join(', ') + o << "Rl::#{st['name']}.new(#{args})".ljust(0) << " # #{st['description']}\n" +end +o << "```\n" +o << "Aliases (same class): " << API['aliases'].map { |a| "#{a['name'].to_s.sub(/\A\*/, '')}=#{a['type']}" }.join(', ') << "\n" + +# --- enums --- +o << "\n## Enums (constants under Rl::)\n```\n" +API['enums'].each do |e| + vals = e['values'].map { |v| "#{v['name']}=#{v['value']}" }.join(' ') + o << "# #{e['name']}: #{e['description']}\n#{vals}\n" +end +o << "```\n" + +# --- defines / constants --- +colors = []; ints = []; floats = []; strings = [] +API['defines'].each do |d| + case d['type'] + when 'COLOR' then colors << d['name'] + when 'INT' then ints << "#{d['name']}=#{d['value']}" + when 'FLOAT' then floats << "#{d['name']}=#{d['value']}" + when 'STRING' then strings << "#{d['name']}=#{d['value'].inspect}" + end +end +o << "\n## Other constants under Rl::\n```\n" +o << "# Colors (Rl::Color constants)\n#{colors.join(' ')}\n" +o << "# Numeric\n#{(ints + floats).join(' ')}\n" unless (ints + floats).empty? +o << "# String\n#{strings.join(' ')}\n" unless strings.empty? +o << "```\n" + +# --- RmlUi (hand-maintained, typed) --- +o << <<~RML + + ## RmlUi (HTML/CSS UI; call Rml.init AFTER Rl.init_window) + ```ruby + # setup / lifecycle + Rml.init # -> nil (inits RmlUi + rlgl backend) + Rml.load_font(path:String, fallback:false) # register .ttf + Rml.shutdown + ctx = Rml::Context.new(name:String, width:Integer=screen_w, height:Integer=screen_h) + ctx.resize(width:Integer, height:Integer) + ctx.dimensions = Rl::Vector2 + + # per-frame: process_input before block, update+render after (exception-safe) + ctx.frame { ...mutate ui... } + ctx.process_input ; ctx.update ; ctx.render # manual equivalent + + # documents + doc = ctx.load_document(path:String) { |doc| ... } # -> Rml::Document + ctx.document(id:String) # -> Rml::Element (already-loaded lookup) | nil + ctx.num_documents # -> Integer + doc.show ; doc.hide ; doc.close ; doc.pull_to_front ; doc.push_to_back + doc.title ; doc.title = String + + # Rml::Element (Document is a subclass) + el[name] # get attribute -> String|nil ; el[name] = value + el.attribute(name) ; el.set_attribute(name, v) ; el.has_attribute?(name) ; el.remove_attribute(name) + el.id ; el.id = v ; el.tag_name + el.inner_rml ; el.inner_rml = html ; el.text ; el.text = s + el.add_class(c) ; el.remove_class(c) ; el.set_class(c, bool) ; el.class_set?(c) + el.set_property("color","red") ; el.property(name) ; el.remove_property(name) + el.focus ; el.blur ; el.click ; el.scroll_into_view(align_top=true) ; el.visible? + el.element(id) # alias get_element_by_id -> Element|nil + el.query_selector(sel) ; el.query_selector_all(sel) ; el.elements_by_tag(tag) + el.parent ; el.child_count ; el.child(i) ; el.children ; el.owner_document + el.client_width ; el.client_height ; el.offset_left ; el.offset_top ; el.absolute_left ; el.absolute_top + el.on(:click) { |event| ... } # event types: click, mouseover, change, submit, ... + + # Rml::Event (passed to el.on) + ev.type ; ev.target ; ev.current ; ev.stop_propagation ; ev.stop_immediate_propagation + ev[key] -> Float ; ev.param(key) -> Float ; ev.param_str(key) -> String ; ev.mouse_x ; ev.mouse_y + + # MVC data model (binds Ruby to {{vars}} / data-* in RML). Create BEFORE load_document. + m = ctx.data_model(name:String) do |m| + m.bind(:score) { game.score } # one-way computed (read each frame) + m.value(:hp, 100) # two-way scalar + m.event(:reset) { game.reset! } # controller: rml `data-event-click="reset()"` + end # block form finishes it automatically + m[:hp] ; m[:hp] = 80 # read / write+dirty + m.dirty(:score, ...) ; m.dirty_all # re-evaluate bound vars after state changes + ``` +RML + +# --- Flecs (ECS), hand-maintained --- +o << <<~FLECS + + ## Flecs (ECS, module `Flecs::`) + Entity Component System. Components are real C structs declared at runtime from + a meta descriptor string and (de)serialized to/from Ruby Hashes. Works + identically on desktop and web. Entities/components are integer ids wrapped in + Flecs::Entity / Flecs::Component (use them anywhere an id is expected). + ```ruby + world = Flecs::World.new # owns the ecs_world_t (freed by GC) + + # Components: a meta struct descriptor (C type syntax). Returns Flecs::Component. + pos = world.struct("Position", "{float x; float y;}") + vel = world.struct("Velocity", "{float x; float y;}") + # supported member types: bool, char, [iu]8/16/32/64, f32/f64, uptr/iptr, + # string (char*), entity, nested structs, inline arrays. + npc = world.tag("Npc") # dataless id -> Flecs::Component + + # Entities (Flecs::Entity) + e = world.entity("player") # name optional + e = world.entity # anonymous + world.lookup("player") # -> Flecs::Entity | nil + e.id ; e.to_i ; e.name ; e.name = "p2" ; e.alive? ; e.delete + + # Components on entities (Hash <-> struct) + e.set(pos, x: 1.0, y: 2.0) # kwargs or e.set(pos, {x:1,y:2}) + e.get(pos) # -> {x: 1.0, y: 2.0} | nil + e.add(npc) ; e.remove(npc) ; e.has?(npc) # tags or components + e.set(pos, x: 0, y: 0).add(npc) # chainable + + # Systems: run each progress() during a phase. Block gets |entity_id, *comp_hashes| + # in the order of `with:`; mutations to the component Hashes are written back. + world.system("Move", with: [pos, vel]) do |id, p, v| + p[:x] += v[:x]; p[:y] += v[:y] + end + world.progress(dt = 0.0) # -> Boolean (false = quit); runs all systems once + + # Ad-hoc queries (cached) -> Flecs::Query (Enumerable) + q = world.query(pos, vel) + q.each { |id, p, v| ... } # same writeback semantics + + # phases: Flecs::ON_LOAD, Flecs::PRE_UPDATE, Flecs::ON_UPDATE (default), Flecs::ON_START + ``` + NOTE: the system/query block receives the entity as an **Integer id** (not a + Flecs::Entity) for speed; wrap with `world.entity_for(id)` if you need methods — + or just use ids. Component data is delivered as Hashes; mutate them in place. + Multithreaded systems are NOT exposed (single-threaded `progress` only; this is + also the only mode that works on the wasm/web build). +FLECS + +# --- Jolt Physics (3D), hand-maintained --- +o << <<~JOLT + + ## Jolt Physics (3D, module `Jolt::`) + Rigid-body 3D physics via the joltc C API. Vectors accept Arrays or Rl::Vector3 + and are returned as Rl::Vector3/Vector4. Single-threaded `step` (identical on + desktop and web). Full spec: docs/API_SPEC_JOLT.md. + ```ruby + world = Jolt::World.new(gravity: [0, -9.81, 0], max_bodies: 10240) + world.gravity = [0, -20, 0] + world.step(dt = 1.0/60.0, collision_steps: 1) # advance; alias: update + world.optimize_broad_phase # once after bulk-adding bodies + + # shapes (reusable) -> Jolt::Shape + Jolt.box(width, height, depth) # FULL dimensions (not half-extents) + Jolt.sphere(radius) + Jolt.capsule(half_height, radius) # half-height of cylinder section + Jolt.cylinder(half_height, radius) + Jolt.convex_hull(points) # Array of [x,y,z] + Jolt.mesh(vertices) # triangle soup (3 verts/tri); STATIC bodies only + + # bodies -> Jolt::Body. motion: Jolt::STATIC | KINEMATIC | DYNAMIC + b = world.body(shape: Jolt.sphere(0.5), position: [0,10,0], rotation: [0,0,0,1], + motion: Jolt::DYNAMIC, restitution: 0.0, friction: 0.2, activate: true, + velocity: nil, user_data: nil, mass: nil, linear_damping: 0.05, + angular_damping: 0.05, ccd: false, sensor: false) # alias: add_body + b.sensor = true ; b.ccd = true # also settable at runtime + b.id ; b.position -> Rl::Vector3 ; b.center_of_mass ; b.rotation -> Rl::Vector4 + b.position = [x,y,z] + b.set_transform(position:, rotation: nil, activate: true) + b.linear_velocity ; b.linear_velocity = [x,y,z] + b.angular_velocity ; b.angular_velocity = [x,y,z] + b.apply_force(v) ; b.apply_impulse(v) ; b.apply_torque(v) # chainable + b.active? ; b.activate ; b.deactivate ; b.remove + b.user_data ; b.user_data = entity_id # 64-bit tag (map collisions -> game objs) + b.motion_type ; b.motion_type = Jolt::KINEMATIC ; b.set_motion_type(mt, activate: true) + b.friction = 0.8 ; b.restitution = 0.9 ; b.gravity_factor = 0.0 + + # queries + hit = world.raycast([0,10,0], [0,-20,0]) # -> Jolt::RayHit | nil + hit.body_id ; hit.body ; hit.fraction ; hit.point -> Rl::Vector3 ; hit.normal -> Rl::Vector3 + world.overlap_point([x,y,z]) -> Array<Jolt::Body> # bodies containing a point + + # collision events (began this step) -> Array<Jolt::Contact>; ended -> ContactEnd + world.contacts.each do |c| + c.body_a_id ; c.body_b_id ; c.body_a ; c.body_b + c.point -> Rl::Vector3 ; c.normal -> Rl::Vector3 + c.involves?(b) ; other = c.other(b) # the other body in the contact + end + world.contacts_ended.each { |c| c.involves?(zone) ; c.other(zone) } # stopped touching + # sensor bodies (sensor: true) + contacts/contacts_ended = trigger volumes (enter/leave) + + # constraints / joints (return Jolt::Constraint; joint.remove to detach). + # The WORLD retains constraints + ragdolls, so a dropped handle still stays + # alive (a GC'd Constraint/Ragdoll would otherwise detach itself). Use .remove. + world.weld(a, b) # rigid weld + world.ball_joint(a, b, point) # point-to-point + world.distance_joint(a, b, pa, pb, min: 0, max: 2) # rope/rod + world.hinge(a, b, point, axis, min_deg: -90, max_deg: 90) # door + world.slider(a, b, point, axis, min: -2, max: 2) # piston + world.cone(a, b, point, axis, half_angle_deg: 30) # swing/twist limit + + # character controller (kinematic capsule; stair-step + slope) -> Jolt::Character + ch = world.character(shape: Jolt.capsule(0.6, 0.3), position: [0,2,0], + max_slope_deg: 45, mass: 70) + # per frame: set velocity (apply gravity/jump yourself), then update + step + v = ch.velocity + vy = ch.on_ground? ? (jump ? 6.0 : 0.0) : v.y - 20.0 * dt + ch.velocity = [input_x * 5, vy, input_z * 5] + ch.update(dt) ; world.step(dt) + ch.position -> Rl::Vector3 ; ch.position = [x,y,z] ; ch.on_ground? + ch.ground_state # :on_ground|:on_steep|:not_supported|:in_air ; ch.ground_normal ; ch.supported? + ch.max_strength = 6000 ; ch.mass = 70 # push force vs dynamic bodies / collision mass + # ride moving platforms: a KINEMATIC body whose velocity the character inherits + ch.ground_velocity -> Rl::Vector3 # velocity of the surface underfoot (0 if airborne) + ch.ground_body -> Jolt::Body | nil # the body it stands on + ch.ride(dt) # = update(dt) + inherit a STATIC/KINEMATIC + # platform's velocity (DYNAMIC ground ignored, + # else its reaction to your weight flings you) + + # ragdoll: tree of dynamic bodies + swing-twist joints. Parts PARENTS-FIRST. + rd = world.ragdoll(parts: [ + { name: :torso, shape: Jolt.capsule(0.22,0.16), position: [0,4,0], mass: 20 }, + { name: :head, shape: Jolt.sphere(0.16), position: [0,4.45,0], parent: :torso, + joint: [0,4.24,0], twist_axis: [0,1,0], plane_axis: [1,0,0], + cone_deg: 25, plane_deg: 25, twist_min_deg: -25, twist_max_deg: 25 }, + ], user_data: 0) + rd.body_count ; rd.bodies -> Array<Jolt::Body> ; rd[0] ; rd.activate + rd.bodies.each { |b| b.apply_impulse([fx,fy,fz]) } ; rd.remove + # capsule parts: local axis = Y; draw via + # Rl.vector3_rotate_by_quaternion([0, half_height, 0], body.rotation) + ``` + NOTE: STATIC = never moves (floors/walls), KINEMATIC = you move it (infinite + mass), DYNAMIC = simulated; collision layer is derived from motion type. + Use body.user_data to bridge contacts back to game objects (e.g. flecs entity + ids). Not exposed: shape-cast queries, height-field/compound shapes, vehicles, + soft bodies, ragdoll pose/motor driving, custom layers, multithreading. + Determinism is OFF. +JOLT + +# --- unbound functions (do NOT call these) --- +o << "\n## NOT bound (do not call — no Ruby method exists)\n" +o << "These raylib/raymath functions are intentionally unbound (callbacks, raw\n" +o << "pointers/buffers, varargs, or array/string returns). Use Ruby equivalents\n" +o << "(`File`, `format`, arrays, `puts`) or avoid.\n```\n" +o << skip_reason.keys.sort.each_slice(4).map { |s| s.join(', ') }.join(",\n") << "\n```\n" + +File.write(OUT, o) +nfn = API['functions'].reject { |f| skip_reason[f['name']] }.size + + RMATH['functions'].reject { |f| skip_reason[f['name']] }.size +warn "wrote #{OUT}: #{nfn} functions, #{API['structs'].size} structs, " \ + "#{API['enums'].size} enums, #{skip_reason.size} unbound (#{o.lines.size} lines)" diff --git a/mrbgems/raylib/tools/gen_raylib.rb b/mrbgems/raylib/tools/gen_raylib.rb new file mode 100644 index 0000000..794c668 --- /dev/null +++ b/mrbgems/raylib/tools/gen_raylib.rb @@ -0,0 +1,402 @@ +#!/usr/bin/env ruby +# Generates C mruby bindings for ALL supported raylib functions/structs/enums +# from raylib's official parser output (raylib_api.json). +# +# ruby gen_raylib.rb <raylib_api.json> <out.c> +# +# Supported marshaling: +# scalars (int/uint/char/short/long/float/double/bool), const char* (string), +# raylib structs by value (wrapped mruby Data objects), and single struct +# pointers (passed by reference -> inout). Functions using other pointer kinds +# (primitive arrays, void*, char**, callbacks, varargs, pointer/array returns) +# are skipped and listed in a comment at the top of the output. +require 'json' + +json_path, out_path, raymath_path = ARGV +abort "usage: gen_raylib.rb <api.json> <out.c> [raymath_api.json]" unless json_path && out_path + +# Load a raylib API json, tolerating a known raylib 6.0 bug: the +# LoadDirectoryFilesEx description contains literal unescaped double-quotes +# ("*.*", "FILES*", "DIRS*") that the parser copies verbatim, breaking strict +# JSON. We escape those embedded quotes and retry. Safe on already-valid json: +# the escaped form (\"X\") does not contain the unescaped substring "X". +def load_api(path) + raw = File.read(path) + return JSON.parse(raw) rescue JSON.parse(raw + .gsub('"*.*"', '\"*.*\"') + .gsub('"FILES*"', '\"FILES*\"') + .gsub('"DIRS*"', '\"DIRS*\"')) +end + +api = load_api(json_path) +raymath = (raymath_path && File.exist?(raymath_path)) ? load_api(raymath_path) : nil + +# Functions present in raylib_api.json but not compiled in every raylib platform +# build (e.g. PLATFORM_WEB), which would cause link errors. Kept minimal. +SKIP_FUNCTIONS = %w[ + GetClipboardImage +].to_h { |n| [n, true] } + +STRUCTS = api['structs'].map { |s| s['name'] } +STRUCT_SET = STRUCTS.to_h { |n| [n, true] } +ALIASES = api['aliases'].to_h { |a| [a['name'], a['type']] } # Texture2D -> Texture +CALLBACKS = api['callbacks'].to_h { |c| [c['name'], true] } + +INT_TYPES = %w[int char short long size_t int8_t int16_t int32_t int64_t + uint8_t uint16_t uint32_t uint64_t].to_h { |t| [t, true] } + +def base_struct(type) + t = type.gsub('const', '').gsub('*', '').strip + t = ALIASES[t] || t + STRUCT_SET[t] ? t : nil +end + +def pointer?(type) = type.include?('*') + +# Classify a type for marshaling. Returns [:kind, base_struct_or_nil]. +def classify(type, as_return: false) + t = type.strip + return [:void, nil] if t == 'void' + return [:bool, nil] if t == 'bool' + return [:float, nil] if t == 'float' || t == 'double' + return [:string, nil] if t == 'const char *' || (as_return && t == 'char *') + + unless pointer?(t) + base = t.sub(/^unsigned /, '').sub(/^signed /, '') + return [:int, nil] if INT_TYPES[base] || t == 'unsigned int' || t == 'unsigned char' || + t == 'unsigned short' || t == 'unsigned long' || base == 'unsigned' + s = base_struct(t) + return [:struct, s] if s + return [:unsupported, nil] + end + + # pointer types + return [:unsupported, nil] if t.include?('**') + return [:unsupported, nil] if t.include?('(') # function pointer + s = base_struct(t) + return [:unsupported, nil] if s.nil? || as_return # struct* return unsupported + [:structptr, s] +end + +def snake(name) + name.gsub(/(\d)([A-Z][a-z])/, '\1_\2') # Vector2Add -> Vector2_Add (keeps Mode2D) + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z])([A-Z])/, '\1_\2') + .downcase +end + +# Ruby method name for a function (Is* -> predicate?) +def ruby_method(name) + if name =~ /\AIs([A-Z].*)\z/ + snake($1) + '?' + else + snake(name) + end +end + +# --- field accessor support --- +def field_kind(type) + k, s = classify(type) + return [k, s] if %i[int float bool].include?(k) + return [:struct, s] if k == :struct + [:unsupported, nil] +end + +out = +"" +out << "/* AUTO-GENERATED by tools/gen_raylib.rb — do not edit. */\n" +out << "#include <string.h>\n#include <mruby.h>\n#include <mruby/string.h>\n" +out << "#include <mruby/class.h>\n#include <mruby/data.h>\n#include <mruby/variable.h>\n" +out << "#include <mruby/array.h>\n" +out << "#include <raylib.h>\n" +out << "#include <raymath.h>\n" if raymath +out << "\n" + +out << "static void rl_struct_free(mrb_state *mrb, void *p){ if (p) mrb_free(mrb, p); }\n\n" + +# Per-struct: data type, wrap/ptr helpers (declared early so functions can use them) +STRUCTS.each do |name| + out << "static const mrb_data_type rl_dt_#{name} = { \"Rl::#{name}\", rl_struct_free };\n" +end +out << "\n" +STRUCTS.each do |name| + out << <<~C + static mrb_value rl_wrap_#{name}(mrb_state *mrb, #{name} v){ + #{name} *p = (#{name}*)mrb_malloc(mrb, sizeof(#{name})); *p = v; + struct RClass *m = mrb_module_get(mrb, "Rl"); + struct RClass *c = mrb_class_get_under(mrb, m, "#{name}"); + return mrb_obj_value(mrb_data_object_alloc(mrb, c, p, &rl_dt_#{name})); + } + static #{name} *rl_ptr_#{name}(mrb_state *mrb, mrb_value o){ + return (#{name}*)mrb_data_get_ptr(mrb, o, &rl_dt_#{name}); + } + C +end +out << "\n" + +# Helper to resolve a struct's helper base name through aliases. +def hbase(type) = base_struct(type) + +# --- struct initialize + field accessors --- +struct_defs = api['structs'] +struct_defs.each do |st| + name = st['name'] + fields = st['fields'] + supported = fields.map { |f| [f, field_kind(f['type'])] }.select { |_, (k, _)| k != :unsupported } + + # constructor + fmt = +"|" + decls = [] + assigns = [] + argptrs = [] + supported.each_with_index do |(f, (k, s)), i| + v = "a#{i}" + case k + when :int then fmt << 'i'; decls << "mrb_int #{v} = 0;"; argptrs << "&#{v}"; assigns << "p->#{f['name']} = (#{f['type']})#{v};" + when :float then fmt << 'f'; decls << "mrb_float #{v} = 0;"; argptrs << "&#{v}"; assigns << "p->#{f['name']} = (#{f['type']})#{v};" + when :bool then fmt << 'b'; decls << "mrb_bool #{v} = 0;"; argptrs << "&#{v}"; assigns << "p->#{f['name']} = #{v};" + when :struct then fmt << 'o'; decls << "mrb_value #{v} = mrb_nil_value();"; argptrs << "&#{v}" + assigns << "if (!mrb_nil_p(#{v})) p->#{f['name']} = *rl_ptr_#{hbase(f['type'])}(mrb, #{v});" + end + end + out << "static mrb_value rl_init_#{name}(mrb_state *mrb, mrb_value self){\n" + out << " #{name} *p = (#{name}*)mrb_malloc(mrb, sizeof(#{name})); memset(p, 0, sizeof(#{name}));\n" + out << " mrb_data_init(self, p, &rl_dt_#{name});\n" + unless supported.empty? + out << " #{decls.join(' ')}\n" + out << " mrb_get_args(mrb, \"#{fmt}\"#{argptrs.empty? ? '' : ', ' + argptrs.join(', ')});\n" + out << " #{assigns.join("\n ")}\n" + end + out << " return self;\n}\n" + + # accessors + supported.each do |(f, (k, s))| + fn = f['name'] + case k + when :int + out << "static mrb_value rl_g_#{name}_#{fn}(mrb_state *mrb, mrb_value self){ return mrb_fixnum_value(rl_ptr_#{name}(mrb,self)->#{fn}); }\n" + out << "static mrb_value rl_s_#{name}_#{fn}(mrb_state *mrb, mrb_value self){ mrb_int v; mrb_get_args(mrb,\"i\",&v); rl_ptr_#{name}(mrb,self)->#{fn}=(#{f['type']})v; return mrb_fixnum_value(v); }\n" + when :float + out << "static mrb_value rl_g_#{name}_#{fn}(mrb_state *mrb, mrb_value self){ return mrb_float_value(mrb, rl_ptr_#{name}(mrb,self)->#{fn}); }\n" + out << "static mrb_value rl_s_#{name}_#{fn}(mrb_state *mrb, mrb_value self){ mrb_float v; mrb_get_args(mrb,\"f\",&v); rl_ptr_#{name}(mrb,self)->#{fn}=(#{f['type']})v; return mrb_float_value(mrb,v); }\n" + when :bool + out << "static mrb_value rl_g_#{name}_#{fn}(mrb_state *mrb, mrb_value self){ return mrb_bool_value(rl_ptr_#{name}(mrb,self)->#{fn}); }\n" + out << "static mrb_value rl_s_#{name}_#{fn}(mrb_state *mrb, mrb_value self){ mrb_bool v; mrb_get_args(mrb,\"b\",&v); rl_ptr_#{name}(mrb,self)->#{fn}=v; return mrb_bool_value(v); }\n" + when :struct + hb = hbase(f['type']) + out << "static mrb_value rl_g_#{name}_#{fn}(mrb_state *mrb, mrb_value self){ return rl_wrap_#{hb}(mrb, rl_ptr_#{name}(mrb,self)->#{fn}); }\n" + out << "static mrb_value rl_s_#{name}_#{fn}(mrb_state *mrb, mrb_value self){ mrb_value v; mrb_get_args(mrb,\"o\",&v); rl_ptr_#{name}(mrb,self)->#{fn}=*rl_ptr_#{hb}(mrb,v); return v; }\n" + end + end +end +out << "\n" + +# --- functions --- +skipped = [] +fn_regs = [] +seen_fns = {} +emit_function = lambda do |fn| + name = fn['name'] + return if seen_fns[name] + if SKIP_FUNCTIONS[name] + skipped << "#{name} (platform)"; return + end + seen_fns[name] = true + params = fn['params'] || [] + rkind, rstruct = classify(fn['returnType'], as_return: true) + + # skip on unsupported return / params + if rkind == :unsupported + skipped << "#{name} (ret #{fn['returnType']})"; return + end + bad = false + pinfo = params.map do |p| + t = p['type'] + if t == '...' || t == 'va_list' || CALLBACKS[t.gsub('*','').strip] + bad = true; break + end + k, s = classify(t) + if k == :unsupported + bad = true; break + end + [k, s] + end + if bad || pinfo.nil? + skipped << "#{name} (params)"; return + end + + cname = "rl_fn_#{name}" + fmt = +"" + decls = [] + argptrs = [] + callargs = [] + pinfo.each_with_index do |(k, s), i| + v = "a#{i}" + case k + when :int then fmt << 'i'; decls << "mrb_int #{v};"; argptrs << "&#{v}"; callargs << "(#{params[i]['type']})#{v}" + when :float then fmt << 'f'; decls << "mrb_float #{v};"; argptrs << "&#{v}"; callargs << "(#{params[i]['type']})#{v}" + when :bool then fmt << 'b'; decls << "mrb_bool #{v};"; argptrs << "&#{v}"; callargs << "#{v}" + when :string then fmt << 'z!'; decls << "const char *#{v} = NULL;"; argptrs << "&#{v}"; callargs << "#{v}" + when :struct then fmt << 'o'; decls << "mrb_value #{v};"; argptrs << "&#{v}"; callargs << "(*rl_ptr_#{s}(mrb,#{v}))" + when :structptr then fmt << 'o'; decls << "mrb_value #{v};"; argptrs << "&#{v}"; callargs << "rl_ptr_#{s}(mrb,#{v})" + end + end + + body = +"" + body << "static mrb_value #{cname}(mrb_state *mrb, mrb_value self){\n" + body << " #{decls.join(' ')}\n" unless decls.empty? + body << " mrb_get_args(mrb, \"#{fmt}\"#{argptrs.empty? ? '' : ', ' + argptrs.join(', ')});\n" unless fmt.empty? + call = "#{name}(#{callargs.join(', ')})" + case rkind + when :void then body << " #{call};\n return mrb_nil_value();\n" + when :bool then body << " return mrb_bool_value(#{call});\n" + when :int then body << " return mrb_fixnum_value(#{call});\n" + when :float then body << " return mrb_float_value(mrb, #{call});\n" + when :string then body << " const char *r = #{call};\n return r ? mrb_str_new_cstr(mrb, r) : mrb_nil_value();\n" + when :struct then body << " return rl_wrap_#{rstruct}(mrb, #{call});\n" + end + body << "}\n" + out << body + + fn_regs << %( mrb_define_module_function(mrb, rl, "#{ruby_method(name)}", #{cname}, MRB_ARGS_REQ(#{params.size}));) +end + +api['functions'].each { |fn| emit_function.call(fn) } +raymath['functions'].each { |fn| emit_function.call(fn) } if raymath + +# --- hand-written shader uniform setters --- +# SetShaderValue / SetShaderValueV take a `const void *value` + a uniform-type +# tag, which the generic marshaller can't express. We accept a Ruby Numeric or +# Array of Numerics and pack it into the right C buffer based on `uniform_type`. +# (These were in the skip list as "(params)"; remove them now that they're bound.) +skipped.reject! { |s| s.start_with?('SetShaderValue ', 'SetShaderValueV ') } +out << <<~'C' + + /* ---- hand-written shader uniform setters ---- */ + static int rl_uniform_comps(mrb_int t){ + switch (t) { + case SHADER_UNIFORM_VEC2: case SHADER_UNIFORM_IVEC2: return 2; + case SHADER_UNIFORM_VEC3: case SHADER_UNIFORM_IVEC3: return 3; + case SHADER_UNIFORM_VEC4: case SHADER_UNIFORM_IVEC4: return 4; + default: return 1; /* FLOAT, INT, SAMPLER2D */ + } + } + static mrb_bool rl_uniform_is_int(mrb_int t){ + return (t == SHADER_UNIFORM_INT || t == SHADER_UNIFORM_SAMPLER2D || + (t >= SHADER_UNIFORM_IVEC2 && t <= SHADER_UNIFORM_IVEC4)); + } + /* Pack `count` * `comps` scalars from a Ruby value (scalar, flat Array, or + Array of Arrays) into buf. Returns 0 on success, -1 on arity mismatch. */ + static int rl_pack_uniform(mrb_state *mrb, mrb_value v, void *buf, + int comps, int count, mrb_bool is_int){ + float *f = (float*)buf; int *ip = (int*)buf; + int total = comps * count, k = 0; + if (!mrb_array_p(v)) { + if (total != 1) return -1; + if (is_int) ip[0] = (int)mrb_as_int(mrb, v); else f[0] = (float)mrb_as_float(mrb, v); + return 0; + } + mrb_int n = RARRAY_LEN(v); + for (mrb_int i = 0; i < n; i++) { + mrb_value e = mrb_ary_ref(mrb, v, i); + if (mrb_array_p(e)) { + mrb_int m = RARRAY_LEN(e); + for (mrb_int j = 0; j < m; j++, k++) { + if (k >= total) return -1; + mrb_value s = mrb_ary_ref(mrb, e, j); + if (is_int) ip[k] = (int)mrb_as_int(mrb, s); else f[k] = (float)mrb_as_float(mrb, s); + } + } else { + if (k >= total) return -1; + if (is_int) ip[k] = (int)mrb_as_int(mrb, e); else f[k] = (float)mrb_as_float(mrb, e); + k++; + } + } + return (k == total) ? 0 : -1; + } + static mrb_value rl_fn_SetShaderValue(mrb_state *mrb, mrb_value self){ + mrb_value sh, val; mrb_int loc, utype; + mrb_get_args(mrb, "oioi", &sh, &loc, &val, &utype); + int comps = rl_uniform_comps(utype); + mrb_bool is_int = rl_uniform_is_int(utype); + int buf[4]; /* 4 ints or 4 floats, same size */ + if (rl_pack_uniform(mrb, val, buf, comps, 1, is_int) != 0) + mrb_raisef(mrb, E_ARGUMENT_ERROR, "shader uniform expects %d component(s)", comps); + SetShaderValue(*rl_ptr_Shader(mrb, sh), (int)loc, buf, (int)utype); + return mrb_nil_value(); + } + static mrb_value rl_fn_SetShaderValueV(mrb_state *mrb, mrb_value self){ + mrb_value sh, val; mrb_int loc, utype, count; + mrb_get_args(mrb, "oioii", &sh, &loc, &val, &utype, &count); + int comps = rl_uniform_comps(utype); + mrb_bool is_int = rl_uniform_is_int(utype); + if (count < 1) mrb_raise(mrb, E_ARGUMENT_ERROR, "count must be >= 1"); + void *buf = mrb_malloc(mrb, (size_t)comps * (size_t)count * sizeof(int)); + int rc = rl_pack_uniform(mrb, val, buf, comps, (int)count, is_int); + if (rc != 0) { mrb_free(mrb, buf); + mrb_raisef(mrb, E_ARGUMENT_ERROR, "shader uniform array expects %d*%d values", + comps, (int)count); } + SetShaderValueV(*rl_ptr_Shader(mrb, sh), (int)loc, buf, (int)utype, (int)count); + mrb_free(mrb, buf); + return mrb_nil_value(); + } +C +fn_regs << %( mrb_define_module_function(mrb, rl, "set_shader_value", rl_fn_SetShaderValue, MRB_ARGS_REQ(4));) +fn_regs << %( mrb_define_module_function(mrb, rl, "set_shader_value_v", rl_fn_SetShaderValueV, MRB_ARGS_REQ(5));) + +# --- enums + defines (constants) --- +enum_regs = [] +api['enums'].each do |e| + e['values'].each do |v| + enum_regs << %( mrb_define_const(mrb, rl, "#{v['name']}", mrb_fixnum_value(#{v['value']}));) + end +end + +define_regs = [] +color_consts = [] +api['defines'].each do |d| + case d['type'] + when 'INT' then define_regs << %( mrb_define_const(mrb, rl, "#{d['name']}", mrb_fixnum_value(#{d['value']}));) + when 'FLOAT' then define_regs << %( mrb_define_const(mrb, rl, "#{d['name']}", mrb_float_value(mrb, #{d['value'].to_f}));) + when 'STRING' then define_regs << %( mrb_define_const(mrb, rl, "#{d['name']}", mrb_str_new_cstr(mrb, #{d['value'].inspect}));) + when 'COLOR' + if d['value'] =~ /\{\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\}/ + color_consts << %( mrb_define_const(mrb, rl, "#{d['name']}", rl_wrap_Color(mrb, (Color){#{$1},#{$2},#{$3},#{$4}}));) + end + end +end + +# --- init function --- +out << "\nvoid rl_define_generated(mrb_state *mrb){\n" +out << " struct RClass *rl = mrb_define_module(mrb, \"Rl\");\n" +out << " struct RClass *obj = mrb->object_class;\n (void)obj;\n" +# struct classes + accessors +struct_defs.each do |st| + name = st['name'] + out << " {\n struct RClass *c = mrb_define_class_under(mrb, rl, \"#{name}\", mrb->object_class);\n" + out << " MRB_SET_INSTANCE_TT(c, MRB_TT_DATA);\n" + out << " mrb_define_method(mrb, c, \"initialize\", rl_init_#{name}, MRB_ARGS_OPT(16));\n" + fields = st['fields'] + fields.each do |f| + k, _ = field_kind(f['type']) + next if k == :unsupported + fn = f['name'] + out << " mrb_define_method(mrb, c, \"#{fn}\", rl_g_#{name}_#{fn}, MRB_ARGS_NONE());\n" + out << " mrb_define_method(mrb, c, \"#{fn}=\", rl_s_#{name}_#{fn}, MRB_ARGS_REQ(1));\n" + end + out << " }\n" +end +out << "\n" +out << enum_regs.join("\n") << "\n\n" +out << define_regs.join("\n") << "\n\n" +out << color_consts.join("\n") << "\n\n" +out << fn_regs.join("\n") << "\n" +out << "}\n" + +# Report skipped functions as a comment. +total = api['functions'].size + (raymath ? raymath['functions'].size : 0) +bound = total - skipped.size +header = "/* Generated: #{bound}/#{total} functions bound (raylib#{raymath ? ' + raymath' : ''}). */\n" +header << "/* Skipped (#{skipped.size}): #{skipped.join('; ')} */\n\n" + +File.write(out_path, header + out) +warn "gen_raylib: wrote #{out_path} (#{bound}/#{total} functions, #{STRUCTS.size} structs)" diff --git a/mrbgems/raylib/tools/gen_rbs.rb b/mrbgems/raylib/tools/gen_rbs.rb new file mode 100644 index 0000000..0447620 --- /dev/null +++ b/mrbgems/raylib/tools/gen_rbs.rb @@ -0,0 +1,189 @@ +#!/usr/bin/env ruby +# Generates sig/raylib.rbs — RBS type signatures for the raylib + raymath Ruby +# bindings. Reuses the type-mapping machinery (snake, ruby_method, rtype) from +# gen_ai_reference.rb, adapted for RBS syntax. +# +# ruby mrbgems/raylib/tools/gen_rbs.rb +require 'json' + +ROOT = File.expand_path('../../..', __dir__) +RAYLIB = File.join(ROOT, 'vendor', 'raylib') +GEN_C = File.join(__dir__, '..', 'src', 'raylib_gen.c') +OUT = File.join(ROOT, 'sig', 'raylib.rbs') + +# raylib 6.0 relocated the parser: parser/output/ -> tools/rlparser/output/. +# Tolerant loader for a known raylib 6.0 bug (LoadDirectoryFilesEx description +# has unescaped quotes). Kept in sync with gen_raylib.rb / gen_ai_reference.rb. +def load_api(path) + raw = File.read(path) + return JSON.parse(raw) rescue JSON.parse(raw + .gsub('"*.*"', '\"*.*\"') + .gsub('"FILES*"', '\"FILES*\"') + .gsub('"DIRS*"', '\"DIRS*\"')) +end + +API = load_api(File.join(RAYLIB, 'tools/rlparser/output/raylib_api.json')) +RMATH = load_api(File.join(RAYLIB, 'tools/rlparser/output/raymath_api.json')) + +ALIASES = API['aliases'].to_h { |a| [a['name'], a['type']] } +STRUCTS = API['structs'].map { |s| s['name'] }.to_h { |n| [n, true] } + +def snake(n) + n.gsub(/(\d)([A-Z][a-z])/, '\1_\2').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z])([A-Z])/, '\1_\2').downcase +end +def ruby_method(n) = n =~ /\AIs([A-Z].*)\z/ ? snake($1) + '?' : snake(n) +def base_struct(t) + b = t.gsub('const', '').gsub('*', '').strip + b = ALIASES[b] || b + STRUCTS[b] ? b : nil +end +def rtype(t, numeric: false) + s = t.strip + return nil if s == 'void' + return 'Boolean' if s == 'bool' + # mruby's `f` (mrb_float) mrb_get_args format auto-converts Integer -> Float, so + # float *inputs* (params, struct fields) accept ints too. Use `Float | Integer` + # (NOT Numeric): both members declare arithmetic so `v.x * 2` type-checks, + # whereas the abstract Numeric does not declare `*`/`/`/`**`. Returns stay Float: + # the C value really is a Float (pass numeric: true only for inputs). + # WART: RBS's numeric tower widens `**`/some `/` (and camera-math via Math.sin) + # to `Complex`, which then won't fit `Float | Integer` params — surfacing as + # :information ArgumentTypeMismatch under the Steepfile's lenient config (non- + # failing). mruby's auto-coercing numerics don't map cleanly to RBS's strict + # tower; accepted trade-off (see .agents/knowledge/steep.md). Float would reject + # int literals; Numeric breaks arithmetic — Float | Integer is the best fit. + return (numeric ? 'Float | Integer' : 'Float') if s == 'float' || s == 'double' + return 'String' if s == 'const char *' || s == 'char *' + if (b = base_struct(s)) then return "Rl::#{b}" end + return 'Integer' unless s.include?('*') + nil +end +def rbs_type(t, numeric: false) + r = rtype(t, numeric: numeric) + return 'nil' if r.nil? && t.strip == 'void' + return 'untyped' if r.nil? + r == 'Boolean' ? 'bool' : r +end + +skip_reason = {} +if File.exist?(GEN_C) && File.read(GEN_C, 4096) =~ /Skipped \(\d+\):\s*(.*?)\*\//m + $1.split(';').each do |e| + e = e.strip + if e =~ /\A([A-Za-z_]\w*)\s*\((.*)\)/ then skip_reason[$1] = $2 end + end +end +skip_reason.delete('SetShaderValue'); skip_reason.delete('SetShaderValueV') + +SPECIAL_RBS = { + 'SetShaderValue' => ' def self.set_shader_value: (Rl::Shader shader, Integer loc_index, untyped value, Integer uniform_type) -> nil', + 'SetShaderValueV' => ' def self.set_shader_value_v: (Rl::Shader shader, Integer loc_index, untyped value, Integer uniform_type, Integer count) -> nil', +} + +# Functions emitted only as hand-written sugar below (skip their JSON form): the +# sugar accepts a Symbol key (resolve_key) where the JSON form would be Integer. +SUGAR_OVERRIDE = %w[DrawText DrawTexturePro IsKeyDown IsKeyPressed IsKeyReleased IsKeyUp] + +def rbs_sig(fn) + return SPECIAL_RBS[fn['name']] if SPECIAL_RBS[fn['name']] + name = ruby_method(fn['name']) + params = (fn['params'] || []).map { |p| + "#{rbs_type(p['type'], numeric: true)} #{snake(p['name'])}" + } + ret = rbs_type(fn['returnType']) # numeric: false -> Float for float returns + ret = 'nil' if ret.nil? + " def self.#{name}: (#{params.join(', ')}) -> #{ret}" +end + +def rbs_struct(st) + fields = st['fields'].map { |f| [f['name'], rbs_type(f['type'], numeric: true)] }.select { |_, t| t && t != 'nil' } + lines = [] + lines << "class Rl::#{st['name']}" + fields.each do |name, t| + lines << " attr_accessor #{name}: #{t}" + end + init_params = fields.map { |n, t| "?#{t} #{n}" } + lines << " def initialize: (#{init_params.join(', ')}) -> void" + lines << "end" + lines.join("\n") +end + +o = +"" +o << "# Generated by gen_rbs.rb — DO NOT EDIT.\n" +o << "# Regenerate: ruby mrbgems/raylib/tools/gen_rbs.rb\n\n" + +# --- module Rl: functions --- +o << "module Rl\n" +fns = (API['functions'] + RMATH['functions']).reject { |f| skip_reason[f['name']] || SUGAR_OVERRIDE.include?(f['name']) } +fns.each { |f| o << rbs_sig(f) << "\n" } + +# --- Ruby sugar (hand-written, not from JSON) --- +o << "\n # --- Ruby sugar (mrblib/raylib.rb) ---\n" +o << " def self.while_window_open: () { -> void } -> void\n" +o << " def self.window_should_close?: () -> bool\n" +o << " def self.draw: (?clear_color: Rl::Color) { -> void } -> void\n" +o << " def self.mode_2d: (Rl::Camera2D camera) { -> void } -> void\n" +o << " def self.mode_3d: (Rl::Camera3D camera) { -> void } -> void\n" +o << " def self.texture_mode: (Rl::RenderTexture target) { -> void } -> void\n" +o << " def self.blend_mode: (Integer mode) { -> void } -> void\n" +o << " def self.shader_mode: (Rl::Shader shader) { -> void } -> void\n" +o << " def self.scissor_mode: (Integer x, Integer y, Integer width, Integer height) { -> void } -> void\n" +o << " def self.draw_text: (text: String, x: Integer, y: Integer, font_size: Integer, color: Rl::Color) -> nil\n" +o << " def self.draw_texture_pro: (texture: Rl::Texture, source: Rl::Rectangle, dest: Rl::Rectangle, ?origin: Rl::Vector2, ?rotation: Float, ?tint: Rl::Color) -> nil\n" +o << " def self.target_fps=: (Integer) -> Integer\n" +o << " def self.master_volume=: (Float) -> Float\n" +o << " def self.frame_time: () -> Float\n" +o << " def self.time: () -> Float\n" +o << " def self.fps: () -> Integer\n" +o << " def self.screen_width: () -> Integer\n" +o << " def self.screen_height: () -> Integer\n" +o << " def self.mouse_x: () -> Integer\n" +o << " def self.mouse_y: () -> Integer\n" +o << " def self.mouse_position: () -> Rl::Vector2\n" +o << " def self.mouse_wheel: () -> Float\n" +o << " def self.platform: () -> Symbol\n" +o << " def self.web?: () -> bool\n" +o << " def self.desktop?: () -> bool\n" +o << " def self.key_down?: (untyped key) -> bool\n" +o << " def self.key_pressed?: (untyped key) -> bool\n" +o << " def self.key_released?: (untyped key) -> bool\n" +o << " def self.key_up?: (untyped key) -> bool\n" + +# --- constants (enums + defines) --- +o << "\n # --- Constants ---\n" +API['enums'].each do |e| + e['values'].each { |v| o << " #{v['name']}: Integer\n" } +end + +colors = []; ints = []; floats = []; strings = [] +API['defines'].each do |d| + case d['type'] + when 'COLOR' then colors << d['name'] + when 'INT' then ints << d['name'] + when 'FLOAT' then floats << d['name'] + when 'STRING' then strings << d['name'] + end +end +colors.each { |c| o << " #{c}: Rl::Color\n" } +(ints + floats).each { |c| o << " #{c}: #{floats.include?(c) ? 'Float' : 'Integer'}\n" } +strings.each { |c| o << " #{c}: String\n" } + +# --- aliases (Texture2D = Texture, etc.) --- +# raylib 6.0 adds a pointer typedef `typedef Transform *ModelAnimPose;` whose +# alias name comes through as "*ModelAnimPose" — strip a leading '*' so the RBS +# identifier stays valid. +API['aliases'].each do |a| + name = a['name'].to_s.sub(/\A\*/, '') + o << " #{name}: untyped # alias for Rl::#{a['type']}\n" +end + +o << "end\n\n" + +# --- structs as classes --- +API['structs'].each do |st| + o << rbs_struct(st) << "\n\n" +end + +File.write(OUT, o) +warn "wrote #{OUT}: #{fns.size} functions, #{API['structs'].size} structs, " \ + "#{API['enums'].sum { |e| e['values'].size } + colors.size + ints.size + floats.size + strings.size} constants" diff --git a/mrbgems/raylib/tools/gen_smaa_tex.rb b/mrbgems/raylib/tools/gen_smaa_tex.rb new file mode 100644 index 0000000..8ca52f2 --- /dev/null +++ b/mrbgems/raylib/tools/gen_smaa_tex.rb @@ -0,0 +1,204 @@ +#!/usr/bin/env ruby +# gen_smaa_tex.rb — OFFLINE (CRuby) generator for the SMAA lookup textures. +# Produces mrbgems/raylib/mrblib/smaa_data.rb containing two base64 constants +# (AREA_TEX_B64, SEARCH_TEX_B64) — the canonical 160x560 RGBA8 area texture and +# 64x16 RGBA8 search texture, byte-exact with iryoku/smaa (verified against the +# real Scripts/AreaTex.py + SearchTex.py and Textures/SearchTex.h). +# +# Runtime (mruby) generation is too slow (~12s) for the closed-form area math, so +# we bake the data here (fast CRuby) and decode it at load (cheap base64). To +# regenerate after changing the algorithm: ruby tools/gen_smaa_tex.rb +# +# Ported faithfully from iryoku/smaa Scripts/AreaTex.py + SearchTex.py (BSD). +# Area: ALL 7 ortho subsample offsets (left half x0..79) + diagonal (right half +# x80..159) — full canonical AreaTexDX10. We run SMAA with diag disabled, but the +# diag half is left canonical (harmless; shader never samples it). + +SMOOTH_MAX_DISTANCE = 32 +SUBSAMPLE_OFFSETS_ORTHO = [0.0, -0.25, 0.25, -0.125, 0.125, -0.375, 0.375] +SIZE_ORTHO = 16 +AREATEX_W = 160 +AREATEX_H = 560 +EDGES_ORTHO = [ + [0, 0], [3, 0], [0, 3], [3, 3], [1, 0], [4, 0], [1, 3], [4, 3], + [0, 1], [3, 1], [0, 4], [3, 4], [1, 1], [4, 1], [1, 4], [4, 4] +].freeze + +def v_add(a, b) = [a[0] + b[0], a[1] + b[1]] +def v_smul(a, s) = [a[0] * s, a[1] * s] +def v_sqrt(a) = [Math.sqrt(a[0]), Math.sqrt(a[1])] +def v_lerp(a, b, p) = [a[0] + (b[0] - a[0]) * p, a[1] + (b[1] - a[1]) * p] +def lerp(a, b, p) = a + (b - a) * p +def saturate(a) = [[a, 0.0].max, 1.0].min +def copysign1(y) = (y < 0.0) ? -1.0 : 1.0 +def frac(x) = x - x.floor + +def smootharea(d, a1, a2) + b1 = v_smul(v_sqrt(v_smul(a1, 2.0)), 0.5) + b2 = v_smul(v_sqrt(v_smul(a2, 2.0)), 0.5) + p = saturate(d.to_f / SMOOTH_MAX_DISTANCE) + [v_lerp(b1, a1, p), v_lerp(b2, a2, p)] +end + +def area_ortho_area(p1, p2, x) + dx = p2[0] - p1[0]; dy = p2[1] - p1[1] + x1 = x.to_f; x2 = x + 1.0 + y1 = p1[1] + dy * (x1 - p1[0]) / dx + y2 = p1[1] + dy * (x2 - p1[0]) / dx + inside = (x1 >= p1[0] && x1 < p2[0]) || (x2 > p1[0] && x2 <= p2[0]) + return [0.0, 0.0] unless inside + istrapezoid = (copysign1(y1) == copysign1(y2)) || y1.abs < 1e-4 || y2.abs < 1e-4 + if istrapezoid + a = (y1 + y2) / 2.0 + return a < 0.0 ? [a.abs, 0.0] : [0.0, a.abs] + end + x0 = -p1[1] * dx / dy + p1[0] + a1 = (x0 > p1[0]) ? (y1 * frac(x0) / 2.0) : 0.0 + a2 = (x0 < p2[0]) ? (y2 * (1.0 - frac(x0)) / 2.0) : 0.0 + a = (a1.abs > a2.abs) ? a1 : -a2 + return a < 0.0 ? [a1.abs, a2.abs] : [a2.abs, a1.abs] +end + +def areaortho(pattern, left, right, offset) + d = left + right + 1 + o1 = 0.5 + offset + o2 = 0.5 + offset - 1.0 + case pattern + when 0 then [0.0, 0.0] + when 1 then left <= right ? area_ortho_area([0.0, o2], [d / 2.0, 0.0], left) : [0.0, 0.0] + when 2 then left >= right ? area_ortho_area([d / 2.0, 0.0], [d, o2], left) : [0.0, 0.0] + when 3 + a1 = area_ortho_area([0.0, o2], [d / 2.0, 0.0], left) + a2 = area_ortho_area([d / 2.0, 0.0], [d, o2], left) + a1, a2 = smootharea(d, a1, a2) + [a1[0] + a2[0], a1[1] + a2[1]] + when 4 then left <= right ? area_ortho_area([0.0, o1], [d / 2.0, 0.0], left) : [0.0, 0.0] + when 5 then [0.0, 0.0] + when 6 + if offset.abs > 0.0 + a1 = area_ortho_area([0.0, o1], [d, o2], left) + a2 = v_add(area_ortho_area([0.0, o1], [d / 2.0, 0.0], left), + area_ortho_area([d / 2.0, 0.0], [d, o2], left)) + avg = v_smul(v_add(a1, a2), 0.5); [avg[0], avg[1]] + else + area_ortho_area([0.0, o1], [d, o2], left) + end + when 7 then area_ortho_area([0.0, o1], [d, o2], left) + when 8 then left >= right ? area_ortho_area([d / 2.0, 0.0], [d, o1], left) : [0.0, 0.0] + when 9 + if offset.abs > 0.0 + a1 = area_ortho_area([0.0, o2], [d, o1], left) + a2 = v_add(area_ortho_area([0.0, o2], [d / 2.0, 0.0], left), + area_ortho_area([d / 2.0, 0.0], [d, o1], left)) + avg = v_smul(v_add(a1, a2), 0.5); [avg[0], avg[1]] + else + area_ortho_area([0.0, o2], [d, o1], left) + end + when 10 then [0.0, 0.0] + when 11 then area_ortho_area([0.0, o2], [d, o1], left) + when 12 + a1 = area_ortho_area([0.0, o1], [d / 2.0, 0.0], left) + a2 = area_ortho_area([d / 2.0, 0.0], [d, o1], left) + a1, a2 = smootharea(d, a1, a2) + [a1[0] + a2[0], a1[1] + a2[1]] + when 13 then area_ortho_area([0.0, o2], [d, o1], left) + when 14 then area_ortho_area([0.0, o1], [d, o2], left) + when 15 then [0.0, 0.0] + end +end + +def generate_area_tex + w = AREATEX_W + tex = Array.new(w * AREATEX_H * 4, 0) + SUBSAMPLE_OFFSETS_ORTHO.each_with_index do |offset, y_idx| + pos_y = 5 * SIZE_ORTHO * y_idx + 16.times do |pattern| + ex, ey = EDGES_ORTHO[pattern] + 16.times do |left| + 16.times do |right| + p = areaortho(pattern, left * left, right * right, offset) + px = left + SIZE_ORTHO * ex + py = pos_y + right + SIZE_ORTHO * ey + idx = (py * w + px) * 4 + tex[idx] = (255.0 * p[0]).to_i + tex[idx + 1] = (255.0 * p[1]).to_i + end + end + end + end + tex.pack("C*") +end + +def generate_search_tex + bilerp = lambda do |c| + a = lerp(c[0], c[1], 1.0 - 0.25) + b = lerp(c[2], c[3], 1.0 - 0.25) + lerp(a, b, 1.0 - 0.125) + end + edge = {} + (0..15).each do |bits| + combo = [(bits >> 0) & 1, (bits >> 1) & 1, (bits >> 2) & 1, (bits >> 3) & 1] + edge[bilerp.call(combo)] = combo + end + delta_left = lambda do |left, top| + d = 0 + d += 1 if top[3] == 1 + d += 1 if d == 1 && top[2] == 1 && left[1] != 1 && left[3] != 1 + d + end + delta_right = lambda do |left, top| + d = 0 + d += 1 if top[3] == 1 && left[1] != 1 && left[3] != 1 + d += 1 if d == 1 && top[2] == 1 && left[0] != 1 && left[2] != 1 + d + end + gw, gh = 66, 33 + g = Array.new(gw * gh, 0) + 33.times do |x| + 33.times do |y| + tx = 0.03125 * x + ty = 0.03125 * y + next unless edge.key?(tx) && edge.key?(ty) + edges = [edge[tx], edge[ty]] + g[y * gw + x] = 127 * delta_left.call(*edges) + g[y * gw + (33 + x)] = 127 * delta_right.call(*edges) + end + end + cw, ch = 64, 16 + out = Array.new(cw * ch * 4, 0) + ch.times do |y| + cw.times do |x| + val = g[(17 + y) * gw + x] + idx = ((ch - 1 - y) * cw + x) * 4 + out[idx] = val; out[idx + 1] = val; out[idx + 2] = val; out[idx + 3] = val + end + end + out.pack("C*") +end + +area = generate_area_tex +search = generate_search_tex +abort "area size wrong: #{area.bytesize}" unless area.bytesize == 358_400 +abort "search size wrong: #{search.bytesize}" unless search.bytesize == 4_096 + +# The baked bytes live in C: a ~358KB Ruby string LITERAL hangs mruby's irep +# loader at boot, and string literals are capped at MRB_PARSER_TOKBUF_MAX +# (65534 chars). A C const array has neither limit; raylib_bindings.c exposes it +# to Ruby via Rl.smaa_area_bytes / Rl.smaa_search_bytes (mrb_str_new at runtime) +# and Ruby uploads it with Rl.update_texture. +out_path = File.expand_path("../src/smaa_tex_data.c", __dir__) +def c_array(name, bytes) + "const unsigned char #{name}[#{bytes.bytesize}] = {\n" + + bytes.bytes.each_slice(12).map { |row| " " + row.map { |b| "0x%02x" % b }.join(",") }.join(",\n") + + "\n};\n" +end +File.write(out_path, + "/* AUTO-GENERATED by tools/gen_smaa_tex.rb -- DO NOT EDIT.\n" \ + " * Canonical SMAA lookup textures (iryoku/smaa). Exposed to Ruby via the\n" \ + " * Rl.smaa_area_bytes / Rl.smaa_search_bytes helpers in raylib_bindings.c.\n" \ + " * Area: 160x560x4 RGBA8 (ortho region byte-exact; diag half zeroed -- we run\n" \ + " * SMAA with SMAA_DISABLE_DIAG_DETECTION). Search: 64x16x4 RGBA8 (R=index).\n" \ + " */\n" + + c_array("smaa_area_tex", area) + + c_array("smaa_search_tex", search)) +puts "wrote #{out_path} (area=#{area.bytesize}, search=#{search.bytesize} bytes)" diff --git a/mrbgems/raylib/tools/smaa_canonical.glsl b/mrbgems/raylib/tools/smaa_canonical.glsl new file mode 100644 index 0000000..395abc0 --- /dev/null +++ b/mrbgems/raylib/tools/smaa_canonical.glsl @@ -0,0 +1,254 @@ +vec3 SMAAGatherNeighbours(vec2 texcoord, + vec4 offset[3], + sampler2D tex) { + float P = texture(tex, texcoord).r; + float Pleft = texture(tex, offset[0].xy).r; + float Ptop = texture(tex, offset[0].zw).r; + return vec3(P, Pleft, Ptop); +} +vec2 SMAACalculatePredicatedThreshold(vec2 texcoord, + vec4 offset[3], + sampler2D predicationTex) { + vec3 neighbours = SMAAGatherNeighbours(texcoord, offset, predicationTex); + vec2 delta = abs(neighbours.xx - neighbours.yz); + vec2 edges = step(0.01, delta); + return 2.0 * 0.1 * (1.0 - 0.4 * edges); +} +void SMAAMovc(bvec2 cond, inout vec2 variable, vec2 value) { + if (cond.x) variable.x = value.x; + if (cond.y) variable.y = value.y; +} +void SMAAMovc(bvec4 cond, inout vec4 variable, vec4 value) { + SMAAMovc(cond.xy, variable.xy, value.xy); + SMAAMovc(cond.zw, variable.zw, value.zw); +} +vec2 SMAALumaEdgeDetectionPS(vec2 texcoord, + vec4 offset[3], + sampler2D colorTex + ) { + vec2 threshold = vec2(0.1, 0.1); + vec3 weights = vec3(0.2126, 0.7152, 0.0722); + float L = dot(texture(colorTex, texcoord).rgb, weights); + float Lleft = dot(texture(colorTex, offset[0].xy).rgb, weights); + float Ltop = dot(texture(colorTex, offset[0].zw).rgb, weights); + vec4 delta; + delta.xy = abs(L - vec2(Lleft, Ltop)); + vec2 edges = step(threshold, delta.xy); + if (dot(edges, vec2(1.0, 1.0)) == 0.0) + discard; + float Lright = dot(texture(colorTex, offset[1].xy).rgb, weights); + float Lbottom = dot(texture(colorTex, offset[1].zw).rgb, weights); + delta.zw = abs(L - vec2(Lright, Lbottom)); + vec2 maxDelta = max(delta.xy, delta.zw); + float Lleftleft = dot(texture(colorTex, offset[2].xy).rgb, weights); + float Ltoptop = dot(texture(colorTex, offset[2].zw).rgb, weights); + delta.zw = abs(vec2(Lleft, Ltop) - vec2(Lleftleft, Ltoptop)); + maxDelta = max(maxDelta.xy, delta.zw); + float finalDelta = max(maxDelta.x, maxDelta.y); + edges.xy *= step(finalDelta, 2.0 * delta.xy); + return edges; +} +vec2 SMAAColorEdgeDetectionPS(vec2 texcoord, + vec4 offset[3], + sampler2D colorTex + ) { + vec2 threshold = vec2(0.1, 0.1); + vec4 delta; + vec3 C = texture(colorTex, texcoord).rgb; + vec3 Cleft = texture(colorTex, offset[0].xy).rgb; + vec3 t = abs(C - Cleft); + delta.x = max(max(t.r, t.g), t.b); + vec3 Ctop = texture(colorTex, offset[0].zw).rgb; + t = abs(C - Ctop); + delta.y = max(max(t.r, t.g), t.b); + vec2 edges = step(threshold, delta.xy); + if (dot(edges, vec2(1.0, 1.0)) == 0.0) + discard; + vec3 Cright = texture(colorTex, offset[1].xy).rgb; + t = abs(C - Cright); + delta.z = max(max(t.r, t.g), t.b); + vec3 Cbottom = texture(colorTex, offset[1].zw).rgb; + t = abs(C - Cbottom); + delta.w = max(max(t.r, t.g), t.b); + vec2 maxDelta = max(delta.xy, delta.zw); + vec3 Cleftleft = texture(colorTex, offset[2].xy).rgb; + t = abs(C - Cleftleft); + delta.z = max(max(t.r, t.g), t.b); + vec3 Ctoptop = texture(colorTex, offset[2].zw).rgb; + t = abs(C - Ctoptop); + delta.w = max(max(t.r, t.g), t.b); + maxDelta = max(maxDelta.xy, delta.zw); + float finalDelta = max(maxDelta.x, maxDelta.y); + edges.xy *= step(finalDelta, 2.0 * delta.xy); + return edges; +} +vec2 SMAADepthEdgeDetectionPS(vec2 texcoord, + vec4 offset[3], + sampler2D depthTex) { + vec3 neighbours = SMAAGatherNeighbours(texcoord, offset, depthTex); + vec2 delta = abs(neighbours.xx - vec2(neighbours.y, neighbours.z)); + vec2 edges = step((0.1 * 0.1), delta); + if (dot(edges, vec2(1.0, 1.0)) == 0.0) + discard; + return edges; +} +float SMAASearchLength(sampler2D searchTex, vec2 e, float offset) { + vec2 scale = vec2(66.0, 33.0) * vec2(0.5, -1.0); + vec2 bias = vec2(66.0, 33.0) * vec2(offset, 1.0); + scale += vec2(-1.0, 1.0); + bias += vec2( 0.5, -0.5); + scale *= 1.0 / vec2(64.0, 16.0); + bias *= 1.0 / vec2(64.0, 16.0); + return textureLod(searchTex, (scale * e + bias), 0.0).r; +} +float SMAASearchXLeft(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) { + vec2 e = vec2(0.0, 1.0); + while (texcoord.x > end && + e.g > 0.8281 && + e.r == 0.0) { + e = textureLod(edgesTex, texcoord, 0.0).rg; + texcoord = (-vec2(2.0, 0.0) * SMAA_RT_METRICS.xy + texcoord); + } + float offset = (-(255.0 / 127.0) * SMAASearchLength(searchTex, e, 0.0) + 3.25); + return (SMAA_RT_METRICS.x * offset + texcoord.x); +} +float SMAASearchXRight(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) { + vec2 e = vec2(0.0, 1.0); + while (texcoord.x < end && + e.g > 0.8281 && + e.r == 0.0) { + e = textureLod(edgesTex, texcoord, 0.0).rg; + texcoord = (vec2(2.0, 0.0) * SMAA_RT_METRICS.xy + texcoord); + } + float offset = (-(255.0 / 127.0) * SMAASearchLength(searchTex, e, 0.5) + 3.25); + return (-SMAA_RT_METRICS.x * offset + texcoord.x); +} +float SMAASearchYUp(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) { + vec2 e = vec2(1.0, 0.0); + while (texcoord.y > end && + e.r > 0.8281 && + e.g == 0.0) { + e = textureLod(edgesTex, texcoord, 0.0).rg; + texcoord = (-vec2(0.0, 2.0) * SMAA_RT_METRICS.xy + texcoord); + } + float offset = (-(255.0 / 127.0) * SMAASearchLength(searchTex, e.gr, 0.0) + 3.25); + return (SMAA_RT_METRICS.y * offset + texcoord.y); +} +float SMAASearchYDown(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) { + vec2 e = vec2(1.0, 0.0); + while (texcoord.y < end && + e.r > 0.8281 && + e.g == 0.0) { + e = textureLod(edgesTex, texcoord, 0.0).rg; + texcoord = (vec2(0.0, 2.0) * SMAA_RT_METRICS.xy + texcoord); + } + float offset = (-(255.0 / 127.0) * SMAASearchLength(searchTex, e.gr, 0.5) + 3.25); + return (-SMAA_RT_METRICS.y * offset + texcoord.y); +} +vec2 SMAAArea(sampler2D areaTex, vec2 dist, float e1, float e2, float offset) { + vec2 texcoord = (vec2(16, 16) * round(4.0 * vec2(e1, e2)) + dist); + texcoord = ((1.0 / vec2(160.0, 560.0)) * texcoord + 0.5 * (1.0 / vec2(160.0, 560.0))); + texcoord.y = ((1.0 / 7.0) * offset + texcoord.y); + return textureLod(areaTex, texcoord, 0.0).rg; +} +void SMAADetectHorizontalCornerPattern(sampler2D edgesTex, inout vec2 weights, vec4 texcoord, vec2 d) { + vec2 leftRight = step(d.xy, d.yx); + vec2 rounding = (1.0 - (float(25) / 100.0)) * leftRight; + rounding /= leftRight.x + leftRight.y; + vec2 factor = vec2(1.0, 1.0); + factor.x -= rounding.x * textureLodOffset(edgesTex, texcoord.xy, 0.0, ivec2(0, 1)).r; + factor.x -= rounding.y * textureLodOffset(edgesTex, texcoord.zw, 0.0, ivec2(1, 1)).r; + factor.y -= rounding.x * textureLodOffset(edgesTex, texcoord.xy, 0.0, ivec2(0, -2)).r; + factor.y -= rounding.y * textureLodOffset(edgesTex, texcoord.zw, 0.0, ivec2(1, -2)).r; + weights *= clamp(factor, 0.0, 1.0); +} +void SMAADetectVerticalCornerPattern(sampler2D edgesTex, inout vec2 weights, vec4 texcoord, vec2 d) { + vec2 leftRight = step(d.xy, d.yx); + vec2 rounding = (1.0 - (float(25) / 100.0)) * leftRight; + rounding /= leftRight.x + leftRight.y; + vec2 factor = vec2(1.0, 1.0); + factor.x -= rounding.x * textureLodOffset(edgesTex, texcoord.xy, 0.0, ivec2( 1, 0)).g; + factor.x -= rounding.y * textureLodOffset(edgesTex, texcoord.zw, 0.0, ivec2( 1, 1)).g; + factor.y -= rounding.x * textureLodOffset(edgesTex, texcoord.xy, 0.0, ivec2(-2, 0)).g; + factor.y -= rounding.y * textureLodOffset(edgesTex, texcoord.zw, 0.0, ivec2(-2, 1)).g; + weights *= clamp(factor, 0.0, 1.0); +} +vec4 SMAABlendingWeightCalculationPS(vec2 texcoord, + vec2 pixcoord, + vec4 offset[3], + sampler2D edgesTex, + sampler2D areaTex, + sampler2D searchTex, + vec4 subsampleIndices) { + vec4 weights = vec4(0.0, 0.0, 0.0, 0.0); + vec2 e = texture(edgesTex, texcoord).rg; + + if (e.g > 0.0) { + vec2 d; + vec3 coords; + coords.x = SMAASearchXLeft(edgesTex, searchTex, offset[0].xy, offset[2].x); + coords.y = offset[1].y; + d.x = coords.x; + float e1 = textureLod(edgesTex, coords.xy, 0.0).r; + coords.z = SMAASearchXRight(edgesTex, searchTex, offset[0].zw, offset[2].y); + d.y = coords.z; + d = abs(round((SMAA_RT_METRICS.zz * d + -pixcoord.xx))); + vec2 sqrt_d = sqrt(d); + float e2 = textureLodOffset(edgesTex, coords.zy, 0.0, ivec2(1, 0)).r; + weights.rg = SMAAArea(areaTex, sqrt_d, e1, e2, subsampleIndices.y); + coords.y = texcoord.y; + SMAADetectHorizontalCornerPattern(edgesTex, weights.rg, coords.xyzy, d); + } + + if (e.r > 0.0) { + vec2 d; + vec3 coords; + coords.y = SMAASearchYUp(edgesTex, searchTex, offset[1].xy, offset[2].z); + coords.x = offset[0].x; + d.x = coords.y; + float e1 = textureLod(edgesTex, coords.xy, 0.0).g; + coords.z = SMAASearchYDown(edgesTex, searchTex, offset[1].zw, offset[2].w); + d.y = coords.z; + d = abs(round((SMAA_RT_METRICS.ww * d + -pixcoord.yy))); + vec2 sqrt_d = sqrt(d); + float e2 = textureLodOffset(edgesTex, coords.xz, 0.0, ivec2(0, 1)).g; + weights.ba = SMAAArea(areaTex, sqrt_d, e1, e2, subsampleIndices.x); + coords.x = texcoord.x; + SMAADetectVerticalCornerPattern(edgesTex, weights.ba, coords.xyxz, d); + } + return weights; +} +vec4 SMAANeighborhoodBlendingPS(vec2 texcoord, + vec4 offset, + sampler2D colorTex, + sampler2D blendTex + ) { + vec4 a; + a.x = texture(blendTex, offset.xy).a; + a.y = texture(blendTex, offset.zw).g; + a.wz = texture(blendTex, texcoord).xz; + + if (dot(a, vec4(1.0, 1.0, 1.0, 1.0)) < 1e-5) { + vec4 color = textureLod(colorTex, texcoord, 0.0); + return color; + } else { + bool h = max(a.x, a.z) > max(a.y, a.w); + vec4 blendingOffset = vec4(0.0, a.y, 0.0, a.w); + vec2 blendingWeight = a.yw; + SMAAMovc(bvec4(h, h, h, h), blendingOffset, vec4(a.x, 0.0, a.z, 0.0)); + SMAAMovc(bvec2(h, h), blendingWeight, a.xz); + blendingWeight /= dot(blendingWeight, vec2(1.0, 1.0)); + vec4 blendingCoord = (blendingOffset * vec4(SMAA_RT_METRICS.xy, -SMAA_RT_METRICS.xy) + texcoord.xyxy); + vec4 color = blendingWeight.x * textureLod(colorTex, blendingCoord.xy, 0.0); + color += blendingWeight.y * textureLod(colorTex, blendingCoord.zw, 0.0); + return color; + } +} +vec4 SMAAResolvePS(vec2 texcoord, + sampler2D currentColorTex, + sampler2D previousColorTex + ) { + vec4 current = texture(currentColorTex, texcoord); + vec4 previous = texture(previousColorTex, texcoord); + return mix(current, previous, 0.5); +} diff --git a/mrbgems/rmlui/mrbgem.rake b/mrbgems/rmlui/mrbgem.rake new file mode 100644 index 0000000..290d2b5 --- /dev/null +++ b/mrbgems/rmlui/mrbgem.rake @@ -0,0 +1,16 @@ +MRuby::Gem::Specification.new('rmlui') do |spec| + spec.license = 'MIT' + spec.authors = 'raylib-jamstack' + spec.summary = 'Ruby (Rml::) bindings for RmlUi, rendered via raylib/rlgl' + + stack_root = ENV['JAMSTACK_ROOT'] || File.expand_path('../../..', __dir__) + + rmlui_inc = File.join(stack_root, 'vendor', 'rmlui', 'Include') + raylib_inc = File.join(stack_root, 'vendor', 'raylib', 'src') + + spec.cxx.include_paths << rmlui_inc + spec.cxx.include_paths << raylib_inc + spec.cxx.flags << '-std=c++17' + + # RmlUi static lib + its deps (freetype, libstdc++) are linked by build.zig. +end diff --git a/mrbgems/rmlui/mrblib/console.rb b/mrbgems/rmlui/mrblib/console.rb new file mode 100644 index 0000000..53fa194 --- /dev/null +++ b/mrbgems/rmlui/mrblib/console.rb @@ -0,0 +1,203 @@ +module Jamstack + class Console + attr_reader :doc, :input, :scrollback + + KI_RETURN = 72 + KI_TAB = 70 + KI_UP = 91 + KI_DOWN = 93 + + def initialize(ctx, binding: binding(), toggle_key: 92, toggle_key_alt: 96, rml_path: "game/ui/console.rml") + @binding = binding + @toggle_key = toggle_key + @toggle_key_alt = toggle_key_alt + @open = false + @history = [] + @history_index = 0 + @lines = [] + @max_lines = 200 + @bs_prev = false + @grave_prev = false + + @doc = ctx.load_document(rml_path) + @doc.hide + @scrollback = @doc.element("scrollback") + @input = @doc.element("cmd_input") + + append_info("Jamstack REPL — press \\ or ~ to toggle. Enter to eval. Up/Down for history. Tab to complete.") + + @input.on(:keydown) do |ev| + key = ev["key_identifier"].to_i + case key + when KI_RETURN + ev.stop_propagation + submit + when KI_TAB + ev.stop_propagation + complete + when KI_UP + ev.stop_propagation + history_prev + when KI_DOWN + ev.stop_propagation + history_next + end + end + end + + def open? + @open + end + + def update + ctrl = Rl.key_down?(:left_control) || Rl.key_down?(Rl::KEY_RIGHT_CONTROL) + bs_down = Rl.key_down?(@toggle_key) + grave_down = Rl.key_down?(@toggle_key_alt) + + bs_pressed = bs_down && !@bs_prev + grave_pressed = grave_down && !@grave_prev + @bs_prev = bs_down + @grave_prev = grave_down + + return unless bs_pressed || grave_pressed + + if ctrl && @open + shift = Rl.key_down?(:left_shift) || Rl.key_down?(Rl::KEY_RIGHT_SHIFT) + char = if bs_pressed + shift ? "|" : "\\" + else + shift ? "~" : "`" + end + @input["value"] = @input["value"].to_s + char + @input.caret_end + else + toggle + end + while Rl.get_key_pressed != 0; end + while Rl.get_char_pressed != 0; end + end + + def toggle + @open ? hide : show + end + + def show + @doc.show + @doc.pull_to_front + @input.focus + @open = true + end + + def hide + @doc.hide + @input.blur + @open = false + end + + def puts(msg) + msg.to_s.split("\n").each { |line| append_line(line, "result") } + end + + private + + def submit + line = @input["value"].to_s + @input["value"] = "" + return if line.empty? + + @history << line + @history_index = @history.length + + append_line("> #{line}", "cmd") + + begin + result = eval_line(line) + append_line(format_result(result), "result") unless result.nil? + rescue => e + append_line(format_error(e), "error") + end + end + + def history_prev + return if @history.empty? + @history_index -= 1 if @history_index > 0 + @input["value"] = @history[@history_index] || "" + @input.caret_end + end + + def history_next + return if @history.empty? + @history_index += 1 if @history_index < @history.length + @input["value"] = @history[@history_index] || "" + @input.caret_end + end + + def complete + text = @input["value"].to_s + return if text.empty? + + receiver_expr, prefix, kind = Jamstack::Bridge.parse_completion(text) + candidates = Jamstack::Bridge.gather_candidates(receiver_expr, prefix, kind, @binding) + return if candidates.empty? + + sep = kind == :constant ? "::" : "." + base = receiver_expr ? receiver_expr + sep : "" + + if candidates.length == 1 + @input["value"] = base + candidates[0] + else + common = Jamstack::Bridge.common_prefix(candidates) + @input["value"] = base + common if common.length > prefix.length + shown = candidates.length > 40 ? candidates.first(40) + ["..."] : candidates + append_line(shown.join(" "), "info") + end + @input.caret_end + end + + def eval_line(line) + Jamstack::Bridge.eval_in_binding(line, @binding, "(console)") + end + + def append_info(text) + append_line(text, "info") + end + + def append_line(text, cls) + @lines << [cls, text] + @lines.shift if @lines.length > @max_lines + rebuild_scrollback + end + + def rebuild_scrollback + html = @lines.map { |cls, text| + "<div class=\"line #{cls}\">#{escape_html(text)}</div>" + }.join + html += "<div id=\"scroll_end\"></div>" + @scrollback.inner_rml = html + sentinel = @scrollback.element("scroll_end") + sentinel.scroll_into_view(false) if sentinel + end + + def escape_html(s) + s = s.to_s + s = s.gsub("&", "&") + s = s.gsub("<", "<") + s = s.gsub(">", ">") + s = s.gsub("\n", "<br/>") + s + end + + def format_result(result) + "#{result.inspect}" + end + + def format_error(e) + msg = "#{e.class}: #{e.message}" + if e.respond_to?(:backtrace) && bt = e.backtrace + bt = bt.is_a?(Array) ? bt : bt.to_a + msg += "\n " + bt.first(5).join("\n ") unless bt.empty? + end + msg + end + end +end diff --git a/mrbgems/rmlui/mrblib/rmlui.rb b/mrbgems/rmlui/mrblib/rmlui.rb new file mode 100644 index 0000000..f4fe4eb --- /dev/null +++ b/mrbgems/rmlui/mrblib/rmlui.rb @@ -0,0 +1,244 @@ +# Friendly Ruby surface for RmlUi, built on the low-level Rml._* primitives. +# Implements the subset of docs/API_SPEC_RMLUI.md needed for the first milestone: +# init, fonts, a Context, loading + showing a static document, and the per-frame +# update/render/input cycle. + +module Rml + class << self + # Must be called AFTER Rl.init_window (needs the GL context). + def init + _init + end + + def shutdown + _shutdown + end + + def load_font(path, fallback: false) + _load_font(path.to_s, fallback) + end + end + + # A node in the RML document tree. Wraps a native Element* (non-owning). + # (API_SPEC_RMLUI 6) + class Element + attr_reader :ptr + def initialize(ptr); @ptr = ptr; end + + # attributes + def [](name) = Rml._el_get_attribute(@ptr, name.to_s) + def []=(name, v); Rml._el_set_attribute(@ptr, name.to_s, v.to_s); end + def attribute(name) = Rml._el_get_attribute(@ptr, name.to_s) + def set_attribute(name, v); Rml._el_set_attribute(@ptr, name.to_s, v.to_s); self; end + def has_attribute?(name) = Rml._el_has_attribute(@ptr, name.to_s) + def remove_attribute(name); Rml._el_remove_attribute(@ptr, name.to_s); self; end + + # identity / content + def id = Rml._el_get_id(@ptr) + def id=(v); Rml._el_set_id(@ptr, v.to_s); end + def tag_name = Rml._el_tag(@ptr) + def inner_rml = Rml._el_get_inner_rml(@ptr) + def inner_rml=(v); Rml._el_set_inner_rml(@ptr, v.to_s); end + alias_method :text, :inner_rml + def text=(v); Rml._el_set_inner_rml(@ptr, v.to_s); end + + # classes / style properties + def set_class(name, on); Rml._el_set_class(@ptr, name.to_s, on); self; end + def add_class(name); set_class(name, true); end + def remove_class(name); set_class(name, false); end + def class_set?(name) = Rml._el_is_class_set(@ptr, name.to_s) + def set_property(name, val); Rml._el_set_property(@ptr, name.to_s, val.to_s); self; end + def property(name) = Rml._el_get_property(@ptr, name.to_s) + def remove_property(name); Rml._el_remove_property(@ptr, name.to_s); self; end + + # actions + def focus; Rml._el_focus(@ptr); self; end + def blur; Rml._el_blur(@ptr); self; end + def click; Rml._el_click(@ptr); self; end + def scroll_into_view(align_top = true); Rml._el_scroll_into_view(@ptr, align_top); self; end + def visible? = Rml._el_is_visible(@ptr) + def select_all; Rml._el_select(@ptr); self; end + def set_selection_range(start, finish); Rml._el_set_selection_range(@ptr, start, finish); self; end + def caret_end; v = self["value"].to_s; set_selection_range(v.length, v.length); self; end + + # traversal / queries (return Element / Array<Element> / nil) + def element(id) = Rml._el_get_element_by_id(@ptr, id.to_s) + alias_method :get_element_by_id, :element + def query_selector(sel) = Rml._el_query_selector(@ptr, sel.to_s) + def query_selector_all(sel) = Rml._el_query_selector_all(@ptr, sel.to_s) + def elements_by_tag(tag) = Rml._el_get_elements_by_tag(@ptr, tag.to_s) + def parent = Rml._el_parent(@ptr) + def child_count = Rml._el_num_children(@ptr) + def child(i) = Rml._el_child(@ptr, i) + def children = (0...child_count).map { |i| child(i) } + def owner_document = Rml._el_owner_document(@ptr) + + # geometry + def client_width = Rml._el_client_width(@ptr) + def client_height = Rml._el_client_height(@ptr) + def offset_left = Rml._el_offset_left(@ptr) + def offset_top = Rml._el_offset_top(@ptr) + def absolute_left = Rml._el_absolute_left(@ptr) + def absolute_top = Rml._el_absolute_top(@ptr) + + # events: el.on(:click) { |event| ... } + def on(type, &block); Rml._el_add_event_listener(@ptr, type.to_s, &block); self; end + end + + # An event delivered to an Element#on listener. (API_SPEC_RMLUI 5) + class Event + def initialize(ptr); @ptr = ptr; end + def type = Rml._ev_type(@ptr) + def target = Rml._ev_target(@ptr) + def current = Rml._ev_current(@ptr) + def stop_propagation; Rml._ev_stop_propagation(@ptr); end + def stop_immediate_propagation; Rml._ev_stop_immediate(@ptr); end + def [](key) = Rml._ev_param_float(@ptr, key.to_s) + def param(key) = Rml._ev_param_float(@ptr, key.to_s) + def param_str(key) = Rml._ev_param_str(@ptr, key.to_s) + def mouse_x = param("mouse_x") + def mouse_y = param("mouse_y") + end + + # An ElementDocument: an Element with show/hide/title/etc. + class Document < Element + def show; Rml._document_show(@ptr); self; end + def hide; Rml._document_hide(@ptr); self; end + def close; Rml._doc_close(@ptr); self; end + def title = Rml._doc_title(@ptr) + def title=(t); Rml._doc_set_title(@ptr, t.to_s); end + def pull_to_front; Rml._doc_pull_to_front(@ptr); self; end + def push_to_back; Rml._doc_push_to_back(@ptr); self; end + end + + # MVC data model: binds Ruby state to {{vars}} / data-* attributes in RML. + # (API_SPEC_RMLUI 4) + class DataModel + def initialize(ctx_ptr, name) + @getters = {} + @values = {} + @events = {} + @ptr = Rml._data_model_create(ctx_ptr, name.to_s, self) + end + + # one-way computed view: m.bind(:hp) { player.hp } + def bind(name, &getter) + @getters[name.to_s] = getter + Rml._data_model_bind_get(@ptr, name.to_s) + self + end + + # two-way scalar: m.value(:volume, 0.5) + def value(name, initial) + @values[name.to_s] = initial + Rml._data_model_bind_scalar(@ptr, name.to_s) + self + end + + # controller callback: m.event(:reset) { ... } (rml: data-event-click="reset()") + def event(name, &blk) + @events[name.to_s] = blk + Rml._data_model_bind_event(@ptr, name.to_s) + self + end + + def finish + Rml._data_model_finish(@ptr) + self + end + + # notify the view that bound variables changed (DataModelHandle::DirtyVariable) + def dirty(*names) + names.each { |n| Rml._data_model_dirty(@ptr, n.to_s) } + self + end + + def dirty_all + Rml._data_model_dirty_all(@ptr) + self + end + + def [](name) + @values[name.to_s] + end + + def []=(name, v) + @values[name.to_s] = v + Rml._data_model_dirty(@ptr, name.to_s) + end + + # --- called from the C++ bridge --- + def __get(name) + @getters.key?(name) ? @getters[name].call : @values[name] + end + + def __set(name, v) + @values[name] = v + end + + def __event(name) + blk = @events[name] + blk.call if blk + end + end + + class Context + def initialize(name, width: nil, height: nil) + width ||= Rl.screen_width + height ||= Rl.screen_height + @ptr = Rml._create_context(name.to_s, width, height) + raise "failed to create RmlUi context #{name.inspect}" if @ptr.nil? + end + + def dimensions=(vec2) + Rml._context_set_dimensions(@ptr, vec2.x, vec2.y) + end + + def resize(width, height) + Rml._context_set_dimensions(@ptr, width, height) + end + + # Create + configure a data model. Must be called BEFORE load_document so the + # document can bind to it by name. (API_SPEC_RMLUI 4.1) + def data_model(name) + m = DataModel.new(@ptr, name) + yield m if block_given? + m.finish + m + end + + def load_document(path) + ptr = Rml._context_load_document(@ptr, path.to_s) + raise "failed to load document #{path}" if ptr.nil? + doc = Document.new(ptr) + yield doc if block_given? + doc + end + + # Look up an already-loaded document by its id/source. Returns a bare + # Element wrapper (use load_document's return value for Document methods). + def document(id) = Rml._context_get_document(@ptr, id.to_s) + def num_documents = Rml._context_num_documents(@ptr) + + def process_input + Rml._context_process_input(@ptr) + end + + def update + Rml._context_update(@ptr) + end + + def render + Rml._context_render(@ptr) + end + + # Block helper: process_input before, update+render after (API_SPEC_RMLUI 2). + def frame + process_input + yield + ensure + update + render + end + end +end diff --git a/mrbgems/rmlui/src/rml_bindings.cpp b/mrbgems/rmlui/src/rml_bindings.cpp new file mode 100644 index 0000000..3c22899 --- /dev/null +++ b/mrbgems/rmlui/src/rml_bindings.cpp @@ -0,0 +1,882 @@ +/* RmlUi <-> mruby bindings + raylib (rlgl) render/system backend. + * + * Minimal milestone: initialize RmlUi against raylib's GL context, create a + * context, load + show a static .rml document, update/render it over the game, + * and feed raylib mouse input. Data binding comes next. + * + * The render interface is implemented against rlgl (raylib's GL abstraction) so + * the same code path works on desktop GL and (later) WebGL under emscripten. + */ +#include <mruby.h> +#include <mruby/string.h> +#include <mruby/data.h> +#include <mruby/array.h> +#include <mruby/gc.h> + +#include <RmlUi/Core.h> +#include <raylib.h> +#include <rlgl.h> + +#include <vector> +#include <string> +#include <cstring> +#include <set> + +using namespace Rml; + +/* ------------------------------------------------------------------ */ +/* Render interface (rlgl) */ +/* ------------------------------------------------------------------ */ +namespace { + +struct RlGeometry { + std::vector<Vertex> vertices; + std::vector<int> indices; +}; + +class RaylibRenderInterface : public Rml::RenderInterface { +public: + CompiledGeometryHandle CompileGeometry(Span<const Vertex> vertices, Span<const int> indices) override + { + RlGeometry *geo = new RlGeometry(); + geo->vertices.assign(vertices.begin(), vertices.end()); + geo->indices.assign(indices.begin(), indices.end()); + return (CompiledGeometryHandle)geo; + } + + void RenderGeometry(CompiledGeometryHandle handle, Vector2f translation, TextureHandle texture) override + { + RlGeometry *geo = (RlGeometry *)handle; + unsigned int tex = texture ? (unsigned int)texture : rlGetTextureIdDefault(); + + // IMPORTANT: rlBegin() resets the draw group's texture to the default texture + // on a draw-mode change, so rlSetTexture() MUST be called AFTER rlBegin(). + // (raylib's own DrawTexture* only works set-then-begin because everything is + // already RL_QUADS and no mode change occurs.) + rlBegin(RL_TRIANGLES); + rlSetTexture(tex); + for (int idx : geo->indices) { + const Vertex &v = geo->vertices[(size_t)idx]; + // Apply the element transform (SetTransform) to the translated vertex, + // then perspective-divide. RmlUi passes the local transform via + // SetTransform and the element's screen position via `translation` + // (matching GL2's modelview = transform * translate(v)). When no + // transform is set (nullptr) we fall back to the raw translated point, + // so non-transformed elements render exactly as before. + float x = v.position.x + translation.x; + float y = v.position.y + translation.y; + if (m_has_transform) { + Rml::Vector4f tp = m_transform * Rml::Vector4f(x, y, 0.0f, 1.0f); + float w = tp.w; + if (w != 0.0f) { x = tp.x / w; y = tp.y / w; } + else { x = tp.x; y = tp.y; } + } + rlColor4ub(v.colour.red, v.colour.green, v.colour.blue, v.colour.alpha); + rlTexCoord2f(v.tex_coord.x, v.tex_coord.y); + rlVertex2f(x, y); + } + rlEnd(); + // Flush each geometry as its own draw. rlgl's batch is quad-centric and pads + // RL_TRIANGLES vertex runs for quad-index alignment; letting multiple glyph + // runs (different textures) accumulate corrupts geometry across draw groups. + rlSetTexture(0); + rlDrawRenderBatchActive(); + } + + void ReleaseGeometry(CompiledGeometryHandle handle) override + { + delete (RlGeometry *)handle; + } + + TextureHandle LoadTexture(Vector2i &dims, const String &source) override + { + Image img = LoadImage(source.c_str()); + if (img.data == nullptr) return 0; + ImageFormat(&img, RL_PIXELFORMAT_UNCOMPRESSED_R8G8B8A8); + dims.x = img.width; + dims.y = img.height; + unsigned int id = rlLoadTexture(img.data, img.width, img.height, + RL_PIXELFORMAT_UNCOMPRESSED_R8G8B8A8, 1); + UnloadImage(img); + return (TextureHandle)id; + } + + TextureHandle GenerateTexture(Span<const byte> source, Vector2i dims) override + { + // RmlUi 6.x renders with premultiplied alpha. The font/image atlas data here + // is straight (non-premultiplied) RGBA (RGB=255 where alpha=coverage), so we + // premultiply on upload; otherwise premult blending makes the area outside + // each glyph additive and fills the whole quad (solid squares). + // RmlUi 6.x already supplies premultiplied-alpha RGBA, upload as-is. + unsigned int id = rlLoadTexture(source.data(), dims.x, dims.y, + RL_PIXELFORMAT_UNCOMPRESSED_R8G8B8A8, 1); + return (TextureHandle)id; + } + + void ReleaseTexture(TextureHandle texture) override + { + rlUnloadTexture((unsigned int)texture); + } + + // Stores the element's local transform (includes transform-origin baking and + // any perspective()/rotate3d()). Applied per-vertex in RenderGeometry with a + // perspective divide, mirroring RmlUi's GL2 reference backend (modelview = + // transform * translate(v)). nullptr => identity (render raw, as before). + void SetTransform(const Rml::Matrix4f* transform) override + { + if (transform) { m_transform = *transform; m_has_transform = true; } + else m_has_transform = false; + } + + void EnableScissorRegion(bool enable) override + { + rlDrawRenderBatchActive(); + if (enable) rlEnableScissorTest(); + else rlDisableScissorTest(); + } + + void SetScissorRegion(Rectanglei region) override + { + rlDrawRenderBatchActive(); + int h = region.Height(); + int top = region.Position().y; + int y = GetScreenHeight() - (top + h); /* GL scissor is bottom-left origin */ + rlScissor(region.Position().x, y, region.Width(), h); + } + +private: + Rml::Matrix4f m_transform; + bool m_has_transform = false; +}; + +class RaylibSystemInterface : public Rml::SystemInterface { +public: + double GetElapsedTime() override { return GetTime(); } + bool LogMessage(Log::Type type, const String &message) override + { + int level = (type == Log::LT_ERROR || type == Log::LT_ASSERT) ? LOG_WARNING : LOG_INFO; + TraceLog(level, "RmlUi: %s", message.c_str()); + return true; + } +}; + +RaylibRenderInterface *g_render = nullptr; +RaylibSystemInterface *g_system = nullptr; + +/* ------------------------------------------------------------------ */ +/* Data binding bridge (Ruby <-> RmlUi data model) */ +/* ------------------------------------------------------------------ */ + +struct ModelSession { + mrb_state *mrb; + mrb_value ruby_model; /* the Ruby Rml::DataModel instance */ + DataModelConstructor *ctor; /* alive only during construction */ + DataModelHandle handle; +}; + +static Variant ruby_to_variant(mrb_state *mrb, mrb_value v) +{ + switch (mrb_type(v)) { + case MRB_TT_INTEGER: return Variant((int)mrb_integer(v)); + case MRB_TT_FLOAT: return Variant((float)mrb_float(v)); + case MRB_TT_TRUE: return Variant(true); + case MRB_TT_FALSE: return Variant(false); + case MRB_TT_STRING: return Variant(String(mrb_string_cstr(mrb, v))); + case MRB_TT_SYMBOL: return Variant(String(mrb_sym_name(mrb, mrb_symbol(v)))); + default: { + mrb_value s = mrb_funcall(mrb, v, "to_s", 0); + return Variant(String(mrb_string_cstr(mrb, s))); + } + } +} + +static mrb_value variant_to_ruby(mrb_state *mrb, const Variant &var) +{ + switch (var.GetType()) { + case Variant::BOOL: + return mrb_bool_value(var.Get<bool>()); + case Variant::INT: case Variant::INT64: + case Variant::UINT: case Variant::UINT64: + case Variant::BYTE: case Variant::CHAR: + return mrb_fixnum_value(var.Get<int>()); + case Variant::FLOAT: case Variant::DOUBLE: + return mrb_float_value(mrb, var.Get<double>()); + default: + return mrb_str_new_cstr(mrb, var.Get<String>().c_str()); + } +} + +} /* anonymous namespace */ + +/* ------------------------------------------------------------------ */ +/* mruby bindings (low-level Rml._* primitives) */ +/* ------------------------------------------------------------------ */ + +static mrb_value +rml_init(mrb_state *mrb, mrb_value self) +{ + if (g_render == nullptr) { + g_render = new RaylibRenderInterface(); + g_system = new RaylibSystemInterface(); + Rml::SetSystemInterface(g_system); + Rml::SetRenderInterface(g_render); + Rml::Initialise(); + } + return mrb_nil_value(); +} + +static mrb_value +rml_shutdown(mrb_state *mrb, mrb_value self) +{ + if (g_render != nullptr) { + Rml::Shutdown(); + delete g_render; g_render = nullptr; + delete g_system; g_system = nullptr; + } + return mrb_nil_value(); +} + +static mrb_value +rml_load_font(mrb_state *mrb, mrb_value self) +{ + const char *path; + mrb_bool fallback = FALSE; + mrb_get_args(mrb, "z|b", &path, &fallback); + bool ok = Rml::LoadFontFace(path, fallback); + return mrb_bool_value(ok); +} + +static mrb_value +rml_create_context(mrb_state *mrb, mrb_value self) +{ + const char *name; + mrb_int w, h; + mrb_get_args(mrb, "zii", &name, &w, &h); + Context *ctx = Rml::CreateContext(name, Vector2i((int)w, (int)h)); + if (!ctx) return mrb_nil_value(); + return mrb_cptr_value(mrb, ctx); +} + +static Context * +ctx_arg(mrb_state *mrb) +{ + mrb_value p; + mrb_get_args(mrb, "o", &p); + return (Context *)mrb_cptr(p); +} + +static mrb_value +rml_context_set_dimensions(mrb_state *mrb, mrb_value self) +{ + mrb_value p; mrb_int w, h; + mrb_get_args(mrb, "oii", &p, &w, &h); + ((Context *)mrb_cptr(p))->SetDimensions(Vector2i((int)w, (int)h)); + return mrb_nil_value(); +} + +/* ------------------------------------------------------------------ */ +/* Keyboard input: raylib -> RmlUi key map + modifier state */ +/* (closes the input gap that blocked the in-game console, roadmap R6) */ +/* ------------------------------------------------------------------ */ + +/* raylib KeyboardKey -> RmlUi Input::KeyIdentifier. OEM punctuation keys + * (;',./etc.) deliberately return KI_UNKNOWN — those arrive via + * GetCharPressed -> ProcessTextInput (the character codepoint path), which is + * how RmlUi's own GLFW backend handles them too. */ +static Input::KeyIdentifier rl_key_to_rml(int key) +{ + switch (key) { + case KEY_SPACE: return Input::KI_SPACE; + case KEY_APOSTROPHE: return Input::KI_OEM_7; /* ' " */ + case KEY_COMMA: return Input::KI_OEM_COMMA; + case KEY_MINUS: return Input::KI_OEM_MINUS; + case KEY_PERIOD: return Input::KI_OEM_PERIOD; + case KEY_SLASH: return Input::KI_OEM_2; /* / ? */ + case KEY_ZERO: return Input::KI_0; + case KEY_ONE: return Input::KI_1; + case KEY_TWO: return Input::KI_2; + case KEY_THREE: return Input::KI_3; + case KEY_FOUR: return Input::KI_4; + case KEY_FIVE: return Input::KI_5; + case KEY_SIX: return Input::KI_6; + case KEY_SEVEN: return Input::KI_7; + case KEY_EIGHT: return Input::KI_8; + case KEY_NINE: return Input::KI_9; + case KEY_SEMICOLON: return Input::KI_OEM_1; + case KEY_EQUAL: return Input::KI_OEM_PLUS; + case KEY_A: return Input::KI_A; + case KEY_B: return Input::KI_B; + case KEY_C: return Input::KI_C; + case KEY_D: return Input::KI_D; + case KEY_E: return Input::KI_E; + case KEY_F: return Input::KI_F; + case KEY_G: return Input::KI_G; + case KEY_H: return Input::KI_H; + case KEY_I: return Input::KI_I; + case KEY_J: return Input::KI_J; + case KEY_K: return Input::KI_K; + case KEY_L: return Input::KI_L; + case KEY_M: return Input::KI_M; + case KEY_N: return Input::KI_N; + case KEY_O: return Input::KI_O; + case KEY_P: return Input::KI_P; + case KEY_Q: return Input::KI_Q; + case KEY_R: return Input::KI_R; + case KEY_S: return Input::KI_S; + case KEY_T: return Input::KI_T; + case KEY_U: return Input::KI_U; + case KEY_V: return Input::KI_V; + case KEY_W: return Input::KI_W; + case KEY_X: return Input::KI_X; + case KEY_Y: return Input::KI_Y; + case KEY_Z: return Input::KI_Z; + case KEY_LEFT_BRACKET: return Input::KI_OEM_4; /* [ { */ + case KEY_BACKSLASH: return Input::KI_OEM_5; /* \ | */ + case KEY_RIGHT_BRACKET: return Input::KI_OEM_6; /* ] } */ + case KEY_GRAVE: return Input::KI_OEM_3; /* ` ~ */ + case KEY_BACKSPACE: return Input::KI_BACK; + case KEY_TAB: return Input::KI_TAB; + case KEY_ENTER: return Input::KI_RETURN; + case KEY_ESCAPE: return Input::KI_ESCAPE; + case KEY_INSERT: return Input::KI_INSERT; + case KEY_DELETE: return Input::KI_DELETE; + case KEY_RIGHT: return Input::KI_RIGHT; + case KEY_LEFT: return Input::KI_LEFT; + case KEY_DOWN: return Input::KI_DOWN; + case KEY_UP: return Input::KI_UP; + case KEY_PAGE_UP: return Input::KI_PRIOR; + case KEY_PAGE_DOWN: return Input::KI_NEXT; + case KEY_HOME: return Input::KI_HOME; + case KEY_END: return Input::KI_END; + case KEY_CAPS_LOCK: return Input::KI_CAPITAL; + case KEY_F1: return Input::KI_F1; + case KEY_F2: return Input::KI_F2; + case KEY_F3: return Input::KI_F3; + case KEY_F4: return Input::KI_F4; + case KEY_F5: return Input::KI_F5; + case KEY_F6: return Input::KI_F6; + case KEY_F7: return Input::KI_F7; + case KEY_F8: return Input::KI_F8; + case KEY_F9: return Input::KI_F9; + case KEY_F10: return Input::KI_F10; + case KEY_F11: return Input::KI_F11; + case KEY_F12: return Input::KI_F12; + case KEY_LEFT_SHIFT: return Input::KI_LSHIFT; + case KEY_RIGHT_SHIFT: return Input::KI_RSHIFT; + case KEY_LEFT_CONTROL: return Input::KI_LCONTROL; + case KEY_RIGHT_CONTROL: return Input::KI_RCONTROL; + case KEY_LEFT_ALT: return Input::KI_LMENU; + case KEY_RIGHT_ALT: return Input::KI_RMENU; + case KEY_LEFT_SUPER: return Input::KI_LWIN; + case KEY_RIGHT_SUPER: return Input::KI_RWIN; + default: return Input::KI_UNKNOWN; + } +} + +/* raylib modifier key state -> RmlUi KeyModifier bitmask. */ +static int rl_key_modifiers(void) +{ + int m = 0; + if (IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL)) m |= Input::KM_CTRL; + if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) m |= Input::KM_SHIFT; + if (IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT)) m |= Input::KM_ALT; + if (IsKeyDown(KEY_LEFT_SUPER) || IsKeyDown(KEY_RIGHT_SUPER)) m |= Input::KM_META; + return m; +} + +/* Keys currently down from RmlUi's perspective. raylib's GetKeyPressed() only + * reports press-edges (a queue); IsKeyReleased(k) is true for one frame on + * release, so we track the down-set to know which keys to check for release. */ +static std::set<int> g_rml_down_keys; + +static mrb_value +rml_context_update(mrb_state *mrb, mrb_value self) +{ + ctx_arg(mrb)->Update(); + return mrb_nil_value(); +} + +static mrb_value +rml_context_render(mrb_state *mrb, mrb_value self) +{ + Context *ctx = ctx_arg(mrb); + rlDrawRenderBatchActive(); + rlSetBlendMode(RL_BLEND_ALPHA_PREMULTIPLY); /* RmlUi uses premultiplied alpha */ + ctx->Render(); + rlDrawRenderBatchActive(); + rlSetBlendMode(RL_BLEND_ALPHA); + return mrb_nil_value(); +} + +static mrb_value +rml_context_process_input(mrb_state *mrb, mrb_value self) +{ + Context *ctx = ctx_arg(mrb); + ctx->ProcessMouseMove(GetMouseX(), GetMouseY(), 0); + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) ctx->ProcessMouseButtonDown(0, 0); + if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) ctx->ProcessMouseButtonUp(0, 0); + + /* --- keyboard input (closes the RmlUi input gap; was mouse-only) --- + * raylib's GetKeyPressed() drains a queue of press-edge events (returns 0 when + * empty); IsKeyReleased(k) is true for exactly one frame on release. We track + * the down-set ourselves to synthesize ProcessKeyUp, since raylib has no + * "what was released?" queue. GetCharPressed() yields Unicode codepoints for + * text input (already respects shift/capslock); OEM keys (;',./etc.) are + * delivered through this path, not through KI_* codes. */ + int mods = rl_key_modifiers(); + while (int k = GetKeyPressed()) { + g_rml_down_keys.insert(k); + Input::KeyIdentifier ki = rl_key_to_rml(k); + if (ki != Input::KI_UNKNOWN) + ctx->ProcessKeyDown(ki, mods); + } + for (auto it = g_rml_down_keys.begin(); it != g_rml_down_keys.end(); ) { + if (IsKeyReleased(*it)) { + Input::KeyIdentifier ki = rl_key_to_rml(*it); + if (ki != Input::KI_UNKNOWN) + ctx->ProcessKeyUp(ki, mods); + it = g_rml_down_keys.erase(it); + } else { + ++it; + } + } + while (int c = GetCharPressed()) { + if (c >= 32 && c != 127) /* printable; skip control chars (handled as keys) */ + ctx->ProcessTextInput((Character)c); + } + return mrb_nil_value(); +} + +static mrb_value +rml_context_load_document(mrb_state *mrb, mrb_value self) +{ + mrb_value p; + const char *path; + mrb_get_args(mrb, "oz", &p, &path); + ElementDocument *doc = ((Context *)mrb_cptr(p))->LoadDocument(path); + if (!doc) return mrb_nil_value(); + return mrb_cptr_value(mrb, doc); +} + +static mrb_value +rml_document_show(mrb_state *mrb, mrb_value self) +{ + mrb_value p; + mrb_get_args(mrb, "o", &p); + ((ElementDocument *)mrb_cptr(p))->Show(); + return mrb_nil_value(); +} + +static mrb_value +rml_document_hide(mrb_state *mrb, mrb_value self) +{ + mrb_value p; + mrb_get_args(mrb, "o", &p); + ((ElementDocument *)mrb_cptr(p))->Hide(); + return mrb_nil_value(); +} + +/* ================================================================== */ +/* Element / Event / Document — comprehensive bindings */ +/* (modeled on RmlUi's Lua bindings) */ +/* ================================================================== */ + +static Element *el_arg(mrb_state *mrb) +{ + mrb_value p; mrb_get_args(mrb, "o", &p); + return (Element *)mrb_cptr(p); +} + +/* wrap an Element* as a Ruby Rml::Element (or nil) */ +static mrb_value wrap_element(mrb_state *mrb, Element *e) +{ + if (!e) return mrb_nil_value(); + struct RClass *m = mrb_module_get(mrb, "Rml"); + struct RClass *c = mrb_class_get_under(mrb, m, "Element"); + return mrb_funcall(mrb, mrb_obj_value(c), "new", 1, mrb_cptr_value(mrb, e)); +} + +static mrb_value wrap_element_list(mrb_state *mrb, const ElementList &list) +{ + mrb_value arr = mrb_ary_new_capa(mrb, (mrb_int)list.size()); + for (Element *e : list) mrb_ary_push(mrb, arr, wrap_element(mrb, e)); + return arr; +} + +/* Event listener that dispatches to a stored Ruby block. */ +class RubyEventListener : public EventListener { +public: + RubyEventListener(mrb_state *m, mrb_value blk) : mrb(m), block(blk) { mrb_gc_register(m, blk); } + void ProcessEvent(Event &event) override + { + struct RClass *m = mrb_module_get(mrb, "Rml"); + struct RClass *c = mrb_class_get_under(mrb, m, "Event"); + mrb_value ev = mrb_funcall(mrb, mrb_obj_value(c), "new", 1, mrb_cptr_value(mrb, &event)); + mrb_yield(mrb, block, ev); + } +private: + mrb_state *mrb; + mrb_value block; +}; + +/* --- attributes --- */ +static mrb_value rml_el_get_attribute(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *name; mrb_get_args(mrb, "oz", &p, &name); + Variant *v = ((Element *)mrb_cptr(p))->GetAttribute(name); + return v ? mrb_str_new_cstr(mrb, v->Get<String>().c_str()) : mrb_nil_value(); +} +static mrb_value rml_el_set_attribute(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *name, *val; mrb_get_args(mrb, "ozz", &p, &name, &val); + ((Element *)mrb_cptr(p))->SetAttribute(name, String(val)); + return mrb_nil_value(); +} +static mrb_value rml_el_has_attribute(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *name; mrb_get_args(mrb, "oz", &p, &name); + return mrb_bool_value(((Element *)mrb_cptr(p))->HasAttribute(name)); +} +static mrb_value rml_el_remove_attribute(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *name; mrb_get_args(mrb, "oz", &p, &name); + ((Element *)mrb_cptr(p))->RemoveAttribute(name); + return mrb_nil_value(); +} + +/* --- id / tag / rml --- */ +static mrb_value rml_el_get_id(mrb_state *mrb, mrb_value self) { + return mrb_str_new_cstr(mrb, el_arg(mrb)->GetId().c_str()); +} +static mrb_value rml_el_set_id(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *v; mrb_get_args(mrb, "oz", &p, &v); + ((Element *)mrb_cptr(p))->SetId(v); return mrb_nil_value(); +} +static mrb_value rml_el_tag(mrb_state *mrb, mrb_value self) { + return mrb_str_new_cstr(mrb, el_arg(mrb)->GetTagName().c_str()); +} +static mrb_value rml_el_get_inner_rml(mrb_state *mrb, mrb_value self) { + return mrb_str_new_cstr(mrb, el_arg(mrb)->GetInnerRML().c_str()); +} +static mrb_value rml_el_set_inner_rml(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *v; mrb_get_args(mrb, "oz", &p, &v); + ((Element *)mrb_cptr(p))->SetInnerRML(v); return mrb_nil_value(); +} + +/* --- classes / properties --- */ +static mrb_value rml_el_set_class(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *name; mrb_bool on; mrb_get_args(mrb, "ozb", &p, &name, &on); + ((Element *)mrb_cptr(p))->SetClass(name, on); return mrb_nil_value(); +} +static mrb_value rml_el_is_class_set(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *name; mrb_get_args(mrb, "oz", &p, &name); + return mrb_bool_value(((Element *)mrb_cptr(p))->IsClassSet(name)); +} +static mrb_value rml_el_set_property(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *name, *val; mrb_get_args(mrb, "ozz", &p, &name, &val); + return mrb_bool_value(((Element *)mrb_cptr(p))->SetProperty(name, val)); +} +static mrb_value rml_el_get_property(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *name; mrb_get_args(mrb, "oz", &p, &name); + const Property *prop = ((Element *)mrb_cptr(p))->GetProperty(name); + return prop ? mrb_str_new_cstr(mrb, prop->ToString().c_str()) : mrb_nil_value(); +} +static mrb_value rml_el_remove_property(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *name; mrb_get_args(mrb, "oz", &p, &name); + ((Element *)mrb_cptr(p))->RemoveProperty(name); return mrb_nil_value(); +} + +/* --- actions --- */ +static mrb_value rml_el_focus(mrb_state *mrb, mrb_value self) { el_arg(mrb)->Focus(); return mrb_nil_value(); } +static mrb_value rml_el_blur(mrb_state *mrb, mrb_value self) { el_arg(mrb)->Blur(); return mrb_nil_value(); } +static mrb_value rml_el_click(mrb_state *mrb, mrb_value self) { el_arg(mrb)->Click(); return mrb_nil_value(); } +static mrb_value rml_el_scroll_into_view(mrb_state *mrb, mrb_value self) { + mrb_value p; mrb_bool top = TRUE; mrb_get_args(mrb, "o|b", &p, &top); + ((Element *)mrb_cptr(p))->ScrollIntoView(top); return mrb_nil_value(); +} +static mrb_value rml_el_select(mrb_state *mrb, mrb_value self) { + auto *el = el_arg(mrb); + auto *input = dynamic_cast<ElementFormControlInput *>(el); + if (input) input->Select(); + return mrb_nil_value(); +} +static mrb_value rml_el_set_selection_range(mrb_state *mrb, mrb_value self) { + mrb_value p; mrb_int start, end; + mrb_get_args(mrb, "oii", &p, &start, &end); + auto *input = dynamic_cast<ElementFormControlInput *>((Element *)mrb_cptr(p)); + if (input) input->SetSelectionRange((int)start, (int)end); + return mrb_nil_value(); +} +static mrb_value rml_el_is_visible(mrb_state *mrb, mrb_value self) { + return mrb_bool_value(el_arg(mrb)->IsVisible()); +} + +/* --- traversal / queries --- */ +static mrb_value rml_el_get_element_by_id(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *id; mrb_get_args(mrb, "oz", &p, &id); + return wrap_element(mrb, ((Element *)mrb_cptr(p))->GetElementById(id)); +} +static mrb_value rml_el_query_selector(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *sel; mrb_get_args(mrb, "oz", &p, &sel); + return wrap_element(mrb, ((Element *)mrb_cptr(p))->QuerySelector(sel)); +} +static mrb_value rml_el_query_selector_all(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *sel; mrb_get_args(mrb, "oz", &p, &sel); + ElementList list; ((Element *)mrb_cptr(p))->QuerySelectorAll(list, sel); + return wrap_element_list(mrb, list); +} +static mrb_value rml_el_get_elements_by_tag(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *tag; mrb_get_args(mrb, "oz", &p, &tag); + ElementList list; ((Element *)mrb_cptr(p))->GetElementsByTagName(list, tag); + return wrap_element_list(mrb, list); +} +static mrb_value rml_el_parent(mrb_state *mrb, mrb_value self) { + return wrap_element(mrb, el_arg(mrb)->GetParentNode()); +} +static mrb_value rml_el_num_children(mrb_state *mrb, mrb_value self) { + return mrb_fixnum_value(el_arg(mrb)->GetNumChildren()); +} +static mrb_value rml_el_child(mrb_state *mrb, mrb_value self) { + mrb_value p; mrb_int i; mrb_get_args(mrb, "oi", &p, &i); + return wrap_element(mrb, ((Element *)mrb_cptr(p))->GetChild((int)i)); +} +static mrb_value rml_el_owner_document(mrb_state *mrb, mrb_value self) { + return wrap_element(mrb, el_arg(mrb)->GetOwnerDocument()); +} + +/* --- geometry --- */ +static mrb_value rml_el_client_width(mrb_state *mrb, mrb_value self) { return mrb_float_value(mrb, el_arg(mrb)->GetClientWidth()); } +static mrb_value rml_el_client_height(mrb_state *mrb, mrb_value self) { return mrb_float_value(mrb, el_arg(mrb)->GetClientHeight()); } +static mrb_value rml_el_offset_left(mrb_state *mrb, mrb_value self) { return mrb_float_value(mrb, el_arg(mrb)->GetOffsetLeft()); } +static mrb_value rml_el_offset_top(mrb_state *mrb, mrb_value self) { return mrb_float_value(mrb, el_arg(mrb)->GetOffsetTop()); } +static mrb_value rml_el_absolute_left(mrb_state *mrb, mrb_value self) { return mrb_float_value(mrb, el_arg(mrb)->GetAbsoluteLeft()); } +static mrb_value rml_el_absolute_top(mrb_state *mrb, mrb_value self) { return mrb_float_value(mrb, el_arg(mrb)->GetAbsoluteTop()); } + +/* --- events --- */ +static mrb_value rml_el_add_event_listener(mrb_state *mrb, mrb_value self) { + mrb_value p, blk; const char *type; + mrb_get_args(mrb, "oz&", &p, &type, &blk); + ((Element *)mrb_cptr(p))->AddEventListener(type, new RubyEventListener(mrb, blk)); + return mrb_nil_value(); +} + +/* --- Event accessors --- */ +static Event *ev_arg(mrb_state *mrb) { mrb_value p; mrb_get_args(mrb, "o", &p); return (Event *)mrb_cptr(p); } +static mrb_value rml_ev_type(mrb_state *mrb, mrb_value self) { return mrb_str_new_cstr(mrb, ev_arg(mrb)->GetType().c_str()); } +static mrb_value rml_ev_target(mrb_state *mrb, mrb_value self) { return wrap_element(mrb, ev_arg(mrb)->GetTargetElement()); } +static mrb_value rml_ev_current(mrb_state *mrb, mrb_value self) { return wrap_element(mrb, ev_arg(mrb)->GetCurrentElement()); } +static mrb_value rml_ev_stop_propagation(mrb_state *mrb, mrb_value self) { ev_arg(mrb)->StopPropagation(); return mrb_nil_value(); } +static mrb_value rml_ev_stop_immediate(mrb_state *mrb, mrb_value self) { ev_arg(mrb)->StopImmediatePropagation(); return mrb_nil_value(); } +static mrb_value rml_ev_param_float(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *k; mrb_get_args(mrb, "oz", &p, &k); + return mrb_float_value(mrb, ((Event *)mrb_cptr(p))->GetParameter<float>(k, 0.0f)); +} +static mrb_value rml_ev_param_str(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *k; mrb_get_args(mrb, "oz", &p, &k); + return mrb_str_new_cstr(mrb, ((Event *)mrb_cptr(p))->GetParameter<String>(k, String()).c_str()); +} + +/* --- Document (ElementDocument*) --- */ +static ElementDocument *doc_arg(mrb_state *mrb) { mrb_value p; mrb_get_args(mrb, "o", &p); return (ElementDocument *)mrb_cptr(p); } +static mrb_value rml_doc_title(mrb_state *mrb, mrb_value self) { return mrb_str_new_cstr(mrb, doc_arg(mrb)->GetTitle().c_str()); } +static mrb_value rml_doc_set_title(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *t; mrb_get_args(mrb, "oz", &p, &t); + ((ElementDocument *)mrb_cptr(p))->SetTitle(t); return mrb_nil_value(); +} +static mrb_value rml_doc_pull_to_front(mrb_state *mrb, mrb_value self) { doc_arg(mrb)->PullToFront(); return mrb_nil_value(); } +static mrb_value rml_doc_push_to_back(mrb_state *mrb, mrb_value self) { doc_arg(mrb)->PushToBack(); return mrb_nil_value(); } +static mrb_value rml_doc_close(mrb_state *mrb, mrb_value self) { doc_arg(mrb)->Close(); return mrb_nil_value(); } + +/* --- Context document accessors --- */ +static mrb_value rml_context_get_document(mrb_state *mrb, mrb_value self) { + mrb_value p; const char *id; mrb_get_args(mrb, "oz", &p, &id); + return wrap_element(mrb, ((Context *)mrb_cptr(p))->GetDocument(id)); +} +static mrb_value rml_context_num_documents(mrb_state *mrb, mrb_value self) { + mrb_value p; mrb_get_args(mrb, "o", &p); + return mrb_fixnum_value(((Context *)mrb_cptr(p))->GetNumDocuments()); +} + +/* --- data model --- */ + +static mrb_value +rml_data_model_create(mrb_state *mrb, mrb_value self) +{ + mrb_value ctxp, model; + const char *name; + mrb_get_args(mrb, "ozo", &ctxp, &name, &model); + Context *ctx = (Context *)mrb_cptr(ctxp); + ModelSession *s = new ModelSession(); + s->mrb = mrb; + s->ruby_model = model; + s->ctor = new DataModelConstructor(ctx->CreateDataModel(name)); + mrb_gc_register(mrb, model); /* keep the Ruby model alive for callbacks */ + return mrb_cptr_value(mrb, s); +} + +/* read-only computed binding: getter dispatches to ruby model.__get(name) */ +static mrb_value +rml_data_model_bind_get(mrb_state *mrb, mrb_value self) +{ + mrb_value sp; const char *name; + mrb_get_args(mrb, "oz", &sp, &name); + ModelSession *s = (ModelSession *)mrb_cptr(sp); + std::string nm = name; + mrb_state *m = s->mrb; mrb_value model = s->ruby_model; + s->ctor->BindFunc(nm, [m, model, nm](Variant &out) { + mrb_value r = mrb_funcall(m, model, "__get", 1, mrb_str_new_cstr(m, nm.c_str())); + out = ruby_to_variant(m, r); + }); + return mrb_nil_value(); +} + +/* two-way scalar: getter + setter dispatch to ruby model.__get/__set */ +static mrb_value +rml_data_model_bind_scalar(mrb_state *mrb, mrb_value self) +{ + mrb_value sp; const char *name; + mrb_get_args(mrb, "oz", &sp, &name); + ModelSession *s = (ModelSession *)mrb_cptr(sp); + std::string nm = name; + mrb_state *m = s->mrb; mrb_value model = s->ruby_model; + s->ctor->BindFunc(nm, + [m, model, nm](Variant &out) { + mrb_value r = mrb_funcall(m, model, "__get", 1, mrb_str_new_cstr(m, nm.c_str())); + out = ruby_to_variant(m, r); + }, + [m, model, nm](const Variant &in) { + mrb_funcall(m, model, "__set", 2, mrb_str_new_cstr(m, nm.c_str()), variant_to_ruby(m, in)); + }); + return mrb_nil_value(); +} + +static mrb_value +rml_data_model_bind_event(mrb_state *mrb, mrb_value self) +{ + mrb_value sp; const char *name; + mrb_get_args(mrb, "oz", &sp, &name); + ModelSession *s = (ModelSession *)mrb_cptr(sp); + std::string nm = name; + mrb_state *m = s->mrb; mrb_value model = s->ruby_model; + s->ctor->BindEventCallback(nm, + [m, model, nm](DataModelHandle, Event &, const VariantList &) { + mrb_funcall(m, model, "__event", 1, mrb_str_new_cstr(m, nm.c_str())); + }); + return mrb_nil_value(); +} + +static mrb_value +rml_data_model_finish(mrb_state *mrb, mrb_value self) +{ + mrb_value sp; + mrb_get_args(mrb, "o", &sp); + ModelSession *s = (ModelSession *)mrb_cptr(sp); + s->handle = s->ctor->GetModelHandle(); + delete s->ctor; + s->ctor = nullptr; + return mrb_nil_value(); +} + +static mrb_value +rml_data_model_dirty(mrb_state *mrb, mrb_value self) +{ + mrb_value sp; const char *name; + mrb_get_args(mrb, "oz", &sp, &name); + ((ModelSession *)mrb_cptr(sp))->handle.DirtyVariable(name); + return mrb_nil_value(); +} + +static mrb_value +rml_data_model_dirty_all(mrb_state *mrb, mrb_value self) +{ + mrb_value sp; + mrb_get_args(mrb, "o", &sp); + ((ModelSession *)mrb_cptr(sp))->handle.DirtyAllVariables(); + return mrb_nil_value(); +} + +extern "C" void +mrb_rmlui_gem_init(mrb_state *mrb) +{ + struct RClass *rml = mrb_define_module(mrb, "Rml"); + + mrb_define_module_function(mrb, rml, "_init", rml_init, MRB_ARGS_NONE()); + mrb_define_module_function(mrb, rml, "_shutdown", rml_shutdown, MRB_ARGS_NONE()); + mrb_define_module_function(mrb, rml, "_load_font", rml_load_font, MRB_ARGS_ARG(1, 1)); + mrb_define_module_function(mrb, rml, "_create_context", rml_create_context, MRB_ARGS_REQ(3)); + mrb_define_module_function(mrb, rml, "_context_set_dimensions", rml_context_set_dimensions, MRB_ARGS_REQ(3)); + mrb_define_module_function(mrb, rml, "_context_update", rml_context_update, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_context_render", rml_context_render, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_context_process_input", rml_context_process_input, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_context_load_document", rml_context_load_document, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_document_show", rml_document_show, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_document_hide", rml_document_hide, MRB_ARGS_REQ(1)); + + mrb_define_module_function(mrb, rml, "_data_model_create", rml_data_model_create, MRB_ARGS_REQ(3)); + mrb_define_module_function(mrb, rml, "_data_model_bind_get", rml_data_model_bind_get, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_data_model_bind_scalar", rml_data_model_bind_scalar, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_data_model_bind_event", rml_data_model_bind_event, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_data_model_finish", rml_data_model_finish, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_data_model_dirty", rml_data_model_dirty, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_data_model_dirty_all", rml_data_model_dirty_all, MRB_ARGS_REQ(1)); + + /* Element */ + mrb_define_module_function(mrb, rml, "_el_get_attribute", rml_el_get_attribute, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_set_attribute", rml_el_set_attribute, MRB_ARGS_REQ(3)); + mrb_define_module_function(mrb, rml, "_el_has_attribute", rml_el_has_attribute, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_remove_attribute", rml_el_remove_attribute, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_get_id", rml_el_get_id, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_set_id", rml_el_set_id, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_tag", rml_el_tag, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_get_inner_rml", rml_el_get_inner_rml, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_set_inner_rml", rml_el_set_inner_rml, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_set_class", rml_el_set_class, MRB_ARGS_REQ(3)); + mrb_define_module_function(mrb, rml, "_el_is_class_set", rml_el_is_class_set, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_set_property", rml_el_set_property, MRB_ARGS_REQ(3)); + mrb_define_module_function(mrb, rml, "_el_get_property", rml_el_get_property, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_remove_property", rml_el_remove_property, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_focus", rml_el_focus, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_blur", rml_el_blur, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_click", rml_el_click, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_scroll_into_view", rml_el_scroll_into_view, MRB_ARGS_ARG(1, 1)); + mrb_define_module_function(mrb, rml, "_el_select", rml_el_select, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_set_selection_range", rml_el_set_selection_range, MRB_ARGS_REQ(3)); + mrb_define_module_function(mrb, rml, "_el_is_visible", rml_el_is_visible, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_get_element_by_id", rml_el_get_element_by_id, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_query_selector", rml_el_query_selector, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_query_selector_all", rml_el_query_selector_all, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_get_elements_by_tag", rml_el_get_elements_by_tag, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_parent", rml_el_parent, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_num_children", rml_el_num_children, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_child", rml_el_child, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_el_owner_document", rml_el_owner_document, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_client_width", rml_el_client_width, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_client_height", rml_el_client_height, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_offset_left", rml_el_offset_left, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_offset_top", rml_el_offset_top, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_absolute_left", rml_el_absolute_left, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_absolute_top", rml_el_absolute_top, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_el_add_event_listener", rml_el_add_event_listener, MRB_ARGS_REQ(2) | MRB_ARGS_BLOCK()); + + /* Event */ + mrb_define_module_function(mrb, rml, "_ev_type", rml_ev_type, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_ev_target", rml_ev_target, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_ev_current", rml_ev_current, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_ev_stop_propagation", rml_ev_stop_propagation, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_ev_stop_immediate", rml_ev_stop_immediate, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_ev_param_float", rml_ev_param_float, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_ev_param_str", rml_ev_param_str, MRB_ARGS_REQ(2)); + + /* Document */ + mrb_define_module_function(mrb, rml, "_doc_title", rml_doc_title, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_doc_set_title", rml_doc_set_title, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_doc_pull_to_front", rml_doc_pull_to_front, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_doc_push_to_back", rml_doc_push_to_back, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, rml, "_doc_close", rml_doc_close, MRB_ARGS_REQ(1)); + + /* Context */ + mrb_define_module_function(mrb, rml, "_context_get_document", rml_context_get_document, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, rml, "_context_num_documents", rml_context_num_documents, MRB_ARGS_REQ(1)); +} + +extern "C" void +mrb_rmlui_gem_final(mrb_state *mrb) +{ + /* RmlUi shutdown is explicit via Rml._shutdown */ +} diff --git a/notes/restructure-plan.md b/notes/restructure-plan.md deleted file mode 100644 index 90126b7..0000000 --- a/notes/restructure-plan.md +++ /dev/null @@ -1,344 +0,0 @@ -# Restructure plan — single-file → modular C/Raylib project - -> **Status:** Pending. This document describes the target architecture and the -> step-by-step plan for splitting `src/main.c` (778 lines) into composable -> modules. The orchestrator will execute this plan. - ---- - -## §1 Design principles - -1. **`.h` = contract, `.c` = implementation.** Every module exposes a single - self-contained header. No other module ever includes a `.c` file. -2. **All shared mutable state through `PlayerState*`.** Defined in `types.h`. - File-scope statics only for module-private data (fonts, colors in UI). -3. **One `.o` per module.** Each `.c` compiles independently. The linker - resolves dependencies. This makes parallel agent waves possible. -4. **Zero-copy from current codebase.** Move functions as-is, then improve. - The first pass preserves all behavior exactly. -5. **Linux native + Windows cross-compile.** Both platforms supported. The - Makefile handles both targets (platform conditionals in the build, not in - the source unless needed). -6. **Minimal module count.** Four modules + types header. Don't over-split. - ---- - -## §2 Target module structure - -``` -src/ - types.h CONTRACT — shared types, enums, constants - player.h CONTRACT — audio playback - player.c IMPL - study.h CONTRACT — silence detection + study mode - study.c IMPL - ui.h CONTRACT — rendering + input - ui.c IMPL - main.c COMPOSITION ROOT — entry point + main loop -``` - -### §2.1 `src/types.h` — shared types and constants - -No `.c` file. Pure definitions. - -```c -#pragma once - -// ── Platform detection ── -#ifdef PLATFORM_LINUX - #define _GLFW_X11 -#endif - -// ── Constants ── -#define SCREEN_W 1920 -#define SCREEN_H 1080 -#define MAX_SILENCE_REGIONS 4096 - -// ── Types ── -typedef struct { - float start; /* normalized 0..1 */ - float end; /* normalized 0..1 */ -} SilenceRegion; - -typedef struct { - Music music; - bool loaded; - bool playing; - float duration; - float currentTime; - char filename[256]; - SilenceRegion silence[MAX_SILENCE_REGIONS]; - int silenceCount; - bool studyMode; - bool wasInSilence; - int lastSilenceIdx; - int skipAutoUpdate; - double lastVPress; -} PlayerState; -``` - -### §2.2 `src/player.h` — audio playback contract - -```c -#pragma once -#include "types.h" - -void player_load(PlayerState *s, const char *path); -void player_unload(PlayerState *s); -void player_seek(PlayerState *s, float seconds); -void player_play(PlayerState *s); -void player_pause(PlayerState *s); -void player_update(PlayerState *s); /* call once per frame */ -void format_time(float seconds, char *buf, int bufsize); -const char *basename_from_path(const char *path); -``` - -**`src/player.c`** implements: -- `player_load()` — load MP3, set up Music stream, start playback, update filename + window title, calls `study_detect_silence()` to populate silence regions -- `player_unload()` — stop + unload music stream -- `player_seek()` — seek music stream to target seconds -- `player_play()` / `player_pause()` — Resume/Pause + update state -- `player_update()` — `UpdateMusicStream()` + `GetMusicTimePlayed()` with skip logic -- `format_time()` — seconds → "MM:SS" or "H:MM:SS" string -- `basename_from_path()` — extract filename from path -- `strcasecmp_ext()` — helper for extension checking (static) - -### §2.3 `src/study.h` — study mode contract - -```c -#pragma once -#include "types.h" - -void study_detect_silence(const char *path, PlayerState *s, float threshold, float minDuration); -int study_find_silence_at(const PlayerState *s, float pos); -float study_speaking_portion_start(const PlayerState *s, int portion); -int study_current_portion(const PlayerState *s, float pos); -int study_total_portions(const PlayerState *s); -float study_segment_seek_target(const PlayerState *s, int portion); -bool study_in_padding_zone(const PlayerState *s, float pos, int portion); -void study_update(PlayerState *s); /* auto-pause logic for one frame */ -``` - -**`src/study.c`** implements: -- `study_detect_silence()` — Wave analysis, silence region detection with padding (move from current `detect_silence`) -- `study_find_silence_at()` — find silence region at normalized position -- `study_speaking_portion_start()` — get start of speaking portion N -- `study_current_portion()` — get current speaking portion index -- `study_total_portions()` — total speaking portions -- `study_segment_seek_target()` — seek target for a portion (with 2-frame offset) -- `study_in_padding_zone()` — check if position is in padding zone -- `study_update()` — the auto-pause state machine: detect silence entry/exit, auto-pause → seek to next portion - -### §2.4 `src/ui.h` — rendering + input contract - -```c -#pragma once -#include "types.h" - -void ui_init(void); -void ui_destroy(void); -void ui_render_frame(PlayerState *s); -``` - -**`src/ui.c`** implements: -- **File-scope statics:** fonts (`fontSmall`, `font`, `fontMed`, `fontLarge`, `fontHelp`), sizes, colors, layout constants, button positions -- `ui_init()` — load fonts, set colors, compute layout -- `ui_destroy()` — unload embedded fonts -- `ui_render_frame()` — one complete frame: - - Handle drag-drop file loading (desktop) → calls `player_load()` - - Handle keyboard input (C, N, Space, V, B, Arrows, 0–9, Up/Down) → calls `player_seek()`, `player_play()`, `player_pause()` - - Handle mouse input (click-to-seek, play/pause button, section nav buttons, study mode checkbox) - - Call `player_update()` for music stream update - - Call `study_update()` for study mode auto-pause logic - - Call `BeginDrawing()` / `EndDrawing()` with all rendering (title, progress bar, time labels, percentage, buttons, checkboxes, help text) -- `draw_text_centered()` — static helper -- `draw_play_icon()`, `draw_pause_icon()`, `draw_seek_back_icon()`, `draw_seek_fwd_icon()` — static helpers -- `button_hit()` — static helper - -### §2.5 `src/main.c` — composition root - -No `.h` file. Entry point only. - -```c -#include "raylib.h" -#include "player.h" -#include "study.h" -#include "ui.h" -#include "font_data.h" -#ifdef PLATFORM_WEB -#include <emscripten/emscripten.h> -#endif - -/* File-scope PlayerState (needed for emscripten main loop callback) */ -static PlayerState state = { 0 }; - -#ifdef PLATFORM_WEB -EMSCRIPTEN_KEEPALIVE -void load_file_web(const char *path) { - player_load(&state, path); -} -#endif - -static void update_frame(void) { - ui_render_frame(&state); -} - -int main(void) { - InitWindow(SCREEN_W, SCREEN_H, "Study Player"); - InitAudioDevice(); - SetTargetFPS(60); - ui_init(); - - memset(&state, 0, sizeof(state)); - state.studyMode = true; - state.lastSilenceIdx = -1; - -#ifdef PLATFORM_WEB - emscripten_set_main_loop(update_frame, 0, 1); -#else - while (!WindowShouldClose()) { - update_frame(); - } -#endif - - player_unload(&state); - ui_destroy(); - CloseAudioDevice(); - CloseWindow(); - return 0; -} -``` - ---- - -## §3 Dependency graph - -``` -types.h ← player.h ← ui.h - ← study.h ← ui.h - ← player.c (player depends on study for silence detection) - ← main.c - -player.h ← player.c (includes: types.h) -study.h ← study.c (includes: types.h) -ui.h ← ui.c (includes: types.h, player.h, study.h) -``` - -- `types.h` — no dependencies (pure definitions) -- `player.h` — depends on `types.h` (PlayerState, Music type via raylib) -- `study.h` — depends on `types.h` (PlayerState, SilenceRegion) -- `ui.h` — depends on `types.h` (PlayerState) -- `player.c` — depends on `types.h`, `player.h` (its own contract), `study.h` (calls `study_detect_silence` in `player_load`), `raylib.h` -- `study.c` — depends on `types.h`, `study.h` (its own contract), `raylib.h` -- `ui.c` — depends on `types.h`, `player.h`, `study.h`, `ui.h`, `raylib.h`, `font_data.h` -- `main.c` — depends on all `.h` files, `raylib.h`, `font_data.h`, `emscripten.h` (web only) - -All modules compile to `.o` independently — zero `.c` includes another `.c`. - ---- - -## §4 Wave plan - -### WAVE 0 — Orchestrator + build system agent (sequentially) - -**Orchestrator (direct work):** -1. Write `src/types.h` with all shared types and constants -2. Pre-author `src/player.h`, `src/study.h`, `src/ui.h` — define every public - function signature so module agents have fixed contracts to implement - against -3. Write TASK prompts to `prompts/build-system.md`, `prompts/player.md`, - `prompts/study.md`, `prompts/ui.md`, `prompts/main.md` - -**Build system agent:** (reads ANY file, writes only Makefile + bin/*) -1. Update `Makefile`: - - Linux native target (default): `gcc -o build/study-player src/*.c ...` - - Windows target (`make windows`): cross-compile via MinGW - - Font header generation as a make prerequisite - - `SRCS = $(wildcard src/*.c)`, `OBJS = $(SRCS:.c=.o)` - - Raylib `.o` compilation with `-w` (third-party warnings suppressed) -2. Update `.gitignore` (add `prompts/`, `reports/`) - -**Verification:** Module `.h` files compile cleanly (no syntax errors). -`make` will fail on missing `.c` implementations — that's expected, WAVE 1 -resolves it. - -### WAVE 1 — All `.c` implementations in parallel (disjoint files) - -Four module agents, launched as concurrent tool calls. Each owns its `.h` + -`.c` pair, reads only other `.h` files, writes only its own files: - -| Agent | Files it owns | .h files it reads | -|---|---|---| -| Agent A: player | `src/player.h`, `src/player.c` | `src/types.h` | -| Agent B: study | `src/study.h`, `src/study.c` | `src/types.h` | -| Agent C: ui | `src/ui.h`, `src/ui.c` | `src/types.h`, `src/player.h`, `src/study.h` | -| Agent D: main | `src/main.c` (no .h) | all `.h` files | - -File sets are DISJOINT. No compile-time dependency between `.c` files — each -compiles to `.o` independently. All `.h` contracts were fixed in WAVE 0. - -**Verification:** `make clean && make -j$(nproc)` — exit 0, zero warnings -(Linux). Then `make windows -j$(nproc)` — exit 0, zero warnings (Windows). - -### WAVE 2 (if needed) — Integration fixes - -Any link errors, behavioral regressions, or contract gaps discovered during -WAVE 1 verification. Summon affected agents to fix. - ---- - -## §5 Function migration map - -Every function in the current `src/main.c` moves to exactly one target file: - -| Current function | → Target file | New name | -|---|---|---| -| `SilenceRegion` struct | `types.h` | (unchanged) | -| `PlayerState` struct | `types.h` | (unchanged) | -| `#define` constants | `types.h` | (unchanged) | -| `strcasecmp_ext()` | `player.c` | static (no prefix) | -| `detect_silence()` | `study.c` | `study_detect_silence()` | -| `basename_from_path()` | `player.c` | (unchanged, public) | -| `seek_to()` | `player.c` | `player_seek()` | -| `format_time()` | `player.c` | (unchanged, public) | -| `find_silence_at()` | `study.c` | `study_find_silence_at()` | -| `speaking_portion_start()` | `study.c` | `study_speaking_portion_start()` | -| `current_speaking_portion()` | `study.c` | `study_current_portion()` | -| `total_speaking_portions()` | `study.c` | `study_total_portions()` | -| `segment_seek_target()` | `study.c` | `study_segment_seek_target()` | -| `in_padding_zone()` | `study.c` | `study_in_padding_zone()` | -| `draw_text_centered()` | `ui.c` | static (no prefix) | -| `draw_play_icon()` | `ui.c` | static (no prefix) | -| `draw_pause_icon()` | `ui.c` | static (no prefix) | -| `draw_seek_back_icon()` | `ui.c` | static (no prefix) | -| `draw_seek_fwd_icon()` | `ui.c` | static (no prefix) | -| `button_hit()` | `ui.c` | static (no prefix) | -| `load_audio_file()` | `player.c` | `player_load()` | -| `load_file_web()` (emscripten) | `main.c` | (unchanged) | -| `update_frame()` | `main.c` | (simplified — just calls `ui_render_frame()`) | -| `main()` | `main.c` | (unchanged, simplified) | -| File-scope statics (state, fonts, colors, layout) | `main.c` (`state`), `ui.c` (rest) | — | - -**Auto-pause logic** currently inlined in `update_frame()` (lines 533–562) -moves into `study_update()` in `study.c`. The UI module calls -`study_update(&state)` after `player_update(&state)`. - ---- - -## §6 Current code as-is invariants (must preserve) - -During the split, preserve every existing behavior: -1. Drag-and-drop MP3 loading (desktop) -2. All keyboard shortcuts: C, N, Space, V, B, Arrows, Up, Down, 0–9 -3. Click-to-seek on progress bar -4. Study mode auto-pause at silence boundaries -5. Study mode checkbox toggle -6. Play/pause button and section navigation buttons -7. Progress bar rendering with elapsed/remaining time labels -8. Percentage display above progress bar -9. "PLAYING"/"PAUSED" status text -10. Help text at bottom -11. Dark theme colors -12. Embeddable font support (`FONT_EMBEDDED`) -13. Web platform support (`PLATFORM_WEB`, emscripten main loop, file upload) -14. Linux native build + Windows cross-compile both work diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..e77e3aa --- /dev/null +++ b/opencode.json @@ -0,0 +1,42 @@ +{ + "lsp": { + "ruby-lsp": { + "_comment": "Ruby LSP for mruby. The installed Dispatch harness reads this opencode.json `lsp` block (via its B6 parser) ONLY when .dispatch/lsp.json is absent — so a fresh clone gets this config. The harness reads `command`/`extensions`/`env`/`initialization` here (NOT the editor-style `enabled`/`settings`). ruby-lsp targets MRI, so formatter+linters are off (they'd false-positive on mruby); navigation + RBS-backed intelligence stay on. The hand-written RBS in sig/ (raylib/rmlui/flecs/jolt/jamstack) give hover/completion/definition for the C/C++ mrbgem bindings. env notes: `bundle` is not on the default PATH (it lives in the user gem bin dir), and without GEM_HOME bundler installs into the root-owned system gem dir → PermissionError. PATH/GEM_HOME/GEM_PATH fix both.", + "command": ["/home/tradam/.local/bin/ruby-lsp"], + "extensions": [".rb"], + "env": { + "PATH": "/home/tradam/.local/bin:/home/tradam/.local/share/gem/ruby/3.4.0/bin:/home/tradam/.local/share/mise/shims:/home/tradam/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/bin", + "GEM_HOME": "/home/tradam/.local/share/gem/ruby/3.4.0", + "GEM_PATH": "/home/tradam/.local/share/gem/ruby/3.4.0" + }, + "initialization": { + "rubyVersion": "3.4.0", + "formatter": "none", + "linters": [], + "enabledFeatures": { + "diagnostics": false, + "codeActions": false, + "codeLens": false, + "typeHierarchy": false, + "workspaceSymbols": false, + "onTypeFormatting": false, + "hover": true, + "completion": true, + "definition": true, + "signatureHelp": true, + "documentSymbols": true, + "foldingRanges": true, + "selectionRanges": true, + "inlayHints": true, + "semanticHighlighting": true + } + } + }, + "clangd": { + "_comment": "clangd for the C/C++ mrbgem bindings (mrbgems/*/src/*) + src/main.c. clangd is at /usr/lib/llvm21/bin/clangd (not on PATH, so absolute). Compile flags (include roots vendor/{mruby,raylib,rmlui,flecs,joltc} + -DMRB_INT64) live in compile_commands.json, GENERATED by `ruby tools/gen_compile_commands.rb` (rebuild.sh regenerates it) — clangd's no-compile-db fallback resolves -I paths against the file's dir and breaks, so the generator (setting directory=project root) is required. .clangd holds Index config. Targets the DESKTOP build; web-only <emscripten.h> is #ifdef __EMSCRIPTEN__ (not defined) so skipped. --background-index builds a persistent cross-file index (definition/refs across the 6 C/C++ units); --clang-tidy is OFF (it would flag the vendored/macro-heavy code).", + "command": ["/usr/lib/llvm21/bin/clangd", "--background-index", "--log=error"], + "extensions": [".c", ".h", ".cpp", ".cc", ".cxx", ".hpp"] + } + }, + "_disabled_lsp_note": "Steep LSP was here but was DISABLED 2026-06-25 — its langserver drifted into a corrupted state after ~3h (phantom Ruby::SyntaxError on a comment line; a fresh `steep check` CLI stays GREEN and is the authoritative gate via tools/check-types.sh) and re-errored on every .rb edit, hanging the editor. The full steep LSP config is preserved in opencode.lsp-steep.disabled.json — paste its `steep` block back into `lsp` to re-enable. See .agents/knowledge/steep.md + HANDOFF-per-edit-diagnostics.md." +} diff --git a/opencode.lsp-steep.disabled.json b/opencode.lsp-steep.disabled.json new file mode 100644 index 0000000..75f71c8 --- /dev/null +++ b/opencode.lsp-steep.disabled.json @@ -0,0 +1,13 @@ +{ + "_comment_disabled": "Steep (RBS type checker) LSP config — DISABLED on 2026-06-25. MOVED OUT of opencode.json because the Steep langserver drifted into a corrupted state after ~3h (reported phantom Ruby::SyntaxError on a comment line that a fresh `steep check` CLI does not reproduce) and re-errored on every .rb edit, hanging the editor's LSP per-edit. A fresh `steep check` (CLI) stays GREEN (it is the authoritative type-check gate in tools/check-types.sh). To RE-ENABLE live Steep diagnostics: paste the `steep` block below back into opencode.json's `lsp` object (after `clangd`). Root cause + investigation notes sent to agent 6e09. See .agents/knowledge/steep.md.", + "steep": { + "_comment": "Steep (RBS type checker) as an LSP for .rb/.rbs — surfaces type diagnostics (NoMethod/ArgumentTypeMismatch/RBS errors) on edits, per the Steepfile (which uses D::Ruby.lenient so un-annotated game scripts + mruby's loose numerics don't flood). Coexists with ruby-lsp: ruby-lsp gives hover/completion/format (its diagnostics are off); steep gives the type checking ruby-lsp can't do. env mirrors ruby-lsp: the steep binary shebang is #!/usr/bin/ruby so GEM_HOME/GEM_PATH must point at the user gem dir to load steep + rbs 4.0.3; PATH includes the gem bin so the langserver's worker processes find `steep`. --steepfile=<abs> so it's robust to the spawn cwd (resolves sig/game paths from the Steepfile dir). First diagnostics take a few seconds to warm up (it indexes sig/*.rbs); retry on a fresh spawn. B6 parser reads command/extensions/env/initialization.", + "command": ["/home/tradam/.local/share/gem/ruby/3.4.0/bin/steep", "langserver", "--steepfile=/home/tradam/projects/raylib-jamstack/Steepfile"], + "extensions": [".rb", ".rbs"], + "env": { + "PATH": "/home/tradam/.local/bin:/home/tradam/.local/share/gem/ruby/3.4.0/bin:/home/tradam/.local/share/mise/shims:/home/tradam/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/bin", + "GEM_HOME": "/home/tradam/.local/share/gem/ruby/3.4.0", + "GEM_PATH": "/home/tradam/.local/share/gem/ruby/3.4.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c4592ba --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "raylib-jamstack-tools", + "version": "0.0.0", + "private": true, + "description": "Node tooling for raylib-jamstack dev tools (agent-bridge relay, web screenshot capture). Not a shipped package — dev-only.", + "scripts": { + "relay": "node tools/agent-bridge/server.js", + "screenshot:web": "node tools/web_screenshot.js" + }, + "devDependencies": { + "puppeteer": "^24.0.0" + } +} diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000..f8665b0 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,47 @@ +# Vendor patches + +`vendor/` is git-ignored (`.gitignore`), so fixes we carry against vendored +dependencies must live here as tracked files and be re-applied on a fresh clone. +Each `.patch` is a `git diff` against the pinned version; apply from the repo root. + +## Applying (after cloning vendors per `BUILDING.md`) + +```sh +git -C vendor/raylib apply "$(pwd)/patches/raylib-6.0-web-cursorhidden.patch" +``` + +(Rebuild after: `make -C vendor/raylib/src clean` then `zig build` / `build_web.sh`, +since raylib shares `.o` files across targets — see +`.agents/rules/raylib-platform-objs.md`.) + +## raylib-6.0-web-cursorhidden.patch + +**Pinned against:** raylib `6.0` (tag, detached HEAD in `vendor/raylib`). +**File touched:** `vendor/raylib/src/platforms/rcore_web.c`. + +**What:** raylib 6.0 regressed `IsCursorHidden()` on the web target. 6.0 split +cursor state in `rcore_web.c` into `cursorHidden` (`HideCursor`) vs `cursorLocked` +(`DisableCursor` / pointer-lock), but `EmscriptenPointerlockCallback` was updated +to set only `cursorLocked` — it stopped setting `cursorHidden` (5.5 set +`cursorHidden` there). `IsCursorHidden()` reads `cursorHidden`, so after +`DisableCursor()` on web it never returns true. + +**Symptom (without patch):** mouse-look broke in `game/physics_playground.rb` +after the 6.0 upgrade. `Rl.cursor_hidden?` stayed `false` and +`Rl.get_mouse_delta` returned `0.0` even after clicking; `Rl.get_mouse_x/y` +worked (position tracked, only pointer-lock deltas died). The game gates repeated +`disable_cursor` calls on `!cursor_hidden?`, so with the flag stuck false it +spammed `emscripten_request_pointerlock()` every frame; browsers reject that +(pointer lock must come from a single user gesture) → pointer lock never stably +engages → deltas dead. + +**Fix:** the callback now also does `cursorHidden = cursorLocked`, restoring 5.5 +semantics. `EmscriptenMouseMoveCallback` already branches on `cursorLocked`, so +deltas flow once lock engages — the patch just makes `IsCursorHidden()` reflect it. + +**Upstream:** this is a genuine raylib 6.0 bug (incomplete `cursorHidden`/ +`cursorLocked` refactor in the web platform). **Report it upstream** and re-check +on the next raylib pull — if fixed, drop this patch. + +Full scar-tissue write-up: `.agents/knowledge/web-target.md` ("raylib 6.0 +regression: IsCursorHidden() on web"). diff --git a/patches/raylib-6.0-web-cursorhidden.patch b/patches/raylib-6.0-web-cursorhidden.patch new file mode 100644 index 0000000..d97a584 --- /dev/null +++ b/patches/raylib-6.0-web-cursorhidden.patch @@ -0,0 +1,19 @@ +diff --git a/src/platforms/rcore_web.c b/src/platforms/rcore_web.c +--- a/src/platforms/rcore_web.c ++++ b/src/platforms/rcore_web.c +@@ -1752,6 +1752,15 @@ static EM_BOOL EmscriptenPointerlockCallback(int eventType, const EmscriptenPointerlockChangeEvent *pointerlockChangeEvent, void *userData) + { + CORE.Input.Mouse.cursorLocked = EM_ASM_INT( { if (document.pointerLockElement) return 1; }, 0); + ++ // raylib-jamstack: raylib 6.0 regression — this callback stopped setting ++ // cursorHidden (5.5 set cursorHidden here; 6.0 only sets cursorLocked). ++ // IsCursorHidden() reads cursorHidden, so without this, DisableCursor() on ++ // web never makes IsCursorHidden() true, breaking games that gate repeated ++ // pointer-lock requests on !IsCursorHidden() (e.g. physics_playground), which ++ // then spam emscripten_request_pointerlock() every frame and the browser ++ // rejects it. Restore 5.5 semantics: cursorHidden mirrors cursorLocked. ++ CORE.Input.Mouse.cursorHidden = CORE.Input.Mouse.cursorLocked; ++ + if (CORE.Input.Mouse.cursorLocked) + { + CORE.Input.Mouse.lockedPosition = CORE.Input.Mouse.currentPosition; diff --git a/rebuild.sh b/rebuild.sh new file mode 100755 index 0000000..896bec3 --- /dev/null +++ b/rebuild.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Incremental rebuild: mruby (libmruby.a, picks up mrbgem changes) + zig link. +set -e +ROOT="$(cd "$(dirname "$0")" && pwd)" +CLEANPATH=$(echo "$PATH" | tr ':' '\n' | grep -v '^/mnt/c' | paste -sd:) +export PATH="$(ruby -e 'puts Gem.user_dir')/bin:$CLEANPATH" +export JAMSTACK_ROOT="$ROOT" +export MRUBY_CONFIG="$ROOT/build_config.rb" + +LIB="$ROOT/vendor/mruby/build/host/lib/libmruby.a" +# Target the lib path specifically so rake doesn't try to build mruby's CLI tools +# (which fail to link without raylib/rmlui). This exits 0 on success. +( cd "$ROOT/vendor/mruby" && rake "$LIB" ) +( cd "$ROOT" && zig build ) +# Regenerate clangd's compile database (picks up new sources / the generated +# raylib_gen.c). See .agents/knowledge/ruby-lsp.md — clangd needs this so the +# relative -Ivendor/... include roots resolve against the project root. +ruby "$ROOT/tools/gen_compile_commands.rb" >/dev/null 2>&1 || \ + echo "warn: compile_commands.json not regenerated ($?)" +echo "OK -> $ROOT/zig-out/bin/game" diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..4d9be26 --- /dev/null +++ b/roadmap.md @@ -0,0 +1,575 @@ +# Roadmap — raylib-jamstack: AI Harness + First-Class Agentic Development + +This roadmap has **two intertwined goals**: + +1. **Build out the AI harness** — finish the layered context system this repo + started (`.agents/rules/`, `.agents/knowledge/`), borrowing the proven shape + from `../dispatch/arch-rewrite` (5-layer harness + orchestrator + NDJSON + observability) and `../roblox` (`.live/` state mount + cmd/result protocol + + in-game console), adapted to **mruby + Flecs**. +2. **Make agentic development first-class at runtime** — a running game (desktop + *or* browser) that an AI agent can introspect, hot-patch, and debug live: + editable Flecs systems on the fly, a WebSocket eval bridge, full game-state + read/write, Flecs's own debugging/logging surfaced to the agent, structured + logging, and an in-game Ruby console. + +> Reference reading: the AI-harness article +> (`dev.to/louaiboumediene/the-ai-harness`), `../dispatch/arch-rewrite` +> (`AGENTS.md`, `ORCHESTRATOR.md`, `.dispatch/`, `notes/observability-design.md`), +> `../roblox` (`AGENTS.md` §15, `.live/`, `.opencode/`). + +--- + +## Status — what's shipped (implemented + verified) + +- **Part A harness:** H0–H3 + plan-first/session-hygiene done. H4 (subagents) + deferred; H5/H6 partial/deferred. +- **Part B runtime — desktop AND web, all verified:** + - **R1** eval bridge — TCP + `.live/bin/eval`; web `jamstack_eval` C export. ✅ + - **R2** structured `Jamstack::Log` — NDJSON ring buffer + sinks, queryable over the bridge. ✅ + - **R3** hot-reloadable Flecs systems (`Flecs::Hot`) — same system id, state survives; + verified live, **including in a browser tab**. ✅ + - **R4** `.live/` file mount (desktop, `Jamstack::Live`) + **W2** dependency-free Node + relay (`tools/agent-bridge/server.js`) so the SAME `.live/bin/*` drive a browser tab. ✅ + - **R5** flecs REST/Explorer + stats (`world.enable_rest`/`enable_stats`, desktop); + **R5a** wasm `ecs_rest_server_init` + flecs's `flecs_explorer_request` wired. ✅ + - **Demo:** `game/ragdoll_demo.rb` — flecs-tracked, bridge-summonable ragdolls + (mruby↔flecs↔Jolt). Verified live (desktop + browser): `summon_ragdoll(x,y,z)`, + `$flecs.query`/`lookup` live position sync, and a live `Flecs::Hot` reload. + +- **Known issues / immediate next:** + - ~~**🔴 flecs entity DELETE leak**~~ — **FIXED.** Root cause: 32-bit `mrb_int` on + wasm32 truncated the generation bits (high 32) of `ecs_entity_t` when ids + round-tripped through `fl_yield_iter`/`fl_w_delete`/`fl_w_alive`. Fix: force + `-DMRB_INT64` in `build_config.rb` (both builds) + on `src/main.c` compilation + (`build.zig`/`build_web.sh`) so the full 64-bit entity id round-trips on every + target. Verified with a summon/delete/summon recycle test on desktop + web. + - ~~**ballpit_demo.rb yaw fix**~~ — **FIXED.** Same `cam_yaw += md.x` → `-=` as + the ragdoll demo. + - ~~**bin/snapshot / bin/query**~~ — **DONE.** Added `Flecs::World#rest_request` + (in-process `ecs_http_server_request`, no socket — works on desktop AND web). + `bin/snapshot` → `/world` → writes `.live/<token>/state.json`; `bin/query <expr>` + → `/query?expr=<expr>&values=true`. Verified on desktop (running game) + web + (node headless). + - **R6 — in-game RmlUi console** — keyboard-input gap now CLOSED: + `rml_context_process_input` forwards keys (raylib→RmlUi KI_* map), text input + (`GetCharPressed`→`ProcessTextInput`), and modifiers. Unblocks R6; the console + itself is the next build on top of it. + - **H6** (LSP/RBS generator): **DONE for Rl/Rml.** `gen_rbs.rb` generates + `sig/raylib.rbs` from `raylib_api.json` (650 functions, 34 structs, 331 + constants). Hand-written `sig/rmlui.rbs`, `sig/flecs.rbs`, `sig/jolt.rbs`, + `sig/jamstack.rbs`. `opencode.json` enables `ruby-lsp` with RBS auto-discovery. + `sig/jolt.rbs` and `sig/flecs.rbs` are fully typed (public API + the `_` C + primitives + the `Flecs::Hot` sugar, from the `API_SPEC_*.md` + binding + sources). Only the genuinely dynamic part is left loose: Flecs component + *values* are `Hash[Symbol, untyped]` (runtime meta structs ↔ Ruby Hashes, + no per-component class) — the static surface (lifecycle, phases, system/query + registration, REST, Hot) is precise, with a `term` alias for the + Component|Entity|Integer id union. + +- **Agentic-runtime tip:** reach live *objects* via `ObjectSpace.each_object` (e.g. the + player `Jolt::Character`) instead of rebuilding to expose a global — eval is live; a + top-level *local* isn't reachable from an eval binding, but the object is. + +--- + +## Guiding principles (adapted to this repo) + +Borrowed from the article and `../dispatch` (P1–P8), trimmed to what matters here: + +- **P1 — The repo is a harness, not just code.** Agent-facing meta-info (rules, + knowledge, features, skills, glossary) is a first-class deliverable, maintained + with the same care as the bindings. +- **P2 — Document only the non-inferable.** If a frontier model could infer it + from the code, leave it out. Tribal knowledge (ABI traps, WSL, premultiplied + alpha, flecs wasm stack, generator naming) is gold; generic advice is noise. +- **P3 — Tiny files always loaded; big files on demand.** Rules + AGENTS.md every + session (~tens of lines); knowledge docs only when touching that area; + skills only when invoked. +- **P4 — One canonical vocabulary.** A `GLOSSARY.md` with an "aliases to avoid" + column prevents synonym drift (System vs system vs phase-callback, Component vs + struct vs tag, mrbgem vs binding vs gem). +- **P5 — Separate code-mutation from runtime-observation** (the `../roblox` + lesson). The agent **mutates** behavior by editing Ruby files / hot-reloading; + it **observes** state through a read-mostly `.live/` surface + logs. Keep the + write path and the read path distinct and obvious. +- **P6 — Single-threaded safety.** mruby is **not** thread-safe and Flecs mutation + must happen on the main thread. Every agent/console/bridge command is funneled + through a **frame-polled command queue drained on the main thread**. No command + ever runs on a socket/JS callback thread directly. +- **P7 — Dev-only by construction.** The eval bridge executes arbitrary Ruby. It + binds to localhost, is gated behind a build flag / env var, and never ships in a + release web build. + +--- + +## Where we are today (baseline) + +**Harness (partial):** +- `AGENTS.md` (constitution, 62 lines) + `CLAUDE.md` (currently a *duplicate*, not + a symlink) + `docs/AI_REFERENCE.md` (generated typed API). +- `.agents/rules/` — 6 safety reflexes (toolchain, link order, LTO, mruby rebuild, + raylib platform objs, generated files). +- `.agents/knowledge/` — 8 per-area docs (build-system, environment, raylib-, + rmlui-, flecs-, jolt-binding, web-target, testing). +- **Missing harness layers:** `GLOSSARY.md`, per-area orientation (folded into the + existing `.agents/knowledge/` docs — one tribal-knowledge location, no separate + `features/` tree), `.agents/skills/` (codified workflows), subagents, the symlink + trick, an orchestrator/plan-mode workflow. + +**Runtime (nothing agentic yet):** +- `src/main.c` boots one `mrb_state`, runs `argv[1]` as **plain-text** Ruby + (default `game/main.rb`), no REPL/eval-in/hot-reload. +- Platform seam: `Rl.while_window_open` (`mrbgems/raylib/mrblib/raylib.rb`) — + desktop `until window_should_close?` vs web `emscripten_set_main_loop`. +- Flecs binding exists (`Flecs::World`, systems, queries) but **no game uses it + yet**; the amalgamation is compiled **with** REST/HTTP but **no Ruby way to + start it**; no flecs logging exposed. +- `mruby-eval`, `mruby-socket`, `mruby-io` are **already compiled in** (via the + `default` gembox). `mirb` is intentionally not linked. +- Web: `web/shell.html` (hardcoded entry script, `Module.print → console.log`), + served by `python3 -m http.server`. No dev server / live reload / WS. + +This baseline is good news: the eval primitive (`mruby-eval`) and the I/O +(`mruby-socket`/`mruby-io`) we need already exist; the work is wiring, not new +toolchain plumbing. + +--- + +## Target architecture (the runtime loop) + +``` + ┌────────────────────── one mrb_state (main thread) ─────────────────────┐ + │ │ + AI agent ──┐ │ Rl.while_window_open do │ + console ──┤ │ Bridge.drain_queue # eval queued Ruby cmds here (main thread) │ + (RmlUi) ──┼──▶│ Hot.progress(dt) # flecs world.progress; systems dispatch via │ + │ │ # registry → current Ruby proc (swappable) │ + WS / JS ───┘ │ draw... │ + bridge │ end │ + │ ▲ │ │ + │ │ enqueue ▼ emit │ + │ command queue Log pipeline (NDJSON ring buffer) │ + └────────┼─────────────────────────┼──────────────────────────────────────┘ + │ │ + desktop: in-proc non-blocking TCP/WS poll ├─▶ stdout (→ console.log on web) + web: JS calls exported jamstack_eval() ├─▶ .live/{game,browser}-console + └─▶ flecs logs (ecs_log_set_level) + │ + Relay (tools/agent-bridge, Bun/Node WS hub) + │ + .live/<token>/ (status.json, state.json, game-console, browser-console, .agent/cmd-*/result-*, bin/*) +``` + +**Why a queue, not direct calls:** sockets (desktop) and JS WS callbacks (web) run +outside the frame; mruby/Flecs aren't safe to touch there. Everything is enqueued +and drained at one well-defined point each frame (P6). This single design works +identically on both targets — it *is* the new platform seam, sitting right next to +`Rl.while_window_open`. + +--- + +# Part A — Complete the AI Harness + +Cheap, high-leverage, mostly docs. Do this first; it makes every later phase +faster because Plan Mode gets grounded context. + +## Phase H0 — Harness plumbing & dedupe +- **Symlink trick** (article Layer 6): make the harness tool-agnostic. `.agents/` + is the single source of truth; symlink `.claude/ → .agents` (and add others as + tools appear). Replace the duplicated `CLAUDE.md` with a symlink to `AGENTS.md` + (or a one-line pointer) so the constitution lives in exactly one place. +- Add `.claude/settings.json` (or equivalent): permission allowlist for the build + commands (`zig build`, `./rebuild.sh`, `build_web.sh`, run scripts) and the + WebFetch domains we actually use (raylib, flecs.dev, RmlUi, emscripten docs). +- **Acceptance:** editing a rule in `.agents/` is visible from every tool dir; no + duplicated constitution. + +## Phase H1 — Glossary (canonical vocabulary) +- New `GLOSSARY.md` (table: Term | Meaning | Aliases to avoid). Seed with the + terms that drift in an ECS + bindings repo: + - `World`, `Entity`, `Component` (runtime struct via meta addon) vs **tag** vs + **pair**, `System` vs **phase callback**, `query`, `phase` (`ON_UPDATE`...), + `binding`/`mrbgem` (avoid "gem"/"plugin"), `generator` (`gen_raylib.rb`), + `platform seam`, `command queue`, `hot-reload` vs **reboot**, `the bridge`, + `the live mount`. +- **Acceptance:** every new doc/skill uses glossary terms; "aliases to avoid" + catches the obvious synonyms. + +## Phase H2 — Per-module orientation (folded into `.agents/knowledge/`) +**Decision:** keep ONE tribal-knowledge location. Each per-area knowledge doc opens +with an "At a glance" header (Summary / Key files / API+spec pointer / Cross-refs) +above its deep tribal detail — no separate `.agents/features/` tree (a 2nd per-area +location just drifts). Covers the modules that exist; add agentic ones as built +(Part B): +- `raylib-`, `rmlui-`, `flecs-`, `jolt-binding.md`, `build-system.md`, `web-target.md`. +- Later (after Part B): `agent-bridge.md`, `hot-reload.md`, `logging.md`, + `flecs-observability.md`, `console.md` (all in `.agents/knowledge/`). +- **Rule of thumb:** write the knowledge doc *first* when you start work in an area + that lacks one — it doubles as the Plan-Mode brief and pays for itself. + +## Phase H3 — Skills (`.agents/skills/`, codified workflows) +Each skill is a short SKILL.md procedure for a thing we always forget the steps +of. Initial set, all THIS-repo-specific: +- `/add-binding-fn` — add a raylib fn: edit the **generator**, never `raylib_gen.c`; + regen; `make clean` if switching target; rebuild order. +- `/add-flecs-system` — define a component (meta struct), register a system via + the **hot-reloadable** registry (Phase R3), verify with the live mount. +- `/new-demo` — scaffold a `game/*.rb` scene against the platform seam. +- `/build-and-verify` — PATH strip → `./rebuild.sh` (desktop) → offscreen + render→PNG → web build → node smoke test (mirrors `.agents/knowledge/testing.md`). +- `/add-knowledge` — where a newly-discovered gotcha goes (rule vs knowledge vs + feature) so scar tissue gets crystallized, not lost. +- Later: `/agent-eval`, `/hot-reload-system`, `/inspect-state`, `/debug-with-logs`. + +## Phase H4 — Subagents (scoped) — **DONE** +Scoped subagents in `.opencode/agent/`: +- **`backend-engineer`** — generalized C/C++ developer: new native features + ("backend"), binding fixes/additions, generators, build system, and + **performance migrations** (Ruby → C). Three job types, one agent. +- **`gameplay-ruby`** — `game/**` Ruby + `mrblib/*.rb` sugar. Uses the live web + bridge (`bin/eval`, `bin/snapshot`, `bin/query`, ObjectSpace) to test. Never + touches C/C++ or generators. +- **`reviewer`** — read-only; checks against rules + glossary + API specs. + Permission: edit deny, bash ask. + +New skill: `.agents/skills/ruby-to-native/SKILL.md` — the performance migration +workflow: profile via `bin/eval` → write C → swap call site → verify via web +bridge → update types. Covers bulk array processing, struct batch ops, and +keeping Ruby flexibility (don't over-migrate). + +`AGENTS.md` updated with web-first workflow + subagent section. + +## Phase H5 — Orchestration & cadence (lightweight) +Adopt the lighter half of `../dispatch`'s workflow: +- A short **plan-first** note in `AGENTS.md`: write a brief → Plan Mode → review + the *plan* not the code → execute via a skill. +- `tasks.md` (live milestone log) + a `HANDOFF.md` convention for cross-session + continuity. Skip the full prompts/reports orchestrator unless multi-agent waves + become routine. +- **Acceptance:** a feature can be driven from a one-paragraph brief + a skill, + with ≤2–3 corrections. + +## Phase H6 — LSP & editor intelligence (mruby + bindings) +Give the agent (and humans) language-server feedback over Ruby game code — made +*accurate* for mruby and our C-defined bindings. + +**The catch (why this isn't just `lsp: true`):** opencode ships `ruby-lsp`, but it +assumes **CRuby**. Pointed at `game/**` it flags `Rl`/`Rml`/`Flecs`/`Jolt` as +*undefined constants* (they're C-defined — no Ruby source to index) and offers +CRuby-stdlib completions mruby lacks. opencode's own docs warn LSP "is not always a +net positive"; raw-enabled here it mostly emits **misleading** diagnostics. + +**The leverage:** `gen_ai_reference.rb` already parses `raylib_api.json` / +`raymath_api.json` and has the exact machinery — a C→Ruby type map (`rtype`), +method naming (`ruby_method`), and fully-typed signatures (`sig`). The typed data +to teach an LSP our bindings already exists in machine form. + +**Steps:** +- **Generate RBS** (`sig/*.rbs`, **generated — never hand-edit**, like + `AI_REFERENCE.md`/`raylib_gen.c`): extend `gen_ai_reference.rb` (or a sibling + `gen_rbs.rb`) to emit signatures for `Rl`/`Rml` reusing the existing type map; + regenerate in the same step as AI_REFERENCE. `ruby-lsp` consumes RBS natively → + completion / hover / signature-help / go-to-def on the bindings, and the + undefined-constant noise disappears. Flecs/Jolt RBS comes later (their API lives + in `API_SPEC_FLECS.md` etc., not `raylib_api.json`) — hand-write or add an emit. +- **`opencode.json` `lsp` block:** enable `ruby-lsp` for `.rb`; pick a rubocop + noise policy (disable style cops or scope them); wire it to the `sig/` dir (RBS + auto-discovery / `rbs_collection`). +- **mruby-core accuracy (deferred):** a slim hand-maintained RBS of mruby's actual + core subset would kill the residual CRuby-stdlib false positives — high upkeep, + low ROI; defer until the noise actually bites. +- **C/C++ side (bonus, separate):** opencode auto-installs `clangd` for the binding + sources, but it needs a `compile_commands.json` we don't emit (zig/emscripten). + Optional until we can emit one; not part of the mruby ask. + +**Sequencing:** enabling `ruby-lsp` is only clearly net-positive *after* the RBS +stubs exist — before that it mainly adds noise. Land the generator with the config, +or gate the config behind it. + +**Acceptance:** hover on `Rl.draw_text` in a `game/*.rb` shows the typed signature; +`Flecs::World` resolves; no false "undefined `Rl`" diagnostics; the agent can use +LSP nav on bindings. + +--- + +# Part B — First-Class Agentic Runtime + +This is the substance of the request: hot-reloadable Ruby in the browser, a WS +eval bridge, full state read/write, Flecs debugging/logging, structured logging, +and an in-game console. Built bottom-up; each phase is independently useful. + +## Phase R1 — Eval-in + frame-polled command queue (foundation) +The primitive everything else rides on. +- Keep the `mrb_state` from `src/main.c` alive after boot (already is) and expose a + re-entrant eval that runs **on the main thread only**. +- **C surface:** `jamstack_eval(const char *code) -> char *json` that does + `mrb_load_string_cxt` on the persistent state and returns + `{ ok, result (inspect), stdout, error, backtrace }` as JSON. Capture + `mrb->exc`, format the backtrace, reset the exception so the loop survives a bad + eval. On web, export via `EMSCRIPTEN_KEEPALIVE` + `cwrap`/`ccall`. +- **Ruby surface:** a `Bridge` (or `Console`) module with a thread-safe-by-frame + command queue: producers `enqueue(code, id)`; `Bridge.drain` runs each queued + command via eval inside `while_window_open`, before `world.progress`, and routes + results back. Cap per-frame drain to bound frame time. +- **Desktop input path:** non-blocking TCP poll (`mruby-socket`, accept+recv with + no blocking) each frame → enqueue. (WS upgrade comes in R4; raw TCP/line-JSON is + fine to start.) +- **Web input path:** JS pushes into the queue via the exported C function (R4 + wires the actual WS; here just prove `jamstack_eval` works from `Module.ccall`). +- **Acceptance:** with the game running, send `Rl.get_fps` and a multi-line script; + get the value, stdout, and a clean error+backtrace for a deliberate exception — + the loop keeps running. + +## Phase R2 — Structured logging pipeline (for AI debugging) +- Ruby `Log` module: levels (`debug/info/warn/error`), structured fields, tags, + monotonic frame counter. Emits **NDJSON** (one JSON object per line) — the + format `../dispatch/notes/observability-design.md` standardizes on and that + agents can grep/tail trivially. +- **Ring buffer** in memory (last N entries) queryable via eval + (`Log.tail(50)`, `Log.grep(/.../)`) so an agent can pull recent context without + a file. +- **Two console streams (P5 — keep them distinct, not merged):** + - **`game-console`** — the structured `Log` NDJSON above (game + engine + *intent*): mruby exceptions, flecs logs, gameplay events. All targets. Flows + *through* the runtime/bridge. + - **`browser-console`** — the **actual platform console**: every line the + browser would print to DevTools (JS exceptions, unhandled rejections, + Emscripten `printErr`, and **wasm aborts** — OOM/stack-overflow/asserts). + Web only; desktop analog is process stdout/stderr (raylib `TraceLog`, flecs, + C `fprintf(stderr)`, uncaught mruby). + - **Critical — runtime independence:** the lines worth most (aborts) are the + ones that *kill* mruby, so a Ruby-side sink would miss them. Capture + browser-console in **pure JS** (R4), buffer, and flush to the relay over WS + regardless of module health. Both streams normalize to NDJSON, `source`-tagged; + `bin/tail-log [--game|--browser]` can merge them by timestamp. + - **Scope (deferred):** `browser-console` only exists on **web**, so it rides with + R4 — and the capture work (in-page JS shim, and *especially* a browser + extension) is **deferred until a real need appears** (judged low-odds). **R2 + delivers `game-console`:** the Ruby `Log` NDJSON plus tee-ing desktop process + stdout/stderr (raylib/flecs/uncaught mruby) into the same stream. +- **Capture mruby exceptions** from the loop and from eval into game-console with + backtraces. +- **Flecs logs:** call `ecs_log_set_level(n)` (bind it) and install a flecs + OS-API log callback that funnels flecs's own tracing into this same pipeline, so + ECS internals and game logs interleave with timestamps. +- **Acceptance:** an agent can answer "what errored in the last 5s and on which + entity" purely from the NDJSON stream. + +## Phase R3 — Hot-reloadable Flecs systems & features (the core ask) +Goal: **edit systems and add/modify features on a running browser game without +resetting** — entities and component data survive; only behavior swaps. + +**Mechanism (the key design):** don't re-register Flecs systems on reload (that +loses identity and forces component re-lookup). Instead: +- A **system registry** keyed by name. `define_system(name, with:, phase:, &blk)`: + - First call: registers a Flecs system whose C callback dispatches to a **stable + Ruby dispatcher** that looks up the *current* proc by name in the registry. + - Subsequent calls (reload): just **replace the proc** in the registry. Same + system id, same matched tables, same entity/component state → new logic. +- `Hot.reload_string(code)` / `Hot.reload_file(path)`: re-`eval` a Ruby module of + systems; `define_system` is **idempotent** so re-running a file just swaps procs. +- Convention so reload is clean: game code is organized into reloadable units — + `game/components.rb`, `game/systems/*.rb`, `game/features/*.rb` — each + re-runnable top-to-bottom without side effects beyond (re)registration. + +**What survives vs what needs a reboot — document this table in `hot-reload.md`:** + +| Change | Hot-reload? | Why | +|---|---|---| +| Edit a system's body / add/remove a system | ✅ swap proc | state untouched | +| Add a **new** component (new meta struct) + entities | ✅ additive | new id, no layout change | +| Change an existing component's struct layout | ⚠️ reboot (or migrate) | existing entities hold old layout | +| Add/rename a query, change phases | ✅ re-register that system | cheap | +| Change C/C++ binding, generator, build flags | ❌ reboot | native ABI / relink | +| raylib/native resource re-init (window, GPU) | ❌ reboot | native lifetime | + +- **Web reboot path:** since C/C++ changes need a reboot anyway, the web flow is + "rebuild wasm + reload page". Ruby-only changes never reboot — that's the win. +- **Acceptance:** in the browser, change a movement system's speed and add a brand + new system while entities keep their positions; verify via the live mount that + state persisted and behavior changed. + +## Phase R4 — Agent bridge (WebSocket) + `.live/` mount +Turn R1's queue into the channel the AI and console actually use. Mirrors +`../dispatch` (WS transport + collector) and `../roblox` (`.live/` + cmd/result). + +- **Web side — `web/agent-bridge.js`** (loaded by `shell.html`, dev builds only): + - `cwrap` `jamstack_eval`; expose a WS client that connects out to the relay. + - On `{id, code}` → push into the wasm command queue → return + `{id, ok, result, stdout, error}`. + - **Browser-console capture (runtime-independent; the shim is installed *first* + in `shell.html`, before the Emscripten module script, so early `.wasm`/`.data` + load failures are caught):** tee `console.*`, `window.onerror`, + `unhandledrejection`, `Module.print`/`printErr`, and `Module.onAbort` → buffer + → forward over WS as `{type:"browser-log"}`. Two completeness tiers: + - **A (baseline, always on):** the in-page JS shim above. Catches everything + routed through JS incl. all wasm aborts; misses a few browser-internal lines + (some WebGL/CSP/deprecation). Works in any tab, no install; also the only + tier that captures pre-attach / early-boot errors. + - **B (exact) — Chrome extension via `chrome.debugger`: DEFERRED / unlikely.** + It would attach CDP to the human's *real* tab for the literal, complete + DevTools console (incl. the browser-internal lines A misses), but it's a lot + of work (MV3 service worker, the "being debugged" banner, one-debugger-per-tab, + Chrome-only) for a payoff we probably never need. Revisit only if A proves + insufficient in practice. + Even tier A is built only once a web console is actually wanted; until then the + browser stream is out of scope (desktop uses process stdout/stderr). +- **Desktop side:** promote R1's raw TCP to a minimal WS server in-process + (`mruby-socket`, non-blocking, framed), or just keep line-delimited JSON over TCP + if simpler — same queue, same protocol shape. +- **Relay — `tools/agent-bridge/server.js`** (Bun/Node WS hub): + - The running game connects as the **runtime**; agents/CLI and the human console + connect as **clients**. Routes eval requests to the runtime, broadcasts logs + + state snapshots to clients. + - Maintains the `.live/` surface so **file-based agents** work without speaking + WS (the `../roblox` pattern): + Per-instance, keyed by play token (`.live/<token>/`) so parallel games don't + collide; all writes atomic (temp + `rename`) so agents never read a half file: + - `status.json` — connected?, target, frame, fps, entity count, token, + uptime (cheap heartbeat). + - `game-console` — structured `Log` NDJSON (R2): mruby/flecs/gameplay. + - `browser-console` — the real browser DevTools stream (R2 capture); web only. + - `state.json` — throttled world snapshot (Flecs REST JSON, Phase R5). + - `.agent/cmd-<id>.json` → `result-<id>.json` — write-a-command, + poll-for-result (the **only** agent-writable path). + - `bin/*` — helper scripts: `eval`, `query <entity>`, `snapshot`, + `tail-log [--game|--browser]`, `hot-reload <path>` (wrap the cmd/result + protocol so an agent just runs a command). +- **`.live/` mechanism (no FUSE — unlike `../roblox`):** real files, because here a + host-side writer always exists. **Desktop:** the game has `mruby-io`, so it writes + `.live/` directly and scans `.agent/` for new `cmd-*` inside the R1 frame drain — + no relay needed for file-based agents (verify `mruby-dir` for `readdir`, else add + it or route through the relay). **Web:** the tab can't touch host disk, so the + relay is the writer/bridge. FUSE was a `../roblox` workaround for a *sandboxed* + runtime with no host helper; that constraint doesn't exist here, and FUSE is a + poor fit for our WSL environment besides. +- **Security (P7):** localhost only; bridge + relay enabled by a build flag/env + (`JAMSTACK_BRIDGE=1`); never included in a release web build. +- **Acceptance:** from a shell, `.live/bin/eval 'world.count(Position)'` returns a + number; `.live/bin/tail-log` streams live errors; the same works against a + browser tab. + +## Phase R5 — Flecs observability (REST / Explorer / stats) +Surface Flecs's built-in debugging so the agent (and a human) get a full ECS view. +- **Bind it:** `Flecs::World#enable_rest(port: 27750)` → sets the `EcsRest` + singleton; enable `FLECS_STATS`/`FLECS_MONITOR` for per-system timing and world + stats; bind `ecs_log_set_level`. +- **Desktop:** `EcsRest` starts the HTTP server on `:27750` (separate thread, + handled by flecs). Connect the **hosted Flecs Explorer** in *remote* mode + (`flecs.dev/explorer?remote=true&host=localhost:27750`) for a full entity / + query / stats UI — zero UI code on our side. +- **Web spike (R5a — research, do early):** flecs's `ecs_http` uses POSIX sockets + and **cannot bind a listening port in the browser**, so the desktop REST path + won't work in wasm. Plan: call `ecs_http_server_request()` (feed a synthetic + request string, get the JSON reply, **no socket**) from a bound Ruby/C function, + and ship those JSON replies over the **R4 WebSocket**. The relay re-exposes them + on a local HTTP port so the hosted Explorer can connect to the browser game + too. Validate that the REST module's request handler is reachable without the + socket server thread. +- **Feed `.live/state.json`** from the same REST JSON (entities, components, + stats) so file-based agents see structured world state. +- **Acceptance:** desktop game inspectable in the Explorer; browser game's world + queryable as JSON through the bridge; per-system timing visible. + +## Phase R6 — In-game Ruby console (RmlUi) — **DONE** +A user-facing REPL console with the same powers as the agent bridge (it shares +the `mrb_state`, so "full game state access" is free). +- ~~RmlUi panel (`game/ui/console.rml` + `.rcss`): scrollback + input, toggle key + (e.g. backtick `` ` ``).~~ **DONE.** `Jamstack::Console` (in + `mrbgems/rmlui/mrblib/console.rb`) manages the panel: toggle with backtick, + Enter to eval, Up/Down for command history, auto-scroll, error backtraces. +- ~~Executes any Ruby via `Bridge`/`Console.eval`~~ — **DONE.** Uses `eval(code, + binding)` with the game script's binding; full access to local variables and + game state. Pretty-prints results via `inspect`, errors with class + message + + backtrace. +- Works on desktop and web (it's just Ruby + RmlUi). **Verified on desktop** + (eval, results, errors, history, toggle all pass). Web build compiles cleanly. +- ~~Note the **RmlUi keyboard-input gap**~~ — **CLOSED.** `rml_context_process_input` + now forwards keys (raylib→RmlUi KI_* map), text input (`GetCharPressed`→ + `ProcessTextInput`), and modifiers. See `.agents/knowledge/rmlui-binding.md`. +- **Acceptance:** open console in the browser, type `world.each(Position){|e,p| ...}` + and a hot-reload command; see results inline; close and keep playing. + +## Phase R7 — Crystallize the new tribal knowledge — **DONE** +The runtime work generates exactly the kind of scar tissue the harness exists to +hold. Close the loop: +- ~~Knowledge docs: `agent-bridge.md`, `hot-reload.md`, `logging.md`, + `flecs-observability.md`, `console.md`.~~ — **DONE.** All exist; `console.md` + created with the R6 REPL console details (toggle, variable propagation, + tab completion, caret control, key identifiers, HTML escaping). +- ~~New rules: "all eval/console/bridge commands run on the main thread"~~ — + **DONE.** `.agents/rules/main-thread-eval.md`. +- Skills: `/agent-eval`, `/hot-reload-system`, `/inspect-state`, + `/debug-with-logs` — deferred (low ROI; the knowledge docs cover the workflows). +- ~~Update `AGENTS.md` with "Agentic dev loop" section.~~ — **DONE.** Also + updated the rules count (6→7) and the knowledge file table (added `console.md`). + +--- + +## Dependency / sequencing summary + +``` +Part A (H0→H5) ── can proceed in parallel with Part B; do H0–H3 first for leverage +Part B: + R1 eval+queue ─┬─▶ R2 logging + ├─▶ R3 hot-reload (needs R1) + └─▶ R4 bridge+.live (needs R1; consumes R2 logs) + R5 flecs obs ──▶ feeds R4 (.live/state.json); R5a wasm spike research early + R6 console ────▶ needs R1 (+ RmlUi keyboard fix); reuses R2 + R7 docs ───────▶ after each of R1–R6 lands (continuous) +``` + +**Suggested order:** H0–H3 → R1 → R2 → R3 → R4 → R5 + R5a → web W1 + W2 → +~~FIX flecs delete leak~~ ✅ → ~~bin-snapshot~~ ✅ → ~~ballpit yaw fix~~ ✅ → +~~H6 RBS generator~~ ✅ → ~~R7 tribal knowledge~~ ✅ → ~~H4 subagents~~ ✅ → +H5 remaining. +Next: H5 (tasks.md / HANDOFF.md cross-session convention). + +## Open research questions / spikes +- **R5a (highest risk):** confirm `ecs_http_server_request()` works without the + socket server thread in the emscripten build, and that the hosted Explorer can + drive it through the relay. Fallback: ship raw REST JSON to the agent only (no + Explorer UI on web). +- **WS in mruby on desktop:** is an in-process non-blocking WS server via + `mruby-socket` worth it, or keep line-JSON over TCP and let the relay speak WS to + clients? (Lower risk: latter.) +- **Component-layout migration:** is a "migrate on reload" path worth building, or + is reboot-on-layout-change an acceptable permanent constraint? (Default: reboot.) +- ~~**RmlUi keyboard input** (R6 dependency)~~ — **DONE.** Gap scoped + closed: + `rml_context_process_input` forwards keys, text input, and modifiers. See + `.agents/knowledge/rmlui-binding.md`. +- **Snapshot cost:** how often can `.live/state.json` be regenerated from REST JSON + without hurting frame time on large worlds? (Make it on-demand + throttled.) +- **Subagent granularity (H4):** one **game-code** subagent (summoned for any + `game/**` edit) vs a finer split (`gameplay-ruby`, …). **Deferred** — lean toward a + single game subagent first; revisit only if context bloat or coordination + problems appear. The **game/engine boundary** (GLOSSARY "Code boundaries") holds + either way, so this is just how many agents share the game side. +- **Parallel sessions vs ONE running game (R4):** the harness article's git-worktree + parallelism assumes stateless file editing + independent test runs. Our hot-reload + model is a **single process / single `mrb_state`** (P6). Two agents hot-reloading + into the same game stomp each other. Plan: parallel worktrees must each spawn their + **own** game instance (own process/port/bridge, own `.live/` dir keyed by the play + token). Decide the per-instance `.live/` layout before R4. +- **Browser-console capture: DEFERRED.** Only matters on web; the in-page shim (A) + is built if/when a web console is actually wanted, and the Chrome extension (B) is + judged unlikely to be worth the effort (Playwright also rejected as too heavy). + Until then, "console" means desktop process stdout/stderr + the Ruby `game-console`. +- **Flecs entity-delete leak (🔴 urgent):** the full 64-bit `ecs_entity_t` carries + generation bits that flecs bumps on entity recycling; if `mrb_as_int` truncates or + the binding strips them, `alive?`/`delete` operate on a stale id. Fix needs + inspection of `fl_yield_iter`'s id yield, `fl_w_delete`, and `fl_w_alive` in + `flecs_bindings.c`. (Observed: delete of 30 entities after a re-summon left 30/30 + in the query with `alive?`=false before any mruby-side delete call.) +- **Expose the bridge as an MCP server (R4):** wrap `jamstack_eval` / `query_state` / + `tail_log` / `load_level` / `hot_reload` as MCP tools so any MCP-capable client gets + first-class runtime access — the runtime analog of the symlink trick's + tool-agnosticism — alongside the file-based `.live/bin/*`. Evaluate vs. raw WS. + +## Non-goals (for now) +- No multi-threaded eval or background system execution (P6). +- No shipping the eval bridge in production web builds (P7). +- No hot-reload of C/C++/generator/build changes — those reboot, by design. +- No full `../dispatch` prompts/reports orchestrator until multi-agent waves are + routine (H5 keeps it light). diff --git a/sig/flecs.rbs b/sig/flecs.rbs new file mode 100644 index 0000000..b73d89d --- /dev/null +++ b/sig/flecs.rbs @@ -0,0 +1,191 @@ +# Flecs (ECS) Ruby API signatures (hand-written from docs/API_SPEC_FLECS.md). +# Mirrors mrbgems/flecs/mrblib/flecs.rb (public API), the low-level C primitives +# in mrbgems/flecs/src/flecs_bindings.c (Flecs::World#_*), and the hot-reload +# sugar in mrbgems/flecs/mrblib/hot.rb (Flecs::Hot). +# +# Components are real C structs declared at runtime via the meta addon and +# (de)serialized to/from Ruby Hashes — there is no per-component Ruby class, so +# component VALUES are typed Hash[Symbol, untyped] (dynamic by design). Everything +# else (lifecycle, phases, system/query registration, REST, Hot) is static and +# typed precisely. + +# A component/tag id: a Flecs::Component, a Flecs::Entity, or a raw Integer id +# (anything responding to `to_i`, per API_SPEC_FLECS.md §3). Used wherever a +# component/tag term is accepted (set/get/add/remove/has?, query, system `with:`). +type term = Flecs::Component | Flecs::Entity | Integer + +module Flecs + # Pipeline phase ids (Integer constants). Systems run in this order each + # `progress`: ON_LOAD -> PRE_UPDATE -> ON_UPDATE (default) -> ON_START. + ON_LOAD: Integer + PRE_UPDATE: Integer + ON_UPDATE: Integer + ON_START: Integer +end + +# Owns all entities/components/systems. One per game (more allowed). Wraps an +# `ecs_world_t` (freed automatically by GC). Single-threaded `progress` only. +class Flecs::World + def initialize: () -> void + + # --- entities --- + # Create an entity (optionally named). Anonymous if `name` is omitted. + def entity: (?String name) -> Flecs::Entity + # Wrap a raw entity id (e.g. one yielded to a system/query block). + def entity_for: (Integer id) -> Flecs::Entity + # Look up an entity/component by name -> nil if not found. + def lookup: (String name) -> Flecs::Entity? + + # --- components / tags --- + # Declare a component as a C struct from a meta descriptor string, e.g. + # world.struct("Position", "{float x; float y;}") + # Returns a Flecs::Component (usable wherever an id is expected). + def struct: (String name, String descriptor) -> Flecs::Component + alias component struct + # A tag is a dataless entity used as an id (add/remove/has?). + def tag: (String name) -> Flecs::Component + + # --- queries --- + # Cached query over the given component/tag ids. Reuse across frames. + def query: (*term components) -> Flecs::Query + + # --- systems --- + # Register a system that runs each `progress` during `phase`. Block receives + # |entity_id, *component_hashes| per matched entity; mutations to the component + # hashes are written back into component memory. Tag terms yield `nil` for their + # slot. Returns the system entity id (Integer). + def system: ( + String name, + with: Array[term], + ?phase: Integer + ) { (Integer entity_id, *(Hash[Symbol, untyped] | nil) component_hashes) -> void } -> Integer + + # --- simulation --- + # Advance the world by `dt` seconds, running all systems. Returns false when + # the world wants to quit. + def progress: (?Float dt) -> bool + + # --- observability (R5; dev-only) --- + # Start the flecs REST API so the hosted Flecs Explorer can inspect the live + # world. Served during progress. Returns self. + def enable_rest: (?Integer port) -> Flecs::World + # Per-system timing + world monitor stats (shown in the Explorer). Returns self. + def enable_stats: () -> Flecs::World + # Query the flecs REST API in-process (no socket — works on desktop AND web). + # Requires enable_rest first. Returns raw JSON, or nil for an empty body. + # world.rest_request("GET", "/world") + # world.rest_request("GET", "/query?expr=Position&values=true") + def rest_request: (String method, String path, ?String body) -> String? + + # --- low-level C primitives (flecs_bindings.c) --- + def _entity: (?String? name) -> Integer + def _lookup: (String name) -> Integer? + def _name: (Integer id) -> String? + def _set_name: (Integer id, String name) -> void + def _delete: (Integer id) -> void + def _alive?: (Integer id) -> bool + def _add: (Integer id, Integer comp) -> void + def _remove: (Integer id, Integer comp) -> void + def _has?: (Integer id, Integer comp) -> bool + # value is a Hash matching the component's meta descriptor. + def _set: (Integer id, Integer comp, Hash[Symbol, untyped] value) -> Flecs::World + def _get: (Integer id, Integer comp) -> Hash[Symbol, untyped]? + def _struct: (String name, String descriptor) -> Integer + def _query: (Array[Integer] ids) -> Flecs::Query + def _system: ( + String name, + Integer phase, + Array[Integer] ids + ) { (Integer entity_id, *(Hash[Symbol, untyped] | nil) component_hashes) -> void } -> Integer + def _progress: (?Float dt) -> bool + def _enable_rest: (?Integer port) -> Flecs::World + def _rest_request: (String method, String path, ?String body) -> String? + def _enable_stats: () -> Flecs::World +end + +# A lightweight wrapper around an entity id bound to its world. Created by +# World#entity / #entity_for (not constructed directly by game code). +class Flecs::Entity + attr_reader id: Integer + attr_reader world: Flecs::World + + def initialize: (Flecs::World world, Integer id) -> void + def to_i: () -> Integer + alias to_int to_i + + def name: () -> String? + def name=: (String name) -> void + def delete: () -> void + def alive?: () -> bool + + # Set a component's fields: kwargs `set(pos, x: 1, y: 2)` or a Hash + # `set(pos, {x: 1, y: 2})`. Chainable; returns self. + def set: (term comp, ?Hash[Symbol, untyped]? fields, **untyped kw) -> Flecs::Entity + # Read a component back as a Hash, or nil if the entity has no such component. + def get: (term comp) -> Hash[Symbol, untyped]? + # add/remove a tag (or a component with its default value). Chainable. + def add: (term comp) -> Flecs::Entity + def remove: (term comp) -> Flecs::Entity + def has?: (term comp) -> bool + + def ==: (untyped other) -> bool + def inspect: () -> String +end + +# A wrapper around a component/tag id (also just an entity under the hood). +# Created by World#struct / #tag; usable wherever an id is expected. +class Flecs::Component + attr_reader id: Integer + attr_reader world: Flecs::World + + def initialize: (Flecs::World world, Integer id) -> void + def to_i: () -> Integer + alias to_int to_i + def name: () -> String? + def inspect: () -> String +end + +# A cached query over a set of components/tags. NOTE: intentionally does NOT +# `include Enumerable` in the RBS: `each` yields (entity_id, *component_hashes), +# a multi-arg yield that can't satisfy Enumerable's single-Elem contract, so the +# mixin fails Steep's module-self-type check. The Ruby class still mixes in +# Enumerable at runtime; only `.each` is documented here (game code uses it +# directly). Add explicit derived-method signatures if you start using them. +class Flecs::Query + # Yields entity_id + component hashes per matched entity; mutations to the + # hashes are written back. Tag terms yield `nil` for their slot. Returns self. + def each: () { (Integer entity_id, *(Hash[Symbol, untyped] | nil) component_hashes) -> void } -> Flecs::Query + # low-level C primitive (flecs_bindings.c) + def _each: () { (Integer entity_id, *(Hash[Symbol, untyped] | nil) component_hashes) -> void } -> Flecs::Query +end + +# Hot-reloadable systems (R3). Register a system ONCE with a stable dispatcher that +# looks up the current proc by name; on reload just replace the proc — same system +# id, same matched tables, same entity/component data, new logic. Set +# `Flecs::Hot.world` before defining systems. +module Flecs::Hot + # The world systems are registered against (nil until set). + def self.world: () -> Flecs::World? + def self.world=: (Flecs::World? v) -> Flecs::World? + + # Register (first call) or hot-swap (reload) a system. Idempotent: re-running a + # systems file just replaces procs. `with` terms may be Component/Entity/Integer + # ids OR String names (looked up live, so reload need not re-create components). + # Returns the system entity id (Integer). + def self.define_system: ( + String name, + with: Array[term | String], + ?phase: Integer + ) { (Integer entity_id, *(Hash[Symbol, untyped] | nil) component_hashes) -> void } -> Integer + + # Re-eval a chunk of systems Ruby (from the bridge). Never raises. + def self.reload_string: (String code) -> bool + # Re-eval a systems file (the reloadable unit). Never raises. + def self.reload_file: (String path) -> bool + # The registered system id for `name`, or nil if none. + def self.id_for: (String name) -> Integer? + # Names of all registered systems. + def self.systems: () -> Array[String] + # Forget all registrations (does not delete the systems from the world). + def self.reset!: () -> void +end diff --git a/sig/jamstack.rbs b/sig/jamstack.rbs new file mode 100644 index 0000000..9073c3e --- /dev/null +++ b/sig/jamstack.rbs @@ -0,0 +1,19 @@ +# Jamstack module — REPL console, live-mount helpers. +# Mirrors mrbgems/rmlui/mrblib/console.rb + mrbgems/raylib/mrblib/live.rb. + +module Jamstack +end + +class Jamstack::Console + attr_reader doc: Rml::Document + attr_reader input: Rml::Element + attr_reader scrollback: Rml::Element + + def initialize: (Rml::Context ctx, ?binding: untyped, ?toggle_key: Integer, ?rml_path: String) -> void + def open?: () -> bool + def update: () -> void + def toggle: () -> void + def show: () -> void + def hide: () -> void + def puts: (untyped msg) -> void +end diff --git a/sig/jolt.rbs b/sig/jolt.rbs new file mode 100644 index 0000000..1bb73f6 --- /dev/null +++ b/sig/jolt.rbs @@ -0,0 +1,352 @@ +# Jolt Physics Ruby API signatures (hand-written from docs/API_SPEC_JOLT.md). +# Mirrors mrbgems/jolt/mrblib/jolt.rb (public API) + the low-level C primitives +# in mrbgems/jolt/src/jolt_bindings.c (the Jolt::World#_* and Jolt._* methods +# the sugar wraps). Vectors accept Array[Numeric] or Rl::Vector3/Vector4 and are +# returned as Rl::Vector3/Vector4 when raylib is present (the documented contract). + +# A 3-component vector: a plain Array of numerics [x, y, z] or an Rl::Vector3. +type vec3 = Array[Numeric] | Rl::Vector3 + +# A quaternion / 4-component vector: [x, y, z, w] or an Rl::Vector4. +type quat = Array[Numeric] | Rl::Vector4 + +module Jolt + # Motion types (Integer constants). A body's collision layer is derived from + # its motion type (static -> STATIC layer, else MOVING layer). + STATIC: Integer + KINEMATIC: Integer + DYNAMIC: Integer + + # --- shape factories (module functions) --- + # Full dimensions, NOT half-extents (box divides by 2 internally). + def self.box: (Numeric width, Numeric height, Numeric depth) -> Jolt::Shape + def self.sphere: (Numeric radius) -> Jolt::Shape + # half_height = half the cylindrical section (capsule/cylinder). + def self.capsule: (Numeric half_height, Numeric radius) -> Jolt::Shape + def self.cylinder: (Numeric half_height, Numeric radius) -> Jolt::Shape + # Convex hull from points: Array of [x,y,z] triplets OR a flat [x,y,z,...] Array. + def self.convex_hull: (Array[Array[Numeric] | Numeric] points) -> Jolt::Shape + # Triangle mesh (STATIC bodies only): Array of [x,y,z] triples or flat; 3 verts/tri. + def self.mesh: (Array[Array[Numeric] | Numeric] vertices) -> Jolt::Shape + + # --- vector coercion helpers (internal; in => Array[Float], out => Rl type) --- + def self.v3: (vec3 v) -> Array[Float] + def self.v4: (quat v) -> Array[Float] + # Whether raylib (Rl::Vector3/4) is available for output coercion (memoized). + def self.rl?: () -> bool + # Wrap a 3/4-float Array into Rl::Vector3/4 when raylib is present, else return it. + def self.out3: (Array[Numeric] a) -> (Rl::Vector3 | Array[Numeric]) + def self.out4: (Array[Numeric] a) -> (Rl::Vector4 | Array[Numeric]) + + # --- low-level shape primitives (jolt_bindings.c; take half-extents) --- + def self._box: (Float hx, Float hy, Float hz) -> Jolt::Shape + def self._sphere: (Float r) -> Jolt::Shape + def self._capsule: (Float half_height, Float r) -> Jolt::Shape + def self._cylinder: (Float half_height, Float r) -> Jolt::Shape + def self._convex_hull: (Array[Numeric] points) -> Jolt::Shape + def self._mesh: (Array[Numeric] vertices) -> Jolt::Shape +end + +# A reusable collision volume (box/sphere/capsule/cylinder/convex_hull/mesh). +# Reference-counted in Jolt; the body holds a ref. Opaque data object. +class Jolt::Shape +end + +class Jolt::World + def initialize: (?gravity: vec3, ?max_bodies: Integer) -> void + + # --- simulation --- + def gravity=: (vec3 v) -> vec3 + # Advance the simulation. `collision_steps` = sub-steps per call. Returns self. + def step: (?Float dt, ?collision_steps: Integer) -> Jolt::World + alias update step + def optimize_broad_phase: () -> Jolt::World + + # --- bodies --- + # Create + add a body. `shape` required; all others have defaults. + # `motion`: Jolt::STATIC/KINEMATIC/DYNAMIC. `mass`: nil = density-derived. + # `velocity`/`user_data`/`mass` default to nil. Returns the new Body. + def body: ( + shape: Jolt::Shape, + ?position: vec3, + ?rotation: quat, + ?motion: Integer, + ?restitution: Float, + ?friction: Float, + ?activate: bool, + ?velocity: vec3?, + ?user_data: Integer?, + ?linear_damping: Float, + ?angular_damping: Float, + ?mass: Float?, + ?ccd: bool, + ?sensor: bool + ) -> Jolt::Body + alias add_body body + + # Bodies whose shape contains `point` (overlap query). + def overlap_point: (vec3 point) -> Array[Jolt::Body] + + # Collisions that BEGAN this step (OnContactAdded only — not persisted, so small). + def contacts: () -> Array[Jolt::Contact] + # Collisions that ENDED this step (stopped touching). Pair with sensor bodies + # for trigger leave events. + def contacts_ended: () -> Array[Jolt::ContactEnd] + + # --- constraints / joints (the world retains each; call #remove to delete) --- + # weld two bodies rigidly at their current relative transform. + def weld: (Jolt::Body a, Jolt::Body b) -> Jolt::Constraint + # point-to-point joint (free rotation about a world-space point). + def ball_joint: (Jolt::Body a, Jolt::Body b, vec3 point) -> Jolt::Constraint + # rope/rod between two world-space attach points within [min, max] metres. + def distance_joint: (Jolt::Body a, Jolt::Body b, vec3 point_a, vec3 point_b, ?min: Float, ?max: Float?) -> Jolt::Constraint + # hinge (door) about `axis` through world `point`; angle limits in DEGREES. + def hinge: (Jolt::Body a, Jolt::Body b, vec3 point, vec3 axis, ?min_deg: Float, ?max_deg: Float) -> Jolt::Constraint + # slider (piston) along `axis` through world `point`; limits in METRES. + def slider: (Jolt::Body a, Jolt::Body b, vec3 point, vec3 axis, ?min: Float, ?max: Float) -> Jolt::Constraint + # cone / swing limit about `axis` through world `point`; half-angle in DEGREES. + def cone: (Jolt::Body a, Jolt::Body b, vec3 point, vec3 axis, ?half_angle_deg: Float) -> Jolt::Constraint + + # --- character controller + ragdoll --- + # Kinematic player capsule (Jolt CharacterVirtual) with stair/slope handling. + def character: (shape: Jolt::Shape, ?position: vec3, ?max_slope_deg: Float, ?mass: Float) -> Jolt::Character + # Tree of dynamic bodies wired with swing-twist joints. `parts` listed + # PARENTS BEFORE CHILDREN (skeleton order); each is a Hash (see API_SPEC_JOLT.md §4b). + def ragdoll: (parts: Array[Hash[Symbol, untyped]], ?user_data: Integer) -> Jolt::Ragdoll + + # --- queries --- + # Cast a ray (direction is the full ray vector). nil if nothing is hit. + def raycast: (vec3 origin, vec3 direction) -> Jolt::RayHit? + + # --- internal retain/forget (the world owns its joints/ragdolls; see #initialize) --- + def _retain_joint: (Jolt::Constraint c) -> Jolt::Constraint + def _forget_joint: (Jolt::Constraint c) -> Jolt::Constraint? + def _forget_ragdoll: (Jolt::Ragdoll r) -> Jolt::Ragdoll? + + # --- low-level C primitives (jolt_bindings.c) --- + def _setup: (Float gx, Float gy, Float gz, Integer max_bodies) -> Jolt::World + def _step: (Float dt, Integer collision_steps) -> Jolt::World + def _optimize: () -> Jolt::World + def _set_gravity: (Float x, Float y, Float z) -> Jolt::World + # -> body id (Integer). 17-arg format "offfffffiffbfffbb" (see jolt-binding.md). + def _add_body: ( + Jolt::Shape shape, + Float px, Float py, Float pz, + Float qx, Float qy, Float qz, Float qw, + Integer motion, + Float restitution, Float friction, + bool activate, + Float linear_damping, Float angular_damping, Float mass, + bool ccd, bool sensor + ) -> Integer + def _remove_body: (Integer id) -> Jolt::World + def _position: (Integer id) -> Array[Float] + def _com_position: (Integer id) -> Array[Float] + def _rotation: (Integer id) -> Array[Float] + def _set_transform: (Integer id, Float px, Float py, Float pz, Float qx, Float qy, Float qz, Float qw, bool activate) -> Jolt::World + def _linear_velocity: (Integer id) -> Array[Float] + def _set_linear_velocity: (Integer id, Float x, Float y, Float z) -> Jolt::World + def _angular_velocity: (Integer id) -> Array[Float] + def _set_angular_velocity: (Integer id, Float x, Float y, Float z) -> Jolt::World + def _add_force: (Integer id, Float x, Float y, Float z) -> Jolt::World + def _add_impulse: (Integer id, Float x, Float y, Float z) -> Jolt::World + def _add_torque: (Integer id, Float x, Float y, Float z) -> Jolt::World + def _active?: (Integer id) -> bool + def _activate: (Integer id) -> Jolt::World + def _deactivate: (Integer id) -> Jolt::World + # -> [body_id, fraction, hx,hy,hz, nx,ny,nz] | nil + def _raycast: (Float ox, Float oy, Float oz, Float dx, Float dy, Float dz) -> Array[untyped]? + # -> Array of [idA, idB, px,py,pz, nx,ny,nz] + def _contacts: () -> Array[Array[untyped]] + # -> Array of [idA, idB] + def _contacts_ended: () -> Array[[Integer, Integer]] + # -> Array of body ids whose shape contains the point + def _overlap_point: (Float x, Float y, Float z) -> Array[Integer] + def _set_sensor: (Integer id, bool v) -> Jolt::World + def _set_ccd: (Integer id, bool v) -> Jolt::World + def _fixed: (Integer a, Integer b) -> Jolt::Constraint + def _point: (Integer a, Integer b, Float px, Float py, Float pz) -> Jolt::Constraint + def _distance: (Integer a, Integer b, Float ax, Float ay, Float az, Float bx, Float by, Float bz, Float min, Float max) -> Jolt::Constraint + def _hinge: (Integer a, Integer b, Float px, Float py, Float pz, Float ax, Float ay, Float az, Float min, Float max) -> Jolt::Constraint + def _slider: (Integer a, Integer b, Float px, Float py, Float pz, Float ax, Float ay, Float az, Float min, Float max) -> Jolt::Constraint + def _cone: (Integer a, Integer b, Float px, Float py, Float pz, Float ax, Float ay, Float az, Float half) -> Jolt::Constraint + def _user_data: (Integer id) -> Integer + def _set_user_data: (Integer id, Integer v) -> Jolt::World + def _motion_type: (Integer id) -> Integer + def _set_motion_type: (Integer id, Integer mt, bool activate) -> Jolt::World + def _friction: (Integer id) -> Float + def _set_friction: (Integer id, Float v) -> Jolt::World + def _restitution: (Integer id) -> Float + def _set_restitution: (Integer id, Float v) -> Jolt::World + def _gravity_factor: (Integer id) -> Float + def _set_gravity_factor: (Integer id, Float v) -> Jolt::World + def _character: (Jolt::Shape shape, Float px, Float py, Float pz, Float slope_deg, Float mass) -> Jolt::Character + def _ragdoll: (Array[Array[untyped]] parts, Integer user_data) -> Jolt::Ragdoll +end + +# A rigid body: a body id bound to its world. Created by World#body (not +# constructed directly by game code). +class Jolt::Body + attr_reader id: Integer + attr_reader world: Jolt::World + + def initialize: (Jolt::World world, Integer id) -> void + def to_i: () -> Integer + alias to_int to_i + + def position: () -> Rl::Vector3 + def position=: (vec3 v) -> vec3 + def center_of_mass: () -> Rl::Vector3 + def rotation: () -> Rl::Vector4 + def set_transform: (position: vec3, ?rotation: quat?, ?activate: bool) -> Jolt::Body + + def linear_velocity: () -> Rl::Vector3 + def linear_velocity=: (vec3 v) -> vec3 + def angular_velocity: () -> Rl::Vector3 + def angular_velocity=: (vec3 v) -> vec3 + + # Chainable: apply a force/impulse/torque (world-space vector) to the body. + def apply_force: (vec3 v) -> Jolt::Body + def apply_impulse: (vec3 v) -> Jolt::Body + def apply_torque: (vec3 v) -> Jolt::Body + + def active?: () -> bool + def activate: () -> Jolt::Body + def deactivate: () -> Jolt::Body + def remove: () -> void + + # 64-bit tag (e.g. a flecs entity id) for collision lookup. + def user_data: () -> Integer + def user_data=: (Integer v) -> Integer + + def motion_type: () -> Integer + def motion_type=: (Integer mt) -> Integer + def set_motion_type: (Integer mt, ?activate: bool) -> Jolt::Body + + # tunable properties (get + set) + def friction: () -> Float + def friction=: (Float v) -> Float + def restitution: () -> Float + def restitution=: (Float v) -> Float + def gravity_factor: () -> Float + def gravity_factor=: (Float v) -> Float + # sensor: detects overlaps (contacts/contacts_ended) without a physical response. + def sensor=: (bool v) -> bool + # continuous collision detection (linear cast) — fast bodies vs thin walls. + def ccd=: (bool v) -> bool + + def ==: (untyped other) -> bool + def inspect: () -> String +end + +# Result of World#raycast (nil if nothing was hit). +class Jolt::RayHit + attr_reader body_id: Integer + attr_reader fraction: Float + attr_reader point: Rl::Vector3 + attr_reader normal: Rl::Vector3 + + def body: () -> Jolt::Body +end + +# A constraint/joint (World#weld/ball_joint/distance_joint/hinge/slider/cone). +# The world retains it; you don't need to hold the handle to keep the joint alive. +class Jolt::Constraint + # detach + destroy now (also done on GC). Chainable. + def remove: () -> Jolt::Constraint + def _remove: () -> Jolt::Constraint +end + +# A collision that began this step (from World#contacts). +class Jolt::Contact + attr_reader body_a_id: Integer + attr_reader body_b_id: Integer + attr_reader point: Rl::Vector3 + attr_reader normal: Rl::Vector3 + + def body_a: () -> Jolt::Body + def body_b: () -> Jolt::Body + # is a given body/id in this contact? + def involves?: (Jolt::Body | Integer x) -> bool + # the *other* body in the contact. + def other: (Jolt::Body | Integer x) -> Jolt::Body +end + +# A collision that ENDED this step (from World#contacts_ended). No point/normal. +class Jolt::ContactEnd + attr_reader body_a_id: Integer + attr_reader body_b_id: Integer + + def body_a: () -> Jolt::Body + def body_b: () -> Jolt::Body + def involves?: (Jolt::Body | Integer x) -> bool + def other: (Jolt::Body | Integer x) -> Jolt::Body +end + +# Kinematic character controller (Jolt CharacterVirtual). Not a rigid body — you +# set its velocity each frame (applying gravity/jump yourself) and call #update, +# which moves and slides it along the world, stepping stairs and handling slopes. +class Jolt::Character + GROUND: Hash[Integer, Symbol] + + def update: (?Float dt) -> Jolt::Character + def position: () -> Rl::Vector3 + def position=: (vec3 v) -> vec3 + def velocity: () -> Rl::Vector3 + def velocity=: (vec3 v) -> vec3 + + # :on_ground | :on_steep | :not_supported | :in_air + def ground_state: () -> Symbol + def on_ground?: () -> bool + def supported?: () -> bool + def ground_normal: () -> Rl::Vector3 + + # velocity of the surface underfoot (moving platform / elevator); zero if airborne. + def ground_velocity: () -> Rl::Vector3 + # the body the character stands on, or nil when airborne. + def ground_body: () -> Jolt::Body? + + # like #update, but first ADDS ground_velocity so a KINEMATIC platform carries + # the player. Only STATIC/KINEMATIC ground is inherited (dynamic ground ignored). + def ride: (?Float dt) -> Jolt::Character + + # max force (N) exerted on dynamic bodies it walks into (raise above 100 N default). + def max_strength: () -> Float + def max_strength=: (Float v) -> Float + # effective mass vs. dynamic bodies (still kinematic to gravity). Setter only. + def mass=: (Float v) -> Float + + # --- low-level C primitives (jolt_bindings.c) --- + def _update: (Float dt) -> Jolt::Character + def _position: () -> Array[Float] + def _set_position: (Float x, Float y, Float z) -> Jolt::Character + def _velocity: () -> Array[Float] + def _set_velocity: (Float x, Float y, Float z) -> Jolt::Character + def _ground_state: () -> Integer + def _ground_normal: () -> Array[Float] + def _supported?: () -> bool + def _ground_velocity: () -> Array[Float] + def _ground_body_id: () -> Integer + def _max_strength: () -> Float + def _set_max_strength: (Float v) -> Jolt::Character + def _set_mass: (Float v) -> Jolt::Character +end + +# A ragdoll: a tree of dynamic bodies wired with swing-twist joints (from +# World#ragdoll). Each body is a normal Jolt::Body — read position/rotation to +# render, apply impulses to fling it around. +class Jolt::Ragdoll + # Array<Jolt::Body>, one per part, in skeleton order (memoized). + def bodies: () -> Array[Jolt::Body] + def body_count: () -> Integer + def []: (Integer i) -> Jolt::Body + def activate: () -> Jolt::Ragdoll + # take it out of the world (also on GC). Chainable. + def remove: () -> Jolt::Ragdoll + + # --- low-level C primitives (jolt_bindings.c) --- + def _body_count: () -> Integer + def _body_id: (Integer i) -> Integer + def _activate: () -> Jolt::Ragdoll + def _remove: () -> Jolt::Ragdoll +end diff --git a/sig/raylib.rbs b/sig/raylib.rbs new file mode 100644 index 0000000..b7567ee --- /dev/null +++ b/sig/raylib.rbs @@ -0,0 +1,1353 @@ +# Generated by gen_rbs.rb — DO NOT EDIT. +# Regenerate: ruby mrbgems/raylib/tools/gen_rbs.rb + +module Rl + def self.init_window: (Integer width, Integer height, String title) -> nil + def self.close_window: () -> nil + def self.window_should_close: () -> bool + def self.window_ready?: () -> bool + def self.window_fullscreen?: () -> bool + def self.window_hidden?: () -> bool + def self.window_minimized?: () -> bool + def self.window_maximized?: () -> bool + def self.window_focused?: () -> bool + def self.window_resized?: () -> bool + def self.window_state?: (Integer flag) -> bool + def self.set_window_state: (Integer flags) -> nil + def self.clear_window_state: (Integer flags) -> nil + def self.toggle_fullscreen: () -> nil + def self.toggle_borderless_windowed: () -> nil + def self.maximize_window: () -> nil + def self.minimize_window: () -> nil + def self.restore_window: () -> nil + def self.set_window_icon: (Rl::Image image) -> nil + def self.set_window_icons: (Rl::Image images, Integer count) -> nil + def self.set_window_title: (String title) -> nil + def self.set_window_position: (Integer x, Integer y) -> nil + def self.set_window_monitor: (Integer monitor) -> nil + def self.set_window_min_size: (Integer width, Integer height) -> nil + def self.set_window_max_size: (Integer width, Integer height) -> nil + def self.set_window_size: (Integer width, Integer height) -> nil + def self.set_window_opacity: (Float | Integer opacity) -> nil + def self.set_window_focused: () -> nil + def self.get_screen_width: () -> Integer + def self.get_screen_height: () -> Integer + def self.get_render_width: () -> Integer + def self.get_render_height: () -> Integer + def self.get_monitor_count: () -> Integer + def self.get_current_monitor: () -> Integer + def self.get_monitor_position: (Integer monitor) -> Rl::Vector2 + def self.get_monitor_width: (Integer monitor) -> Integer + def self.get_monitor_height: (Integer monitor) -> Integer + def self.get_monitor_physical_width: (Integer monitor) -> Integer + def self.get_monitor_physical_height: (Integer monitor) -> Integer + def self.get_monitor_refresh_rate: (Integer monitor) -> Integer + def self.get_window_position: () -> Rl::Vector2 + def self.get_window_scale_dpi: () -> Rl::Vector2 + def self.get_monitor_name: (Integer monitor) -> String + def self.set_clipboard_text: (String text) -> nil + def self.get_clipboard_text: () -> String + def self.enable_event_waiting: () -> nil + def self.disable_event_waiting: () -> nil + def self.show_cursor: () -> nil + def self.hide_cursor: () -> nil + def self.cursor_hidden?: () -> bool + def self.enable_cursor: () -> nil + def self.disable_cursor: () -> nil + def self.cursor_on_screen?: () -> bool + def self.clear_background: (Rl::Color color) -> nil + def self.begin_drawing: () -> nil + def self.end_drawing: () -> nil + def self.begin_mode2d: (Rl::Camera2D camera) -> nil + def self.end_mode2d: () -> nil + def self.begin_mode3d: (Rl::Camera3D camera) -> nil + def self.end_mode3d: () -> nil + def self.begin_texture_mode: (Rl::RenderTexture target) -> nil + def self.end_texture_mode: () -> nil + def self.begin_shader_mode: (Rl::Shader shader) -> nil + def self.end_shader_mode: () -> nil + def self.begin_blend_mode: (Integer mode) -> nil + def self.end_blend_mode: () -> nil + def self.begin_scissor_mode: (Integer x, Integer y, Integer width, Integer height) -> nil + def self.end_scissor_mode: () -> nil + def self.begin_vr_stereo_mode: (Rl::VrStereoConfig config) -> nil + def self.end_vr_stereo_mode: () -> nil + def self.load_vr_stereo_config: (Rl::VrDeviceInfo device) -> Rl::VrStereoConfig + def self.unload_vr_stereo_config: (Rl::VrStereoConfig config) -> nil + def self.load_shader: (String vs_file_name, String fs_file_name) -> Rl::Shader + def self.load_shader_from_memory: (String vs_code, String fs_code) -> Rl::Shader + def self.shader_valid?: (Rl::Shader shader) -> bool + def self.get_shader_location: (Rl::Shader shader, String uniform_name) -> Integer + def self.get_shader_location_attrib: (Rl::Shader shader, String attrib_name) -> Integer + def self.set_shader_value: (Rl::Shader shader, Integer loc_index, untyped value, Integer uniform_type) -> nil + def self.set_shader_value_v: (Rl::Shader shader, Integer loc_index, untyped value, Integer uniform_type, Integer count) -> nil + def self.set_shader_value_matrix: (Rl::Shader shader, Integer loc_index, Rl::Matrix mat) -> nil + def self.set_shader_value_texture: (Rl::Shader shader, Integer loc_index, Rl::Texture texture) -> nil + def self.unload_shader: (Rl::Shader shader) -> nil + def self.get_screen_to_world_ray: (Rl::Vector2 position, Rl::Camera3D camera) -> Rl::Ray + def self.get_screen_to_world_ray_ex: (Rl::Vector2 position, Rl::Camera3D camera, Integer width, Integer height) -> Rl::Ray + def self.get_world_to_screen: (Rl::Vector3 position, Rl::Camera3D camera) -> Rl::Vector2 + def self.get_world_to_screen_ex: (Rl::Vector3 position, Rl::Camera3D camera, Integer width, Integer height) -> Rl::Vector2 + def self.get_world_to_screen2d: (Rl::Vector2 position, Rl::Camera2D camera) -> Rl::Vector2 + def self.get_screen_to_world2d: (Rl::Vector2 position, Rl::Camera2D camera) -> Rl::Vector2 + def self.get_camera_matrix: (Rl::Camera3D camera) -> Rl::Matrix + def self.get_camera_matrix2d: (Rl::Camera2D camera) -> Rl::Matrix + def self.set_target_fps: (Integer fps) -> nil + def self.get_frame_time: () -> Float + def self.get_time: () -> Float + def self.get_fps: () -> Integer + def self.swap_screen_buffer: () -> nil + def self.poll_input_events: () -> nil + def self.wait_time: (Float | Integer seconds) -> nil + def self.set_random_seed: (Integer seed) -> nil + def self.get_random_value: (Integer min, Integer max) -> Integer + def self.take_screenshot: (String file_name) -> nil + def self.set_config_flags: (Integer flags) -> nil + def self.open_url: (String url) -> nil + def self.set_trace_log_level: (Integer log_level) -> nil + def self.load_file_text: (String file_name) -> String + def self.save_file_text: (String file_name, String text) -> bool + def self.file_rename: (String file_name, String file_rename) -> Integer + def self.file_remove: (String file_name) -> Integer + def self.file_copy: (String src_path, String dst_path) -> Integer + def self.file_move: (String src_path, String dst_path) -> Integer + def self.file_text_replace: (String file_name, String search, String replacement) -> Integer + def self.file_text_find_index: (String file_name, String search) -> Integer + def self.file_exists: (String file_name) -> bool + def self.directory_exists: (String dir_path) -> bool + def self.file_extension?: (String file_name, String ext) -> bool + def self.get_file_length: (String file_name) -> Integer + def self.get_file_mod_time: (String file_name) -> Integer + def self.get_file_extension: (String file_name) -> String + def self.get_file_name: (String file_path) -> String + def self.get_file_name_without_ext: (String file_path) -> String + def self.get_directory_path: (String file_path) -> String + def self.get_prev_directory_path: (String dir_path) -> String + def self.get_working_directory: () -> String + def self.get_application_directory: () -> String + def self.make_directory: (String dir_path) -> Integer + def self.change_directory: (String dir_path) -> bool + def self.path_file?: (String path) -> bool + def self.file_name_valid?: (String file_name) -> bool + def self.load_directory_files: (String dir_path) -> Rl::FilePathList + def self.load_directory_files_ex: (String base_path, String filter, bool scan_subdirs) -> Rl::FilePathList + def self.unload_directory_files: (Rl::FilePathList files) -> nil + def self.file_dropped?: () -> bool + def self.load_dropped_files: () -> Rl::FilePathList + def self.unload_dropped_files: (Rl::FilePathList files) -> nil + def self.get_directory_file_count: (String dir_path) -> Integer + def self.get_directory_file_count_ex: (String base_path, String filter, bool scan_subdirs) -> Integer + def self.load_automation_event_list: (String file_name) -> Rl::AutomationEventList + def self.unload_automation_event_list: (Rl::AutomationEventList list) -> nil + def self.export_automation_event_list: (Rl::AutomationEventList list, String file_name) -> bool + def self.set_automation_event_list: (Rl::AutomationEventList list) -> nil + def self.set_automation_event_base_frame: (Integer frame) -> nil + def self.start_automation_event_recording: () -> nil + def self.stop_automation_event_recording: () -> nil + def self.play_automation_event: (Rl::AutomationEvent event) -> nil + def self.key_pressed_repeat?: (Integer key) -> bool + def self.get_key_pressed: () -> Integer + def self.get_char_pressed: () -> Integer + def self.get_key_name: (Integer key) -> String + def self.set_exit_key: (Integer key) -> nil + def self.gamepad_available?: (Integer gamepad) -> bool + def self.get_gamepad_name: (Integer gamepad) -> String + def self.gamepad_button_pressed?: (Integer gamepad, Integer button) -> bool + def self.gamepad_button_down?: (Integer gamepad, Integer button) -> bool + def self.gamepad_button_released?: (Integer gamepad, Integer button) -> bool + def self.gamepad_button_up?: (Integer gamepad, Integer button) -> bool + def self.get_gamepad_button_pressed: () -> Integer + def self.get_gamepad_axis_count: (Integer gamepad) -> Integer + def self.get_gamepad_axis_movement: (Integer gamepad, Integer axis) -> Float + def self.set_gamepad_mappings: (String mappings) -> Integer + def self.set_gamepad_vibration: (Integer gamepad, Float | Integer left_motor, Float | Integer right_motor, Float | Integer duration) -> nil + def self.mouse_button_pressed?: (Integer button) -> bool + def self.mouse_button_down?: (Integer button) -> bool + def self.mouse_button_released?: (Integer button) -> bool + def self.mouse_button_up?: (Integer button) -> bool + def self.get_mouse_x: () -> Integer + def self.get_mouse_y: () -> Integer + def self.get_mouse_position: () -> Rl::Vector2 + def self.get_mouse_delta: () -> Rl::Vector2 + def self.set_mouse_position: (Integer x, Integer y) -> nil + def self.set_mouse_offset: (Integer offset_x, Integer offset_y) -> nil + def self.set_mouse_scale: (Float | Integer scale_x, Float | Integer scale_y) -> nil + def self.get_mouse_wheel_move: () -> Float + def self.get_mouse_wheel_move_v: () -> Rl::Vector2 + def self.set_mouse_cursor: (Integer cursor) -> nil + def self.get_touch_x: () -> Integer + def self.get_touch_y: () -> Integer + def self.get_touch_position: (Integer index) -> Rl::Vector2 + def self.get_touch_point_id: (Integer index) -> Integer + def self.get_touch_point_count: () -> Integer + def self.set_gestures_enabled: (Integer flags) -> nil + def self.gesture_detected?: (Integer gesture) -> bool + def self.get_gesture_detected: () -> Integer + def self.get_gesture_hold_duration: () -> Float + def self.get_gesture_drag_vector: () -> Rl::Vector2 + def self.get_gesture_drag_angle: () -> Float + def self.get_gesture_pinch_vector: () -> Rl::Vector2 + def self.get_gesture_pinch_angle: () -> Float + def self.update_camera: (Rl::Camera3D camera, Integer mode) -> nil + def self.update_camera_pro: (Rl::Camera3D camera, Rl::Vector3 movement, Rl::Vector3 rotation, Float | Integer zoom) -> nil + def self.set_shapes_texture: (Rl::Texture texture, Rl::Rectangle source) -> nil + def self.get_shapes_texture: () -> Rl::Texture + def self.get_shapes_texture_rectangle: () -> Rl::Rectangle + def self.draw_pixel: (Integer pos_x, Integer pos_y, Rl::Color color) -> nil + def self.draw_pixel_v: (Rl::Vector2 position, Rl::Color color) -> nil + def self.draw_line: (Integer start_pos_x, Integer start_pos_y, Integer end_pos_x, Integer end_pos_y, Rl::Color color) -> nil + def self.draw_line_v: (Rl::Vector2 start_pos, Rl::Vector2 end_pos, Rl::Color color) -> nil + def self.draw_line_ex: (Rl::Vector2 start_pos, Rl::Vector2 end_pos, Float | Integer thick, Rl::Color color) -> nil + def self.draw_line_strip: (Rl::Vector2 points, Integer point_count, Rl::Color color) -> nil + def self.draw_line_bezier: (Rl::Vector2 start_pos, Rl::Vector2 end_pos, Float | Integer thick, Rl::Color color) -> nil + def self.draw_line_dashed: (Rl::Vector2 start_pos, Rl::Vector2 end_pos, Integer dash_size, Integer space_size, Rl::Color color) -> nil + def self.draw_circle: (Integer center_x, Integer center_y, Float | Integer radius, Rl::Color color) -> nil + def self.draw_circle_v: (Rl::Vector2 center, Float | Integer radius, Rl::Color color) -> nil + def self.draw_circle_gradient: (Rl::Vector2 center, Float | Integer radius, Rl::Color inner, Rl::Color outer) -> nil + def self.draw_circle_sector: (Rl::Vector2 center, Float | Integer radius, Float | Integer start_angle, Float | Integer end_angle, Integer segments, Rl::Color color) -> nil + def self.draw_circle_sector_lines: (Rl::Vector2 center, Float | Integer radius, Float | Integer start_angle, Float | Integer end_angle, Integer segments, Rl::Color color) -> nil + def self.draw_circle_lines: (Integer center_x, Integer center_y, Float | Integer radius, Rl::Color color) -> nil + def self.draw_circle_lines_v: (Rl::Vector2 center, Float | Integer radius, Rl::Color color) -> nil + def self.draw_ellipse: (Integer center_x, Integer center_y, Float | Integer radius_h, Float | Integer radius_v, Rl::Color color) -> nil + def self.draw_ellipse_v: (Rl::Vector2 center, Float | Integer radius_h, Float | Integer radius_v, Rl::Color color) -> nil + def self.draw_ellipse_lines: (Integer center_x, Integer center_y, Float | Integer radius_h, Float | Integer radius_v, Rl::Color color) -> nil + def self.draw_ellipse_lines_v: (Rl::Vector2 center, Float | Integer radius_h, Float | Integer radius_v, Rl::Color color) -> nil + def self.draw_ring: (Rl::Vector2 center, Float | Integer inner_radius, Float | Integer outer_radius, Float | Integer start_angle, Float | Integer end_angle, Integer segments, Rl::Color color) -> nil + def self.draw_ring_lines: (Rl::Vector2 center, Float | Integer inner_radius, Float | Integer outer_radius, Float | Integer start_angle, Float | Integer end_angle, Integer segments, Rl::Color color) -> nil + def self.draw_rectangle: (Integer pos_x, Integer pos_y, Integer width, Integer height, Rl::Color color) -> nil + def self.draw_rectangle_v: (Rl::Vector2 position, Rl::Vector2 size, Rl::Color color) -> nil + def self.draw_rectangle_rec: (Rl::Rectangle rec, Rl::Color color) -> nil + def self.draw_rectangle_pro: (Rl::Rectangle rec, Rl::Vector2 origin, Float | Integer rotation, Rl::Color color) -> nil + def self.draw_rectangle_gradient_v: (Integer pos_x, Integer pos_y, Integer width, Integer height, Rl::Color top, Rl::Color bottom) -> nil + def self.draw_rectangle_gradient_h: (Integer pos_x, Integer pos_y, Integer width, Integer height, Rl::Color left, Rl::Color right) -> nil + def self.draw_rectangle_gradient_ex: (Rl::Rectangle rec, Rl::Color top_left, Rl::Color bottom_left, Rl::Color bottom_right, Rl::Color top_right) -> nil + def self.draw_rectangle_lines: (Integer pos_x, Integer pos_y, Integer width, Integer height, Rl::Color color) -> nil + def self.draw_rectangle_lines_ex: (Rl::Rectangle rec, Float | Integer line_thick, Rl::Color color) -> nil + def self.draw_rectangle_rounded: (Rl::Rectangle rec, Float | Integer roundness, Integer segments, Rl::Color color) -> nil + def self.draw_rectangle_rounded_lines: (Rl::Rectangle rec, Float | Integer roundness, Integer segments, Rl::Color color) -> nil + def self.draw_rectangle_rounded_lines_ex: (Rl::Rectangle rec, Float | Integer roundness, Integer segments, Float | Integer line_thick, Rl::Color color) -> nil + def self.draw_triangle: (Rl::Vector2 v1, Rl::Vector2 v2, Rl::Vector2 v3, Rl::Color color) -> nil + def self.draw_triangle_lines: (Rl::Vector2 v1, Rl::Vector2 v2, Rl::Vector2 v3, Rl::Color color) -> nil + def self.draw_triangle_fan: (Rl::Vector2 points, Integer point_count, Rl::Color color) -> nil + def self.draw_triangle_strip: (Rl::Vector2 points, Integer point_count, Rl::Color color) -> nil + def self.draw_poly: (Rl::Vector2 center, Integer sides, Float | Integer radius, Float | Integer rotation, Rl::Color color) -> nil + def self.draw_poly_lines: (Rl::Vector2 center, Integer sides, Float | Integer radius, Float | Integer rotation, Rl::Color color) -> nil + def self.draw_poly_lines_ex: (Rl::Vector2 center, Integer sides, Float | Integer radius, Float | Integer rotation, Float | Integer line_thick, Rl::Color color) -> nil + def self.draw_spline_linear: (Rl::Vector2 points, Integer point_count, Float | Integer thick, Rl::Color color) -> nil + def self.draw_spline_basis: (Rl::Vector2 points, Integer point_count, Float | Integer thick, Rl::Color color) -> nil + def self.draw_spline_catmull_rom: (Rl::Vector2 points, Integer point_count, Float | Integer thick, Rl::Color color) -> nil + def self.draw_spline_bezier_quadratic: (Rl::Vector2 points, Integer point_count, Float | Integer thick, Rl::Color color) -> nil + def self.draw_spline_bezier_cubic: (Rl::Vector2 points, Integer point_count, Float | Integer thick, Rl::Color color) -> nil + def self.draw_spline_segment_linear: (Rl::Vector2 p1, Rl::Vector2 p2, Float | Integer thick, Rl::Color color) -> nil + def self.draw_spline_segment_basis: (Rl::Vector2 p1, Rl::Vector2 p2, Rl::Vector2 p3, Rl::Vector2 p4, Float | Integer thick, Rl::Color color) -> nil + def self.draw_spline_segment_catmull_rom: (Rl::Vector2 p1, Rl::Vector2 p2, Rl::Vector2 p3, Rl::Vector2 p4, Float | Integer thick, Rl::Color color) -> nil + def self.draw_spline_segment_bezier_quadratic: (Rl::Vector2 p1, Rl::Vector2 c2, Rl::Vector2 p3, Float | Integer thick, Rl::Color color) -> nil + def self.draw_spline_segment_bezier_cubic: (Rl::Vector2 p1, Rl::Vector2 c2, Rl::Vector2 c3, Rl::Vector2 p4, Float | Integer thick, Rl::Color color) -> nil + def self.get_spline_point_linear: (Rl::Vector2 start_pos, Rl::Vector2 end_pos, Float | Integer t) -> Rl::Vector2 + def self.get_spline_point_basis: (Rl::Vector2 p1, Rl::Vector2 p2, Rl::Vector2 p3, Rl::Vector2 p4, Float | Integer t) -> Rl::Vector2 + def self.get_spline_point_catmull_rom: (Rl::Vector2 p1, Rl::Vector2 p2, Rl::Vector2 p3, Rl::Vector2 p4, Float | Integer t) -> Rl::Vector2 + def self.get_spline_point_bezier_quad: (Rl::Vector2 p1, Rl::Vector2 c2, Rl::Vector2 p3, Float | Integer t) -> Rl::Vector2 + def self.get_spline_point_bezier_cubic: (Rl::Vector2 p1, Rl::Vector2 c2, Rl::Vector2 c3, Rl::Vector2 p4, Float | Integer t) -> Rl::Vector2 + def self.check_collision_recs: (Rl::Rectangle rec1, Rl::Rectangle rec2) -> bool + def self.check_collision_circles: (Rl::Vector2 center1, Float | Integer radius1, Rl::Vector2 center2, Float | Integer radius2) -> bool + def self.check_collision_circle_rec: (Rl::Vector2 center, Float | Integer radius, Rl::Rectangle rec) -> bool + def self.check_collision_circle_line: (Rl::Vector2 center, Float | Integer radius, Rl::Vector2 p1, Rl::Vector2 p2) -> bool + def self.check_collision_point_rec: (Rl::Vector2 point, Rl::Rectangle rec) -> bool + def self.check_collision_point_circle: (Rl::Vector2 point, Rl::Vector2 center, Float | Integer radius) -> bool + def self.check_collision_point_triangle: (Rl::Vector2 point, Rl::Vector2 p1, Rl::Vector2 p2, Rl::Vector2 p3) -> bool + def self.check_collision_point_line: (Rl::Vector2 point, Rl::Vector2 p1, Rl::Vector2 p2, Integer threshold) -> bool + def self.check_collision_point_poly: (Rl::Vector2 point, Rl::Vector2 points, Integer point_count) -> bool + def self.check_collision_lines: (Rl::Vector2 start_pos1, Rl::Vector2 end_pos1, Rl::Vector2 start_pos2, Rl::Vector2 end_pos2, Rl::Vector2 collision_point) -> bool + def self.get_collision_rec: (Rl::Rectangle rec1, Rl::Rectangle rec2) -> Rl::Rectangle + def self.load_image: (String file_name) -> Rl::Image + def self.load_image_raw: (String file_name, Integer width, Integer height, Integer format, Integer header_size) -> Rl::Image + def self.load_image_from_texture: (Rl::Texture texture) -> Rl::Image + def self.load_image_from_screen: () -> Rl::Image + def self.image_valid?: (Rl::Image image) -> bool + def self.unload_image: (Rl::Image image) -> nil + def self.export_image: (Rl::Image image, String file_name) -> bool + def self.export_image_as_code: (Rl::Image image, String file_name) -> bool + def self.gen_image_color: (Integer width, Integer height, Rl::Color color) -> Rl::Image + def self.gen_image_gradient_linear: (Integer width, Integer height, Integer direction, Rl::Color start, Rl::Color end) -> Rl::Image + def self.gen_image_gradient_radial: (Integer width, Integer height, Float | Integer density, Rl::Color inner, Rl::Color outer) -> Rl::Image + def self.gen_image_gradient_square: (Integer width, Integer height, Float | Integer density, Rl::Color inner, Rl::Color outer) -> Rl::Image + def self.gen_image_checked: (Integer width, Integer height, Integer checks_x, Integer checks_y, Rl::Color col1, Rl::Color col2) -> Rl::Image + def self.gen_image_white_noise: (Integer width, Integer height, Float | Integer factor) -> Rl::Image + def self.gen_image_perlin_noise: (Integer width, Integer height, Integer offset_x, Integer offset_y, Float | Integer scale) -> Rl::Image + def self.gen_image_cellular: (Integer width, Integer height, Integer tile_size) -> Rl::Image + def self.gen_image_text: (Integer width, Integer height, String text) -> Rl::Image + def self.image_copy: (Rl::Image image) -> Rl::Image + def self.image_from_image: (Rl::Image image, Rl::Rectangle rec) -> Rl::Image + def self.image_from_channel: (Rl::Image image, Integer selected_channel) -> Rl::Image + def self.image_text: (String text, Integer font_size, Rl::Color color) -> Rl::Image + def self.image_text_ex: (Rl::Font font, String text, Float | Integer font_size, Float | Integer spacing, Rl::Color tint) -> Rl::Image + def self.image_format: (Rl::Image image, Integer new_format) -> nil + def self.image_to_pot: (Rl::Image image, Rl::Color fill) -> nil + def self.image_crop: (Rl::Image image, Rl::Rectangle crop) -> nil + def self.image_alpha_crop: (Rl::Image image, Float | Integer threshold) -> nil + def self.image_alpha_clear: (Rl::Image image, Rl::Color color, Float | Integer threshold) -> nil + def self.image_alpha_mask: (Rl::Image image, Rl::Image alpha_mask) -> nil + def self.image_alpha_premultiply: (Rl::Image image) -> nil + def self.image_blur_gaussian: (Rl::Image image, Integer blur_size) -> nil + def self.image_resize: (Rl::Image image, Integer new_width, Integer new_height) -> nil + def self.image_resize_nn: (Rl::Image image, Integer new_width, Integer new_height) -> nil + def self.image_resize_canvas: (Rl::Image image, Integer new_width, Integer new_height, Integer offset_x, Integer offset_y, Rl::Color fill) -> nil + def self.image_mipmaps: (Rl::Image image) -> nil + def self.image_dither: (Rl::Image image, Integer r_bpp, Integer g_bpp, Integer b_bpp, Integer a_bpp) -> nil + def self.image_flip_vertical: (Rl::Image image) -> nil + def self.image_flip_horizontal: (Rl::Image image) -> nil + def self.image_rotate: (Rl::Image image, Integer degrees) -> nil + def self.image_rotate_cw: (Rl::Image image) -> nil + def self.image_rotate_ccw: (Rl::Image image) -> nil + def self.image_color_tint: (Rl::Image image, Rl::Color color) -> nil + def self.image_color_invert: (Rl::Image image) -> nil + def self.image_color_grayscale: (Rl::Image image) -> nil + def self.image_color_contrast: (Rl::Image image, Float | Integer contrast) -> nil + def self.image_color_brightness: (Rl::Image image, Integer brightness) -> nil + def self.image_color_replace: (Rl::Image image, Rl::Color color, Rl::Color replace) -> nil + def self.unload_image_colors: (Rl::Color colors) -> nil + def self.unload_image_palette: (Rl::Color colors) -> nil + def self.get_image_alpha_border: (Rl::Image image, Float | Integer threshold) -> Rl::Rectangle + def self.get_image_color: (Rl::Image image, Integer x, Integer y) -> Rl::Color + def self.image_clear_background: (Rl::Image dst, Rl::Color color) -> nil + def self.image_draw_pixel: (Rl::Image dst, Integer pos_x, Integer pos_y, Rl::Color color) -> nil + def self.image_draw_pixel_v: (Rl::Image dst, Rl::Vector2 position, Rl::Color color) -> nil + def self.image_draw_line: (Rl::Image dst, Integer start_pos_x, Integer start_pos_y, Integer end_pos_x, Integer end_pos_y, Rl::Color color) -> nil + def self.image_draw_line_v: (Rl::Image dst, Rl::Vector2 start, Rl::Vector2 end, Rl::Color color) -> nil + def self.image_draw_line_ex: (Rl::Image dst, Rl::Vector2 start, Rl::Vector2 end, Integer thick, Rl::Color color) -> nil + def self.image_draw_circle: (Rl::Image dst, Integer center_x, Integer center_y, Integer radius, Rl::Color color) -> nil + def self.image_draw_circle_v: (Rl::Image dst, Rl::Vector2 center, Integer radius, Rl::Color color) -> nil + def self.image_draw_circle_lines: (Rl::Image dst, Integer center_x, Integer center_y, Integer radius, Rl::Color color) -> nil + def self.image_draw_circle_lines_v: (Rl::Image dst, Rl::Vector2 center, Integer radius, Rl::Color color) -> nil + def self.image_draw_rectangle: (Rl::Image dst, Integer pos_x, Integer pos_y, Integer width, Integer height, Rl::Color color) -> nil + def self.image_draw_rectangle_v: (Rl::Image dst, Rl::Vector2 position, Rl::Vector2 size, Rl::Color color) -> nil + def self.image_draw_rectangle_rec: (Rl::Image dst, Rl::Rectangle rec, Rl::Color color) -> nil + def self.image_draw_rectangle_lines: (Rl::Image dst, Rl::Rectangle rec, Integer thick, Rl::Color color) -> nil + def self.image_draw_triangle: (Rl::Image dst, Rl::Vector2 v1, Rl::Vector2 v2, Rl::Vector2 v3, Rl::Color color) -> nil + def self.image_draw_triangle_ex: (Rl::Image dst, Rl::Vector2 v1, Rl::Vector2 v2, Rl::Vector2 v3, Rl::Color c1, Rl::Color c2, Rl::Color c3) -> nil + def self.image_draw_triangle_lines: (Rl::Image dst, Rl::Vector2 v1, Rl::Vector2 v2, Rl::Vector2 v3, Rl::Color color) -> nil + def self.image_draw_triangle_fan: (Rl::Image dst, Rl::Vector2 points, Integer point_count, Rl::Color color) -> nil + def self.image_draw_triangle_strip: (Rl::Image dst, Rl::Vector2 points, Integer point_count, Rl::Color color) -> nil + def self.image_draw: (Rl::Image dst, Rl::Image src, Rl::Rectangle src_rec, Rl::Rectangle dst_rec, Rl::Color tint) -> nil + def self.image_draw_text: (Rl::Image dst, String text, Integer pos_x, Integer pos_y, Integer font_size, Rl::Color color) -> nil + def self.image_draw_text_ex: (Rl::Image dst, Rl::Font font, String text, Rl::Vector2 position, Float | Integer font_size, Float | Integer spacing, Rl::Color tint) -> nil + def self.load_texture: (String file_name) -> Rl::Texture + def self.load_texture_from_image: (Rl::Image image) -> Rl::Texture + def self.load_texture_cubemap: (Rl::Image image, Integer layout) -> Rl::Texture + def self.load_render_texture: (Integer width, Integer height) -> Rl::RenderTexture + def self.texture_valid?: (Rl::Texture texture) -> bool + def self.unload_texture: (Rl::Texture texture) -> nil + def self.render_texture_valid?: (Rl::RenderTexture target) -> bool + def self.unload_render_texture: (Rl::RenderTexture target) -> nil + def self.gen_texture_mipmaps: (Rl::Texture texture) -> nil + def self.set_texture_filter: (Rl::Texture texture, Integer filter) -> nil + def self.set_texture_wrap: (Rl::Texture texture, Integer wrap) -> nil + def self.draw_texture: (Rl::Texture texture, Integer pos_x, Integer pos_y, Rl::Color tint) -> nil + def self.draw_texture_v: (Rl::Texture texture, Rl::Vector2 position, Rl::Color tint) -> nil + def self.draw_texture_ex: (Rl::Texture texture, Rl::Vector2 position, Float | Integer rotation, Float | Integer scale, Rl::Color tint) -> nil + def self.draw_texture_rec: (Rl::Texture texture, Rl::Rectangle source, Rl::Vector2 position, Rl::Color tint) -> nil + def self.draw_texture_n_patch: (Rl::Texture texture, Rl::NPatchInfo n_patch_info, Rl::Rectangle dest, Rl::Vector2 origin, Float | Integer rotation, Rl::Color tint) -> nil + def self.color_is_equal: (Rl::Color col1, Rl::Color col2) -> bool + def self.fade: (Rl::Color color, Float | Integer alpha) -> Rl::Color + def self.color_to_int: (Rl::Color color) -> Integer + def self.color_normalize: (Rl::Color color) -> Rl::Vector4 + def self.color_from_normalized: (Rl::Vector4 normalized) -> Rl::Color + def self.color_to_hsv: (Rl::Color color) -> Rl::Vector3 + def self.color_from_hsv: (Float | Integer hue, Float | Integer saturation, Float | Integer value) -> Rl::Color + def self.color_tint: (Rl::Color color, Rl::Color tint) -> Rl::Color + def self.color_brightness: (Rl::Color color, Float | Integer factor) -> Rl::Color + def self.color_contrast: (Rl::Color color, Float | Integer contrast) -> Rl::Color + def self.color_alpha: (Rl::Color color, Float | Integer alpha) -> Rl::Color + def self.color_alpha_blend: (Rl::Color dst, Rl::Color src, Rl::Color tint) -> Rl::Color + def self.color_lerp: (Rl::Color color1, Rl::Color color2, Float | Integer factor) -> Rl::Color + def self.get_color: (Integer hex_value) -> Rl::Color + def self.get_pixel_data_size: (Integer width, Integer height, Integer format) -> Integer + def self.get_font_default: () -> Rl::Font + def self.load_font: (String file_name) -> Rl::Font + def self.load_font_from_image: (Rl::Image image, Rl::Color key, Integer first_char) -> Rl::Font + def self.font_valid?: (Rl::Font font) -> bool + def self.unload_font_data: (Rl::GlyphInfo glyphs, Integer glyph_count) -> nil + def self.unload_font: (Rl::Font font) -> nil + def self.export_font_as_code: (Rl::Font font, String file_name) -> bool + def self.draw_fps: (Integer pos_x, Integer pos_y) -> nil + def self.draw_text_ex: (Rl::Font font, String text, Rl::Vector2 position, Float | Integer font_size, Float | Integer spacing, Rl::Color tint) -> nil + def self.draw_text_pro: (Rl::Font font, String text, Rl::Vector2 position, Rl::Vector2 origin, Float | Integer rotation, Float | Integer font_size, Float | Integer spacing, Rl::Color tint) -> nil + def self.draw_text_codepoint: (Rl::Font font, Integer codepoint, Rl::Vector2 position, Float | Integer font_size, Rl::Color tint) -> nil + def self.set_text_line_spacing: (Integer spacing) -> nil + def self.measure_text: (String text, Integer font_size) -> Integer + def self.measure_text_ex: (Rl::Font font, String text, Float | Integer font_size, Float | Integer spacing) -> Rl::Vector2 + def self.get_glyph_index: (Rl::Font font, Integer codepoint) -> Integer + def self.get_glyph_info: (Rl::Font font, Integer codepoint) -> Rl::GlyphInfo + def self.get_glyph_atlas_rec: (Rl::Font font, Integer codepoint) -> Rl::Rectangle + def self.get_codepoint_count: (String text) -> Integer + def self.text_is_equal: (String text1, String text2) -> bool + def self.text_length: (String text) -> Integer + def self.text_subtext: (String text, Integer position, Integer length) -> String + def self.text_remove_spaces: (String text) -> String + def self.get_text_between: (String text, String begin, String end) -> String + def self.text_replace: (String text, String search, String replacement) -> String + def self.text_replace_alloc: (String text, String search, String replacement) -> String + def self.text_replace_between: (String text, String begin, String end, String replacement) -> String + def self.text_replace_between_alloc: (String text, String begin, String end, String replacement) -> String + def self.text_insert: (String text, String insert, Integer position) -> String + def self.text_insert_alloc: (String text, String insert, Integer position) -> String + def self.text_find_index: (String text, String search) -> Integer + def self.text_to_upper: (String text) -> String + def self.text_to_lower: (String text) -> String + def self.text_to_pascal: (String text) -> String + def self.text_to_snake: (String text) -> String + def self.text_to_camel: (String text) -> String + def self.text_to_integer: (String text) -> Integer + def self.text_to_float: (String text) -> Float + def self.draw_line3d: (Rl::Vector3 start_pos, Rl::Vector3 end_pos, Rl::Color color) -> nil + def self.draw_point3d: (Rl::Vector3 position, Rl::Color color) -> nil + def self.draw_circle3d: (Rl::Vector3 center, Float | Integer radius, Rl::Vector3 rotation_axis, Float | Integer rotation_angle, Rl::Color color) -> nil + def self.draw_triangle3d: (Rl::Vector3 v1, Rl::Vector3 v2, Rl::Vector3 v3, Rl::Color color) -> nil + def self.draw_triangle_strip3d: (Rl::Vector3 points, Integer point_count, Rl::Color color) -> nil + def self.draw_cube: (Rl::Vector3 position, Float | Integer width, Float | Integer height, Float | Integer length, Rl::Color color) -> nil + def self.draw_cube_v: (Rl::Vector3 position, Rl::Vector3 size, Rl::Color color) -> nil + def self.draw_cube_wires: (Rl::Vector3 position, Float | Integer width, Float | Integer height, Float | Integer length, Rl::Color color) -> nil + def self.draw_cube_wires_v: (Rl::Vector3 position, Rl::Vector3 size, Rl::Color color) -> nil + def self.draw_sphere: (Rl::Vector3 center_pos, Float | Integer radius, Rl::Color color) -> nil + def self.draw_sphere_ex: (Rl::Vector3 center_pos, Float | Integer radius, Integer rings, Integer slices, Rl::Color color) -> nil + def self.draw_sphere_wires: (Rl::Vector3 center_pos, Float | Integer radius, Integer rings, Integer slices, Rl::Color color) -> nil + def self.draw_cylinder: (Rl::Vector3 position, Float | Integer radius_top, Float | Integer radius_bottom, Float | Integer height, Integer slices, Rl::Color color) -> nil + def self.draw_cylinder_ex: (Rl::Vector3 start_pos, Rl::Vector3 end_pos, Float | Integer start_radius, Float | Integer end_radius, Integer sides, Rl::Color color) -> nil + def self.draw_cylinder_wires: (Rl::Vector3 position, Float | Integer radius_top, Float | Integer radius_bottom, Float | Integer height, Integer slices, Rl::Color color) -> nil + def self.draw_cylinder_wires_ex: (Rl::Vector3 start_pos, Rl::Vector3 end_pos, Float | Integer start_radius, Float | Integer end_radius, Integer sides, Rl::Color color) -> nil + def self.draw_capsule: (Rl::Vector3 start_pos, Rl::Vector3 end_pos, Float | Integer radius, Integer slices, Integer rings, Rl::Color color) -> nil + def self.draw_capsule_wires: (Rl::Vector3 start_pos, Rl::Vector3 end_pos, Float | Integer radius, Integer slices, Integer rings, Rl::Color color) -> nil + def self.draw_plane: (Rl::Vector3 center_pos, Rl::Vector2 size, Rl::Color color) -> nil + def self.draw_ray: (Rl::Ray ray, Rl::Color color) -> nil + def self.draw_grid: (Integer slices, Float | Integer spacing) -> nil + def self.load_model: (String file_name) -> Rl::Model + def self.load_model_from_mesh: (Rl::Mesh mesh) -> Rl::Model + def self.model_valid?: (Rl::Model model) -> bool + def self.unload_model: (Rl::Model model) -> nil + def self.get_model_bounding_box: (Rl::Model model) -> Rl::BoundingBox + def self.draw_model: (Rl::Model model, Rl::Vector3 position, Float | Integer scale, Rl::Color tint) -> nil + def self.draw_model_ex: (Rl::Model model, Rl::Vector3 position, Rl::Vector3 rotation_axis, Float | Integer rotation_angle, Rl::Vector3 scale, Rl::Color tint) -> nil + def self.draw_model_wires: (Rl::Model model, Rl::Vector3 position, Float | Integer scale, Rl::Color tint) -> nil + def self.draw_model_wires_ex: (Rl::Model model, Rl::Vector3 position, Rl::Vector3 rotation_axis, Float | Integer rotation_angle, Rl::Vector3 scale, Rl::Color tint) -> nil + def self.draw_bounding_box: (Rl::BoundingBox box, Rl::Color color) -> nil + def self.draw_billboard: (Rl::Camera3D camera, Rl::Texture texture, Rl::Vector3 position, Float | Integer scale, Rl::Color tint) -> nil + def self.draw_billboard_rec: (Rl::Camera3D camera, Rl::Texture texture, Rl::Rectangle source, Rl::Vector3 position, Rl::Vector2 size, Rl::Color tint) -> nil + def self.draw_billboard_pro: (Rl::Camera3D camera, Rl::Texture texture, Rl::Rectangle source, Rl::Vector3 position, Rl::Vector3 up, Rl::Vector2 size, Rl::Vector2 origin, Float | Integer rotation, Rl::Color tint) -> nil + def self.upload_mesh: (Rl::Mesh mesh, bool dynamic) -> nil + def self.unload_mesh: (Rl::Mesh mesh) -> nil + def self.draw_mesh: (Rl::Mesh mesh, Rl::Material material, Rl::Matrix transform) -> nil + def self.draw_mesh_instanced: (Rl::Mesh mesh, Rl::Material material, Rl::Matrix transforms, Integer instances) -> nil + def self.get_mesh_bounding_box: (Rl::Mesh mesh) -> Rl::BoundingBox + def self.gen_mesh_tangents: (Rl::Mesh mesh) -> nil + def self.export_mesh: (Rl::Mesh mesh, String file_name) -> bool + def self.export_mesh_as_code: (Rl::Mesh mesh, String file_name) -> bool + def self.gen_mesh_poly: (Integer sides, Float | Integer radius) -> Rl::Mesh + def self.gen_mesh_plane: (Float | Integer width, Float | Integer length, Integer res_x, Integer res_z) -> Rl::Mesh + def self.gen_mesh_cube: (Float | Integer width, Float | Integer height, Float | Integer length) -> Rl::Mesh + def self.gen_mesh_sphere: (Float | Integer radius, Integer rings, Integer slices) -> Rl::Mesh + def self.gen_mesh_hemi_sphere: (Float | Integer radius, Integer rings, Integer slices) -> Rl::Mesh + def self.gen_mesh_cylinder: (Float | Integer radius, Float | Integer height, Integer slices) -> Rl::Mesh + def self.gen_mesh_cone: (Float | Integer radius, Float | Integer height, Integer slices) -> Rl::Mesh + def self.gen_mesh_torus: (Float | Integer radius, Float | Integer size, Integer rad_seg, Integer sides) -> Rl::Mesh + def self.gen_mesh_knot: (Float | Integer radius, Float | Integer size, Integer rad_seg, Integer sides) -> Rl::Mesh + def self.gen_mesh_heightmap: (Rl::Image heightmap, Rl::Vector3 size) -> Rl::Mesh + def self.gen_mesh_cubicmap: (Rl::Image cubicmap, Rl::Vector3 cube_size) -> Rl::Mesh + def self.load_material_default: () -> Rl::Material + def self.material_valid?: (Rl::Material material) -> bool + def self.unload_material: (Rl::Material material) -> nil + def self.set_material_texture: (Rl::Material material, Integer map_type, Rl::Texture texture) -> nil + def self.set_model_mesh_material: (Rl::Model model, Integer mesh_id, Integer material_id) -> nil + def self.update_model_animation: (Rl::Model model, Rl::ModelAnimation anim, Float | Integer frame) -> nil + def self.update_model_animation_ex: (Rl::Model model, Rl::ModelAnimation anim_a, Float | Integer frame_a, Rl::ModelAnimation anim_b, Float | Integer frame_b, Float | Integer blend) -> nil + def self.unload_model_animations: (Rl::ModelAnimation animations, Integer anim_count) -> nil + def self.model_animation_valid?: (Rl::Model model, Rl::ModelAnimation anim) -> bool + def self.check_collision_spheres: (Rl::Vector3 center1, Float | Integer radius1, Rl::Vector3 center2, Float | Integer radius2) -> bool + def self.check_collision_boxes: (Rl::BoundingBox box1, Rl::BoundingBox box2) -> bool + def self.check_collision_box_sphere: (Rl::BoundingBox box, Rl::Vector3 center, Float | Integer radius) -> bool + def self.get_ray_collision_sphere: (Rl::Ray ray, Rl::Vector3 center, Float | Integer radius) -> Rl::RayCollision + def self.get_ray_collision_box: (Rl::Ray ray, Rl::BoundingBox box) -> Rl::RayCollision + def self.get_ray_collision_mesh: (Rl::Ray ray, Rl::Mesh mesh, Rl::Matrix transform) -> Rl::RayCollision + def self.get_ray_collision_triangle: (Rl::Ray ray, Rl::Vector3 p1, Rl::Vector3 p2, Rl::Vector3 p3) -> Rl::RayCollision + def self.get_ray_collision_quad: (Rl::Ray ray, Rl::Vector3 p1, Rl::Vector3 p2, Rl::Vector3 p3, Rl::Vector3 p4) -> Rl::RayCollision + def self.init_audio_device: () -> nil + def self.close_audio_device: () -> nil + def self.audio_device_ready?: () -> bool + def self.set_master_volume: (Float | Integer volume) -> nil + def self.get_master_volume: () -> Float + def self.load_wave: (String file_name) -> Rl::Wave + def self.wave_valid?: (Rl::Wave wave) -> bool + def self.load_sound: (String file_name) -> Rl::Sound + def self.load_sound_from_wave: (Rl::Wave wave) -> Rl::Sound + def self.load_sound_alias: (Rl::Sound source) -> Rl::Sound + def self.sound_valid?: (Rl::Sound sound) -> bool + def self.unload_wave: (Rl::Wave wave) -> nil + def self.unload_sound: (Rl::Sound sound) -> nil + def self.unload_sound_alias: (Rl::Sound alias) -> nil + def self.export_wave: (Rl::Wave wave, String file_name) -> bool + def self.export_wave_as_code: (Rl::Wave wave, String file_name) -> bool + def self.play_sound: (Rl::Sound sound) -> nil + def self.stop_sound: (Rl::Sound sound) -> nil + def self.pause_sound: (Rl::Sound sound) -> nil + def self.resume_sound: (Rl::Sound sound) -> nil + def self.sound_playing?: (Rl::Sound sound) -> bool + def self.set_sound_volume: (Rl::Sound sound, Float | Integer volume) -> nil + def self.set_sound_pitch: (Rl::Sound sound, Float | Integer pitch) -> nil + def self.set_sound_pan: (Rl::Sound sound, Float | Integer pan) -> nil + def self.wave_copy: (Rl::Wave wave) -> Rl::Wave + def self.wave_crop: (Rl::Wave wave, Integer init_frame, Integer final_frame) -> nil + def self.wave_format: (Rl::Wave wave, Integer sample_rate, Integer sample_size, Integer channels) -> nil + def self.load_music_stream: (String file_name) -> Rl::Music + def self.music_valid?: (Rl::Music music) -> bool + def self.unload_music_stream: (Rl::Music music) -> nil + def self.play_music_stream: (Rl::Music music) -> nil + def self.music_stream_playing?: (Rl::Music music) -> bool + def self.update_music_stream: (Rl::Music music) -> nil + def self.stop_music_stream: (Rl::Music music) -> nil + def self.pause_music_stream: (Rl::Music music) -> nil + def self.resume_music_stream: (Rl::Music music) -> nil + def self.seek_music_stream: (Rl::Music music, Float | Integer position) -> nil + def self.set_music_volume: (Rl::Music music, Float | Integer volume) -> nil + def self.set_music_pitch: (Rl::Music music, Float | Integer pitch) -> nil + def self.set_music_pan: (Rl::Music music, Float | Integer pan) -> nil + def self.get_music_time_length: (Rl::Music music) -> Float + def self.get_music_time_played: (Rl::Music music) -> Float + def self.load_audio_stream: (Integer sample_rate, Integer sample_size, Integer channels) -> Rl::AudioStream + def self.audio_stream_valid?: (Rl::AudioStream stream) -> bool + def self.unload_audio_stream: (Rl::AudioStream stream) -> nil + def self.audio_stream_processed?: (Rl::AudioStream stream) -> bool + def self.play_audio_stream: (Rl::AudioStream stream) -> nil + def self.pause_audio_stream: (Rl::AudioStream stream) -> nil + def self.resume_audio_stream: (Rl::AudioStream stream) -> nil + def self.audio_stream_playing?: (Rl::AudioStream stream) -> bool + def self.stop_audio_stream: (Rl::AudioStream stream) -> nil + def self.set_audio_stream_volume: (Rl::AudioStream stream, Float | Integer volume) -> nil + def self.set_audio_stream_pitch: (Rl::AudioStream stream, Float | Integer pitch) -> nil + def self.set_audio_stream_pan: (Rl::AudioStream stream, Float | Integer pan) -> nil + def self.set_audio_stream_buffer_size_default: (Integer size) -> nil + def self.clamp: (Float | Integer value, Float | Integer min, Float | Integer max) -> Float + def self.lerp: (Float | Integer start, Float | Integer end, Float | Integer amount) -> Float + def self.normalize: (Float | Integer value, Float | Integer start, Float | Integer end) -> Float + def self.remap: (Float | Integer value, Float | Integer input_start, Float | Integer input_end, Float | Integer output_start, Float | Integer output_end) -> Float + def self.wrap: (Float | Integer value, Float | Integer min, Float | Integer max) -> Float + def self.float_equals: (Float | Integer x, Float | Integer y) -> Integer + def self.vector2_zero: () -> Rl::Vector2 + def self.vector2_one: () -> Rl::Vector2 + def self.vector2_add: (Rl::Vector2 v1, Rl::Vector2 v2) -> Rl::Vector2 + def self.vector2_add_value: (Rl::Vector2 v, Float | Integer add) -> Rl::Vector2 + def self.vector2_subtract: (Rl::Vector2 v1, Rl::Vector2 v2) -> Rl::Vector2 + def self.vector2_subtract_value: (Rl::Vector2 v, Float | Integer sub) -> Rl::Vector2 + def self.vector2_length: (Rl::Vector2 v) -> Float + def self.vector2_length_sqr: (Rl::Vector2 v) -> Float + def self.vector2_dot_product: (Rl::Vector2 v1, Rl::Vector2 v2) -> Float + def self.vector2_cross_product: (Rl::Vector2 v1, Rl::Vector2 v2) -> Float + def self.vector2_distance: (Rl::Vector2 v1, Rl::Vector2 v2) -> Float + def self.vector2_distance_sqr: (Rl::Vector2 v1, Rl::Vector2 v2) -> Float + def self.vector2_angle: (Rl::Vector2 v1, Rl::Vector2 v2) -> Float + def self.vector2_line_angle: (Rl::Vector2 start, Rl::Vector2 end) -> Float + def self.vector2_scale: (Rl::Vector2 v, Float | Integer scale) -> Rl::Vector2 + def self.vector2_multiply: (Rl::Vector2 v1, Rl::Vector2 v2) -> Rl::Vector2 + def self.vector2_negate: (Rl::Vector2 v) -> Rl::Vector2 + def self.vector2_divide: (Rl::Vector2 v1, Rl::Vector2 v2) -> Rl::Vector2 + def self.vector2_normalize: (Rl::Vector2 v) -> Rl::Vector2 + def self.vector2_transform: (Rl::Vector2 v, Rl::Matrix mat) -> Rl::Vector2 + def self.vector2_lerp: (Rl::Vector2 v1, Rl::Vector2 v2, Float | Integer amount) -> Rl::Vector2 + def self.vector2_reflect: (Rl::Vector2 v, Rl::Vector2 normal) -> Rl::Vector2 + def self.vector2_min: (Rl::Vector2 v1, Rl::Vector2 v2) -> Rl::Vector2 + def self.vector2_max: (Rl::Vector2 v1, Rl::Vector2 v2) -> Rl::Vector2 + def self.vector2_rotate: (Rl::Vector2 v, Float | Integer angle) -> Rl::Vector2 + def self.vector2_move_towards: (Rl::Vector2 v, Rl::Vector2 target, Float | Integer max_distance) -> Rl::Vector2 + def self.vector2_invert: (Rl::Vector2 v) -> Rl::Vector2 + def self.vector2_clamp: (Rl::Vector2 v, Rl::Vector2 min, Rl::Vector2 max) -> Rl::Vector2 + def self.vector2_clamp_value: (Rl::Vector2 v, Float | Integer min, Float | Integer max) -> Rl::Vector2 + def self.vector2_equals: (Rl::Vector2 p, Rl::Vector2 q) -> Integer + def self.vector2_refract: (Rl::Vector2 v, Rl::Vector2 n, Float | Integer r) -> Rl::Vector2 + def self.vector3_zero: () -> Rl::Vector3 + def self.vector3_one: () -> Rl::Vector3 + def self.vector3_add: (Rl::Vector3 v1, Rl::Vector3 v2) -> Rl::Vector3 + def self.vector3_add_value: (Rl::Vector3 v, Float | Integer add) -> Rl::Vector3 + def self.vector3_subtract: (Rl::Vector3 v1, Rl::Vector3 v2) -> Rl::Vector3 + def self.vector3_subtract_value: (Rl::Vector3 v, Float | Integer sub) -> Rl::Vector3 + def self.vector3_scale: (Rl::Vector3 v, Float | Integer scalar) -> Rl::Vector3 + def self.vector3_multiply: (Rl::Vector3 v1, Rl::Vector3 v2) -> Rl::Vector3 + def self.vector3_cross_product: (Rl::Vector3 v1, Rl::Vector3 v2) -> Rl::Vector3 + def self.vector3_perpendicular: (Rl::Vector3 v) -> Rl::Vector3 + def self.vector3_length: (Rl::Vector3 v) -> Float + def self.vector3_length_sqr: (Rl::Vector3 v) -> Float + def self.vector3_dot_product: (Rl::Vector3 v1, Rl::Vector3 v2) -> Float + def self.vector3_distance: (Rl::Vector3 v1, Rl::Vector3 v2) -> Float + def self.vector3_distance_sqr: (Rl::Vector3 v1, Rl::Vector3 v2) -> Float + def self.vector3_angle: (Rl::Vector3 v1, Rl::Vector3 v2) -> Float + def self.vector3_negate: (Rl::Vector3 v) -> Rl::Vector3 + def self.vector3_divide: (Rl::Vector3 v1, Rl::Vector3 v2) -> Rl::Vector3 + def self.vector3_normalize: (Rl::Vector3 v) -> Rl::Vector3 + def self.vector3_project: (Rl::Vector3 v1, Rl::Vector3 v2) -> Rl::Vector3 + def self.vector3_reject: (Rl::Vector3 v1, Rl::Vector3 v2) -> Rl::Vector3 + def self.vector3_ortho_normalize: (Rl::Vector3 v1, Rl::Vector3 v2) -> nil + def self.vector3_transform: (Rl::Vector3 v, Rl::Matrix mat) -> Rl::Vector3 + def self.vector3_rotate_by_quaternion: (Rl::Vector3 v, Rl::Vector4 q) -> Rl::Vector3 + def self.vector3_rotate_by_axis_angle: (Rl::Vector3 v, Rl::Vector3 axis, Float | Integer angle) -> Rl::Vector3 + def self.vector3_move_towards: (Rl::Vector3 v, Rl::Vector3 target, Float | Integer max_distance) -> Rl::Vector3 + def self.vector3_lerp: (Rl::Vector3 v1, Rl::Vector3 v2, Float | Integer amount) -> Rl::Vector3 + def self.vector3_cubic_hermite: (Rl::Vector3 v1, Rl::Vector3 tangent1, Rl::Vector3 v2, Rl::Vector3 tangent2, Float | Integer amount) -> Rl::Vector3 + def self.vector3_reflect: (Rl::Vector3 v, Rl::Vector3 normal) -> Rl::Vector3 + def self.vector3_min: (Rl::Vector3 v1, Rl::Vector3 v2) -> Rl::Vector3 + def self.vector3_max: (Rl::Vector3 v1, Rl::Vector3 v2) -> Rl::Vector3 + def self.vector3_barycenter: (Rl::Vector3 p, Rl::Vector3 a, Rl::Vector3 b, Rl::Vector3 c) -> Rl::Vector3 + def self.vector3_unproject: (Rl::Vector3 source, Rl::Matrix projection, Rl::Matrix view) -> Rl::Vector3 + def self.vector3_invert: (Rl::Vector3 v) -> Rl::Vector3 + def self.vector3_clamp: (Rl::Vector3 v, Rl::Vector3 min, Rl::Vector3 max) -> Rl::Vector3 + def self.vector3_clamp_value: (Rl::Vector3 v, Float | Integer min, Float | Integer max) -> Rl::Vector3 + def self.vector3_equals: (Rl::Vector3 p, Rl::Vector3 q) -> Integer + def self.vector3_refract: (Rl::Vector3 v, Rl::Vector3 n, Float | Integer r) -> Rl::Vector3 + def self.vector4_zero: () -> Rl::Vector4 + def self.vector4_one: () -> Rl::Vector4 + def self.vector4_add: (Rl::Vector4 v1, Rl::Vector4 v2) -> Rl::Vector4 + def self.vector4_add_value: (Rl::Vector4 v, Float | Integer add) -> Rl::Vector4 + def self.vector4_subtract: (Rl::Vector4 v1, Rl::Vector4 v2) -> Rl::Vector4 + def self.vector4_subtract_value: (Rl::Vector4 v, Float | Integer add) -> Rl::Vector4 + def self.vector4_length: (Rl::Vector4 v) -> Float + def self.vector4_length_sqr: (Rl::Vector4 v) -> Float + def self.vector4_dot_product: (Rl::Vector4 v1, Rl::Vector4 v2) -> Float + def self.vector4_distance: (Rl::Vector4 v1, Rl::Vector4 v2) -> Float + def self.vector4_distance_sqr: (Rl::Vector4 v1, Rl::Vector4 v2) -> Float + def self.vector4_scale: (Rl::Vector4 v, Float | Integer scale) -> Rl::Vector4 + def self.vector4_multiply: (Rl::Vector4 v1, Rl::Vector4 v2) -> Rl::Vector4 + def self.vector4_negate: (Rl::Vector4 v) -> Rl::Vector4 + def self.vector4_divide: (Rl::Vector4 v1, Rl::Vector4 v2) -> Rl::Vector4 + def self.vector4_normalize: (Rl::Vector4 v) -> Rl::Vector4 + def self.vector4_min: (Rl::Vector4 v1, Rl::Vector4 v2) -> Rl::Vector4 + def self.vector4_max: (Rl::Vector4 v1, Rl::Vector4 v2) -> Rl::Vector4 + def self.vector4_lerp: (Rl::Vector4 v1, Rl::Vector4 v2, Float | Integer amount) -> Rl::Vector4 + def self.vector4_move_towards: (Rl::Vector4 v, Rl::Vector4 target, Float | Integer max_distance) -> Rl::Vector4 + def self.vector4_invert: (Rl::Vector4 v) -> Rl::Vector4 + def self.vector4_equals: (Rl::Vector4 p, Rl::Vector4 q) -> Integer + def self.matrix_determinant: (Rl::Matrix mat) -> Float + def self.matrix_trace: (Rl::Matrix mat) -> Float + def self.matrix_transpose: (Rl::Matrix mat) -> Rl::Matrix + def self.matrix_invert: (Rl::Matrix mat) -> Rl::Matrix + def self.matrix_identity: () -> Rl::Matrix + def self.matrix_add: (Rl::Matrix left, Rl::Matrix right) -> Rl::Matrix + def self.matrix_subtract: (Rl::Matrix left, Rl::Matrix right) -> Rl::Matrix + def self.matrix_multiply: (Rl::Matrix left, Rl::Matrix right) -> Rl::Matrix + def self.matrix_multiply_value: (Rl::Matrix left, Float | Integer value) -> Rl::Matrix + def self.matrix_translate: (Float | Integer x, Float | Integer y, Float | Integer z) -> Rl::Matrix + def self.matrix_rotate: (Rl::Vector3 axis, Float | Integer angle) -> Rl::Matrix + def self.matrix_rotate_x: (Float | Integer angle) -> Rl::Matrix + def self.matrix_rotate_y: (Float | Integer angle) -> Rl::Matrix + def self.matrix_rotate_z: (Float | Integer angle) -> Rl::Matrix + def self.matrix_rotate_xyz: (Rl::Vector3 angle) -> Rl::Matrix + def self.matrix_rotate_zyx: (Rl::Vector3 angle) -> Rl::Matrix + def self.matrix_scale: (Float | Integer x, Float | Integer y, Float | Integer z) -> Rl::Matrix + def self.matrix_frustum: (Float | Integer left, Float | Integer right, Float | Integer bottom, Float | Integer top, Float | Integer near_plane, Float | Integer far_plane) -> Rl::Matrix + def self.matrix_perspective: (Float | Integer fov_y, Float | Integer aspect, Float | Integer near_plane, Float | Integer far_plane) -> Rl::Matrix + def self.matrix_ortho: (Float | Integer left, Float | Integer right, Float | Integer bottom, Float | Integer top, Float | Integer near_plane, Float | Integer far_plane) -> Rl::Matrix + def self.matrix_look_at: (Rl::Vector3 eye, Rl::Vector3 target, Rl::Vector3 up) -> Rl::Matrix + def self.quaternion_add: (Rl::Vector4 q1, Rl::Vector4 q2) -> Rl::Vector4 + def self.quaternion_add_value: (Rl::Vector4 q, Float | Integer add) -> Rl::Vector4 + def self.quaternion_subtract: (Rl::Vector4 q1, Rl::Vector4 q2) -> Rl::Vector4 + def self.quaternion_subtract_value: (Rl::Vector4 q, Float | Integer sub) -> Rl::Vector4 + def self.quaternion_identity: () -> Rl::Vector4 + def self.quaternion_length: (Rl::Vector4 q) -> Float + def self.quaternion_normalize: (Rl::Vector4 q) -> Rl::Vector4 + def self.quaternion_invert: (Rl::Vector4 q) -> Rl::Vector4 + def self.quaternion_multiply: (Rl::Vector4 q1, Rl::Vector4 q2) -> Rl::Vector4 + def self.quaternion_scale: (Rl::Vector4 q, Float | Integer mul) -> Rl::Vector4 + def self.quaternion_divide: (Rl::Vector4 q1, Rl::Vector4 q2) -> Rl::Vector4 + def self.quaternion_lerp: (Rl::Vector4 q1, Rl::Vector4 q2, Float | Integer amount) -> Rl::Vector4 + def self.quaternion_nlerp: (Rl::Vector4 q1, Rl::Vector4 q2, Float | Integer amount) -> Rl::Vector4 + def self.quaternion_slerp: (Rl::Vector4 q1, Rl::Vector4 q2, Float | Integer amount) -> Rl::Vector4 + def self.quaternion_cubic_hermite_spline: (Rl::Vector4 q1, Rl::Vector4 out_tangent1, Rl::Vector4 q2, Rl::Vector4 in_tangent2, Float | Integer t) -> Rl::Vector4 + def self.quaternion_from_vector3_to_vector3: (Rl::Vector3 from, Rl::Vector3 to) -> Rl::Vector4 + def self.quaternion_from_matrix: (Rl::Matrix mat) -> Rl::Vector4 + def self.quaternion_to_matrix: (Rl::Vector4 q) -> Rl::Matrix + def self.quaternion_from_axis_angle: (Rl::Vector3 axis, Float | Integer angle) -> Rl::Vector4 + def self.quaternion_from_euler: (Float | Integer pitch, Float | Integer yaw, Float | Integer roll) -> Rl::Vector4 + def self.quaternion_to_euler: (Rl::Vector4 q) -> Rl::Vector3 + def self.quaternion_transform: (Rl::Vector4 q, Rl::Matrix mat) -> Rl::Vector4 + def self.quaternion_equals: (Rl::Vector4 p, Rl::Vector4 q) -> Integer + def self.matrix_compose: (Rl::Vector3 translation, Rl::Vector4 rotation, Rl::Vector3 scale) -> Rl::Matrix + def self.matrix_decompose: (Rl::Matrix mat, Rl::Vector3 translation, Rl::Vector4 rotation, Rl::Vector3 scale) -> nil + + # --- Ruby sugar (mrblib/raylib.rb) --- + def self.while_window_open: () { -> void } -> void + def self.window_should_close?: () -> bool + def self.draw: (?clear_color: Rl::Color) { -> void } -> void + def self.mode_2d: (Rl::Camera2D camera) { -> void } -> void + def self.mode_3d: (Rl::Camera3D camera) { -> void } -> void + def self.texture_mode: (Rl::RenderTexture target) { -> void } -> void + def self.blend_mode: (Integer mode) { -> void } -> void + def self.shader_mode: (Rl::Shader shader) { -> void } -> void + def self.scissor_mode: (Integer x, Integer y, Integer width, Integer height) { -> void } -> void + def self.draw_text: (text: String, x: Integer, y: Integer, font_size: Integer, color: Rl::Color) -> nil + def self.draw_texture_pro: (texture: Rl::Texture, source: Rl::Rectangle, dest: Rl::Rectangle, ?origin: Rl::Vector2, ?rotation: Float, ?tint: Rl::Color) -> nil + def self.target_fps=: (Integer) -> Integer + def self.master_volume=: (Float) -> Float + def self.frame_time: () -> Float + def self.time: () -> Float + def self.fps: () -> Integer + def self.screen_width: () -> Integer + def self.screen_height: () -> Integer + def self.mouse_x: () -> Integer + def self.mouse_y: () -> Integer + def self.mouse_position: () -> Rl::Vector2 + def self.mouse_wheel: () -> Float + def self.platform: () -> Symbol + def self.web?: () -> bool + def self.desktop?: () -> bool + def self.key_down?: (untyped key) -> bool + def self.key_pressed?: (untyped key) -> bool + def self.key_released?: (untyped key) -> bool + def self.key_up?: (untyped key) -> bool + + # --- Constants --- + FLAG_VSYNC_HINT: Integer + FLAG_FULLSCREEN_MODE: Integer + FLAG_WINDOW_RESIZABLE: Integer + FLAG_WINDOW_UNDECORATED: Integer + FLAG_WINDOW_HIDDEN: Integer + FLAG_WINDOW_MINIMIZED: Integer + FLAG_WINDOW_MAXIMIZED: Integer + FLAG_WINDOW_UNFOCUSED: Integer + FLAG_WINDOW_TOPMOST: Integer + FLAG_WINDOW_ALWAYS_RUN: Integer + FLAG_WINDOW_TRANSPARENT: Integer + FLAG_WINDOW_HIGHDPI: Integer + FLAG_WINDOW_MOUSE_PASSTHROUGH: Integer + FLAG_BORDERLESS_WINDOWED_MODE: Integer + FLAG_MSAA_4X_HINT: Integer + FLAG_INTERLACED_HINT: Integer + LOG_ALL: Integer + LOG_TRACE: Integer + LOG_DEBUG: Integer + LOG_INFO: Integer + LOG_WARNING: Integer + LOG_ERROR: Integer + LOG_FATAL: Integer + LOG_NONE: Integer + KEY_NULL: Integer + KEY_APOSTROPHE: Integer + KEY_COMMA: Integer + KEY_MINUS: Integer + KEY_PERIOD: Integer + KEY_SLASH: Integer + KEY_ZERO: Integer + KEY_ONE: Integer + KEY_TWO: Integer + KEY_THREE: Integer + KEY_FOUR: Integer + KEY_FIVE: Integer + KEY_SIX: Integer + KEY_SEVEN: Integer + KEY_EIGHT: Integer + KEY_NINE: Integer + KEY_SEMICOLON: Integer + KEY_EQUAL: Integer + KEY_A: Integer + KEY_B: Integer + KEY_C: Integer + KEY_D: Integer + KEY_E: Integer + KEY_F: Integer + KEY_G: Integer + KEY_H: Integer + KEY_I: Integer + KEY_J: Integer + KEY_K: Integer + KEY_L: Integer + KEY_M: Integer + KEY_N: Integer + KEY_O: Integer + KEY_P: Integer + KEY_Q: Integer + KEY_R: Integer + KEY_S: Integer + KEY_T: Integer + KEY_U: Integer + KEY_V: Integer + KEY_W: Integer + KEY_X: Integer + KEY_Y: Integer + KEY_Z: Integer + KEY_LEFT_BRACKET: Integer + KEY_BACKSLASH: Integer + KEY_RIGHT_BRACKET: Integer + KEY_GRAVE: Integer + KEY_SPACE: Integer + KEY_ESCAPE: Integer + KEY_ENTER: Integer + KEY_TAB: Integer + KEY_BACKSPACE: Integer + KEY_INSERT: Integer + KEY_DELETE: Integer + KEY_RIGHT: Integer + KEY_LEFT: Integer + KEY_DOWN: Integer + KEY_UP: Integer + KEY_PAGE_UP: Integer + KEY_PAGE_DOWN: Integer + KEY_HOME: Integer + KEY_END: Integer + KEY_CAPS_LOCK: Integer + KEY_SCROLL_LOCK: Integer + KEY_NUM_LOCK: Integer + KEY_PRINT_SCREEN: Integer + KEY_PAUSE: Integer + KEY_F1: Integer + KEY_F2: Integer + KEY_F3: Integer + KEY_F4: Integer + KEY_F5: Integer + KEY_F6: Integer + KEY_F7: Integer + KEY_F8: Integer + KEY_F9: Integer + KEY_F10: Integer + KEY_F11: Integer + KEY_F12: Integer + KEY_LEFT_SHIFT: Integer + KEY_LEFT_CONTROL: Integer + KEY_LEFT_ALT: Integer + KEY_LEFT_SUPER: Integer + KEY_RIGHT_SHIFT: Integer + KEY_RIGHT_CONTROL: Integer + KEY_RIGHT_ALT: Integer + KEY_RIGHT_SUPER: Integer + KEY_KB_MENU: Integer + KEY_KP_0: Integer + KEY_KP_1: Integer + KEY_KP_2: Integer + KEY_KP_3: Integer + KEY_KP_4: Integer + KEY_KP_5: Integer + KEY_KP_6: Integer + KEY_KP_7: Integer + KEY_KP_8: Integer + KEY_KP_9: Integer + KEY_KP_DECIMAL: Integer + KEY_KP_DIVIDE: Integer + KEY_KP_MULTIPLY: Integer + KEY_KP_SUBTRACT: Integer + KEY_KP_ADD: Integer + KEY_KP_ENTER: Integer + KEY_KP_EQUAL: Integer + KEY_BACK: Integer + KEY_MENU: Integer + KEY_VOLUME_UP: Integer + KEY_VOLUME_DOWN: Integer + MOUSE_BUTTON_LEFT: Integer + MOUSE_BUTTON_RIGHT: Integer + MOUSE_BUTTON_MIDDLE: Integer + MOUSE_BUTTON_SIDE: Integer + MOUSE_BUTTON_EXTRA: Integer + MOUSE_BUTTON_FORWARD: Integer + MOUSE_BUTTON_BACK: Integer + MOUSE_CURSOR_DEFAULT: Integer + MOUSE_CURSOR_ARROW: Integer + MOUSE_CURSOR_IBEAM: Integer + MOUSE_CURSOR_CROSSHAIR: Integer + MOUSE_CURSOR_POINTING_HAND: Integer + MOUSE_CURSOR_RESIZE_EW: Integer + MOUSE_CURSOR_RESIZE_NS: Integer + MOUSE_CURSOR_RESIZE_NWSE: Integer + MOUSE_CURSOR_RESIZE_NESW: Integer + MOUSE_CURSOR_RESIZE_ALL: Integer + MOUSE_CURSOR_NOT_ALLOWED: Integer + GAMEPAD_BUTTON_UNKNOWN: Integer + GAMEPAD_BUTTON_LEFT_FACE_UP: Integer + GAMEPAD_BUTTON_LEFT_FACE_RIGHT: Integer + GAMEPAD_BUTTON_LEFT_FACE_DOWN: Integer + GAMEPAD_BUTTON_LEFT_FACE_LEFT: Integer + GAMEPAD_BUTTON_RIGHT_FACE_UP: Integer + GAMEPAD_BUTTON_RIGHT_FACE_RIGHT: Integer + GAMEPAD_BUTTON_RIGHT_FACE_DOWN: Integer + GAMEPAD_BUTTON_RIGHT_FACE_LEFT: Integer + GAMEPAD_BUTTON_LEFT_TRIGGER_1: Integer + GAMEPAD_BUTTON_LEFT_TRIGGER_2: Integer + GAMEPAD_BUTTON_RIGHT_TRIGGER_1: Integer + GAMEPAD_BUTTON_RIGHT_TRIGGER_2: Integer + GAMEPAD_BUTTON_MIDDLE_LEFT: Integer + GAMEPAD_BUTTON_MIDDLE: Integer + GAMEPAD_BUTTON_MIDDLE_RIGHT: Integer + GAMEPAD_BUTTON_LEFT_THUMB: Integer + GAMEPAD_BUTTON_RIGHT_THUMB: Integer + GAMEPAD_AXIS_LEFT_X: Integer + GAMEPAD_AXIS_LEFT_Y: Integer + GAMEPAD_AXIS_RIGHT_X: Integer + GAMEPAD_AXIS_RIGHT_Y: Integer + GAMEPAD_AXIS_LEFT_TRIGGER: Integer + GAMEPAD_AXIS_RIGHT_TRIGGER: Integer + MATERIAL_MAP_ALBEDO: Integer + MATERIAL_MAP_METALNESS: Integer + MATERIAL_MAP_NORMAL: Integer + MATERIAL_MAP_ROUGHNESS: Integer + MATERIAL_MAP_OCCLUSION: Integer + MATERIAL_MAP_EMISSION: Integer + MATERIAL_MAP_HEIGHT: Integer + MATERIAL_MAP_CUBEMAP: Integer + MATERIAL_MAP_IRRADIANCE: Integer + MATERIAL_MAP_PREFILTER: Integer + MATERIAL_MAP_BRDF: Integer + SHADER_LOC_VERTEX_POSITION: Integer + SHADER_LOC_VERTEX_TEXCOORD01: Integer + SHADER_LOC_VERTEX_TEXCOORD02: Integer + SHADER_LOC_VERTEX_NORMAL: Integer + SHADER_LOC_VERTEX_TANGENT: Integer + SHADER_LOC_VERTEX_COLOR: Integer + SHADER_LOC_MATRIX_MVP: Integer + SHADER_LOC_MATRIX_VIEW: Integer + SHADER_LOC_MATRIX_PROJECTION: Integer + SHADER_LOC_MATRIX_MODEL: Integer + SHADER_LOC_MATRIX_NORMAL: Integer + SHADER_LOC_VECTOR_VIEW: Integer + SHADER_LOC_COLOR_DIFFUSE: Integer + SHADER_LOC_COLOR_SPECULAR: Integer + SHADER_LOC_COLOR_AMBIENT: Integer + SHADER_LOC_MAP_ALBEDO: Integer + SHADER_LOC_MAP_METALNESS: Integer + SHADER_LOC_MAP_NORMAL: Integer + SHADER_LOC_MAP_ROUGHNESS: Integer + SHADER_LOC_MAP_OCCLUSION: Integer + SHADER_LOC_MAP_EMISSION: Integer + SHADER_LOC_MAP_HEIGHT: Integer + SHADER_LOC_MAP_CUBEMAP: Integer + SHADER_LOC_MAP_IRRADIANCE: Integer + SHADER_LOC_MAP_PREFILTER: Integer + SHADER_LOC_MAP_BRDF: Integer + SHADER_LOC_VERTEX_BONEIDS: Integer + SHADER_LOC_VERTEX_BONEWEIGHTS: Integer + SHADER_LOC_MATRIX_BONETRANSFORMS: Integer + SHADER_LOC_VERTEX_INSTANCETRANSFORM: Integer + SHADER_UNIFORM_FLOAT: Integer + SHADER_UNIFORM_VEC2: Integer + SHADER_UNIFORM_VEC3: Integer + SHADER_UNIFORM_VEC4: Integer + SHADER_UNIFORM_INT: Integer + SHADER_UNIFORM_IVEC2: Integer + SHADER_UNIFORM_IVEC3: Integer + SHADER_UNIFORM_IVEC4: Integer + SHADER_UNIFORM_UINT: Integer + SHADER_UNIFORM_UIVEC2: Integer + SHADER_UNIFORM_UIVEC3: Integer + SHADER_UNIFORM_UIVEC4: Integer + SHADER_UNIFORM_SAMPLER2D: Integer + SHADER_ATTRIB_FLOAT: Integer + SHADER_ATTRIB_VEC2: Integer + SHADER_ATTRIB_VEC3: Integer + SHADER_ATTRIB_VEC4: Integer + PIXELFORMAT_UNCOMPRESSED_GRAYSCALE: Integer + PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA: Integer + PIXELFORMAT_UNCOMPRESSED_R5G6B5: Integer + PIXELFORMAT_UNCOMPRESSED_R8G8B8: Integer + PIXELFORMAT_UNCOMPRESSED_R5G5B5A1: Integer + PIXELFORMAT_UNCOMPRESSED_R4G4B4A4: Integer + PIXELFORMAT_UNCOMPRESSED_R8G8B8A8: Integer + PIXELFORMAT_UNCOMPRESSED_R32: Integer + PIXELFORMAT_UNCOMPRESSED_R32G32B32: Integer + PIXELFORMAT_UNCOMPRESSED_R32G32B32A32: Integer + PIXELFORMAT_UNCOMPRESSED_R16: Integer + PIXELFORMAT_UNCOMPRESSED_R16G16B16: Integer + PIXELFORMAT_UNCOMPRESSED_R16G16B16A16: Integer + PIXELFORMAT_COMPRESSED_DXT1_RGB: Integer + PIXELFORMAT_COMPRESSED_DXT1_RGBA: Integer + PIXELFORMAT_COMPRESSED_DXT3_RGBA: Integer + PIXELFORMAT_COMPRESSED_DXT5_RGBA: Integer + PIXELFORMAT_COMPRESSED_ETC1_RGB: Integer + PIXELFORMAT_COMPRESSED_ETC2_RGB: Integer + PIXELFORMAT_COMPRESSED_ETC2_EAC_RGBA: Integer + PIXELFORMAT_COMPRESSED_PVRT_RGB: Integer + PIXELFORMAT_COMPRESSED_PVRT_RGBA: Integer + PIXELFORMAT_COMPRESSED_ASTC_4x4_RGBA: Integer + PIXELFORMAT_COMPRESSED_ASTC_8x8_RGBA: Integer + TEXTURE_FILTER_POINT: Integer + TEXTURE_FILTER_BILINEAR: Integer + TEXTURE_FILTER_TRILINEAR: Integer + TEXTURE_FILTER_ANISOTROPIC_4X: Integer + TEXTURE_FILTER_ANISOTROPIC_8X: Integer + TEXTURE_FILTER_ANISOTROPIC_16X: Integer + TEXTURE_WRAP_REPEAT: Integer + TEXTURE_WRAP_CLAMP: Integer + TEXTURE_WRAP_MIRROR_REPEAT: Integer + TEXTURE_WRAP_MIRROR_CLAMP: Integer + CUBEMAP_LAYOUT_AUTO_DETECT: Integer + CUBEMAP_LAYOUT_LINE_VERTICAL: Integer + CUBEMAP_LAYOUT_LINE_HORIZONTAL: Integer + CUBEMAP_LAYOUT_CROSS_THREE_BY_FOUR: Integer + CUBEMAP_LAYOUT_CROSS_FOUR_BY_THREE: Integer + FONT_DEFAULT: Integer + FONT_BITMAP: Integer + FONT_SDF: Integer + BLEND_ALPHA: Integer + BLEND_ADDITIVE: Integer + BLEND_MULTIPLIED: Integer + BLEND_ADD_COLORS: Integer + BLEND_SUBTRACT_COLORS: Integer + BLEND_ALPHA_PREMULTIPLY: Integer + BLEND_CUSTOM: Integer + BLEND_CUSTOM_SEPARATE: Integer + GESTURE_NONE: Integer + GESTURE_TAP: Integer + GESTURE_DOUBLETAP: Integer + GESTURE_HOLD: Integer + GESTURE_DRAG: Integer + GESTURE_SWIPE_RIGHT: Integer + GESTURE_SWIPE_LEFT: Integer + GESTURE_SWIPE_UP: Integer + GESTURE_SWIPE_DOWN: Integer + GESTURE_PINCH_IN: Integer + GESTURE_PINCH_OUT: Integer + CAMERA_CUSTOM: Integer + CAMERA_FREE: Integer + CAMERA_ORBITAL: Integer + CAMERA_FIRST_PERSON: Integer + CAMERA_THIRD_PERSON: Integer + CAMERA_PERSPECTIVE: Integer + CAMERA_ORTHOGRAPHIC: Integer + NPATCH_NINE_PATCH: Integer + NPATCH_THREE_PATCH_VERTICAL: Integer + NPATCH_THREE_PATCH_HORIZONTAL: Integer + LIGHTGRAY: Rl::Color + GRAY: Rl::Color + DARKGRAY: Rl::Color + YELLOW: Rl::Color + GOLD: Rl::Color + ORANGE: Rl::Color + PINK: Rl::Color + RED: Rl::Color + MAROON: Rl::Color + GREEN: Rl::Color + LIME: Rl::Color + DARKGREEN: Rl::Color + SKYBLUE: Rl::Color + BLUE: Rl::Color + DARKBLUE: Rl::Color + PURPLE: Rl::Color + VIOLET: Rl::Color + DARKPURPLE: Rl::Color + BEIGE: Rl::Color + BROWN: Rl::Color + DARKBROWN: Rl::Color + WHITE: Rl::Color + BLACK: Rl::Color + BLANK: Rl::Color + MAGENTA: Rl::Color + RAYWHITE: Rl::Color + RAYLIB_VERSION_MAJOR: Integer + RAYLIB_VERSION_MINOR: Integer + RAYLIB_VERSION_PATCH: Integer + PI: Float + RAYLIB_VERSION: String + Quaternion: untyped # alias for Rl::Vector4 + Texture2D: untyped # alias for Rl::Texture + TextureCubemap: untyped # alias for Rl::Texture + RenderTexture2D: untyped # alias for Rl::RenderTexture + Camera: untyped # alias for Rl::Camera3D + ModelAnimPose: untyped # alias for Rl::Transform +end + +class Rl::Vector2 + attr_accessor x: Float | Integer + attr_accessor y: Float | Integer + def initialize: (?Float | Integer x, ?Float | Integer y) -> void +end + +class Rl::Vector3 + attr_accessor x: Float | Integer + attr_accessor y: Float | Integer + attr_accessor z: Float | Integer + def initialize: (?Float | Integer x, ?Float | Integer y, ?Float | Integer z) -> void +end + +class Rl::Vector4 + attr_accessor x: Float | Integer + attr_accessor y: Float | Integer + attr_accessor z: Float | Integer + attr_accessor w: Float | Integer + def initialize: (?Float | Integer x, ?Float | Integer y, ?Float | Integer z, ?Float | Integer w) -> void +end + +class Rl::Matrix + attr_accessor m0: Float | Integer + attr_accessor m4: Float | Integer + attr_accessor m8: Float | Integer + attr_accessor m12: Float | Integer + attr_accessor m1: Float | Integer + attr_accessor m5: Float | Integer + attr_accessor m9: Float | Integer + attr_accessor m13: Float | Integer + attr_accessor m2: Float | Integer + attr_accessor m6: Float | Integer + attr_accessor m10: Float | Integer + attr_accessor m14: Float | Integer + attr_accessor m3: Float | Integer + attr_accessor m7: Float | Integer + attr_accessor m11: Float | Integer + attr_accessor m15: Float | Integer + def initialize: (?Float | Integer m0, ?Float | Integer m4, ?Float | Integer m8, ?Float | Integer m12, ?Float | Integer m1, ?Float | Integer m5, ?Float | Integer m9, ?Float | Integer m13, ?Float | Integer m2, ?Float | Integer m6, ?Float | Integer m10, ?Float | Integer m14, ?Float | Integer m3, ?Float | Integer m7, ?Float | Integer m11, ?Float | Integer m15) -> void +end + +class Rl::Color + attr_accessor r: Integer + attr_accessor g: Integer + attr_accessor b: Integer + attr_accessor a: Integer + def initialize: (?Integer r, ?Integer g, ?Integer b, ?Integer a) -> void +end + +class Rl::Rectangle + attr_accessor x: Float | Integer + attr_accessor y: Float | Integer + attr_accessor width: Float | Integer + attr_accessor height: Float | Integer + def initialize: (?Float | Integer x, ?Float | Integer y, ?Float | Integer width, ?Float | Integer height) -> void +end + +class Rl::Image + attr_accessor data: untyped + attr_accessor width: Integer + attr_accessor height: Integer + attr_accessor mipmaps: Integer + attr_accessor format: Integer + def initialize: (?untyped data, ?Integer width, ?Integer height, ?Integer mipmaps, ?Integer format) -> void +end + +class Rl::Texture + attr_accessor id: Integer + attr_accessor width: Integer + attr_accessor height: Integer + attr_accessor mipmaps: Integer + attr_accessor format: Integer + def initialize: (?Integer id, ?Integer width, ?Integer height, ?Integer mipmaps, ?Integer format) -> void +end + +class Rl::RenderTexture + attr_accessor id: Integer + attr_accessor texture: Rl::Texture + attr_accessor depth: Rl::Texture + def initialize: (?Integer id, ?Rl::Texture texture, ?Rl::Texture depth) -> void +end + +class Rl::NPatchInfo + attr_accessor source: Rl::Rectangle + attr_accessor left: Integer + attr_accessor top: Integer + attr_accessor right: Integer + attr_accessor bottom: Integer + attr_accessor layout: Integer + def initialize: (?Rl::Rectangle source, ?Integer left, ?Integer top, ?Integer right, ?Integer bottom, ?Integer layout) -> void +end + +class Rl::GlyphInfo + attr_accessor value: Integer + attr_accessor offsetX: Integer + attr_accessor offsetY: Integer + attr_accessor advanceX: Integer + attr_accessor image: Rl::Image + def initialize: (?Integer value, ?Integer offsetX, ?Integer offsetY, ?Integer advanceX, ?Rl::Image image) -> void +end + +class Rl::Font + attr_accessor baseSize: Integer + attr_accessor glyphCount: Integer + attr_accessor glyphPadding: Integer + attr_accessor texture: Rl::Texture + attr_accessor recs: Rl::Rectangle + attr_accessor glyphs: Rl::GlyphInfo + def initialize: (?Integer baseSize, ?Integer glyphCount, ?Integer glyphPadding, ?Rl::Texture texture, ?Rl::Rectangle recs, ?Rl::GlyphInfo glyphs) -> void +end + +class Rl::Camera3D + attr_accessor position: Rl::Vector3 + attr_accessor target: Rl::Vector3 + attr_accessor up: Rl::Vector3 + attr_accessor fovy: Float | Integer + attr_accessor projection: Integer + def initialize: (?Rl::Vector3 position, ?Rl::Vector3 target, ?Rl::Vector3 up, ?Float | Integer fovy, ?Integer projection) -> void +end + +class Rl::Camera2D + attr_accessor offset: Rl::Vector2 + attr_accessor target: Rl::Vector2 + attr_accessor rotation: Float | Integer + attr_accessor zoom: Float | Integer + def initialize: (?Rl::Vector2 offset, ?Rl::Vector2 target, ?Float | Integer rotation, ?Float | Integer zoom) -> void +end + +class Rl::Mesh + attr_accessor vertexCount: Integer + attr_accessor triangleCount: Integer + attr_accessor vertices: untyped + attr_accessor texcoords: untyped + attr_accessor texcoords2: untyped + attr_accessor normals: untyped + attr_accessor tangents: untyped + attr_accessor colors: untyped + attr_accessor indices: untyped + attr_accessor boneCount: Integer + attr_accessor boneIndices: untyped + attr_accessor boneWeights: untyped + attr_accessor animVertices: untyped + attr_accessor animNormals: untyped + attr_accessor vaoId: Integer + attr_accessor vboId: untyped + def initialize: (?Integer vertexCount, ?Integer triangleCount, ?untyped vertices, ?untyped texcoords, ?untyped texcoords2, ?untyped normals, ?untyped tangents, ?untyped colors, ?untyped indices, ?Integer boneCount, ?untyped boneIndices, ?untyped boneWeights, ?untyped animVertices, ?untyped animNormals, ?Integer vaoId, ?untyped vboId) -> void +end + +class Rl::Shader + attr_accessor id: Integer + attr_accessor locs: untyped + def initialize: (?Integer id, ?untyped locs) -> void +end + +class Rl::MaterialMap + attr_accessor texture: Rl::Texture + attr_accessor color: Rl::Color + attr_accessor value: Float | Integer + def initialize: (?Rl::Texture texture, ?Rl::Color color, ?Float | Integer value) -> void +end + +class Rl::Material + attr_accessor shader: Rl::Shader + attr_accessor maps: Rl::MaterialMap + attr_accessor params: Integer + def initialize: (?Rl::Shader shader, ?Rl::MaterialMap maps, ?Integer params) -> void +end + +class Rl::Transform + attr_accessor translation: Rl::Vector3 + attr_accessor rotation: Rl::Vector4 + attr_accessor scale: Rl::Vector3 + def initialize: (?Rl::Vector3 translation, ?Rl::Vector4 rotation, ?Rl::Vector3 scale) -> void +end + +class Rl::BoneInfo + attr_accessor name: Integer + attr_accessor parent: Integer + def initialize: (?Integer name, ?Integer parent) -> void +end + +class Rl::ModelSkeleton + attr_accessor boneCount: Integer + attr_accessor bones: Rl::BoneInfo + attr_accessor bindPose: Integer + def initialize: (?Integer boneCount, ?Rl::BoneInfo bones, ?Integer bindPose) -> void +end + +class Rl::Model + attr_accessor transform: Rl::Matrix + attr_accessor meshCount: Integer + attr_accessor materialCount: Integer + attr_accessor meshes: Rl::Mesh + attr_accessor materials: Rl::Material + attr_accessor meshMaterial: untyped + attr_accessor skeleton: Rl::ModelSkeleton + attr_accessor currentPose: Integer + attr_accessor boneMatrices: Rl::Matrix + def initialize: (?Rl::Matrix transform, ?Integer meshCount, ?Integer materialCount, ?Rl::Mesh meshes, ?Rl::Material materials, ?untyped meshMaterial, ?Rl::ModelSkeleton skeleton, ?Integer currentPose, ?Rl::Matrix boneMatrices) -> void +end + +class Rl::ModelAnimation + attr_accessor name: Integer + attr_accessor boneCount: Integer + attr_accessor keyframeCount: Integer + attr_accessor keyframePoses: untyped + def initialize: (?Integer name, ?Integer boneCount, ?Integer keyframeCount, ?untyped keyframePoses) -> void +end + +class Rl::Ray + attr_accessor position: Rl::Vector3 + attr_accessor direction: Rl::Vector3 + def initialize: (?Rl::Vector3 position, ?Rl::Vector3 direction) -> void +end + +class Rl::RayCollision + attr_accessor hit: bool + attr_accessor distance: Float | Integer + attr_accessor point: Rl::Vector3 + attr_accessor normal: Rl::Vector3 + def initialize: (?bool hit, ?Float | Integer distance, ?Rl::Vector3 point, ?Rl::Vector3 normal) -> void +end + +class Rl::BoundingBox + attr_accessor min: Rl::Vector3 + attr_accessor max: Rl::Vector3 + def initialize: (?Rl::Vector3 min, ?Rl::Vector3 max) -> void +end + +class Rl::Wave + attr_accessor frameCount: Integer + attr_accessor sampleRate: Integer + attr_accessor sampleSize: Integer + attr_accessor channels: Integer + attr_accessor data: untyped + def initialize: (?Integer frameCount, ?Integer sampleRate, ?Integer sampleSize, ?Integer channels, ?untyped data) -> void +end + +class Rl::AudioStream + attr_accessor buffer: untyped + attr_accessor processor: untyped + attr_accessor sampleRate: Integer + attr_accessor sampleSize: Integer + attr_accessor channels: Integer + def initialize: (?untyped buffer, ?untyped processor, ?Integer sampleRate, ?Integer sampleSize, ?Integer channels) -> void +end + +class Rl::Sound + attr_accessor stream: Rl::AudioStream + attr_accessor frameCount: Integer + def initialize: (?Rl::AudioStream stream, ?Integer frameCount) -> void +end + +class Rl::Music + attr_accessor stream: Rl::AudioStream + attr_accessor frameCount: Integer + attr_accessor looping: bool + attr_accessor ctxType: Integer + attr_accessor ctxData: untyped + def initialize: (?Rl::AudioStream stream, ?Integer frameCount, ?bool looping, ?Integer ctxType, ?untyped ctxData) -> void +end + +class Rl::VrDeviceInfo + attr_accessor hResolution: Integer + attr_accessor vResolution: Integer + attr_accessor hScreenSize: Float | Integer + attr_accessor vScreenSize: Float | Integer + attr_accessor eyeToScreenDistance: Float | Integer + attr_accessor lensSeparationDistance: Float | Integer + attr_accessor interpupillaryDistance: Float | Integer + attr_accessor lensDistortionValues: Integer + attr_accessor chromaAbCorrection: Integer + def initialize: (?Integer hResolution, ?Integer vResolution, ?Float | Integer hScreenSize, ?Float | Integer vScreenSize, ?Float | Integer eyeToScreenDistance, ?Float | Integer lensSeparationDistance, ?Float | Integer interpupillaryDistance, ?Integer lensDistortionValues, ?Integer chromaAbCorrection) -> void +end + +class Rl::VrStereoConfig + attr_accessor projection: Integer + attr_accessor viewOffset: Integer + attr_accessor leftLensCenter: Integer + attr_accessor rightLensCenter: Integer + attr_accessor leftScreenCenter: Integer + attr_accessor rightScreenCenter: Integer + attr_accessor scale: Integer + attr_accessor scaleIn: Integer + def initialize: (?Integer projection, ?Integer viewOffset, ?Integer leftLensCenter, ?Integer rightLensCenter, ?Integer leftScreenCenter, ?Integer rightScreenCenter, ?Integer scale, ?Integer scaleIn) -> void +end + +class Rl::FilePathList + attr_accessor count: Integer + attr_accessor paths: untyped + def initialize: (?Integer count, ?untyped paths) -> void +end + +class Rl::AutomationEvent + attr_accessor frame: Integer + attr_accessor type: Integer + attr_accessor params: Integer + def initialize: (?Integer frame, ?Integer type, ?Integer params) -> void +end + +class Rl::AutomationEventList + attr_accessor capacity: Integer + attr_accessor count: Integer + attr_accessor events: Rl::AutomationEvent + def initialize: (?Integer capacity, ?Integer count, ?Rl::AutomationEvent events) -> void +end + diff --git a/sig/rmlui.rbs b/sig/rmlui.rbs new file mode 100644 index 0000000..510d015 --- /dev/null +++ b/sig/rmlui.rbs @@ -0,0 +1,110 @@ +# RmlUi Ruby API signatures (hand-written from docs/API_SPEC_RMLUI.md). +# Mirrors mrbgems/rmlui/mrblib/rmlui.rb. + +module Rml + def self._init: () -> void + def self.init: () -> void + def self._shutdown: () -> void + def self.shutdown: () -> void + def self._load_font: (String path, bool fallback) -> void + def self.load_font: (String path, ?fallback: bool) -> void +end + +class Rml::Context + def initialize: (String name, ?width: Integer, ?height: Integer) -> void + def dimensions=: (Rl::Vector2) -> void + def resize: (Integer width, Integer height) -> void + def data_model: (String name) { (Rml::DataModel) -> void } -> Rml::DataModel + def load_document: (String path) ?{ (Rml::Document) -> void } -> Rml::Document + def document: (String id) -> Rml::Element? + def num_documents: () -> Integer + def process_input: () -> void + def update: () -> void + def render: () -> void + def frame: () { -> void } -> void +end + +class Rml::Element + attr_reader ptr: untyped + def initialize: (untyped ptr) -> void + def []: (String name) -> String? + def []=: (String name, untyped v) -> void + def attribute: (String name) -> String? + def set_attribute: (String name, untyped v) -> Rml::Element + def has_attribute?: (String name) -> bool + def remove_attribute: (String name) -> Rml::Element + def id: () -> String + def id=: (String) -> void + def tag_name: () -> String + def inner_rml: () -> String + def inner_rml=: (String) -> void + def text: () -> String + def text=: (String) -> void + def set_class: (String name, bool on) -> Rml::Element + def add_class: (String name) -> Rml::Element + def remove_class: (String name) -> Rml::Element + def class_set?: (String name) -> bool + def set_property: (String name, String val) -> Rml::Element + def property: (String name) -> String + def remove_property: (String name) -> Rml::Element + def focus: () -> Rml::Element + def blur: () -> Rml::Element + def click: () -> Rml::Element + def scroll_into_view: (?bool align_top) -> Rml::Element + def visible?: () -> bool + def select_all: () -> Rml::Element + def set_selection_range: (Integer start, Integer finish) -> Rml::Element + def caret_end: () -> Rml::Element + def element: (String id) -> Rml::Element? + alias get_element_by_id element + def query_selector: (String sel) -> Rml::Element? + def query_selector_all: (String sel) -> Array[Rml::Element] + def elements_by_tag: (String tag) -> Array[Rml::Element] + def parent: () -> Rml::Element? + def child_count: () -> Integer + def child: (Integer i) -> Rml::Element? + def children: () -> Array[Rml::Element] + def owner_document: () -> Rml::Element? + def client_width: () -> Integer + def client_height: () -> Integer + def offset_left: () -> Integer + def offset_top: () -> Integer + def absolute_left: () -> Integer + def absolute_top: () -> Integer + def on: (Symbol type) ?{ (Rml::Event) -> void } -> Rml::Element +end + +class Rml::Document < Rml::Element + def show: () -> Rml::Document + def hide: () -> Rml::Document + def close: () -> Rml::Document + def title: () -> String + def title=: (String) -> void + def pull_to_front: () -> Rml::Document + def push_to_back: () -> Rml::Document +end + +class Rml::Event + def initialize: (untyped ptr) -> void + def type: () -> String + def target: () -> Rml::Element + def current: () -> Rml::Element + def stop_propagation: () -> void + def stop_immediate_propagation: () -> void + def []: (String key) -> Float + def param: (String key) -> Float + def param_str: (String key) -> String + def mouse_x: () -> Float + def mouse_y: () -> Float +end + +class Rml::DataModel + def bind: (Symbol name) { -> untyped } -> Rml::DataModel + def value: (Symbol name, untyped initial) -> Rml::DataModel + def event: (Symbol name) ?{ (Rml::Event) -> void } -> Rml::DataModel + def finish: () -> Rml::DataModel + def dirty: (*Symbol names) -> Rml::DataModel + def dirty_all: () -> Rml::DataModel + def []: (String name) -> untyped + def []=: (String name, untyped v) -> void +end diff --git a/src/config.c b/src/config.c deleted file mode 100644 index ed3581d..0000000 --- a/src/config.c +++ /dev/null @@ -1,167 +0,0 @@ -#include "config.h" -#include <stdio.h> -#include <string.h> -#include <stdlib.h> - -#ifdef PLATFORM_LINUX -#include <unistd.h> -#endif - -#define CONFIG_FILENAME "study-player.cfg" -#define MAX_LINE 256 - -static void set_defaults(UILayout *layout) -{ - layout->titleY = 60.0f; - layout->titleX = 1920.0f / 2.0f; - layout->barY = 460.0f; - layout->barHeight = 50.0f; - layout->barWidth = 1920.0f * 0.65f; - layout->btnRadius = 55.0f; - layout->helpY = 1080.0f - 80.0f; - layout->helpX = 40.0f; - layout->btnCenterX = 1920.0f / 2.0f; - - layout->barX = (1920.0f - layout->barWidth) / 2.0f; - layout->statusY = layout->barY + layout->barHeight + 30.0f; - layout->statusX = 1920.0f / 2.0f; - layout->btnY = layout->statusY + 120.0f + 55.0f; - layout->smartPlayY = 1080.0f - 80.0f - 120.0f; - layout->smartPlayX = 1920.0f / 2.0f - 100.0f; - layout->secNavY = layout->btnY + 55.0f + 80.0f + 30.0f; - layout->secNavX = 1920.0f / 2.0f; -} - -static void chomp(char *line) -{ - size_t len = strlen(line); - while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) - line[--len] = '\0'; -} - -static const char *dirname_of(const char *path, char *buf, size_t bufsize) -{ - const char *lastSep = NULL; - for (const char *p = path; *p; p++) - if (*p == '/') lastSep = p; - - if (!lastSep) - { - buf[0] = '.'; - buf[1] = '\0'; - return buf; - } - - size_t dirlen = (size_t)(lastSep - path); - if (dirlen >= bufsize) dirlen = bufsize - 1; - memcpy(buf, path, dirlen); - buf[dirlen] = '\0'; - return buf; -} - -static void build_config_path(const char *exePath, char *out, size_t outSize) -{ - char dir[512]; - dirname_of(exePath, dir, sizeof(dir)); - snprintf(out, outSize, "%s/%s", dir, CONFIG_FILENAME); -} - -static float parse_float(const char *s) -{ - char *end; - float val = strtof(s, &end); - if (end == s) return -1.0f; - return val; -} - -static int parse_config(const char *path, UILayout *layout) -{ - FILE *fp = fopen(path, "r"); - if (!fp) return 0; - - char line[MAX_LINE]; - int loaded = 0; - - while (fgets(line, sizeof(line), fp)) - { - chomp(line); - - if (line[0] == '#' || line[0] == '\0') - continue; - - char *eq = strchr(line, '='); - if (!eq) continue; - *eq = '\0'; - const char *key = line; - const char *val = eq + 1; - - float f = parse_float(val); - if (f < 0.0f) continue; - - if (strcmp(key, "title_y") == 0) layout->titleY = f; - else if (strcmp(key, "title_x") == 0) layout->titleX = f; - else if (strcmp(key, "bar_y") == 0) layout->barY = f; - else if (strcmp(key, "bar_height") == 0) layout->barHeight = f; - else if (strcmp(key, "bar_width") == 0) layout->barWidth = f; - else if (strcmp(key, "btn_radius") == 0) layout->btnRadius = f; - else if (strcmp(key, "help_y") == 0) layout->helpY = f; - else if (strcmp(key, "help_x") == 0) layout->helpX = f; - else if (strcmp(key, "status_x") == 0) layout->statusX = f; - else if (strcmp(key, "btn_y") == 0) layout->btnY = f; - else if (strcmp(key, "btn_center_x") == 0) layout->btnCenterX = f; - else if (strcmp(key, "smart_play_y") == 0) layout->smartPlayY = f; - else if (strcmp(key, "smart_play_x") == 0) layout->smartPlayX = f; - else if (strcmp(key, "sec_nav_y") == 0) layout->secNavY = f; - else if (strcmp(key, "sec_nav_x") == 0) layout->secNavX = f; - - loaded = 1; - } - - fclose(fp); - - layout->barX = (1920.0f - layout->barWidth) / 2.0f; - layout->statusY = layout->barY + layout->barHeight + 30.0f; - - return loaded; -} - -int config_load(const char *exePath, UILayout *layout) -{ - set_defaults(layout); - - char cfgPath[1024]; - build_config_path(exePath, cfgPath, sizeof(cfgPath)); - - int loaded = parse_config(cfgPath, layout); - layout->barX = (1920.0f - layout->barWidth) / 2.0f; - layout->statusY = layout->barY + layout->barHeight + 30.0f; - return loaded; -} - -int config_save(const char *exePath, const UILayout *layout) -{ - char cfgPath[1024]; - build_config_path(exePath, cfgPath, sizeof(cfgPath)); - - FILE *fp = fopen(cfgPath, "w"); - if (!fp) return 0; - - fprintf(fp, "# Study Player layout config\n"); - fprintf(fp, "title_y=%.2f\n", layout->titleY); - fprintf(fp, "title_x=%.2f\n", layout->titleX); - fprintf(fp, "bar_y=%.2f\n", layout->barY); - fprintf(fp, "bar_height=%.2f\n", layout->barHeight); - fprintf(fp, "bar_width=%.2f\n", layout->barWidth); - fprintf(fp, "btn_radius=%.2f\n", layout->btnRadius); - fprintf(fp, "help_y=%.2f\n", layout->helpY); - fprintf(fp, "help_x=%.2f\n", layout->helpX); - fprintf(fp, "status_x=%.2f\n", layout->statusX); - fprintf(fp, "btn_y=%.2f\n", layout->btnY); - fprintf(fp, "btn_center_x=%.2f\n", layout->btnCenterX); - fprintf(fp, "smart_play_y=%.2f\n", layout->smartPlayY); - fprintf(fp, "smart_play_x=%.2f\n", layout->smartPlayX); - fprintf(fp, "sec_nav_y=%.2f\n", layout->secNavY); - fprintf(fp, "sec_nav_x=%.2f\n", layout->secNavX); - fclose(fp); - return 1; -} diff --git a/src/config.h b/src/config.h deleted file mode 100644 index 42ccca2..0000000 --- a/src/config.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once - -/* config.h — UI layout persistence contract. - * - * Owns: loading/saving UILayout to study-player.cfg. - * UILayout itself lives in types.h (the shared-types header). */ - -#include "types.h" - -/* Load layout from <exeDir>/study-player.cfg; returns 1 if loaded, 0 if - * the file was missing or unreadable (defaults are applied either way). */ -int config_load(const char *exePath, UILayout *layout); - -/* Save layout to <exeDir>/study-player.cfg; returns 1 on success. */ -int config_save(const char *exePath, const UILayout *layout); diff --git a/src/layout_editor.c b/src/layout_editor.c deleted file mode 100644 index 0a40c59..0000000 --- a/src/layout_editor.c +++ /dev/null @@ -1,279 +0,0 @@ -#include "layout_editor.h" -#include "raylib.h" - -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" -#define RAYGUI_IMPLEMENTATION -#include "raygui.h" -#pragma GCC diagnostic pop - -#define TITLE_W 400.0f -#define TITLE_H 70.0f -#define TIME_W 80.0f -#define PCT_W 60.0f -#define PCT_H 30.0f -#define STATUS_W 120.0f -#define STATUS_H 40.0f -#define HELP_W 600.0f -#define HELP_H 30.0f - -static int dragIndex = -1; -static float dragOffsetX = 0.0f; -static float dragOffsetY = 0.0f; - -void layout_editor_init(void) -{ - GuiLoadStyleDefault(); -} - -static Color fillColor = { 60, 60, 80, 100 }; -static Color borderColor = { 180, 180, 200, 200 }; -static Color highlightColor = { 233, 69, 96, 150 }; -static Color labelColor = { 234, 234, 234, 255 }; -static const int labelFontSize = 20; - -static void draw_label(const char *text, Rectangle r, Color fill, Color border) -{ - DrawRectangleRec(r, fill); - DrawRectangleLinesEx(r, 2.0f, border); - int textY = (int)(r.y + r.height / 2.0f - (float)labelFontSize / 2.0f); - DrawText(text, (int)r.x + 10, textY, labelFontSize, labelColor); -} - -void layout_editor_draw(const char *exePath, UILayout *layout) -{ - (void)exePath; - - Vector2 mouse = GetMousePosition(); - - /* --- Hit detection on press --- */ - if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { - dragIndex = -1; - - /* Check element 6 first, then 5, 4, 3, 2, 1, 0. - Later checks overwrite earlier ones if they overlap — - priority goes to elements drawn last / on top. */ - - /* 6: Section nav buttons (two circles) */ - { - float secPrevX = layout->secNavX - 65.0f; - float secNextX = layout->secNavX + 65.0f; - float secBtnRadius = 35.0f; - float dx1 = mouse.x - secPrevX; - float dy1 = mouse.y - layout->secNavY; - float dx2 = mouse.x - secNextX; - float dy2 = mouse.y - layout->secNavY; - if ((dx1 * dx1 + dy1 * dy1 <= secBtnRadius * secBtnRadius) || - (dx2 * dx2 + dy2 * dy2 <= secBtnRadius * secBtnRadius)) { - dragIndex = 6; - dragOffsetX = mouse.x - layout->secNavX; - dragOffsetY = mouse.y - layout->secNavY; - } - } - - /* 5: Smart play button (Play) */ - { - Rectangle r = { layout->smartPlayX, - layout->smartPlayY, 200.0f, 80.0f }; - if (CheckCollisionPointRec(mouse, r)) { - dragIndex = 5; - dragOffsetX = mouse.x - layout->smartPlayX; - dragOffsetY = mouse.y - layout->smartPlayY; - } - } - - /* 4: Help */ - { - Rectangle r = { layout->helpX, layout->helpY, HELP_W, HELP_H }; - if (CheckCollisionPointRec(mouse, r)) { - dragIndex = 4; - dragOffsetX = mouse.x - layout->helpX; - dragOffsetY = mouse.y - layout->helpY; - } - } - - /* 3: Play button (circle) */ - { - float dx = mouse.x - layout->btnCenterX; - float dy = mouse.y - layout->btnY; - if (dx * dx + dy * dy <= layout->btnRadius * layout->btnRadius) { - dragIndex = 3; - dragOffsetX = mouse.x - layout->btnCenterX; - dragOffsetY = mouse.y - layout->btnY; - } - } - - /* 2: Status */ - { - Rectangle r = { layout->statusX - STATUS_W / 2.0f, - layout->statusY, STATUS_W, STATUS_H }; - if (CheckCollisionPointRec(mouse, r)) { - dragIndex = 2; - dragOffsetX = mouse.x - layout->statusX; - dragOffsetY = mouse.y - layout->statusY; - } - } - - /* 1: Bar group (bar + time labels + percentage) */ - { - float gx = layout->barX - 90.0f; - float gw = layout->barWidth + 10.0f + 80.0f + 90.0f; - float gy = layout->barY - 40.0f; - float gh = layout->barHeight + 40.0f; - Rectangle group = { gx, gy, gw, gh }; - if (CheckCollisionPointRec(mouse, group)) { - dragIndex = 1; - dragOffsetX = mouse.x - layout->barX; - dragOffsetY = mouse.y - layout->barY; - } - } - - /* 0: Title */ - { - Rectangle r = { layout->titleX - TITLE_W / 2.0f, - layout->titleY, TITLE_W, TITLE_H }; - if (CheckCollisionPointRec(mouse, r)) { - dragIndex = 0; - dragOffsetX = mouse.x - layout->titleX; - dragOffsetY = mouse.y - layout->titleY; - } - } - } - - /* --- Drag update --- */ - if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && dragIndex >= 0) { - switch (dragIndex) { - case 0: /* Title */ - layout->titleX = mouse.x - dragOffsetX; - layout->titleY = mouse.y - dragOffsetY; - break; - case 1: /* Bar group */ - layout->barX = mouse.x - dragOffsetX; - layout->barY = mouse.y - dragOffsetY; - break; - case 2: /* Status */ - layout->statusX = mouse.x - dragOffsetX; - layout->statusY = mouse.y - dragOffsetY; - break; - case 3: /* Play button */ - layout->btnCenterX = mouse.x - dragOffsetX; - layout->btnY = mouse.y - dragOffsetY; - break; - case 4: /* Help */ - layout->helpX = mouse.x - dragOffsetX; - layout->helpY = mouse.y - dragOffsetY; - break; - case 5: /* Smart play */ - layout->smartPlayX = mouse.x - dragOffsetX; - layout->smartPlayY = mouse.y - dragOffsetY; - break; - case 6: /* Section nav */ - layout->secNavX = mouse.x - dragOffsetX; - layout->secNavY = mouse.y - dragOffsetY; - break; - } - } - - /* --- Release --- */ - if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) { - dragIndex = -1; - } - - Color barFill = (dragIndex == 1) ? highlightColor : fillColor; - - /* --- 0: Title --- */ - { - Color f = (dragIndex == 0) ? highlightColor : fillColor; - Rectangle r = { layout->titleX - TITLE_W / 2.0f, - layout->titleY, TITLE_W, TITLE_H }; - draw_label("Title", r, f, borderColor); - } - - /* --- 1: Bar group --- */ - /* Percentage above bar (centered on bar's X) */ - { - Rectangle r = { layout->barX + layout->barWidth / 2.0f - PCT_W / 2.0f, - layout->barY - 40.0f, PCT_W, PCT_H }; - draw_label("%", r, barFill, borderColor); - } - /* Elapsed time */ - { - Rectangle r = { layout->barX - 90.0f, layout->barY, - TIME_W, layout->barHeight }; - draw_label("Time", r, barFill, borderColor); - } - /* Bar */ - { - Rectangle r = { layout->barX, layout->barY, - layout->barWidth, layout->barHeight }; - draw_label("Bar", r, barFill, borderColor); - } - /* Remaining time */ - { - Rectangle r = { layout->barX + layout->barWidth + 10.0f, - layout->barY, TIME_W, layout->barHeight }; - draw_label("Time", r, barFill, borderColor); - } - - /* --- 2: Status --- */ - { - Color f = (dragIndex == 2) ? highlightColor : fillColor; - Rectangle r = { layout->statusX - STATUS_W / 2.0f, - layout->statusY, STATUS_W, STATUS_H }; - draw_label("Status", r, f, borderColor); - } - - /* --- 3: Play button --- */ - { - float cx = layout->btnCenterX; - float cy = layout->btnY; - float r = layout->btnRadius; - Color f = (dragIndex == 3) ? highlightColor : fillColor; - DrawCircle((int)cx, (int)cy, r, f); - DrawCircleLines((int)cx, (int)cy, r, borderColor); - float half = r * 0.5f; - Vector2 v1 = { cx - half * 0.7f, cy - half }; - Vector2 v2 = { cx - half * 0.7f, cy + half }; - Vector2 v3 = { cx + half * 0.8f, cy }; - DrawTriangle(v1, v2, v3, labelColor); - } - - /* --- 4: Help --- */ - { - Color f = (dragIndex == 4) ? highlightColor : fillColor; - Rectangle r = { layout->helpX, layout->helpY, HELP_W, HELP_H }; - draw_label("Help", r, f, borderColor); - } - - /* --- 5: Smart play button --- */ - { - Color f = (dragIndex == 5) ? highlightColor : fillColor; - Rectangle r = { layout->smartPlayX, - layout->smartPlayY, 200.0f, 80.0f }; - draw_label("Play", r, f, borderColor); - } - - /* --- 6: Section nav buttons --- */ - { - Color f = (dragIndex == 6) ? highlightColor : fillColor; - float secBtnRadius = 35.0f; - float secPrevX = layout->secNavX - 65.0f; - float secNextX = layout->secNavX + 65.0f; - float secCenterY = layout->secNavY; - - DrawCircle((int)secPrevX, (int)secCenterY, secBtnRadius, f); - DrawCircleLines((int)secPrevX, (int)secCenterY, secBtnRadius, borderColor); - DrawText("<", (int)secPrevX - MeasureText("<", labelFontSize) / 2, - (int)secCenterY - labelFontSize / 2, labelFontSize, labelColor); - - DrawCircle((int)secNextX, (int)secCenterY, secBtnRadius, f); - DrawCircleLines((int)secNextX, (int)secCenterY, secBtnRadius, borderColor); - DrawText(">", (int)secNextX - MeasureText(">", labelFontSize) / 2, - (int)secCenterY - labelFontSize / 2, labelFontSize, labelColor); - - const char *secLabel = "Sec"; - int secLabelW = MeasureText(secLabel, labelFontSize); - DrawText(secLabel, (int)(layout->secNavX - secLabelW / 2.0f), - (int)secCenterY - labelFontSize / 2, labelFontSize, labelColor); - } -} diff --git a/src/layout_editor.h b/src/layout_editor.h deleted file mode 100644 index a05cca7..0000000 --- a/src/layout_editor.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once -#include "config.h" - -void layout_editor_init(void); -void layout_editor_draw(const char *exePath, UILayout *layout); @@ -1,226 +1,196 @@ -/* main.c — composition root. +/* Host entry point: boots mruby, loads and runs the game's main.rb. * - * Owns: window/audio init, main loop, tab switching + config save, - * drag-drop / web file loading, platform glue. - * Delegates to: player, study, ui, config, layout_editor. */ - -#define _POSIX_C_SOURCE 200809L - -#include "raylib.h" -#include "raygui.h" - + * Minimal spine. A release build would embed compiled bytecode instead of + * reading source at runtime (see BUILD_SYSTEM.md 4), but for the proof we load + * the Ruby file directly. + */ +#define _POSIX_C_SOURCE 200809L /* fileno/dup/dup2 under -std=c11 (stdout capture) */ #include <stdio.h> #include <stdlib.h> -#include <string.h> - -#include "types.h" -#include "config.h" -#include "layout_editor.h" -#include "player.h" -#include "study.h" -#include "ui.h" - -#ifdef PLATFORM_LINUX -#include <unistd.h> -#endif -#ifdef PLATFORM_WEB -#include <emscripten/emscripten.h> +#include <string.h> /* memcpy (web eval result copy) */ +#include <unistd.h> /* dup, dup2, close (stdout capture) */ +#include <mruby.h> +#include <mruby/compile.h> +#include <mruby/string.h> +#include <mruby/variable.h> /* mrb_const_get (web eval entry) */ +#ifdef __EMSCRIPTEN__ +#include <emscripten.h> #endif -/* ------------------------------------------------------------------ */ -/* Shared state (needed for emscripten main loop callback) */ -/* ------------------------------------------------------------------ */ +static char * +read_file(const char *path, size_t *out_len) +{ + FILE *f = fopen(path, "rb"); + if (!f) { fprintf(stderr, "could not open %s\n", path); return NULL; } + fseek(f, 0, SEEK_END); + long len = ftell(f); + fseek(f, 0, SEEK_SET); + char *buf = (char *)malloc(len + 1); + if (!buf) { fclose(f); return NULL; } + size_t n = fread(buf, 1, len, f); + buf[n] = '\0'; + fclose(f); + if (out_len) *out_len = n; + return buf; +} -static PlayerState state = { 0 }; -static UIState ui; -static UILayout layout; -static char exeDir[512]; -static int activeTab = 0; -static int prevTab = 0; +/* --- Jamstack agent bridge (R1): native bits ------------------------------- * + * The bridge runs Ruby in the LIVE game on the main thread; the bulk lives in + * Ruby (mrbgems/raylib/mrblib/bridge.rb). Two things must be done in C: + * + * 1. stdout capture. mruby's puts/print/p write straight to C fd 1 (NOT via + * $stdout, and there is no __printstr__/StringIO in this build), so the only + * way to capture an eval's output is to redirect fd 1 around the eval. + * Jamstack.__cap_begin / __cap_end bracket Jamstack::Bridge.eval_code. + * 2. env access. There is no ENV in this gembox, so the JAMSTACK_BRIDGE gate is + * read via Jamstack.getenv (C getenv). + * + * Dev-only by construction; see .agents/knowledge/agent-bridge.md. */ +static mrb_state *g_mrb = NULL; /* live interpreter (web jamstack_eval, R4) */ +static int g_saved_fd1 = -1; +static FILE *g_cap_tmp = NULL; -/* --- Screenshot mode (env: STUDY_PLAYER_SCREENSHOT=filename) --- */ -static const char *screenshotFile = NULL; -static int screenshotFrameCount = 0; -#define SCREENSHOT_DELAY_FRAMES 60 /* ~1 second at 60fps for window to fully map */ +static mrb_value +js_cap_begin(mrb_state *mrb, mrb_value self) +{ + (void)self; + if (g_saved_fd1 != -1) return mrb_false_value(); /* already capturing */ + fflush(stdout); + g_cap_tmp = tmpfile(); + if (!g_cap_tmp) return mrb_false_value(); + g_saved_fd1 = dup(1); + dup2(fileno(g_cap_tmp), 1); + return mrb_true_value(); +} -/* ------------------------------------------------------------------ */ -/* File loading (drag-drop desktop / JS callback web) */ -/* ------------------------------------------------------------------ */ +static mrb_value +js_cap_end(mrb_state *mrb, mrb_value self) +{ + (void)self; + if (g_saved_fd1 == -1) return mrb_str_new(mrb, "", 0); + fflush(stdout); + dup2(g_saved_fd1, 1); + close(g_saved_fd1); + g_saved_fd1 = -1; + + fflush(g_cap_tmp); + fseek(g_cap_tmp, 0, SEEK_END); + long n = ftell(g_cap_tmp); + fseek(g_cap_tmp, 0, SEEK_SET); + mrb_value s; + if (n > 0) { + char *buf = (char *)malloc((size_t)n); + size_t r = buf ? fread(buf, 1, (size_t)n, g_cap_tmp) : 0; + s = mrb_str_new(mrb, buf, (mrb_int)r); + free(buf); + } else { + s = mrb_str_new(mrb, "", 0); + } + fclose(g_cap_tmp); + g_cap_tmp = NULL; + return s; +} -static void load_audio_file(const char *path) +static mrb_value +js_getenv(mrb_state *mrb, mrb_value self) { - if (!player_load(&state, path)) return; + (void)self; + const char *name; + mrb_get_args(mrb, "z", &name); + const char *v = getenv(name); + if (!v) return mrb_nil_value(); + return mrb_str_new_cstr(mrb, v); +} - /* Detect silence regions (threshold: 0.015, min duration: 0.75s) */ - study_detect_silence(path, &state, 0.015f, 0.75f); +/* Run arbitrary JS on web (no-op on desktop). Used for canvas/rendering controls. */ +#ifdef __EMSCRIPTEN__ +static mrb_value +jamstack_eval_js(mrb_state *mrb, mrb_value self) +{ + const char *code; + mrb_get_args(mrb, "z", &code); + emscripten_run_script(code); + return mrb_nil_value(); +} +#else +static mrb_value +jamstack_eval_js(mrb_state *mrb, mrb_value self) +{ + (void)mrb; (void)self; + return mrb_nil_value(); +} +#endif - char titleBuf[320]; - snprintf(titleBuf, sizeof(titleBuf), "Study Player - %s", state.filename); - SetWindowTitle(titleBuf); +static void +jamstack_bridge_init(mrb_state *mrb) +{ + struct RClass *m = mrb_define_module(mrb, "Jamstack"); + mrb_define_module_function(mrb, m, "__cap_begin", js_cap_begin, MRB_ARGS_NONE()); + mrb_define_module_function(mrb, m, "__cap_end", js_cap_end, MRB_ARGS_NONE()); + mrb_define_module_function(mrb, m, "getenv", js_getenv, MRB_ARGS_REQ(1)); + mrb_define_module_function(mrb, m, "eval_js", jamstack_eval_js, MRB_ARGS_REQ(1)); } -#ifdef PLATFORM_WEB -/* Called from JavaScript when a file is uploaded via the file input */ +#ifdef __EMSCRIPTEN__ +/* Web eval entry: JS calls + * Module.ccall('jamstack_eval','string',['string'],[code]) + * Runs Jamstack::Bridge.eval_json on the persistent g_mrb (main never returns on + * web — set_main_loop unwinds — so g_mrb stays alive). Single-threaded, so a direct + * call between frames is main-thread-safe (no queue needed). The caller need not + * free; the previous result is freed on the next call. */ EMSCRIPTEN_KEEPALIVE -void load_file_web(const char *path) +char * +jamstack_eval(const char *code) { - load_audio_file(path); + static char *last = NULL; + if (last) { free(last); last = NULL; } + if (!g_mrb || !code) return NULL; + mrb_state *mrb = g_mrb; + int ai = mrb_gc_arena_save(mrb); + struct RClass *js = mrb_module_get(mrb, "Jamstack"); + mrb_value bridge = mrb_const_get(mrb, mrb_obj_value(js), mrb_intern_lit(mrb, "Bridge")); + mrb_value r = mrb_funcall(mrb, bridge, "eval_json", 1, mrb_str_new_cstr(mrb, code)); + char *out = NULL; + if (mrb_string_p(r)) { + mrb_int n = RSTRING_LEN(r); + out = (char *)malloc((size_t)n + 1); + if (out) { memcpy(out, RSTRING_PTR(r), (size_t)n); out[n] = '\0'; } + } + if (mrb->exc) mrb->exc = NULL; /* eval_json shouldn't raise; be safe */ + mrb_gc_arena_restore(mrb, ai); + last = out; + return out; } #endif -/* ------------------------------------------------------------------ */ -/* Main loop body (one frame) */ -/* ------------------------------------------------------------------ */ - -static void update_frame(void) +int +main(int argc, char **argv) { - /* --- Drag & drop file loading (desktop only) --- */ -#ifndef PLATFORM_WEB - if (IsFileDropped()) - { - FilePathList files = LoadDroppedFiles(); - if (files.count > 0) - load_audio_file(files.paths[0]); - UnloadDroppedFiles(files); - } -#endif + const char *script = (argc > 1) ? argv[1] : "game/main.rb"; - /* --- Input (player tab only) --- */ - if (activeTab == 0) - ui_handle_input(&ui, &state, &layout); - - /* --- Music stream update + study auto-pause --- */ - if (state.loaded) - { - player_update(&state); - if (state.studyMode && state.playing) - study_auto_pause_check(&state, ui.smartPlayHeld, IsKeyDown(KEY_SPACE)); - } - - /* --- Save layout on tab switch --- */ - if (prevTab == 1 && activeTab == 0) - config_save(exeDir, &layout); - prevTab = activeTab; - - /* --- Drawing --- */ - BeginDrawing(); - ClearBackground(ui.bgColor); - - char *tabNames[] = { "Player", "Layout" }; - GuiTabBar((Rectangle){ 0, 10, SCREEN_W, 32 }, tabNames, 2, &activeTab); - - if (activeTab == 0) { - if (state.loaded) - ui_render_player(&ui, &state, &layout); - else - ui_render_empty(&ui, &layout); - ui_render_overlay(&ui, &state, &layout); - } else { - layout_editor_draw(exeDir, &layout); - } - - EndDrawing(); - - /* --- Screenshot mode: render to FBO, save as PNG, then exit --- */ - if (screenshotFile) { - screenshotFrameCount++; - if (screenshotFrameCount >= SCREENSHOT_DELAY_FRAMES) { - RenderTexture2D target = LoadRenderTexture(SCREEN_W, SCREEN_H); - BeginTextureMode(target); - ClearBackground(ui.bgColor); - /* Explicitly fill the entire texture with bg color + alpha 255 */ - DrawRectangle(0, 0, SCREEN_W, SCREEN_H, ui.bgColor); - char *tabNames2[] = { "Player", "Layout" }; - GuiTabBar((Rectangle){ 0, 10, SCREEN_W, 32 }, tabNames2, 2, &activeTab); - if (state.loaded) - ui_render_player(&ui, &state, &layout); - else - ui_render_empty(&ui, &layout); - ui_render_overlay(&ui, &state, &layout); - EndTextureMode(); - - /* Read from the render texture — it has proper content */ - Image img = LoadImageFromTexture(target.texture); - ImageFlipVertical(&img); - /* ClearBackground doesn't fill FBO alpha on some Mesa drivers. - * Manually replace all transparent (alpha=0) pixels with the - * background color, so the saved PNG has a proper background. */ - if (img.format == PIXELFORMAT_UNCOMPRESSED_R8G8B8A8) { - unsigned char *px = (unsigned char *)img.data; - for (int i = 0; i < img.width * img.height * 4; i += 4) { - if (px[i+3] == 0) { - px[i] = ui.bgColor.r; - px[i+1] = ui.bgColor.g; - px[i+2] = ui.bgColor.b; - px[i+3] = 255; - } - } - } - ExportImage(img, screenshotFile); - UnloadImage(img); - UnloadRenderTexture(target); - screenshotFile = NULL; - } - } -} + mrb_state *mrb = mrb_open(); + if (!mrb) { fprintf(stderr, "failed to open mruby\n"); return 1; } -/* ------------------------------------------------------------------ */ -/* Entry point */ -/* ------------------------------------------------------------------ */ + g_mrb = mrb; + jamstack_bridge_init(mrb); -int main(void) -{ - InitWindow(SCREEN_W, SCREEN_H, "Study Player"); - InitAudioDevice(); - SetTargetFPS(60); - - /* --- Screenshot mode (env-controlled, for automated testing) --- */ - screenshotFile = getenv("STUDY_PLAYER_SCREENSHOT"); - if (screenshotFile) - MaximizeWindow(); /* force window to be mapped/visible */ - - ui_init(&ui); - - memset(&state, 0, sizeof(state)); - state.studyMode = true; - state.lastSilenceIdx = -1; - - /* Determine executable directory (for config load/save) */ - { - char exePath[512] = {0}; -#ifdef PLATFORM_LINUX - readlink("/proc/self/exe", exePath, sizeof(exePath) - 1); -#endif - config_load(exePath, &layout); - - const char *lastSlash = strrchr(exePath, '/'); - if (lastSlash) { - size_t len = (size_t)(lastSlash - exePath); - if (len >= sizeof(exeDir)) len = sizeof(exeDir) - 1; - memcpy(exeDir, exePath, len); - exeDir[len] = '\0'; - } else { - exeDir[0] = '.'; - exeDir[1] = '\0'; - } - - layout_editor_init(); - } - -#ifdef PLATFORM_WEB - emscripten_set_main_loop(update_frame, 0, 1); -#else - while (!WindowShouldClose()) { - update_frame(); - if (screenshotFile == NULL && screenshotFrameCount >= SCREENSHOT_DELAY_FRAMES) - break; /* screenshot taken, exit */ - } -#endif + size_t len = 0; + char *src = read_file(script, &len); + if (!src) { mrb_close(mrb); return 1; } + + mrbc_context *cxt = mrbc_context_new(mrb); + mrbc_filename(mrb, cxt, script); + + mrb_load_string_cxt(mrb, src, cxt); - player_unload(&state); - ui_destroy(&ui); - CloseAudioDevice(); - CloseWindow(); + int rc = 0; + if (mrb->exc) { + mrb_print_error(mrb); + rc = 1; + } - return 0; + mrbc_context_free(mrb, cxt); + free(src); + mrb_close(mrb); + return rc; } diff --git a/src/player.c b/src/player.c deleted file mode 100644 index c1a7fff..0000000 --- a/src/player.c +++ /dev/null @@ -1,115 +0,0 @@ -#include "player.h" - -#include <ctype.h> -#include <stdio.h> -#include <string.h> - -/* ------------------------------------------------------------------ */ -/* Helpers (module-private) */ -/* ------------------------------------------------------------------ */ - -static int ext_equals_nocase(const char *a, const char *b) -{ - while (*a && *b) { - if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return 1; - a++; b++; - } - return *a != *b; -} - -static const char *basename_from_path(const char *path) -{ - const char *last = path; - for (const char *p = path; *p; p++) { - if (*p == '/' || *p == '\\') last = p + 1; - } - return last; -} - -/* ------------------------------------------------------------------ */ -/* Public API */ -/* ------------------------------------------------------------------ */ - -bool player_load(PlayerState *state, const char *path) -{ - const char *ext = GetFileExtension(path); - if (ext == NULL || ext_equals_nocase(ext, ".mp3") != 0) return false; - - if (state->loaded) { - StopMusicStream(state->music); - UnloadMusicStream(state->music); - state->loaded = false; - state->playing = false; - } - - state->music = LoadMusicStream(path); - state->duration = GetMusicTimeLength(state->music); - state->loaded = true; - state->playing = true; - - const char *base = basename_from_path(path); - strncpy(state->filename, base, sizeof(state->filename) - 1); - state->filename[sizeof(state->filename) - 1] = '\0'; - - PlayMusicStream(state->music); - return true; -} - -void player_unload(PlayerState *state) -{ - if (!state->loaded) return; - StopMusicStream(state->music); - UnloadMusicStream(state->music); - state->loaded = false; - state->playing = false; -} - -void player_play(PlayerState *state) -{ - if (!state->loaded || state->playing) return; - ResumeMusicStream(state->music); - state->playing = true; -} - -void player_pause(PlayerState *state) -{ - if (!state->loaded || !state->playing) return; - PauseMusicStream(state->music); - state->playing = false; -} - -void player_seek(PlayerState *state, float target) -{ - if (!state->loaded) return; - if (target < 0.0f) target = 0.0f; - if (target > state->duration) target = state->duration; - SeekMusicStream(state->music, target); - state->currentTime = target; - state->skipAutoUpdate = 3; /* skip a few frames to let audio engine catch up */ -} - -void player_update(PlayerState *state) -{ - if (!state->loaded) return; - UpdateMusicStream(state->music); - if (state->playing) { - if (state->skipAutoUpdate > 0) { - state->skipAutoUpdate--; - } else { - state->currentTime = GetMusicTimePlayed(state->music); - } - } -} - -void player_format_time(float seconds, char *buf, int bufsize) -{ - int total = (int)seconds; - if (total < 0) total = 0; - int h = total / 3600; - int m = (total % 3600) / 60; - int s = total % 60; - if (h > 0) - snprintf(buf, bufsize, "%d:%02d:%02d", h, m, s); - else - snprintf(buf, bufsize, "%d:%02d", m, s); -} diff --git a/src/player.h b/src/player.h deleted file mode 100644 index 5ff8d7c..0000000 --- a/src/player.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -/* player.h — audio playback contract. - * - * Owns: loading MP3 files, play/pause/seek, time formatting, - * music-stream updates, cleanup. */ - -#include "types.h" - -/* Load an MP3 file into the player. Handles unloading any previously - * loaded stream. Sets state->loaded, state->playing, state->duration, - * state->filename. Returns true on success. */ -bool player_load(PlayerState *state, const char *path); - -/* Unload the current audio stream and reset loaded/playing flags. */ -void player_unload(PlayerState *state); - -/* Playback control. No-ops if nothing is loaded. */ -void player_play(PlayerState *state); -void player_pause(PlayerState *state); - -/* Seek to an absolute time (seconds), clamped to [0, duration]. */ -void player_seek(PlayerState *state, float target); - -/* Per-frame update: pumps the music stream and refreshes currentTime. - * Call once per frame while state->loaded is true. */ -void player_update(PlayerState *state); - -/* Format a time in seconds as "H:MM:SS" or "M:SS" into buf. */ -void player_format_time(float seconds, char *buf, int bufsize); diff --git a/src/study.c b/src/study.c deleted file mode 100644 index 7f402bd..0000000 --- a/src/study.c +++ /dev/null @@ -1,196 +0,0 @@ -#include "study.h" - -#include <stdio.h> - -/* ------------------------------------------------------------------ */ -/* Silence detection */ -/* ------------------------------------------------------------------ */ - -void study_detect_silence(const char *path, PlayerState *state, - float threshold, float minDuration) -{ - state->silenceCount = 0; - Wave wave = LoadWave(path); - if (wave.data == NULL || wave.frameCount == 0) return; - - /* Convert to 32-bit float mono for easy analysis */ - WaveFormat(&wave, wave.sampleRate, 32, 1); - float *samples = (float *)wave.data; - unsigned int totalFrames = wave.frameCount; - float sampleRate = (float)wave.sampleRate; - - /* Scan in chunks of ~10ms */ - int chunkSize = (int)(sampleRate * 0.01f); - if (chunkSize < 1) chunkSize = 1; - float minFrames = minDuration * sampleRate; - - bool inSilence = false; - unsigned int silenceStart = 0; - - for (unsigned int i = 0; i < totalFrames; i += chunkSize) - { - unsigned int end = i + chunkSize; - if (end > totalFrames) end = totalFrames; - - /* Find peak amplitude in this chunk */ - float peak = 0.0f; - for (unsigned int j = i; j < end; j++) - { - float v = samples[j]; - if (v < 0) v = -v; - if (v > peak) peak = v; - } - - if (peak < threshold) - { - if (!inSilence) { silenceStart = i; inSilence = true; } - } - else - { - if (inSilence) - { - unsigned int len = i - silenceStart; - if ((float)len >= minFrames && state->silenceCount < MAX_SILENCE_REGIONS) - { - state->silence[state->silenceCount].start = (float)silenceStart / (float)totalFrames; - state->silence[state->silenceCount].end = (float)i / (float)totalFrames; - state->silenceCount++; - } - inSilence = false; - } - } - } - /* Close any trailing silence */ - if (inSilence) - { - unsigned int len = totalFrames - silenceStart; - if ((float)len >= minFrames && state->silenceCount < MAX_SILENCE_REGIONS) - { - state->silence[state->silenceCount].start = (float)silenceStart / (float)totalFrames; - state->silence[state->silenceCount].end = (float)totalFrames / (float)totalFrames; - state->silenceCount++; - } - } - - UnloadWave(wave); - - /* Pad speaking portions by shrinking silence regions 0.25s on each side */ - if (state->duration > 0.0f) - { - float padNorm = 0.25f / state->duration; - for (int i = 0; i < state->silenceCount; i++) - { - state->silence[i].start += padNorm; - state->silence[i].end -= padNorm; - if (state->silence[i].start >= state->silence[i].end) - { - /* Region too small after padding, remove it */ - for (int j = i; j < state->silenceCount - 1; j++) - state->silence[j] = state->silence[j + 1]; - state->silenceCount--; - i--; - } - } - } -} - -/* ------------------------------------------------------------------ */ -/* Portion navigation */ -/* ------------------------------------------------------------------ */ - -int study_find_silence_at(const PlayerState *state, float pos) -{ - for (int i = 0; i < state->silenceCount; i++) - if (pos >= state->silence[i].start && pos < state->silence[i].end) return i; - return -1; -} - -float study_speaking_portion_start(const PlayerState *state, int portion) -{ - if (portion <= 0) return 0.0f; - if (portion > state->silenceCount) return state->silence[state->silenceCount - 1].end; - return state->silence[portion - 1].end; -} - -int study_current_speaking_portion(const PlayerState *state, float pos) -{ - int portion = 0; - for (int i = 0; i < state->silenceCount; i++) - { - if (pos >= state->silence[i].end) - portion = i + 1; - else - break; - } - return portion; -} - -int study_total_speaking_portions(const PlayerState *state) -{ - return state->silenceCount + 1; -} - -float study_segment_seek_target(const PlayerState *state, int portion) -{ - float pos = study_speaking_portion_start(state, portion); - float target = pos * state->duration + (2.0f / 60.0f); - if (target < 0.0f) target = 0.0f; - if (target > state->duration) target = state->duration; - return target; -} - -bool study_in_padding_zone(const PlayerState *state, float pos, int portion) -{ - if (state->duration <= 0.0f) return false; - float padNorm = 0.25f / state->duration; - float start = study_speaking_portion_start(state, portion); - return (pos >= start && pos < start + padNorm); -} - -/* ------------------------------------------------------------------ */ -/* Auto-pause logic */ -/* ------------------------------------------------------------------ */ - -void study_auto_pause_check(PlayerState *state, bool smartPlayHeld, bool spaceHeld) -{ - if (!state->studyMode || state->duration <= 0.0f || !state->playing) - return; - - if (smartPlayHeld || spaceHeld) { - /* Override: still track silence state but don't auto-pause */ - float pos = state->currentTime / state->duration; - int silIdx = study_find_silence_at(state, pos); - state->wasInSilence = (silIdx >= 0); - if (silIdx >= 0) state->lastSilenceIdx = silIdx; - return; - } - - float pos = state->currentTime / state->duration; - int silIdx = study_find_silence_at(state, pos); - bool nowInSilence = (silIdx >= 0); - - if (nowInSilence && !state->wasInSilence) - { - /* Entering silence: jump to start of next speaking portion */ - float target = study_segment_seek_target(state, silIdx + 1); - PauseMusicStream(state->music); - SeekMusicStream(state->music, target); - state->currentTime = target; - state->playing = false; - state->skipAutoUpdate = 3; - } - else if (!nowInSilence && state->wasInSilence) - { - /* Exiting silence: land at start of current speaking portion */ - int portion = study_current_speaking_portion(state, pos); - float target = study_segment_seek_target(state, portion); - PauseMusicStream(state->music); - SeekMusicStream(state->music, target); - state->currentTime = target; - state->playing = false; - state->skipAutoUpdate = 3; - } - - state->wasInSilence = nowInSilence; - if (nowInSilence) state->lastSilenceIdx = silIdx; -} diff --git a/src/study.h b/src/study.h deleted file mode 100644 index 0b7b030..0000000 --- a/src/study.h +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -/* study.h — study mode contract. - * - * Owns: silence detection, speaking-portion navigation, auto-pause logic. - * All functions take PlayerState* and work with the normalized positions - * stored in state->silence[]. */ - -#include "types.h" - -/* Analyze an audio file and populate state->silence[] with detected gaps. - * threshold — amplitude below which a chunk is "silent" (e.g. 0.015). - * minDuration — minimum silence length in seconds (e.g. 0.75). - * Requires state->duration to be set (call after player_load). */ -void study_detect_silence(const char *path, PlayerState *state, - float threshold, float minDuration); - -/* Return the index of the silence region containing pos (normalized 0..1), - * or -1 if none. */ -int study_find_silence_at(const PlayerState *state, float pos); - -/* Start (normalized) of speaking portion N (0-based). Portion 0 = 0.0. */ -float study_speaking_portion_start(const PlayerState *state, int portion); - -/* Which speaking portion (0-based) the position falls in. - * During silence, returns the previous speaking portion. */ -int study_current_speaking_portion(const PlayerState *state, float pos); - -/* Total number of speaking portions (silenceCount + 1). */ -int study_total_speaking_portions(const PlayerState *state); - -/* Seek target (seconds) for jumping to a speaking portion. - * Lands 2 render-frames (~33ms) into the padding zone. */ -float study_segment_seek_target(const PlayerState *state, int portion); - -/* Is pos inside the padding zone of the given speaking portion? - * Padding zone = [speaking_start, speaking_start + 0.25s/duration]. */ -bool study_in_padding_zone(const PlayerState *state, float pos, int portion); - -/* Run the study-mode auto-pause check for this frame. - * Call after player_update while state->studyMode && state->playing. - * smartPlayHeld / spaceHeld suppress auto-pause when true. - * Mutates: state->playing, state->currentTime, state->wasInSilence, - * state->lastSilenceIdx, state->skipAutoUpdate. */ -void study_auto_pause_check(PlayerState *state, bool smartPlayHeld, bool spaceHeld); diff --git a/src/types.h b/src/types.h deleted file mode 100644 index 87ccc37..0000000 --- a/src/types.h +++ /dev/null @@ -1,77 +0,0 @@ -#pragma once - -/* types.h — shared types, constants, and defines. - * - * This is the ONE header every module includes for shared data structures. - * No .c file — pure declarations. Raylib is included because PlayerState - * holds a Music handle. */ - -#include "raylib.h" - -/* ------------------------------------------------------------------ */ -/* Screen dimensions */ -/* ------------------------------------------------------------------ */ - -#define SCREEN_W 1920 -#define SCREEN_H 1080 - -/* ------------------------------------------------------------------ */ -/* Limits */ -/* ------------------------------------------------------------------ */ - -#define MAX_SILENCE_REGIONS 4096 -#define MAX_FILENAME 256 - -/* ------------------------------------------------------------------ */ -/* Audio / study types */ -/* ------------------------------------------------------------------ */ - -/* A detected gap in the audio where amplitude stays below threshold. - * start/end are normalized 0..1 relative to total frame count. */ -typedef struct { - float start; /* normalized 0..1 */ - float end; /* normalized 0..1 */ -} SilenceRegion; - -/* All mutable runtime state. Passed by pointer to every module. */ -typedef struct { - Music music; - bool loaded; - bool playing; - float duration; - float currentTime; - char filename[MAX_FILENAME]; - - SilenceRegion silence[MAX_SILENCE_REGIONS]; - int silenceCount; - - bool studyMode; - bool wasInSilence; /* for detecting silence entry */ - int lastSilenceIdx; /* index of silence region we were last in, or -1 */ - int skipAutoUpdate; /* frames to skip auto-updating currentTime */ -} PlayerState; - -/* ------------------------------------------------------------------ */ -/* UI layout — pixel positions for every on-screen element. */ -/* Persisted to study-player.cfg via config_load / config_save. */ -/* ------------------------------------------------------------------ */ - -typedef struct { - float titleY; - float titleX; - float barY; - float barHeight; - float barWidth; - float barX; - float statusY; - float statusX; - float btnRadius; - float helpY; - float helpX; - float btnY; - float btnCenterX; - float smartPlayY; - float smartPlayX; - float secNavY; - float secNavX; -} UILayout; diff --git a/src/ui.c b/src/ui.c deleted file mode 100644 index 600c368..0000000 --- a/src/ui.c +++ /dev/null @@ -1,469 +0,0 @@ -#include "ui.h" -#include "study.h" -#include "player.h" -#include "font_data.h" - -#include <stdio.h> - -/* ------------------------------------------------------------------ */ -/* Module-private drawing helpers */ -/* ------------------------------------------------------------------ */ - -static void draw_text_centered(Font f, const char *text, float centerX, - float y, float fontSize, Color color) -{ - float spacing = fontSize * 0.03f; - Vector2 size = MeasureTextEx(f, text, fontSize, spacing); - float x = centerX - size.x / 2.0f; - DrawTextEx(f, text, (Vector2){ x, y }, fontSize, spacing, color); -} - -static void draw_play_icon(float cx, float cy, float size, Color color) -{ - float half = size / 2.0f; - Vector2 v1 = { cx - half * 0.7f, cy - half }; - Vector2 v2 = { cx - half * 0.7f, cy + half }; - Vector2 v3 = { cx + half * 0.8f, cy }; - DrawTriangle(v1, v2, v3, color); -} - -static void draw_pause_icon(float cx, float cy, float size, Color color) -{ - float half = size / 2.0f; - float barW = size * 0.25f; - float gap = size * 0.15f; - DrawRectangleRec((Rectangle){ cx - gap - barW, cy - half, barW, size }, color); - DrawRectangleRec((Rectangle){ cx + gap, cy - half, barW, size }, color); -} - -static void draw_seek_back_icon(float cx, float cy, float size, Color color) -{ - float half = size / 2.0f; - Vector2 v1 = { cx + half * 0.7f, cy - half }; - Vector2 v2 = { cx - half * 0.8f, cy }; - Vector2 v3 = { cx + half * 0.7f, cy + half }; - DrawTriangle(v1, v2, v3, color); -} - -static void draw_seek_fwd_icon(float cx, float cy, float size, Color color) -{ - float half = size / 2.0f; - Vector2 v1 = { cx - half * 0.7f, cy - half }; - Vector2 v2 = { cx - half * 0.7f, cy + half }; - Vector2 v3 = { cx + half * 0.8f, cy }; - DrawTriangle(v1, v2, v3, color); -} - -static bool button_hit(float cx, float cy, float radius) -{ - if (!IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) return false; - Vector2 m = GetMousePosition(); - float dx = m.x - cx; - float dy = m.y - cy; - return (dx * dx + dy * dy) <= (radius * radius); -} - -/* ------------------------------------------------------------------ */ -/* Lifecycle */ -/* ------------------------------------------------------------------ */ - -void ui_init(UIState *ui) -{ -#if FONT_EMBEDDED - ui->fontSmall = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 60, NULL, 0); - ui->font = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 80, NULL, 0); - ui->fontMed = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 100, NULL, 0); - ui->fontLarge = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 160, NULL, 0); - ui->fontHelp = LoadFontFromMemory(".otf", embedded_font_data, embedded_font_data_len, 40, NULL, 0); - ui->szSmall = 60.0f; - ui->szHelp = 40.0f; - ui->szFont = 80.0f; - ui->szMed = 100.0f; - ui->szLarge = 160.0f; -#else - ui->fontSmall = GetFontDefault(); - ui->font = GetFontDefault(); - ui->fontMed = GetFontDefault(); - ui->fontLarge = GetFontDefault(); - ui->fontHelp = GetFontDefault(); - ui->szSmall = 30.0f; - ui->szHelp = 20.0f; - ui->szFont = 40.0f; - ui->szMed = 50.0f; - ui->szLarge = 80.0f; -#endif - - ui->bgColor = (Color){ 26, 26, 46, 255 }; - ui->textColor = (Color){ 234, 234, 234, 255 }; - ui->accentColor = (Color){ 233, 69, 96, 255 }; - ui->mutedColor = (Color){ 140, 140, 160, 255 }; - ui->barBgColor = (Color){ 60, 60, 60, 255 }; - ui->btnHoverColor = (Color){ 255, 255, 255, 40 }; - - ui->smartPlayHeld = false; -} - -void ui_destroy(UIState *ui) -{ -#if FONT_EMBEDDED - UnloadFont(ui->fontSmall); - UnloadFont(ui->font); - UnloadFont(ui->fontMed); - UnloadFont(ui->fontLarge); - UnloadFont(ui->fontHelp); -#else - (void)ui; -#endif -} - -/* ------------------------------------------------------------------ */ -/* Input */ -/* ------------------------------------------------------------------ */ - -void ui_handle_input(UIState *ui, PlayerState *state, const UILayout *layout) -{ - if (!state->loaded) { - /* Study-mode checkbox is always available */ - goto checkbox; - } - - /* --- Play/pause button --- */ - if (button_hit(layout->btnCenterX, layout->btnY, layout->btnRadius)) - { - if (state->playing) - player_pause(state); - else - player_play(state); - } - - /* --- Section nav buttons --- */ - { - float progress = (state->duration > 0.0f) ? state->currentTime / state->duration : 0.0f; - int portion = study_current_speaking_portion(state, progress) + 1; - int total = study_total_speaking_portions(state); - if (portion > total) portion = total; - float secBtnRadius = 35.0f; - float secPrevX = layout->secNavX - 65.0f; - float secNextX = layout->secNavX + 65.0f; - float secBtnY = layout->secNavY; - - if (button_hit(secPrevX, secBtnY, secBtnRadius)) - { - float pos = state->currentTime / state->duration; - int p = study_current_speaking_portion(state, pos); - bool inSil = (study_find_silence_at(state, pos) >= 0); - bool inPad = study_in_padding_zone(state, pos, p); - if ((inSil || inPad) && p > 0) p--; - float target = study_segment_seek_target(state, p); - player_seek(state, target); - state->wasInSilence = false; - state->lastSilenceIdx = -1; - } - if (button_hit(secNextX, secBtnY, secBtnRadius)) - { - float pos = state->currentTime / state->duration; - int p = study_current_speaking_portion(state, pos); - if (p < total - 1) p++; - float target = study_segment_seek_target(state, p); - player_seek(state, target); - state->wasInSilence = false; - state->lastSilenceIdx = -1; - } - } - - /* --- Smart play hold button --- */ - { - Rectangle smartBtn = { layout->smartPlayX, layout->smartPlayY, 200.0f, 80.0f }; - Vector2 mouse = GetMousePosition(); - bool overBtn = (mouse.x >= smartBtn.x && mouse.x <= smartBtn.x + smartBtn.width && - mouse.y >= smartBtn.y && mouse.y <= smartBtn.y + smartBtn.height); - - if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && overBtn) { - ui->smartPlayHeld = true; - if (!state->playing) { - ResumeMusicStream(state->music); - state->playing = true; - } - } - - if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && ui->smartPlayHeld) { - /* Allow finger drift - stay held */ - } - - if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) { - ui->smartPlayHeld = false; - } - - if (GetTouchPointCount() > 0 && ui->smartPlayHeld) { - /* Touch fallback: keep held while touch points active */ - } - } - - /* --- Keyboard input --- */ - if (IsKeyPressed(KEY_C) && state->playing) - player_pause(state); - - if (IsKeyPressed(KEY_N) && !state->playing) - { - float pos = state->currentTime / state->duration; - int portion = study_current_speaking_portion(state, pos); - float target = study_segment_seek_target(state, portion); - player_seek(state, target); - player_play(state); - } - - if (IsKeyPressed(KEY_SPACE) && !state->playing) - player_play(state); - - if (IsKeyPressed(KEY_V)) - { - float pos = state->currentTime / state->duration; - int portion = study_current_speaking_portion(state, pos); - bool inSil = (study_find_silence_at(state, pos) >= 0); - bool inPad = study_in_padding_zone(state, pos, portion); - if ((inSil || inPad) && portion > 0) - portion--; - float target = study_segment_seek_target(state, portion); - player_seek(state, target); - state->wasInSilence = false; - state->lastSilenceIdx = -1; - } - - if (IsKeyPressed(KEY_B)) - { - float pos = state->currentTime / state->duration; - int portion = study_current_speaking_portion(state, pos); - int total = study_total_speaking_portions(state); - if (portion < total - 1) portion++; - float target = study_segment_seek_target(state, portion); - player_seek(state, target); - state->wasInSilence = false; - state->lastSilenceIdx = -1; - } - - /* Click-to-seek on progress bar */ - if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) - { - Vector2 mouse = GetMousePosition(); - if (mouse.x >= layout->barX && mouse.x <= layout->barX + layout->barWidth && - mouse.y >= layout->barY && mouse.y <= layout->barY + layout->barHeight) - { - float target = ((mouse.x - layout->barX) / layout->barWidth) * state->duration; - player_seek(state, target); - } - } - - /* Arrow key seeking */ - if (IsKeyPressed(KEY_LEFT)) - player_seek(state, state->currentTime - 5.0f); - if (IsKeyPressed(KEY_RIGHT)) - player_seek(state, state->currentTime + 5.0f); - if (IsKeyPressed(KEY_UP) && !state->playing) - player_play(state); - if (IsKeyPressed(KEY_DOWN) && state->playing) - { - player_pause(state); - float rewind = state->currentTime - 1.0f; - if (rewind < 0.0f) rewind = 0.0f; - SeekMusicStream(state->music, rewind); - state->currentTime = rewind; - } - - /* Number key seeking (0-9 = 0%-90%) */ - for (int k = 0; k <= 9; k++) - { - if (IsKeyPressed(KEY_ZERO + k)) - { - float target = state->duration * (k / 10.0f); - player_seek(state, target); - break; - } - } - -checkbox: - /* --- Study mode checkbox --- */ - { - float helpSpacing = ui->szHelp * 0.03f; - const char *label = "Study Mode"; - float cbSize = 30.0f; - Vector2 labelSize = MeasureTextEx(ui->fontHelp, label, ui->szHelp, helpSpacing); - float totalW = cbSize + 10 + labelSize.x; - float cbX = SCREEN_W - totalW - layout->helpX; - float cbY = layout->helpY + (ui->szHelp - cbSize) / 2.0f; - - if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) - { - Vector2 mouse = GetMousePosition(); - if (mouse.x >= cbX && mouse.x <= cbX + totalW && - mouse.y >= cbY && mouse.y <= cbY + cbSize) - { - state->studyMode = !state->studyMode; - } - } - } -} - -/* ------------------------------------------------------------------ */ -/* Rendering */ -/* ------------------------------------------------------------------ */ - -void ui_render_player(const UIState *ui, const PlayerState *state, - const UILayout *layout) -{ - draw_text_centered(ui->font, state->filename, layout->titleX, layout->titleY, - ui->szFont, ui->mutedColor); - - /* Progress bar */ - float progress = (state->duration > 0.0f) ? state->currentTime / state->duration : 0.0f; - if (progress > 1.0f) progress = 1.0f; - - Rectangle barBg = { layout->barX, layout->barY, layout->barWidth, layout->barHeight }; - Rectangle barFill = { layout->barX, layout->barY, layout->barWidth * progress, layout->barHeight }; - DrawRectangleRounded(barBg, 0.4f, 8, ui->barBgColor); - if (progress > 0.001f) - DrawRectangleRounded(barFill, 0.4f, 8, ui->accentColor); - - /* Time labels */ - char timeBuf[16]; - int elapsedSec = (int)state->currentTime; - if (elapsedSec < 0) elapsedSec = 0; - int totalSec = (int)state->duration; - int remainSec = totalSec - elapsedSec; - if (remainSec < 0) remainSec = 0; - - player_format_time((float)elapsedSec, timeBuf, sizeof(timeBuf)); - float timeFontSize = ui->szSmall; - float timeSpacing = timeFontSize * 0.03f; - Vector2 leftSize = MeasureTextEx(ui->fontSmall, timeBuf, timeFontSize, timeSpacing); - DrawTextEx(ui->fontSmall, timeBuf, - (Vector2){ layout->barX - leftSize.x - 20, - layout->barY + (layout->barHeight - timeFontSize) / 2.0f }, - timeFontSize, timeSpacing, ui->textColor); - - char remainBuf[16]; - player_format_time((float)remainSec, remainBuf, sizeof(remainBuf)); - float rightX = layout->barX + layout->barWidth + 20; - DrawTextEx(ui->fontSmall, remainBuf, - (Vector2){ rightX, layout->barY + (layout->barHeight - timeFontSize) / 2.0f }, - timeFontSize, timeSpacing, ui->textColor); - - /* Percent centered above progress bar */ - char pctBuf[16]; - int pct = (int)(progress * 100.0f); - snprintf(pctBuf, sizeof(pctBuf), "%d%%", pct); - bool inSilence = (study_find_silence_at(state, progress) >= 0); - draw_text_centered(ui->fontSmall, pctBuf, - layout->barX + layout->barWidth / 2.0f, - layout->barY - timeFontSize - 10, timeFontSize, ui->textColor); - - /* Playback status */ - Color statusColor = (state->playing && inSilence) - ? (Color){ 160, 40, 55, 255 } : ui->accentColor; - draw_text_centered(ui->font, state->playing ? "PLAYING" : "PAUSED", - layout->statusX, layout->statusY, ui->szFont, statusColor); - - /* Buttons */ - Vector2 mousePos = GetMousePosition(); - - /* Play/pause button */ - Color playBtnColor = (state->playing && inSilence) - ? (Color){ 160, 40, 55, 255 } : ui->accentColor; - float ppx = layout->btnCenterX; - float pdx = mousePos.x - ppx, pdy = mousePos.y - layout->btnY; - bool hoverPP = (pdx*pdx + pdy*pdy) <= ((layout->btnRadius+5)*(layout->btnRadius+5)); - DrawCircle((int)ppx, (int)layout->btnY, layout->btnRadius + 8, playBtnColor); - if (hoverPP) DrawCircle((int)ppx, (int)layout->btnY, layout->btnRadius + 8, ui->btnHoverColor); - if (state->playing) - draw_pause_icon(ppx, layout->btnY, 50, ui->textColor); - else - draw_play_icon(ppx, layout->btnY, 50, ui->textColor); - - /* Speaking portion counter with prev/next section buttons */ - { - int portion = study_current_speaking_portion(state, progress) + 1; - int total = study_total_speaking_portions(state); - if (portion > total) portion = total; - char portionBuf[32]; - snprintf(portionBuf, sizeof(portionBuf), "%d/%d", portion, total); - float secBtnRadius = 35.0f; - float portionY = layout->secNavY - ui->szSmall - secBtnRadius - 10.0f; - float portionSpacing = ui->szSmall * 0.03f; - Vector2 portionSize = MeasureTextEx(ui->fontSmall, portionBuf, ui->szSmall, portionSpacing); - float portionX = layout->secNavX - portionSize.x / 2.0f; - DrawTextEx(ui->fontSmall, portionBuf, - (Vector2){ portionX, portionY }, - ui->szSmall, portionSpacing, ui->mutedColor); - - /* Section nav buttons */ - float secPrevX = layout->secNavX - 65.0f; - float secNextX = layout->secNavX + 65.0f; - float secBtnY_draw = layout->secNavY; - - float sd3 = mousePos.x - secPrevX, sd4 = mousePos.y - secBtnY_draw; - bool hoverSecPrev = (sd3*sd3 + sd4*sd4) <= (secBtnRadius*secBtnRadius); - DrawCircle((int)secPrevX, (int)secBtnY_draw, secBtnRadius, (Color){ 50, 50, 70, 255 }); - if (hoverSecPrev) DrawCircle((int)secPrevX, (int)secBtnY_draw, secBtnRadius, ui->btnHoverColor); - draw_seek_back_icon(secPrevX, secBtnY_draw, 30, ui->textColor); - - float sd5 = mousePos.x - secNextX, sd6 = mousePos.y - secBtnY_draw; - bool hoverSecNext = (sd5*sd5 + sd6*sd6) <= (secBtnRadius*secBtnRadius); - DrawCircle((int)secNextX, (int)secBtnY_draw, secBtnRadius, (Color){ 50, 50, 70, 255 }); - if (hoverSecNext) DrawCircle((int)secNextX, (int)secBtnY_draw, secBtnRadius, ui->btnHoverColor); - draw_seek_fwd_icon(secNextX, secBtnY_draw, 30, ui->textColor); - } - - /* --- Smart play hold button rendering --- */ - { - Rectangle smartBtn = { layout->smartPlayX, layout->smartPlayY, 200.0f, 80.0f }; - Color btnFill = ui->smartPlayHeld ? (Color){ 80, 30, 50, 220 } : (Color){ 50, 50, 70, 180 }; - Color btnBorder = ui->smartPlayHeld ? ui->accentColor : ui->mutedColor; - Color btnTextColor = ui->smartPlayHeld ? ui->accentColor : ui->textColor; - DrawRectangleRounded(smartBtn, 0.3f, 8, btnFill); - DrawRectangleRoundedLines(smartBtn, 0.3f, 8, btnBorder); - float btnSpacing = ui->szSmall * 0.03f; - Vector2 btnSize = MeasureTextEx(ui->fontSmall, "Play", ui->szSmall, btnSpacing); - float tx = smartBtn.x + (smartBtn.width - btnSize.x) / 2.0f; - float ty = smartBtn.y + (smartBtn.height - ui->szSmall) / 2.0f; - DrawTextEx(ui->fontSmall, "Play", (Vector2){ tx, ty }, ui->szSmall, btnSpacing, btnTextColor); - } -} - -void ui_render_empty(const UIState *ui, const UILayout *layout) -{ - draw_text_centered(ui->fontLarge, "Study Player", - layout->titleX, layout->titleY, ui->szLarge, ui->textColor); -#ifdef PLATFORM_WEB - draw_text_centered(ui->fontMed, "Use Load MP3 button above", - (float)SCREEN_W / 2.0f, SCREEN_H / 2.0f - 20, ui->szMed, ui->mutedColor); -#else - draw_text_centered(ui->fontMed, "Drag an MP3 file here", - (float)SCREEN_W / 2.0f, SCREEN_H / 2.0f - 20, ui->szMed, ui->mutedColor); -#endif -} - -void ui_render_overlay(const UIState *ui, const PlayerState *state, - const UILayout *layout) -{ - /* --- Help text --- */ - float helpSpacing = ui->szHelp * 0.03f; - DrawTextEx(ui->fontHelp, - "C: pause N: play Space(hold): override V/B: prev/next Arrows: seek 0-9: jump", - (Vector2){ layout->helpX, layout->helpY }, - ui->szHelp, helpSpacing, ui->mutedColor); - - /* --- Study mode checkbox --- */ - const char *label = "Study Mode"; - float cbSize = 30.0f; - Vector2 labelSize = MeasureTextEx(ui->fontHelp, label, ui->szHelp, helpSpacing); - float totalW = cbSize + 10 + labelSize.x; - float cbX = SCREEN_W - totalW - layout->helpX; - float cbY = layout->helpY + (ui->szHelp - cbSize) / 2.0f; - - Rectangle cbRect = { cbX, cbY, cbSize, cbSize }; - DrawRectangleLinesEx(cbRect, 2, ui->mutedColor); - if (state->studyMode) - DrawRectangleRec((Rectangle){ cbX + 6, cbY + 6, cbSize - 12, cbSize - 12 }, ui->accentColor); - DrawTextEx(ui->fontHelp, label, - (Vector2){ cbX + cbSize + 10, layout->helpY }, - ui->szHelp, helpSpacing, ui->mutedColor); -} diff --git a/src/ui.h b/src/ui.h deleted file mode 100644 index d6d0b24..0000000 --- a/src/ui.h +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once - -/* ui.h — rendering and input contract. - * - * Owns: font/color initialization, all drawing, all mouse/keyboard input - * for the player tab, the smart-play hold button, study-mode checkbox. - * Does NOT own: tab bar (drawn by main via raygui), layout editor tab, - * drag-drop file loading (main), config save on tab switch (main). */ - -#include "types.h" -#include "config.h" - -/* ------------------------------------------------------------------ */ -/* UI state — fonts, colors, interaction flags. */ -/* ------------------------------------------------------------------ */ - -typedef struct { - /* Fonts (loaded in ui_init, unloaded in ui_destroy) */ - Font fontSmall, font, fontMed, fontLarge, fontHelp; - float szSmall, szHelp, szFont, szMed, szLarge; - - /* Colors */ - Color bgColor; - Color textColor; - Color accentColor; - Color mutedColor; - Color barBgColor; - Color btnHoverColor; - - /* Interaction state */ - bool smartPlayHeld; -} UIState; - -/* ------------------------------------------------------------------ */ -/* Lifecycle */ -/* ------------------------------------------------------------------ */ - -/* Load fonts (embedded or default) and set colors. Call after InitWindow. */ -void ui_init(UIState *ui); - -/* Unload fonts (embedded only). Call before CloseWindow. */ -void ui_destroy(UIState *ui); - -/* ------------------------------------------------------------------ */ -/* Input — call once per frame BEFORE player_update, only on tab 0. */ -/* Processes button clicks, keyboard, click-to-seek, smart-play hold, */ -/* study-mode checkbox toggle. Mutates state and ui->smartPlayHeld. */ -/* ------------------------------------------------------------------ */ - -void ui_handle_input(UIState *ui, PlayerState *state, const UILayout *layout); - -/* ------------------------------------------------------------------ */ -/* Rendering — call inside BeginDrawing/EndDrawing, only on tab 0. */ -/* ------------------------------------------------------------------ */ - -/* Draw the player UI (filename, progress bar, buttons, help, checkbox). - * Call when state->loaded is true. */ -void ui_render_player(const UIState *ui, const PlayerState *state, - const UILayout *layout); - -/* Draw the "no file loaded" splash screen. */ -void ui_render_empty(const UIState *ui, const UILayout *layout); - -/* Draw the help text and study-mode checkbox (always visible on tab 0). - * Call after ui_render_player or ui_render_empty. */ -void ui_render_overlay(const UIState *ui, const PlayerState *state, - const UILayout *layout); diff --git a/study-player.cfg b/study-player.cfg deleted file mode 100644 index 3d221cc..0000000 --- a/study-player.cfg +++ /dev/null @@ -1,16 +0,0 @@ -# Study Player layout config -title_y=60.00 -title_x=960.00 -bar_y=460.00 -bar_height=50.00 -bar_width=1248.00 -btn_radius=55.00 -help_y=1000.00 -help_x=40.00 -status_x=960.00 -btn_y=715.00 -btn_center_x=960.00 -smart_play_y=868.00 -smart_play_x=1611.00 -sec_nav_y=745.00 -sec_nav_x=1713.00 diff --git a/tasks.md b/tasks.md deleted file mode 100644 index bb351bd..0000000 --- a/tasks.md +++ /dev/null @@ -1,44 +0,0 @@ -# tasks.md — live progress checklist - -> Updated by the orchestrator after each milestone. One line per completed wave. - ---- - -## Module split (COMPLETE) - -The single-file `src/main.c` (778 lines) has been decomposed into 7 modules -with separate headers for code isolation and parallel agent development. - -- [x] WAVE 0 — Orchestrator: pre-author all `.h` contracts - - [x] `src/types.h` — PlayerState, SilenceRegion, UILayout, constants - - [x] `src/player.h` — audio playback contract - - [x] `src/study.h` — study mode contract - - [x] `src/ui.h` — rendering + input contract (UIState) - - [x] `src/config.h` — updated to include types.h (UILayout moved) -- [x] WAVE 1 — All `.c` implementations - - [x] `src/player.c` — load/play/pause/seek/update/format_time - - [x] `src/study.c` — detect_silence, portion nav, auto-pause check - - [x] `src/ui.c` — fonts, colors, drawing, icons, input, smart play - - [x] `src/main.c` — thin composition root (main loop, tabs, drag-drop) -- [x] WAVE 2 — Integration fixes - - [x] Fixed multiple-definition link error (font_data.h single-include rule) -- [x] Post-milestone verification - - [x] `make clean && make -j$(nproc)` exits 0, zero warnings (Linux) - - [x] Makefile auto-discovers new `.c` files via `$(wildcard src/*.c)` - - [x] All state passed by pointer — no global mutable variables - - [x] `font_data.h` included in exactly one `.c` file (ui.c) - -## Branch structure - -- **V1** branch — preserves the pre-refactor state (single-file main.c) -- **dev** branch — the refactored modular codebase (current) - ---- - -## Open items (future) - -- [ ] `make windows -j$(nproc)` cross-compile verification (needs MinGW) -- [ ] `bin/build-web` web build verification (needs emscripten) -- [ ] Unit tests for study module (portion navigation edge cases) -- [ ] Volume control -- [ ] Playlist support diff --git a/tools/SCREENSHOT.md b/tools/SCREENSHOT.md new file mode 100644 index 0000000..d3dacad --- /dev/null +++ b/tools/SCREENSHOT.md @@ -0,0 +1,163 @@ +# Screenshot capture (`bin/screenshot`) + +Capture a game frame to a PNG for visual debugging — shader bugs, rendering +glitches, layout, AA quality. Generic and parameterized: any game script + any +output path. Two capture targets: + +- **`--target web`** (default) — headless Chromium screenshots the relay-served + web game. **The working path under WSL** (no display needed). The web build runs + the SAME shaders (SMAA/FXAA/CRT) as desktop. +- **`--target desktop`** — raylib's own `TakeScreenshot` framebuffer capture of + the desktop binary. Pixel-exact and noise-free, but needs a driven display + (see [WSLg limitation](#wslg-limitation-desktop-target) below). + +## Quick start (web — works here) + +```sh +# 0) one-time setup: install puppeteer (downloads ~180MB headless Chromium) +npm install + +# 1) ensure the web build exists + the relay is serving it +EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh # only after C/C++/mrblib edits +node tools/agent-bridge/server.js & # serves http://localhost:8080 +# (the game running is whatever web/shell.html → Module.arguments points at, +# e.g. game/fx_demo.rb — the --target=web game-script arg is just a label) + +# 2) capture +bin/screenshot game/fx_demo.rb /tmp/shot.png +# → ✓ captured: /tmp/shot.png (PNG image data, 1280 x 720, 8-bit/color RGB) +``` + +Put outputs in `/tmp/` or `.live/` (gitignored). **Do not commit PNGs** — the repo +`.gitignore` enforces `*.png`. + +## Usage + +``` +bin/screenshot <game-script.rb> <output.png> [options] +``` + +| Option | Default | Notes | +|--------|---------|-------| +| `--target web\|desktop` | `web` | capture target | +| `--frames N` | `3000` (web, ms) / `30` (desktop, frames) | settle time before capture | +| `--win WxH` | `1280x720` web / `720x720` desktop | viewport size | +| `--timeout SECS` | `30` | hard kill timeout | +| `--url URL` | `http://localhost:8080/game.html` | web: relay URL | +| `--browser chrome\|puppeteer` | auto (chrome if present, else puppeteer) | web: capture backend | +| `--delay SECS` | `0` | desktop: extra wall-clock wait before capture | +| `--keep-running` | off | desktop: don't exit the game after capture | +| `--game-bin PATH` | `zig-out/bin/game` | desktop: game binary | +| `--ffmpeg` | off | desktop: force the `ffmpeg -f x11grab` fallback | + +Exit 0 + prints the absolute PNG path on success. The PNG is validated +(`file(1)` checks the PNG signature + dimensions); a blank/invalid capture is a +non-zero exit. + +### Examples + +```sh +# web, give shaders longer to settle (heavy SMAA/FXAA scene) +bin/screenshot game/fx_demo.rb /tmp/fx.png --frames 5000 + +# web, larger viewport +bin/screenshot game/fx_demo.rb /tmp/fx.png --win 1920x1080 + +# use a system google-chrome/chromium if installed (no puppeteer needed) +bin/screenshot game/fx_demo.rb /tmp/fx.png --browser chrome + +# desktop, raylib framebuffer capture (needs a real display) +bin/screenshot game/fx_demo.rb /tmp/fx.png --target desktop --frames 45 + +# a different relay port +bin/screenshot game/main.rb /tmp/main.png --url http://localhost:8090/game.html +``` + +## How each target works + +### `--target web` (default) + +`bin/screenshot` opens `http://localhost:8080/game.html` in a **headless +Chromium**, waits for the emscripten boot → mruby → game script → WebGL first +frame to composite (default 3000ms; raise `--frames` for heavier scenes), then +screenshots the viewport to a PNG. + +Two backends, tried in order unless `--browser` pins one: + +1. **system chrome** — `google-chrome`/`chromium --headless=new --screenshot`, + with `--virtual-time-budget` + `--run-all-compositor-stages-before-draw` so + the WebGL canvas renders before the shot. Use if a system Chrome is installed + (no download needed). +2. **puppeteer** (the default here, no system Chrome) — `tools/web_screenshot.js` + drives a project-local headless Chromium. Software GL via SwiftShader + (`--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader`) so it + renders without a GPU. Page-console errors, real resource 404s, and + request failures are echoed to stderr (the relay's `/jamstack/*` poll noise + and `favicon.ico` 404 are filtered — they're expected). + +**Prerequisites:** the relay must be serving the page (`curl -s localhost:8080/game.html +| head -1` should return HTML) and puppeteer installed once (`npm install`). + +### `--target desktop` + +Runs `zig-out/bin/game <game-script>` with the `JAMSTACK_SCREENSHOT` env hook +(below). After `JAMSTACK_SCREENSHOT_FRAMES` frames the game calls +`Rl.take_screenshot(path)` and exits cleanly. This is **pixel-exact** (reads the +framebuffer, no desktop chrome) — best for shader debugging. Fallback: +`ffmpeg -f x11grab` grabs the X root window (includes desktop chrome) if the +raylib path produces no PNG, or with `--ffmpeg`. + +**The env hook** lives in `mrbgems/raylib/mrblib/raylib.rb` → `Rl.while_window_open` +(the single platform seam), desktop branch only. It makes **any** game script +screenshot-capable with zero per-script changes: + +| Env var | Default | Meaning | +|---------|---------|---------| +| `JAMSTACK_SCREENSHOT=<path>` | unset | enables screenshot mode; PNG written to `<path>` | +| `JAMSTACK_SCREENSHOT_FRAMES=<n>` | `30` | frames to render before capture (let shaders/physics settle) | +| `JAMSTACK_SCREENSHOT_DELAY=<secs>` | `0` | extra wall-clock wait before capture (async/asset settle) | +| `JAMSTACK_SCREENSHOT_ONCE=<0\|1>` | `1` | `1` exit after capture; `0` keep running (--keep-running) | + +The capture fires right after the game block returns (the frame is fully drawn +and swapped), then `break`s the loop + `close_window` for a clean exit. + +You can also drive it directly (no `bin/screenshot`): +```sh +JAMSTACK_SCREENSHOT=/tmp/shot.png JAMSTACK_SCREENSHOT_FRAMES=40 \ + DISPLAY=:0 ./zig-out/bin/game game/fx_demo.rb +``` + +For **manual** capture at a specific game state (not "frame N"), see +`tools/screenshot_mode.rb` — `Jamstack::Screenshot.capture(path)` (gated by +`JAMSTACK_SCREENSHOT_MANUAL=1`, so committed calls stay inert). + +## WSLg limitation (`--target desktop`) + +The desktop target needs a **driven display** — a real, interactive window. In +this WSL2/WSLg dev environment, running the desktop binary from a non-interactive +shell **stalls** (verified): + +- raylib is built **Wayland-only** (`GLFW_LINUX_ENABLE_WAYLAND=TRUE + GLFW_LINUX_ENABLE_X11=FALSE`; WSLg's X11/GLX path segfaults inside Mesa — see + `.agents/knowledge/environment.md`). With `XDG_RUNTIME_DIR=/mnt/wslg/runtime-dir + WAYLAND_DISPLAY=wayland-0`, `InitWindow` connects to Wayland then **blocks in + `do_sys_poll`** (0% CPU, state `S`) — the compositor doesn't drive a + non-interactive window, so the framebuffer never renders and no PNG is produced. +- The X11 path (`DISPLAY=:0`) initializes but **segfaults at GL/FBO setup** + (the known Mesa `dri2GalliumConfigQueryb` crash); software GL (`LIBGL_ALWAYS_SOFTWARE`) + doesn't help because the X11 backend isn't compiled in. + +So `--target desktop` here will hit its 30s timeout and fall back to `ffmpeg` +(which also can't grab an unrendered window). **Use `--target web` in this +environment.** The desktop path is correct and works on a machine with a real +interactive display (or an interactive WSLg session where the window is +foregrounded); it's left in for that case and for CI on real GPUs. + +## Files + +- `bin/screenshot` — the wrapper (both targets). +- `tools/web_screenshot.js` — puppeteer headless-Chromium capture (web backend). +- `mrbgems/raylib/mrblib/raylib.rb` — the `JAMSTACK_SCREENSHOT` env hook in + `Rl.while_window_open` (desktop backend). +- `tools/screenshot_mode.rb` — optional in-script manual capture helper. +- `package.json` — declares `puppeteer` as a devDependency (one-time `npm install`). diff --git a/tools/agent-bridge/server.js b/tools/agent-bridge/server.js new file mode 100644 index 0000000..76abf85 --- /dev/null +++ b/tools/agent-bridge/server.js @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/* Jamstack web relay (W2 / R4b) — dependency-free Node HTTP server. + * + * Gives a browser game the SAME .live/<token>/ interface as desktop, so the + * existing bin/eval, snapshot, query, tail-log, hot-reload work against a + * browser tab: + * - serves build/web/ (same-origin -> no CORS, no ws dep) and injects + * <script src="/agent-bridge.js"> into game.html on the fly (no rebuild), + * - bridges .live/<token>/.agent/cmd-*.rb <-> the browser: + * GET /jamstack/poll -> next {id, code} (relay reads+deletes cmd file) + * POST /jamstack/result -> writes result-<id>.json + * POST /jamstack/console -> appends a line to game-console + * POST /jamstack/status -> writes status.json + * GET /jamstack/snapshot -> relayEval flecs /world -> state.json + JSON + * GET /jamstack/query -> relayEval flecs /query?expr=... -> JSON + * - writes 5 bin/ scripts (eval, snapshot, query, tail-log, hot-reload) + * + * Usage: node tools/agent-bridge/server.js (then open http://localhost:8080) + * Env: JAMSTACK_RELAY_PORT (8080), JAMSTACK_LIVE token (web). Dev-only, localhost. + */ +'use strict'; +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const WEB = path.join(ROOT, 'build', 'web'); +const TOKEN = process.env.JAMSTACK_LIVE || 'web'; +const PORT = parseInt(process.env.JAMSTACK_RELAY_PORT || '8080', 10); +const LIVE = path.join(ROOT, '.live', TOKEN); +const AGENT = path.join(LIVE, '.agent'); +const BIN = path.join(LIVE, 'bin'); + +const MIME = { + '.html': 'text/html', '.js': 'text/javascript', '.wasm': 'application/wasm', + '.data': 'application/octet-stream', '.json': 'application/json', + '.css': 'text/css', '.png': 'image/png', '.ttf': 'font/ttf', +}; + +function writeAtomic(p, s) { const t = p + '.tmp'; fs.writeFileSync(t, s); fs.renameSync(t, p); } +function writeStatus(o) { writeAtomic(path.join(LIVE, 'status.json'), JSON.stringify(o)); } + +function writeBinScripts() { + const evalSh = '#!/bin/sh\n' + + '# Run Ruby in the live browser game; prints the JSON result envelope.\n' + + 'ag="$(cd "$(dirname "$0")/.." && pwd)/.agent"\n' + + 'id="p$$_$(date +%s%N 2>/dev/null || date +%s)"\n' + + 'printf \'%s\' "$1" > "$ag/.tmp-$id"\n' + + 'mv "$ag/.tmp-$id" "$ag/cmd-$id.rb"\n' + + 'i=0\n' + + 'while [ $i -lt 250 ]; do\n' + + ' if [ -f "$ag/result-$id.json" ]; then cat "$ag/result-$id.json"; echo; rm -f "$ag/result-$id.json"; exit 0; fi\n' + + ' i=$((i + 1)); sleep 0.02\n' + + 'done\n' + + 'echo \'{"ok":false,"error":"timeout (is the tab open + connected to the relay?)"}\' >&2; exit 1\n'; + const tailSh = '#!/bin/sh\n' + + 'd="$(cd "$(dirname "$0")/.." && pwd)"\n' + + 'exec tail -n "${1:-40}" -f "$d/game-console"\n'; + const hotSh = '#!/bin/sh\n' + + 'exec "$(dirname "$0")/eval" "Flecs::Hot.reload_file(\\"$1\\")"\n'; + const snapSh = '#!/bin/sh\n' + + '# Dump flecs world state to state.json (host filesystem, not MEMFS).\n' + + 'd="$(cd "$(dirname "$0")/.." && pwd)"\n' + + '"$d/bin/eval" \'Flecs::Hot.world.rest_request("GET","/world","")\' | \\\n' + + 'node -e \'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{try{const e=JSON.parse(d.trim());if(e.ok&&e.result){require("fs").writeFileSync(process.argv[1]+"/state.json",e.result);process.stdout.write(e.result)}else{process.stderr.write("snapshot failed: "+(e.error||"unknown")+"\\n");process.exit(1)}}catch(x){process.stderr.write(x.message+"\\n");process.exit(1)}})\' "$d"\n'; const querySh = '#!/bin/sh\n' + + '# Query flecs entities; prints REST JSON.\n' + + 'exec "$(dirname "$0")/eval" "Flecs::Hot.world.rest_request(\\"GET\\",\\"/query?expr=$1&values=true\\",\\"\\")"\n'; + const scripts = { 'eval': evalSh, 'tail-log': tailSh, 'hot-reload': hotSh, 'snapshot': snapSh, 'query': querySh }; + for (const [name, content] of Object.entries(scripts)) { + const p = path.join(BIN, name); + fs.writeFileSync(p, content); + fs.chmodSync(p, 0o755); + } +} + +function cleanAgent() { + try { + for (const f of fs.readdirSync(AGENT)) { + if (f.startsWith('cmd-') || f.startsWith('result-') || f.startsWith('.tmp')) { + try { fs.unlinkSync(path.join(AGENT, f)); } catch (e) {} + } + } + } catch (e) {} +} + +fs.mkdirSync(AGENT, { recursive: true }); +fs.mkdirSync(BIN, { recursive: true }); +writeBinScripts(); +cleanAgent(); +writeStatus({ connected: false, target: 'web', token: TOKEN, ts: Date.now() / 1000 }); + +function readBody(req, cb) { + let b = ''; + req.on('data', (c) => { b += c; if (b.length > 4e6) req.destroy(); }); + req.on('end', () => cb(b)); +} + +function handlePoll(res) { + let names = []; + try { names = fs.readdirSync(AGENT).filter((n) => n.startsWith('cmd-')).sort(); } catch (e) {} + if (names.length === 0) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end('{}'); } + const f = names[0]; + let id = f.slice(4); if (id.endsWith('.rb')) id = id.slice(0, -3); + let code = ''; + try { code = fs.readFileSync(path.join(AGENT, f), 'utf8'); } catch (e) {} + try { fs.unlinkSync(path.join(AGENT, f)); } catch (e) {} + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: id, code: code })); +} + +/* Synchronous eval is NOT possible in the relay (single-threaded Node: a + busy-wait would block the /jamstack/poll endpoint the browser needs to fetch + the command). Instead, bin/snapshot pipes bin/eval through node to extract + the result field and write it to state.json. See writeBinScripts. */ + +function serveStatic(res, p) { + const rel = (p === '/' ? '/game.html' : p).replace(/\?.*$/, ''); + const full = path.normalize(path.join(WEB, rel)); + if (!full.startsWith(WEB)) { res.writeHead(403); return res.end('forbidden'); } + fs.readFile(full, (err, data) => { + if (err) { res.writeHead(404); return res.end('not found'); } + const ext = path.extname(full); + let body = data; + if (ext === '.html') { + body = Buffer.from(data.toString().replace( + '</body>', ' <script src="/agent-bridge.js"></script>\n</body>')); + } + // no-store: the wasm/js are rebuilt often during dev; without this the + // browser caches game.wasm aggressively (Emscripten's fetch is cached + // separately from the HTML, so a hard-refresh doesn't bust it) and serves a + // stale build -- making code changes appear to have "no effect". + res.writeHead(200, { + 'Content-Type': MIME[ext] || 'application/octet-stream', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + }); + res.end(body); + }); +} + +const server = http.createServer((req, res) => { + const p = req.url.replace(/\?.*$/, ''); + if (p === '/jamstack/poll') return handlePoll(res); + if (p === '/jamstack/result') return readBody(req, (b) => { + try { const o = JSON.parse(b || '{}'); const r = (o.result == null) ? '{}' : (typeof o.result === 'string' ? o.result : JSON.stringify(o.result)); + writeAtomic(path.join(AGENT, 'result-' + o.id + '.json'), r); } catch (e) {} + res.writeHead(204); res.end(); + }); + if (p === '/jamstack/console') return readBody(req, (b) => { + try { const o = JSON.parse(b || '{}'); if (o.line != null) fs.appendFileSync(path.join(LIVE, 'game-console'), String(o.line) + '\n'); } catch (e) {} + res.writeHead(204); res.end(); + }); + if (p === '/jamstack/status') return readBody(req, (b) => { + try { const o = JSON.parse(b || '{}'); writeStatus(Object.assign({ connected: true, target: 'web', token: TOKEN }, o)); } catch (e) {} + res.writeHead(204); res.end(); + }); + if (p === '/agent-bridge.js') { + return fs.readFile(path.join(ROOT, 'web', 'agent-bridge.js'), (err, data) => { + if (err) { res.writeHead(404); return res.end(); } + res.writeHead(200, { 'Content-Type': 'text/javascript' }); res.end(data); + }); + } + return serveStatic(res, p); +}); + +server.listen(PORT, '0.0.0.0', () => { + console.log('jamstack relay: http://localhost:' + PORT + ' -> ' + WEB); + console.log(' .live mount: ' + LIVE); + console.log(' bin: eval snapshot query tail-log hot-reload'); + console.log(' open the URL in a browser, then: sh ' + path.join(BIN, 'eval') + " 'Rl.get_fps'"); +}); diff --git a/tools/check-types.sh b/tools/check-types.sh new file mode 100755 index 0000000..907616c --- /dev/null +++ b/tools/check-types.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Type-check gate for raylib-jamstack: RBS signature consistency + Steep type check. +# +# rbs validate — sig/*.rbs are internally consistent (catches duplicate +# methods, bad types, broken aliases). HARD FAIL. +# steep check — the Steep project loads cleanly + no :error-level issues. +# +# Under the Steepfile's `D::Ruby.lenient` config, game-code type typos are +# :information — visible in the editor (live LSP) and via +# `steep check --severity-level=information`, but NOT a CI failure here. So this +# gate fails only on real signature/structural breakage and is GREEN on correct +# code. (Editor = live type guidance; this script = sig/structural integrity.) +# +# Self-contained: sets the gem env (rbs + steep live in the user gem dir, like +# opencode.json's ruby-lsp/steep env). Run from the repo root. +set -euo pipefail + +GEM_BIN="${GEM_BIN:-$HOME/.local/share/gem/ruby/3.4.0/bin}" +export PATH="$GEM_BIN:$PATH" +command -v rbs >/dev/null 2>&1 || { echo "rbs not found — install: gem install rbs" >&2; exit 1; } +command -v steep >/dev/null 2>&1 || { echo "steep not found — install: gem install steep" >&2; exit 1; } + +echo "== rbs validate ==" +rbs validate +echo "== steep check ==" +steep check +echo "OK: signatures valid + steep project loads clean." diff --git a/tools/gen_compile_commands.rb b/tools/gen_compile_commands.rb new file mode 100644 index 0000000..391e65b --- /dev/null +++ b/tools/gen_compile_commands.rb @@ -0,0 +1,58 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +require "json" + +# Generate compile_commands.json for clangd. +# +# The raylib-jamstack build is Zig-orchestrated (build.zig) + mruby rake +# (build_config.rb), neither of which emits compile_commands.json. clangd needs +# it (or it falls back to the file's own dir as the compile directory, which +# breaks the relative `-Ivendor/...` include roots — they'd resolve against the +# file's dir instead of the project root). This generator emits one entry per +# C/C++ source with `directory` = the project root, so the relative include +# paths resolve correctly. +# +# Run after a fresh clone / when source files are added: +# ruby tools/gen_compile_commands.rb +# (rebuild.sh regenerates it automatically; see .agents/knowledge/ruby-lsp.md.) +# +# Output (compile_commands.json, gitignored — it holds absolute paths) targets +# the DESKTOP (gcc/clang) build. Web-only headers (<emscripten.h>) are guarded +# by #ifdef __EMSCRIPTEN__, which we do NOT define here, so clangd skips them. + +ROOT = File.expand_path("..", __dir__) +Dir.chdir(ROOT) + +INCLUDES = %w[ + vendor/mruby/include + vendor/raylib/src + vendor/rmlui/Include + vendor/flecs/distr + vendor/joltc/include +].freeze + +SOURCES = Dir.glob(["mrbgems/*/src/*.{c,cpp}", "src/*.{c,cpp}"]). + # Skip the generated raylib bindings only if you don't want to index them; + # we DO index raylib_gen.c so definition jumps into the generated surface work. + sort + +entries = SOURCES.map do |src| + cpp = src.end_with?(".cpp") + args = [ + cpp ? "clang++" : "clang", + cpp ? "-std=c++17" : "-std=c11", + "-DMRB_INT64", # forced by build_config.rb on every target + "-fsyntax-only", + *INCLUDES.flat_map { |i| ["-I", i] }, + src, + ] + { + "directory" => ROOT, + "file" => src, + "arguments" => args, + } +end + +out = File.join(ROOT, "compile_commands.json") +File.write(out, JSON.pretty_generate(entries) + "\n") +puts "wrote #{out} (#{entries.size} entries)" diff --git a/tools/screenshot_mode.rb b/tools/screenshot_mode.rb new file mode 100644 index 0000000..b43dcb3 --- /dev/null +++ b/tools/screenshot_mode.rb @@ -0,0 +1,70 @@ +# tools/screenshot_mode.rb — OPTIONAL in-script screenshot helpers. +# +# The PRIMARY, generic capture path is the env hook in Rl.while_window_open +# (gated by JAMSTACK_SCREENSHOT=<path>) + the bin/screenshot wrapper. That works +# on ANY game script with zero changes — see tools/SCREENSHOT.md. +# +# This file is for the case where you want MANUAL control over WHEN the frame is +# captured: e.g. capture at a specific game-state event (after a physics step +# settles, when a shader parameter hits a value, N frames after input). Require +# it from your game script and call Jamstack::Screenshot.capture(path) inside +# your Rl.while_window_open block at the moment you choose: +# +# require_relative "../tools/screenshot_mode" +# ... +# Rl.while_window_open do +# ... draw ... +# Jamstack::Screenshot.capture("/tmp/scene.png") if some_condition +# end +# +# capture() is a no-op unless JAMSTACK_SCREENSHOT_MANUAL=1 is set, so it is safe +# to leave committed in a game script — it only fires when you opt in. This keeps +# the env hook (auto-exit) and manual capture from both running. +# +# NOTE: take_screenshot reads the framebuffer AFTER the current frame's drawing +# is complete, so call capture() at the END of your block (after Rl.draw {}). +# On desktop the PNG is written synchronously by raylib. Do NOT commit PNGs. + +module Jamstack + module Screenshot + class << self + # True only when manual screenshot mode is opted in via the env var. + # Keeps committed `capture` calls inert in normal runs. + def enabled? = !::Jamstack.getenv('JAMSTACK_SCREENSHOT_MANUAL').nil? + + # Write a pixel-exact PNG of the current framebuffer to +path+. + # No-op unless JAMSTACK_SCREENSHOT_MANUAL=1. Returns true if written. + def capture(path) + return false unless enabled? + ::Rl.take_screenshot(path) + ::Jamstack::Log.info("screenshot(manual) -> #{path}") rescue nil + true + end + + # Count frames and capture once +path+ after +frames+ frames have elapsed + # since the first call. Useful inside the loop: pass the same path each + # frame; it fires exactly once then stops. Returns true on the capturing + # frame, nil otherwise. No-op unless enabled?. + def capture_after(frames, path) + return nil unless enabled? + @counters ||= {} + n = (@counters[path] ||= 0) + 1 + @counters[path] = n + if n == frames + capture(path) + else + nil + end + end + + # Reset the per-path frame counter (e.g. to re-capture the same path). + def reset(path = nil) + if path + @counters&.delete(path) + else + @counters = {} + end + end + end + end +end diff --git a/tools/web_screenshot.js b/tools/web_screenshot.js new file mode 100644 index 0000000..488a781 --- /dev/null +++ b/tools/web_screenshot.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/* tools/web_screenshot.js — capture a frame of the web game via headless Chromium. + * + * Used by bin/screenshot --target=web as the headless-browser capture path (no + * system chrome needed: puppeteer downloads a Chromium into the project cache). + * + * Usage: + * node tools/web_screenshot.js <url> <out.png> [waitMs] [width] [height] + * + * Loads <url> in headless Chromium at <width>x<height>, waits <waitMs> (default + * 2500ms) for the wasm boot + WebGL + a couple of shader frames, then captures a + * full-page PNG to <out.png>. Prints the output path on success, exits non-zero + * on failure. + * + * The page needs a moment: emscripten boots mruby -> game/fx_demo.rb runs the FX + * pipeline (SMAA/FXAA/CRT shaders) -> at least one frame composites. 2.5s is a + * safe floor; raise via the 3rd arg for heavier scenes. + * + * Chromium in headless mode needs --no-sandbox under many CI/root contexts, and + * --use-gl=swiftshader / --enable-unsafe-swiftshader so WebGL renders without a + * GPU (WSL/headless). Without software GL the canvas stays blank (no GPU). + */ +'use strict'; + +const url = process.argv[2]; +const out = process.argv[3]; +const wait = parseInt(process.argv[4] || '2500', 10); +const width = parseInt(process.argv[5] || '1280', 10); +const height = parseInt(process.argv[6] || '720', 10); + +if (!url || !out) { + console.error('usage: node tools/web_screenshot.js <url> <out.png> [waitMs] [w] [h]'); + process.exit(2); +} + +(async () => { + let browser; + try { + const puppeteer = require('puppeteer'); + browser = await puppeteer.launch({ + headless: 'new', + args: [ + `--window-size=${width},${height}`, + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + '--disable-gpu-sandbox', + ], + }); + const page = await browser.newPage(); + await page.setViewport({ width, height, deviceScaleFactor: 1 }); + // Capture console + pageerrors for diagnostics (routed to stderr only). + // Diagnostics -> stderr. The relay's agent-bridge.js polls /jamstack/console + // and /jamstack/status every frame; those requests get ERR_ABORTED when the + // page tears down at screenshot time and are NOT errors -> filtered out. + // favicon.ico 404 is also expected (the relay serves none) -> filtered. + const noise = (u) => + /\/jamstack\/(console|status|poll)/.test(u) || /favicon\.ico/.test(u); + page.on('pageerror', e => console.error('[pageerror]', e.message)); + page.on('response', r => { if (r.status() >= 400 && !noise(r.url())) console.error('[http]', r.status(), r.url()); }); + page.on('requestfailed', r => { if (!noise(r.url())) console.error('[reqfail]', r.url(), r.failure()?.errorText); }); + + // CRITICAL: disable the browser HTTP cache. Puppeteer/headless Chromium will + // otherwise serve a STALE game.wasm/game.js/game.data across rebuilds, which + // makes two captures of DIFFERENT builds come out byte-identical (looks like a + // code change had no effect — a dangerous false negative when A/B-testing + // shader edits). The relay already sends Cache-Control: no-store, but + // puppeteer may still cache — disable explicitly + cache-bust the entry URL. + await page.setCacheEnabled(false); + const bust = url.includes('?') ? `&cb=${Date.now()}` : `?cb=${Date.now()}`; + // NOTE: waitUntil 'load' (not 'networkidle0') — the relay's /jamstack/poll + // long-poll keeps a connection alive, so networkidle never settles. + await page.goto(url + bust, { waitUntil: 'load', timeout: 30000 }); + // Extra settle time so shaders composite (load fires before first rendered + // frame). Wait for the canvas to have non-zero size + a beat. + await page.waitForFunction( + () => { const c = document.querySelector('canvas'); return c && c.width > 0 && c.height > 0; }, + { timeout: 15000 } + ).catch(() => {}); + await new Promise(r => setTimeout(r, wait)); + + await page.screenshot({ path: out, type: 'png' }); + console.log(out); + await browser.close(); + process.exit(0); + } catch (e) { + console.error('web_screenshot failed:', e.message); + try { await browser.close(); } catch (_) {} + process.exit(1); + } +})(); diff --git a/web/_headers b/web/_headers new file mode 100644 index 0000000..49f24c7 --- /dev/null +++ b/web/_headers @@ -0,0 +1,5 @@ +# Cloudflare Pages headers. Emscripten output filenames are stable across builds +# (game.wasm etc.), so force revalidation to avoid serving a stale build. ETags +# make this cheap (304 when unchanged). Copied to the static branch root on deploy. +/* + Cache-Control: no-cache diff --git a/web/agent-bridge.js b/web/agent-bridge.js new file mode 100644 index 0000000..776e78a --- /dev/null +++ b/web/agent-bridge.js @@ -0,0 +1,61 @@ +/* Jamstack web agent bridge (W2): injected into game.html by the relay + * (tools/agent-bridge/server.js). Polls the relay for Ruby commands, runs them in + * the live game via the wasm export (Module.jamstack), and posts results back, so + * the desktop .live/bin/eval interface works against this browser tab. Forwards + * console output to the relay's game-console. No-ops quietly if no relay is present + * (e.g. the page is served by a plain static server). Dev-only. */ +(function () { + 'use strict'; + + function ready() { return window.Module && typeof window.Module.jamstack === 'function'; } + + function post(url, obj) { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(obj), + }).catch(function () {}); + } + + // Tee console.* to the relay (this also carries the Ruby Log stream, since + // Log -> puts -> Module.print -> console.log on web). + ['log', 'info', 'warn', 'error'].forEach(function (lvl) { + var orig = console[lvl] ? console[lvl].bind(console) : function () {}; + console[lvl] = function () { + orig.apply(null, arguments); + try { + var line = Array.prototype.map.call(arguments, String).join(' '); + post('/jamstack/console', { line: line }); + } catch (e) {} + }; + }); + + var relayUp = false; + + function loop() { + if (!ready()) { setTimeout(loop, 100); return; } + fetch('/jamstack/poll').then(function (r) { + if (!r.ok) throw new Error('relay ' + r.status); + relayUp = true; + return r.json(); + }).then(function (cmd) { + var had = cmd && cmd.code != null && cmd.code !== ''; + if (had) { + var out; + try { out = window.Module.jamstack(cmd.code); } + catch (e) { out = JSON.stringify({ ok: false, error: 'jamstack: ' + String(e) }); } + post('/jamstack/result', { id: cmd.id, result: out }); + } + setTimeout(loop, had ? 0 : 50); + }).catch(function () { + relayUp = false; // no relay (static server) -> back off quietly + setTimeout(loop, 1000); + }); + } + + setInterval(function () { + if (relayUp && ready()) post('/jamstack/status', { connected: true, ts: Date.now() / 1000 }); + }, 1000); + + loop(); +})(); diff --git a/web/shell.html b/web/shell.html index 199756b..792a90c 100644 --- a/web/shell.html +++ b/web/shell.html @@ -1,110 +1,289 @@ -<!DOCTYPE html> -<html lang="en"> +<!doctype html> +<html lang="en-us"> <head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1.0"> -<title>Study Player</title> -<style> - * { margin: 0; padding: 0; box-sizing: border-box; } - body { - background: #0d0d1a; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 100vh; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - color: #eaeaea; - } - #controls { - display: flex; - gap: 12px; - margin-bottom: 12px; - align-items: center; - } - #controls button, #controls label { - background: #1a1a2e; - color: #eaeaea; - border: 2px solid #e94560; - border-radius: 6px; - padding: 8px 18px; - font-size: 15px; - cursor: pointer; - transition: background 0.15s; - } - #controls button:hover, #controls label:hover { - background: #e94560; - } - #controls input[type="file"] { - display: none; - } - #canvas-container { - position: relative; - display: inline-block; - border: 2px solid #333; - border-radius: 4px; - overflow: hidden; - } - canvas.emscripten { - display: block; - } - #status { - margin-top: 10px; - font-size: 13px; - color: #8c8ca0; - } -</style> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover"> + <title>raylib-jamstack</title> + <style> + * { box-sizing: border-box; } + html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #11111b; overflow: hidden; } + body { font-family: ui-monospace, Menlo, Consolas, "DejaVu Sans Mono", monospace; } + + /* Layout: landscape (wide) -> game left, REPL right; + portrait (tall, e.g. phone) -> game top, REPL bottom. + The game canvas is always a square. Switching is driven by the + `orientation` media feature (portrait = height >= width). */ + #layout { display: flex; flex-direction: row; width: 100vw; height: 100vh; } + + /* Game canvas: square. Landscape -> height-capped (fills the left pane). */ + #game-pane { + flex: 0 0 auto; height: 100vh; + display: flex; align-items: center; justify-content: center; + background: #000; + } + canvas { + display: block; border: 0; background: #000; + height: 100vh; aspect-ratio: 1 / 1; max-width: 70vw; + touch-action: none; + } + + /* HTML Ruby REPL: fills the remaining space. */ + #repl-pane { + flex: 1 1 0; min-width: 0; height: 100vh; + display: flex; flex-direction: column; + background: #181825; color: #cdd6f4; + border-left: 1px solid #313244; + } + + /* Portrait (tall viewport, e.g. phone): stack vertically. + Game on top (square, width-capped), REPL fills the height below. */ + @media (orientation: portrait) { + #layout { flex-direction: column; } + #game-pane { width: 100vw; height: auto; } + canvas { width: 100vw; height: auto; max-width: none; max-height: 85vh; } + #repl-pane { + width: 100vw; height: auto; flex: 1 1 0; min-height: 0; + border-left: 0; border-top: 1px solid #313244; + } + } + #repl-head { + flex: 0 0 auto; padding: 6px 10px; font-size: 12px; color: #a6adc8; + background: #11111b; border-bottom: 1px solid #313244; + display: flex; justify-content: space-between; gap: 8px; + } + #repl-head .dot { color: #a6e3a1; } + #repl-head .dot.idle { color: #6c7086; } + #scrollback { + flex: 1 1 auto; min-height: 0; overflow: auto; padding: 8px 10px; + font-size: 13px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; + } + #scrollback .line { padding: 1px 0; } + #scrollback .cmd { color: #89b4fa; } + #scrollback .result { color: #a6e3a1; } + #scrollback .error { color: #f38ba8; } + #scrollback .stdout { color: #cdd6f4; } + #scrollback .info { color: #6c7086; } + + #input-row { flex: 0 0 auto; display: flex; align-items: stretch; border-top: 1px solid #313244; background: #11111b; } + #prompt { + flex: 0 0 auto; padding: 8px 10px; color: #f9e2af; user-select: none; + cursor: pointer; touch-action: none; -webkit-tap-highlight-color: transparent; + border-right: 1px solid #313244; + } + #prompt:active { color: #fab387; background: #1e1e2e; } + #input { + flex: 1 1 auto; min-width: 0; resize: none; border: 0; outline: 0; + background: transparent; color: #cdd6f4; padding: 8px 10px 8px 0; + font: inherit; font-size: 13px; line-height: 1.4; + height: 1.6em; max-height: 8em; overflow: auto; + } + #input::placeholder { color: #585b70; } + + #status { color: #cdd6f4; font-family: sans-serif; position: fixed; top: 8px; left: 8px; } + </style> </head> <body> - <div id="controls"> - <label for="file-input">📂 Load MP3</label> - <input type="file" id="file-input" accept=".mp3"> - <button id="fullscreen-btn">⛶ Fullscreen</button> + <div id="layout"> + <div id="game-pane"> + <canvas id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas> + </div> + <div id="repl-pane"> + <div id="repl-head"> + <span><span id="repl-dot" class="dot idle">●</span> ruby repl <span style="color:#585b70">(Enter: eval · Shift+Enter: newline · ↑/↓: hist)</span></span> + <span id="repl-target">—</span> + </div> + <div id="scrollback"></div> + <div id="input-row"> + <span id="prompt" role="button" aria-label="Autocomplete (Tab)" title="Autocomplete (Tab)">></span> + <textarea id="input" rows="1" spellcheck="false" autocapitalize="off" autocomplete="off" disabled placeholder="warming up…"></textarea> + </div> + </div> </div> - <div id="canvas-container"> - <canvas class="emscripten" id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas> - </div> - <div id="status"></div> - + <div id="status">loading…</div> <script> - var Module = { - canvas: document.getElementById('canvas'), - onRuntimeInitialized: function() { - console.log('Emscripten runtime initialized'); - document.getElementById('status').textContent = 'Ready \u2014 load an MP3 file'; + var statusEl = document.getElementById('status'); + var canvas = document.getElementById('canvas'); + var scrollback = document.getElementById('scrollback'); + var input = document.getElementById('input'); + var replDot = document.getElementById('repl-dot'); + var replTarget = document.getElementById('repl-target'); + + var HISTORY_KEY = 'jamstack.repl.hist'; + var hist = []; + var histIndex = 0; + try { hist = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch (e) {} + histIndex = hist.length; + var draftOnHistory = ''; + + function appendLine(text, cls) { + var div = document.createElement('div'); + div.className = 'line ' + (cls || 'stdout'); + div.textContent = text; + scrollback.appendChild(div); + scrollback.scrollTop = scrollback.scrollHeight; + } + function appendInfo(t) { appendLine(t, 'info'); } + + function ready() { return window.Module && typeof window.Module.jamstack === 'function'; } + + function evalRuby(code) { + var raw; + try { raw = window.Module.jamstack(code); } + catch (e) { raw = JSON.stringify({ ok: false, error: 'jamstack threw: ' + String(e) }); } + var env; + try { env = JSON.parse(raw || '{}'); } catch (e) { env = { ok: false, error: 'bad json: ' + raw }; } + if (env.stdout) appendLine(env.stdout, 'stdout'); + if (env.ok) { + var r = env.result; + if (r != null && r !== '' && r !== 'nil') appendLine('=> ' + r, 'result'); + } else { + appendLine(env.error || '(no error message)', 'error'); + if (env.backtrace && env.backtrace.length) appendLine(env.backtrace.slice(0, 6).join('\n'), 'error'); } - }; + } - document.getElementById('fullscreen-btn').addEventListener('click', function() { - var container = document.getElementById('canvas-container'); - if (container.requestFullscreen) container.requestFullscreen(); - else if (container.webkitRequestFullscreen) container.webkitRequestFullscreen(); - else if (container.msRequestFullscreen) container.msRequestFullscreen(); - }); + // Build a Ruby single-quoted string literal from `s`. Single-quoted Ruby + // strings do NOT interpolate `#{}`, so the text is passed verbatim to + // complete_json (only `\\` and `\'` need escaping). Avoids injection via + // Ruby string interpolation when the input contains `#`/`{}`. + function rubySingleQuote(s) { + return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; + } + + // Tab completion via Jamstack::Bridge.complete_json (runs in the game's + // binding on the main thread). env.result is the Ruby-inspected JSON string + // (eval_code wraps every result with String#inspect), so we parse TWICE: + // first unwrap the inspect quoting -> the JSON string, then parse that into + // the completion object. + function completeRuby() { + var text = input.value; + if (text === '') return; + var raw; + try { raw = window.Module.jamstack('Jamstack::Bridge.complete_json(' + rubySingleQuote(text) + ')'); } + catch (e) { return; } + var env; + try { env = JSON.parse(raw || '{}'); } catch (e) { return; } + if (!env.ok) { if (env.error) appendLine(env.error, 'error'); return; } + var inner, comp; + try { inner = JSON.parse(env.result || '""'); } + catch (e) { return; } + try { comp = (typeof inner === 'string') ? JSON.parse(inner) : inner; } + catch (e) { return; } + if (!comp || !comp.candidates || comp.candidates.length === 0) return; + if (comp.single) { + input.value = comp.completed; + } else { + if (comp.completed != null) input.value = comp.completed; + var shown = comp.candidates.length > 40 + ? comp.candidates.slice(0, 40).concat(['…']) : comp.candidates; + appendLine(shown.join(' '), 'info'); + } + autoSize(); + input.selectionStart = input.selectionEnd = input.value.length; + } + + function saveHistory() { + try { localStorage.setItem(HISTORY_KEY, JSON.stringify(hist.slice(-200))); } catch (e) {} + } + + // Keyboard: Emscripten's GLFW port registers `window.addEventListener( + // 'keydown', GLFW.onKeydown, true)` (CAPTURE phase) at runtime init, and + // onKeydown calls event.preventDefault() for Backspace and Tab — which kills + // the textarea's native backspace-deletion and tab-insertion while the REPL + // is focused. It also forwards EVERY key to raylib, so typing `a` here would + // also steer the player. We register our OWN window-capture keydown listener + // FIRST (this inline script runs before the Emscripten glue inits), so we + // run before GLFW.onKeydown. When the REPL input is focused we + // stopImmediatePropagation() to fully isolate the REPL from the game. + // stopImmediatePropagation does NOT cancel default actions (only + // preventDefault does), so letter insertion and Backspace deletion still + // happen natively; we only preventDefault the keys we handle ourselves. + window.addEventListener('keydown', function (ev) { + if (document.activeElement !== input) return; // game owns the keyboard + ev.stopImmediatePropagation(); // keep Emscripten/raylib off the REPL + var k = ev.key; + if (k === 'Enter' && !ev.shiftKey) { + ev.preventDefault(); + var code = input.value; + if (code.trim() === '') { input.value = ''; autoSize(); return; } + hist.push(code); histIndex = hist.length; saveHistory(); + appendLine('> ' + code, 'cmd'); + input.value = ''; autoSize(); + evalRuby(code); + scrollback.scrollTop = scrollback.scrollHeight; + } else if (k === 'ArrowUp') { + ev.preventDefault(); + if (histIndex === hist.length) draftOnHistory = input.value; + if (histIndex > 0) { histIndex--; input.value = hist[histIndex] || ''; autoSize(); } + } else if (k === 'ArrowDown') { + ev.preventDefault(); + if (histIndex < hist.length) { + histIndex++; + input.value = (histIndex === hist.length) ? draftOnHistory : (hist[histIndex] || ''); + autoSize(); + } + } else if (k === 'Tab') { + ev.preventDefault(); + completeRuby(); + } else if (k === 'l' && ev.ctrlKey) { + ev.preventDefault(); + scrollback.textContent = ''; + } + // Letters, Backspace, Shift+Enter, Left/Right/Home/End: let the textarea + // perform its native default (insert/delete/move) — only Emscripten is + // suppressed via stopImmediatePropagation above. + }, true); - document.getElementById('file-input').addEventListener('change', function(e) { - var file = e.target.files[0]; - if (!file) return; - console.log('File selected:', file.name, 'size:', file.size); - document.getElementById('status').textContent = 'Loading ' + file.name + '...'; - var reader = new FileReader(); - reader.onload = function(ev) { - var data = new Uint8Array(ev.target.result); - var filename = '/tmp/' + file.name; - console.log('Writing to virtual FS:', filename, 'bytes:', data.length); - try { FS.unlink(filename); } catch(ex) {} - try { FS.mkdir('/tmp'); } catch(ex) {} - FS.writeFile(filename, data); - console.log('Calling load_file_web...'); - Module.ccall('load_file_web', null, ['string'], [filename]); - console.log('load_file_web returned'); - document.getElementById('status').textContent = file.name; - }; - reader.readAsArrayBuffer(file); + input.addEventListener('input', autoSize); + + // Tap the `>` prompt to trigger Tab completion (mobile has no Tab key). + // preventDefault on touchstart/mousedown keeps focus on the textarea so the + // mobile soft keyboard stays open. Guard against double-fire on touch + // devices (touchstart then a synthetic mousedown). + var promptEl = document.getElementById('prompt'); + var tabTouched = false; + function tapComplete(ev) { + ev.preventDefault(); + ev.stopPropagation(); + completeRuby(); + input.focus(); + } + promptEl.addEventListener('touchstart', function (ev) { + tabTouched = true; + tapComplete(ev); + }, { passive: false }); + promptEl.addEventListener('mousedown', function (ev) { + if (tabTouched) { tabTouched = false; return; } + tapComplete(ev); }); - </script> + function autoSize() { + input.style.height = '1.6em'; + input.style.height = Math.min(input.scrollHeight, 8 * 20) + 'px'; + } + var Module = { + arguments: ['game/fx_demo.rb'], + canvas: canvas, + print: function (t) { console.log(t); }, + printErr: function (t) { console.error(t); }, + setStatus: function (t) { statusEl.textContent = t; if (!t) statusEl.style.display = 'none'; }, + onRuntimeInitialized: function () { + Module.jamstack = function (code) { + return Module.ccall('jamstack_eval', 'string', ['string'], [code]); + }; + Module.flecsRequest = function (method, path, body) { + return Module.ccall('flecs_explorer_request', 'string', + ['string', 'string', 'string'], [method, path, body || '']); + }; + input.disabled = false; input.placeholder = 'type Ruby, then Enter…'; + replDot.classList.remove('idle'); + replTarget.textContent = 'fx_demo.rb'; + appendInfo('Jamstack HTML REPL ready — evals in the live game via jamstack_eval.'); + appendInfo('Try: score player_x = 100 Rl.get_fps Rl.platform'); + input.focus(); + }, + }; + </script> {{{ SCRIPT }}} - </body> </html> |
