dragonruby-ui
SKILL.md
This skill covers DragonRuby UI patterns. Layout grid and basic label/rect output are in the main dragonruby skill.
Layout Grid
# 12×24 grid in landscape — each cell maps to pixel coords:
rect = Layout.rect(row: 0, col: 0, w: 6, h: 2)
# => { x:, y:, w:, h:, center: { x:, y: } }
point = Layout.point(row: 5, col: 4, row_anchor: 0.5, col_anchor: 0.5)
# Group of rects spaced horizontally:
group = Layout.rect_group(row: 10, dcol: 1, w: 1, h: 1, group: items)
# Visualise the grid during development:
args.outputs.debug << Layout.debug_primitives
Button
def button_prefab(rect, text, hovered: false, active: false)
bg_color = active ? [80, 160, 80] : hovered ? [60, 80, 120] : [40, 60, 100]
[
rect.merge(path: :solid, *bg_color.zip([:r, :g, :b]).to_h),
rect.merge(path: :border, r: 200, g: 200, b: 255),
rect.center.merge(text: text, alignment_enum: 1, vertical_alignment_enum: 1,
r: 255, g: 255, b: 255)
]
end
def button_clicked?(args, rect)
args.inputs.mouse.click && args.inputs.mouse.click.point.inside_rect?(rect)
end
def button_hovered?(args, rect)
args.inputs.mouse.inside_rect?(rect)
end
# Usage:
btn = Layout.rect(row: 8, col: 8, w: 8, h: 2)
args.outputs.primitives << button_prefab(btn, "Start", hovered: button_hovered?(args, btn))
if button_clicked?(args, btn)
args.state.next_scene = :game
end
Checkbox
def defaults_checkbox(id:, x:, y:, size: 24, checked: false)
{ id: id, x: x, y: y, w: size, h: size, checked: checked, changed_at: -999 }
end
def tick_checkbox(args, cb)
if args.inputs.mouse.click && args.inputs.mouse.click.point.inside_rect?(cb)
cb.checked = !cb.checked
cb.changed_at = Kernel.tick_count
end
end
def render_checkbox(cb, duration: 15)
perc = Easing.smooth_stop(start_at: cb.changed_at, duration: duration,
tick_count: Kernel.tick_count, power: 4)
perc = cb.checked ? perc : 1 - perc
fill_w = (cb.w * perc).to_i
[
cb.merge(path: :border, r: 200, g: 200, b: 200),
cb.merge(w: fill_w, path: :solid, r: 80, g: 200, b: 80)
]
end
Toggle Switch
def render_toggle(toggle, label:, duration: 15)
perc = toggle.on ? Easing.smooth_stop(start_at: toggle.changed_at, duration: duration,
tick_count: Kernel.tick_count, power: 4)
: Easing.smooth_stop(start_at: toggle.changed_at, duration: duration,
tick_count: Kernel.tick_count, power: 4, flip: true)
track_color = toggle.on ? [60, 180, 60] : [80, 80, 80]
knob_x = toggle.x + perc * (toggle.w - toggle.h)
[
toggle.merge(path: :solid, **track_color.zip([:r,:g,:b]).to_h, a: 200),
{ x: knob_x, y: toggle.y, w: toggle.h, h: toggle.h, path: :solid, r: 240, g: 240, b: 240 },
{ x: toggle.x + toggle.w + 8, y: toggle.y, text: label, r: 255, g: 255, b: 255 }
]
end
Slider
def render_slider(s)
fill_w = (s.w * s.value).to_i
[
s.merge(path: :solid, r: 50, g: 50, b: 50),
s.merge(w: fill_w, path: :solid, r: 100, g: 150, b: 255),
{ x: s.x + fill_w - 6, y: s.y - 4, w: 12, h: s.h + 8, path: :solid, r: 255, g: 255, b: 255 }
]
end
def tick_slider(args, s)
if args.inputs.mouse.held && args.inputs.mouse.inside_rect?(s)
s.value = ((args.inputs.mouse.x - s.x).to_f / s.w).clamp(0.0, 1.0)
end
end
Progress Bar
def render_progress_bar(rect, value, color: [80, 200, 80], bg_color: [40, 40, 40])
[
rect.merge(path: :solid, r: *bg_color),
rect.merge(w: (rect.w * value).to_i, path: :solid, r: *color)
]
end
Scroll View
Physics-based scrolling with momentum:
args.state.scroll_y ||= 0
args.state.scroll_dy ||= 0.0
SCROLL_FRICTION = 0.92
CONTENT_H = 2000 # total scrollable height
def tick_scroll(args)
if args.inputs.mouse.wheel
args.state.scroll_dy += args.inputs.mouse.wheel.y * 10
end
if args.inputs.mouse.click
args.state.scroll_drag_start_y = args.inputs.mouse.y
args.state.scroll_drag_start = args.state.scroll_y
elsif args.inputs.mouse.held && args.state.scroll_drag_start_y
args.state.scroll_y = args.state.scroll_drag_start +
(args.inputs.mouse.y - args.state.scroll_drag_start_y) * 2
args.state.scroll_dy = 0
elsif args.inputs.mouse.up
args.state.scroll_drag_start_y = nil
end
args.state.scroll_dy *= SCROLL_FRICTION
args.state.scroll_dy = 0 if args.state.scroll_dy.abs < 0.5
args.state.scroll_y += args.state.scroll_dy
args.state.scroll_y = args.state.scroll_y.clamp(-(CONTENT_H - 720), 0)
end
# Apply offset when rendering items:
def render_scroll_items(args, items, clip_rect)
offset_items = items.map { |i| i.merge(y: i.y + args.state.scroll_y) }
visible = Geometry.find_all_intersect_rect(clip_rect, offset_items)
args.outputs.primitives << visible
end
Menu Navigation (Keyboard / Controller / Mouse)
Support all input devices seamlessly:
def tick_menu(args)
items = args.state.menu_items
if args.inputs.last_active == :mouse
# Highlight on hover
args.state.hovered = items.find { |i| args.inputs.mouse.inside_rect?(i.rect) }
else
# Navigate with keys/controller
args.state.hovered = Geometry.rect_navigate(
rect: args.state.hovered,
rects: items,
left_right: args.inputs.key_down.left_right,
up_down: args.inputs.key_down.up_down,
using: :rect,
wrap_y: true
)
end
# Confirm selection:
if args.inputs.mouse.click && args.state.hovered
args.state.hovered.on_click&.call
elsif args.inputs.keyboard.key_down.enter || args.inputs.controller_one.key_down.a
args.state.hovered&.on_click&.call
end
end
Radial Menu
def build_radial_menu(items, cx:, cy:, radius:)
items.each_with_index.map do |item, i|
angle = 90 + (360.0 / items.length) * i
x = cx + angle.vector_x * radius - item[:w] / 2
y = cy + angle.vector_y * radius - item[:h] / 2
item.merge(x: x, y: y, menu_angle: angle)
end
end
args.state.menu_items = build_radial_menu(
[{ w: 80, h: 30, text: "Attack" }, { w: 80, h: 30, text: "Item" }],
cx: 640, cy: 360, radius: 120
)
Input Remapping
args.state.bindings ||= {
keyboard: { move_left: [:left], move_right: [:right], jump: [:space] },
controller: { move_left: [:left], move_right: [:right], jump: [:a] }
}
def action_pressed?(args, action)
device = args.inputs.last_active == :controller ? :controller : :keyboard
keys = args.state.bindings[device][action]
input = device == :controller ? args.inputs.controller_one : args.inputs.keyboard
keys.any? { |k| input.key_down_or_held?(k) }
end
def start_remapping(args, action)
args.state.remapping = action
args.state.remap_mode = true
end
def tick_remap(args)
return unless args.state.remap_mode
key = args.inputs.keyboard.truthy_keys.first
if key
args.state.bindings[:keyboard][args.state.remapping] = [key]
args.state.remap_mode = false
end
end
Tooltip
def render_tooltip(args, rect, text)
return unless args.inputs.mouse.inside_rect?(rect)
return if Kernel.tick_count - (args.state.hover_start ||= Kernel.tick_count) < 45
tw, th = args.gtk.calcstringbox(text)
tx = args.inputs.mouse.x + 12
ty = args.inputs.mouse.y + 12
args.outputs.primitives << [
{ x: tx - 4, y: ty - 4, w: tw + 8, h: th + 8, path: :solid, r: 30, g: 30, b: 30, a: 220 },
{ x: tx, y: ty, text: text, r: 255, g: 255, b: 255 }
]
end
Animated Selection Cursor (Lerp)
args.state.cursor_x ||= 0
args.state.cursor_y ||= 0
target = args.state.selected_item
args.state.cursor_x = args.state.cursor_x.lerp(target.x - 4, 0.25)
args.state.cursor_y = args.state.cursor_y.lerp(target.y - 4, 0.25)
args.outputs.borders << { x: args.state.cursor_x, y: args.state.cursor_y,
w: target.w + 8, h: target.h + 8, r: 255, g: 220, b: 0 }
Persistent UI State (save checkbox/slider values)
def save_ui_state(args)
data = args.state.checkboxes.map { |c| "#{c.id},#{c.checked}" }.join("\n")
GTK.write_file('data/ui_state.txt', data)
end
def load_ui_state(args)
raw = GTK.read_file('data/ui_state.txt')
return unless raw
raw.each_line do |line|
id, val = line.strip.split(',')
cb = args.state.checkboxes.find { |c| c.id.to_s == id }
cb.checked = val == 'true' if cb
end
end
Accessibility (Screen Reader)
# Register interactive elements for assistive technology:
args.outputs.a11y[:play_button] = {
a11y_text: "Play Game",
a11y_trait: :button,
x: btn.x, y: btn.y, w: btn.w, h: btn.h
}
args.outputs.a11y[:hp_label] = {
a11y_text: "Health: #{hp}",
a11y_trait: :label,
x: hud_x, y: hud_y, w: 100, h: 24
}
Frame-by-Frame Debug Control
Useful for in-game dev tools:
args.state.clock ||= 0
args.state.frame_by_frame ||= false
if args.inputs.keyboard.key_down.f9
args.state.frame_by_frame = !args.state.frame_by_frame
end
if args.state.frame_by_frame
args.state.clock += 1 if args.inputs.keyboard.key_down.period # '.' = next frame
else
args.state.clock += 1
end
Pulse / Attention Animation
class PulseButton
attr_accessor :rect, :text, :clicked_at
PULSE_SPLINE = [[0, 0.9, 1.0, 1.0], [1.0, 0.1, 0, 0]]
def tick(inputs)
if inputs.mouse.click && inputs.mouse.click.point.inside_rect?(@rect)
@clicked_at = Kernel.tick_count
yield if block_given?
end
end
def render
scale = if @clicked_at
1 - 0.15 * Easing.spline(@clicked_at, Kernel.tick_count, 20, PULSE_SPLINE)
else
1.0
end
scaled = Geometry.scale_rect(@rect, scale, 0.5, 0.5)
[scaled.merge(path: :solid, r: 60, g: 100, b: 180),
scaled.center.merge(text: @text, alignment_enum: 1, vertical_alignment_enum: 1)]
end
end
Weekly Installs
3
Repository
nitemaeric/drag…y-skillsFirst Seen
7 days ago
Security Audits
Installed on
cline3
gemini-cli3
github-copilot3
codex3
kimi-cli3
cursor3