rgame 0.4.0-x86_64-linux-gnu

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 (239) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +251 -0
  3. data/LICENSE +26 -0
  4. data/README.md +325 -0
  5. data/docs/api/README.md +224 -0
  6. data/docs/api/app.md +249 -0
  7. data/docs/api/assets.md +463 -0
  8. data/docs/api/audio.md +249 -0
  9. data/docs/api/cli.md +259 -0
  10. data/docs/api/components.md +1112 -0
  11. data/docs/api/drawing.md +398 -0
  12. data/docs/api/examples.md +263 -0
  13. data/docs/api/game.md +219 -0
  14. data/docs/api/images.md +118 -0
  15. data/docs/api/input.md +476 -0
  16. data/docs/api/internals.md +289 -0
  17. data/docs/api/localization.md +285 -0
  18. data/docs/api/scene_graph.md +539 -0
  19. data/docs/api/signals.md +147 -0
  20. data/docs/api/systems.md +231 -0
  21. data/docs/api/text.md +133 -0
  22. data/docs/api/tile_maps.md +237 -0
  23. data/docs/api/toolbox.md +493 -0
  24. data/docs/api/ui.md +927 -0
  25. data/docs/api/values.md +316 -0
  26. data/examples/assets/README.md +322 -0
  27. data/examples/assets/blip.ogg +0 -0
  28. data/examples/assets/glyphs.json +5 -0
  29. data/examples/assets/glyphs.png +0 -0
  30. data/examples/assets/hero.json +12 -0
  31. data/examples/assets/hero.png +0 -0
  32. data/examples/assets/icons.json +13 -0
  33. data/examples/assets/icons.png +0 -0
  34. data/examples/assets/music.ogg +0 -0
  35. data/examples/assets/skills.json +10 -0
  36. data/examples/assets/skills.png +0 -0
  37. data/examples/assets/tileset.png +0 -0
  38. data/examples/assets/tileset.tsx +65 -0
  39. data/examples/assets/town.tmx +26 -0
  40. data/examples/assets/ui.json +11 -0
  41. data/examples/assets/ui.png +0 -0
  42. data/examples/collision/locales/en.yml +8 -0
  43. data/examples/collision/main.rb +316 -0
  44. data/examples/collision_tiles/locales/en.yml +9 -0
  45. data/examples/collision_tiles/main.rb +274 -0
  46. data/examples/fullscreen/locales/en.yml +10 -0
  47. data/examples/fullscreen/main.rb +216 -0
  48. data/examples/game_menu/locales/en.yml +8 -0
  49. data/examples/game_menu/main.rb +170 -0
  50. data/examples/input_glyphs/locales/en.yml +14 -0
  51. data/examples/input_glyphs/main.rb +213 -0
  52. data/examples/jump_topdown/locales/en.yml +9 -0
  53. data/examples/jump_topdown/main.rb +178 -0
  54. data/examples/localization/locales/de.yml +12 -0
  55. data/examples/localization/locales/en.yml +13 -0
  56. data/examples/localization/main.rb +158 -0
  57. data/examples/menu_navigation/locales/en.yml +23 -0
  58. data/examples/menu_navigation/main.rb +365 -0
  59. data/examples/music/locales/en.yml +7 -0
  60. data/examples/music/main.rb +134 -0
  61. data/examples/pathfinding/locales/en.yml +17 -0
  62. data/examples/pathfinding/main.rb +298 -0
  63. data/examples/pooling/locales/en.yml +7 -0
  64. data/examples/pooling/main.rb +259 -0
  65. data/examples/quick_wheel/locales/en.yml +16 -0
  66. data/examples/quick_wheel/main.rb +184 -0
  67. data/examples/radial_menu/locales/en.yml +16 -0
  68. data/examples/radial_menu/main.rb +184 -0
  69. data/examples/save_load/locales/en.yml +11 -0
  70. data/examples/save_load/main.rb +207 -0
  71. data/examples/save_load_ids/locales/en.yml +11 -0
  72. data/examples/save_load_ids/main.rb +322 -0
  73. data/examples/scroll_map/locales/en.yml +4 -0
  74. data/examples/scroll_map/main.rb +140 -0
  75. data/examples/signals/locales/en.yml +6 -0
  76. data/examples/signals/main.rb +278 -0
  77. data/examples/skill_bar/locales/en.yml +14 -0
  78. data/examples/skill_bar/main.rb +159 -0
  79. data/examples/sound/locales/en.yml +6 -0
  80. data/examples/sound/main.rb +122 -0
  81. data/examples/split_screen/locales/en.yml +9 -0
  82. data/examples/split_screen/main.rb +304 -0
  83. data/examples/sprite/locales/en.yml +8 -0
  84. data/examples/sprite/main.rb +180 -0
  85. data/examples/timer/locales/en.yml +12 -0
  86. data/examples/timer/main.rb +273 -0
  87. data/examples/velocity/locales/en.yml +6 -0
  88. data/examples/velocity/main.rb +196 -0
  89. data/examples/walk/locales/en.yml +4 -0
  90. data/examples/walk/main.rb +99 -0
  91. data/exe/rgame +9 -0
  92. data/lib/rgame/boot.rb +3 -0
  93. data/lib/rgame/cli/new_project.rb +139 -0
  94. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  95. data/lib/rgame/cli/templates/README.md.tt +93 -0
  96. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  97. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  98. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  99. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  100. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  101. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  102. data/lib/rgame/cli/templates/rspec.tt +2 -0
  103. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  104. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  105. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  106. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  107. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  108. data/lib/rgame/cli.rb +66 -0
  109. data/lib/rgame/core/app.rb +46 -0
  110. data/lib/rgame/core/asset_manager.rb +206 -0
  111. data/lib/rgame/core/audio.rb +145 -0
  112. data/lib/rgame/core/font.rb +46 -0
  113. data/lib/rgame/core/gamepad.rb +55 -0
  114. data/lib/rgame/core/image.rb +55 -0
  115. data/lib/rgame/core/input.rb +71 -0
  116. data/lib/rgame/core/locale.rb +22 -0
  117. data/lib/rgame/core/nine_slice.rb +142 -0
  118. data/lib/rgame/core/recording.rb +54 -0
  119. data/lib/rgame/core/renderer.rb +354 -0
  120. data/lib/rgame/core/sprite_sheet.rb +105 -0
  121. data/lib/rgame/core/tile_map_renderer.rb +172 -0
  122. data/lib/rgame/core/ui_atlas.rb +101 -0
  123. data/lib/rgame/core/virtual_gamepad.rb +26 -0
  124. data/lib/rgame/core.rb +18 -0
  125. data/lib/rgame/core_ext.so +0 -0
  126. data/lib/rgame/engine/actor_blockers.rb +131 -0
  127. data/lib/rgame/engine/animation_set.rb +50 -0
  128. data/lib/rgame/engine/animator.rb +44 -0
  129. data/lib/rgame/engine/audio_bus.rb +24 -0
  130. data/lib/rgame/engine/audio_director.rb +59 -0
  131. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  132. data/lib/rgame/engine/camera.rb +78 -0
  133. data/lib/rgame/engine/circle_collider.rb +34 -0
  134. data/lib/rgame/engine/collision_box.rb +59 -0
  135. data/lib/rgame/engine/collision_system.rb +132 -0
  136. data/lib/rgame/engine/component.rb +64 -0
  137. data/lib/rgame/engine/components/action_trigger.rb +40 -0
  138. data/lib/rgame/engine/components/animated_sprite.rb +71 -0
  139. data/lib/rgame/engine/components/box_collider.rb +99 -0
  140. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  141. data/lib/rgame/engine/components/character_body.rb +50 -0
  142. data/lib/rgame/engine/components/circle_collider.rb +80 -0
  143. data/lib/rgame/engine/components/collision_world.rb +231 -0
  144. data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
  145. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  146. data/lib/rgame/engine/components/hop.rb +76 -0
  147. data/lib/rgame/engine/components/identity.rb +73 -0
  148. data/lib/rgame/engine/components/mover.rb +285 -0
  149. data/lib/rgame/engine/components/navigator.rb +145 -0
  150. data/lib/rgame/engine/components/path_follow.rb +176 -0
  151. data/lib/rgame/engine/components/player_controller.rb +27 -0
  152. data/lib/rgame/engine/components/pool.rb +53 -0
  153. data/lib/rgame/engine/components/screen_wrap.rb +49 -0
  154. data/lib/rgame/engine/components/sprite.rb +47 -0
  155. data/lib/rgame/engine/components/targeting.rb +52 -0
  156. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  157. data/lib/rgame/engine/components/tile_world.rb +109 -0
  158. data/lib/rgame/engine/components/timer.rb +75 -0
  159. data/lib/rgame/engine/components/velocity.rb +43 -0
  160. data/lib/rgame/engine/components/wander_controller.rb +64 -0
  161. data/lib/rgame/engine/components/world.rb +133 -0
  162. data/lib/rgame/engine/contact_set.rb +74 -0
  163. data/lib/rgame/engine/culling.rb +45 -0
  164. data/lib/rgame/engine/debug_overlay.rb +111 -0
  165. data/lib/rgame/engine/i18n/plural.rb +45 -0
  166. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  167. data/lib/rgame/engine/i18n/template.rb +59 -0
  168. data/lib/rgame/engine/i18n.rb +322 -0
  169. data/lib/rgame/engine/input/action_mapper.rb +100 -0
  170. data/lib/rgame/engine/input/actions.rb +92 -0
  171. data/lib/rgame/engine/input/input_map.rb +196 -0
  172. data/lib/rgame/engine/layout.rb +82 -0
  173. data/lib/rgame/engine/nav_grid.rb +87 -0
  174. data/lib/rgame/engine/node2d.rb +555 -0
  175. data/lib/rgame/engine/path.rb +76 -0
  176. data/lib/rgame/engine/player.rb +69 -0
  177. data/lib/rgame/engine/player_layer.rb +70 -0
  178. data/lib/rgame/engine/players.rb +205 -0
  179. data/lib/rgame/engine/pool.rb +51 -0
  180. data/lib/rgame/engine/presentation.rb +171 -0
  181. data/lib/rgame/engine/scene/scene_stack.rb +87 -0
  182. data/lib/rgame/engine/sealed_privates.rb +54 -0
  183. data/lib/rgame/engine/signal.rb +75 -0
  184. data/lib/rgame/engine/spatial_hash.rb +116 -0
  185. data/lib/rgame/engine/text.rb +194 -0
  186. data/lib/rgame/engine/tile_blockers.rb +63 -0
  187. data/lib/rgame/engine/tile_map.rb +147 -0
  188. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  189. data/lib/rgame/engine/tileset.rb +99 -0
  190. data/lib/rgame/engine/timer.rb +51 -0
  191. data/lib/rgame/engine/ui/button.rb +248 -0
  192. data/lib/rgame/engine/ui/column.rb +20 -0
  193. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  194. data/lib/rgame/engine/ui/menu.rb +290 -0
  195. data/lib/rgame/engine/ui/navigation.rb +57 -0
  196. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  197. data/lib/rgame/engine/ui/option_button.rb +163 -0
  198. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  199. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  200. data/lib/rgame/engine/ui/pointing.rb +146 -0
  201. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  202. data/lib/rgame/engine/ui/ring.rb +55 -0
  203. data/lib/rgame/engine/ui/row.rb +21 -0
  204. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  205. data/lib/rgame/engine/ui/stack.rb +58 -0
  206. data/lib/rgame/engine/ui/stepping.rb +93 -0
  207. data/lib/rgame/engine/ui/text_button.rb +59 -0
  208. data/lib/rgame/engine/view.rb +76 -0
  209. data/lib/rgame/engine/viewports.rb +171 -0
  210. data/lib/rgame/engine/world_view.rb +71 -0
  211. data/lib/rgame/engine.rb +86 -0
  212. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  213. data/lib/rgame/fonts/OFL.txt +102 -0
  214. data/lib/rgame/game.rb +271 -0
  215. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  216. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  217. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  218. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  219. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  220. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  221. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  222. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  223. data/lib/rgame/rubocop/default.yml +39 -0
  224. data/lib/rgame/rubocop/plugin.rb +45 -0
  225. data/lib/rgame/rubocop.rb +11 -0
  226. data/lib/rgame/util/color.rb +23 -0
  227. data/lib/rgame/util/controls.rb +169 -0
  228. data/lib/rgame/util/route_search.rb +27 -0
  229. data/lib/rgame/util/save_file.rb +107 -0
  230. data/lib/rgame/util/solid_grid.rb +37 -0
  231. data/lib/rgame/util/tensor.rb +3 -0
  232. data/lib/rgame/util/tile_sweep.rb +36 -0
  233. data/lib/rgame/util/z.rb +123 -0
  234. data/lib/rgame/util.rb +10 -0
  235. data/lib/rgame/util_ext.so +0 -0
  236. data/lib/rgame/version.rb +12 -0
  237. data/lib/rgame.rb +5 -0
  238. data/licenses/SDL2/LICENSE.txt +18 -0
  239. metadata +309 -0
