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
@@ -0,0 +1,165 @@
1
+ module SFML
2
+ # A pool-backed particle emitter built on top of `SFML::VertexArray`.
3
+ # Each particle is two triangles (a quad) so a single CSFML draw
4
+ # call ships thousands at once.
5
+ #
6
+ # class Sparks < SFML::ParticleSystem
7
+ # def emit_one
8
+ # angle = rand(0.0..2 * Math::PI)
9
+ # speed = rand(80.0..200.0)
10
+ # spawn(
11
+ # position: @origin,
12
+ # velocity: SFML::Vector2.new(Math.cos(angle), Math.sin(angle)) * speed,
13
+ # lifetime: 0.8,
14
+ # color: SFML::Color.new(255, 200, 50),
15
+ # size: 4,
16
+ # )
17
+ # end
18
+ # end
19
+ #
20
+ # sparks = Sparks.new(max: 500)
21
+ #
22
+ # def update(dt)
23
+ # 5.times { sparks.emit_one } if mouse_down?
24
+ # sparks.update(dt)
25
+ # end
26
+ #
27
+ # def draw
28
+ # window.draw(sparks)
29
+ # end
30
+ #
31
+ # Particles fade linearly from full alpha at spawn to zero alpha
32
+ # at `lifetime`. Override `#update_particle(p, dt)` to apply
33
+ # gravity, drag, custom colour curves, etc.
34
+ #
35
+ # Acceleration: pass `gravity:` to the constructor for the common
36
+ # case (px/s² added to velocity every frame). For richer behaviour,
37
+ # subclass and override.
38
+ class ParticleSystem
39
+ # A single particle. Plain Struct so allocations stay cheap;
40
+ # mutated in-place from `#update`.
41
+ Particle = Struct.new(
42
+ :x, :y,
43
+ :vx, :vy,
44
+ :age, :lifetime,
45
+ :r, :g, :b, :a,
46
+ :size,
47
+ ) do
48
+ def alive? = age < lifetime
49
+ def normalized_age = age / lifetime
50
+ end
51
+
52
+ DEFAULT_GRAVITY = [0.0, 0.0].freeze
53
+
54
+ def initialize(max: 1000, gravity: DEFAULT_GRAVITY, texture: nil)
55
+ @max = Integer(max)
56
+ @gravity_x = Float(gravity[0])
57
+ @gravity_y = Float(gravity[1])
58
+ @texture = texture
59
+ @particles = []
60
+ @vertex_array = VertexArray.new(:triangles)
61
+ end
62
+
63
+ attr_reader :particles, :texture
64
+ def size = @particles.size
65
+ def empty? = @particles.empty?
66
+ def full? = @particles.size >= @max
67
+
68
+ # Spawn a new particle. Silently dropped if the pool is full
69
+ # (better than reallocating; users can size up `max:`).
70
+ #
71
+ # @param position [Vector2, Array] world coords
72
+ # @param velocity [Vector2, Array] px/s
73
+ # @param lifetime [Numeric] seconds
74
+ # @param color [SFML::Color]
75
+ # @param size [Numeric] half-side of the quad in pixels
76
+ def spawn(position:, velocity: [0, 0], lifetime: 1.0, color: Color.white, size: 4)
77
+ return if full?
78
+
79
+ px, py = _xy(position)
80
+ vx, vy = _xy(velocity)
81
+
82
+ @particles << Particle.new(
83
+ Float(px), Float(py),
84
+ Float(vx), Float(vy),
85
+ 0.0, Float(lifetime),
86
+ color.r, color.g, color.b, color.a,
87
+ Float(size),
88
+ )
89
+ self
90
+ end
91
+
92
+ # Drop every live particle.
93
+ def clear
94
+ @particles.clear
95
+ self
96
+ end
97
+
98
+ # Advance all particles by `dt` and remove dead ones.
99
+ def update(dt)
100
+ seconds = dt.is_a?(Time) ? dt.as_seconds : Float(dt)
101
+ gx, gy = @gravity_x, @gravity_y
102
+
103
+ @particles.each do |p|
104
+ next unless p.alive?
105
+
106
+ # Gravity + integration. Subclasses can override
107
+ # update_particle to layer additional forces / drag.
108
+ p.vx += gx * seconds
109
+ p.vy += gy * seconds
110
+ p.x += p.vx * seconds
111
+ p.y += p.vy * seconds
112
+ p.age += seconds
113
+ update_particle(p, seconds)
114
+ end
115
+
116
+ @particles.delete_if { |p| !p.alive? }
117
+
118
+ self
119
+ end
120
+
121
+ # Hook: tweak a particle's state. Default does nothing; override
122
+ # for drag, attractors, colour curves over lifetime, etc.
123
+ def update_particle(_particle, _dt); end
124
+
125
+ # Drawable interface — rebuilds the VertexArray and forwards.
126
+ def draw_on(target, states_ptr = nil)
127
+ _rebuild_vertex_array
128
+ @vertex_array.draw_on(target, states_ptr)
129
+ end
130
+
131
+ private
132
+
133
+ def _xy(value)
134
+ case value
135
+ when Vector2 then [value.x, value.y]
136
+ when Array then value
137
+ else raise ArgumentError, "expected Vector2 or [x, y]; got #{value.class}"
138
+ end
139
+ end
140
+
141
+ # Pack live particles into the vertex array as two triangles each.
142
+ # We rebuild from scratch every frame — for ~1000 particles this
143
+ # is ~6000 vertex writes per frame, which is well within budget.
144
+ def _rebuild_vertex_array
145
+ @vertex_array.clear
146
+ @particles.each do |p|
147
+ next unless p.alive?
148
+
149
+ s = p.size
150
+ # Fade alpha linearly from full → zero across lifetime.
151
+ alpha = (p.a * (1.0 - p.normalized_age)).round.clamp(0, 255)
152
+ col = Color.new(p.r, p.g, p.b, alpha)
153
+
154
+ # Two triangles forming a quad centred on (x, y).
155
+ tl = Vertex.new([p.x - s, p.y - s], color: col)
156
+ tr = Vertex.new([p.x + s, p.y - s], color: col)
157
+ bl = Vertex.new([p.x - s, p.y + s], color: col)
158
+ br = Vertex.new([p.x + s, p.y + s], color: col)
159
+
160
+ @vertex_array << tl << tr << br
161
+ @vertex_array << tl << br << bl
162
+ end
163
+ end
164
+ end
165
+ end
@@ -14,7 +14,7 @@ module SFML
14
14
 
