Class: Ruby2D::Sprite

Inherits:
Image
  • Object
show all
Defined in:
lib/ruby2d/sprite.rb

Overview

An animated sprite from a single image, a horizontal strip sprite sheet, or a SpriteSheet (texture atlas).

Constant Summary

Constants included from Interactive

Interactive::OBJECT_EVENTS, Interactive::OBJECT_EVENT_FILTER_PREDICATES

Instance Attribute Summary collapse

Attributes inherited from Image

#height, #path, #rotate, #width

Attributes included from Renderable

#color, #height, #padding_bottom, #padding_left, #padding_right, #padding_top, #visible, #width, #x, #x_align, #y, #y_align, #z

Instance Method Summary collapse

Methods inherited from Image

#rx, #rx=, #ry, #ry=, #tint, #tint=, #x=, #y=

Methods included from Renderable

#_alignment_anchor_dx, #_alignment_anchor_dy, #_apply_padding, #_extract_alignment, #_point_in_polygon?, #_point_on_segment?, #_require_numeric_position, #_resolve_alignment, #_unrotate, #_validate_dimensions, #add, #colour=, #contains?, flatten_color, flatten_per_vertex, flatten_points, flatten_resolved_color, #hide, #opacity, #opacity=, #padding=, #remove, resolve_color_or_default, resolve_single_color, #show

Methods included from Interactive

#_fire_event, #interactive?, #off, #on

Constructor Details

#initialize(source, width: nil, height: nil, x: 0, y: 0, z: 0, rotate: 0, rx: nil, ry: nil, tint: nil, opacity: nil, loop: false, time: 300, speed: 1.0, animations: nil, default: 0, frame: nil, clip_x: 0, clip_y: 0, clip_width: nil, clip_height: nil, add: true, visible: true, padding: nil, padding_top: nil, padding_right: nil, padding_bottom: nil, padding_left: nil) ⇒ Sprite

Create a sprite. source can be either an image path or a SpriteSheet instance. When given a SpriteSheet, animations may reference frames by name (strings or { name:, time: } hashes) and frame: selects a single static frame.



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/ruby2d/sprite.rb', line 21

def initialize(source, width: nil, height: nil,
               x: 0, y: 0, z: 0, rotate: 0, rx: nil, ry: nil,
               tint: nil, opacity: nil,
               loop: false, time: 300, speed: 1.0,
               animations: nil, default: 0,
               frame: nil,
               clip_x: 0, clip_y: 0, clip_width: nil, clip_height: nil,
               add: true, visible: true,
               padding: nil, padding_top: nil, padding_right: nil,
               padding_bottom: nil, padding_left: nil)
  @sheet = source.is_a?(SpriteSheet) ? source : nil

  if @sheet
    super(nil, x: x, y: y, z: z, rotate: rotate, rx: rx, ry: ry,
          tint: tint, opacity: opacity, add: false,
          padding: padding, padding_top: padding_top, padding_right: padding_right,
          padding_bottom: padding_bottom, padding_left: padding_left,
          _share_from: @sheet.texture)
  else
    super(source, x: x, y: y, z: z, rotate: rotate, rx: rx, ry: ry,
          tint: tint, opacity: opacity, add: false,
          padding: padding, padding_top: padding_top, padding_right: padding_right,
          padding_bottom: padding_bottom, padding_left: padding_left)
  end

  # Stash the resolved sheet rect (if any) so trim metadata can be
  # applied after @clip_width/@clip_height get their final values.
  sheet_rect = nil

  if frame
    raise Error, '`frame:` requires a SpriteSheet source' unless @sheet

    sheet_rect  = lookup_sheet_frame(frame.to_s)
    clip_x      = sheet_rect[:x]
    clip_y      = sheet_rect[:y]
    clip_width  = sheet_rect[:width]
    clip_height = sheet_rect[:height]
    @frame      = frame.to_s
  end

  @img_width  = @orig_width
  @img_height = @orig_height

  @flip = nil

  @loop = loop
  @frame_time = time
  self.speed = speed
  @animations = normalize_animations(animations || {})
  @current_frame = default

  # When a sheet is in use and no explicit clip / frame was given, fall back
  # to the first frame in the sheet so the sprite shows something useful.
  if @sheet && !frame && clip_width.nil? && clip_height.nil?
    first = first_sheet_rect
    if first
      sheet_rect  = first
      clip_x      = first[:x]
      clip_y      = first[:y]
      clip_width  = first[:width]
      clip_height = first[:height]
    end
  end

  @clip_x = clip_x
  @clip_y = clip_y
  @clip_width  = (clip_width  || @img_width ).to_i
  @clip_height = (clip_height || @img_height).to_i
  @clip_width  = @img_width  if @clip_width  <= 0
  @clip_height = @img_height if @clip_height <= 0

  # Trim metadata. For atlas frames it can come from the sheet rect;
  # for path-based sprites and untrimmed atlas frames it falls back
  # to the no-trim defaults (source = clip, trim = 0). With those
  # defaults, the C draw path's trim math collapses to today's
  # untrimmed behavior.
  @source_width  = (sheet_rect && sheet_rect[:source_width])  || @clip_width
  @source_height = (sheet_rect && sheet_rect[:source_height]) || @clip_height
  @trim_x = (sheet_rect && sheet_rect[:trim_x]) || 0
  @trim_y = (sheet_rect && sheet_rect[:trim_y]) || 0

  # Track user-provided display dimensions so animations can update
  # @width/@height to match the source (footprint) dimensions when no
  # explicit size was given.
  @user_width  = width
  @user_height = height
  @width  = width  || @source_width
  @height = height || @source_height

  @clipped = true

  setup_animation

  @visible = visible
  self.add if add
