Conjuration - DragonRuby GTK Framework
Warning
This project is a work in progress and is not yet ready for use.
Conjuration provides foundations for building a game in DragonRuby GTK.
The motivation behind Conjuration is to provide structure and convention to DragonRuby GTK projects, without adding constraints or limiting access to the underlying DR APIs. Think what Ruby on Rails is to Ruby.
Features
- Gameloop conventions
- Order of operations
- Setup (Run once when a scene is loaded)
- Input (Handle user input logic)
- Update (Handle game logic)
- Render (Draw things to a scene / camera (HUD, UI))
- Order of operations
- Scene Management
- Scene transitions
- Camera Management
- Look at
- Zooming
- Panning
- Following
- Multiple cameras
- Minimaps
- Camera shake
- Impact frames (Hit stops)
- Rendering
- Virtual scenes (worlds beyond the GPU texture limit)
- Viewport culling
- Chunked tile caching
- Parallax scrolling layers
- Deferred z-ordering (y-sort / depth)
- Grid projections (isometric & top-down)
- UI & HUD Management
- Flexbox Layout
- Interactive node management
- Input Management — via dragon_input, bundled as a dependency
- Action-based bindings (keyboard, mouse, controller; pure-Ruby backend, optional native Steam Input)
- Default key mapping (reserved
:ui_*actions injected automatically — see Menu input) - User remapping (in-game rebind UI, or the Steam overlay when available)
- Device-following input glyphs (prompts swap art with the last-used device)
- Debugging tools
- Debug layers
- Scene / Camera debug overlay
Installation
The recommended way to install Conjuration is with drenv, a DragonRuby environment and dependency manager.
From your project directory, add Conjuration as a dependency:
drenv add github:Nitemaeric/conjurationThen require the generated bundle at the top of mygame/app/main.rb:
require 'app/drenv_bundle.rb'drenv vendors Conjuration into mygame/vendor/ and pins it in mygame/drenv.lock. Run drenv run to sync dependencies and launch your game, or drenv bundle to refresh them without launching.
Quick Start
View the following files to get started:
Conventions
Game clock — key timings to game.clock, not Kernel.tick_count
Kernel.tick_count advances every frame, including while the game is frozen by a hit stop (Game#hit_stop). An easing or timer keyed to it therefore skips ahead when the game thaws — the freeze that should hold an animation loses the frames instead.
game.clock (delegated to scenes as scene.clock) is a frame counter advanced once per un-frozen update, so it holds still through a hit stop (and, later, a paused scene). Key easings and hand-rolled timers to it:
angle = clock * 0.02 # holds during a hit stop
angle = Kernel.tick_count * 0.02 # skips ahead when the freeze endsDragonRuby's easing math (Easing.ease, Numeric#ease) is unchanged — Conjuration just provides the correct clock to key it to. See basic_camera_scene.rb for the orbit animation driven off clock.
Draw ordering — camera.draw(sprite, z:)
camera.draw(sprite) emits immediately, in call order — the fast path, unchanged. Pass z: to defer a draw into a per-frame buffer that is flushed sorted by z after the whole world pass, so an entity can be interleaved between tiles without the scene ordering every call:
camera.draw(floor) # immediate — renders under everything z-ordered
camera.draw(sprite, z: -sprite[:y]) # y-sort: lower on screen draws in front- Immediate (no-
z:) draws always render under all z-ordered draws, whatever theirz. - Equal-
z:draws keep their call order. - y-sorting (
z: -y) is a convention, not a separate feature; the samez:handles layering, depth, and iso ordering.
Only interleaving entities pay for the sort — tiles and backgrounds stay on the immediate fast path. See basic_camera_scene.rb for the follow target y-sorted against pillars.
Menu input — works out of the box
Conjuration depends on dragon_input; drenv vendors it automatically when you add Conjuration, so you don't declare it yourself. Framework UI (menu navigation and confirm) needs no setup at all — if you never call DragonInput.setup, the first menu query bootstraps a minimal config, and either way the reserved UI actions (:ui_confirm, :ui_up/down/left/right, bound to Enter/arrows and controller A/D-pad) are injected into your action sets the first time a menu reads input, filling only gaps so your own bindings win. Calling your own DragonInput.setup later simply replaces the config; the reserved actions are re-injected into it.
-
Don't pump manually. Conjuration calls
DragonInput.tickonce per frame insideGame#tick. The pump is gated onDragonInput.config, so a game that never uses input pays only a nil-check per tick. -
Escape hatch: assign
game.input_source = your_source(any object answeringjust_pressed?(pad, action)/pressed?(pad, action)) to bypass the DragonInput wrapper entirely.game.ui_pad(default:one) picks the logical pad the framework UI listens to.
Parallax — camera.draw(layer, parallax:)
Pass parallax: (a factor below 1.0) to make a background layer scroll slower than the camera, for depth from motion:
camera.draw(far_hills, parallax: 0.3) # scrolls at 30% of camera speed
camera.draw(clouds, parallax: 0.5, z: -100) # composes with z-orderingThe camera culls and transforms each layer against its own derived view — the focal point scaled by the factor — not the real one. This is the reason it belongs in the framework: a hand-rolled parallax tests visibility against the real view_rect while drawing at the scaled position, so layer sprites near the view edges are culled (or kept) wrongly. camera.draw gets the boundary right.
- The default path (
parallax: 1.0, i.e. omitted) is the memoized fast path, unchanged and allocation-free — only layers that passparallax:do any extra work. - Zoom applies un-scaled: only the translation parallaxes, so every layer stays at the same zoom and they never warp apart as you zoom.
-
parallax:composes withz:— layer your backgrounds withz:and scroll them withparallax:independently.
See parallax_scene.rb for a side-scroller with sky, hills, clouds, and tree layers over a 1:1 ground plane.
Projections — grid ↔ world mapping (Conjuration::Projection)
Isometric is not a camera feature. The camera works in continuous world space and is projection-blind; so is TileLayer. What makes a view isometric is only (a) how grid cells map to world positions and (b) draw order — and draw order is already handled by camera.draw(sprite, z:) above. So Projection is pure, stateless maths — no engine changes:
iso = Conjuration::Projection::Isometric.new(tile_w: 64, tile_h: 32)
iso.to_world(col, row) # => { x:, y: } the tile's CENTRE in world space
iso.to_grid(x, y) # => { col:, row: } which tile a world point falls in-
to_worldreturns the tile centre, so draw the tile sprite anchored there (anchor_x: 0.5, anchor_y: 0.5); for aTileLayer/rect entry the bounding box is(x - tile_w/2, y - tile_h/2, tile_w, tile_h). An iso tile is still an axis-aligned world rect, so it chunks like any other sprite. -
to_gridis the exact inverse and does true diamond hit-testing (not the sprite's bounding box), so picking is correct right up to tile edges and corners. Feed it a world point fromcamera.to_world(inputs.mouse.rect)to pick under the cursor. -
Depth is the convention
z: col + row— a greatercol + rowplaces a tile nearer the viewer (lower on screen) and draws it on top. It rides on the z-ordering above; it is not a separate feature. -
Elevation is opt-in and free when unused.
to_world(col, row, height)raises the returned centre byheight * elevation_step(a constructor param, defaulttile_h / 2.0— one block).heightis a positional third arg, not a kwarg, so the per-frame draw/pick paths stay allocation-free;to_world(col, row)andto_world(col, row, 0)are identical.TopDown#to_worldtakes the same arg as a no-op so the family shares one signature.-
Height does not change the depth key. A tall tile still occupies its cell, so keep
z: col + row. Stacked/elevated content within one cell draws bottom-up simply by emitting in that order — the deferred z-sort is stable, so equal-zprimitives keep call order. -
Picking an elevated tile: the probe technique.
to_gridis ground-plane only;to_grid(x, y, height)un-offsets the point byheight * elevation_stepbefore the diamond test. To pick under the cursor, iterate candidate heights highest-first, and take the first whose cell actually stands that tall (heightmap[cell] == height) — a raised tile occludes the lower cells drawn behind it, so the tallest match is the visible top face. The projection stays stateless; the heightmap lives in your scene.
-
Height does not change the depth key. A tall tile still occupies its cell, so keep
-
Projection::TopDownis the identity mapping with the same contract, so isometric reads as one option in a family rather than a special case — swap the projection object and the same tile/pick code drives either view.
See isometric_scene.rb: a diamond TileLayer floor, a hand-authored heightmap with a raised plateau (top diamond plus a stacked "cliff face"), elevation-aware mouse picking via the probe above, and a knight whose feet follow the terrain height while walking behind then in front of raised ground purely from z: col + row.