@@ -0,0 +1,172 @@
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, both culled to a rectangle of the world.
7
+ #
8
+ # tiles = RGame::Core::TileMapRenderer.new(map, tileset_images)
9
+ #
10
+ # map.layer_count.times do |layer|
11
+ # tiles.draw_layer(renderer, layer, cull_x, cull_y, cull_w, cull_h,
12
+ # elapsed: seconds)
13
+ # end
14
+ #
15
+ # ## It draws in world coordinates
16
+ #
17
+ # A tile at column 3 is drawn at `3 * tile_width`, and getting it onto the
18
+ # screen is the caller's transform — the same deal every other drawable
19
+ # gets. The rectangle passed in is therefore a **cull rect** and nothing
20
+ # else: which part of the world is worth drawing.
21
+ #
22
+ # It used to be both, offsetting the output by `-camera` as well as culling
23
+ # to it, which worked exactly as long as there was one camera. Under
24
+ # split-screen the same map is drawn through several, so a call that bakes
25
+ # placement into its output can only be right for one of them. Culling is
26
+ # genuinely per-camera; placement is the transform stack's job.
27
+ #
28
+ # ## One call per layer, because the actors go between them
29
+ #
30
+ # A layer is drawn on its own, and the order they are drawn in is the
31
+ # caller's. That is what lets a scene put its actors between two of them —
32
+ # tree trunks below, canopies above — which is the whole reason this does
33
+ # not simply draw the map in one go.
34
+ #
35
+ # It is also why nothing here consults the map's `above_layer?` flag any
36
+ # more: which layers cover the actors is a question about where the actors
37
+ # are in the scene, and Tiled already answers "in what order do the layers
38
+ # go" by listing them. `RGame::Engine::TileMapLayer` mounts one node per
39
+ # layer and the tree does the rest.
40
+ #
41
+ # ## What is baked and what is not
42
+ #
43
+ # Within each layer, every tile that is *not* animated is baked into one
44
+ # recording, the first time that layer is drawn. Scrolling a baked layer is
45
+ # then one call per texture however many thousand tiles went into it. The
46
+ # handful that *are* animated are drawn individually each frame, culled to
47
+ # the viewport — a map far larger than the screen costs only what is on it.
48
+ #
49
+ # Splitting per layer rather than into two bands bakes the same tiles into
50
+ # more recordings, not more vertices: the partition changed, the contents
51
+ # did not.
52
+ #
53
+ # ## It loads nothing and holds no clock
54
+ #
55
+ # The tiles arrive already sliced, so two maps sharing a tileset share one
56
+ # GPU upload — which is only true if something above pulled the image
57
+ # through the asset manager, and is why this class does not load its own.
58
+ #
59
+ # And `elapsed` is an argument rather than a clock read, so animation is
60
+ # something the caller advances. Pausing is "stop accumulating"; a spec
61
+ # picks the frame it wants. See CLAUDE.md, "`draw` renders state; time
62
+ # enters through `update`".
63
+ #
64
+ # ## What it needs of a map
65
+ #
66
+ # It never names the map's class — the tile map lives a layer *above* this
67
+ # one and Core may not reach up (CLAUDE.md, "The rule points both ways").
68
+ # What it calls is the 'a tile map' contract in
69
+ # `spec/support/shared_examples/`: `layer_count`, `width`, `height`,
70
+ # `tile_width`, `tile_height`, `gid`, and a `tileset` answering `local_id`,
71
+ # `animations` and `frame_local_id`.
72
+ class TileMapRenderer
73
+ # The map this was built from. A scene reads it for collision and world
74
+ # bounds, which are its business rather than this class's.
75
+ attr_reader :map
76
+
77
+ # `tiles` is the tileset image sliced into an Array indexed by local tile
78
+ # id — what `Image#tiles` returns.
79
+ def initialize(map, tiles)
80
+ @map = map
81
+ @tileset = map.tileset
82
+ @tiles = tiles
83
+ @animated = collect_animated_tiles
84
+ @static = Array.new(map.layer_count)
85
+ end
86
+
87
+ def layer_count = @map.layer_count
88
+
89
+ # One layer, culled to `(cull_x, cull_y, cull_width, cull_height)` in world
90
+ # coordinates and drawn in them.
91
+ #
92
+ # The recording is replayed at its own origin, so it lands wherever the
93
+ # caller's transform puts it. That also makes it **view-independent**: one
94
+ # bake serves every viewport, which is what keeps split-screen affordable
95
+ # and is why the bake is not keyed on a camera. Baking happens on the
96
+ # first draw, and it is safe to do that inside a transform or a clip —
97
+ # recording runs on its own canvas, begun at identity, and captures
98
+ # neither.
99
+ #
100
+ # No `z:`. A layer is drawn by a node of its own, so where it sits is the
101
+ # scene tree's answer; everything this issues belongs to that one node and
102
+ # goes in its slot.
103
+ #
104
+ # @api private
105
+ def draw_layer(renderer, index, cull_x, cull_y, cull_width, cull_height, elapsed: 0.0)
106
+ unless index.is_a?(Integer) && index >= 0 && index < @static.size
107
+ raise ArgumentError, "no layer #{index.inspect} in this map (it has #{@static.size})"
108
+ end
109
+
110
+ @static[index] ||= bake(renderer, index)
111
+ @static[index].draw
112
+ draw_animated(renderer, @animated[index], cull_x, cull_y,
113
+ cull_width, cull_height, elapsed)
114
+ end
115
+
116
+ private
117
+
118
+ def collect_animated_tiles
119
+ found = Array.new(@map.layer_count) { [] }
120
+ each_tile do |layer, col, row, local|
121
+ next unless @tileset.animations.key?(local)
122
+
123
+ found[layer] << [col, row, local]
124
+ end
125
+ found
126
+ end
127
+
128
+ def each_tile
129
+ @map.layer_count.times do |layer|
130
+ @map.height.times do |row|
131
+ @map.width.times do |col|
132
+ gid = @map.gid(layer, col, row)
133
+ next if gid.zero?
134
+
135
+ yield(layer, col, row, @tileset.local_id(gid))
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ def bake(renderer, index)
142
+ renderer.record do
143
+ each_tile do |layer, col, row, local|
144
+ next unless layer == index
145
+ next if @tileset.animations.key?(local)
146
+
147
+ renderer.image_at(@tiles[local], col * @map.tile_width, row * @map.tile_height)
148
+ end
149
+ end
150
+ end
151
+
152
+ def draw_animated(renderer, tiles, cull_x, cull_y, cull_width, cull_height, elapsed)
153
+ tile_width = @map.tile_width
154
+ tile_height = @map.tile_height
155
+
156
+ ms = (elapsed * 1000.0).to_i
157
+
158
+ col_start = cull_x.fdiv(tile_width).floor
159
+ row_start = cull_y.fdiv(tile_height).floor
160
+ col_end = (cull_x + cull_width).fdiv(tile_width).ceil
161
+ row_end = (cull_y + cull_height).fdiv(tile_height).ceil
162
+
163
+ tiles.each do |col, row, local|
164
+ next if col < col_start || col >= col_end || row < row_start || row >= row_end
165
+
166
+ renderer.image_at(@tiles[@tileset.frame_local_id(local, ms)],
167
+ col * tile_width, row * tile_height)
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,101 @@
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 and named images.
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
+ # renderer.image(:home, cx, cy)
16
+ #
17
+ # A button has four states, a panel has one, a scrollbar has three pieces —
18
+ # all of them small, and all of them cheaper as sub-rectangles of a single
19
+ # texture than as a dozen separate files. This is that texture plus a
20
+ # descriptor naming what is where.
21
+ #
22
+ # ## The descriptor
23
+ #
24
+ # {
25
+ # "image": "buttons.png",
26
+ # "scale": 3,
27
+ # "nine_slices": {
28
+ # "button_idle": { "x": 11, "y": 59, "w": 26, "h": 28, "border": 7 },
29
+ # "button_focus": { "x": 43, "y": 59, "w": 26, "h": 28, "border": 7 },
30
+ # "panel": { "x": 0, "y": 0, "w": 32, "h": 32,
31
+ # "border": { "left": 4, "right": 4, "top": 8, "bottom": 4 },
32
+ # "scale": 2 }
33
+ # },
34
+ # "images": {
35
+ # "home": { "x": 0, "y": 96, "w": 50, "h": 50 },
36
+ # "gear": { "x": 50, "y": 96, "w": 50, "h": 50 }
37
+ # }
38
+ # }
39
+ #
40
+ # `image` is resolved next to the descriptor. Each nine-slice entry is a
41
+ # source rectangle plus a border — a uniform integer or one value per side —
42
+ # and an optional `scale` overriding the sheet default. See
43
+ # RGame::Core::NineSlice for what those mean when it is drawn.
44
+ #
45
+ # Each `images` entry is a rectangle and nothing else: an icon is drawn
46
+ # whole, so it has no border, and no scale either, because how large to draw
47
+ # it is the draw call's `scale:`. Either section may be absent or `null`.
48
+ #
49
+ # ## Element names, not filenames
50
+ #
51
+ # Both sections are keyed by whatever the descriptor calls each element, and
52
+ # those names are what a widget asks for. That is why nine-slices are the
53
+ # one asset the renderer resolves by registration only: `:button_focus` is
54
+ # not a file and never can be. `Renderer#register_ui_atlas` registers every
55
+ # element of both kinds in one call.
56
+ #
57
+ # Parsing happens once, at load. Nothing here is touched again per frame.
58
+ class UiAtlas
59
+ # The nine-slice elements, by name. Values are `NineSlice`s.
60
+ attr_reader :nine_slices
61
+
62
+ # The image elements, by name. Values are `Image`s cut from the sheet.
63
+ attr_reader :images
64
+
65
+ # Loads a descriptor and the sheet beside it.
66
+ #
67
+ # The `AssetManager` uses `.new` instead, with an image it has already
68
+ # cached, so the sheet is shared with any other use of the same file.
69
+ def self.load(app, atlas_path)
70
+ data = JSON.parse(File.read(atlas_path), symbolize_names: true)
71
+ directory = File.dirname(File.expand_path(atlas_path))
72
+ new(Image.new(app, File.join(directory, data[:image])), data)
73
+ end
74
+
75
+ # `data` is the parsed descriptor; `image` an already-loaded sheet.
76
+ def initialize(image, data)
77
+ sheet_scale = data[:scale] || 1
78
+
79
+ @nine_slices = (data[:nine_slices] || {}).to_h do |id, spec|
80
+ [id, element(id) { build_nine_slice(image, spec, sheet_scale) }]
81
+ end
82
+ @images = (data[:images] || {}).to_h do |id, spec|
83
+ [id, element(id) { image.subimage(spec[:x], spec[:y], spec[:w], spec[:h]) }]
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def build_nine_slice(image, spec, sheet_scale)
90
+ NineSlice.new(image, x: spec[:x], y: spec[:y], w: spec[:w], h: spec[:h],
91
+ border: spec[:border], scale: spec[:scale] || sheet_scale)
92
+ end
93
+
94
+ def element(id)
95
+ yield
96
+ rescue StandardError => e
97
+ raise ArgumentError, "ui atlas element #{id.inspect}: #{e.message}"
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgame/core_ext'
4
+
5
+ module RGame
6
+ module Core
7
+ # A synthetic game controller, for tests that need the real gamepad path
8
+ # with no hardware. It is C (`ext/rgame_core/input/virtual_gamepad.c`, bound
9
+ # in `ruby/virtual_gamepad_ext.c`), and it serves tests, not gameplay.
10
+ #
11
+ # pad = RGame::Core::VirtualGamepad.new # an App must be open
12
+ # pad.set_button(0, true) # SDL_CONTROLLER_BUTTON_A
13
+ # pad.set_axis(0, -32_768) # left stick fully left
14
+ # pad.detach # the App sees it unplugged
15
+ #
16
+ # SDL fabricates the device in-process, so the App seats it and raises its
17
+ # `gamepad_connected` and `gamepad_disconnected` callbacks as it would for a
18
+ # real pad. Buttons and axes take SDL's own numbers.
19
+ #
20
+ # It is part of the extension rather than a spec helper because only the
21
+ # extension is guaranteed to reach the SDL the App runs on, whether SDL is
22
+ # linked statically or dynamically.
23
+ class VirtualGamepad
24
+ end
25
+ end
26
+ end
data/lib/rgame/core.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'version'
4
+ require_relative 'core/app'
5
+ require_relative 'core/locale'
6
+ require_relative 'core/input'
7
+ require_relative 'core/gamepad'
8
+ require_relative 'core/virtual_gamepad'
9
+ require_relative 'core/image'
10
+ require_relative 'core/renderer'
11
+ require_relative 'core/recording'
12
+ require_relative 'core/font'
13
+ require_relative 'core/audio'
14
+ require_relative 'core/sprite_sheet'
15
+ require_relative 'core/nine_slice'
16
+ require_relative 'core/ui_atlas'
17
+ require_relative 'core/tile_map_renderer'
18
+ require_relative 'core/asset_manager'
Binary file
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The registered colliders as a blocker source: the broadphase answers "what is near
6
+ # this box", and the same snapping arithmetic TileBlockers runs against a tile edge
7
+ # runs against a collider's edge instead. Pure — it needs no scene, no node and no
8
+ # tree, only something answering `query_box` and `reindex`.
9
+ #
10
+ # It answers the blocker-source question — "where does this box land moving dx" — that
11
+ # Engine::CollisionSystem asks of every source it holds; see that class's header for
12
+ # the protocol and for how the most restrictive answer wins across sources. This is
13
+ # the source that makes two actors stop each other the way a wall stops one.
14
+ #
15
+ # ## Per body, unlike the other sources
16
+ #
17
+ # A TileBlockers is the same grid for everybody, so one is shared by every actor on
18
+ # the map. This one takes an `owner` and a layer list, so it belongs to exactly one
19
+ # body: two actors declaring different `blocked_by` cannot share one. That is why
20
+ # Components::Mover builds its own resolver rather than borrowing the scene's.
21
+ #
22
+ # ## Three things a grid does not need
23
+ #
24
+ # - **The most restrictive candidate wins.** Several colliders may be in the way, so
25
+ # this keeps a running minimum (maximum, going the other way). That is
26
+ # dup-insensitive, which matters because the broadphase may offer the same collider
27
+ # once per cell the two share.
28
+ # - **An overlap that already exists is not resolved.** A step is blocked only if it
29
+ # *crosses* an edge the mover was on the near side of. Two actors that start
30
+ # overlapping stay overlapping and neither is teleported out — blocking stops the
31
+ # mover, it never moves the thing it hit.
32
+ # - **Boxes only.** A CircleCollider on a blocked layer is skipped, and still reports
33
+ # on_hit exactly as it does now. It is a documented limit rather than a raise at
34
+ # attach: layer membership is a runtime fact, so a check at attach would catch only
35
+ # the circles that already exist and quietly miss the ones spawned later.
36
+ class ActorBlockers
37
+ # `world` answers query_box/reindex (a Components::CollisionWorld); `owner` is the
38
+ # mover's own collider, excluded by identity so a body is never stopped by itself;
39
+ # `layers` is the set of collider layers that may stop it.
40
+ def initialize(world:, owner:, layers:)
41
+ @world = world
42
+ @owner = owner
43
+ @layers = Array(layers)
44
+ @blocker = nil
45
+ end
46
+
47
+ # Who produced the edge the last resolve_x / resolve_y returned, or nil when that
48
+ # axis was free. Per-move state, and safe because this source belongs to one body,
49
+ # which reads it inside its own update.
50
+ attr_reader :blocker
51
+
52
+ # Where the box's left edge lands moving dx, snapping flush against the nearest
53
+ # collider it would cross into.
54
+ def resolve_x(x, y, w, h, dx)
55
+ @blocker = nil
56
+ nx = x + dx
57
+ return nx if dx.zero?
58
+
59
+ sweep_x = dx.negative? ? nx : x
60
+ @best = nx
61
+ @world.query_box(sweep_x, y, w + dx.abs, h) do |other|
62
+ next unless candidate?(other)
63
+ next unless y < other.aabb_y + other.aabb_h && other.aabb_y < y + h
64
+
65
+ if dx.positive?
66
+ edge = other.aabb_x
67
+ take_min(edge - w, other) if x + w <= edge && nx + w > edge
68
+ else
69
+ edge = other.aabb_x + other.aabb_w
70
+ take_max(edge, other) if x >= edge && nx < edge
71
+ end
72
+ end
73
+ @best
74
+ end
75
+
76
+ # The mirror of resolve_x. CollisionSystem feeds it the resolved x, which is what
77
+ # makes a diagonal push into a wall slide along it.
78
+ def resolve_y(x, y, w, h, dy)
79
+ @blocker = nil
80
+ ny = y + dy
81
+ return ny if dy.zero?
82
+
83
+ sweep_y = dy.negative? ? ny : y
84
+ @best = ny
85
+ @world.query_box(x, sweep_y, w, h + dy.abs) do |other|
86
+ next unless candidate?(other)
87
+ next unless x < other.aabb_x + other.aabb_w && other.aabb_x < x + w
88
+
89
+ if dy.positive?
90
+ edge = other.aabb_y
91
+ take_min(edge - h, other) if y + h <= edge && ny + h > edge
92
+ else
93
+ edge = other.aabb_y + other.aabb_h
94
+ take_max(edge, other) if y >= edge && ny < edge
95
+ end
96
+ end
97
+ @best
98
+ end
99
+
100
+ # Called by CollisionSystem#move once the resolved position is written back, with
101
+ # the box the step started from. Re-bucketing the owner here is what keeps the index
102
+ # exact for whoever resolves next this step; see CollisionWorld#reindex.
103
+ def moved(_actor, from_x, from_y, from_w, from_h)
104
+ @world.reindex(@owner, from_x, from_y, from_w, from_h)
105
+ end
106
+
107
+ private
108
+
109
+ def take_min(landed, other)
110
+ return unless landed < @best
111
+
112
+ @best = landed
113
+ @blocker = other
114
+ end
115
+
116
+ def take_max(landed, other)
117
+ return unless landed > @best
118
+
119
+ @best = landed
120
+ @blocker = other
121
+ end
122
+
123
+ def candidate?(other)
124
+ !other.equal?(@owner) &&
125
+ other.is_a?(Components::BoxCollider) &&
126
+ @layers.include?(other.layer) &&
127
+ !other.node.freed?
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,50 @@
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
+ private_constant :Anim
19
+
20
+ def initialize(animations)
21
+ @anims = animations.each_with_object({}) do |(name, a), table|
22
+ table[name.to_sym] = Anim.new(a[:row], a[:col] || 0, a[:frames], 1.0 / a[:fps], a[:flip_x] || false)
23
+ end
24
+ end
25
+
26
+ # Sheet row of `name` (fixed per animation).
27
+ def row(name)
28
+ @anims.fetch(name).row
29
+ end
30
+
31
+ # Sheet column of `name` at `elapsed` seconds — the only part that animates.
32
+ def col(name, elapsed)
33
+ anim = @anims.fetch(name)
34
+ anim.first_col + ((elapsed / anim.frame_secs).floor % anim.frames)
35
+ end
36
+
37
+ # Whether `name` draws mirrored (fixed per animation).
38
+ def flip_x(name)
39
+ @anims.fetch(name).flip_x
40
+ end
41
+
42
+ # [row, col, flip_x] for the given animation at `elapsed` seconds. A convenience
43
+ # for tests/inspection — it allocates an Array, so the per-frame draw path reads the
44
+ # row/col/flip_x accessors above directly instead.
45
+ def frame(name, elapsed)
46
+ [row(name), col(name, elapsed), flip_x(name)]
47
+ end
48
+ end
49
+ end
50
+ 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,59 @@
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 AudioBus by default, but
19
+ # any object exposing the same three signals). Returns self, so a caller can
20
+ # build and subscribe in one expression.
21
+ #
22
+ # **The handles are kept, so `unsubscribe` can take them back off.** AudioBus
23
+ # is a module — one hub for the whole process — and it holds its listeners
24
+ # until something removes them. A connected director is therefore reachable
25
+ # from a global, and it keeps the audio device alive, along with the asset
26
+ # manager that device resolves paths through and the App that manager loads
27
+ # images for, down to the window.
28
+ #
29
+ # A game never notices, because its App lives as long as the process does.
30
+ # Anywhere an App is created and discarded — a spec, a tool,
31
+ # `tools/drive_test_project.rb` — it is the difference between a window
32
+ # closing and a window leaking. RGame::Game subscribes a director when it
33
+ # starts and releases it when the loop ends, which is why a game calls
34
+ # neither method itself.
35
+ def subscribe(audio_bus = Engine::AudioBus)
36
+ @bus = audio_bus
37
+ @handles = [
38
+ audio_bus.on_play_sound.connect { @audio.play_sound(it) },
39
+ audio_bus.on_play_music.connect { @audio.play_music(it) },
40
+ audio_bus.on_stop_music.connect { @audio.stop_music }
41
+ ]
42
+ self
43
+ end
44
+
45
+ # Takes this director's listeners back off the hub. Safe to call twice, and
46
+ # safe on a director that was never subscribed — releasing something is not
47
+ # a good place to be strict about how many times it happens.
48
+ def unsubscribe
49
+ return self if @handles.nil?
50
+
51
+ @bus.on_play_sound.disconnect(@handles[0])
52
+ @bus.on_play_music.disconnect(@handles[1])
53
+ @bus.on_stop_music.disconnect(@handles[2])
54
+ @handles = nil
55
+ self
56
+ end
57
+ end
58
+ end
59
+ end