Import Oxide engine (Stages 0–10) under MIT license

Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit f56a1eea3b
128 changed files with 40493 additions and 2 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "oxide-examples"
description = "Oxide Engine — runnable examples"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
rust-version.workspace = true
publish = false
[dependencies]
oxide-engine = { path = "../engine" }
oxide-physics = { path = "../physics" }
oxide-script = { path = "../script" }
log.workspace = true
env_logger.workspace = true
anyhow.workspace = true
# Each example is a standalone binary in `src/bin/`. Run with:
# cargo run -p oxide-examples --bin <name>
+151
View File
@@ -0,0 +1,151 @@
//! `character_capsule` — a runnable tour of the Stage 9 character controller.
//!
//! Run with:
//! ```sh
//! cargo run -p oxide-examples --bin character_capsule
//! ```
//!
//! This example has no window or GPU dependency. It builds a floor with a low
//! step and a wall, then drives a kinematic capsule character through a scripted
//! routine — walk forward, climb the step, jump, and push into the wall — and
//! prints its position and grounded state, so move-and-slide, auto-step,
//! grounding, and jumping can be reviewed by eye.
use oxide_engine::prelude::*;
use oxide_physics::{CharacterController, Collider, PhysicsModule, PhysicsWorld, RigidBody};
/// Gravity acceleration (m/s²) applied to the character's vertical velocity.
const GRAVITY: f32 = 9.81;
/// Upward speed (m/s) imparted by a jump.
const JUMP_SPEED: f32 = 4.5;
fn main() {
env_logger::init();
println!("Oxide physics demo — Stage 9 capsule character controller\n");
let mut app = App::new();
app.add_modules(DefaultModules);
app.add_module(PhysicsModule);
// Floor (top at y = 0.5).
spawn_static_box(&mut app, "floor", Vec3::ZERO, Vec3::new(20.0, 0.5, 20.0));
// A 0.2 m step (top at y = 0.7) in front of the start position.
spawn_static_box(
&mut app,
"step",
Vec3::new(3.0, 0.35, 0.0),
Vec3::new(1.5, 0.35, 5.0),
);
// A wall further along (near face at x = 7.5).
spawn_static_box(
&mut app,
"wall",
Vec3::new(8.0, 2.0, 0.0),
Vec3::new(0.5, 2.0, 5.0),
);
// The character: a capsule starting on the floor (rest center ≈ y 1.4).
let player = app.scene.spawn(
"player",
Transform::from_translation(Vec3::new(0.0, 1.4, 0.0)),
);
app.scene
.world_mut()
.insert_one(player, CharacterController::default())
.unwrap();
// Register the static world once so the query pipeline is populated.
app.update(1.0 / 60.0);
println!("Driving the character (walk → climb step → jump → into wall):\n");
println!(
" {:>5} {:>7} {:>7} {:>8} note",
"step", "x", "y", "grounded"
);
let dt = 1.0 / 60.0;
let mut vy = 0.0f32; // vertical velocity carried between frames
for step in 0..300 {
// Always push forward (+X); request a jump once, mid-run, while grounded.
let want_jump = step == 150;
// Read the grounded state from the previous resolved move to decide
// jumping and gravity reset.
let grounded = drive(&mut app, player, &mut vy, 2.0, want_jump, dt);
if step % 25 == 0 || step == 150 {
let p = app.scene.world_transform(player).unwrap().translation;
let note = match step {
0 => "start: walking forward",
150 => "JUMP!",
_ if !grounded => "airborne",
_ if p.x > 6.5 => "blocked by the wall",
_ if p.y > 1.5 => "up on the step",
_ => "",
};
println!(
" {:>5} {:>7.3} {:>7.3} {:>8} {}",
step, p.x, p.y, grounded, note
);
}
}
let end = app.scene.world_transform(player).unwrap().translation;
println!(
"\nFinal position: x = {:.2}, y = {:.2}. The capsule walked forward, stepped",
end.x, end.y
);
println!("up onto the ledge, jumped, and was stopped by the wall (x never passes ~7.2).");
}
/// One control tick: integrate gravity into `vy` (jumping if asked & grounded),
/// move the character, apply the resolved translation, and return whether it is
/// grounded afterwards.
fn drive(
app: &mut App,
player: Entity,
vy: &mut f32,
forward_speed: f32,
want_jump: bool,
dt: f32,
) -> bool {
// Integrate gravity into the vertical velocity; the controller clamps the
// resulting downward motion against the floor and reports grounded.
*vy -= GRAVITY * dt;
let desired = Vec3::new(forward_speed * dt, *vy * dt, 0.0);
let movement = {
let world = app.get_resource::<PhysicsWorld>().unwrap();
world
.move_character(&app.scene, player, desired, dt)
.unwrap()
};
// Apply the collision-corrected translation.
let mut t = app.scene.local_transform(player).unwrap();
t.translation += movement.translation;
app.scene.set_local_transform(player, t);
// On the ground: cancel downward velocity (and allow a jump this frame).
if movement.grounded && *vy < 0.0 {
*vy = 0.0;
}
if want_jump && movement.grounded {
*vy = JUMP_SPEED;
}
movement.grounded
}
/// Spawns a static box collider (world geometry) at `pos` with `half_extents`.
fn spawn_static_box(app: &mut App, name: &str, pos: Vec3, half_extents: Vec3) -> Entity {
let e = app.scene.spawn(name, Transform::from_translation(pos));
app.scene
.world_mut()
.insert_one(e, RigidBody::static_body())
.unwrap();
app.scene
.world_mut()
.insert_one(e, Collider::cuboid(half_extents))
.unwrap();
e
}
+124
View File
@@ -0,0 +1,124 @@
//! Stage 4 example: load primitive meshes and render them lit, in 3D.
//!
//! Run with:
//! cargo run -p oxide-examples --bin hello_mesh
//!
//! Shows a spinning cube, a sphere, and a ground plane drawn through the
//! [`ForwardRenderer`] with a single directional light. Esc quits.
#![deny(warnings)]
use oxide_engine::math::Quat;
use oxide_engine::prelude::*;
use oxide_engine::window::event::{ElementState, Key, NamedKey, WindowEvent};
use oxide_engine::window::RenderCtx;
/// GPU resources, built lazily on the first frame (once the surface format is
/// known) and reused thereafter.
struct Gpu3d {
pipeline: RenderPipeline,
cube: GpuMesh,
sphere: GpuMesh,
plane: GpuMesh,
}
#[derive(Default)]
struct HelloMesh {
angle: f32,
gpu: Option<Gpu3d>,
}
impl WindowApp for HelloMesh {
fn init(&mut self, ctx: &mut AppCtx<'_>) {
ctx.set_clear_color(Color::rgb(0.05, 0.06, 0.09));
log::info!("hello_mesh: spinning cube + sphere + ground plane (Esc quits)");
}
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
if let WindowEvent::KeyboardInput { event: key, .. } = event {
if key.state == ElementState::Pressed && key.logical_key == Key::Named(NamedKey::Escape)
{
ctx.request_exit();
}
}
}
fn update(&mut self, ctx: &mut AppCtx<'_>) {
self.angle += ctx.dt;
}
fn render(&mut self, ctx: &RenderCtx<'_>) {
let device = ctx.gpu.device();
let queue = ctx.gpu.queue();
let gpu = self.gpu.get_or_insert_with(|| {
// The window runner already clears the surface to the configured
// clear color before `render`, so the viewport pipeline is just the
// forward pass (no clear pass needed here).
let mut pipeline = RenderPipeline::new();
pipeline.add_pass("forward", ForwardPass::new(device, ctx.surface_format));
Gpu3d {
pipeline,
cube: Mesh::cube().upload(device, "cube"),
sphere: Mesh::uv_sphere(0.8, 32, 16).upload(device, "sphere"),
plane: Mesh::plane(12.0).upload(device, "plane"),
}
});
// Orbit the camera slowly around the scene.
let eye = Vec3::new(
4.0 * (self.angle * 0.3).cos(),
2.6,
4.0 * (self.angle * 0.3).sin(),
);
let view = Transform::looking_at(eye, Vec3::new(0.0, 0.2, 0.0), Vec3::Y);
let camera = Camera::default();
let objects = [
RenderObject {
mesh: &gpu.plane,
material: Material::diffuse(Color::rgb(0.25, 0.27, 0.30)),
transform: Transform::from_translation(Vec3::new(0.0, -1.0, 0.0)),
},
RenderObject {
mesh: &gpu.cube,
material: Material::diffuse(Color::rgb(0.85, 0.20, 0.15)),
transform: Transform::from_trs(
Vec3::new(-1.3, 0.0, 0.0),
Quat::from_euler(oxide_engine::math::EulerRot::YXZ, self.angle, 0.4, 0.0),
Vec3::ONE,
),
},
RenderObject {
mesh: &gpu.sphere,
material: Material::metal(Color::rgb(0.55, 0.65, 0.95), 0.35),
transform: Transform::from_translation(Vec3::new(1.3, 0.2, 0.0)),
},
];
let lighting = Lighting::default();
gpu.pipeline.render(&mut FrameContext {
device,
queue,
color: ctx.view,
size: ctx.size,
viewport_rect: None,
clear_color: Color::rgb(0.05, 0.06, 0.09),
camera: &camera,
view_transform: &view,
lighting: &lighting,
objects: &objects,
});
}
}
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = WindowConfig {
title: "Oxide — hello_mesh".to_string(),
width: 960,
height: 540,
..Default::default()
};
run(config, HelloMesh::default())
}
+92
View File
@@ -0,0 +1,92 @@
//! Stage 2 example: open a window, clear it to a configurable color.
//!
//! Run with:
//! cargo run -p oxide-examples --bin hello_window
//!
//! Controls:
//! 15 select a preset clear color
//! Space cycle to the next preset
//! Esc quit
//!
//! Average FPS is logged once per second, which makes the "stable 60+ FPS"
//! test criterion observable from the terminal.
#![deny(warnings)]
use oxide_engine::prelude::*;
use oxide_engine::window::event::Key;
use oxide_engine::window::event::{ElementState, KeyCode, NamedKey, PhysicalKey, WindowEvent};
const PRESETS: [(&str, Color); 5] = [
("cornflower blue", Color::rgba(0.39, 0.58, 0.93, 1.0)),
("oxide red", Color::rgba(0.55, 0.15, 0.08, 1.0)),
("forest green", Color::rgba(0.05, 0.35, 0.12, 1.0)),
("near black", Color::rgba(0.02, 0.02, 0.03, 1.0)),
("white", Color::WHITE),
];
#[derive(Default)]
struct HelloWindow {
preset: usize,
frames: u32,
elapsed: f32,
}
impl HelloWindow {
fn apply_preset(&mut self, ctx: &mut AppCtx<'_>, index: usize) {
self.preset = index % PRESETS.len();
let (name, color) = PRESETS[self.preset];
ctx.set_clear_color(color);
log::info!("clear color {} — {name}", self.preset + 1);
}
}
impl WindowApp for HelloWindow {
fn init(&mut self, ctx: &mut AppCtx<'_>) {
self.apply_preset(ctx, 0);
log::info!("press 15 to pick a color, Space to cycle, Esc to quit");
}
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
let WindowEvent::KeyboardInput { event: key, .. } = event else {
return;
};
if key.state != ElementState::Pressed {
return;
}
if key.logical_key == Key::Named(NamedKey::Escape) {
ctx.request_exit();
return;
}
match key.physical_key {
PhysicalKey::Code(KeyCode::Digit1) => self.apply_preset(ctx, 0),
PhysicalKey::Code(KeyCode::Digit2) => self.apply_preset(ctx, 1),
PhysicalKey::Code(KeyCode::Digit3) => self.apply_preset(ctx, 2),
PhysicalKey::Code(KeyCode::Digit4) => self.apply_preset(ctx, 3),
PhysicalKey::Code(KeyCode::Digit5) => self.apply_preset(ctx, 4),
PhysicalKey::Code(KeyCode::Space) => self.apply_preset(ctx, self.preset + 1),
_ => {}
}
}
fn update(&mut self, ctx: &mut AppCtx<'_>) {
self.frames += 1;
self.elapsed += ctx.dt;
if self.elapsed >= 1.0 {
log::info!("{:.0} FPS", self.frames as f32 / self.elapsed);
self.frames = 0;
self.elapsed = 0.0;
}
}
}
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = WindowConfig {
title: "Oxide — hello_window".to_string(),
width: 960,
height: 540,
..Default::default()
};
run(config, HelloWindow::default())
}
+83
View File
@@ -0,0 +1,83 @@
//! `math_demo` — a runnable tour of the Stage 1 math primitives.
//!
//! Run with:
//! ```sh
//! cargo run -p oxide-examples --bin math_demo
//! ```
//!
//! This example has no window or GPU dependency; it simply exercises the math
//! API and prints results so the foundation can be reviewed by eye.
use oxide_engine::prelude::*;
fn main() {
env_logger::init();
log::info!("Oxide math demo — Stage 1 primitives");
// --- Transform hierarchy (parent * child) ---
let parent = Transform::from_trs(
Vec3::new(10.0, 0.0, 0.0),
Quat::from_rotation_y(90_f32.to_radians()),
Vec3::splat(2.0),
);
let child_local = Transform::from_translation(Vec3::new(0.0, 0.0, 1.0));
let child_world = parent.mul_transform(&child_local);
println!("== Transform ==");
println!("parent translation : {}", parent.translation);
println!("child local pos : {}", child_local.translation);
println!("child world pos : {}", child_world.translation);
println!("parent forward : {}", parent.forward());
println!(
"round-trip inverse : {}",
parent.mul_transform(&parent.inverse()).translation
);
// --- Bounding box + ray pick ---
let bounds = Aabb::from_points([
Vec3::new(-1.0, -1.0, -1.0),
Vec3::new(1.0, 2.0, 1.0),
Vec3::new(0.5, 0.5, 3.0),
]);
let ray = Ray::new(Vec3::new(0.0, 0.0, -10.0), Vec3::Z);
println!("\n== AABB / Ray ==");
println!("bounds center : {}", bounds.center());
println!("bounds size : {}", bounds.size());
match bounds.ray_intersection(&ray) {
Some(t) => println!("ray hit at t={t:.3}, point={}", ray.at(t)),
None => println!("ray missed the bounds"),
}
// --- Frustum culling ---
let proj = Mat4::perspective_rh(60_f32.to_radians(), 16.0 / 9.0, 0.1, 100.0);
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
let frustum = Frustum::from_view_projection(proj * view);
let in_view = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
let off_screen = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, 100.0), Vec3::splat(1.0));
println!("\n== Frustum ==");
println!(
"box at origin visible : {}",
frustum.intersects_aabb(&in_view)
);
println!(
"box behind camera : {}",
frustum.intersects_aabb(&off_screen)
);
// --- Color + value remap ---
let sky = Color::from_hex(0x87CEEB);
let ground = Color::from_hex(0x3A2E1F);
let blended = sky.lerp(ground, 0.5);
println!("\n== Color ==");
println!("sky (linear) : {:?}", sky.to_vec3());
println!("blended sRGB bytes : {:?}", blended.to_srgb_u8());
let value_range = Range3::new(Vec3::ZERO, Vec3::splat(100.0));
println!("\n== Range3 ==");
println!(
"remap 25 → unit : {}",
value_range.inverse_lerp(Vec3::splat(25.0))
);
log::info!("math demo complete");
}
+105
View File
@@ -0,0 +1,105 @@
//! `physics_stack` — a runnable tour of the Stage 9 rigid-body simulation.
//!
//! Run with:
//! ```sh
//! cargo run -p oxide-examples --bin physics_stack
//! ```
//!
//! This example has no window or GPU dependency. It builds a static floor and a
//! stack of dynamic boxes, steps the physics simulation at a fixed 60 Hz, and
//! prints the boxes' heights over time — so you can see them settle into a
//! stable stack (and a dropped ball land and come to rest) by eye.
use oxide_engine::prelude::*;
use oxide_physics::{Collider, PhysicsModule, PhysicsWorld, RigidBody};
fn main() {
env_logger::init();
println!("Oxide physics demo — Stage 9 rigid-body stack\n");
let mut app = App::new();
app.add_modules(DefaultModules);
app.add_module(PhysicsModule);
// A static ground plane (a wide, thin box; its top surface is at y = 0.5).
let floor = app
.scene
.spawn("floor", Transform::from_translation(Vec3::ZERO));
app.scene
.world_mut()
.insert_one(floor, RigidBody::static_body())
.unwrap();
app.scene
.world_mut()
.insert_one(floor, Collider::cuboid(Vec3::new(10.0, 0.5, 10.0)))
.unwrap();
// A stack of three unit boxes, each starting a little above its rest height
// so they drop and settle onto one another.
let mut boxes = Vec::new();
for i in 0..3 {
let y = 1.2 + i as f32 * 1.05;
let e = app.scene.spawn(
format!("box{i}"),
Transform::from_translation(Vec3::new(0.0, y, 0.0)),
);
app.scene
.world_mut()
.insert_one(e, RigidBody::default())
.unwrap();
app.scene
.world_mut()
.insert_one(e, Collider::cuboid(Vec3::splat(0.5)))
.unwrap();
boxes.push(e);
}
// A ball dropped from higher up, to land on the top box.
let ball = app.scene.spawn(
"ball",
Transform::from_translation(Vec3::new(0.0, 6.0, 0.0)),
);
app.scene
.world_mut()
.insert_one(ball, RigidBody::default())
.unwrap();
app.scene
.world_mut()
.insert_one(ball, Collider::ball(0.5))
.unwrap();
println!("Stepping the simulation at 60 Hz (boxes start stacked, ball falls in):\n");
println!(
" {:>5} {:>7} {:>7} {:>7} {:>7}",
"step", "box0", "box1", "box2", "ball"
);
let dt = 1.0 / 60.0;
for step in 0..=180 {
if step % 20 == 0 {
let ys: Vec<f32> = boxes.iter().map(|&e| height(&app, e)).collect();
println!(
" {:>5} {:>7.3} {:>7.3} {:>7.3} {:>7.3}",
step,
ys[0],
ys[1],
ys[2],
height(&app, ball)
);
}
app.update(dt);
}
// Report the resting state.
let world = app.get_resource::<PhysicsWorld>().unwrap();
let resting = boxes
.iter()
.all(|&e| world.linear_velocity(e).length() < 0.05);
println!("\nAfter ~3 s: boxes rest near y ≈ 1.0 / 2.0 / 3.0, all settled = {resting}.");
println!("The stack stays standing — stable contact, no jitter or explosion.");
}
/// The current world-space height (y) of an entity.
fn height(app: &App, e: Entity) -> f32 {
app.scene.world_transform(e).unwrap().translation.y
}
+111
View File
@@ -0,0 +1,111 @@
//! `scene_basic` — a runnable tour of the Stage 3 scene graph.
//!
//! Run with:
//! ```sh
//! cargo run -p oxide-examples --bin scene_basic
//! ```
//!
//! This example has no window or GPU dependency. It builds a small entity
//! hierarchy, prints each node's local and resolved world transform, reparents
//! a node, and round-trips the whole scene through RON — so the Stage 3 scene
//! API can be reviewed by eye.
use oxide_engine::prelude::*;
use oxide_engine::scene::DespawnPolicy;
fn main() {
env_logger::init();
println!("Oxide scene demo — Stage 3 scene graph\n");
let mut scene = Scene::new();
// A little solar-system-ish hierarchy: sun → planet → moon, plus a probe.
let sun = scene.spawn("sun", Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)));
let planet = scene.spawn_child(
sun,
"planet",
Transform::from_trs(
Vec3::new(10.0, 0.0, 0.0),
Quat::from_rotation_y(90_f32.to_radians()),
Vec3::ONE,
),
);
let moon = scene.spawn_child(
planet,
"moon",
Transform::from_translation(Vec3::new(0.0, 0.0, 2.0)),
);
let probe = scene.spawn_child(planet, "probe", Transform::from_translation(Vec3::Y));
println!("== Hierarchy & world transforms ==");
print_tree(&scene);
// World transforms compose down the chain: the moon inherits the planet's
// rotation, so its local +Z offset lands along the world axes accordingly.
let moon_world = scene.world_transform(moon).unwrap();
println!(
"\nmoon local pos : {}",
scene.local_transform(moon).unwrap().translation
);
println!("moon world pos : {}", moon_world.translation);
// Reparent the probe directly under the sun and observe its world transform
// change (its local transform is preserved).
println!("\n== Reparent probe: planet → sun ==");
scene.set_parent(probe, Some(sun)).unwrap();
println!(
"probe world pos: {}",
scene.world_transform(probe).unwrap().translation
);
// Disable a node (later systems will skip disabled subtrees).
scene.set_enabled(moon, false);
println!(
"\nmoon enabled? : {}",
scene.is_enabled(moon).unwrap_or(true)
);
// Serialize → deserialize round-trip.
println!("\n== RON serialization ==");
let ron = scene.to_ron().expect("serialize");
println!("{ron}");
let restored = Scene::from_ron(&ron).expect("deserialize");
println!(
"restored {} entities, {} roots",
restored.len(),
restored.roots().len()
);
// Despawn the planet, detaching its children up to its parent.
println!("\n== Despawn planet (detach children) ==");
scene.despawn(planet, DespawnPolicy::DetachChildren);
print_tree(&scene);
}
/// Prints the scene as an indented tree with each node's world position.
fn print_tree(scene: &Scene) {
let worlds = scene.world_transforms();
for &root in scene.roots() {
print_node(scene, root, 0, &worlds);
}
}
fn print_node(
scene: &Scene,
entity: oxide_engine::scene::Entity,
depth: usize,
worlds: &std::collections::HashMap<oxide_engine::scene::Entity, Transform>,
) {
let indent = " ".repeat(depth);
let name = scene.name(entity).unwrap_or_default();
let enabled = scene.is_enabled(entity).unwrap_or(true);
let world = worlds
.get(&entity)
.map(|t| t.translation)
.unwrap_or(Vec3::ZERO);
let tag = if enabled { "" } else { " (disabled)" };
println!("{indent}- {name}{tag} world={world}");
for &child in scene.children(entity) {
print_node(scene, child, depth + 1, worlds);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! `script_spin` — a runnable tour of the Stage 10 scripting + live-reload layer.
//!
//! Run with:
//! ```sh
//! cargo run -p oxide-examples --bin script_spin
//! ```
//!
//! This example has no window or GPU dependency. It writes a tiny `.rhai` script
//! that rotates an entity around Y every frame, attaches it via a [`Script`]
//! component, and steps the app — printing the entity's yaw so you can watch it
//! spin. Then, **without restarting**, it rewrites the script on disk to spin
//! three times faster and reloads it the way the editor's file watcher does; the
//! spin rate visibly jumps while the entity keeps its current orientation. That
//! is the Stage 10 live-reload promise in a headless harness.
use std::path::PathBuf;
use oxide_engine::prelude::*;
use oxide_engine::watch::{reload_changed_assets, ChangeEvent, ChangeKind};
use oxide_script::{Script, ScriptModule};
/// The initial script: a slow spin around Y, proportional to the frame delta.
const SLOW_SPIN: &str = r#"
// Rotate this entity around Y. `dt` is the frame time in seconds.
let speed = 1.0; // radians/second
fn init() {
print("spin script started");
}
fn update(dt) {
rotate_y(dt * 1.0);
}
"#;
/// The live edit: the same script, spinning three times faster.
const FAST_SPIN: &str = r#"
let speed = 3.0; // radians/second
fn update(dt) {
rotate_y(dt * 3.0);
}
"#;
fn main() {
env_logger::init();
println!("Oxide scripting demo — Stage 10 live-reloaded spin\n");
// A throwaway project directory holding `assets/scripts/spin.rhai`.
let project = TempProject::new();
let rel = "scripts/spin.rhai";
project.write(rel, SLOW_SPIN);
println!("wrote {rel}:\n{}", indent(SLOW_SPIN));
// The asset database maps the file to a stable uid the Script references.
let mut db = AssetDatabase::new(project.root());
let uid = db.register(rel);
let mut app = App::new();
app.add_modules(DefaultModules);
app.add_module(ScriptModule);
app.insert_resource(db);
// One entity that runs the script.
let spinner = app.scene.spawn("spinner", Transform::IDENTITY);
app.scene
.world_mut()
.insert_one(spinner, Script::new(AssetRef::new(uid)))
.unwrap();
let dt = 1.0 / 60.0;
println!("\n-- spinning (script: 1.0 rad/s) --");
run_seconds(&mut app, spinner, dt, 1.0);
// Edit the script live: faster spin, no restart. This is exactly what the
// editor does when the watcher sees the file change.
project.write(rel, FAST_SPIN);
let abs = project.abs(rel);
let reloaded = reload_changed_assets(
&app.assets,
[ChangeEvent {
path: abs,
kind: ChangeKind::Modified,
}],
);
println!("\n>> edited spin.rhai live (reloaded {reloaded} asset) -> 3.0 rad/s\n");
println!("-- spinning (script: 3.0 rad/s, orientation preserved) --");
run_seconds(&mut app, spinner, dt, 1.0);
let final_yaw = yaw_of(&app, spinner);
println!("\nfinal yaw = {final_yaw:.2} rad — the rate jumped without a restart.");
}
/// Steps the app for `seconds` of fixed `dt` frames, printing the spinner's yaw
/// roughly ten times so the rotation is visible.
fn run_seconds(app: &mut App, entity: Entity, dt: f32, seconds: f32) {
let frames = (seconds / dt).round() as u32;
let every = (frames / 10).max(1);
for frame in 1..=frames {
app.update(dt);
if frame % every == 0 {
println!(
" t = {:.2}s yaw = {:.3} rad",
frame as f32 * dt,
yaw_of(app, entity)
);
}
}
}
/// The entity's yaw (rotation about Y) in radians.
fn yaw_of(app: &App, entity: Entity) -> f32 {
app.scene
.local_transform(entity)
.map(|t| t.rotation.to_euler(EulerRot::YXZ).0)
.unwrap_or(0.0)
}
/// Indents a block of text two spaces for tidy console output.
fn indent(text: &str) -> String {
text.trim_matches('\n')
.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
}
/// A throwaway project directory, removed on drop.
struct TempProject {
root: PathBuf,
}
impl TempProject {
fn new() -> Self {
let mut root = std::env::temp_dir();
root.push(format!("oxide-script-spin-{}", std::process::id()));
std::fs::create_dir_all(root.join("assets")).expect("create temp project");
Self { root }
}
fn root(&self) -> &std::path::Path {
&self.root
}
fn abs(&self, relative: &str) -> PathBuf {
self.root.join("assets").join(relative)
}
fn write(&self, relative: &str, contents: &str) {
let path = self.abs(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
}
impl Drop for TempProject {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
+416
View File
@@ -0,0 +1,416 @@
//! Stage 8 example: a game HUD drawn on top of a live 3D scene.
//!
//! Run with:
//! cargo run -p oxide-examples --bin ui_hud
//!
//! Composites two Stage-8 ingredients in one frame:
//!
//! - The Stage-4 [`ForwardPass`] renders a spinning cube + sphere + ground
//! plane (the same scene as `hello_mesh`).
//! - A screen-space [`UiOverlayPass`] then draws a HUD *on top* of it. Both
//! passes load (never clear) the surface the window runner already cleared,
//! so the overlay composites over the 3D image with alpha blending.
//!
//! HUD layout (each corner is an [`UiAnchor`]-pinned widget):
//!
//! - **Top-left** — `HP: NN`, oscillating down then back up; turns red when low.
//! - **Top-right** — `Ammo: NN`, counting down as if firing, reloading at 0.
//! - **Bottom-left** — a 100×100 minimap stand-in with a dot orbiting in sync
//! with the camera.
//! - **Centre** — a crosshair (two thin bars) — the "target indicator".
//!
//! The animated digits exist to demonstrate the glyph atlas's dirty-flag
//! caching: once each of `0``9` (plus the static label text) has been
//! rasterized once, the atlas stops growing and every later frame is a 100%
//! cache hit. The example logs each atlas growth and the moment it reaches
//! steady state. Esc quits.
#![deny(warnings)]
use oxide_engine::math::Quat;
use oxide_engine::prelude::*;
use oxide_engine::ui::text::{common_system_font_paths, Font};
use oxide_engine::window::event::{ElementState, Key, NamedKey, WindowEvent};
use oxide_engine::window::RenderCtx;
/// Frames the atlas must stay the same size before we declare steady state.
const STEADY_FRAMES: u32 = 60;
struct UiHud {
/// Drives both the camera orbit and the minimap dot.
angle: f32,
/// Seconds elapsed — drives the HP / Ammo animations.
clock: f32,
theme: UiTheme,
font_descriptor: UiFontRef,
gpu: Option<GpuState>,
// --- atlas diagnostics ---
/// Glyph count observed on the previous frame; a change means the atlas grew.
prev_glyph_count: usize,
/// Consecutive frames the glyph count has held steady.
steady_frames: u32,
/// Set once we have logged the steady-state message, so it logs only once.
steady_logged: bool,
}
struct GpuState {
/// Forward 3D pass + its depth buffer, wrapped in a pipeline.
pipeline: RenderPipeline,
ui_pass: UiOverlayPass,
cube: GpuMesh,
sphere: GpuMesh,
plane: GpuMesh,
}
impl Default for UiHud {
fn default() -> Self {
Self {
angle: 0.0,
clock: 0.0,
theme: build_theme(),
font_descriptor: UiFontRef::regular("System"),
gpu: None,
prev_glyph_count: 0,
steady_frames: 0,
steady_logged: false,
}
}
}
fn build_theme() -> UiTheme {
let descriptor = UiFontRef::regular("System");
UiTheme::new()
.with_default(UiVisualStyle {
foreground: Some(Color::WHITE),
font: Some(descriptor.clone()),
font_size: Some(20.0),
..UiVisualStyle::EMPTY
})
// Semi-opaque chips behind the text so it stays legible over any part
// of the 3D scene.
.with_style(
"chip",
UiVisualStyle {
background: Some(Color::rgba(0.05, 0.06, 0.08, 0.55)),
..UiVisualStyle::EMPTY
},
)
.with_style(
"minimap",
UiVisualStyle {
background: Some(Color::rgba(0.05, 0.07, 0.10, 0.65)),
..UiVisualStyle::EMPTY
},
)
.with_style(
"minimap-dot",
UiVisualStyle {
background: Some(Color::rgb(0.30, 0.85, 0.45)),
..UiVisualStyle::EMPTY
},
)
.with_style(
"crosshair",
UiVisualStyle {
background: Some(Color::rgba(0.95, 0.95, 0.95, 0.85)),
..UiVisualStyle::EMPTY
},
)
}
/// A corner-pinned label chip of fixed size, holding a single line of text.
fn corner_chip(text: &str, low: bool, anchor: UiAnchor) -> Widget {
let mut leaf = Widget::leaf(Vec2::ZERO)
.with_text(text)
.with_theme_style("chip")
.with_style(UiLayoutStyle {
anchor,
padding: UiInsets::symmetric(12.0, 8.0),
align_horizontal: UiAlign::Start,
align_vertical: UiAlign::Center,
..Default::default()
});
if low {
leaf = leaf.with_visual(UiVisualStyle {
foreground: Some(Color::rgb(0.95, 0.30, 0.25)),
..UiVisualStyle::EMPTY
});
}
leaf
}
/// The full HUD document for the given gameplay values.
///
/// `margin` insets every corner from the screen edge. `dot` is the minimap
/// dot's position as a 0..1 fraction of the minimap panel.
fn build_hud(hp: i32, ammo: i32, dot: Vec2) -> Widget {
const M: f32 = 16.0; // edge margin
const CHIP_W: f32 = 150.0;
const CHIP_H: f32 = 36.0;
const MAP: f32 = 100.0;
const CROSS: f32 = 22.0;
const BAR: f32 = 2.0;
// Top-left HP chip pinned to the top-left corner.
let hp_chip = corner_chip(
&format!("HP: {hp}"),
hp < 30,
UiAnchor::TOP_LEFT.with_offsets(Vec2::new(M, M), Vec2::new(M + CHIP_W, M + CHIP_H)),
);
// Top-right Ammo chip pinned to the top-right corner.
let ammo_chip = corner_chip(
&format!("Ammo: {ammo}"),
ammo == 0,
UiAnchor::TOP_RIGHT.with_offsets(Vec2::new(-M - CHIP_W, M), Vec2::new(-M, M + CHIP_H)),
);
// Bottom-left minimap: a solid panel with a single orbiting dot.
let minimap = Widget::anchor()
.with_theme_style("minimap")
.with_style(UiLayoutStyle {
anchor: UiAnchor::BOTTOM_LEFT
.with_offsets(Vec2::new(M, -M - MAP), Vec2::new(M + MAP, -M)),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::ZERO)
.with_theme_style("minimap-dot")
.with_style(UiLayoutStyle {
anchor: UiAnchor::between(dot, dot)
.with_offsets(Vec2::splat(-4.0), Vec2::splat(4.0)),
..Default::default()
}),
);
// Centre crosshair: a horizontal + vertical bar crossing at screen centre.
let crosshair = Widget::anchor()
.with_style(UiLayoutStyle {
anchor: UiAnchor::between(Vec2::splat(0.5), Vec2::splat(0.5))
.with_offsets(Vec2::splat(-CROSS * 0.5), Vec2::splat(CROSS * 0.5)),
..Default::default()
})
.with_child(
// Horizontal bar.
Widget::leaf(Vec2::ZERO)
.with_theme_style("crosshair")
.with_style(UiLayoutStyle {
anchor: UiAnchor::between(Vec2::new(0.0, 0.5), Vec2::new(1.0, 0.5))
.with_offsets(Vec2::new(0.0, -BAR * 0.5), Vec2::new(0.0, BAR * 0.5)),
..Default::default()
}),
)
.with_child(
// Vertical bar.
Widget::leaf(Vec2::ZERO)
.with_theme_style("crosshair")
.with_style(UiLayoutStyle {
anchor: UiAnchor::between(Vec2::new(0.5, 0.0), Vec2::new(0.5, 1.0))
.with_offsets(Vec2::new(-BAR * 0.5, 0.0), Vec2::new(BAR * 0.5, 0.0)),
..Default::default()
}),
);
Widget::anchor()
.with_style(UiLayoutStyle {
width: UiSizing::Grow(1.0),
height: UiSizing::Grow(1.0),
..Default::default()
})
.with_child(hp_chip)
.with_child(ammo_chip)
.with_child(minimap)
.with_child(crosshair)
}
impl UiHud {
/// Health oscillates 10..100 (down then up), as if taking damage and healing.
fn hp(&self) -> i32 {
(55.0 + 45.0 * (self.clock * 0.8).sin()).round() as i32
}
/// Ammo counts 30 → 0 (one round every 0.25 s), reloading back to 30.
fn ammo(&self) -> i32 {
let fired = (self.clock / 0.25) as i32 % 31;
30 - fired
}
/// Minimap dot position as a 0..1 fraction, orbiting with the camera.
fn minimap_dot(&self) -> Vec2 {
Vec2::new(0.5 + 0.34 * self.angle.cos(), 0.5 + 0.34 * self.angle.sin())
}
fn hud_document(&self) -> Widget {
build_hud(self.hp(), self.ammo(), self.minimap_dot())
}
}
impl WindowApp for UiHud {
fn init(&mut self, ctx: &mut AppCtx<'_>) {
ctx.set_clear_color(Color::rgb(0.05, 0.06, 0.09));
log::info!("ui_hud: 3D scene + HUD overlay (Esc quits)");
}
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
if let WindowEvent::KeyboardInput { event: key, .. } = event {
if key.state == ElementState::Pressed && key.logical_key == Key::Named(NamedKey::Escape)
{
ctx.request_exit();
}
}
}
fn update(&mut self, ctx: &mut AppCtx<'_>) {
self.angle += ctx.dt * 0.6;
self.clock += ctx.dt;
}
fn render(&mut self, ctx: &RenderCtx<'_>) {
let device = ctx.gpu.device();
let queue = ctx.gpu.queue();
// Lazy-init GPU resources once the surface format is known.
if self.gpu.is_none() {
let mut pipeline = RenderPipeline::new();
pipeline.add_pass("forward", ForwardPass::new(device, ctx.surface_format));
let mut ui_pass = UiOverlayPass::new(device, ctx.surface_format);
match common_system_font_paths()
.iter()
.find_map(|p| Font::from_path(p).ok())
{
Some(f) => {
ui_pass
.fonts_mut()
.insert_with_descriptor(self.font_descriptor.clone(), f);
}
None => log::error!(
"No system sans-serif font found in any of {:?}. Install \
'liberation-fonts' or 'dejavu-sans' and re-run.",
common_system_font_paths()
),
}
self.gpu = Some(GpuState {
pipeline,
ui_pass,
cube: Mesh::cube().upload(device, "cube"),
sphere: Mesh::uv_sphere(0.8, 32, 16).upload(device, "sphere"),
plane: Mesh::plane(12.0).upload(device, "plane"),
});
}
let (w, h) = ctx.size;
// Build the HUD (needs &self) and paint it (needs the pass's fonts via
// an immutable borrow) before taking the &mut borrow on `self.gpu` —
// the same ordering dance `ui_menu` uses.
let viewport =
oxide_engine::math::Rect::from_min_size(Vec2::ZERO, Vec2::new(w as f32, h as f32));
let document = self.hud_document();
let tree = ui_layout(&document, viewport, 1.0);
let painted = ui_paint(
&document,
&tree,
&self.theme,
self.gpu.as_ref().unwrap().ui_pass.fonts(),
1.0,
);
let gpu = self.gpu.as_mut().unwrap();
gpu.ui_pass
.set_batches(vec![UiBatch::screen_space(painted, (w, h))]);
// Orbit the camera around the scene (same as hello_mesh).
let eye = Vec3::new(4.0 * self.angle.cos(), 2.6, 4.0 * self.angle.sin());
let view = Transform::looking_at(eye, Vec3::new(0.0, 0.2, 0.0), Vec3::Y);
let camera = Camera::default();
let objects = [
RenderObject {
mesh: &gpu.plane,
material: Material::diffuse(Color::rgb(0.25, 0.27, 0.30)),
transform: Transform::from_translation(Vec3::new(0.0, -1.0, 0.0)),
},
RenderObject {
mesh: &gpu.cube,
material: Material::diffuse(Color::rgb(0.85, 0.20, 0.15)),
transform: Transform::from_trs(
Vec3::new(-1.3, 0.0, 0.0),
Quat::from_euler(
oxide_engine::math::EulerRot::YXZ,
self.angle * 2.0,
0.4,
0.0,
),
Vec3::ONE,
),
},
RenderObject {
mesh: &gpu.sphere,
material: Material::metal(Color::rgb(0.55, 0.65, 0.95), 0.35),
transform: Transform::from_translation(Vec3::new(1.3, 0.2, 0.0)),
},
];
let lighting = Lighting::default();
let mut frame = FrameContext {
device,
queue,
color: ctx.view,
size: ctx.size,
viewport_rect: None,
clear_color: Color::rgb(0.05, 0.06, 0.09),
camera: &camera,
view_transform: &view,
lighting: &lighting,
objects: &objects,
};
// 3D first, then the HUD overlay on top — both load the surface the
// window runner already cleared.
gpu.pipeline.render(&mut frame);
gpu.ui_pass.run(&mut frame);
let glyph_count = gpu.ui_pass.atlas_glyph_count();
// End the &mut borrow on self.gpu before touching the diagnostics fields.
let _ = gpu;
self.report_atlas(glyph_count);
}
}
impl UiHud {
/// Log atlas growth and the moment it reaches steady state, proving the
/// HUD's animated digits become 100% cache hits.
fn report_atlas(&mut self, glyph_count: usize) {
if glyph_count != self.prev_glyph_count {
log::info!(
"ui_hud: glyph atlas grew to {glyph_count} glyphs (new digit/char rasterized)"
);
self.prev_glyph_count = glyph_count;
self.steady_frames = 0;
self.steady_logged = false;
return;
}
self.steady_frames += 1;
if self.steady_frames == STEADY_FRAMES && !self.steady_logged {
log::info!(
"ui_hud: atlas steady at {glyph_count} glyphs for {STEADY_FRAMES} frames — \
every subsequent frame is a 100% cache hit (no rasterize, no re-upload)"
);
self.steady_logged = true;
}
}
}
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = WindowConfig {
title: "Oxide — ui_hud".to_string(),
width: 960,
height: 540,
..Default::default()
};
run(config, UiHud::default())
}
+495
View File
@@ -0,0 +1,495 @@
//! Stage 8 example: main menu + settings panel built with the engine's UI
//! system.
//!
//! Run with:
//! cargo run -p oxide-examples --bin ui_menu
//!
//! Walks the full Stage-8 pipeline:
//!
//! - **Pieces 12** — widget tree, layout, themed visual style.
//! - **Piece 3** — text shaping + glyph atlas (loads a system font).
//! - **Piece 4a** — screen-space `UiOverlayPass` renders quads + glyphs.
//! - **Piece 5** — `Router` hit-tests cursor + tracks hover/press.
//! - **Piece 6** — immediate-mode `frame.clicked_left(...)` queries + typed
//! `WidgetValue`s for the volume slider and invert-Y checkbox.
//!
//! Main menu has Play (logs), Settings (navigates), Quit (exits). The
//! settings panel has a draggable volume slider, a clickable invert-Y
//! checkbox, and a Back button.
#![deny(warnings)]
use oxide_engine::prelude::*;
use oxide_engine::ui::text::{common_system_font_paths, Font};
use oxide_engine::window::event::{ElementState, Key, NamedKey, WindowEvent};
use oxide_engine::window::RenderCtx;
use oxide_engine::winit::event::MouseButton;
#[derive(Clone, Copy, PartialEq)]
enum Screen {
Main,
Settings,
}
struct UiMenu {
screen: Screen,
volume: f32,
invert_y: bool,
/// True while the user is dragging the volume slider — set on
/// `Pressed("volume", Left)`, cleared on release. While true the
/// slider tracks cursor.x clamped to the track even if the cursor
/// drifts outside the rect (standard "drag capture" behavior).
dragging_volume: bool,
router: UiRouter,
theme: UiTheme,
main_font_descriptor: UiFontRef,
/// Built on the first frame once the surface format is known.
gpu: Option<GpuState>,
}
struct GpuState {
ui_pass: UiOverlayPass,
}
impl Default for UiMenu {
fn default() -> Self {
Self {
screen: Screen::Main,
volume: 0.6,
invert_y: false,
dragging_volume: false,
router: UiRouter::new(),
theme: build_theme(),
main_font_descriptor: UiFontRef::regular("System"),
gpu: None,
}
}
}
fn build_theme() -> UiTheme {
let descriptor = UiFontRef::regular("System");
UiTheme::new()
.with_default(UiVisualStyle {
foreground: Some(Color::WHITE),
font: Some(descriptor.clone()),
font_size: Some(16.0),
..UiVisualStyle::EMPTY
})
.with_style(
"panel",
UiVisualStyle {
background: Some(Color::rgb(0.15, 0.16, 0.18)),
..UiVisualStyle::EMPTY
},
)
.with_style(
"button",
UiVisualStyle {
background: Some(Color::rgb(0.25, 0.27, 0.30)),
foreground: Some(Color::WHITE),
font: Some(descriptor.clone()),
font_size: Some(16.0),
..UiVisualStyle::EMPTY
},
)
.with_style(
"button-hover",
UiVisualStyle {
background: Some(Color::rgb(0.35, 0.37, 0.40)),
..UiVisualStyle::EMPTY
},
)
.with_style(
"button-primary",
UiVisualStyle {
background: Some(Color::rgb(0.20, 0.45, 0.80)),
foreground: Some(Color::WHITE),
..UiVisualStyle::EMPTY
},
)
.with_style(
"track",
UiVisualStyle {
background: Some(Color::rgb(0.10, 0.11, 0.13)),
..UiVisualStyle::EMPTY
},
)
.with_style(
"fill",
UiVisualStyle {
background: Some(Color::rgb(0.30, 0.55, 0.90)),
..UiVisualStyle::EMPTY
},
)
.with_style(
"checkbox-on",
UiVisualStyle {
background: Some(Color::rgb(0.30, 0.55, 0.90)),
..UiVisualStyle::EMPTY
},
)
.with_style(
"checkbox-off",
UiVisualStyle {
background: Some(Color::rgb(0.20, 0.22, 0.25)),
..UiVisualStyle::EMPTY
},
)
}
fn button(id: &str, text: &str, hovered: bool) -> Widget {
Widget::leaf(Vec2::new(200.0, 48.0))
.with_id(id)
.with_text(text)
.with_theme_style(if hovered { "button-hover" } else { "button" })
.with_style(UiLayoutStyle {
width: UiSizing::Fixed(200.0),
height: UiSizing::Fixed(48.0),
padding: UiInsets::all(12.0),
align_horizontal: UiAlign::Center,
align_vertical: UiAlign::Center,
..Default::default()
})
}
fn primary_button(id: &str, text: &str, hovered: bool) -> Widget {
let mut w = button(id, text, hovered);
if !hovered {
w = w.with_theme_style("button-primary");
}
w
}
fn build_main_menu(hovered: Option<&str>) -> Widget {
let is = |id: &str| hovered == Some(id);
Widget::anchor()
.with_style(UiLayoutStyle {
width: UiSizing::Grow(1.0),
height: UiSizing::Grow(1.0),
..Default::default()
})
.with_child(
Widget::column()
.with_style(UiLayoutStyle {
width: UiSizing::Fixed(240.0),
height: UiSizing::Fixed(280.0),
padding: UiInsets::all(20.0),
anchor: UiAnchor::between(Vec2::splat(0.5), Vec2::splat(0.5))
.with_offsets(Vec2::new(-120.0, -140.0), Vec2::new(120.0, 140.0)),
..Default::default()
})
.with_theme_style("panel")
.with_gap(12.0)
.with_child(
Widget::leaf(Vec2::new(200.0, 32.0))
.with_text("Oxide")
.with_style(UiLayoutStyle {
width: UiSizing::Fixed(200.0),
height: UiSizing::Fixed(32.0),
align_horizontal: UiAlign::Center,
align_vertical: UiAlign::Center,
..Default::default()
})
.with_visual(UiVisualStyle {
font_size: Some(22.0),
..UiVisualStyle::EMPTY
}),
)
.with_child(primary_button("play", "Play", is("play")))
.with_child(button("settings", "Settings", is("settings")))
.with_child(button("quit", "Quit", is("quit"))),
)
}
fn build_settings(volume: f32, invert_y: bool, hovered: Option<&str>) -> Widget {
let is = |id: &str| hovered == Some(id);
// Fill spans 0..volume of the parent's width via a percentage anchor —
// scales with whatever the track width ends up being instead of
// hardcoding pixels.
let fill_fraction = volume.clamp(0.0, 1.0);
Widget::anchor()
.with_style(UiLayoutStyle {
width: UiSizing::Grow(1.0),
height: UiSizing::Grow(1.0),
..Default::default()
})
.with_child(
Widget::column()
.with_style(UiLayoutStyle {
width: UiSizing::Fixed(320.0),
height: UiSizing::Fixed(280.0),
padding: UiInsets::all(20.0),
anchor: UiAnchor::between(Vec2::splat(0.5), Vec2::splat(0.5))
.with_offsets(Vec2::new(-160.0, -140.0), Vec2::new(160.0, 140.0)),
..Default::default()
})
.with_theme_style("panel")
.with_gap(16.0)
.with_child(
Widget::leaf(Vec2::new(200.0, 32.0))
.with_text("Settings")
.with_style(UiLayoutStyle {
width: UiSizing::Fixed(280.0),
height: UiSizing::Fixed(32.0),
align_horizontal: UiAlign::Center,
align_vertical: UiAlign::Center,
..Default::default()
})
.with_visual(UiVisualStyle {
font_size: Some(20.0),
..UiVisualStyle::EMPTY
}),
)
// Volume row: label + slider track + fill.
.with_child(
Widget::column()
.with_style(UiLayoutStyle {
width: UiSizing::Fixed(280.0),
height: UiSizing::Fixed(48.0),
..Default::default()
})
.with_gap(4.0)
.with_child(
Widget::leaf(Vec2::new(200.0, 18.0))
.with_text("Volume")
.with_style(UiLayoutStyle {
width: UiSizing::Fixed(280.0),
height: UiSizing::Fixed(18.0),
align_vertical: UiAlign::Center,
..Default::default()
}),
)
.with_child(
Widget::anchor()
.with_style(UiLayoutStyle {
width: UiSizing::Fixed(280.0),
height: UiSizing::Fixed(20.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::ZERO)
.with_id("volume")
.with_value(volume)
.with_theme_style("track")
.with_style(UiLayoutStyle {
anchor: UiAnchor::FILL,
..Default::default()
}),
)
.with_child(
Widget::leaf(Vec2::ZERO)
.with_theme_style("fill")
.with_style(UiLayoutStyle {
anchor: UiAnchor::between(
Vec2::ZERO,
Vec2::new(fill_fraction, 1.0),
),
..Default::default()
}),
),
),
)
// Invert-Y row: checkbox box (clickable) + label.
.with_child(
Widget::row()
.with_style(UiLayoutStyle {
width: UiSizing::Fixed(280.0),
height: UiSizing::Fixed(28.0),
..Default::default()
})
.with_gap(12.0)
.with_child(
Widget::leaf(Vec2::new(24.0, 24.0))
.with_id("invert_y")
.with_value(invert_y)
.with_theme_style(if invert_y {
"checkbox-on"
} else {
"checkbox-off"
})
.with_style(UiLayoutStyle {
width: UiSizing::Fixed(24.0),
height: UiSizing::Fixed(24.0),
align_vertical: UiAlign::Center,
..Default::default()
}),
)
.with_child(
Widget::leaf(Vec2::new(200.0, 24.0))
.with_text("Invert Y axis")
.with_style(UiLayoutStyle {
width: UiSizing::Grow(1.0),
height: UiSizing::Fixed(24.0),
align_vertical: UiAlign::Center,
..Default::default()
}),
),
)
.with_child(button("back", "Back", is("back"))),
)
}
impl UiMenu {
fn current_document(&self) -> Widget {
let hovered = self.router.hovered().map(|id| id.as_str());
match self.screen {
Screen::Main => build_main_menu(hovered),
Screen::Settings => build_settings(self.volume, self.invert_y, hovered),
}
}
}
impl WindowApp for UiMenu {
fn init(&mut self, ctx: &mut AppCtx<'_>) {
ctx.set_clear_color(Color::rgb(0.08, 0.09, 0.10));
log::info!("ui_menu: main menu → click Play/Settings/Quit (Esc quits)");
}
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
if let WindowEvent::KeyboardInput { event: key, .. } = event {
if key.state == ElementState::Pressed && key.logical_key == Key::Named(NamedKey::Escape)
{
ctx.request_exit();
}
}
}
fn update(&mut self, ctx: &mut AppCtx<'_>) {
let (w, h) = ctx.size();
let viewport =
oxide_engine::math::Rect::from_min_size(Vec2::ZERO, Vec2::new(w as f32, h as f32));
let document = self.current_document();
let tree = ui_layout(&document, viewport, 1.0);
let frame = self.router.process(&tree, ctx.input());
match self.screen {
Screen::Main => {
if frame.clicked_left("play") {
log::info!("Play clicked (would start a game)");
}
if frame.clicked_left("settings") {
log::info!("→ settings");
self.screen = Screen::Settings;
}
if frame.clicked_left("quit") {
ctx.request_exit();
}
}
Screen::Settings => {
if frame.clicked_left("back") {
log::info!("← back to main");
self.screen = Screen::Main;
}
if frame.clicked_left("invert_y") {
self.invert_y = !self.invert_y;
log::info!("invert_y = {}", self.invert_y);
}
// Volume slider: drag-capture.
// - Press on the track → start tracking.
// - While tracking AND mouse held: update volume from
// cursor.x clamped to the track rect, regardless of
// whether the cursor is still inside it (so dragging
// past either edge pins to 0.0 or 1.0).
// - On release: stop tracking.
if frame.pressed("volume", MouseButton::Left) {
self.dragging_volume = true;
}
if !ctx.input().mouse_held(MouseButton::Left) {
self.dragging_volume = false;
}
if self.dragging_volume {
if let Some(cursor) = ctx.input().cursor() {
if let Some(node) = tree.find(&"volume".into()) {
let new_v = ((cursor.x - node.rect.min.x) / node.rect.width().max(1.0))
.clamp(0.0, 1.0);
if (new_v - self.volume).abs() > 0.001 {
self.volume = new_v;
log::debug!("volume = {:.2}", self.volume);
}
}
}
}
}
}
}
fn render(&mut self, ctx: &RenderCtx<'_>) {
let device = ctx.gpu.device();
let queue = ctx.gpu.queue();
// Lazy-init GPU resources once the surface format is known.
if self.gpu.is_none() {
let mut ui_pass = UiOverlayPass::new(device, ctx.surface_format);
// Load a system font and register it under the same descriptor
// the theme uses.
let font = common_system_font_paths()
.iter()
.find_map(|p| Font::from_path(p).ok());
match font {
Some(f) => {
ui_pass
.fonts_mut()
.insert_with_descriptor(self.main_font_descriptor.clone(), f);
}
None => {
log::error!(
"No system sans-serif font found in any of {:?}. Install \
'liberation-fonts' or 'dejavu-sans' and re-run.",
common_system_font_paths()
);
}
}
self.gpu = Some(GpuState { ui_pass });
}
// Build the document + tree + painted frame before taking the
// mutable borrow on the pass, so self.theme + self.current_document
// (which need &self) and gpu.ui_pass (which needs &mut self.gpu)
// don't clash.
let (w, h) = ctx.size;
let viewport =
oxide_engine::math::Rect::from_min_size(Vec2::ZERO, Vec2::new(w as f32, h as f32));
let document = self.current_document();
let tree = ui_layout(&document, viewport, 1.0);
let painted = ui_paint(
&document,
&tree,
&self.theme,
self.gpu.as_ref().unwrap().ui_pass.fonts(),
1.0,
);
let gpu = self.gpu.as_mut().unwrap();
gpu.ui_pass
.set_batches(vec![UiBatch::screen_space(painted, (w, h))]);
let camera = Camera::default();
let view_transform = oxide_engine::math::Transform::default();
let lighting = Lighting::default();
let mut frame = FrameContext {
device,
queue,
color: ctx.view,
size: ctx.size,
viewport_rect: None,
clear_color: Color::rgb(0.08, 0.09, 0.10),
camera: &camera,
view_transform: &view_transform,
lighting: &lighting,
objects: &[],
};
// The window runner already cleared the surface to the configured
// clear color, so we just run the UI overlay on top.
gpu.ui_pass.run(&mut frame);
}
}
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = WindowConfig {
title: "Oxide — ui_menu".to_string(),
width: 960,
height: 600,
..Default::default()
};
run(config, UiMenu::default())
}