Class: Ruby2D::Window

Inherits:
Object
  • Object
show all
Extended by:
ClassMethods
Includes:
GamepadEvents, KeyEvents, MouseEvents, ObjectEventDispatch
Defined in:
lib/ruby2d/window.rb,
lib/ruby2d/window/key_events.rb,
lib/ruby2d/window/mouse_events.rb,
lib/ruby2d/window/class_methods.rb,
lib/ruby2d/window/object_events.rb,
lib/ruby2d/window/gamepad_events.rb

Overview

The application window

Defined Under Namespace

Modules: ClassMethods, GamepadEvents, KeyEvents, MouseEvents, ObjectEventDispatch Classes: EventDescriptor, KeyEvent, MouseEvent, ObjectEventDescriptor

Constant Summary collapse

FPS_CAP_VALUES =

Accepted fps_cap values, shared by the strict constructor check and the lenient runtime setter so the two messages can't drift apart.

'nil, a positive number, :infinity, or Float::INFINITY'
VIEWPORT_MODES =

Recognized viewport: modes. The native parser silently falls back to letterbox for anything else, so validate here to surface typos rather than let viewport_mode report a value the renderer never applied. Must match R2D_ParseViewportMode in ext/ruby2d/window.c.

%i[letterbox stretch integer overscan expand fixed].freeze
EVENT_FILTER_PREDICATES =

Maps event types to the predicate used in the kwarg form of on — e.g. on key_down: :escape filters via event.key?(:escape). For gamepad events the entries map to the primary predicate (the one a bare scalar matcher targets); hash matchers like { gamepad: pad1, button: :south } derive the predicate from each key.

{
  key_down: :key?, key_held: :key?, key_up: :key?,
  mouse_down: :button?, mouse_held: :button?, mouse_up: :button?,
  gamepad_button_down: :button?, gamepad_button_held: :button?,
  gamepad_button_up:   :button?, gamepad_axis: :axis?
}.freeze
GAMEPAD_EVENT_UNPACK =

Gamepad events dispatch an internal data struct, but user blocks receive the unpacked args (gamepad / button / axis / value). This map describes the unpack for each gamepad event type.

{
  gamepad_connect:     ->(d) { [d.gamepad] },
  gamepad_disconnect:  ->(d) { [d.gamepad] },
  gamepad_button_down: ->(d) { [d.gamepad, d.button] },
  gamepad_button_held: ->(d) { [d.gamepad, d.button] },
  gamepad_button_up:   ->(d) { [d.gamepad, d.button] },
  gamepad_axis:        ->(d) { [d.gamepad, d.axis, d.value] }
}.freeze

Constants included from GamepadEvents

GamepadEvents::DEFAULT_GAMEPAD_MAPPINGS_PATH

Class Attribute Summary collapse

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from ClassMethods

current, mouse_position, register_interactive, render_ready_check, unregister_interactive

Methods included from ObjectEventDispatch

#cleanup_interaction_state, #dispatch_object_mouse_down, #dispatch_object_mouse_held, #dispatch_object_mouse_move, #dispatch_object_mouse_scroll, #dispatch_object_mouse_up, #init_object_event_stores, #register_interactive, #topmost_interactive_at, #unregister_interactive

Methods included from GamepadEvents

#add_gamepad_mapping, #clear_gamepad_frame_state, #gamepad_callback, #gamepads, #init_gamepad_event_stores, #load_default_gamepad_mappings

Methods included from MouseEvents

#mouse_callback, #mouse_held?, #mouse_inside?, #mouse_move_delta_x, #mouse_move_delta_y, #mouse_moved?, #mouse_position, #mouse_pressed?, #mouse_released?, #mouse_scroll_delta_x, #mouse_scroll_delta_y, #mouse_scroll_direction, #mouse_scrolled?

Methods included from KeyEvents

#key_callback, #key_held?, #key_pressed?, #key_released?

Constructor Details

#initialize(title: 'Ruby 2D', width: 640, height: 480, fps_cap: nil) ⇒ Window

Create a window



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/ruby2d/window.rb', line 42

