rgame 0.1.0

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 (161) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +26 -0
  3. data/README.md +406 -0
  4. data/docs/api/README.md +167 -0
  5. data/docs/api/app.md +192 -0
  6. data/docs/api/assets.md +426 -0
  7. data/docs/api/audio.md +208 -0
  8. data/docs/api/components.md +321 -0
  9. data/docs/api/drawing.md +330 -0
  10. data/docs/api/game.md +99 -0
  11. data/docs/api/images.md +118 -0
  12. data/docs/api/input.md +179 -0
  13. data/docs/api/internals.md +110 -0
  14. data/docs/api/scene_graph.md +159 -0
  15. data/docs/api/signals.md +142 -0
  16. data/docs/api/systems.md +98 -0
  17. data/docs/api/text.md +116 -0
  18. data/docs/api/toolbox.md +240 -0
  19. data/docs/api/values.md +101 -0
  20. data/ext/README.md +225 -0
  21. data/ext/rgame_core/app/app.c +721 -0
  22. data/ext/rgame_core/app/app_gl.h +64 -0
  23. data/ext/rgame_core/app/frame_loop.c +42 -0
  24. data/ext/rgame_core/app/frame_loop.h +54 -0
  25. data/ext/rgame_core/audio/audio.c +466 -0
  26. data/ext/rgame_core/audio/audio_internal.h +45 -0
  27. data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
  28. data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
  29. data/ext/rgame_core/example.rb +188 -0
  30. data/ext/rgame_core/extconf.rb +167 -0
  31. data/ext/rgame_core/graphics/backend.c +52 -0
  32. data/ext/rgame_core/graphics/backend.h +64 -0
  33. data/ext/rgame_core/graphics/canvas.c +247 -0
  34. data/ext/rgame_core/graphics/canvas.h +143 -0
  35. data/ext/rgame_core/graphics/clip.c +87 -0
  36. data/ext/rgame_core/graphics/clip.h +89 -0
  37. data/ext/rgame_core/graphics/draw_queue.c +216 -0
  38. data/ext/rgame_core/graphics/draw_queue.h +174 -0
  39. data/ext/rgame_core/graphics/gl_backend.c +122 -0
  40. data/ext/rgame_core/graphics/gl_backend.h +43 -0
  41. data/ext/rgame_core/graphics/image.c +304 -0
  42. data/ext/rgame_core/graphics/image_internal.h +30 -0
  43. data/ext/rgame_core/graphics/primitives.c +189 -0
  44. data/ext/rgame_core/graphics/primitives.h +111 -0
  45. data/ext/rgame_core/graphics/recording.c +119 -0
  46. data/ext/rgame_core/graphics/recording.h +88 -0
  47. data/ext/rgame_core/graphics/texture.c +181 -0
  48. data/ext/rgame_core/graphics/texture.h +165 -0
  49. data/ext/rgame_core/graphics/transform.c +128 -0
  50. data/ext/rgame_core/graphics/transform.h +106 -0
  51. data/ext/rgame_core/include/rgame/core.h +577 -0
  52. data/ext/rgame_core/input/device_slots.c +103 -0
  53. data/ext/rgame_core/input/device_slots.h +93 -0
  54. data/ext/rgame_core/input/gamepad.c +145 -0
  55. data/ext/rgame_core/input/gamepad.h +63 -0
  56. data/ext/rgame_core/input/input.c +109 -0
  57. data/ext/rgame_core/input/input.h +99 -0
  58. data/ext/rgame_core/ruby/audio_ext.c +321 -0
  59. data/ext/rgame_core/ruby/core_ext.c +513 -0
  60. data/ext/rgame_core/ruby/core_ext.h +51 -0
  61. data/ext/rgame_core/ruby/font_ext.c +168 -0
  62. data/ext/rgame_core/ruby/image_ext.c +230 -0
  63. data/ext/rgame_core/ruby/recording_ext.c +186 -0
  64. data/ext/rgame_core/ruby/renderer_ext.c +376 -0
  65. data/ext/rgame_core/text/atlas.c +59 -0
  66. data/ext/rgame_core/text/atlas.h +85 -0
  67. data/ext/rgame_core/text/font.c +281 -0
  68. data/ext/rgame_core/text/font.h +139 -0
  69. data/ext/rgame_core/text/font_atlas.c +385 -0
  70. data/ext/rgame_core/text/font_internal.h +47 -0
  71. data/ext/rgame_core/text/glyph_cache.c +142 -0
  72. data/ext/rgame_core/text/glyph_cache.h +89 -0
  73. data/ext/rgame_core/vendor/README.md +159 -0
  74. data/ext/rgame_core/vendor/miniaudio.h +95864 -0
  75. data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
  76. data/ext/rgame_core/vendor/stb_image.h +7988 -0
  77. data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
  78. data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
  79. data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
  80. data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
  81. data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
  82. data/ext/rgame_util/color.c +19 -0
  83. data/ext/rgame_util/color.h +60 -0
  84. data/ext/rgame_util/color_ext.c +156 -0
  85. data/ext/rgame_util/extconf.rb +27 -0
  86. data/ext/rgame_util/tensor.c +186 -0
  87. data/ext/rgame_util/util_ext.c +27 -0
  88. data/ext/rgame_util/util_ext.h +16 -0
  89. data/lib/rgame/boot.rb +13 -0
  90. data/lib/rgame/core/app.rb +82 -0
  91. data/lib/rgame/core/asset_manager.rb +224 -0
  92. data/lib/rgame/core/audio.rb +124 -0
  93. data/lib/rgame/core/font.rb +49 -0
  94. data/lib/rgame/core/gamepad.rb +55 -0
  95. data/lib/rgame/core/image.rb +55 -0
  96. data/lib/rgame/core/input.rb +77 -0
  97. data/lib/rgame/core/nine_slice.rb +163 -0
  98. data/lib/rgame/core/recording.rb +52 -0
  99. data/lib/rgame/core/renderer.rb +363 -0
  100. data/lib/rgame/core/sprite_sheet.rb +108 -0
  101. data/lib/rgame/core/tile_map_renderer.rb +160 -0
  102. data/lib/rgame/core/ui_atlas.rb +86 -0
  103. data/lib/rgame/core.rb +24 -0
  104. data/lib/rgame/engine/actor.rb +53 -0
  105. data/lib/rgame/engine/animation_set.rb +49 -0
  106. data/lib/rgame/engine/animator.rb +44 -0
  107. data/lib/rgame/engine/audio_bus.rb +24 -0
  108. data/lib/rgame/engine/audio_director.rb +29 -0
  109. data/lib/rgame/engine/body.rb +49 -0
  110. data/lib/rgame/engine/cached_label.rb +33 -0
  111. data/lib/rgame/engine/camera.rb +33 -0
  112. data/lib/rgame/engine/camera_view.rb +28 -0
  113. data/lib/rgame/engine/circle_collider.rb +32 -0
  114. data/lib/rgame/engine/collision_box.rb +34 -0
  115. data/lib/rgame/engine/collision_system.rb +44 -0
  116. data/lib/rgame/engine/component.rb +30 -0
  117. data/lib/rgame/engine/components/action_trigger.rb +41 -0
  118. data/lib/rgame/engine/components/animated_sprite.rb +63 -0
  119. data/lib/rgame/engine/components/character_body.rb +70 -0
  120. data/lib/rgame/engine/components/circle_collider.rb +44 -0
  121. data/lib/rgame/engine/components/collision_world.rb +103 -0
  122. data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
  123. data/lib/rgame/engine/components/path_follow.rb +84 -0
  124. data/lib/rgame/engine/components/player_controller.rb +24 -0
  125. data/lib/rgame/engine/components/pool.rb +53 -0
  126. data/lib/rgame/engine/components/screen_wrap.rb +27 -0
  127. data/lib/rgame/engine/components/sprite.rb +31 -0
  128. data/lib/rgame/engine/components/targeting.rb +54 -0
  129. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  130. data/lib/rgame/engine/components/tile_world.rb +68 -0
  131. data/lib/rgame/engine/components/timer.rb +75 -0
  132. data/lib/rgame/engine/components/velocity.rb +27 -0
  133. data/lib/rgame/engine/components/wander_controller.rb +60 -0
  134. data/lib/rgame/engine/debug_overlay.rb +106 -0
  135. data/lib/rgame/engine/i18n.rb +97 -0
  136. data/lib/rgame/engine/input/action_mapper.rb +46 -0
  137. data/lib/rgame/engine/input/actions.rb +41 -0
  138. data/lib/rgame/engine/input/player_controller.rb +14 -0
  139. data/lib/rgame/engine/matrix.rb +32 -0
  140. data/lib/rgame/engine/node2d.rb +271 -0
  141. data/lib/rgame/engine/path.rb +78 -0
  142. data/lib/rgame/engine/pool.rb +51 -0
  143. data/lib/rgame/engine/resettable.rb +67 -0
  144. data/lib/rgame/engine/scene/scene_stack.rb +65 -0
  145. data/lib/rgame/engine/signal.rb +75 -0
  146. data/lib/rgame/engine/spatial_hash.rb +71 -0
  147. data/lib/rgame/engine/tile_collision.rb +78 -0
  148. data/lib/rgame/engine/tile_map.rb +149 -0
  149. data/lib/rgame/engine/tileset.rb +101 -0
  150. data/lib/rgame/engine/timer.rb +51 -0
  151. data/lib/rgame/engine.rb +68 -0
  152. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  153. data/lib/rgame/fonts/OFL.txt +102 -0
  154. data/lib/rgame/game.rb +129 -0
  155. data/lib/rgame/util/color.rb +27 -0
  156. data/lib/rgame/util/controls.rb +107 -0
  157. data/lib/rgame/util/tensor.rb +12 -0
  158. data/lib/rgame/util.rb +8 -0
  159. data/lib/rgame/version.rb +12 -0
  160. data/lib/rgame.rb +20 -0
  161. metadata +215 -0
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Core
5
+ # Draws a tile map: the static layers baked once, the animated tiles drawn
6
+ # per frame and culled to the viewport.
7
+ #
8
+ # tiles = RGame::Core::TileMapRenderer.new(map, tileset_images)
9
+ #
10
+ # tiles.draw(renderer, camera_x, camera_y, view_w, view_h, elapsed: seconds)
11
+ # tiles.draw_overlay(renderer, camera_x, camera_y, view_w, view_h, z: 20,
12
+ # elapsed: seconds)
13
+ #
14
+ # ## Two bands, with the actors between them
15
+ #
16
+ # Layers split by the map's own `above_layer?` flag. The **below** band —
17
+ # ground, and detail on the actors' level — is drawn under them by `#draw`;
18
+ # the **above** band — tree canopies, roofs — over them by `#draw_overlay`,
19
+ # at a `z` the scene picks. Two calls rather than one because the scene
20
+ # draws its actors in between, and collapsing them would put every canopy
21
+ # behind every character.
22
+ #
23
+ # ## What is baked and what is not
24
+ #
25
+ # Within each band, every tile that is *not* animated is baked into one
26
+ # recording, the first time that band is drawn. Scrolling a baked layer is
27
+ # then one call per texture however many thousand tiles went into it. The
28
+ # handful that *are* animated are drawn individually each frame, culled to
29
+ # the viewport — a map far larger than the screen costs only what is on it.
30
+ #
31
+ # ## It loads nothing and holds no clock
32
+ #
33
+ # The tiles arrive already sliced, so two maps sharing a tileset share one
34
+ # GPU upload — which is only true if something above pulled the image
35
+ # through the asset manager, and is why this class does not load its own.
36
+ #
37
+ # And `elapsed` is an argument rather than a clock read, so animation is
38
+ # something the caller advances. Pausing is "stop accumulating"; a spec
39
+ # picks the frame it wants. See CLAUDE.md, "`draw` renders state; time
40
+ # enters through `update`".
41
+ #
42
+ # ## What it needs of a map
43
+ #
44
+ # It never names the map's class — the tile map lives a layer *above* this
45
+ # one and Core may not reach up (CLAUDE.md, "The rule points both ways").
46
+ # What it calls is the 'a tile map' contract in
47
+ # `spec/support/shared_examples/`: `layer_count`, `above_layer?`, `width`,
48
+ # `height`, `tile_width`, `tile_height`, `gid`, and a `tileset` answering
49
+ # `local_id`, `animations` and `frame_local_id`.
50
+ class TileMapRenderer
51
+ # The map this was built from. A scene reads it for collision and world
52
+ # bounds, which are its business rather than this class's.
53
+ attr_reader :map
54
+
55
+ # `tiles` is the tileset image sliced into an Array indexed by local tile
56
+ # id — what `Image#tiles` returns.
57
+ def initialize(map, tiles)
58
+ @map = map
59
+ @tileset = map.tileset
60
+ @tiles = tiles
61
+ @animated_below, @animated_above = collect_animated_tiles
62
+ # Baked on first draw, not here: recording needs a live frame, and there
63
+ # is no renderer at construction.
64
+ @static_below = nil
65
+ @static_above = nil
66
+ end
67
+
68
+ def draw(renderer, camera_x, camera_y, viewport_width, viewport_height, elapsed: 0.0)
69
+ @static_below ||= bake(renderer) { |layer| !@map.above_layer?(layer) }
70
+ @static_below.draw(-camera_x, -camera_y, z: BELOW_Z)
71
+ draw_animated(renderer, @animated_below, camera_x, camera_y,
72
+ viewport_width, viewport_height, BELOW_Z, elapsed)
73
+ end
74
+
75
+ def draw_overlay(renderer, camera_x, camera_y, viewport_width, viewport_height,
76
+ z:, elapsed: 0.0)
77
+ @static_above ||= bake(renderer) { |layer| @map.above_layer?(layer) }
78
+ @static_above.draw(-camera_x, -camera_y, z: z)
79
+ draw_animated(renderer, @animated_above, camera_x, camera_y,
80
+ viewport_width, viewport_height, z, elapsed)
81
+ end
82
+
83
+ # The ground band sits at the bottom; the scene chooses where the overlay
84
+ # goes, because only it knows what its actors are drawn at.
85
+ BELOW_Z = 0
86
+
87
+ private
88
+
89
+ # [col, row, local_id] for every animated tile, split into the two bands.
90
+ # Walked once at construction: a map is thousands of tiles and a handful
91
+ # of animated ones, and finding them again each frame would be the whole
92
+ # cost this class exists to avoid.
93
+ def collect_animated_tiles
94
+ below = []
95
+ above = []
96
+ each_tile do |layer, col, row, local|
97
+ next unless @tileset.animations.key?(local)
98
+
99
+ (@map.above_layer?(layer) ? above : below) << [col, row, local]
100
+ end
101
+ [below, above]
102
+ end
103
+
104
+ # Every non-empty tile of every layer, as [layer, col, row, local_id].
105
+ def each_tile
106
+ @map.layer_count.times do |layer|
107
+ @map.height.times do |row|
108
+ @map.width.times do |col|
109
+ gid = @map.gid(layer, col, row)
110
+ next if gid.zero?
111
+
112
+ yield(layer, col, row, @tileset.local_id(gid))
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ # Bakes the static tiles of the layers the block accepts into one
119
+ # recording, in layer order so a later layer covers an earlier one.
120
+ def bake(renderer)
121
+ renderer.record do
122
+ each_tile do |layer, col, row, local|
123
+ next unless yield(layer)
124
+ next if @tileset.animations.key?(local)
125
+
126
+ renderer.image_at(@tiles[local], col * @map.tile_width, row * @map.tile_height,
127
+ z: BELOW_Z)
128
+ end
129
+ end
130
+ end
131
+
132
+ def draw_animated(renderer, tiles, camera_x, camera_y, viewport_width, viewport_height,
133
+ z, elapsed)
134
+ tile_width = @map.tile_width
135
+ tile_height = @map.tile_height
136
+
137
+ # Tiled writes frame durations in milliseconds; everything here counts
138
+ # in seconds. Converted once per draw rather than once per tile.
139
+ ms = (elapsed * 1000.0).to_i
140
+
141
+ # fdiv, not `/`. With two Integers — an integer camera position, which
142
+ # is entirely ordinary — `/` floors first and the `.ceil` below becomes
143
+ # a no-op, leaving the last column of tiles undrawn: a one-tile strip of
144
+ # nothing along the right and bottom edges of the screen, and only when
145
+ # the camera happens to be on a whole pixel.
146
+ col_start = camera_x.fdiv(tile_width).floor
147
+ row_start = camera_y.fdiv(tile_height).floor
148
+ col_end = (camera_x + viewport_width).fdiv(tile_width).ceil
149
+ row_end = (camera_y + viewport_height).fdiv(tile_height).ceil
150
+
151
+ tiles.each do |col, row, local|
152
+ next if col < col_start || col >= col_end || row < row_start || row >= row_end
153
+
154
+ renderer.image_at(@tiles[@tileset.frame_local_id(local, ms)],
155
+ (col * tile_width) - camera_x, (row * tile_height) - camera_y, z: z)
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'image'
6
+ require_relative 'nine_slice'
7
+
8
+ module RGame
9
+ module Core
10
+ # One sheet of UI chrome, cut into named nine-slices.
11
+ #
12
+ # atlas = RGame::Core::UiAtlas.load(app, 'media/ui.json')
13
+ # renderer.register_ui_atlas(atlas)
14
+ # renderer.nine_slice(:button_idle, x, y, width, height)
15
+ #
16
+ # A button has four states, a panel has one, a scrollbar has three pieces —
17
+ # all of them small, and all of them cheaper as sub-rectangles of a single
18
+ # texture than as a dozen separate files. This is that texture plus a
19
+ # descriptor naming what is where.
20
+ #
21
+ # ## The descriptor
22
+ #
23
+ # {
24
+ # "image": "buttons.png",
25
+ # "scale": 3,
26
+ # "nine_slices": {
27
+ # "button_idle": { "x": 11, "y": 59, "w": 26, "h": 28, "border": 7 },
28
+ # "button_focus": { "x": 43, "y": 59, "w": 26, "h": 28, "border": 7 },
29
+ # "panel": { "x": 0, "y": 0, "w": 32, "h": 32,
30
+ # "border": { "left": 4, "right": 4, "top": 8, "bottom": 4 },
31
+ # "scale": 2 }
32
+ # }
33
+ # }
34
+ #
35
+ # `image` is resolved next to the descriptor. Each entry is a source
36
+ # rectangle plus a border — a uniform integer or one value per side — and an
37
+ # optional `scale` overriding the sheet default. See
38
+ # RGame::Core::NineSlice for what those mean when it is drawn.
39
+ #
40
+ # ## Element names, not filenames
41
+ #
42
+ # `nine_slices` is keyed by whatever the descriptor calls each element, and
43
+ # those names are what a widget asks for. That is why nine-slices are the
44
+ # one asset the renderer resolves by registration only: `:button_focus` is
45
+ # not a file and never can be. `Renderer#register_ui_atlas` registers every
46
+ # element in one call.
47
+ #
48
+ # Parsing happens once, at load. Nothing here is touched again per frame.
49
+ class UiAtlas
50
+ # The elements, by name. Values are `NineSlice`s.
51
+ attr_reader :nine_slices
52
+
53
+ # Loads a descriptor and the sheet beside it.
54
+ #
55
+ # The `AssetManager` uses `.new` instead, with an image it has already
56
+ # cached, so the sheet is shared with any other use of the same file.
57
+ def self.load(app, atlas_path)
58
+ data = JSON.parse(File.read(atlas_path), symbolize_names: true)
59
+ directory = File.dirname(File.expand_path(atlas_path))
60
+ new(Image.new(app, File.join(directory, data[:image])), data)
61
+ end
62
+
63
+ # `data` is the parsed descriptor; `image` an already-loaded sheet.
64
+ def initialize(image, data)
65
+ sheet_scale = data[:scale] || 1
66
+
67
+ @nine_slices = (data[:nine_slices] || {}).to_h do |id, spec|
68
+ [id, build(image, id, spec, sheet_scale)]
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ # A descriptor holds many elements, and a mistake in one of them surfaces
75
+ # from inside NineSlice's arithmetic — "undefined method '-' for nil" says
76
+ # nothing about *which* button is wrong. Naming the element is the whole
77
+ # value this wrapper adds, so it catches broadly on purpose.
78
+ def build(image, id, spec, sheet_scale)
79
+ NineSlice.new(image, x: spec[:x], y: spec[:y], w: spec[:w], h: spec[:h],
80
+ border: spec[:border], scale: spec[:scale] || sheet_scale)
81
+ rescue StandardError => e
82
+ raise ArgumentError, "ui atlas element #{id.inspect}: #{e.message}"
83
+ end
84
+ end
85
+ end
86
+ end
data/lib/rgame/core.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RGame::Core — everything that depends on SDL, OpenGL, or anything built on
4
+ # them. Requiring this file loads those libraries into the process, which is why
5
+ # it is NOT required from lib/rgame.rb; ask for it explicitly:
6
+ #
7
+ # require "rgame/core"
8
+ #
9
+ # Its counterpart is RGame::Util (see lib/rgame/util.rb), which stays free of
10
+ # graphics dependencies.
11
+ require_relative 'version'
12
+ require_relative 'core/app'
13
+ require_relative 'core/input'
14
+ require_relative 'core/gamepad'
15
+ require_relative 'core/image'
16
+ require_relative 'core/renderer'
17
+ require_relative 'core/recording'
18
+ require_relative 'core/font'
19
+ require_relative 'core/audio'
20
+ require_relative 'core/sprite_sheet'
21
+ require_relative 'core/nine_slice'
22
+ require_relative 'core/ui_atlas'
23
+ require_relative 'core/tile_map_renderer'
24
+ require_relative 'core/asset_manager'
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A character in the world. Composes the pieces it owns — a collision box, an
6
+ # animator, a sprite id (the renderer maps it to a sheet), and a controller that
7
+ # decides movement — rather than baking them in. Pure logic; no graphics.
8
+ #
9
+ # The controller responds to `intent(dt, input) -> [ix, iy]` with each axis in
10
+ # [-1, 1]; PlayerController reads input, AI/scripted controllers ignore it.
11
+ class Actor
12
+ attr_accessor :x, :y
13
+ attr_reader :sprite_id, :collision_box, :animator
14
+
15
+ def initialize(x:, y:, sprite_id:, collision_box:, animator:, controller:, speed: 120.0)
16
+ @x = x
17
+ @y = y
18
+ @sprite_id = sprite_id
19
+ @collision_box = collision_box
20
+ @animator = animator
21
+ @controller = controller
22
+ @speed = speed
23
+ end
24
+
25
+ def update(dt, collision_system, input)
26
+ intent_x, intent_y = @controller.intent(dt, input)
27
+
28
+ collision_system.move(self, intent_x * @speed * dt, intent_y * @speed * dt)
29
+
30
+ @animator.play(walk_animation(intent_x, intent_y))
31
+ @animator.update(dt)
32
+ end
33
+
34
+ # [row, col, flip_x] for the current frame.
35
+ def frame
36
+ @animator.frame
37
+ end
38
+
39
+ private
40
+
41
+ # Facing follows movement intent (so we still "walk" while pushing a wall);
42
+ # horizontal wins over vertical.
43
+ def walk_animation(intent_x, intent_y)
44
+ if intent_x.negative? then :walk_left
45
+ elsif intent_x.positive? then :walk_right
46
+ elsif intent_y.negative? then :walk_up
47
+ elsif intent_y.positive? then :walk_down
48
+ else :stand
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Pure animation math: given an atlas's animation table and an elapsed time,
6
+ # resolve which sprite-sheet cell (row, col) to show. No renderer, no images —
7
+ # fully testable.
8
+ #
9
+ # animations: { name => { row:, col:, frames:, fps:, flip_x: }, ... }
10
+ # row – sheet row
11
+ # col – starting column (default 0); lets an animation begin mid-row,
12
+ # e.g. a 1-frame "stand" pointing at the neutral middle pose
13
+ # frames – number of frames, advancing across columns from `col`
14
+ # fps – frames per second
15
+ # flip_x – draw mirrored horizontally
16
+ class AnimationSet
17
+ Anim = Struct.new(:row, :first_col, :frames, :frame_secs, :flip_x)
18
+
19
+ def initialize(animations)
20
+ @anims = animations.each_with_object({}) do |(name, a), table|
21
+ table[name.to_sym] = Anim.new(a[:row], a[:col] || 0, a[:frames], 1.0 / a[:fps], a[:flip_x] || false)
22
+ end
23
+ end
24
+
25
+ # Sheet row of `name` (fixed per animation).
26
+ def row(name)
27
+ @anims.fetch(name).row
28
+ end
29
+
30
+ # Sheet column of `name` at `elapsed` seconds — the only part that animates.
31
+ def col(name, elapsed)
32
+ anim = @anims.fetch(name)
33
+ anim.first_col + ((elapsed / anim.frame_secs).floor % anim.frames)
34
+ end
35
+
36
+ # Whether `name` draws mirrored (fixed per animation).
37
+ def flip_x(name)
38
+ @anims.fetch(name).flip_x
39
+ end
40
+
41
+ # [row, col, flip_x] for the given animation at `elapsed` seconds. A convenience
42
+ # for tests/inspection — it allocates an Array, so the per-frame draw path reads the
43
+ # row/col/flip_x accessors above directly instead.
44
+ def frame(name, elapsed)
45
+ [row(name), col(name, elapsed), flip_x(name)]
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Owns animation playback state for one actor: the AnimationSet plus the current
6
+ # animation name and elapsed time. Pure logic — the renderer asks it for the
7
+ # current [row, col, flip_x] cell. This is what makes an actor own its own
8
+ # animations instead of the scene passing an AnimationSet in every frame.
9
+ class Animator
10
+ def initialize(animation_set, initial: :stand)
11
+ @animation_set = animation_set
12
+ @current = initial
13
+ @elapsed = 0.0
14
+ end
15
+
16
+ attr_reader :current, :elapsed
17
+
18
+ # Switch to `name`; restarts elapsed time on an actual change (a no-op if the
19
+ # animation is already playing, so a continuing walk keeps cycling).
20
+ def play(name)
21
+ return if name == @current
22
+
23
+ @current = name
24
+ @elapsed = 0.0
25
+ end
26
+
27
+ def update(dt)
28
+ @elapsed += dt
29
+ end
30
+
31
+ # Current animation's row/col/flip_x, resolved without allocating — the per-frame
32
+ # draw path reads these directly.
33
+ def row = @animation_set.row(@current)
34
+ def col = @animation_set.col(@current, @elapsed)
35
+ def flip_x = @animation_set.flip_x(@current)
36
+
37
+ # [row, col, flip_x] for the current animation at the current elapsed time. A
38
+ # convenience for tests/inspection; it allocates, so draw uses the accessors above.
39
+ def frame
40
+ @animation_set.frame(@current, @elapsed)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A global, always-present audio bus: gameplay emits audio *facts* here (decoupled
6
+ # from any actual playback), and an AudioDirector turns them into sound. A module
7
+ # rather than an instance so any node can reach it without wiring.
8
+ module AudioBus
9
+ @on_play_sound = Signal.define(:id).new
10
+ @on_play_music = Signal.define(:id).new
11
+ @on_stop_music = Signal.define.new
12
+
13
+ def self.on_play_sound = @on_play_sound
14
+ def self.on_play_music = @on_play_music
15
+ def self.on_stop_music = @on_stop_music
16
+
17
+ # Emit shims so callers say `AudioBus.play_sound(:boom)` rather than reaching
18
+ # through the signal. The signal is still the subscription seam (AudioDirector).
19
+ def self.play_sound(id) = @on_play_sound.emit(id)
20
+ def self.play_music(id) = @on_play_music.emit(id)
21
+ def self.stop_music = @on_stop_music.emit
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The registered handler that turns audio *events* into audio *playback*.
6
+ # Gameplay emits facts — `:play_sound`/`:play_music`/`:stop_music` with the asset
7
+ # id as payload — through the EventDispatcher, never touching the backend; this
8
+ # observer forwards each to an injected audio server (anything responding to
9
+ # #play_sound/#play_music/#stop_music: `RGame::Core::Audio` in a game, a recording
10
+ # fake in specs). Pure logic — it names no audio class, and the 'an audio server'
11
+ # contract in spec/support/shared_examples/ is the interface it calls, so a
12
+ # stand-in is checked against what the real device does.
13
+ class AudioDirector
14
+ def initialize(audio)
15
+ @audio = audio
16
+ end
17
+
18
+ # Subscribe to the audio events on a hub (the global EventDispatcher by default,
19
+ # but any object with #on). Returns self so callers can build-and-subscribe in one
20
+ # line. The director lives for the whole app, so nothing unsubscribes it.
21
+ def subscribe(audio_bus = Engine::AudioBus)
22
+ audio_bus.on_play_sound.connect { @audio.play_sound(it) }
23
+ audio_bus.on_play_music.connect { @audio.play_music(it) }
24
+ audio_bus.on_stop_music.connect { @audio.stop_music }
25
+ self
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Reusable kinematics for free-moving, rotating entities (ship, rocks, bullets):
6
+ # position, linear velocity, facing angle and angular velocity. `integrate`
7
+ # advances them by a fixed dt; `wrap!` implements toroidal screen-wrap and
8
+ # `offscreen?` the despawn test. Pure logic; no graphics.
9
+ #
10
+ # Angle is in radians, 0 = pointing right (+x), increasing clockwise on screen
11
+ # (since y grows downward). A heading's unit vector is therefore (cos, sin).
12
+ class Body
13
+ attr_accessor :x, :y, :vx, :vy, :angle, :spin
14
+
15
+ def initialize(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0, angle: 0.0, spin: 0.0)
16
+ @x = x
17
+ @y = y
18
+ @vx = vx
19
+ @vy = vy
20
+ @angle = angle
21
+ @spin = spin
22
+ end
23
+
24
+ def integrate(dt)
25
+ @x += @vx * dt
26
+ @y += @vy * dt
27
+ @angle += @spin * dt
28
+ end
29
+
30
+ # Toroidal wrap: once the centre passes `margin` beyond an edge it reappears on
31
+ # the opposite side. Using margin = the entity's radius means an object spawned
32
+ # flush against an edge (centre at -radius) is exactly *at* the threshold, not
33
+ # past it, so it never wraps on the frame it spawns.
34
+ def wrap!(width, height, margin)
35
+ span_x = width + (2 * margin)
36
+ span_y = height + (2 * margin)
37
+ @x += span_x while @x < -margin
38
+ @x -= span_x while @x > width + margin
39
+ @y += span_y while @y < -margin
40
+ @y -= span_y while @y > height + margin
41
+ end
42
+
43
+ # Fully past an edge by `margin` (e.g. a bullet that left the screen).
44
+ def offscreen?(width, height, margin)
45
+ @x < -margin || @x > width + margin || @y < -margin || @y > height + margin
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Holds a display string and rebuilds it only when its source value changes, so a
6
+ # per-frame draw can show the cached copy without interpolating (allocating) a String
7
+ # every frame. The format block runs only on an actual change.
8
+ #
9
+ # @score_label = Engine::CachedLabel.new { |score| "Score: #{score}" } # build the block once
10
+ # renderer.text(@score_label[@score], 12, 10) # in on_draw: cached, no alloc
11
+ #
12
+ # `@score_label[value]` returns the cached string when `value` is unchanged, and
13
+ # otherwise rebuilds it through the block. Construct it (and its block) outside the
14
+ # per-frame path — e.g. in `on_add` — so the interpolation isn't itself a per-frame cost.
15
+ class CachedLabel
16
+ def initialize(&format)
17
+ raise ArgumentError, 'CachedLabel needs a format block' unless format
18
+
19
+ @format = format
20
+ @value = nil
21
+ @text = nil
22
+ end
23
+
24
+ # The cached string for `value`, rebuilt only when `value` differs from last time.
25
+ def [](value)
26
+ return @text if !@text.nil? && value == @value
27
+
28
+ @value = value
29
+ @text = @format.call(value)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A 2D camera: follows a target point and clamps to world bounds so it never
6
+ # shows past the map edges. Pure; the platform applies (x, y) as a draw offset.
7
+ class Camera
8
+ attr_reader :x, :y, :viewport_width, :viewport_height
9
+
10
+ def initialize(viewport_width:, viewport_height:, world_width:, world_height:)
11
+ @viewport_width = viewport_width
12
+ @viewport_height = viewport_height
13
+ @world_width = world_width
14
+ @world_height = world_height
15
+ @x = 0.0
16
+ @y = 0.0
17
+ end
18
+
19
+ def center_on(world_x, world_y)
20
+ @x = clamp(world_x - @viewport_width / 2.0, @world_width - @viewport_width)
21
+ @y = clamp(world_y - @viewport_height / 2.0, @world_height - @viewport_height)
22
+ end
23
+
24
+ private
25
+
26
+ def clamp(value, max)
27
+ return 0.0 if max <= 0 # world smaller than the viewport → pin to origin
28
+
29
+ value.clamp(0.0, max.to_f)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A node whose children live in *world* space and are drawn through a Camera: it
6
+ # wraps their draw in renderer.translated(-camera.x, -camera.y), so the camera's
7
+ # offset maps the world onto the screen. The children never know about the camera —
8
+ # they draw at their own absolute (world) origin, and the view transform does the
9
+ # rest. The owning scene drives the camera (e.g. centring it on the player); this
10
+ # node only applies it.
11
+ #
12
+ # The offset is a draw-time transform, not a position baked into a node, so the same
13
+ # world can later be drawn through several cameras (split-screen) by repeating the
14
+ # pass under different offsets/clips.
15
+ class CameraView < Node2D
16
+ def initialize(camera:, **)
17
+ super(**)
18
+ @camera = camera
19
+ end
20
+
21
+ private
22
+
23
+ def draw_children(renderer)
24
+ renderer.translated(-@camera.x, -@camera.y) { super }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A circular collision shape, the sibling of CollisionBox for entities that
6
+ # rotate (rocks, ship): a circle is rotation-invariant, so a spinning rock needs
7
+ # no per-frame box recompute. The collider owns only the radius; the entity owns
8
+ # its centre position. Pure logic; no graphics.
9
+ class CircleCollider
10
+ attr_reader :radius
11
+
12
+ # Squared-distance overlap test (no sqrt): true when two circles touch.
13
+ def self.overlap?(x1, y1, r1, x2, y2, r2)
14
+ dx = x2 - x1
15
+ dy = y2 - y1
16
+ sum = r1 + r2
17
+ (dx * dx) + (dy * dy) <= sum * sum
18
+ end
19
+
20
+ def initialize(radius:)
21
+ @radius = radius
22
+ end
23
+
24
+ # Bounding AABB [x, y, w, h] for a circle centred at (cx, cy) — what the
25
+ # spatial hash buckets on.
26
+ def aabb(cx, cy)
27
+ d = @radius * 2
28
+ [cx - @radius, cy - @radius, d, d]
29
+ end
30
+ end
31
+ end
32
+ end