Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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. Set DrawParams { 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 toCall
follow somethingctx.camera.look_at(hero) every frame
zoom toward the mousectx.camera.zoom_at(ctx.mouse_pos(), 1.1)
know what the mouse points atctx.mouse_world()
put a label on a unitctx.camera.world_to_screen(unit)
skip work for things off screenctx.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.

  • size is the line height in window pixels; \n starts 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_text works 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): true on the frame it’s clicked.
  • ctx.ui_style sets 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_controls checks 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.