def initialize(title: 'Ruby 2D', width: 640, height: 480, fps_cap: nil)
  # Ruby 2D is single-window by design: per-object events and the top-level
  # DSL all route through one shared `DSL.window`. A second window would
  # silently steal that pointer and strand the first window's objects and
  # event handlers, so refuse to create one.
  if Ruby2D::DSL.window?
    raise Error,
          'A window already exists. Ruby 2D supports a single window per ' \
          'process (it may have been created automatically on first use of ' \
          'a Window class method or the top-level DSL).'
  end

  # Title of the window
  @title = title

  # Window size
  @width  = width
  @height = height

  # Frames per second upper limit, and the actual FPS. Valid: nil (no cap),
  # a positive number, or Float::INFINITY (uncapped). The constructor is
  # strict; the runtime setter (#set) warns and falls back instead.
  fps_cap = normalize_fps_cap(fps_cap)
  unless fps_cap_valid?(fps_cap)
    raise Error, "fps_cap must be #{FPS_CAP_VALUES}, got #{fps_cap.inspect}"
  end
  @fps_cap = fps_cap
  @fps = 0

  # Total number of frames that have been rendered
  @frames = 0

  # Whether the frame loop is running, i.e. between `show` and the window
  # closing. Frame-scoped work like `screenshot` needs an end-of-frame to
  # land on, so it consults this rather than doing nothing at all.
  @running = false

  # Renderable objects currently in the window, like a linear scene graph
  @objects = []
  @object_set = {}

  init_window_defaults
  init_event_stores
  init_event_registrations
  init_procs_and_dsl

  Ext.window_create(self)

  Ruby2D::DSL.window = self
end

Class Attribute Details

.shownObject Also known as: shown?

Returns the value of attribute shown.



97
98
99
# File 'lib/ruby2d/window.rb', line 97

def shown
  @shown
end

Instance Attribute Details

#backgroundObject (readonly)

Returns the value of attribute background.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def background
  @background
end

#close_on_escObject (readonly)

Returns the value of attribute close_on_esc.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def close_on_esc
  @close_on_esc
end

#delta_timeObject (readonly)

Returns the value of attribute delta_time.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def delta_time
  @delta_time
end

#diagnosticsObject (readonly)

Returns the value of attribute diagnostics.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def diagnostics
  @diagnostics
end

#fpsObject (readonly)

Returns the value of attribute fps.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def fps
  @fps
end

#fps_capObject (readonly)

Returns the value of attribute fps_cap.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def fps_cap
  @fps_cap
end

#framesObject (readonly)

Returns the value of attribute frames.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def frames
  @frames
end

#heightObject (readonly)

Returns the value of attribute height.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def height
  @height
end

#highdpiObject (readonly)

Returns the value of attribute highdpi.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def highdpi
  @highdpi
end

#iconObject (readonly)

Returns the value of attribute icon.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def icon
  @icon
end

#mouse_xObject (readonly)

Returns the value of attribute mouse_x.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def mouse_x
  @mouse_x
end

#mouse_yObject (readonly)

Returns the value of attribute mouse_y.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def mouse_y
  @mouse_y
end

#pixel_scaleObject (readonly)

Returns the value of attribute pixel_scale.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def pixel_scale
  @pixel_scale
end

#render_modeObject (readonly)

Returns the value of attribute render_mode.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def render_mode
  @render_mode
end

#resizableObject (readonly)

Returns the value of attribute resizable.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def resizable
  @resizable
end

#show_fpsObject (readonly)

Returns the value of attribute show_fps.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def show_fps
  @show_fps
end

#titleObject (readonly)

Returns the value of attribute title.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def title
  @title
end

#viewport_heightObject (readonly)

Returns the value of attribute viewport_height.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def viewport_height
  @viewport_height
end

#viewport_modeObject (readonly)

Returns the value of attribute viewport_mode.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def viewport_mode
  @viewport_mode
end

#viewport_widthObject (readonly)

Returns the value of attribute viewport_width.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def viewport_width
  @viewport_width
end

#widthObject (readonly)

Returns the value of attribute width.



24
25
26
# File 'lib/ruby2d/window.rb', line 24

def width
  @width
end

Instance Method Details

#add(object) ⇒ Object

Add an object to the window



179
180
181
182
183
184
185
186
187
188
# File 'lib/ruby2d/window.rb', line 179

def add(object)
  case object
  when nil
    raise Error, "Cannot add `#{object.class}` to window!"
  when Array
    object.each { |x| add_object(x) }
  else
    add_object(object)
  end
end

#clearObject

Clear all objects from the window



201
202
203
204
205
# File 'lib/ruby2d/window.rb', line 201

def clear
  @objects.clear
  @object_set.clear
  init_object_event_stores
end

#closeObject