15
15
  def initialize(size:, **opts)
16
16
  ptr = C::Graphics.sfRectangleShape_create
17
- raise Error, "sfRectangleShape_create returned NULL" if ptr.null?
17
+ raise GraphicsError, "sfRectangleShape_create returned NULL" if ptr.null?
18
18
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfRectangleShape_destroy))
19
19
 
20
20
  self.size = size
@@ -27,7 +27,7 @@ module SFML
27
27
  size[:y] = Integer(height)
28
28
 
29
29
  ptr = C::Graphics.sfRenderTexture_create(size, nil)
30
- raise Error, "sfRenderTexture_create returned NULL" if ptr.null?
30
+ raise GraphicsError, "sfRenderTexture_create returned NULL" if ptr.null?
31
31
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfRenderTexture_destroy))
32
32
 
33
33
  self.smooth = smooth
@@ -96,7 +96,7 @@ module SFML
96
96
  def texture
97
97
  @texture ||= begin
98
98
  ptr = C::Graphics.sfRenderTexture_getTexture(@handle)
99
- raise Error, "sfRenderTexture_getTexture returned NULL" if ptr.null?
99
+ raise GraphicsError, "sfRenderTexture_getTexture returned NULL" if ptr.null?
100
100
  # Borrowed — RenderTexture owns the underlying sf::Texture, so
101
101
  # we wrap with a raw pointer (no AutoPointer / no destructor).
102
102
  # Sprite.new(@texture) will still get a valid handle through it.
@@ -49,7 +49,7 @@ module SFML
49
49
  C::Window::State[state],
50
50
  ctx_ptr,
51
51
  )
52
- raise Error, "sfRenderWindow_create returned NULL" if ptr.null?
52
+ raise WindowError, "sfRenderWindow_create returned NULL" if ptr.null?
53
53
 
