ruby-sfml 3.0.0.5 → 3.0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +1 -0
  4. data/lib/sfml/app.rb +54 -3
  5. data/lib/sfml/audio/music.rb +3 -3
  6. data/lib/sfml/audio/sound.rb +2 -2
  7. data/lib/sfml/audio/sound_buffer.rb +6 -6
  8. data/lib/sfml/audio/sound_buffer_recorder.rb +3 -3
  9. data/lib/sfml/audio/sound_recorder.rb +3 -3
  10. data/lib/sfml/audio/sound_stream.rb +1 -1
  11. data/lib/sfml/graphics/animation.rb +120 -0
  12. data/lib/sfml/graphics/circle_shape.rb +1 -1
  13. data/lib/sfml/graphics/convex_shape.rb +1 -1
  14. data/lib/sfml/graphics/font.rb +4 -4
  15. data/lib/sfml/graphics/image.rb +8 -8
  16. data/lib/sfml/graphics/particle_system.rb +165 -0
  17. data/lib/sfml/graphics/rectangle_shape.rb +1 -1
  18. data/lib/sfml/graphics/render_texture.rb +2 -2
  19. data/lib/sfml/graphics/render_window.rb +26 -2
  20. data/lib/sfml/graphics/shader.rb +3 -3
  21. data/lib/sfml/graphics/shape.rb +1 -1
  22. data/lib/sfml/graphics/shape_inspectable.rb +1 -1
  23. data/lib/sfml/graphics/sprite.rb +2 -2
  24. data/lib/sfml/graphics/sprite_sheet.rb +100 -0
  25. data/lib/sfml/graphics/text.rb +2 -2
  26. data/lib/sfml/graphics/texture.rb +7 -7
  27. data/lib/sfml/graphics/texture_atlas.rb +126 -0
  28. data/lib/sfml/graphics/transformable_object.rb +2 -2
  29. data/lib/sfml/graphics/vertex_array.rb +2 -2
  30. data/lib/sfml/graphics/vertex_buffer.rb +2 -2
  31. data/lib/sfml/graphics/view.rb +3 -3
  32. data/lib/sfml/input_actions.rb +105 -0
  33. data/lib/sfml/network/ftp.rb +1 -1
  34. data/lib/sfml/network/http.rb +3 -3
  35. data/lib/sfml/network/packet.rb +2 -2
  36. data/lib/sfml/network/socket_selector.rb +1 -1
  37. data/lib/sfml/network/tcp_listener.rb +1 -1
  38. data/lib/sfml/network/tcp_socket.rb +1 -1
  39. data/lib/sfml/network/udp_socket.rb +1 -1
  40. data/lib/sfml/scene.rb +4 -0
  41. data/lib/sfml/system/vector2.rb +75 -0
  42. data/lib/sfml/system/vector3.rb +59 -0
  43. data/lib/sfml/version.rb +1 -1
  44. data/lib/sfml/window/context.rb +1 -1
  45. data/lib/sfml/window/cursor.rb +2 -2
  46. data/lib/sfml/window/window.rb +2 -2
  47. data/lib/sfml.rb +44 -0
  48. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c20f8dbdc29ef33bf370ea4a7fbadf09f9652acc84834dc47f3ca9e325635d5d
4
- data.tar.gz: f65b6a2c2799e01c45071e5801cb33056cdec961d3c52a1b4ffd209538868f51
3
+ metadata.gz: e15752d993b5c76445ea903b92227b45c169009bed4de15166ff5db1d1fbd372
4
+ data.tar.gz: bf5ae66de3f3df40c765c5aee29eef1138d1743e899e6e072e707f63ee0fcf84
5
5
  SHA512:
6
- metadata.gz: 4ef7eabaab2ffe015f2e2325926d4d41f6f941f49392d9d3d72570198d1e5df9873aeec8d4a82a32aeea3dddd2174010b0993ca0c9cfc4070eb6b6630394ec70
7
- data.tar.gz: 0d8d30d9bae64f2c84ecef83acbaebf7eda423133c362e0d7b397c90a531ad72477bbe64c61d80f10cdfc1e8a316612a2d8ca71f14fd1fe4581b5eee66510218
6
+ metadata.gz: dd013a7669a09457c0f5a6915ce8ee91ac32018146d0dae6a6af6d73f5f4dba8864778827aac10e2194c553b715a18895be056f41d0dc8002cfcc99087be4664
7
+ data.tar.gz: 41ba407931cdf67f1493187c517a59d8b981d62ae4fd7ccf051696b9348fb03046228c651d2f0be45f0edb40b5f6a2ef417f4cda6dfd7124e04a46489bb7b258
data/CHANGELOG.md CHANGED
@@ -8,6 +8,82 @@ ruby-sfml's own patch level.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [3.0.0.6] — 2026-05-12
12
+
13
+ Quality-of-life release: the CSFML 3.0 surface was already covered;
14
+ this round adds the helpers and tooling you reach for when building
15
+ on top of it.
16
+
17
+ ### Added — system
18
+
19
+ - **Vector2 / Vector3 math** — `#distance`, `#distance_sq`,
20
+ `#lerp`, `#project_on`, `#reflect`, `#clamp_length`, `#zero?`,
21
+ `#abs`, plus `Vector2#angle`, `#angle_to(other)`,
22
+ `#rotated(degrees)` / `#rotated_rad(radians)`,
23
+ `#perpendicular`, and `Vector3#angle_between`.
24
+ Both classes gain `#to_v3` / `#to_v2` for cross-dimension
25
+ promotion + scalar coercion (`2 * vec`).
26
+
27
+ ### Added — graphics
28
+
29
+ - `RenderWindow#screenshot(path)` — capture the current
30
+ back-buffer to disk (PNG / JPG / BMP / TGA inferred by
31
+ extension).
32
+ - `RenderWindow#capture_image` — same capture, returns an
33
+ in-memory `SFML::Image` for further processing.
34
+ - **`SFML::SpriteSheet`** — slice a uniformly-gridded image into
35
+ numbered frames. `.load(path, frame_size:, padding:, margin:)`,
36
+ `#region(i)`, `#region_at(col, row)`, `#sprite(i)`,
37
+ `#animation(fps:, ...)`.
38
+ - **`SFML::TextureAtlas`** — load Aseprite / TexturePacker JSON
39
+ descriptors. `.load(json_path)`, `#region(name)`,
40
+ `#sprite(name)`, `#animation(names, fps:)` (auto-derives fps
41
+ from Aseprite per-frame durations when present).
42
+ - **`SFML::Animation`** — frame-based animation that drives a
43
+ Sprite's texture_rect over time. Loop / one-shot,
44
+ `#update(dt)`, `#reset`, `#done?`. Sprite-style transform
45
+ setters (`position=`, `rotation=`, `scale=`, `origin=`,
46
+ `color=`) for ergonomic use.
47
+ - **`SFML::ParticleSystem`** — VertexArray-backed particle pool.
48
+ `#spawn(position:, velocity:, lifetime:, color:, size:)`,
49
+ optional `gravity:`, `update_particle` subclass hook for
50
+ drag / attractors / colour curves.
51
+
52
+ ### Added — game-loop
53
+
54
+ - **Fixed timestep** — `fixed_timestep N` class macro on
55
+ `SFML::App` calls `update(dt)` exactly N times per second with
56
+ a fixed dt (semi-implicit Euler accumulator, capped at 5
57
+ catch-up steps to prevent the "spiral of death"). Read
58
+ `interpolation_alpha` from `#draw` to smoothly render between
59
+ fixed updates.
60
+ - **Input actions DSL** — `action :jump, keys: [...],
61
+ scancodes: [...], mouse_buttons: [...], joy_buttons: [...]`
62
+ on `SFML::App` and `SFML::Scene`. Poll with `action_pressed?
63
+ (:name)` from `update` / `draw`; build digital axes with
64
+ `axis(negative:, positive:)`. Scene actions inherit from the
65
+ host App's actions.
66
+
67
+ ### Added — errors
68
+
69
+ - Domain-specific exception hierarchy:
70
+ - `SFML::LoadError` — asset load failures (file, memory,
71
+ stream)
72
+ - `SFML::AudioError` — capture / OpenAL / channel-map
73
+ - `SFML::NetworkError` — sockets / packet framing
74
+ - `SFML::ShaderError` — GLSL compile / link
75
+ - `SFML::GraphicsError` — generic graphics-side failures
76
+ - `SFML::WindowError` — window / context creation
77
+ All inherit from `SFML::Error`, so existing
78
+ `rescue SFML::Error` blocks keep catching everything.
79
+
80
+ ### Added — CI / tooling
81
+
82
+ - GitHub Actions release workflow (`release.yml`) — tag
83
+ `vX.Y.Z.W` triggers a gem build + push to RubyGems. Verifies
84
+ the tag matches `lib/sfml/version.rb` before publishing.
85
+ - CI badge in `README.md`.
86
+
11
87
  ## [3.0.0.5] — 2026-05-11