end

Instance Attribute Details

#clip_heightObject

Returns the value of attribute clip_height.



8
9
10
# File 'lib/ruby2d/sprite.rb', line 8

def clip_height
  @clip_height
end

#clip_widthObject

Returns the value of attribute clip_width.



8
9
10
# File 'lib/ruby2d/sprite.rb', line 8

def clip_width
  @clip_width
end

#clip_xObject

Returns the value of attribute clip_x.



8
9
10
# File 'lib/ruby2d/sprite.rb', line 8

def clip_x
  @clip_x
end

#clip_yObject

Returns the value of attribute clip_y.



8
9
10
# File 'lib/ruby2d/sprite.rb', line 8

def clip_y
  @clip_y
end

#flipObject

Returns the value of attribute flip.



7
8
9
# File 'lib/ruby2d/sprite.rb', line 7

def flip
  @flip
end

#frameObject

Returns the value of attribute frame.



7
8
9
# File 'lib/ruby2d/sprite.rb', line 7

def frame
  @frame
end

#sheetObject (readonly)

Returns the value of attribute sheet.



7
8
9
# File 'lib/ruby2d/sprite.rb', line 7

def sheet
  @sheet
end

#speedObject

Returns the value of attribute speed.



7
8
9
# File 'lib/ruby2d/sprite.rb', line 7

def speed
  @speed
end

Instance Method Details

#_render_sceneObject