54
54
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfRenderWindow_destroy))
55
55
  @event_buffer = C::Window::Event.new
@@ -132,6 +132,30 @@ module SFML
132
132
  Vector2.new(v[:x], v[:y])
133
133
  end
134
134
 
135
+ # Capture the current back-buffer to disk. The format is inferred
136
+ # from the file extension (png / jpg / bmp / tga supported by
137
+ # CSFML's stb_image-based saver). Returns the path.
138
+ #
139
+ # window.screenshot("screenshot.png")
140
+ #
141
+ # `format:` lets you save under a different name than the
142
+ # extension. For an in-memory copy, use `#capture_image` instead.
143
+ def screenshot(path, format: nil)
144
+ capture_image.save(path.to_s)
145
+ path
146
+ end
147
+
148
+ # Read the current back-buffer into a fresh CPU-side SFML::Image.
149
+ # Useful for processing/encoding the frame yourself (sending it
150
+ # over a socket, encoding as JPEG memory bytes, comparing
151
+ # against a reference, etc.).
152
+ def capture_image
153
+ w, h = size.x, size.y
154
+ tex = Texture.create(w, h)
155
+ tex.update_from_render_window(self)
156
+ tex.to_image
157
+ end
158
+
135
159
  # Replace the window's title-bar / taskbar icon with the pixels from
136
160
  # the given SFML::Image. The OS scales it as needed; 32×32 RGBA
137
161
  # is the typical sweet spot.
@@ -247,7 +271,7 @@ module SFML
247
271
  def self.from_handle(handle)
248
272
  ptr = handle.is_a?(FFI::Pointer) ? handle : FFI::Pointer.new(:void, Integer(handle))
249
273
  raw = C::Graphics.sfRenderWindow_createFromHandle(ptr, nil)
250
- raise Error, "sfRenderWindow_createFromHandle returned NULL" if raw.null?
274
+ raise WindowError, "sfRenderWindow_createFromHandle returned NULL" if raw.null?
251
275
 
252
276
  win = allocate
253
277
  win.instance_variable_set(:@handle,
@@ -56,7 +56,7 @@ module SFML
56
56
  ptr = C::Graphics.sfShader_createFromFile(
57
57
  vertex&.to_s, geometry&.to_s, fragment&.to_s,
58
58
  )
59
- raise Error, "sfShader_createFromFile failed (compile error or missing file?)" if ptr.null?
59
+ raise ShaderError, "sfShader_createFromFile failed (compile error or missing file?)" if ptr.null?
60
60
  _wrap(ptr)
61
61
  end
62
62
 
@@ -64,7 +64,7 @@ module SFML
64
64
  def self.from_source(vertex: nil, geometry: nil, fragment: nil)
65
65
  _check_at_least_one(vertex, geometry, fragment)
66
66
  ptr = C::Graphics.sfShader_createFromMemory(vertex, geometry, fragment)
67
- raise Error, "sfShader_createFromMemory failed (GLSL compile error?)" if ptr.null?
67
+ raise ShaderError, "sfShader_createFromMemory failed (GLSL compile error?)" if ptr.null?
68
68
  _wrap(ptr)
69
69
  end
70
70
 
@@ -76,7 +76,7 @@ module SFML
76
76
  streams = [vertex, geometry, fragment].map { |io| io && SFML::InputStream.new(io) }
77
77
  ptrs = streams.map { |s| s ? s.to_ptr : nil }
78
78
  ptr = C::Graphics.sfShader_createFromStream(*ptrs)
79
- raise Error, "sfShader_createFromStream failed (GLSL compile error?)" if ptr.null?
79
+ raise ShaderError, "sfShader_createFromStream failed (GLSL compile error?)" if ptr.null?
80
80
  _wrap(ptr)
81
81
  end
82
82
 
@@ -47,7 +47,7 @@ module SFML
47
47
  end
48
48
 
49
49
  ptr = C::Graphics.sfShape_create(@get_point_count_cb, @get_point_cb, nil)
50
- raise Error, "sfShape_create returned NULL" if ptr.null?
50
+ raise GraphicsError, "sfShape_create returned NULL" if ptr.null?
51
51
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfShape_destroy))
52
52
 
