rgame 0.1.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 (265) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +228 -0
  3. data/README.md +142 -243
  4. data/docs/api/README.md +141 -90
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -166
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1045 -254
  10. data/docs/api/drawing.md +187 -119
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +165 -45
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +344 -85
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +503 -123
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +197 -64
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +400 -147
  23. data/docs/api/ui.md +927 -0
  24. data/docs/api/values.md +234 -19
  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/README.md +6 -5
  92. data/ext/rgame_core/app/app.c +215 -11
  93. data/ext/rgame_core/app/locale.c +67 -0
  94. data/ext/rgame_core/app/locale.h +28 -0
  95. data/ext/rgame_core/audio/audio.c +113 -2
  96. data/ext/rgame_core/example.rb +18 -56
  97. data/ext/rgame_core/extconf.rb +16 -113
  98. data/ext/rgame_core/graphics/canvas.c +45 -4
  99. data/ext/rgame_core/graphics/canvas.h +65 -10
  100. data/ext/rgame_core/graphics/clip.c +22 -13
  101. data/ext/rgame_core/include/rgame/core.h +151 -5
  102. data/ext/rgame_core/input/gamepad.c +57 -3
  103. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  104. data/ext/rgame_core/ruby/core_ext.c +46 -7
  105. data/ext/rgame_core/ruby/core_ext.h +3 -0
  106. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  107. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  108. data/ext/rgame_core/ruby/renderer_ext.c +42 -19
  109. data/ext/rgame_util/color_ext.c +12 -3
  110. data/ext/rgame_util/extconf.rb +2 -20
  111. data/ext/rgame_util/route_search.c +305 -0
  112. data/ext/rgame_util/route_search.h +86 -0
  113. data/ext/rgame_util/route_search_ext.c +150 -0
  114. data/ext/rgame_util/solid_grid.c +58 -0
  115. data/ext/rgame_util/solid_grid.h +49 -0
  116. data/ext/rgame_util/solid_grid_ext.c +161 -0
  117. data/ext/rgame_util/tile_sweep.c +164 -0
  118. data/ext/rgame_util/tile_sweep.h +62 -0
  119. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  120. data/ext/rgame_util/util_ext.c +3 -0
  121. data/ext/rgame_util/util_ext.h +15 -0
  122. data/lib/rgame/boot.rb +0 -10
  123. data/lib/rgame/cli/new_project.rb +139 -0
  124. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  125. data/lib/rgame/cli/templates/README.md.tt +93 -0
  126. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  127. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  128. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  129. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  130. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  131. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  132. data/lib/rgame/cli/templates/rspec.tt +2 -0
  133. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  134. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  135. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  136. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  137. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  138. data/lib/rgame/cli.rb +66 -0
  139. data/lib/rgame/core/app.rb +6 -42
  140. data/lib/rgame/core/asset_manager.rb +13 -31
  141. data/lib/rgame/core/audio.rb +37 -16
  142. data/lib/rgame/core/font.rb +0 -3
  143. data/lib/rgame/core/input.rb +35 -41
  144. data/lib/rgame/core/locale.rb +22 -0
  145. data/lib/rgame/core/nine_slice.rb +0 -21
  146. data/lib/rgame/core/recording.rb +3 -1
  147. data/lib/rgame/core/renderer.rb +75 -84
  148. data/lib/rgame/core/sprite_sheet.rb +0 -3
  149. data/lib/rgame/core/tile_map_renderer.rb +77 -65
  150. data/lib/rgame/core/ui_atlas.rb +28 -13
  151. data/lib/rgame/core.rb +1 -8
  152. data/lib/rgame/engine/actor_blockers.rb +131 -0
  153. data/lib/rgame/engine/animation_set.rb +1 -0
  154. data/lib/rgame/engine/audio_director.rb +36 -6
  155. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  156. data/lib/rgame/engine/camera.rb +55 -10
  157. data/lib/rgame/engine/circle_collider.rb +4 -2
  158. data/lib/rgame/engine/collision_box.rb +26 -1
  159. data/lib/rgame/engine/collision_system.rb +110 -22
  160. data/lib/rgame/engine/component.rb +35 -1
  161. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  162. data/lib/rgame/engine/components/animated_sprite.rb +31 -23
  163. data/lib/rgame/engine/components/box_collider.rb +99 -0
  164. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  165. data/lib/rgame/engine/components/character_body.rb +21 -41
  166. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  167. data/lib/rgame/engine/components/collision_world.rb +159 -31
  168. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  169. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  170. data/lib/rgame/engine/components/hop.rb +76 -0
  171. data/lib/rgame/engine/components/identity.rb +73 -0
  172. data/lib/rgame/engine/components/mover.rb +285 -0
  173. data/lib/rgame/engine/components/navigator.rb +145 -0
  174. data/lib/rgame/engine/components/path_follow.rb +123 -31
  175. data/lib/rgame/engine/components/player_controller.rb +5 -2
  176. data/lib/rgame/engine/components/pool.rb +1 -1
  177. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  178. data/lib/rgame/engine/components/sprite.rb +22 -6
  179. data/lib/rgame/engine/components/targeting.rb +9 -11
  180. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  181. data/lib/rgame/engine/components/tile_world.rb +70 -29
  182. data/lib/rgame/engine/components/timer.rb +1 -1
  183. data/lib/rgame/engine/components/velocity.rb +23 -7
  184. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  185. data/lib/rgame/engine/components/world.rb +133 -0
  186. data/lib/rgame/engine/contact_set.rb +74 -0
  187. data/lib/rgame/engine/culling.rb +45 -0
  188. data/lib/rgame/engine/debug_overlay.rb +23 -18
  189. data/lib/rgame/engine/i18n/plural.rb +45 -0
  190. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  191. data/lib/rgame/engine/i18n/template.rb +59 -0
  192. data/lib/rgame/engine/i18n.rb +276 -51
  193. data/lib/rgame/engine/input/action_mapper.rb +76 -22
  194. data/lib/rgame/engine/input/actions.rb +63 -12
  195. data/lib/rgame/engine/input/input_map.rb +196 -0
  196. data/lib/rgame/engine/layout.rb +82 -0
  197. data/lib/rgame/engine/nav_grid.rb +87 -0
  198. data/lib/rgame/engine/node2d.rb +364 -80
  199. data/lib/rgame/engine/path.rb +4 -6
  200. data/lib/rgame/engine/player.rb +69 -0
  201. data/lib/rgame/engine/player_layer.rb +70 -0
  202. data/lib/rgame/engine/players.rb +205 -0
  203. data/lib/rgame/engine/presentation.rb +171 -0
  204. data/lib/rgame/engine/scene/scene_stack.rb +29 -7
  205. data/lib/rgame/engine/sealed_privates.rb +54 -0
  206. data/lib/rgame/engine/spatial_hash.rb +53 -8
  207. data/lib/rgame/engine/text.rb +194 -0
  208. data/lib/rgame/engine/tile_blockers.rb +63 -0
  209. data/lib/rgame/engine/tile_map.rb +2 -3
  210. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  211. data/lib/rgame/engine/tileset.rb +2 -4
  212. data/lib/rgame/engine/timer.rb +2 -2
  213. data/lib/rgame/engine/ui/button.rb +248 -0
  214. data/lib/rgame/engine/ui/column.rb +20 -0
  215. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  216. data/lib/rgame/engine/ui/menu.rb +290 -0
  217. data/lib/rgame/engine/ui/navigation.rb +57 -0
  218. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  219. data/lib/rgame/engine/ui/option_button.rb +163 -0
  220. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  221. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  222. data/lib/rgame/engine/ui/pointing.rb +146 -0
  223. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  224. data/lib/rgame/engine/ui/ring.rb +55 -0
  225. data/lib/rgame/engine/ui/row.rb +21 -0
  226. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  227. data/lib/rgame/engine/ui/stack.rb +58 -0
  228. data/lib/rgame/engine/ui/stepping.rb +93 -0
  229. data/lib/rgame/engine/ui/text_button.rb +59 -0
  230. data/lib/rgame/engine/view.rb +76 -0
  231. data/lib/rgame/engine/viewports.rb +171 -0
  232. data/lib/rgame/engine/world_view.rb +71 -0
  233. data/lib/rgame/engine.rb +43 -25
  234. data/lib/rgame/game.rb +164 -22
  235. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  236. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  237. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  238. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  239. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  240. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  241. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  242. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  243. data/lib/rgame/rubocop/default.yml +39 -0
  244. data/lib/rgame/rubocop/plugin.rb +45 -0
  245. data/lib/rgame/rubocop.rb +11 -0
  246. data/lib/rgame/util/color.rb +20 -24
  247. data/lib/rgame/util/controls.rb +106 -44
  248. data/lib/rgame/util/route_search.rb +27 -0
  249. data/lib/rgame/util/save_file.rb +107 -0
  250. data/lib/rgame/util/solid_grid.rb +37 -0
  251. data/lib/rgame/util/tensor.rb +0 -9
  252. data/lib/rgame/util/tile_sweep.rb +36 -0
  253. data/lib/rgame/util/z.rb +123 -0
  254. data/lib/rgame/util.rb +5 -3
  255. data/lib/rgame/version.rb +1 -1
  256. data/lib/rgame.rb +0 -15
  257. metadata +176 -20
  258. data/lib/rgame/engine/actor.rb +0 -53
  259. data/lib/rgame/engine/body.rb +0 -49
  260. data/lib/rgame/engine/cached_label.rb +0 -33
  261. data/lib/rgame/engine/camera_view.rb +0 -28
  262. data/lib/rgame/engine/input/player_controller.rb +0 -14
  263. data/lib/rgame/engine/matrix.rb +0 -32
  264. data/lib/rgame/engine/resettable.rb +0 -67
  265. data/lib/rgame/engine/tile_collision.rb +0 -78
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Game
6
+ # A node's draw methods must not read the node's own position — in either
7
+ # space, because both are already applied by the time they run.
8
+ #
9
+ # `Node2D#draw` pushes the node's transform onto the renderer and runs
10
+ # everything below it inside that, so within `on_draw` the origin *is* the
11
+ # node, turned the way the node is turned. Drawing is done at (0, 0), or at
12
+ # an offset from it that means something to the node itself.
13
+ #
14
+ # Both spellings of "where am I" are therefore wrong there, and differ only
15
+ # in how far off they put the result:
16
+ #
17
+ # - `world_x` is where the node sits on the map, which every ancestor's
18
+ # transform — the camera's included — has already contributed to. Passing
19
+ # it applies the whole chain twice.
20
+ # - `x` is the node's offset inside its parent, which its own transform has
21
+ # already applied. Passing it offsets by that much a second time.
22
+ #
23
+ # Both are silent: they draw, they just draw in the wrong place, and only
24
+ # once something is nested under an offset or rotated parent — which is
25
+ # exactly the case a spec at the origin cannot see.
26
+ #
27
+ # A **component** drawing for its node is on this path too, and asks the
28
+ # node by name: `node.world_x` for a cull test is a different object's
29
+ # coordinate and is not flagged. Only a bare, receiverless read of the
30
+ # node's own transform is.
31
+ #
32
+ # `width` and `height` are not positions and are ordinary here — a node
33
+ # drawing its own box says `renderer.rect(0, 0, width, height)`.
34
+ #
35
+ # @example
36
+ # # bad — the traversal has already placed the renderer on this node
37
+ # def on_draw(renderer, _view)
38
+ # renderer.rect(world_x, world_y, width, height)
39
+ # end
40
+ #
41
+ # # bad — same mistake, a smaller distance
42
+ # def on_draw(renderer, _view)
43
+ # renderer.rect(x, y, width, height)
44
+ # end
45
+ #
46
+ # # good — its own origin
47
+ # def on_draw(renderer, _view)
48
+ # renderer.rect(0, 0, width, height)
49
+ # end
50
+ #
51
+ # # good — a component culling against the camera, in world space, by name
52
+ # def draw(renderer, view)
53
+ # return if culled?(view, node.world_x, node.world_y, node.width, node.height)
54
+ #
55
+ # renderer.image(@id, 0, 0)
56
+ # end
57
+ class DrawInLocalSpace < RuboCop::Cop::Base
58
+ MSG = 'Draw in local space: `%{name}` is already applied by the time ' \
59
+ '`%{method}` runs, so passing it places this %{distance} a second ' \
60
+ 'time. Draw at your own origin (0, 0), or an offset from it.'
61
+
62
+ METHODS = %i[draw on_draw _draw_content draw_children].freeze
63
+
64
+ RELATIVE = %i[x y angle].freeze
65
+ WORLD = %i[world_x world_y world_angle].freeze
66
+ IVARS = { :@rel_x => :x, :@rel_y => :y, :@rel_angle => :angle,
67
+ :@world_x => :world_x, :@world_y => :world_y,
68
+ :@world_angle => :world_angle }.freeze
69
+
70
+ def on_def(node)
71
+ return unless METHODS.include?(node.method_name)
72
+
73
+ node.each_descendant(:ivar, :send) do |read|
74
+ name = own_transform_read(read)
75
+ next unless name
76
+
77
+ add_offense(read, message: format(MSG, name: name, method: node.method_name,
78
+ distance: distance_for(name)))
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def own_transform_read(node)
85
+ if node.ivar_type?
86
+ IVARS[node.children.first]
87
+ elsif self_read?(node)
88
+ name = node.method_name
89
+ name if RELATIVE.include?(name) || WORLD.include?(name)
90
+ end
91
+ end
92
+
93
+ def self_read?(node)
94
+ node.send_type? && node.receiver.nil? && node.arguments.empty? && !node.block_literal?
95
+ end
96
+
97
+ def distance_for(name)
98
+ WORLD.include?(name) ? 'on the map' : 'inside its parent'
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Game
6
+ # Shared definition of the per-frame "hot path" for the allocation-guard cops.
7
+ #
8
+ # The hot path is the set of methods that run every frame: the engine's six
9
+ # lifecycle hooks, plus any method an author opts into with a `# hot-path` magic
10
+ # comment on the line directly above its `def`. The opt-in covers per-frame
11
+ # *helpers* the lifecycle methods call, where an allocation is just as costly but
12
+ # the method name alone can't reveal it.
13
+ module HotPath
14
+ METHODS = %i[update control draw draw_children _draw_content
15
+ on_update on_draw on_control].freeze
16
+
17
+ # True for a `def` that runs per frame: a lifecycle method by name, or one tagged
18
+ # `# hot-path` on the line directly above it.
19
+ def hot_path_def?(node)
20
+ return false unless node.def_type?
21
+
22
+ METHODS.include?(node.method_name) || hot_path_tagged?(node)
23
+ end
24
+
25
+ private
26
+
27
+ def hot_path_tagged?(node)
28
+ line_above = node.source_range.first_line - 1
29
+ processed_source.comments.any? do |comment|
30
+ comment.location.line == line_above && comment.text.match?(/\A#\s*hot-path\b/)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Game
6
+ # Shared by the two cops that police a layer boundary —
7
+ # `NoCoreInEngineLayer` and `NoEngineInCoreLayer`. They are mirror images:
8
+ # each forbids one namespace inside the other's files, and the only thing
9
+ # that differs is which namespace and which message.
10
+ #
11
+ # Kept in one place because the interesting part is not the comparison but
12
+ # the *walk*: `RGame::Core::Renderer` is three nested `const` nodes, and a
13
+ # naive check reports the same reference three times. Getting that right
14
+ # once beats getting it right twice and then fixing only one of them.
15
+ module LayerBoundary
16
+ private
17
+
18
+ def const_path(node)
19
+ names = []
20
+ current = node
21
+ while current&.const_type?
22
+ names.unshift(current.short_name.to_s)
23
+ current = current.namespace
24
+ end
25
+ names
26
+ end
27
+
28
+ def opens_namespace?(node, prefixes)
29
+ return false unless under?(node, prefixes)
30
+
31
+ !(node.parent&.const_type? && under?(node.parent, prefixes))
32
+ end
33
+
34
+ def under?(node, prefixes)
35
+ return false unless node.const_type?
36
+
37
+ path = const_path(node)
38
+ prefixes.any? { |prefix| path.first(prefix.length) == prefix }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'layer_boundary'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Game
8
+ # The engine layer must never name `RGame::Core`.
9
+ #
10
+ # `RGame::Engine` holds the game concepts — scene tree, signals, sprites,
11
+ # tile maps — and its whole value is that it can be specified with no
12
+ # window, no GPU and no clock. It reaches the platform only through
13
+ # objects handed to it: a node's `draw` receives a `renderer` and calls
14
+ # methods on it by name, never storing it and never asking its class.
15
+ #
16
+ # Engine specs enforce this at runtime by simply never loading
17
+ # `rgame/core`, so a stray reference raises `NameError`. That only catches
18
+ # code a test run actually executes, though — this cop covers the
19
+ # branches it doesn't reach.
20
+ #
21
+ # A game has the same line: its nodes and specs run headless, and only its
22
+ # glue class loads the window. So `require 'rgame/game'` is refused too,
23
+ # since it loads everything `rgame/core` does.
24
+ #
25
+ # The short spelling `Core::Image` is flagged only inside `module RGame`,
26
+ # the one place it resolves to `RGame::Core`. Anywhere else it names the
27
+ # project's own `Core`.
28
+ #
29
+ # `RGame::Util` is fine anywhere: those are shareable value types with no
30
+ # OS handle behind them, which is exactly why they live in Util.
31
+ #
32
+ # @example
33
+ # # bad — names the class
34
+ # def draw(renderer)
35
+ # RGame::Core::Renderer.new
36
+ # end
37
+ #
38
+ # # bad — the same thing, resolved through the enclosing RGame
39
+ # module RGame
40
+ # Core::Image.new(app, path)
41
+ # end
42
+ #
43
+ # # bad — a require pulls SDL into the process
44
+ # require 'rgame/core'
45
+ #
46
+ # # good — duck-typed against whatever it is handed
47
+ # def draw(renderer, _view)
48
+ # renderer.sprite(:hero, 0, 0, 0, 0)
49
+ # end
50
+ #
51
+ # # good — Util types may be held as attributes
52
+ # @grid = RGame::Util::Tensor.new(w, h, d)
53
+ class NoCoreInEngineLayer < RuboCop::Cop::Base
54
+ include LayerBoundary
55
+
56
+ MSG = 'Headless code must not name `RGame::Core`; receive the object ' \
57
+ 'and call it by method name instead.'
58
+ MSG_REQUIRE = 'Headless code must not require `%{path}` — that loads ' \
59
+ 'SDL/OpenGL and breaks headless specs.'
60
+
61
+ QUALIFIED = [%w[RGame Core]].freeze
62
+ WITHIN_RGAME = [%w[RGame Core], %w[Core]].freeze
63
+ RESTRICTED_REQUIRE = %r{\Argame/(core|game)(/|\z)|\Argame/core_ext\z}
64
+
65
+ # `require "rgame/core"` and friends.
66
+ # @!method core_require(node)
67
+ def_node_matcher :core_require, <<~PATTERN
68
+ (send nil? {:require :require_relative} (str $_))
69
+ PATTERN
70
+
71
+ def on_send(node)
72
+ core_require(node) do |path|
73
+ next unless RESTRICTED_REQUIRE.match?(path.to_s.delete_prefix('./'))
74
+
75
+ add_offense(node, message: format(MSG_REQUIRE, path: path))
76
+ end
77
+ end
78
+
79
+ # Any constant path under RGame::Core. The walk, and reporting one
80
+ # offence per written reference rather than one per path segment, is in
81
+ # LayerBoundary — shared with this cop's mirror.
82
+ def on_const(node)
83
+ add_offense(node) if opens_namespace?(node, prefixes_for(node))
84
+ end
85
+
86
+ private
87
+
88
+ def prefixes_for(node)
89
+ within_rgame?(node) ? WITHIN_RGAME : QUALIFIED
90
+ end
91
+
92
+ def within_rgame?(node)
93
+ node.each_ancestor(:module, :class).any? do |scope|
94
+ const_path(scope.identifier).first == 'RGame'
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -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'