Close the window. A no-op on the web, where a page can't close itself — only the person viewing it can — so there's nothing to shut down: the :close handler doesn't fire, the window isn't marked closed, and the loop keeps running. The user's own quit still arrives as a :close event, which is handled in the event loop rather than here.



523
524
525
526
527
528
529
# File 'lib/ruby2d/window.rb', line 523

def close
  return if Ruby2D.web?

  close_callback
  Ext.window_close(self)
  @close = true
end

#close_callbackObject

Close callback method, called by the native extension



421
422
423
# File 'lib/ruby2d/window.rb', line 421

def close_callback
  @events[:close].each_value(&:call)
end

#cursorObject

Get the current cursor state



496
497
498
499
500
# File 'lib/ruby2d/window.rb', line 496

def cursor
  return :hidden unless Ext.window_cursor_visible(self)

  @cursor_style || :default
end

#cursor=(name) ⇒ Object

Set the cursor: :visible, :hidden, or a system cursor name



503
504
505
506
507
508
509
510
511
512
513
514
515
516
# File 'lib/ruby2d/window.rb', line 503

def cursor=(name)
  case name
  when :visible
    @cursor_style = :default
    Ext.window_show_cursor(self)
  when :hidden
    @cursor_style = nil
    Ext.window_hide_cursor(self)
  else
    name = name.to_sym
    @cursor_style = name
    Ext.window_set_system_cursor(self, name.to_s)
  end
end

#display_heightObject



108
109
110
111
# File 'lib/ruby2d/window.rb', line 108

def display_height
  Ext.window_get_display_dimensions(self)
  @display_height
end

#display_pixel_heightObject



118
119
120
121
# File 'lib/ruby2d/window.rb', line 118

def display_pixel_height
  Ext.window_get_display_dimensions(self)
  @display_pixel_height
end

#display_pixel_widthObject



113
114
115
116
# File 'lib/ruby2d/window.rb', line 113

def display_pixel_width
  Ext.window_get_display_dimensions(self)
  @display_pixel_width
end

#display_widthObject



103
104
105
106
# File 'lib/ruby2d/window.rb', line 103

def display_width
  Ext.window_get_display_dimensions(self)
  @display_width
end

#elapsedObject

Monotonic seconds since engine start — a cross-platform, overflow-safe clock for cooldowns, scheduling, and "time since" math. Unlike Time.now it never jumps or jitters, and reads the same on CRuby and mruby/web. For per-frame motion, prefer the dt argument to update.



233
234
235
# File 'lib/ruby2d/window.rb', line 233

def elapsed
  Ext.now
end

#get(sym) ⇒ Object

Get a window attribute by name. :window returns the Window itself (the DSL escape hatch to the full API); any other symbol reads that attribute.



125
126
127
128
129
130
# File 'lib/ruby2d/window.rb', line 125

def get(sym)
  case sym
  when :window then self
  else public_send(sym)
  end
end

#off(event_descriptor) ⇒ Object

Remove an event handler (or several). Accepts a descriptor returned by on, or an array of them (as on returns when given multiple filters).



334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/ruby2d/window.rb', line 334

def off(event_descriptor)
  return event_descriptor.each { |d| off(d) } if event_descriptor.is_a?(Array)

  case event_descriptor
  when ObjectEventDescriptor
    event_descriptor.object.off(event_descriptor)
  when EventDescriptor
    @events[event_descriptor.type].delete(event_descriptor.id)
  else
    raise Error,
          "Cannot remove event handler: expected a descriptor returned by `on`, got #{event_descriptor.inspect}"
  end
end

#on(event = nil, **filters, &proc) ⇒ Object

Set an event handler. Forms:

on(:key_down) { |event| ... }                              # all events of type
on(key_down: :escape) { ... }                              # filtered by value
on(key_down: [:left, :a]) { ... }                          # array → match any
on(key_down: :left, gamepad_button_down: :dpad_left) { }   # multi-event
on(gamepad_button_down: { gamepad: pad1, button: :south }) # hash → AND match

Raises:



268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/ruby2d/window.rb', line 268

def on(event = nil, **filters, &proc)
  raise Error, '`on` requires a block' unless proc
  if event.is_a?(Symbol) && filters.empty?
    register_event_handler(event, wrap_for_event(event, proc))
  elsif event.nil? && !filters.empty?
    descriptors = filters.map do |type, matcher|
      register_event_handler(type, build_filter_wrapper(type, matcher, proc))
    end
    descriptors.size == 1 ? descriptors.first : descriptors
  else
    raise Error, '`on` requires either an event symbol or event filters'
  end