53
53
  self.fill_color = opts[:fill_color] if opts.key?(:fill_color)
@@ -78,7 +78,7 @@ module SFML
78
78
  # cheap to alias); transform/colour state is independent.
79
79
  def dup
80
80
  ptr = _csfml(:copy, @handle)
81
- raise Error, "#{self.class.name}#dup returned NULL" if ptr.null?
81
+ raise GraphicsError, "#{self.class.name}#dup returned NULL" if ptr.null?
82
82
 
83
83
  destroy_fn = C::Graphics.method(:"#{self.class::CSFML_PREFIX}_destroy")
84
84
  copy = self.class.allocate
@@ -15,7 +15,7 @@ module SFML
15
15
  raise ArgumentError, "Sprite requires a SFML::Texture" unless texture.is_a?(Texture)
16
16
 
17
17
  ptr = C::Graphics.sfSprite_create(texture.handle)
18
- raise Error, "sfSprite_create returned NULL" if ptr.null?
18
+ raise GraphicsError, "sfSprite_create returned NULL" if ptr.null?
19
19
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfSprite_destroy))
20
20
  @texture = texture # keep alive for GC
21
21
 
@@ -92,7 +92,7 @@ module SFML
92
92
  # objects), independent transform / colour state.
93
93
  def dup
94
94
  ptr = C::Graphics.sfSprite_copy(@handle)
95
- raise Error, "sfSprite_copy returned NULL" if ptr.null?
95
+ raise GraphicsError, "sfSprite_copy returned NULL" if ptr.null?
96
96
 
97
97
  copy = self.class.allocate
