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
data/lib/rgame/game.rb ADDED
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'boot'
4
+ require_relative 'core'
5
+ require_relative 'engine'
6
+
7
+ module RGame
8
+ # The entry point of a game, and the one class that knows both halves.
9
+ #
10
+ # class HelloScene < RGame::Engine::Node2D
11
+ # def on_draw(renderer) = renderer.text('Hello world!', 250, 200)
12
+ # end
13
+ #
14
+ # RGame::Game.new(root: HelloScene.new, caption: 'Hello').start
15
+ #
16
+ # A complete game is a root node plus that. `Game` assembles the pieces
17
+ # around it — the window and its loop, the renderer, the asset manager, the
18
+ # sound device, the input mapper, the debug overlay — and drives the root
19
+ # node once per tick.
20
+ #
21
+ # ## Why this class is allowed to name both layers
22
+ #
23
+ # `RGame::Engine` holds game concepts and may not name `RGame::Core`;
24
+ # `RGame::Core` owns handles and may not know Engine exists. Two RuboCop cops
25
+ # say so. Something still has to introduce them, and **this is that
26
+ # something** — see CLAUDE.md, "The rule points both ways". Keeping the
27
+ # introduction to one file is what makes the rule checkable everywhere else,
28
+ # so wiring belongs here and only here.
29
+ #
30
+ # The tile-map loader is the clearest case. Parsing a `.tmx` is Engine's job
31
+ # and drawing one is Core's, and neither may call the other, so `Game`
32
+ # installs the loader that joins them.
33
+ #
34
+ # ## The loop
35
+ #
36
+ # `Game` is an `App`, so it inherits the fixed-timestep loop rather than
37
+ # running one. Its hooks do three things: sample input once per frame, drive
38
+ # the root once per tick, and draw. Nothing here counts steps or measures
39
+ # time — `frame_loop.c` does that, and `update` is called once per whole tick.
40
+ class Game < RGame::Core::App
41
+ Controls = RGame::Util::Controls
42
+
43
+ WIDTH = 640
44
+ HEIGHT = 480
45
+
46
+ attr_reader :root, :renderer
47
+
48
+ # `input_map:` is what physical inputs mean — one entry per action, naming
49
+ # ids from RGame::Util::Controls. It is merged over the universal UI set, so
50
+ # `ui_confirm` and friends work whether or not a game declares them, and it
51
+ # defaults to RGame::Engine::InputMap::DEFAULT_ACTIONS, so a game wanting
52
+ # eight-way movement and a fire button declares nothing.
53
+ #
54
+ # `device:` is which device drives it — the keyboard, or
55
+ # `Controls.gamepad(slot)` for a controller.
56
+ #
57
+ # `input:` overrides the input backend. It exists so a harness can drive a
58
+ # game from a script instead of from hardware — see
59
+ # tools/drive_test_project.rb, and CLAUDE.md's "The test projects are the
60
+ # acceptance test for wiring", which is why driving one has to be possible
61
+ # at all. A game passes nothing and gets the real thing.
62
+ # `players:` is how many seats the game has, and therefore the most people
63
+ # who can play it. Player 0 starts on `device:`; the rest start empty and
64
+ # are filled when someone uses a controller — see RGame::Engine::Players for
65
+ # why that is a press rather than a plug.
66
+ # `fullscreen:` opens the window fullscreen rather than switching after it is
67
+ # already up, so a game that always runs fullscreen never flashes a windowed
68
+ # frame at startup. `width` and `height` still matter: they are the size the
69
+ # window takes when it leaves fullscreen, whether or not this game offers a
70
+ # way to do that.
71
+ #
72
+ # `scale_mode:` decides what `width` and `height` *mean*, and the default
73
+ # answer is "the resolution the game is designed in". Under `:letterbox` the
74
+ # view a node draws into is always that size, whatever the window is doing,
75
+ # so a layout written against fixed numbers keeps working at any window size
76
+ # and in fullscreen — which is what almost every game wants and what nothing
77
+ # in the engine can supply for it afterwards.
78
+ #
79
+ # `:disabled` is the opt-out, and it is the right answer for something that
80
+ # should genuinely use whatever space it is given: a tool, a HUD-shaped
81
+ # program, an editor. It hands the window straight through as the view, and
82
+ # skips the clip, translate and scale that every other mode pushes. See
83
+ # RGame::Engine::Presentation.
84
+ #
85
+ # `locales:` is the directory the translation tables are in, relative to
86
+ # `media_root` unless absolute. Every `.yml` under it is loaded through the
87
+ # asset manager here, in sorted order, and the language is chosen from the
88
+ # player's OS preferences — so a game writes no i18n setup, and a language
89
+ # the player saved is set after `new` and before `start`. A directory that
90
+ # does not exist loads nothing, and every key shows as itself.
91
+ def initialize(root:, width: WIDTH, height: HEIGHT, caption: 'RGame',
92
+ media_root: 'media', input_map: nil, device: Controls::KEYBOARD,
93
+ players: 1, input: nil, fullscreen: false, scale_mode: :letterbox,
94
+ locales: 'locales')
95
+ super(width: width, height: height, caption: caption, media_root: media_root,
96
+ fullscreen: fullscreen)
97
+
98
+ @root = root
99
+ @renderer = RGame::Core::Renderer.new(self)
100
+ @input = input || RGame::Core::Input.new(self)
101
+ @players = RGame::Engine::Players.new(
102
+ Array.new(players) do |id|
103
+ RGame::Engine::Player.new(id: id, device: id.zero? ? device : nil,
104
+ input_map: input_map)
105
+ end
106
+ )
107
+ @presentation = RGame::Engine::Presentation.new(width: width, height: height,
108
+ mode: scale_mode)
109
+ @presentation.fit(self.width, self.height)
110
+ @viewports = RGame::Engine::Viewports.new(@players, width: @presentation.width,
111
+ height: @presentation.height)
112
+ @debug = RGame::Engine::DebugOverlay.new
113
+ @dirty = true
114
+
115
+ install_asset_loaders
116
+ load_locales(locales)
117
+ end
118
+
119
+ # The player registry, also reachable from any node as
120
+ # `node.system(RGame::Engine::Players)` — which is how a scene gets at a
121
+ # camera to follow, without anything being threaded into its constructor.
122
+ #
123
+ # One player exists from the start, so a single-player game never mentions
124
+ # players at all: it is `players.primary` that an unowned node reads from,
125
+ # and `players.primary.camera` that a scene points at its hero.
126
+ attr_reader :players
127
+
128
+ # How the screen is divided. Reachable as `node.system(RGame::Engine::Viewports)`,
129
+ # which is how a cutscene deep in a scene collapses the split without
130
+ # anything being handed to it.
131
+ attr_reader :viewports
132
+
133
+ # Brings the tree live and runs until the window closes.
134
+ #
135
+ # The root gets this object as its `context`, which is how a node deep in
136
+ # the tree reaches the asset manager (`node.root.context.assets`) without
137
+ # anything being threaded through its constructor.
138
+ def start
139
+ @root.context = self
140
+ @root.add_component(@players)
141
+ @root.add_component(@viewports)
142
+ @audio_director = Engine::AudioDirector.new(audio).subscribe
143
+ @root.enter_tree
144
+ run
145
+ ensure
146
+ @audio_director&.unsubscribe
147
+ end
148
+
149
+ # One fixed simulation tick. `dt` is always the engine's fixed step, so the
150
+ # tree never sees variable frame time.
151
+ #
152
+ # **Input is polled here, per tick, not in `frame_begin` per frame.** That
153
+ # is not where it started, and the reason is edge detection: `pressed?` is
154
+ # "held now, not held at the previous poll", so whatever polls decides what
155
+ # a press *is*. `frame_begin` runs once per rendered frame, and a loop that
156
+ # renders faster than it simulates runs it many times between two ticks —
157
+ # each one shifting the previous state, so the press is consumed by a poll
158
+ # no tick ever reads. Menus stop responding, and only on fast machines.
159
+ #
160
+ # Polling per tick costs nothing extra and loses nothing: the C layer
161
+ # snapshots the keyboard once per frame, so several ticks inside one frame
162
+ # read identical state, and the edge lands on the first of them — one press,
163
+ # one `pressed?`, which is what a caller means.
164
+ def update(dt)
165
+ @players.poll(@input)
166
+ @root.control(@players)
167
+ @root.update(dt)
168
+ @root.sweep_freed
169
+ @dirty = true
170
+ end
171
+
172
+ # Only the simulation advancing makes the frame stale. While the overlay is
173
+ # up, redraw anyway, so its numbers stay live even when nothing is moving.
174
+ def needs_redraw? = @dirty || @debug.visible?
175
+
176
+ # The tree is drawn once, with the whole window as its view. Screen-space
177
+ # content — a HUD, a menu, a title card — lands there and is drawn exactly
178
+ # once, as it always was.
179
+ #
180
+ # **World content multiplies inside the tree, not here.** An
181
+ # RGame::Engine::WorldView draws its subtree once per viewport, clipping and
182
+ # translating for each, so where the world begins is the game's choice
183
+ # rather than a shape the platform imposes. That is also what keeps
184
+ # `node.root` meaning the game's own root: nothing is inserted above it.
185
+ def draw
186
+ @viewports.refresh
187
+ if @presentation.scaled?
188
+ presented { draw_tree }
189
+ else
190
+ draw_tree
191
+ end
192
+ @dirty = false
193
+ end
194
+
195
+ # How `width` and `height` are mapped onto the window: `:disabled`,
196
+ # `:stretch`, `:letterbox` or `:integer`. See RGame::Engine::Presentation.
197
+ def scale_mode = @presentation.mode
198
+
199
+ # Switchable while the game runs, for a settings screen.
200
+ #
201
+ # The viewports are resized as well as the presentation refitted, because
202
+ # the two modes disagree about what the logical size *is*: leaving `:integer`
203
+ # for `:disabled` turns a fixed 640x480 back into the window's own size, and
204
+ # a viewport still holding the old one would lay out into a corner.
205
+ def scale_mode=(mode)
206
+ @presentation.mode = mode
207
+ @viewports.resize(@presentation.width, @presentation.height)
208
+ end
209
+
210
+ # The window changed size, so the presentation is refitted and every rect and
211
+ # camera clamp follows. Under a scaling mode the logical size does not move,
212
+ # so the viewports are handed the same numbers again and only the transform
213
+ # in `draw` changes — which is the whole point of the mode.
214
+ def resize(width, height)
215
+ @presentation.fit(width, height)
216
+ @viewports.resize(@presentation.width, @presentation.height)
217
+ end
218
+
219
+ # Hot-plug is bookkeeping, not seating: a controller arriving becomes a
220
+ # device the registry watches, and it is someone *using* it that gives it to
221
+ # a player. Leaving takes it back off whoever had it.
222
+ def gamepad_connected(slot) = @players.device_connected(slot)
223
+ def gamepad_disconnected(slot) = @players.device_disconnected(slot)
224
+
225
+ # The two development keys, both function keys on purpose: **Escape is
226
+ # deliberately not bound here**, because it is the natural `cancel`/`back`
227
+ # button for a game's own menus, and a debug shortcut has no business taking
228
+ # the one key every player expects to close a dialog. F1 shows the overlay,
229
+ # F2 quits.
230
+ def button_down(id)
231
+ close if id == Controls::KEY_F2
232
+ @debug.toggle if id == Controls::KEY_F1
233
+ end
234
+
235
+ private
236
+
237
+ def draw_tree
238
+ @root.draw(@renderer, @viewports.screen)
239
+ @debug.draw(@renderer, @viewports.screen, fps)
240
+ end
241
+
242
+ def presented(&)
243
+ @renderer.clipped(@presentation.offset_x, @presentation.offset_y,
244
+ (@presentation.width * @presentation.scale_x).round,
245
+ (@presentation.height * @presentation.scale_y).round) do
246
+ @renderer.translated(@presentation.offset_x, @presentation.offset_y) do
247
+ @renderer.scaled(@presentation.scale_x, @presentation.scale_y, &)
248
+ end
249
+ end
250
+ end
251
+
252
+ def load_locales(directory)
253
+ assets.glob(File.join(directory, '**', '*.yml')).each { |path| assets.locale(path) }
254
+ RGame::Engine::I18n.locale = RGame::Engine::I18n.choose(RGame::Core.preferred_locales)
255
+ end
256
+
257
+ def install_asset_loaders
258
+ assets.add_loader(:locale) do |path|
259
+ RGame::Engine::I18n.load(File.read(path), source: path)
260
+ end
261
+
262
+ game = self
263
+ assets.add_loader(:tilemap) do |path|
264
+ map, image_path = RGame::Engine::TileMap.load(path)
265
+ tiles = game.assets.image(image_path)
266
+ .tiles(map.tileset.tile_width, map.tileset.tile_height)
267
+ RGame::Core::TileMapRenderer.new(map, tiles)
268
+ end
269
+ end
270
+ end
271
+ end
@@ -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