end

#remove(object) ⇒ Object

Remove an object from the window

Raises:



191
192
193
194
195
196
197
198
# File 'lib/ruby2d/window.rb', line 191

def remove(object)
  raise Error, "Cannot remove `#{object.class}` from window!" if object.nil?
  return false unless @objects.delete(object)

  @object_set.delete(object)
  unregister_interactive(object)
  true
end

#render(z: :foreground, &proc) ⇒ Object

Set the render callback. z: places the block in the scene's z-order: :foreground (default) draws it on top of every object, :background behind them, or a number interleaves it at that depth on the same scale as object z (objects with z at or below it draw first, then the block, then the rest).

Raises:



222
223
224
225
226
227
# File 'lib/ruby2d/window.rb', line 222

def render(z: :foreground, &proc)
  raise Error, '`render` requires a block' unless proc
  @render_proc = proc
  @render_z = render_z_for(z)
  true
end

#render_callbackObject

Run the user's render block (and the overridden render method under the class pattern). Spliced into the scene by render_objects.



414
415
416
417
418
# File 'lib/ruby2d/window.rb', line 414

def render_callback
  render if @overrides_render

  @render_proc.call
end

#render_objectsObject

Iterate the z-sorted scene graph and render each visible object, splicing the user's render block into the same z-order at @render_z: objects with z at or below it draw first, then the block, then the rest. The default :foreground (+∞) draws the block last, on top of everything; :background (-∞) draws it first. Called from the native extension once per frame via tick. Each object draws via its zero-arg _render_scene hook, not the public keyword render: on wasm mruby a zero-arg call into a keyword-heavy method still pays ~5µs of keyword setup, so 100 sprites would burn half a millisecond per frame on pure dispatch.



388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# File 'lib/ruby2d/window.rb', line 388

def render_objects
  # Fast paths for the symbolic block positions: with the block pinned at
  # +∞ (`:foreground`, the default) or -∞ (`:background`) no object can
  # ever sort after (resp. before) it, so the per-object `z` read and
  # compare in the interleaving loop below could never fire — skip them.
  if @render_z == Float::INFINITY
    @objects.each { |obj| obj._render_scene if obj.visible? }
    render_callback
  elsif @render_z == -Float::INFINITY
    render_callback
    @objects.each { |obj| obj._render_scene if obj.visible? }
  else
    block_drawn = false
    @objects.each do |obj|
      if !block_drawn && obj.z > @render_z
        render_callback
        block_drawn = true
      end
      obj._render_scene if obj.visible?
    end
    render_callback unless block_drawn
  end
end

#request_renderObject

Request that the next tick render a frame. No-op in :continuous mode. Safe to call from any thread.



533
534
535
# File 'lib/ruby2d/window.rb', line 533

def request_render
  Ext.window_request_render(self) if Window.shown?
end

#screenshot(path = nil) ⇒ Object

Take a screenshot, saving to path (or a timestamped file if omitted).

The write is deferred to the end of the current frame, after the scene is drawn but before it is presented, so the capture is this frame rather than the last one. path therefore comes back before the file exists; it lands by the time the next update runs. Requesting one also forces the frame to render, so a capture in :on_demand mode never grabs a parked frame, and capturing and closing in the same tick still writes the file.

A closed window has no end-of-frame left to write on, so that raises rather than returning a path to a file that will never appear.

A no-op on the web, returning nil: the only filesystem there is Emscripten's in-memory one, so a capture would cost a framebuffer read and a PNG encode to produce a file nobody can open, and that vanishes on reload.



483
484
485
486
487
488
489
490
491
492
493
# File 'lib/ruby2d/window.rb', line 483

def screenshot(path = nil)
  return if Ruby2D.web?

  if Window.shown? && !@running
    raise Error, '`screenshot` called after the window closed; the file is ' \
                 'written at the end of a frame, so nothing would be saved'
  end

  path ||= "./screenshot-#{Time.now.utc.strftime('%Y-%m-%d--%H-%M-%S')}.png"
  Ext.window_screenshot(self, path)
end

#set(opts) ⇒ Object

Set window attributes



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/ruby2d/window.rb', line 133