Scene-graph draw hook (see Renderable#_render_scene): advance the animation and draw the current frame, minus render's keyword handling — a zero-arg call into the 11-keyword render still pays ~5µs of keyword setup on wasm mruby, half a millisecond per frame at 100 sprites.



421
422
423
424
425
# File 'lib/ruby2d/sprite.rb', line 421

def _render_scene
  _resolve_alignment
  update
  Ext.image_draw(self)
end

#height=(h) ⇒ Object

Set the displayed height. See width= — persists across frames; nil resumes source-tracking.



145
146
147
148
# File 'lib/ruby2d/sprite.rb', line 145

def height=(h)
  @user_height = h
  @height = h
end

#loop=(value) ⇒ Object

Set whether the current animation loops, mid-play, without restarting it (unlike re-calling play). Takes effect at the next loop boundary while the animation is still playing: turning it off lets a cycling animation finish — and fire any completion block — when it next reaches the last frame; turning it on keeps it cycling. It does not resume an animation that has already finished and stopped — call play to restart that.



161
162
163
# File 'lib/ruby2d/sprite.rb', line 161

def loop=(value)
  @loop = value ? true : false
end

#looping?Boolean

Whether the current animation loops

Returns:

  • (Boolean)


151
152
153
# File 'lib/ruby2d/sprite.rb', line 151

def looping?
  @loop
end

#pauseObject

Pause the current animation on its current frame. Idempotent and only meaningful while an animation is playing — calling it on an idle sprite is a no-op.



205
206
207
208
209
210
211
# File 'lib/ruby2d/sprite.rb', line 205

def pause
  return self unless @playing

  @playing = false
  @paused = true
  self
end

#paused?Boolean

Whether the sprite is currently paused

Returns:

  • (Boolean)


226
227
228
# File 'lib/ruby2d/sprite.rb', line 226

def paused?
  @paused == true
end

#play(animation: :default, loop: nil, flip: nil, &done_proc) ⇒ Object

Start playing an animation. Pass loop: to override the sprite's default loop setting and a block to run when a non-looping animation finishes.



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/ruby2d/sprite.rb', line 167

def play(animation: :default, loop: nil, flip: nil, &done_proc)
  anim_name = animation || :default

  if @playing && anim_name == @playing_animation && flip == @flip
    # Same animation already running (same flip): don't restart it — that
    # would jump back to frame 0 — but still honor an explicitly-passed
    # `loop:` or completion block so callers can adjust them mid-play.
    # Anything left unset is preserved, so a per-frame `play(:state)` call
    # stays a safe no-op.
    @loop = loop ? true : false unless loop.nil?
    @done_proc = done_proc if done_proc
  else
    frames = @animations[anim_name]
    # Validate up front, before mutating any state, so a typo'd or empty
    # animation fails clearly here instead of crashing later in `update`.
    raise Error, "Animation `#{anim_name}` is not defined for this sprite" if frames.nil?
    raise Error, "Animation `#{anim_name}` has no frames" if frames.is_a?(Array) && frames.empty?

    @playing = true
    @paused = false
    @playing_animation = anim_name
    @done_proc = done_proc

    self.flip = flip
    reset_playing_animation

    loop = @defaults[:loop] if loop.nil?
    @loop = loop ? true : false

    set_frame
    @frame_budget = 0.0   # first frame gets its full duration
  end
  self
end

#playing?Boolean

Whether an animation is actively playing. False when idle, paused, or held on the last frame of a finished non-looping animation.

Returns:

  • (Boolean)


232
233
234
# File 'lib/ruby2d/sprite.rb', line 232

def playing?
  @playing == true
end

#render(x: nil, y: nil, width: nil, height: nil, rotate: nil, clip_x: nil, clip_y: nil, clip_width: nil, clip_height: nil, tint: nil, opacity: nil) ⇒ Object

Render the sprite. With no arguments it draws the same frame the scene graph does (delegating to _render_scene) — advancing the animation and drawing the current frame. Called with overrides for one-shot rendering inside a render block (one-shot does not advance the animation).



358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/ruby2d/sprite.rb', line 358

def render(x: nil, y: nil, width: nil, height: nil, rotate: nil,
           clip_x: nil, clip_y: nil, clip_width: nil, clip_height: nil,
           tint: nil, opacity: nil)
  if x.nil? && y.nil? && width.nil? && height.nil? && rotate.nil? &&
     clip_x.nil? && clip_y.nil? && clip_width.nil? && clip_height.nil? &&
     tint.nil? && opacity.nil?
    return _render_scene
  end

  Window.render_ready_check

  saved_x, saved_y = @x, @y
  saved_width, saved_height = @width, @height
  saved_rotate = @rotate
  saved_clip_x, saved_clip_y = @clip_x, @clip_y
  saved_clip_width, saved_clip_height = @clip_width, @clip_height
  saved_source_w, saved_source_h = @source_width, @source_height
  saved_trim_x, saved_trim_y = @trim_x, @trim_y
  saved_color = @color

  @x = x if x
  @y = y if y
  @width = width if width
  @height = height if height
  @rotate = rotate if rotate
  @clip_x = clip_x if clip_x
  @clip_y = clip_y if clip_y
  @clip_width = clip_width if clip_width
  @clip_height = clip_height if clip_height

  # Override draws use no trim — the caller is being explicit about
  # source rect and display size, so collapse the trim math to the
  # straightforward `draw clip into (x, y, width, height)` case.
  @source_width  = @clip_width
  @source_height = @clip_height
  @trim_x = 0
  @trim_y = 0

  if tint || opacity
    @color = tint ? Color.new(tint) : Color.new(saved_color)
    @color.opacity = opacity if opacity
  end

  begin
    Ext.image_draw(self)
  ensure
    @x, @y = saved_x, saved_y
    @width, @height = saved_width, saved_height
    @rotate = saved_rotate
    @clip_x, @clip_y = saved_clip_x, saved_clip_y
    @clip_width, @clip_height = saved_clip_width, saved_clip_height
    @source_width, @source_height = saved_source_w, saved_source_h
    @trim_x, @trim_y = saved_trim_x, saved_trim_y
    @color = saved_color
  end
end

#resize!(width = @width, height = @height) ⇒ Object

A SpriteSheet-backed sprite shares one backing texture with every other sprite cut from the same sheet. Re-rasterizing it (Image#resize!) would silently corrupt all of them and invalidate the sheet's frame coordinates, so refuse it. Path/strip-backed sprites own their texture and resize fine.



122
123
124
125
126
127
128
129
130
131
# File 'lib/ruby2d/sprite.rb', line 122

def resize!(width = @width, height = @height)
  if @sheet
    raise Error,
          'Cannot resize! a SpriteSheet-backed sprite: its texture is shared ' \
          'by every sprite cut from the same sheet. Use a standalone Image, or ' \
          'set width/height to change only this sprite\'s display size.'
  end

  super
end

#resumeObject

Resume the animation paused by pause, picking up at the current frame. The frame budget is reset so the first frame after resume gets its full duration.



216
217
218
219
220
221
222
223
# File 'lib/ruby2d/sprite.rb', line 216

def resume
  return self unless @paused

  @paused = false
  @playing = true
  @frame_budget = 0.0
  self
end

#stop(animation = nil) ⇒ Object

Stop the current animation and set to the default frame



271
272
273
274
275
276
277
278
279
# File 'lib/ruby2d/sprite.rb', line 271

def stop(animation = nil)
  return unless !animation || animation == @playing_animation

  @playing = false
  @paused = false
  @playing_animation = @defaults[:animation]
  @current_frame = @defaults[:frame]
  set_frame
end

#update(dt = UPDATE_DT_UNSET) ⇒ Object

Advance the animation by one frame of real time and update the clip rect. Called with no arguments from the scene-graph loop, where dt defaults to the engine's shared frame delta (Window.delta_time) — the same value an update do |dt| block receives. Driving every sprite off that one clock (rather than each polling its own) keeps them in lockstep, lets the engine clamp stalls once, and makes update(dt) directly testable. Pass an explicit dt (in seconds) to drive the animation by hand.



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
# File 'lib/ruby2d/sprite.rb', line 297

def update(dt = UPDATE_DT_UNSET)
  return unless @playing

  # Resolve the shared frame delta only now that we know the sprite is
  # playing — the no-arg scene-graph call hits this every frame per sprite.
  dt = Window.delta_time if dt.equal?(UPDATE_DT_UNSET)

  # Bank the elapsed time, scaled by `@speed` (0.0 freezes, 2.0 is double
  # speed), then spend it one whole frame at a time. Looping here — rather
  # than a single step per call — lets a high `speed` skip frames and a long
  # frame catch up, instead of capping at one frame per tick. Each frame is
  # charged its own `time:`, and the unspent remainder stays banked so
  # playback doesn't slowly drift. The budget is in milliseconds.
  @frame_budget += dt * @speed * 1000.0

  cycle    = @last_frame - @first_frame + 1   # frames in one full loop
  steps    = 0
  finished = false

  while @playing && steps < cycle
    ft = @frame_time || @defaults[:frame_time]
    break if ft.nil? || ft <= 0 || @frame_budget < ft

    @frame_budget -= ft
    steps += 1
    @current_frame += 1

    if @current_frame > @last_frame
      if @loop
        @current_frame = @first_frame
      else
        # Hold on the last frame and stop advancing. The user can call
        # `stop` (or `play` something else) to leave the pose. This lets a
        # death animation linger on its corpse pose, a jump animation hold
        # mid-air, an attack hold its follow-through, etc.
        @current_frame = @last_frame
        @playing = false
        finished = true
      end
    end
    set_frame   # refresh the clip rect and pick up the next frame's `time:`
  end

  # Cap a runaway-fast loop (an absurd `speed`) at one cycle per update so it
  # can't spin; drop the unspent budget rather than letting it grow unbounded.
  @frame_budget = 0.0 if @playing && steps == cycle && @frame_budget >= (@frame_time || @defaults[:frame_time])

  # Fire the completion block last — after the bookkeeping above — so a
  # `play` chained inside it has the final say. Clear it first so the chained
  # play can install its own block without us seeing a stale reference.
  return unless finished && @done_proc

  kept_done_proc = @done_proc
  @done_proc = nil
  kept_done_proc.call
end

#width=(w) ⇒ Object

Set the displayed width. Like the width: constructor option, this persists across animation frames (stored as the user override). A sprite without an explicit size tracks each frame's source dimensions; set to nil to resume that source-tracking. Overrides Image's plain attr_accessor, which wrote @width directly and was silently reset by the next animation tick.



138
139
140
141
# File 'lib/ruby2d/sprite.rb', line 138

def width=(w)
  @user_width = w
  @width = w
end