98
98
  copy.instance_variable_set(:@handle,
@@ -0,0 +1,100 @@
1
+ module SFML
2
+ # A grid-based slice of a texture. Where `TextureAtlas` loads an
3
+ # already-packed sheet with named frames, `SpriteSheet` slices a
4
+ # uniformly-gridded image into numbered frames at load time.
5
+ #
6
+ # Use a SpriteSheet when your art is a regular grid (run cycles
7
+ # baked at 32×32 cells); use a TextureAtlas when frames are
8
+ # densely packed at irregular sizes.
9
+ #
10
+ # sheet = SFML::SpriteSheet.load("hero.png", frame_size: [32, 32])
11
+ #
12
+ # sheet.frame_count # 8 if the image is 256×32
13
+ # sheet.region(0) # SFML::Rect of the first cell
14
+ # sheet.sprite(0) # ready-to-draw SFML::Sprite of the first cell
15
+ #
16
+ # `frame_size` can be a `[w, h]` pair (one number for both if
17
+ # `frame_size: 32`). `padding` and `margin` let you skip pixels
18
+ # between cells and around the edge respectively — common when
19
+ # the source image has separators or a 1px outline to prevent
20
+ # bleed.
21
+ class SpriteSheet
22
+ def self.load(path, frame_size:, padding: 0, margin: 0, smooth: true)
23
+ new(texture: Texture.load(path, smooth: smooth),
24
+ frame_size: frame_size, padding: padding, margin: margin)
25
+ end
26
+
27
+ def initialize(texture:, frame_size:, padding: 0, margin: 0)
28
+ @texture = texture
29
+ fw, fh = _pair(frame_size)
30
+ pw, ph = _pair(padding)
31
+ mw, mh = _pair(margin)
32
+
33
+ tex_w, tex_h = texture.size.x, texture.size.y
34
+ @cols = (tex_w - 2 * mw + pw) / (fw + pw)
35
+ @rows = (tex_h - 2 * mh + ph) / (fh + ph)
36
+ @frame_w, @frame_h = fw, fh
37
+
38
+ # Pre-compute every rect once. Cheap (a few hundred at most),
39
+ # and #region becomes a flat array lookup.
40
+ @regions = Array.new(@cols * @rows) do |i|
41
+ col = i % @cols
42
+ row = i / @cols
43
+ x = mw + col * (fw + pw)
44
+ y = mh + row * (fh + ph)
45
+ Rect.new([x, y], [fw, fh])
46
+ end.freeze
47
+ end
48
+
49
+ attr_reader :texture, :cols, :rows, :frame_w, :frame_h
50
+
51
+ def frame_count = @regions.size
52
+
53
+ # The pixel Rect for cell `index` (0 = top-left, increases
54
+ # row-major). Negative indexes wrap (e.g. -1 = last).
55
+ def region(index)
56
+ i = Integer(index) % frame_count
57
+ @regions[i]
58
+ end
59
+
60
+ # `[col, row]` accessor for callers who prefer 2D coords.
61
+ def region_at(col, row)
62
+ raise IndexError, "col #{col} out of range (cols: #{@cols})" if col < 0 || col >= @cols
63
+ raise IndexError, "row #{row} out of range (rows: #{@rows})" if row < 0 || row >= @rows
64
+ @regions[row * @cols + col]
65
+ end
66
+
67
+ # Fresh Sprite pointing at cell `index`. Combine with `#sprite=`
68
+ # patterns or feed into `SFML::Animation`.
69
+ def sprite(index, **opts)
70
+ Sprite.new(@texture, **opts).tap { |s| s.texture_rect = region(index) }
71
+ end
72
+
73
+ # An Animation looping through `frame_indexes` (defaults to all
74
+ # frames in row-major order).
75
+ def animation(frame_indexes: nil, fps: 12, loop: true)
76
+ indexes = frame_indexes || (0...frame_count).to_a
77
+ Animation.new(
78
+ self,
79
+ frames: indexes.map { |i| region(i) },
80
+ fps: fps,
81
+ loop: loop,
82
+ )
83
+ end
84
+
85
+ def to_s = "#<SpriteSheet #{@cols}×#{@rows} (#{@frame_w}×#{@frame_h}px cells)>"
86
+ alias inspect to_s
87
+
88
+ private
89
+
90
+ # Accept 32 OR [32, 32] OR Vector2[32, 32].
91
+ def _pair(value)
92
+ case value
93
+ when Numeric then [Integer(value), Integer(value)]
94
+ when Vector2 then [Integer(value.x), Integer(value.y)]
95
+ when Array then value.map { |n| Integer(n) }
96
+ else raise ArgumentError, "expected Integer, [w, h], or Vector2; got #{value.class}"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -27,7 +27,7 @@ module SFML
27
27
  raise ArgumentError, "Text requires a SFML::Font" unless font.is_a?(Font)
28
28
 
29
29
  ptr = C::Graphics.sfText_create(font.handle)
30
- raise Error, "sfText_create returned NULL" if ptr.null?
30
+ raise GraphicsError, "sfText_create returned NULL" if ptr.null?
31
31
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfText_destroy))
32
32
  @font = font # keep alive for GC
33
33
 
@@ -161,7 +161,7 @@ module SFML
161
161
  # (Fonts are shareable; each owns its own glyph atlas).
162
162
  def dup
163
163
  ptr = C::Graphics.sfText_copy(@handle)
164
- raise Error, "sfText_copy returned NULL" if ptr.null?
164
+ raise GraphicsError, "sfText_copy returned NULL" if ptr.null?
165
165
 
166
166
  copy = self.class.allocate
167
167
  copy.instance_variable_set(:@handle, FFI::AutoPointer.new(ptr, C::Graphics.method(:sfText_destroy)))
@@ -16,7 +16,7 @@ module SFML
16
16
  ptr = srgb \
17
17
  ? C::Graphics.sfTexture_createSrgbFromFile(path.to_s, nil) \
18
18
  : C::Graphics.sfTexture_createFromFile(path.to_s, nil)
19
- raise Error, "Could not load texture from #{path.inspect}" if ptr.null?
19
+ raise LoadError, "Could not load texture from #{path.inspect}" if ptr.null?
20
20
 
21
21
  tex = allocate
22
22
  tex.send(:_take_ownership, ptr)
@@ -33,7 +33,7 @@ module SFML
33
33
  size = C::System::Vector2u.new
34
34
  size[:x] = Integer(width); size[:y] = Integer(height)
