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,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # What physical inputs mean, for one player.
6
+ #
7
+ # map = InputMap.new(
8
+ # thrust: { axis: [Controls::KEY_DOWN, Controls::KEY_UP], stick: Controls::AXIS_TRIGGER_RIGHT },
9
+ # fire: { buttons: [Controls::KEY_SPACE, Controls::PAD_A] }
10
+ # )
11
+ #
12
+ # One entry per action, naming **physical ids from RGame::Util::Controls**
13
+ # directly. That is the whole point of this class: it is the single table a
14
+ # rebinding screen edits, and it holds nothing but integers, so the engine
15
+ # layer may own one outright.
16
+ #
17
+ # Three kinds of source, and an action may combine them:
18
+ #
19
+ # | Key | Reads as | Meaning |
20
+ # |---|---|---|
21
+ # | `buttons:` | `held?` | down if *any* listed id is down |
22
+ # | `axis:` | `axis` | `[negative_id, positive_id]`, or a list of such pairs — a digital axis from buttons |
23
+ # | `stick:` | `axis` | an analog axis id, for a real stick or trigger |
24
+ #
25
+ # ## One table serves every device
26
+ #
27
+ # Listing a key and a pad button in the same entry is safe, and needs no
28
+ # per-device branching, because **a device only answers for its own kind of
29
+ # input** — asking a gamepad about a keyboard scancode is `false`, never the
30
+ # keyboard's answer (see docs/api/input.md). So `fire` can be "Space or A"
31
+ # and each player's device picks out the half that applies to it.
32
+ #
33
+ # ## An axis can have several pairs, like a button can have several ids
34
+ #
35
+ # `axis: [KEY_LEFT, KEY_RIGHT]` is the common case and stays a bare pair.
36
+ # A list of pairs binds more than one control to the same axis:
37
+ #
38
+ # move_x: { axis: [[Controls::KEY_LEFT, Controls::KEY_RIGHT],
39
+ # [Controls::PAD_DPAD_LEFT, Controls::PAD_DPAD_RIGHT]],
40
+ # stick: Controls::AXIS_LEFT_X }
41
+ #
42
+ # Without it a d-pad cannot drive movement at all, because the same action
43
+ # already needed the arrow keys. The largest deflection wins, so the pairs
44
+ # cost nothing on a device that has only one of them.
45
+ #
46
+ # This replaces a two-stage scheme in which a game's action map named
47
+ # RGame::Core::Input's action names, which named physical ids — two tables in
48
+ # series, neither of them the one a config screen wanted, and the lower one
49
+ # unreachable from the engine layer, which may not name Core at all.
50
+ #
51
+ # ## A stick's sign is the device's, not the game's
52
+ #
53
+ # `AXIS_LEFT_Y` is positive *downwards*, like screen coordinates. An action
54
+ # that wants the opposite ("thrust", "climb") negates at the call site or
55
+ # binds a trigger instead — the map stays declarative rather than growing an
56
+ # inversion flag that every reader would then have to check for.
57
+ class InputMap
58
+ Controls = RGame::Util::Controls
59
+
60
+ # One action's resolved sources. Built once, at construction, so polling
61
+ # walks plain attribute reads and allocates nothing.
62
+ #
63
+ # `buttons` is "held if any of these is down"; `pairs` is a list of
64
+ # `[negative, positive]` button pairs, each a digital axis; `stick` is an
65
+ # analog axis id.
66
+ Binding = Struct.new(:buttons, :pairs, :stick)
67
+
68
+ SOURCES = %i[buttons axis stick].freeze
69
+
70
+ UI = {
71
+ ui_up: { buttons: [Controls::KEY_UP, Controls::PAD_DPAD_UP] },
72
+ ui_down: { buttons: [Controls::KEY_DOWN, Controls::PAD_DPAD_DOWN] },
73
+ ui_left: { buttons: [Controls::KEY_LEFT, Controls::PAD_DPAD_LEFT] },
74
+ ui_right: { buttons: [Controls::KEY_RIGHT, Controls::PAD_DPAD_RIGHT] },
75
+ ui_confirm: { buttons: [Controls::KEY_RETURN, Controls::KEY_SPACE, Controls::PAD_A] },
76
+ ui_cancel: { buttons: [Controls::KEY_ESCAPE, Controls::PAD_B] },
77
+ ui_radial_x: { axis: [[Controls::KEY_LEFT, Controls::KEY_RIGHT],
78
+ [Controls::PAD_DPAD_LEFT, Controls::PAD_DPAD_RIGHT]],
79
+ stick: Controls::AXIS_LEFT_X },
80
+ ui_radial_y: { axis: [[Controls::KEY_UP, Controls::KEY_DOWN],
81
+ [Controls::PAD_DPAD_UP, Controls::PAD_DPAD_DOWN]],
82
+ stick: Controls::AXIS_LEFT_Y }
83
+ }.freeze
84
+
85
+ DEFAULT_ACTIONS = {
86
+ move_x: { axis: [[Controls::KEY_LEFT, Controls::KEY_RIGHT],
87
+ [Controls::KEY_A, Controls::KEY_D],
88
+ [Controls::PAD_DPAD_LEFT, Controls::PAD_DPAD_RIGHT]],
89
+ stick: Controls::AXIS_LEFT_X },
90
+ move_y: { axis: [[Controls::KEY_UP, Controls::KEY_DOWN],
91
+ [Controls::KEY_W, Controls::KEY_S],
92
+ [Controls::PAD_DPAD_UP, Controls::PAD_DPAD_DOWN]],
93
+ stick: Controls::AXIS_LEFT_Y },
94
+ fire: { buttons: [Controls::KEY_SPACE, Controls::PAD_A] }
95
+ }.freeze
96
+
97
+ # Every action this map can answer for, as `name => Binding`.
98
+ attr_reader :bindings
99
+
100
+ # `entries` are merged over the universal UI set, so declaring a game's
101
+ # actions never costs it the ones the UI needs.
102
+ def initialize(entries = {})
103
+ @bindings = UI.merge(entries).to_h { |name, entry| [name, build(name, entry)] }.freeze
104
+ end
105
+
106
+ # The default map: the UI set plus DEFAULT_ACTIONS.
107
+ def self.default = new(DEFAULT_ACTIONS)
108
+
109
+ # A copy with `entries` overriding, which is how a game rebinds one action
110
+ # without restating the rest.
111
+ def merge(entries) = self.class.new(to_h.merge(entries))
112
+
113
+ def [](action) = @bindings[action]
114
+
115
+ def actions = @bindings.keys
116
+
117
+ # The button bound to `action` that `device` can actually press, or nil.
118
+ #
119
+ # This undoes what the section above describes. One entry lists a key and
120
+ # a pad button together so that polling needs no branch, and that is right
121
+ # for reading input and wrong for *showing* it: a prompt has to say "press
122
+ # A" or "press Space", never both, and which one depends on what the
123
+ # player last touched.
124
+ #
125
+ # map.button_for(:fire, Controls::KEYBOARD) # => KEY_SPACE
126
+ # map.button_for(:fire, Controls.gamepad(0)) # => PAD_A
127
+ #
128
+ # The first match wins, so the order an entry lists its ids in is the order
129
+ # a prompt prefers them — `ui_confirm` names Return before Space, and a
130
+ # prompt for it says Return.
131
+ #
132
+ # Nil for an action nobody bound, for one with no buttons at all (a stick
133
+ # or a digital axis is not a button and a prompt for one is a different
134
+ # picture), and for a device kind the entry does not cover. A caller
135
+ # showing a prompt has to handle that nil either way, because a rebinding
136
+ # screen can leave an action unbound.
137
+ #
138
+ # Allocation-free, so a HUD may call it per frame rather than caching a
139
+ # string it would then have to invalidate.
140
+ def button_for(action, device)
141
+ binding = @bindings[action]
142
+ buttons = binding&.buttons
143
+ return nil if buttons.nil?
144
+
145
+ pad = Controls.gamepad?(device)
146
+ buttons.find { |id| Controls.pad_button?(id) == pad }
147
+ end
148
+
149
+ # The entries in the shape they were declared in, so a map can be edited
150
+ # and rebuilt (a config screen) or merged.
151
+ def to_h
152
+ @bindings.to_h do |name, binding|
153
+ entry = {}
154
+ entry[:buttons] = binding.buttons if binding.buttons
155
+ entry[:axis] = binding.pairs.size == 1 ? binding.pairs.first : binding.pairs if binding.pairs
156
+ entry[:stick] = binding.stick if binding.stick
157
+ [name, entry]
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def build(name, entry)
164
+ unknown = entry.keys - SOURCES
165
+ raise ArgumentError, "#{name}: unknown source #{unknown.first.inspect}" unless unknown.empty?
166
+
167
+ buttons = freeze_ids(name, entry[:buttons])
168
+ pairs = axis_pairs(name, entry[:axis])
169
+ raise ArgumentError, "#{name}: no buttons, axis or stick" if buttons.nil? && pairs.nil? && entry[:stick].nil?
170
+
171
+ Binding.new(buttons, pairs, entry[:stick]).freeze
172
+ end
173
+
174
+ def freeze_ids(name, ids)
175
+ return nil if ids.nil?
176
+ raise ArgumentError, "#{name}: buttons must be a list of ids" unless ids.is_a?(Array) && !ids.empty?
177
+
178
+ ids.dup.freeze
179
+ end
180
+
181
+ def axis_pairs(name, axis)
182
+ return nil if axis.nil?
183
+
184
+ pairs = axis.is_a?(Array) && axis.first.is_a?(Array) ? axis : [axis]
185
+ pairs.each { |pair| check_pair(name, pair) }
186
+ pairs.map { |pair| pair.dup.freeze }.freeze
187
+ end
188
+
189
+ def check_pair(name, pair)
190
+ return if pair.is_a?(Array) && pair.size == 2
191
+
192
+ raise ArgumentError, "#{name}: axis must be [negative_id, positive_id], or a list of those"
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Where each viewport goes on screen: pure rectangle arithmetic, and nothing
6
+ # else.
7
+ #
8
+ # Layout.rects(2, 640, 480) # => [[0, 0, 640, 240], [0, 240, 640, 240]]
9
+ #
10
+ # No state, no anchors, no lifetime — so it is specced on its own with no
11
+ # tree and no window, which is the point of separating it from Viewports.
12
+ # That system holds *which* mode is current and *who* is playing; this only
13
+ # answers "given a count and a window, where do they go".
14
+ #
15
+ # ## Rects tile exactly
16
+ #
17
+ # Edges are computed as `(i * total) / count` rather than by multiplying a
18
+ # rounded size, so three rows of a 481-pixel window are 161, 160 and 160 and
19
+ # the last one still ends exactly at 481. Dividing first leaves a seam at the
20
+ # bottom of the screen that nothing draws into — a one-pixel line that looks
21
+ # like a rendering bug and is really a rounding one.
22
+ module Layout
23
+ # A window's worth of rows, columns or cells for `count` viewports.
24
+ #
25
+ # Two players get rows rather than columns because halving the height of a
26
+ # landscape window leaves each view landscape, while halving the width
27
+ # leaves two tall slots that fit a 2D scene badly. Three and four share a
28
+ # 2x2 grid, with the fourth cell left empty for three.
29
+ # Forwards its block with `&block` rather than re-yielding through
30
+ # `{ |*args| yield(*args) }`, which builds an Array per yield. Passing a
31
+ # block straight on allocates nothing — measured, not assumed; see the
32
+ # allocation example in layout_spec.rb.
33
+ def self.each_rect(count, width, height, &)
34
+ return if count <= 0
35
+ return yield(0, 0, 0, width, height) if count == 1
36
+ return each_row(count, width, height, &) if count == 2
37
+
38
+ each_cell(count, 2, 2, width, height, &)
39
+ end
40
+
41
+ # The same, as an Array of `[x, y, width, height]`. For specs and setup;
42
+ # the per-frame path uses #each_rect, which allocates nothing.
43
+ def self.rects(count, width, height)
44
+ result = []
45
+ each_rect(count, width, height) { |_i, x, y, w, h| result << [x, y, w, h] }
46
+ result
47
+ end
48
+
49
+ # `count` full-width rows, stacked.
50
+ def self.each_row(count, width, height)
51
+ count.times do |i|
52
+ top = edge(i, count, height)
53
+ yield(i, 0, top, width, edge(i + 1, count, height) - top)
54
+ end
55
+ end
56
+
57
+ # `count` full-height columns, side by side.
58
+ def self.each_column(count, width, height)
59
+ count.times do |i|
60
+ left = edge(i, count, width)
61
+ yield(i, left, 0, edge(i + 1, count, width) - left, height)
62
+ end
63
+ end
64
+
65
+ # The first `count` cells of a `cols` x `rows` grid, filled left to right,
66
+ # top to bottom.
67
+ def self.each_cell(count, cols, rows, width, height)
68
+ count.times do |i|
69
+ col = i % cols
70
+ row = i / cols
71
+ left = edge(col, cols, width)
72
+ top = edge(row, rows, height)
73
+ yield(i, left, top, edge(col + 1, cols, width) - left, edge(row + 1, rows, height) - top)
74
+ end
75
+ end
76
+
77
+ # The i-th boundary of `count` even divisions of `total`. Multiplying
78
+ # before dividing is what makes the divisions tile with no seam.
79
+ def self.edge(index, count, total) = (index * total) / count
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A walkability grid and the searches over it: region labels and A* routes, run in C by a
6
+ # Util::RouteSearch over a Util::SolidGrid.
7
+ #
8
+ # Where TileBlockers answers *what is in the way of a step*, this answers *what is in the
9
+ # way of a route*: the same fact about the same cells, asked over a graph instead of along
10
+ # a box's sweep. Components::TileWorld owns both, over one store.
11
+ #
12
+ # It is built one of two ways:
13
+ #
14
+ # - **`grid:`** — over a SolidGrid it shares. A cell changed in that grid is changed here
15
+ # at once, and region labels follow it on the next query with nothing rebuilt. This is
16
+ # how a TileWorld builds one, and the way to build one over solidity that will change.
17
+ # - **`width:`, `height:`, `solid:`** — from a callable, `solid.call(col, row) -> bool`,
18
+ # read once per cell into a private grid of its own. Nothing re-reads the callable, so a
19
+ # change behind it is never seen.
20
+ #
21
+ # Anything else — both, or neither — is an ArgumentError.
22
+ #
23
+ # Routes are cells, never pixels. Turning a cell into the point a node must reach depends
24
+ # on the node's collider, which a grid has no business knowing; Components::Navigator
25
+ # does that.
26
+ #
27
+ # The grid is 8-connected with octile costs (1 straight, √2 diagonal), and a diagonal step
28
+ # is allowed only when both orthogonal cells beside it are open, so a route never cuts the
29
+ # corner of a solid cell. Connected regions are labelled whenever the grid has changed
30
+ # since they last were, which makes an unreachable goal — the answer a search is slowest
31
+ # to give, because it has to exhaust the region first — a lookup whenever it lies in
32
+ # another region.
33
+ #
34
+ # Coordinates are Integers, and anything else is a TypeError. An Integer outside the
35
+ # grid, however large, is answered like any cell outside it.
36
+ #
37
+ # A search runs on demand, never per frame, and allocates its result; everything else it
38
+ # keeps is reused between searches. One grid is therefore not safe to search from two
39
+ # threads at once.
40
+ class NavGrid
41
+ def initialize(grid: nil, width: nil, height: nil, solid: nil)
42
+ @grid = grid ? shared(grid, width, height, solid) : private_grid(width, height, solid)
43
+ @search = Util::RouteSearch.new(@grid)
44
+ end
45
+
46
+ def width = @grid.width
47
+ def height = @grid.height
48
+
49
+ def walkable?(col, row)
50
+ !@grid.solid?(col, row) && col >= 0 && row >= 0 && col < @grid.width && row < @grid.height
51
+ end
52
+
53
+ # The connected region containing the cell, as an Integer label — two walkable cells
54
+ # share a label exactly when a route joins them — or nil for a solid cell or one
55
+ # outside the grid.
56
+ def region(col, row) = @search.region(col, row)
57
+
58
+ def reachable?(from_col, from_row, to_col, to_row)
59
+ from = @search.region(from_col, from_row)
60
+ to = @search.region(to_col, to_row)
61
+ !from.nil? && from == to
62
+ end
63
+
64
+ # The cheapest route from start to goal, both included, as `[[col, row], ...]` — or
65
+ # nil when either end is solid, outside the grid, or in another region. Start equal
66
+ # to goal is a route of one cell. Ties between equally cheap routes are broken the
67
+ # same way every time, so the same query always returns the same route.
68
+ def find(from_col, from_row, to_col, to_row) = @search.find(from_col, from_row, to_col, to_row)
69
+
70
+ private
71
+
72
+ def shared(grid, width, height, solid)
73
+ return grid unless width || height || solid
74
+
75
+ raise ArgumentError, 'a NavGrid is built over a grid: or from width:, height: and solid:, not both'
76
+ end
77
+
78
+ def private_grid(width, height, solid)
79
+ unless width && height && solid
80
+ raise ArgumentError, 'a NavGrid needs a grid:, or all of width:, height: and solid:'
81
+ end
82
+
83
+ Util::SolidGrid.build(width, height) { |col, row| solid.call(col, row) }
84
+ end
85
+ end
86
+ end
87
+ end