12
88
 
13
89
  Round-trip release: closes every remaining CSFML 3.0 gap that's
data/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  Modern, idiomatic Ruby bindings for [SFML 3.x](https://www.sfml-dev.org/) via [CSFML](https://github.com/SFML/CSFML) and [Ruby FFI](https://github.com/ffi/ffi).
7
7
 
8
8
  [![gem version](https://img.shields.io/gem/v/ruby-sfml.svg)](https://rubygems.org/gems/ruby-sfml)
9
+ [![CI](https://github.com/sOM2H/ruby-sfml/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/sOM2H/ruby-sfml/actions/workflows/ci.yml)
9
10
  [![docs](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://www.rubydoc.info/gems/ruby-sfml)
10
11
 
11
12
  > **Status:** API surface complete for SFML 3.0 — system, window, graphics (incl. stencil buffer + VBOs), audio (incl. 3D positional + custom DSP + procedural streams), network (incl. HTTP / FTP / socket selector), input (keyboard, mouse, joystick, touch, sensors), plus the higher-level `App` / `Scene` / `Assets` helpers. 410 RSpec examples, 24 runnable example folders.
data/lib/sfml/app.rb CHANGED
@@ -41,8 +41,15 @@ module SFML
41
41
  framerate vsync background
42
42
  style fullscreen
43
43
  antialiasing context
44
+ fixed_timestep
44
45
  ].freeze
45
46
 
47
+ # Cap on catch-up update calls per frame when running with
48
+ # `fixed_timestep`. Prevents the "spiral of death" where a slow
49
+ # frame queues so many physics steps that the next frame is
50
+ # even slower. After this many steps, residual time is dropped.
51
+ FIXED_TIMESTEP_MAX_CATCHUP = 5
52
+
46
53
  class << self
47
54
  CONFIG_KEYS.each do |key|
48
55
  # Each macro doubles as a reader (no args) and a writer
@@ -63,6 +70,7 @@ module SFML
63
70
  end
64
71
 
65
72
  include Keybindings # provides `on_key` + `key_handlers`
73
+ include InputActions # provides `action` + `action_bindings`
66
74
 
67
75
  # Set the scene class the app should switch into automatically
68
76
  # at `setup` time. Inheritable: a subclass that doesn't set
@@ -74,6 +82,8 @@ module SFML
74
82
  end
75
83
  end
76
84
 
85
+ include InputQueries
86
+
77
87
  attr_reader :window
78
88
  attr_accessor :background_color
79
89
 
@@ -155,15 +165,56 @@ module SFML
155
165
  def toggle_pause = (@paused = !paused?)
156
166
  def paused? = @paused == true
157
167
 
168
+ # Fraction of a fixed timestep accumulated since the last
169
+ # `update`. In range [0.0, 1.0). Use it in `#draw` to
170
+ # interpolate between the previous and current world state:
171
+ #
172
+ # def draw
173
+ # pos = @prev_pos.lerp(@curr_pos, interpolation_alpha)
174
+ # ...
175
+ # end
176
+ #
177
+ # Only meaningful when `fixed_timestep` is set; otherwise 0.
178
+ attr_reader :interpolation_alpha
179
+
158
180
  # The main entry point. Calls #setup once, then runs the
159
181
  # per-frame loop until the window closes.
182
+ #
183
+ # When `fixed_timestep N` is set on the class, `update(dt)` is
184
+ # called exactly N times per second (with a fixed dt), and
185
+ # rendering runs as fast as vsync/framerate allows. This is
186
+ # the standard pattern for deterministic physics — without it,
187
+ # large-dt frames produce different results than small-dt frames.
160
188
  def run
161
189
  setup
162
- clock = Clock.new
190
+ clock = Clock.new
191
+ ts = self.class.fixed_timestep
192
+ step_seconds = ts && (1.0 / ts)
193
+ dt_fixed = ts && Time.seconds(step_seconds)
194
+ accumulator = 0.0
195
+ @interpolation_alpha = 0.0
196
+
163
197
  while @window.open?
164
- dt = clock.restart
198
+ frame_dt = clock.restart
165
199
  @window.each_event { |event| _dispatch(event) }
166
- update(dt) unless paused?
200
+
201
+ if dt_fixed
202
+ accumulator += frame_dt.as_seconds
203
+ steps = 0
204
+ while accumulator >= step_seconds && steps < FIXED_TIMESTEP_MAX_CATCHUP
205
+ update(dt_fixed) unless paused?
206
+ accumulator -= step_seconds
207
+ steps += 1
208
+ end
209
+ # If we hit the catch-up cap, drop residual time — better
210
+ # to slightly slow the simulation than spiral into longer
211
+ # and longer frames.
212
+ accumulator = 0.0 if steps == FIXED_TIMESTEP_MAX_CATCHUP
213
+ @interpolation_alpha = accumulator / step_seconds
214
+ else
215
+ update(frame_dt) unless paused?
216
+ end
217
+
167
218
  @window.clear(@background_color)
168
219
  draw
169
220
  @window.display
@@ -7,7 +7,7 @@ module SFML
7
7
  class Music
8
8
  def self.load(path, **opts)
9
9
  ptr = C::Audio.sfMusic_createFromFile(path.to_s)
10
- raise Error, "Could not load music from #{path.inspect}" if ptr.null?
10
+ raise LoadError, "Could not load music from #{path.inspect}" if ptr.null?
11
11
 
12
12
  _wrap(ptr, opts)
13
13
  end
@@ -22,7 +22,7 @@ module SFML
22
22
  buf = FFI::MemoryPointer.new(:uint8, bytes.bytesize)
23
23
  buf.write_bytes(bytes)
24
24
  ptr = C::Audio.sfMusic_createFromMemory(buf, bytes.bytesize)
25
- raise Error, "sfMusic_createFromMemory returned NULL — unsupported format?" if ptr.null?
25
+ raise LoadError, "sfMusic_createFromMemory returned NULL — unsupported format?" if ptr.null?
26
26
 
27
27
  m = _wrap(ptr, opts)
28
28
  m.instance_variable_set(:@_memory_pin, buf) # keep buffer alive
@@ -35,7 +35,7 @@ module SFML
35
35
  def self.from_stream(io, **opts)
36
36
  stream = SFML::InputStream.new(io)
37
37
  ptr = C::Audio.sfMusic_createFromStream(stream.to_ptr)
38
- raise Error, "sfMusic_createFromStream returned NULL — unsupported format?" if ptr.null?
38
+ raise LoadError, "sfMusic_createFromStream returned NULL — unsupported format?" if ptr.null?
39
39
 
40
40
  m = _wrap(ptr, opts)
41
41
  m.instance_variable_set(:@_stream_pin, stream)
@@ -10,7 +10,7 @@ module SFML
10
10
  raise ArgumentError, "Sound requires a SFML::SoundBuffer" unless buffer.is_a?(SoundBuffer)
11
11
 
12
12
  ptr = C::Audio.sfSound_create(buffer.handle)
13
- raise Error, "sfSound_create returned NULL" if ptr.null?
13
+ raise AudioError, "sfSound_create returned NULL" if ptr.null?
14
14
  @handle = FFI::AutoPointer.new(ptr, C::Audio.method(:sfSound_destroy))
15
15
  @buffer = buffer # keep alive
16
16
  # @looping mirrors the loop flag because SFML 3's isLooping reads
@@ -243,7 +243,7 @@ module SFML
243
243
  # independent transport state (volume/pan/spatialisation/etc).
244
244
  def dup
245
245
  ptr = C::Audio.sfSound_copy(@handle)
246
- raise Error, "sfSound_copy returned NULL" if ptr.null?
246
+ raise AudioError, "sfSound_copy returned NULL" if ptr.null?
247
247
 
248
248
  copy = self.class.allocate
249
249
  copy.instance_variable_set(:@handle,
@@ -8,7 +8,7 @@ module SFML
8
8
  class SoundBuffer
9
9
  def self.load(path)
10
10
  ptr = C::Audio.sfSoundBuffer_createFromFile(path.to_s)
11
- raise Error, "Could not load sound buffer from #{path.inspect}" if ptr.null?
11
+ raise LoadError, "Could not load sound buffer from #{path.inspect}" if ptr.null?
12
12
  buf = allocate
13
13
  buf.send(:_take_ownership, ptr)
14
14
  buf
@@ -22,7 +22,7 @@ module SFML
22
22
  buf_p = FFI::MemoryPointer.new(:uint8, bytes.bytesize)
23
23
  buf_p.write_bytes(bytes)
24
24
  ptr = C::Audio.sfSoundBuffer_createFromMemory(buf_p, bytes.bytesize)
25
- raise Error, "sfSoundBuffer_createFromMemory returned NULL — unsupported format?" if ptr.null?
25
+ raise LoadError, "sfSoundBuffer_createFromMemory returned NULL — unsupported format?" if ptr.null?
26
26
 
27
27
  buf = allocate
28
28
  buf.send(:_take_ownership, ptr)
@@ -35,7 +35,7 @@ module SFML
35
35
  def self.from_stream(io)
36
36
  stream = SFML::InputStream.new(io)
37
37
  ptr = C::Audio.sfSoundBuffer_createFromStream(stream.to_ptr)
38
- raise Error, "sfSoundBuffer_createFromStream returned NULL — unsupported format?" if ptr.null?
38
+ raise LoadError, "sfSoundBuffer_createFromStream returned NULL — unsupported format?" if ptr.null?
39
39
 
40
40
  buf = allocate
41
41
  buf.send(:_take_ownership, ptr)
@@ -72,7 +72,7 @@ module SFML
72
72
  Integer(channel_count),
73
73
  Integer(sample_rate),
74
74
  map_buf, map.length)
75
- raise Error, "sfSoundBuffer_createFromSamples returned NULL" if ptr.null?
75
+ raise AudioError, "sfSoundBuffer_createFromSamples returned NULL" if ptr.null?
76
76
 
77
77
  sb = allocate
78
78
  sb.send(:_take_ownership, ptr)
@@ -97,7 +97,7 @@ module SFML
97
97
  # underlying memory block.
98
98
  def dup
99
99
  ptr = C::Audio.sfSoundBuffer_copy(@handle)
100
- raise Error, "sfSoundBuffer_copy returned NULL" if ptr.null?
100
+ raise AudioError, "sfSoundBuffer_copy returned NULL" if ptr.null?
101
101
 
102
102
  copy = self.class.allocate
103
103
  copy.send(:_take_ownership, ptr)
@@ -110,7 +110,7 @@ module SFML
110
110
  # with).
111
111
  def save(path)
112
112
  ok = C::Audio.sfSoundBuffer_saveToFile(@handle, path.to_s)
113
- raise Error, "could not save SoundBuffer to #{path.inspect}" unless ok
113
+ raise AudioError, "could not save SoundBuffer to #{path.inspect}" unless ok
114
114
  path
115
115
  end
116
116
 
@@ -14,7 +14,7 @@ module SFML
14
14
  class SoundBufferRecorder
15
15
  def initialize
16
16
  ptr = C::Audio.sfSoundBufferRecorder_create
17
- raise Error, "sfSoundBufferRecorder_create returned NULL" if ptr.null?
17
+ raise AudioError, "sfSoundBufferRecorder_create returned NULL" if ptr.null?
18
18
  @handle = FFI::AutoPointer.new(ptr, C::Audio.method(:sfSoundBufferRecorder_destroy))
19
19
  end
20
20
 
@@ -47,7 +47,7 @@ module SFML
47
47
  # outlives the recorder via the buffer's data.
48
48
  def buffer
49
49
  ptr = C::Audio.sfSoundBufferRecorder_getBuffer(@handle)
50
- raise Error, "sfSoundBufferRecorder_getBuffer returned NULL" if ptr.null?
50
+ raise AudioError, "sfSoundBufferRecorder_getBuffer returned NULL" if ptr.null?
51
51
  # Borrowed — recorder owns the underlying sf::SoundBuffer.
52
52
  buf = SoundBuffer.allocate
53
53
  buf.instance_variable_set(:@handle, ptr)
@@ -62,7 +62,7 @@ module SFML
62
62
 
63
63
  def device=(name)
64
64
  ok = C::Audio.sfSoundBufferRecorder_setDevice(@handle, name.to_s)
65
- raise Error, "could not select recording device #{name.inspect}" unless ok
65
+ raise AudioError, "could not select recording device #{name.inspect}" unless ok
66
66
  name
67
67
  end
68
68
 
@@ -75,7 +75,7 @@ module SFML
75
75
  end
76
76
 
77
77
  ptr = C::Audio.sfSoundRecorder_create(@start_cb, @process_cb, @stop_cb, nil)
78
- raise Error, "sfSoundRecorder_create returned NULL" if ptr.null?
78
+ raise AudioError, "sfSoundRecorder_create returned NULL" if ptr.null?
79
79
 
80
80
  @handle = FFI::AutoPointer.new(ptr, C::Audio.method(:sfSoundRecorder_destroy))
81
81
  end
@@ -104,7 +104,7 @@ module SFML
104
104
 
105
105
  def start(sample_rate: 44_100)
106
106
  C::Audio.sfSoundRecorder_start(@handle, Integer(sample_rate)) ||
107
- raise(Error, "sfSoundRecorder_start failed (no input device or driver error)")
107
+ raise(AudioError, "sfSoundRecorder_start failed (no input device or driver error)")
108
108
  self
109
109
  end
110
110
 
@@ -124,7 +124,7 @@ module SFML
124
124
 
125
125
  def device=(name)
126
126
  C::Audio.sfSoundRecorder_setDevice(@handle, name.to_s) ||
127
- raise(Error, "sfSoundRecorder_setDevice failed for #{name.inspect}")
127
+ raise(AudioError, "sfSoundRecorder_setDevice failed for #{name.inspect}")
128
128
  end
129
129
 
130
130
  # The channel layout the recorder is producing, as an Array of
@@ -66,7 +66,7 @@ module SFML
66
66
  nil, 0,
67
67
  nil,
68
68
  )
69
- raise Error, "sfSoundStream_create returned NULL" if ptr.null?
69
+ raise AudioError, "sfSoundStream_create returned NULL" if ptr.null?
70
70
 
71
71
  @handle = FFI::AutoPointer.new(ptr, C::Audio.method(:sfSoundStream_destroy))
72
72
 
@@ -0,0 +1,120 @@
1
+ module SFML
2
+ # A frame-based animation that drives a Sprite's `texture_rect`
3
+ # over time. Pair it with a `SpriteSheet` or `TextureAtlas`.
4
+ #
5
+ # sheet = SFML::SpriteSheet.load("hero.png", frame_size: [32, 32])
6
+ # walk = sheet.animation(fps: 12, loop: true)
7
+ #
8
+ # def update(dt)
9
+ # walk.update(dt)
10
+ # end
11
+ #
12
+ # def draw
13
+ # window.draw(walk.sprite)
14
+ # end
15
+ #
16
+ # `Animation` is a self-contained drawable — it builds its own
17
+ # internal `Sprite` and advances `texture_rect` on each `#update`.
18
+ # Use `#sprite` to access the current frame's Sprite for
19
+ # transform setters (`position=`, `rotation=`, etc.), or call
20
+ # `Animation#draw_on(target)` directly.
21
+ #
22
+ # `frames` may be:
23
+ #
24
+ # * An Array of `SFML::Rect` — used as texture_rects in order.
25
+ # * An Array of Integer indexes paired with `sprite_sheet:`.
26
+ # * Constructed implicitly via `SpriteSheet#animation` or
27
+ # `TextureAtlas#animation` (see those classes).
28
+ class Animation
29
+ # @param source [SpriteSheet, TextureAtlas, Texture] backing image
30
+ # @param frames [Array<SFML::Rect>] texture rects to cycle through
31
+ # @param fps [Numeric] frames per second
32
+ # @param loop [Boolean] restart at the end if true; pause if false
33
+ def initialize(source, frames:, fps: 12, loop: true)
34
+ raise ArgumentError, "Animation needs at least one frame" if frames.empty?
35
+
36
+ texture =
37
+ case source
38
+ when Texture then source
39
+ when SpriteSheet,
40
+ TextureAtlas then source.texture
41
+ else raise ArgumentError, "Animation source must be Texture / SpriteSheet / TextureAtlas"
42
+ end
43
+
44
+ @frames = frames
45
+ @frame_seconds = 1.0 / Float(fps)
46
+ @loop = loop
47
+ @sprite = Sprite.new(texture)
48
+ @sprite.texture_rect = @frames.first
49
+ @elapsed = 0.0
50
+ @frame_index = 0
51
+ @done = false
52
+ end
53
+
54
+ attr_reader :sprite, :frame_index
55
+
56
+ # Returns whether the animation has reached the end (only
57
+ # meaningful for non-looping animations).
58
+ def done? = @done
59
+
60
+ def playing? = !@done
61
+
62
+ # Advance by `dt` (a `SFML::Time` or seconds Float). Updates
63
+ # the internal sprite's texture_rect to the current frame.
64
+ def update(dt)
65
+ return if @done
66
+
67
+ seconds = dt.is_a?(Time) ? dt.as_seconds : Float(dt)
68
+ @elapsed += seconds
69
+
70
+ while @elapsed >= @frame_seconds
71
+ @elapsed -= @frame_seconds
72
+ @frame_index += 1
73
+ if @frame_index >= @frames.size
74
+ if @loop
75
+ @frame_index = 0
76
+ else
77
+ @frame_index = @frames.size - 1
78
+ @done = true
79
+ break
80
+ end
81
+ end
82
+ end
83
+
84
+ @sprite.texture_rect = @frames[@frame_index]
85
+ self
86
+ end
87
+
88
+ # Rewind to the first frame and clear the done flag.
89
+ def reset
90
+ @frame_index = 0
91
+ @elapsed = 0.0
92
+ @done = false
93
+ @sprite.texture_rect = @frames.first
94
+ self
95
+ end
96
+
97
+ # Total animation duration in seconds.
98
+ def duration = @frames.size * @frame_seconds
99
+
100
+ # Drawable interface — forwards to the internal Sprite.
101
+ def draw_on(target, states_ptr = nil)
102
+ @sprite.draw_on(target, states_ptr)
103
+ end
104
+
105
+ # Transform passthroughs so callers can write `anim.position =`
106
+ # instead of `anim.sprite.position =`. The full surface of
107
+ # Sprite stays accessible via `#sprite` for advanced cases
108
+ # (color, scale_by, custom blend states, etc.).
109
+ def position = @sprite.position
110
+ def position=(v); @sprite.position = v; end
111
+ def rotation = @sprite.rotation
112
+ def rotation=(v); @sprite.rotation = v; end
113
+ def scale = @sprite.scale
114
+ def scale=(v); @sprite.scale = v; end
115
+ def origin = @sprite.origin
116
+ def origin=(v); @sprite.origin = v; end
117
+ def color = @sprite.color
118
+ def color=(v); @sprite.color = v; end
119
+ end
120
+ end
@@ -15,7 +15,7 @@ module SFML
15
15
 
16
16
  def initialize(radius: 10.0, **opts)
17
17
  ptr = C::Graphics.sfCircleShape_create
18
- raise Error, "sfCircleShape_create returned NULL" if ptr.null?
18
+ raise GraphicsError, "sfCircleShape_create returned NULL" if ptr.null?
19
19
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfCircleShape_destroy))
20
20
 
21
21
  self.radius = radius
@@ -18,7 +18,7 @@ module SFML
18
18
 
19
19
  def initialize(points: nil, **opts)
20
20
  ptr = C::Graphics.sfConvexShape_create
21
- raise Error, "sfConvexShape_create returned NULL" if ptr.null?
21
+ raise GraphicsError, "sfConvexShape_create returned NULL" if ptr.null?
22
22
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfConvexShape_destroy))
23
23
 
24
24
  self.points = points if points
@@ -20,7 +20,7 @@ module SFML
20
20
 
21
21
  def self.load(path)
22
22
  ptr = C::Graphics.sfFont_createFromFile(path.to_s)
23
- raise Error, "Could not load font from #{path.inspect}" if ptr.null?
23
+ raise LoadError, "Could not load font from #{path.inspect}" if ptr.null?
24
24
 
25
25
  font = allocate
26
26
  font.send(:_take_ownership, ptr)
@@ -37,7 +37,7 @@ module SFML
37
37
  buf = FFI::MemoryPointer.new(:uint8, bytes.bytesize)
38
38
  buf.write_bytes(bytes)
39
39
  ptr = C::Graphics.sfFont_createFromMemory(buf, bytes.bytesize)
40
- raise Error, "sfFont_createFromMemory returned NULL" if ptr.null?
40
+ raise LoadError, "sfFont_createFromMemory returned NULL" if ptr.null?
41
41
 
42
42
  font = allocate
43
43
  font.send(:_take_ownership, ptr)
@@ -53,7 +53,7 @@ module SFML
53
53
  def self.from_stream(io)
54
54
  stream = SFML::InputStream.new(io)
55
55
  ptr = C::Graphics.sfFont_createFromStream(stream.to_ptr)
56
- raise Error, "sfFont_createFromStream returned NULL" if ptr.null?
56
+ raise LoadError, "sfFont_createFromStream returned NULL" if ptr.null?
57
57
 
58
58
  font = allocate
59
59
  font.send(:_take_ownership, ptr)
@@ -147,7 +147,7 @@ module SFML
147
147
  # one without affecting the other.
148
148
  def dup
149
149
  ptr = C::Graphics.sfFont_copy(@handle)
150
- raise Error, "sfFont_copy returned NULL" if ptr.null?
150
+ raise GraphicsError, "sfFont_copy returned NULL" if ptr.null?
151
151
 
152
152
  font = self.class.allocate
153
153
  font.send(:_take_ownership, ptr)
@@ -29,13 +29,13 @@ module SFML
29
29
  else
30
30
  C::Graphics.sfImage_create(size)
31
31
  end
32
- raise Error, "sfImage_create returned NULL" if ptr.null?
32
+ raise GraphicsError, "sfImage_create returned NULL" if ptr.null?
33
33
  _take_ownership(ptr)
34
34
  end
35
35
 
36
36
  def self.load(path)
37
37
  ptr = C::Graphics.sfImage_createFromFile(path.to_s)
38
- raise Error, "Could not load image from #{path.inspect}" if ptr.null?
38
+ raise LoadError, "Could not load image from #{path.inspect}" if ptr.null?
39
39
  img = allocate
40
40
  img.send(:_take_ownership, ptr)
41
41
  img
@@ -51,7 +51,7 @@ module SFML
51
51
  buf = FFI::MemoryPointer.new(:uint8, bytes.bytesize)
52
52
  buf.write_bytes(bytes)
53
53
  ptr = C::Graphics.sfImage_createFromMemory(buf, bytes.bytesize)
54
- raise Error, "sfImage_createFromMemory returned NULL — unsupported format?" if ptr.null?
54
+ raise LoadError, "sfImage_createFromMemory returned NULL — unsupported format?" if ptr.null?
55
55
 
56
56
  img = allocate
57
57
  img.send(:_take_ownership, ptr)
@@ -63,7 +63,7 @@ module SFML
63
63
  def self.from_stream(io)
64
64
  stream = SFML::InputStream.new(io)
65
65
  ptr = C::Graphics.sfImage_createFromStream(stream.to_ptr)
66
- raise Error, "sfImage_createFromStream returned NULL — unsupported format?" if ptr.null?
66
+ raise LoadError, "sfImage_createFromStream returned NULL — unsupported format?" if ptr.null?
67
67
 
68
68
  img = allocate
69
69
  img.send(:_take_ownership, ptr)
@@ -83,7 +83,7 @@ module SFML
83
83
  size[:x] = Integer(width)
84
84
  size[:y] = Integer(height)
85
85
  ptr = C::Graphics.sfImage_createFromPixels(size, buf)
86
- raise Error, "sfImage_createFromPixels returned NULL" if ptr.null?
86
+ raise LoadError, "sfImage_createFromPixels returned NULL" if ptr.null?
87
87
 
88
88
  img = allocate
89
89
  img.send(:_take_ownership, ptr)
@@ -151,7 +151,7 @@ module SFML
151
151
 
152
152
  def save(path)
153
153
  ok = C::Graphics.sfImage_saveToFile(@handle, path.to_s)
154
- raise Error, "Could not save image to #{path.inspect}" unless ok
154
+ raise LoadError, "Could not save image to #{path.inspect}" unless ok
155
155
  path
156
156
  end
157
157
 
@@ -164,11 +164,11 @@ module SFML
164
164
  # File.binwrite("out.png", png_bytes)
165
165
  def save_to_memory(format)
166
166
  buffer = C::System.sfBuffer_create
167
- raise Error, "sfBuffer_create returned NULL" if buffer.null?
167
+ raise GraphicsError, "sfBuffer_create returned NULL" if buffer.null?
168
168
 
169
169
  begin
170
170
  ok = C::Graphics.sfImage_saveToMemory(@handle, buffer, format.to_s)
171
- raise Error, "Could not encode image as #{format.inspect}" unless ok
171
+ raise LoadError, "Could not encode image as #{format.inspect}" unless ok
172
172
 
173
173
  size = C::System.sfBuffer_getSize(buffer)
174
174
  data = C::System.sfBuffer_getData(buffer)