rgame 0.2.0 → 0.3.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 (253) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -1
  3. data/README.md +67 -65
  4. data/docs/api/README.md +86 -82
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -165
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1032 -265
  10. data/docs/api/drawing.md +160 -160
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +149 -50
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +226 -148
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +397 -244
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +186 -73
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +395 -146
  23. data/docs/api/ui.md +883 -54
  24. data/docs/api/values.md +216 -33
  25. data/examples/assets/README.md +322 -0
  26. data/examples/assets/blip.ogg +0 -0
  27. data/examples/assets/glyphs.json +5 -0
  28. data/examples/assets/glyphs.png +0 -0
  29. data/examples/assets/hero.json +12 -0
  30. data/examples/assets/hero.png +0 -0
  31. data/examples/assets/icons.json +13 -0
  32. data/examples/assets/icons.png +0 -0
  33. data/examples/assets/music.ogg +0 -0
  34. data/examples/assets/skills.json +10 -0
  35. data/examples/assets/skills.png +0 -0
  36. data/examples/assets/tileset.png +0 -0
  37. data/examples/assets/tileset.tsx +65 -0
  38. data/examples/assets/town.tmx +26 -0
  39. data/examples/assets/ui.json +11 -0
  40. data/examples/assets/ui.png +0 -0
  41. data/examples/collision/locales/en.yml +8 -0
  42. data/examples/collision/main.rb +316 -0
  43. data/examples/collision_tiles/locales/en.yml +9 -0
  44. data/examples/collision_tiles/main.rb +274 -0
  45. data/examples/fullscreen/locales/en.yml +10 -0
  46. data/examples/fullscreen/main.rb +216 -0
  47. data/examples/game_menu/locales/en.yml +8 -0
  48. data/examples/game_menu/main.rb +170 -0
  49. data/examples/input_glyphs/locales/en.yml +14 -0
  50. data/examples/input_glyphs/main.rb +213 -0
  51. data/examples/jump_topdown/locales/en.yml +9 -0
  52. data/examples/jump_topdown/main.rb +178 -0
  53. data/examples/localization/locales/de.yml +12 -0
  54. data/examples/localization/locales/en.yml +13 -0
  55. data/examples/localization/main.rb +158 -0
  56. data/examples/menu_navigation/locales/en.yml +23 -0
  57. data/examples/menu_navigation/main.rb +365 -0
  58. data/examples/music/locales/en.yml +7 -0
  59. data/examples/music/main.rb +134 -0
  60. data/examples/pathfinding/locales/en.yml +17 -0
  61. data/examples/pathfinding/main.rb +298 -0
  62. data/examples/pooling/locales/en.yml +7 -0
  63. data/examples/pooling/main.rb +259 -0
  64. data/examples/quick_wheel/locales/en.yml +16 -0
  65. data/examples/quick_wheel/main.rb +184 -0
  66. data/examples/radial_menu/locales/en.yml +16 -0
  67. data/examples/radial_menu/main.rb +184 -0
  68. data/examples/save_load/locales/en.yml +11 -0
  69. data/examples/save_load/main.rb +207 -0
  70. data/examples/save_load_ids/locales/en.yml +11 -0
  71. data/examples/save_load_ids/main.rb +322 -0
  72. data/examples/scroll_map/locales/en.yml +4 -0
  73. data/examples/scroll_map/main.rb +140 -0
  74. data/examples/signals/locales/en.yml +6 -0
  75. data/examples/signals/main.rb +278 -0
  76. data/examples/skill_bar/locales/en.yml +14 -0
  77. data/examples/skill_bar/main.rb +159 -0
  78. data/examples/sound/locales/en.yml +6 -0
  79. data/examples/sound/main.rb +122 -0
  80. data/examples/split_screen/locales/en.yml +9 -0
  81. data/examples/split_screen/main.rb +304 -0
  82. data/examples/sprite/locales/en.yml +8 -0
  83. data/examples/sprite/main.rb +180 -0
  84. data/examples/timer/locales/en.yml +12 -0
  85. data/examples/timer/main.rb +273 -0
  86. data/examples/velocity/locales/en.yml +6 -0
  87. data/examples/velocity/main.rb +196 -0
  88. data/examples/walk/locales/en.yml +4 -0
  89. data/examples/walk/main.rb +99 -0
  90. data/exe/rgame +9 -0
  91. data/ext/rgame_core/app/app.c +33 -3
  92. data/ext/rgame_core/app/locale.c +67 -0
  93. data/ext/rgame_core/app/locale.h +28 -0
  94. data/ext/rgame_core/audio/audio.c +39 -2
  95. data/ext/rgame_core/example.rb +0 -49
  96. data/ext/rgame_core/extconf.rb +0 -125
  97. data/ext/rgame_core/include/rgame/core.h +38 -2
  98. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  99. data/ext/rgame_core/ruby/core_ext.c +30 -7
  100. data/ext/rgame_core/ruby/core_ext.h +3 -0
  101. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  102. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  103. data/ext/rgame_core/ruby/renderer_ext.c +20 -20
  104. data/ext/rgame_util/extconf.rb +2 -20
  105. data/ext/rgame_util/route_search.c +305 -0
  106. data/ext/rgame_util/route_search.h +86 -0
  107. data/ext/rgame_util/route_search_ext.c +150 -0
  108. data/ext/rgame_util/solid_grid.c +58 -0
  109. data/ext/rgame_util/solid_grid.h +49 -0
  110. data/ext/rgame_util/solid_grid_ext.c +161 -0
  111. data/ext/rgame_util/tile_sweep.c +164 -0
  112. data/ext/rgame_util/tile_sweep.h +62 -0
  113. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  114. data/ext/rgame_util/util_ext.c +3 -0
  115. data/ext/rgame_util/util_ext.h +15 -0
  116. data/lib/rgame/boot.rb +0 -10
  117. data/lib/rgame/cli/new_project.rb +139 -0
  118. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  119. data/lib/rgame/cli/templates/README.md.tt +93 -0
  120. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  121. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  122. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  123. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  124. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  125. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  126. data/lib/rgame/cli/templates/rspec.tt +2 -0
  127. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  128. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  129. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  130. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  131. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  132. data/lib/rgame/cli.rb +66 -0
  133. data/lib/rgame/core/app.rb +6 -44
  134. data/lib/rgame/core/asset_manager.rb +13 -31
  135. data/lib/rgame/core/audio.rb +37 -16
  136. data/lib/rgame/core/font.rb +0 -3
  137. data/lib/rgame/core/locale.rb +22 -0
  138. data/lib/rgame/core/nine_slice.rb +0 -21
  139. data/lib/rgame/core/renderer.rb +6 -63
  140. data/lib/rgame/core/sprite_sheet.rb +0 -3
  141. data/lib/rgame/core/tile_map_renderer.rb +2 -19
  142. data/lib/rgame/core/ui_atlas.rb +28 -13
  143. data/lib/rgame/core.rb +1 -8
  144. data/lib/rgame/engine/actor_blockers.rb +131 -0
  145. data/lib/rgame/engine/animation_set.rb +1 -0
  146. data/lib/rgame/engine/audio_director.rb +36 -6
  147. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  148. data/lib/rgame/engine/camera.rb +3 -3
  149. data/lib/rgame/engine/circle_collider.rb +4 -2
  150. data/lib/rgame/engine/collision_box.rb +26 -1
  151. data/lib/rgame/engine/collision_system.rb +110 -22
  152. data/lib/rgame/engine/component.rb +34 -10
  153. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  154. data/lib/rgame/engine/components/animated_sprite.rb +27 -25
  155. data/lib/rgame/engine/components/box_collider.rb +99 -0
  156. data/lib/rgame/engine/components/camera_follow.rb +6 -5
  157. data/lib/rgame/engine/components/character_body.rb +20 -61
  158. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  159. data/lib/rgame/engine/components/collision_world.rb +159 -31
  160. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  161. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  162. data/lib/rgame/engine/components/hop.rb +76 -0
  163. data/lib/rgame/engine/components/identity.rb +73 -0
  164. data/lib/rgame/engine/components/mover.rb +285 -0
  165. data/lib/rgame/engine/components/navigator.rb +145 -0
  166. data/lib/rgame/engine/components/path_follow.rb +123 -31
  167. data/lib/rgame/engine/components/player_controller.rb +5 -2
  168. data/lib/rgame/engine/components/pool.rb +1 -1
  169. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  170. data/lib/rgame/engine/components/sprite.rb +12 -6
  171. data/lib/rgame/engine/components/targeting.rb +9 -11
  172. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  173. data/lib/rgame/engine/components/tile_world.rb +41 -13
  174. data/lib/rgame/engine/components/timer.rb +1 -1
  175. data/lib/rgame/engine/components/velocity.rb +23 -7
  176. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  177. data/lib/rgame/engine/components/world.rb +133 -0
  178. data/lib/rgame/engine/contact_set.rb +74 -0
  179. data/lib/rgame/engine/culling.rb +4 -6
  180. data/lib/rgame/engine/debug_overlay.rb +5 -11
  181. data/lib/rgame/engine/i18n/plural.rb +45 -0
  182. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  183. data/lib/rgame/engine/i18n/template.rb +59 -0
  184. data/lib/rgame/engine/i18n.rb +276 -51
  185. data/lib/rgame/engine/input/action_mapper.rb +0 -26
  186. data/lib/rgame/engine/input/actions.rb +2 -8
  187. data/lib/rgame/engine/input/input_map.rb +39 -21
  188. data/lib/rgame/engine/nav_grid.rb +87 -0
  189. data/lib/rgame/engine/node2d.rb +248 -133
  190. data/lib/rgame/engine/path.rb +4 -6
  191. data/lib/rgame/engine/players.rb +6 -13
  192. data/lib/rgame/engine/presentation.rb +171 -0
  193. data/lib/rgame/engine/scene/scene_stack.rb +4 -4
  194. data/lib/rgame/engine/sealed_privates.rb +54 -0
  195. data/lib/rgame/engine/spatial_hash.rb +51 -19
  196. data/lib/rgame/engine/text.rb +194 -0
  197. data/lib/rgame/engine/tile_blockers.rb +63 -0
  198. data/lib/rgame/engine/tile_map.rb +2 -3
  199. data/lib/rgame/engine/tile_map_layer.rb +0 -2
  200. data/lib/rgame/engine/tileset.rb +2 -4
  201. data/lib/rgame/engine/timer.rb +2 -2
  202. data/lib/rgame/engine/ui/button.rb +248 -0
  203. data/lib/rgame/engine/ui/column.rb +20 -0
  204. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  205. data/lib/rgame/engine/ui/menu.rb +246 -71
  206. data/lib/rgame/engine/ui/navigation.rb +57 -0
  207. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  208. data/lib/rgame/engine/ui/option_button.rb +163 -0
  209. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  210. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  211. data/lib/rgame/engine/ui/pointing.rb +146 -0
  212. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  213. data/lib/rgame/engine/ui/ring.rb +55 -0
  214. data/lib/rgame/engine/ui/row.rb +21 -0
  215. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  216. data/lib/rgame/engine/ui/stack.rb +58 -0
  217. data/lib/rgame/engine/ui/stepping.rb +93 -0
  218. data/lib/rgame/engine/ui/text_button.rb +59 -0
  219. data/lib/rgame/engine/viewports.rb +2 -5
  220. data/lib/rgame/engine/world_view.rb +5 -4
  221. data/lib/rgame/engine.rb +31 -25
  222. data/lib/rgame/game.rb +99 -27
  223. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  224. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  225. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  226. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  227. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  228. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  229. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  230. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  231. data/lib/rgame/rubocop/default.yml +39 -0
  232. data/lib/rgame/rubocop/plugin.rb +45 -0
  233. data/lib/rgame/rubocop.rb +11 -0
  234. data/lib/rgame/util/color.rb +20 -24
  235. data/lib/rgame/util/controls.rb +14 -28
  236. data/lib/rgame/util/route_search.rb +27 -0
  237. data/lib/rgame/util/save_file.rb +107 -0
  238. data/lib/rgame/util/solid_grid.rb +37 -0
  239. data/lib/rgame/util/tensor.rb +0 -9
  240. data/lib/rgame/util/tile_sweep.rb +36 -0
  241. data/lib/rgame/util/z.rb +4 -14
  242. data/lib/rgame/util.rb +4 -3
  243. data/lib/rgame/version.rb +1 -1
  244. data/lib/rgame.rb +0 -15
  245. metadata +152 -11
  246. data/lib/rgame/engine/actor.rb +0 -53
  247. data/lib/rgame/engine/body.rb +0 -49
  248. data/lib/rgame/engine/cached_label.rb +0 -33
  249. data/lib/rgame/engine/input/player_controller.rb +0 -14
  250. data/lib/rgame/engine/matrix.rb +0 -32
  251. data/lib/rgame/engine/resettable.rb +0 -67
  252. data/lib/rgame/engine/tile_collision.rb +0 -78
  253. data/lib/rgame/engine/ui/menu_item.rb +0 -84
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'layer_boundary'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Game
8
+ # `RGame::Core` must never name `RGame::Engine`. The mirror of
9
+ # `NoCoreInEngineLayer`, and the more easily broken of the two.
10
+ #
11
+ # Engine is built *on top of* Core, so Core should not know it exists.
12
+ # Core owns windows, textures and sound devices; Engine owns scene
13
+ # concepts. A Core class that reaches upward makes the lower layer
14
+ # unusable without the higher one, and inverts a dependency that the whole
15
+ # three-layer split exists to keep pointing one way.
16
+ #
17
+ # Where Core genuinely needs something Engine has — a tile map's grid, say
18
+ # — it takes the object and calls it by method name, exactly as the engine
19
+ # layer does with a renderer. The one place allowed to name both sides is
20
+ # the glue class directly under `RGame`, which is what a glue class is
21
+ # for.
22
+ #
23
+ # Unlike its mirror, nothing catches this at runtime: loading Engine into
24
+ # a Core spec would work fine and the inversion would go unnoticed until
25
+ # someone tried to use Core on its own. This cop is the only guard.
26
+ #
27
+ # Both spellings are flagged — `RGame::Engine` and the bare `Engine` the
28
+ # layer still has before it is ported — because the interim is exactly
29
+ # when the mistake gets made.
30
+ #
31
+ # @example
32
+ # # bad — Core parsing a file format Engine owns
33
+ # def self.load(app, path)
34
+ # map = Engine::TileMap.parse(File.read(path))
35
+ # new(app, map)
36
+ # end
37
+ #
38
+ # # bad — a require, in either spelling
39
+ # require 'rgame/engine'
40
+ #
41
+ # # good — hand Core the parsed thing and call it by name
42
+ # def initialize(map, tiles)
43
+ # @columns = map.width
44
+ # end
45
+ class NoEngineInCoreLayer < RuboCop::Cop::Base
46
+ include LayerBoundary
47
+
48
+ MSG = 'RGame::Core must not name `%{name}`; take the object and call it ' \
49
+ 'by method name, and let the glue layer wire the two together.'
50
+ MSG_REQUIRE = 'RGame::Core must not require `%{path}` — Engine is built on ' \
51
+ 'top of Core, not the other way round.'
52
+
53
+ PREFIXES = [%w[RGame Engine], %w[Engine]].freeze
54
+ RESTRICTED_REQUIRE = %r{(\A|/)engine(/|\z)}
55
+
56
+ # @!method engine_require(node)
57
+ def_node_matcher :engine_require, <<~PATTERN
58
+ (send nil? {:require :require_relative} (str $_))
59
+ PATTERN
60
+
61
+ def on_send(node)
62
+ engine_require(node) do |path|
63
+ next unless RESTRICTED_REQUIRE.match?(path.to_s.delete_prefix('./'))
64
+
65
+ add_offense(node, message: format(MSG_REQUIRE, path: path))
66
+ end
67
+ end
68
+
69
+ def on_const(node)
70
+ return unless opens_namespace?(node, PREFIXES)
71
+
72
+ add_offense(node, message: format(MSG, name: matched_prefix(node).join('::')))
73
+ end
74
+
75
+ private
76
+
77
+ def matched_prefix(node)
78
+ path = const_path(node)
79
+ PREFIXES.find { |prefix| path.first(prefix.length) == prefix }
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'hot_path'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Game
8
+ # Disallow string interpolation inside per-frame methods — the engine's lifecycle
9
+ # hooks (`update`/`control`/`draw`/`on_update`/`on_draw`/`on_control`) and any method
10
+ # tagged `# hot-path`.
11
+ #
12
+ # These run every frame (~60/s), and string interpolation builds a brand-new
13
+ # `String` each time it is evaluated (the `frozen_string_literal` pragma freezes
14
+ # *literals*, not interpolated results). So a `"Score: #{n}"` here is a per-frame
15
+ # allocation. Build the string once — on construction, or when the value changes
16
+ # (see `Engine::Text`) — and draw the cached copy.
17
+ #
18
+ # @example
19
+ # # bad
20
+ # def draw(renderer)
21
+ # renderer.text("Score: #{@score}", 10, 10)
22
+ # end
23
+ #
24
+ # # good (rebuilt only when @score changes)
25
+ # def draw(renderer)
26
+ # renderer.text(@score_label, 10, 10)
27
+ # end
28
+ class NoInterpolationInHotPath < RuboCop::Cop::Base
29
+ include HotPath
30
+
31
+ MSG = 'Avoid string interpolation in a per-frame method: it allocates a String ' \
32
+ 'every frame. Build the string once (on change) and use the cached value.'
33
+
34
+ def on_def(node)
35
+ return unless hot_path_def?(node)
36
+
37
+ node.each_descendant(:dstr) do |dstr|
38
+ add_offense(dstr) if interpolated?(dstr)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def interpolated?(dstr)
45
+ dstr.children.any?(&:begin_type?)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Game
6
+ # Flag a String literal drawn or measured as a label: the first argument of
7
+ # `text` or `text_width`, on any receiver.
8
+ #
9
+ # Text a player reads belongs in a translation table, where `RGame::Game`
10
+ # loads it and a language switch reaches it. A literal at the call site is
11
+ # the one place no table can: it shows the same words in every language, and
12
+ # it is the form a reader copies out of an example. Build an `Engine::Text`
13
+ # from a key once, off the per-frame path, and pass it as it is.
14
+ #
15
+ # This sees the call site only. A constant or ivar holding a String passes,
16
+ # so a table of Strings chosen by state is not caught; whether a constant
17
+ # holds words is not a question syntax can answer.
18
+ #
19
+ # @example
20
+ # # bad
21
+ # renderer.text('Press Space to jump', 12, 12)
22
+ # renderer.text("Lives: #{lives}", 12, 34)
23
+ #
24
+ # # good
25
+ # @help = Engine::Text.new('help.jump') # in initialize
26
+ # renderer.text(@help, 12, 12)
27
+ class NoLiteralText < RuboCop::Cop::Base
28
+ MSG = 'Draw an Engine::Text built from a key, not a String literal: ' \
29
+ 'text a player reads belongs in a translation table.'
30
+
31
+ RESTRICT_ON_SEND = %i[text text_width].freeze
32
+
33
+ def on_send(node)
34
+ label = node.first_argument
35
+ add_offense(label) if label&.type?(:str, :dstr)
36
+ end
37
+ alias on_csend on_send
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'hot_path'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Game
8
+ # Flag fresh Array / Range literals that are needless per-frame allocations.
9
+ #
10
+ # Two triggers:
11
+ # * Anywhere — a literal used as a method-call *receiver*: `[a, b].sum`,
12
+ # `(a..b).any? { ... }`. Each call allocates the collection just to reduce or
13
+ # iterate it; compare directly or loop with an index instead.
14
+ # * Inside a per-frame method (a lifecycle hook or a `# hot-path`-tagged helper) —
15
+ # *any* Array/Range literal, e.g. returning `[x, y, w, h]` for the caller to
16
+ # decompose. Expose the parts separately (see `Engine::AnimationSet` row/col/flip_x).
17
+ #
18
+ # Allowed: an empty `[]` (idiomatic mutable-state seed), a frozen `[...].freeze`
19
+ # (allocated once), a parallel-assignment RHS (`a, b = c, d`, which the VM does
20
+ # not allocate), and the four sends the VM rewrites to `opt_newarray_send`.
21
+ #
22
+ # That last one is not a concession, it is a correction. `[a, b].min` compiles to
23
+ # a single `opt_newarray_send` instruction that reads the operands off the stack
24
+ # and never builds an Array — measured at 0 allocations over 200,000 calls, and
25
+ # visible in `RubyVM::InstructionSequence.compile("[a, b].min").disasm`. Flagging
26
+ # it cost real clarity: `Style/MinMaxComparison` asks for exactly this form, so
27
+ # code was being written around a conflict that does not exist.
28
+ #
29
+ # The optimisation is narrow, which is why OPTIMISED_SENDS is a list rather than a
30
+ # guess. `sum`, `first`, `last` and everything else still emit a plain `newarray`,
31
+ # and even `min` falls back to one the moment it is given a block or an argument
32
+ # (`[a, b].min { ... }`, `[a, b].min(1)`). Ranges never had an Array to build.
33
+ class NoNeedlessAllocation < RuboCop::Cop::Base
34
+ include HotPath
35
+
36
+ MSG_RECEIVER = 'Needless %{kind} allocation: this literal is built every call ' \
37
+ 'just to call `%{method}` on it — rewrite without the literal.'
38
+
39
+ OPTIMISED_SENDS = { min: 0, max: 0, hash: 0, include?: 1 }.freeze
40
+ MSG_HOT_PATH = 'Needless %{kind} allocation in a per-frame method: this literal ' \
41
+ 'is built every frame — build it once or expose the parts directly.'
42
+
43
+ def on_array(node)
44
+ check(node, 'array')
45
+ end
46
+
47
+ def on_irange(node)
48
+ check(node, 'range')
49
+ end
50
+
51
+ def on_erange(node)
52
+ check(node, 'range')
53
+ end
54
+
55
+ private
56
+
57
+ def check(node, kind)
58
+ return if allowed?(node)
59
+
60
+ send = receiving_send(node)
61
+ if send
62
+ add_offense(node, message: format(MSG_RECEIVER, kind: kind, method: send.method_name))
63
+ elsif in_hot_path?(node)
64
+ add_offense(node, message: format(MSG_HOT_PATH, kind: kind))
65
+ end
66
+ end
67
+
68
+ def allowed?(node)
69
+ return true if node.array_type? && node.children.empty?
70
+ return true if frozen?(node)
71
+ return true if node.parent&.masgn_type?
72
+ return true if optimised_send?(node)
73
+
74
+ false
75
+ end
76
+
77
+ def optimised_send?(node)
78
+ return false unless node.array_type?
79
+ return false if node.children.any?(&:splat_type?)
80
+
81
+ send = receiving_send(node)
82
+ return false if send.nil? || send.block_node
83
+
84
+ arity = OPTIMISED_SENDS[send.method_name]
85
+ !arity.nil? && send.arguments.size == arity
86
+ end
87
+
88
+ def frozen?(node)
89
+ send = receiving_send(node)
90
+ send&.method?(:freeze)
91
+ end
92
+
93
+ def receiving_send(node)
94
+ receiver = node
95
+ parent = node.parent
96
+ if parent&.begin_type? && parent.children.one?
97
+ receiver = parent
98
+ parent = parent.parent
99
+ end
100
+ return unless parent&.send_type? && parent.receiver == receiver
101
+
102
+ parent
103
+ end
104
+
105
+ def in_hot_path?(node)
106
+ enclosing = node.each_ancestor(:def).first
107
+ enclosing && hot_path_def?(enclosing)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,39 @@
1
+ # The Game/ cops' defaults, for any project that loads the plugin. Each cop's
2
+ # class comment, under cop/game/, says what it catches and why.
3
+ #
4
+ # Specs are test code: their draw stubs and fixture arrays are not a per-frame
5
+ # path the game ships, so the two allocation guards skip them. The draw-space
6
+ # and translation rules still apply there, because a spec that draws in the
7
+ # wrong place describes a game that does too.
8
+ #
9
+ # The two layer cops are off by default. Each needs to be told which files form
10
+ # a layer, and that is a fact about a project's layout rather than about rgame.
11
+ # A project from `rgame new` enables NoCoreInEngineLayer for its nodes/ and spec/.
12
+
13
+ Game/DrawInLocalSpace:
14
+ Description: A node's draw methods must not read its own position, which is already applied.
15
+ Enabled: true
16
+
17
+ Game/NoInterpolationInHotPath:
18
+ Description: No string interpolation in a per-frame method.
19
+ Enabled: true
20
+ Exclude:
21
+ - '**/spec/**/*'
22
+
23
+ Game/NoNeedlessAllocation:
24
+ Description: No throwaway Array or Range literal on a per-frame path.
25
+ Enabled: true
26
+ Exclude:
27
+ - '**/spec/**/*'
28
+
29
+ Game/NoLiteralText:
30
+ Description: Draw text from a translation key, not a String literal.
31
+ Enabled: true
32
+
33
+ Game/NoCoreInEngineLayer:
34
+ Description: Headless code must not name or require RGame::Core.
35
+ Enabled: false
36
+
37
+ Game/NoEngineInCoreLayer:
38
+ Description: RGame::Core must not name or require the engine layer.
39
+ Enabled: false
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../version'
4
+
5
+ module RuboCop
6
+ module Game
7
+ # Hands RuboCop the `Game/` cops' default configuration, in `default.yml`
8
+ # beside this file.
9
+ #
10
+ # A project loads it by path and class, from `.rubocop.yml`:
11
+ #
12
+ # plugins:
13
+ # - rgame/rubocop:
14
+ # plugin_class_name: RuboCop::Game::Plugin
15
+ #
16
+ # The bare gem name would make RuboCop `require 'rgame'` and load the whole
17
+ # engine, compiled extension included, just to lint. `lint_roller` comes
18
+ # with every RuboCop that supports plugins, so the gem needs no dependency.
19
+ #
20
+ # It lives under `RuboCop` rather than `RGame`, like every other RuboCop
21
+ # extension, because RuboCop's process is the only one that loads it.
22
+ class Plugin < LintRoller::Plugin
23
+ def about
24
+ LintRoller::About.new(
25
+ name: 'rgame',
26
+ version: RGame::VERSION,
27
+ homepage: 'https://github.com/psuessenb/rgame',
28
+ description: 'Per-frame allocation, draw-space, layering and translation rules for rgame games.'
29
+ )
30
+ end
31
+
32
+ def supported?(context)
33
+ context.engine == :rubocop
34
+ end
35
+
36
+ def rules(_context)
37
+ LintRoller::Rules.new(
38
+ type: :path,
39
+ config_format: :rubocop,
40
+ value: File.join(__dir__, 'default.yml')
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lint_roller'
4
+
5
+ require_relative 'rubocop/plugin'
6
+ require_relative 'rubocop/cop/game/draw_in_local_space'
7
+ require_relative 'rubocop/cop/game/no_core_in_engine_layer'
8
+ require_relative 'rubocop/cop/game/no_engine_in_core_layer'
9
+ require_relative 'rubocop/cop/game/no_interpolation_in_hot_path'
10
+ require_relative 'rubocop/cop/game/no_literal_text'
11
+ require_relative 'rubocop/cop/game/no_needless_allocation'
@@ -1,27 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # RGame::Util::Color is implemented in C — see ext/rgame_util/color_ext.c for
4
- # the Ruby binding and ext/rgame_util/color.c for the packing arithmetic.
5
- #
6
- # Color = RGame::Util::Color
7
- # Color.new(255, 128, 0) # r, g, b, a defaults to 255
8
- # Color.rgba(255, 128, 0, 200) # the same thing, named
9
- # Color.from_packed(0xFF8000FF) # 0xRRGGBBAA
10
- # Color::WHITE # also BLACK and TRANSPARENT
11
- #
12
- # c.r; c.g; c.b; c.a # => Integer, 0..255
13
- # c.packed # => Integer, 0xRRGGBBAA
14
- #
15
- # Instances are frozen and compare by value, so a Color works as a Hash key and
16
- # can be shared without anyone tinting it out from under its other holders.
17
- #
18
- # Every draw call accepts a colour as nil (white), [r, g, b], [r, g, b, a] or a
19
- # Color; `Color.coerce` is the single place that conversion happens.
20
- #
21
- # A colour is a *value* — no window, no GPU handle — so it lives in Util rather
22
- # than Core. That is what lets the engine layer hold one as an attribute; see
23
- # CLAUDE.md, "Value objects go in Util; only handle-owners go in Core".
24
- #
25
- # It loads from lib/rgame/util_ext.so, which `make ext-util` copies out of
26
- # ext/rgame_util/.
27
3
  require 'rgame/util_ext'
4
+
5
+ module RGame
6
+ module Util
7
+ class Color
8
+ RED = new(255, 0, 0)
9
+ GREEN = new(0, 255, 0)
10
+ BLUE = new(0, 0, 255)
11
+ YELLOW = new(255, 255, 0)
12
+ CYAN = new(0, 255, 255)
13
+ MAGENTA = new(255, 0, 255)
14
+ ORANGE = new(255, 165, 0)
15
+ PURPLE = new(128, 0, 128)
16
+ BROWN = new(139, 69, 19)
17
+ PINK = new(255, 192, 203)
18
+ GRAY = new(128, 128, 128)
19
+ LIGHT_GRAY = new(192, 192, 192)
20
+ DARK_GRAY = new(64, 64, 64)
21
+ end
22
+ end
23
+ end
@@ -26,17 +26,6 @@ module RGame
26
26
  # by _Static_assert at compile time, and spec/rgame/util/controls_spec.rb
27
27
  # parses that header and compares every value here against it.
28
28
  module Controls
29
- # --- Keyboard. Values are SDL scancodes, which name a physical *position*
30
- # rather than a letter: KEY_A is the key marked A on a QWERTY board and Q
31
- # on AZERTY. A game rebinding controls shows the player what their layout
32
- # calls it; the engine only ever compares numbers. ---
33
- #
34
- # The set is what a Western keyboard can be relied on to have. No numpad
35
- # (most laptops have none), no GUI/Windows/Command key, no print-screen
36
- # cluster, and nothing whose position depends on the layout.
37
-
38
- # Letters. Scancodes are physical *positions*, so KEY_A is the key marked A on
39
- # a QWERTY board and Q on AZERTY.
40
29
  KEY_A = 4
41
30
  KEY_B = 5
42
31
  KEY_C = 6
@@ -64,7 +53,6 @@ module RGame
64
53
  KEY_Y = 28
65
54
  KEY_Z = 29
66
55
 
67
- # Digits along the top row.
68
56
  KEY_1 = 30
69
57
  KEY_2 = 31
70
58
  KEY_3 = 32
@@ -76,14 +64,12 @@ module RGame
76
64
  KEY_9 = 38
77
65
  KEY_0 = 39
78
66
 
79
- # Editing and whitespace.
80
67
  KEY_RETURN = 40
81
68
  KEY_ESCAPE = 41
82
69
  KEY_BACKSPACE = 42
83
70
  KEY_TAB = 43
84
71
  KEY_SPACE = 44
85
72
 
86
- # Punctuation, by position on a US board.
87
73
  KEY_MINUS = 45
88
74
  KEY_EQUALS = 46
89
75
  KEY_LEFTBRACKET = 47
@@ -96,7 +82,6 @@ module RGame
96
82
  KEY_PERIOD = 55
97
83
  KEY_SLASH = 56
98
84
 
99
- # Function row and caps lock.
100
85
  KEY_CAPSLOCK = 57
101
86
  KEY_F1 = 58
102
87
  KEY_F2 = 59
@@ -111,7 +96,6 @@ module RGame
111
96
  KEY_F11 = 68
112
97
  KEY_F12 = 69
113
98
 
114
- # The navigation cluster.
115
99
  KEY_INSERT = 73
116
100
  KEY_HOME = 74
117
101
  KEY_PAGEUP = 75
@@ -119,14 +103,11 @@ module RGame
119
103
  KEY_END = 77
120
104
  KEY_PAGEDOWN = 78
121
105
 
122
- # Arrows.
123
106
  KEY_RIGHT = 79
124
107
  KEY_LEFT = 80
125
108
  KEY_DOWN = 81
126
109
  KEY_UP = 82
127
110
 
128
- # Modifiers. No GUI key: that is Windows on a PC and Command on a Mac, which
129
- # is exactly the platform-specific territory this list stays out of.
130
111
  KEY_LCTRL = 224
131
112
  KEY_LSHIFT = 225
132
113
  KEY_LALT = 226
@@ -134,10 +115,8 @@ module RGame
134
115
  KEY_RSHIFT = 229
135
116
  KEY_RALT = 230
136
117
 
137
- # --- Gamepad buttons. The gamepad range plus SDL's own controller button
138
- # number. The first fifteen are on every controller; MISC1, the paddles
139
- # and TOUCHPAD are hardware the id space describes but most pads do not
140
- # have, and read as never pressed on one that does not. ---
118
+ BUTTON_GAMEPAD_FIRST = 0x1000
119
+
141
120
  PAD_A = 4096
142
121
  PAD_B = 4097
143
122
  PAD_X = 4098
@@ -160,9 +139,6 @@ module RGame
160
139
  PAD_PADDLE4 = 4115
161
140
  PAD_TOUCHPAD = 4116
162
141
 
163
- # --- Analog axes. Their own small space: they are float-valued and read
164
- # through a different call, so folding them into the button space would
165
- # only invite asking for an axis as if it were a button. ---
166
142
  AXIS_LEFT_X = 0
167
143
  AXIS_LEFT_Y = 1
168
144
  AXIS_RIGHT_X = 2
@@ -170,14 +146,24 @@ module RGame
170
146
  AXIS_TRIGGER_LEFT = 4
171
147
  AXIS_TRIGGER_RIGHT = 5
172
148
 
173
- # --- Devices. The keyboard is 0 so single-player code can leave it out;
174
- # gamepads follow, one per player slot. ---
175
149
  KEYBOARD = 0
176
150
  GAMEPAD_FIRST = 1
177
151
  MAX_GAMEPADS = 4
178
152
 
179
153
  # The device id for a player slot: gamepad(0) is the first controller.
180
154
  def self.gamepad(slot) = GAMEPAD_FIRST + slot
155
+
156
+ # Is this *device* a controller rather than the keyboard? What a game asks
157
+ # when the answer changes what it shows the player — the prompt for an
158
+ # action is a key cap or a face button depending on what they last used.
159
+ def self.gamepad?(device) = device >= GAMEPAD_FIRST
160
+
161
+ # Is this *button* one a controller has rather than one a keyboard has?
162
+ # The mirror of the question above, on the other id space, and the pair is
163
+ # what lets a caller keep the two apart: an action bound to both lists its
164
+ # keys and its pad buttons together, and only a device can say which half
165
+ # of that list applies right now.
166
+ def self.pad_button?(id) = id >= BUTTON_GAMEPAD_FIRST
181
167
  end
182
168
  end
183
169
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgame/util_ext'
4
+
5
+ module RGame
6
+ module Util
7
+ # Connected regions and A* routes over a SolidGrid, in C. The search reads its grid and
8
+ # never writes it, and keeps it alive; any number of searches may read one grid.
9
+ #
10
+ # The grid is 8-connected with octile costs (1 straight, √2 diagonal), and a diagonal
11
+ # step is allowed only when both orthogonal cells beside it are open, so a route never
12
+ # cuts the corner of a solid cell. Routes are cells, `[[col, row], ...]`, start and goal
13
+ # included — or nil when either end is solid, outside the grid, or in another region.
14
+ #
15
+ # Regions are labelled on the first ask after the grid's #revision moves, so a goal in
16
+ # another region is a lookup rather than a search that exhausts the map, and it stays
17
+ # right when the grid changes. Everything else a search keeps between queries is reused,
18
+ # so a repeated query allocates nothing but its result. One search is therefore not safe
19
+ # to use from two threads at once; two searches over one grid are.
20
+ #
21
+ # grid = RGame::Util::SolidGrid.build(8, 3) { |col, row| col == 3 && row < 2 }
22
+ # search = RGame::Util::RouteSearch.new(grid)
23
+ # search.find(0, 0, 7, 0) # => [[0, 0], [1, 1], [2, 2], [3, 2], [4, 2], [5, 1], [6, 0], [7, 0]]
24
+ class RouteSearch
25
+ end
26
+ end
27
+ end