Camera, text and UI
Big worlds need a camera, and games need words on screen. Everything here is drawn with sprites, so it batches with the rest of the frame.
World space and screen space
There are two kinds of coordinates:
- World units: sprites,
ctx.draw, and the shapes below. They go through the camera. - Window pixels: UI and text, drawn on top of the world.
(0, 0)is the window’s top-left. SetDrawParams { screen: true, .. }to draw a sprite in window pixels (an icon in a HUD).
The default camera shows world point (0, 0) at the top-left at zoom 1, so
until you move it, world units are window pixels.
The camera
ctx.camera is a Camera2D (C++:
ctx.camera() / ctx.set_camera(c) with the same two fields):
position (the world point at the window’s top-left) and zoom (window
pixels per world unit). Its methods do the usual jobs:
| Want to | Call |
|---|---|
| follow something | ctx.camera.look_at(hero) every frame |
| zoom toward the mouse | ctx.camera.zoom_at(ctx.mouse_pos(), 1.1) |
| know what the mouse points at | ctx.mouse_world() |
| put a label on a unit | ctx.camera.world_to_screen(unit) |
| skip work for things off screen | ctx.camera.visible_rect().contains(p) |
For looking around a big world, add the ready-made system:
#![allow(unused)]
fn main() {
Game::new("Colony", 1280, 720)
.system(camera_controls) // right/middle drag pans, wheel zooms
}
crabby::game("Colony", 1280, 720)
.camera_controls() // right/middle drag pans, wheel zooms
The ants example starts zoomed out to fit every colony:
#![allow(unused)]
fn main() {
let fit = ctx.window_size() / bounds;
ctx.camera.zoom = fit.x.min(fit.y);
ctx.camera.look_at(bounds / 2.0);
}
const crabby::vec2 window = ctx.window_size();
auto cam = ctx.camera();
cam.zoom = std::min(window.x / bounds.x, window.y / bounds.y);
cam.position = bounds / 2 - window / (2 * cam.zoom); // look at the middle
ctx.set_camera(cam);
Shapes
In world units, like sprites:
#![allow(unused)]
fn main() {
ctx.draw_rect(Rect::new(0.0, 0.0, 200.0, 20.0), Color::hex(0x3a5a40));
ctx.draw_line(from, to, 2.0, Color::RED);
ctx.draw_circle(tower, 120.0, Color::BLUE.with_alpha(0.2));
}
ctx.draw_rect(crabby::rect_at({0, 0}, {200, 20}), crabby::rgb(0.23f, 0.35f, 0.25f));
ctx.draw_line(from, to, 2, crabby::colors::red);
ctx.draw_circle(tower, 120, crabby::rgba(0, 0, 1, 0.2f));
C++ rectangles are {min, max}; crabby::rect_at(min, size) makes one
from a corner and a size, like Rust’s Rect::new.
Text
#![allow(unused)]
fn main() {
ctx.draw_text("Hello", vec2(20.0, 20.0), 24.0, Color::WHITE);
let size = ctx.measure_text("Paused", 32.0); // for centering
let title = ctx.load_font("fonts/Title.ttf"); // TrueType or OpenType
ctx.draw_text_ex("Colony", vec2(20.0, 60.0), TextParams { font: title, size: 40.0, ..Default::default() });
}
ctx.draw_text("Hello", {20, 20}, 24, crabby::colors::white);
const crabby::vec2 size = ctx.measure_text("Paused", 32); // for centering
const crabby::font title = ctx.load_font("fonts/Title.ttf"); // TrueType or OpenType
auto params = crabby::text_defaults();
params.font = title;
params.size = 40;
ctx.draw_text("Colony", {20, 60}, params);
In C++, draw_text(text, pos, params) is draw_text_ex, with
crabby::text_defaults() to start from.
sizeis the line height in window pixels;\nstarts a new line.- The built-in font is Ubuntu Light (Ubuntu Font Licence).
- Glyphs are rasterized at the screen’s real pixel size the first time they’re drawn and kept in an atlas texture, so text is sharp on Retina screens and costs only a lookup per glyph after that.
measure_textworks headless too, so layout code can be tested.
UI
A few immediate-mode widgets, drawn in window pixels. Call them every frame:
#![allow(unused)]
fn main() {
fn hud(ctx: &mut Ctx) {
let panel = Rect::new(12.0, 12.0, 230.0, 172.0);
ctx.ui_panel(panel);
let mut at = panel.min + ctx.ui_style.padding;
let (zoom, dropped) = (ctx.camera.zoom, ctx.world.resource::<Scene>().dropped);
let lines = format!("zoom {zoom:.2}x\ncrabs dropped: {dropped}\n{:.0} FPS", ctx.fps());
at.y += ctx.ui_label(at, &lines).y + 8.0;
let follow = ctx.world.resource::<Scene>().follow;
let label = if follow { "Stop following" } else { "Follow orbiter" };
if ctx.ui_button(Rect::new(at.x, at.y, 210.0, 30.0), label) {
ctx.world.resource_mut::<Scene>().follow = !follow;
}
at.y += 38.0;
if ctx.ui_button(Rect::new(at.x, at.y, 210.0, 30.0), "Reset camera") {
ctx.camera.zoom = 1.0;
ctx.camera.position = Vec2::ZERO;
ctx.world.resource_mut::<Scene>().follow = false;
}
}
}
struct Hud {
int dropped = 0;
bool follow = false;
void operator()(crabby::ctx& ctx) {
const crabby::rect panel = crabby::rect_at({12, 12}, {230, 172});
ctx.ui_panel(panel);
const float pad = ctx.ui_style().padding;
crabby::vec2 at = panel.min + crabby::vec2{pad, pad};
char lines[128];
std::snprintf(lines, sizeof lines, "zoom %.2fx\ncrabs dropped: %d\n%.0f FPS",
double(ctx.camera().zoom), dropped, double(ctx.fps()));
at.y += ctx.ui_label(at, lines).y + 8;
if (ctx.ui_button(crabby::rect_at(at, {210, 30}), follow ? "Stop following" : "Follow orbiter"))
follow = !follow;
at.y += 38;
if (ctx.ui_button(crabby::rect_at(at, {210, 30}), "Reset camera")) {
ctx.set_camera({{0, 0}, 1});
follow = false;
}
}
};
// crabby::game("UI", 1280, 720).camera_controls().system(Hud{}, "hud");
ui_panel(rect): a background.ui_label(pos, text): text in the style’s size and color; returns the space it took, so you can stack lines.ui_button(rect, label):trueon the frame it’s clicked.ctx.ui_stylesets colors, text size and padding (C++:ui_style(),set_ui_style(s)).ctx.mouse_over_ui()says whether the mouse is over UI, so a click on a button doesn’t also act on the world behind it.camera_controlschecks it before zooming.
The whole scene from cargo run --release --example ui:
#![allow(unused)]
fn main() {
fn scene(ctx: &mut Ctx) {
let t = ctx.time() as f32;
// The world: a grid of tiles, a few shapes, a crab in orbit.
for y in 0..20 {
for x in 0..20 {
let shade = if (x + y) % 2 == 0 { 0.16 } else { 0.19 };
let tile = Rect::new(x as f32 * 64.0, y as f32 * 64.0, 64.0, 64.0);
ctx.draw_rect(tile, Color::rgb(shade, shade + 0.03, shade));
}
}
let center = vec2(640.0, 640.0);
ctx.draw_circle(center, 180.0, Color::hex(0x3a86ff).with_alpha(0.15));
let orbit = center + Vec2::from_angle(t * 0.6) * 180.0;
ctx.draw_line(center, orbit, 3.0, Color::hex(0xffbe0b));
let crab = ctx.world.resource::<Scene>().crab;
ctx.draw_ex(
&crab,
orbit,
DrawParams {
rotation: t,
..Default::default()
},
);
// A label that sticks to the crab: world position -> window pixels.
let above = ctx.camera.world_to_screen(orbit) + vec2(-28.0, -40.0);
ctx.draw_text("orbiter", above, 16.0, Color::hex(0xffbe0b));
if ctx.world.resource::<Scene>().follow {
ctx.camera.look_at(orbit);
}
if ctx.mouse_pressed(MouseButton::Left) && !ctx.mouse_over_ui() {
let at = ctx.mouse_world();
ctx.world.spawn((Transform2D::at(at), Sprite::new(crab)));
let plop = ctx.world.resource::<Scene>().plop;
let speed = 0.8 + ctx.random().stream(0).f32() * 0.4;
ctx.play_sound_ex(
&plop,
SoundParams {
speed,
..Default::default()
},
);
ctx.world.resource_mut::<Scene>().dropped += 1;
}
}
}
For agents
The control endpoint has camera.get and camera.set, and ponko-mcp
has a set_camera tool. Combined with screenshot, an agent can look at
any part of a running world.