def set(opts)
  # Store new window attributes, or ignore if nil
  set_any_window_properties opts
  set_any_window_dimensions opts

  Ext.window_set_size(self) if Window.shown? && (opts[:width] || opts[:height])

  if Window.shown? && (opts[:viewport] || opts[:viewport_width] || opts[:viewport_height] || !opts[:pixel_scale].nil?)
    Ext.window_set_viewport_mode(self)
  end

  if opts.key?(:fps_cap)
    cap = normalize_fps_cap(opts[:fps_cap])
    if fps_cap_valid?(cap)
      @fps_cap = cap
    else
      Ruby2D.warn "fps_cap must be #{FPS_CAP_VALUES}, got #{opts[:fps_cap].inspect}; ignoring (no cap)."
      @fps_cap = nil
    end
    Ext.window_set_fps_cap(self, @fps_cap) if Window.shown?
  end

  unless opts[:render_mode].nil?
    unless %i[continuous on_demand].include?(opts[:render_mode])
      raise Error, "`render_mode` must be :continuous or :on_demand, got #{opts[:render_mode].inspect}"
    end
    @render_mode = opts[:render_mode]
    Ext.window_set_render_mode(self) if Window.shown?
  end

  @close_on_esc = opts[:close_on_esc] unless opts[:close_on_esc].nil?

  self.cursor = opts[:cursor] unless opts[:cursor].nil?

  unless opts[:show_fps].nil?
    @show_fps = opts[:show_fps]
    Ext.window_show_fps(self, @show_fps)
  end

  unless opts[:diagnostics].nil?
    @diagnostics = opts[:diagnostics]
    Ext.window_diagnostics(self, @diagnostics)
  end
end

#showObject

Show the window

Raises:



442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
# File 'lib/ruby2d/window.rb', line 442

def show
  raise Error, 'Window#show called multiple times; Ruby 2D supports a single window per process' if Window.shown?

  @close = false
  load_default_gamepad_mappings

  if RUBY_ENGINE == 'ruby'
    # CRuby: window_show creates the window and returns — Ruby owns the loop.
    # Mark shown only after it succeeds; on failure it raises, so shown? stays
    # false and no frame dereferences a NULL renderer.
    Ext.window_show(self)
    Window.shown = true
    @running = true
    tick until @close
  else
    # mruby/WASM: window_show creates the window AND runs the loop, blocking
    # until close. Mark shown first so live updates (request_render, set title,
    # etc.) work during the run; a creation failure raises before the loop.
    Window.shown = true
    @running = true
    Ext.window_show(self)
  end

  @running = false
end

#tickObject

One frame: poll events, update, render.



426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/ruby2d/window.rb', line 426

def tick
  Ext.poll_events(self)
  # drain_events returns nil (not an empty array) on event-less frames
  raw = Ext.drain_events(self)
  dispatch_events(raw) if raw

  update_callback

  if Ext.begin_frame(self)
    render_objects
  end

  Ext.end_frame(self)
end

#update(&proc) ⇒ Object

Set the update callback

Raises:



208
209
210
211
212
213
214
215
# File 'lib/ruby2d/window.rb', line 208

def update(&proc)
  raise Error, '`update` requires a block' unless proc
  @update_proc = proc
  # Cache whether the block takes a delta-time arg; the arity never changes
  # after assignment, so the per-frame loop reads this instead of recomputing.
  @update_wants_dt = !proc.arity.zero?
  true
end

#update_callbackObject

Update callback method, called by the native and web extentions



349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
# File 'lib/ruby2d/window.rb', line 349

def update_callback
  # Monotonic seconds since the previous update. Clamped to 0.1s so a paused
  # window or stalled frame doesn't teleport the simulation when updates
  # resume; zero on the first frame. `Ext.now` (SDL_GetTicksNS, and
  # performance.now on web) is the single clock used on every runtime — the
  # same source as the public `elapsed`. A wall clock like Time.now would
  # jitter and stutter dt-scaled motion.
  now = Ext.now
  if @last_update_time
    dt = now - @last_update_time
    @delta_time = dt > 0.1 ? 0.1 : dt
  else
    @delta_time = 0.0
  end
  @last_update_time = now

  update if @overrides_update

  if @update_wants_dt
    @update_proc.call(@delta_time)
  else
    @update_proc.call
  end

  # Frame-scoped polling state (pressed/released, axes_moved, scroll/move
  # flags) lives one frame and is cleared here every tick — independent of
  # whether the user is on the DSL or class pattern, so both can poll.
  clear_event_stores
end