35
35
  ptr = srgb ? C::Graphics.sfTexture_createSrgb(size) : C::Graphics.sfTexture_create(size)
36
- raise Error, "sfTexture_create returned NULL — out of GPU memory?" if ptr.null?
36
+ raise GraphicsError, "sfTexture_create returned NULL — out of GPU memory?" if ptr.null?
37
37
 
38
38
  tex = allocate
39
39
  tex.send(:_take_ownership, ptr)
@@ -51,7 +51,7 @@ module SFML
51
51
  ptr = srgb \
52
52
  ? C::Graphics.sfTexture_createSrgbFromMemory(buf, bytes.bytesize, nil) \
53
53
  : C::Graphics.sfTexture_createFromMemory(buf, bytes.bytesize, nil)
54
- raise Error, "sfTexture_createFromMemory returned NULL — unsupported format?" if ptr.null?
54
+ raise LoadError, "sfTexture_createFromMemory returned NULL — unsupported format?" if ptr.null?
55
55
 
56
56
  tex = allocate
57
57
  tex.send(:_take_ownership, ptr)
@@ -69,7 +69,7 @@ module SFML
69
69
  ptr = srgb \
70
70
  ? C::Graphics.sfTexture_createSrgbFromStream(stream.to_ptr, nil) \
71
71
  : C::Graphics.sfTexture_createFromStream(stream.to_ptr, nil)
72
- raise Error, "sfTexture_createFromStream returned NULL — unsupported format?" if ptr.null?
72
+ raise LoadError, "sfTexture_createFromStream returned NULL — unsupported format?" if ptr.null?
73
73
 
74
74
  tex = allocate
75
75
  tex.send(:_take_ownership, ptr)
@@ -86,7 +86,7 @@ module SFML
86
86
  ptr = srgb \
87
87
  ? C::Graphics.sfTexture_createSrgbFromImage(image.handle, nil) \
88
88
  : C::Graphics.sfTexture_createFromImage(image.handle, nil)
89
- raise Error, "sfTexture_createFromImage returned NULL" if ptr.null?
89
+ raise LoadError, "sfTexture_createFromImage returned NULL" if ptr.null?
90
90
 
91
91
  tex = allocate
92
92
  tex.send(:_take_ownership, ptr)
@@ -110,7 +110,7 @@ module SFML
110
110
  # — useful for screenshots or post-processing inspection.
111
111
  def to_image
112
112
  ptr = C::Graphics.sfTexture_copyToImage(@handle)
113
- raise Error, "sfTexture_copyToImage returned NULL" if ptr.null?
113
+ raise GraphicsError, "sfTexture_copyToImage returned NULL" if ptr.null?
114
114
  img = Image.allocate
115
115
  img.send(:_take_ownership, ptr)
116
116
  img
@@ -164,7 +164,7 @@ module SFML
164
164
  # Deep copy. The returned texture has its own GPU memory.
165
165
  def dup
166
166
  ptr = C::Graphics.sfTexture_copy(@handle)
167
- raise Error, "sfTexture_copy returned NULL" if ptr.null?
167
+ raise GraphicsError, "sfTexture_copy returned NULL" if ptr.null?
168
168
 
169
169
  tex = self.class.allocate
170
170
  tex.send(:_take_ownership, ptr)
@@ -0,0 +1,126 @@
1
+ require "json"
2
+
3
+ module SFML
4
+ # A sprite-sheet plus a frame-name → rectangle index. Loads the
5
+ # JSON descriptors that Aseprite and TexturePacker export — both
6
+ # use the same `frames + meta` shape, just with a few field
7
+ # differences we paper over.
8
+ #
9
+ # atlas = SFML::TextureAtlas.load("hero.json")
10
+ #
11
+ # atlas.texture #=> SFML::Texture
12
+ # atlas.region("hero-walk-0") #=> SFML::Rect
13
+ # atlas.frame_names #=> [...]
14
+ # atlas.sprite("hero-walk-0") #=> ready-to-draw SFML::Sprite
15
+ # atlas.duration("hero-walk-0") #=> Integer ms (Aseprite only)
16
+ #
17
+ # The image path inside the JSON is resolved relative to the JSON
18
+ # file's directory. Override with `image: "path/to/img.png"`.
19
+ #
20
+ # Frame lookups are tolerant of the `.png` extension — Aseprite
21
+ # exports `walk-0.png`, but you can write `atlas.region("walk-0")`.
22
+ class TextureAtlas
23
+ IMAGE_EXTS = /\.(?:png|jpg|jpeg|bmp|tga|gif)\z/i
24
+
25
+ # @param json_path [String] path to the atlas JSON
26
+ # @param image [String, nil] explicit image path (overrides
27
+ # the path embedded in the JSON's `meta.image` field)
28
+ # @return [TextureAtlas]
29
+ def self.load(json_path, image: nil, smooth: true)
30
+ data = JSON.parse(File.read(json_path))
31
+ image_path = image || File.expand_path(data.dig("meta", "image") || "", File.dirname(json_path))
32
+ raise LoadError, "TextureAtlas: image not found at #{image_path}" unless File.file?(image_path)
33
+
34
+ texture = Texture.load(image_path, smooth: smooth)
35
+ regions, durations = _parse_frames(data["frames"])
36
+ new(texture: texture, regions: regions, durations: durations, source: json_path)
37
+ end
38
+
39
+ # Build directly from a Texture + a frame Hash — useful when
40
+ # generating atlases procedurally or when the descriptor lives
41
+ # in a different format you've already parsed.
42
+ def initialize(texture:, regions:, durations: {}, source: nil)
43
+ @texture = texture
44
+ @regions = regions.transform_keys { |k| _normalize(k) }.freeze
45
+ @durations = durations.transform_keys { |k| _normalize(k) }.freeze
46
+ @source = source
47
+ end
48
+
49
+ attr_reader :texture, :source
50
+
51
+ def frame_names = @regions.keys
52
+
53
+ # @return [SFML::Rect] the pixel rect for frame `name`.
54
+ # @raise [SFML::LoadError] if the frame isn't in this atlas.
55
+ def region(name)
56
+ @regions[_normalize(name)] or
57
+ raise LoadError, "TextureAtlas: no frame named #{name.inspect} " \
58
+ "(have: #{@regions.keys.first(5).inspect}...)"
59
+ end
60
+
61
+ # Build a fresh Sprite bound to the atlas texture with its
62
+ # texture_rect set to this frame.
63
+ def sprite(name, **opts)
64
+ Sprite.new(@texture, **opts).tap { |s| s.texture_rect = region(name) }
65
+ end
66
+
67
+ # Build an Animation from a list of frame names. When `fps:` is
68
+ # nil and Aseprite-style `duration` data is present, the per-frame
69
+ # durations are used directly — letting artists set timing in
70
+ # Aseprite without re-tuning in code. Pass an explicit `fps:` to
71
+ # override.
72
+ def animation(frame_names, fps: nil, loop: true)
73
+ rects = frame_names.map { |n| region(n) }
74
+ if fps.nil? && frame_names.first && duration(frame_names.first) > 0
75
+ # Use Aseprite's per-frame ms timings — collapse to average
76
+ # fps for the Animation; per-frame durations would need a
77
+ # variable-rate Animation we don't ship yet.
78
+ avg_ms = frame_names.map { |n| duration(n) }.sum / frame_names.size.to_f
79
+ fps = 1000.0 / avg_ms if avg_ms > 0
80
+ end
81
+ Animation.new(self, frames: rects, fps: fps || 12, loop: loop)
82
+ end
83
+
84
+ # Integer milliseconds per frame, as exported by Aseprite. Returns
85
+ # 0 if the source didn't include durations (TexturePacker etc.).
86
+ def duration(name) = @durations[_normalize(name)] || 0
87
+
88
+ # The internal frame-name → Rect hash. Useful for filtering:
89
+ #
90
+ # walk_frames = atlas.regions.select { |k, _| k.start_with?("walk-") }
91
+ def regions = @regions
92
+
93
+ def to_s = "#<TextureAtlas #{@regions.size} frames#{@source ? " from #{File.basename(@source)}" : ""}>"
94
+ alias inspect to_s
95
+
96
+ # @!visibility private
97
+ def self._parse_frames(raw)
98
+ regions = {}
99
+ durations = {}
100
+
101
+ entries =
102
+ case raw
103
+ when Hash then raw.map { |name, entry| [name, entry] }
104
+ when Array then raw.map { |entry| [entry.fetch("filename"), entry] }
105
+ else raise LoadError, "TextureAtlas: unexpected `frames` shape: #{raw.class}"
106
+ end
107
+
108
+ entries.each do |name, entry|
109
+ rect = entry.fetch("frame")
110
+ regions[name] = Rect.new([rect["x"], rect["y"]], [rect["w"], rect["h"]])
111
+ durations[name] = entry["duration"] if entry["duration"]
112
+ end
113
+
114
+ [regions, durations]
115
+ end
116
+ private_class_method :_parse_frames
117
+
118
+ private
119
+
120
+ # Frame names are stored stripped of any image extension so
121
+ # callers can use either `walk-0` or `walk-0.png`.
122
+ def _normalize(name)
123
+ name.to_s.sub(IMAGE_EXTS, "")
124
+ end
125
+ end
126
+ end
@@ -16,7 +16,7 @@ module SFML
16
16
 
17
17
  def initialize(**opts)
18
18
  ptr = C::Graphics.sfTransformable_create
19
- raise Error, "sfTransformable_create returned NULL" if ptr.null?
19
+ raise GraphicsError, "sfTransformable_create returned NULL" if ptr.null?
20
20
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfTransformable_destroy))
21
21
 
22
22
  self.position = opts[:position] if opts.key?(:position)
@@ -35,7 +35,7 @@ module SFML
35
35
 
36
36
  def dup
37
37
  ptr = C::Graphics.sfTransformable_copy(@handle)
38
- raise Error, "sfTransformable_copy returned NULL" if ptr.null?
38
+ raise GraphicsError, "sfTransformable_copy returned NULL" if ptr.null?
39
39
  copy = self.class.allocate
40
40
  copy.instance_variable_set(:@handle,
41
41
  FFI::AutoPointer.new(ptr, C::Graphics.method(:sfTransformable_destroy)))
@@ -25,7 +25,7 @@ module SFML
25
25
 
26
26
  def initialize(primitive_type = :points, vertices = nil)
27
27
  ptr = C::Graphics.sfVertexArray_create
28
- raise Error, "sfVertexArray_create returned NULL" if ptr.null?
28
+ raise GraphicsError, "sfVertexArray_create returned NULL" if ptr.null?
29
29
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfVertexArray_destroy))
30
30
 
31
31
  self.primitive_type = primitive_type
@@ -113,7 +113,7 @@ module SFML
113
113
  # don't affect the other.
114
114
  def dup
115
115
  ptr = C::Graphics.sfVertexArray_copy(@handle)
116
- raise Error, "sfVertexArray_copy returned NULL" if ptr.null?
116
+ raise GraphicsError, "sfVertexArray_copy returned NULL" if ptr.null?
117
117
  copy = self.class.allocate
118
118
  copy.instance_variable_set(:@handle,
119
119
  FFI::AutoPointer.new(ptr, C::Graphics.method(:sfVertexArray_destroy)))
@@ -49,7 +49,7 @@ module SFML
49
49
  end
50
50
 
51
51
  ptr = C::Graphics.sfVertexBuffer_create(n, ptype, uidx)
52
- raise Error, "sfVertexBuffer_create returned NULL " \
52
+ raise GraphicsError, "sfVertexBuffer_create returned NULL " \
53
53
  "(VBOs unavailable on this GPU?)" if ptr.null?
54
54
  @handle = FFI::AutoPointer.new(ptr, C::Graphics.method(:sfVertexBuffer_destroy))
55
55
 
@@ -101,7 +101,7 @@ module SFML
101
101
  end
102
102
 
103
103
  ok = C::Graphics.sfVertexBuffer_update(@handle, buf, n, Integer(offset))
104
- raise Error, "sfVertexBuffer_update failed " \
104
+ raise GraphicsError, "sfVertexBuffer_update failed " \
105
105
  "(buffer too small or driver rejected the upload?)" unless ok
106
106
  self
107
107
  end