rgame 0.2.0 → 0.3.1

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 +144 -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 +3 -5
  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 +167 -12
  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
@@ -38,9 +38,6 @@ module RGame
38
38
  @height = height
39
39
  @solo_camera = nil
40
40
  @pending = nil
41
- # One View per possible player for the world, one more each for their own
42
- # screen space, and one for the whole window. Built once and mutated in
43
- # place. See View: these are reused, never rebuilt.
44
41
  @pool = []
45
42
  @screen_pool = []
46
43
  @views = []
@@ -114,6 +111,8 @@ module RGame
114
111
  #
115
112
  # Allocation-free once the pool has grown: Layout yields its rects rather
116
113
  # than building them, and the Views are mutated in place.
114
+ #
115
+ # @api private
117
116
  def refresh
118
117
  @screen.set(0, 0, @width, @height)
119
118
  @solo_camera ? refresh_solo : refresh_split
@@ -148,8 +147,6 @@ module RGame
148
147
  end
149
148
  end
150
149
 
151
- # `each_active` is a filter, so it cannot be indexed without building an
152
- # Array. Walking it costs nothing and this runs once per frame.
153
150
  def active_at(index)
154
151
  found = nil
155
152
  i = 0
@@ -18,10 +18,11 @@ module RGame
18
18
  #
19
19
  # ## The subtree does not know how many times it is drawn
20
20
  #
21
- # Children draw at their own absolute origin and never see a camera. This
22
- # node supplies the view, so the same world serves one player or four with
23
- # nothing below it changing which is exactly what a camera owned by a node
24
- # *inside* the world could not do, and why cameras belong to players.
21
+ # Children draw in their own local space and never see a camera. This node
22
+ # supplies the view one clip and one translate around the whole subtree —
23
+ # so the same world serves one player or four with nothing below it changing,
24
+ # which is exactly what a camera owned by a node *inside* the world could not
25
+ # do, and why cameras belong to players.
25
26
  #
26
27
  # ## Only `draw` multiplies, and deliberately
27
28
  #
data/lib/rgame/engine.rb CHANGED
@@ -1,71 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # RGame::Engine — the scene graph a game is written against: nodes, components,
4
- # signals, scenes, collision, tile maps, pathing, pooling.
5
- #
6
- # Pure Ruby, and it links nothing. It may hold `RGame::Util` values but may not
7
- # name `RGame::Core` at all, reaching the platform only through objects handed
8
- # to it — a `renderer` passed into `draw`, an audio server behind
9
- # `AudioDirector`. See CLAUDE.md, "The three layers, and who may talk to whom".
10
- #
11
- # That is what lets the whole layer be specified with no window, no GPU and no
12
- # clock: `spec/` drives `update(dt)` directly and can run a simulated hour in
13
- # milliseconds.
14
- #
15
- # Loaded by `require "rgame"` along with Util, since between them they are
16
- # everything that runs without a window. Requirable on its own too — hence the
17
- # `util` require below rather than relying on `rgame.rb` to have got there
18
- # first: `TileMap` holds a `Util::Tensor`, which is exactly the dependency the
19
- # three-layer rule allows and this file therefore has to declare.
20
3
  require_relative 'util'
21
4
 
22
5
  require_relative 'engine/culling'
23
- require_relative 'engine/matrix'
24
6
  require_relative 'engine/signal'
7
+ require_relative 'engine/sealed_privates'
25
8
  require_relative 'engine/node2d'
26
9
  require_relative 'engine/component'
27
10
  require_relative 'engine/animation_set'
28
11
  require_relative 'engine/animator'
29
12
  require_relative 'engine/camera'
30
13
  require_relative 'engine/layout'
14
+ require_relative 'engine/presentation'
31
15
  require_relative 'engine/view'
32
16
  require_relative 'engine/world_view'
33
17
  require_relative 'engine/player_layer'
34
- require_relative 'engine/ui/menu_item'
18
+ require_relative 'engine/ui/button'
19
+ require_relative 'engine/ui/nine_slice_style'
20
+ require_relative 'engine/ui/shape_style'
21
+ require_relative 'engine/ui/text_button'
22
+ require_relative 'engine/ui/panel_button'
23
+ require_relative 'engine/ui/option_button'
24
+ require_relative 'engine/ui/icon_button'
25
+ require_relative 'engine/ui/stack'
26
+ require_relative 'engine/ui/column'
27
+ require_relative 'engine/ui/row'
28
+ require_relative 'engine/ui/ring'
29
+ require_relative 'engine/ui/navigation'
30
+ require_relative 'engine/ui/stepping'
31
+ require_relative 'engine/ui/pointing'
35
32
  require_relative 'engine/ui/menu'
33
+ require_relative 'engine/ui/panel_menu'
34
+ require_relative 'engine/ui/radial_menu'
36
35
  require_relative 'engine/path'
37
36
  require_relative 'engine/timer'
38
37
  require_relative 'engine/tileset'
39
38
  require_relative 'engine/tile_map'
40
- require_relative 'engine/tile_collision'
39
+ require_relative 'engine/tile_blockers'
40
+ require_relative 'engine/nav_grid'
41
+ require_relative 'engine/actor_blockers'
42
+ require_relative 'engine/bounds_blockers'
41
43
  require_relative 'engine/collision_box'
42
44
  require_relative 'engine/collision_system'
43
45
  require_relative 'engine/circle_collider'
44
46
  require_relative 'engine/spatial_hash'
47
+ require_relative 'engine/contact_set'
45
48
  require_relative 'engine/pool'
46
- require_relative 'engine/resettable'
47
49
  require_relative 'engine/audio_bus'
48
50
  require_relative 'engine/audio_director'
49
51
  require_relative 'engine/i18n'
50
- require_relative 'engine/cached_label'
52
+ require_relative 'engine/text'
51
53
  require_relative 'engine/debug_overlay'
52
- require_relative 'engine/body'
53
- require_relative 'engine/actor'
54
54
  require_relative 'engine/input/actions'
55
55
  require_relative 'engine/input/input_map'
56
56
  require_relative 'engine/input/action_mapper'
57
- require_relative 'engine/input/player_controller'
58
57
  require_relative 'engine/player'
59
58
  require_relative 'engine/players'
60
59
  require_relative 'engine/viewports'
61
60
  require_relative 'engine/scene/scene_stack'
61
+ require_relative 'engine/components/identity'
62
+ require_relative 'engine/components/mover'
62
63
  require_relative 'engine/components/velocity'
63
64
  require_relative 'engine/components/pool'
64
65
  require_relative 'engine/components/path_follow'
66
+ require_relative 'engine/components/navigator'
65
67
  require_relative 'engine/components/timer'
68
+ require_relative 'engine/components/world'
66
69
  require_relative 'engine/components/screen_wrap'
67
70
  require_relative 'engine/components/despawn_offscreen'
68
71
  require_relative 'engine/components/circle_collider'
72
+ require_relative 'engine/components/box_collider'
73
+ require_relative 'engine/components/feet_collider'
69
74
  require_relative 'engine/components/collision_world'
70
75
  require_relative 'engine/components/targeting'
71
76
  require_relative 'engine/components/sprite'
@@ -74,6 +79,7 @@ require_relative 'engine/components/action_trigger'
74
79
  require_relative 'engine/components/tile_world'
75
80
  require_relative 'engine/components/character_body'
76
81
  require_relative 'engine/components/player_controller'
82
+ require_relative 'engine/components/hop'
77
83
  require_relative 'engine/components/wander_controller'
78
84
  require_relative 'engine/components/animated_sprite'
79
85
  require_relative 'engine/components/camera_follow'
data/lib/rgame/game.rb CHANGED
@@ -55,18 +55,45 @@ module RGame
55
55
  # `Controls.gamepad(slot)` for a controller.
56
56
  #
57
57
  # `input:` overrides the input backend. It exists so a harness can drive a
58
- # game from a script instead of from hardware — see tools/drive_example.rb,
59
- # and CLAUDE.md's "The examples are the acceptance test for wiring", which
60
- # is why driving one has to be possible at all. A game passes nothing and
61
- # gets the real thing.
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
62
  # `players:` is how many seats the game has, and therefore the most people
63
63
  # who can play it. Player 0 starts on `device:`; the rest start empty and
64
64
  # are filled when someone uses a controller — see RGame::Engine::Players for
65
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.
66
91
  def initialize(root:, width: WIDTH, height: HEIGHT, caption: 'RGame',
67
92
  media_root: 'media', input_map: nil, device: Controls::KEYBOARD,
68
- players: 1, input: nil)
69
- super(width: width, height: height, caption: caption, media_root: media_root)
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)
70
97
 
71
98
  @root = root
72
99
  @renderer = RGame::Core::Renderer.new(self)
@@ -77,11 +104,16 @@ module RGame
77
104
  input_map: input_map)
78
105
  end
79
106
  )
80
- @viewports = RGame::Engine::Viewports.new(@players, width: width, height: height)
81
- @debug = RGame::Engine::DebugOverlay.new # always wired up; F1 reveals it
82
- @dirty = true # draw the first frame
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
83
114
 
84
115
  install_asset_loaders
116
+ load_locales(locales)
85
117
  end
86
118
 
87
119
  # The player registry, also reachable from any node as
@@ -105,12 +137,13 @@ module RGame
105
137
  # anything being threaded through its constructor.
106
138
  def start
107
139
  @root.context = self
108
- # Root-scoped systems, mounted before the tree comes alive so that an
109
- # on_add anywhere in it can already resolve node.system(...) for either.
110
140
  @root.add_component(@players)
111
141
  @root.add_component(@viewports)
112
- @root.enter_tree # components attach, then on_add
142
+ @audio_director = Engine::AudioDirector.new(audio).subscribe
143
+ @root.enter_tree
113
144
  run
145
+ ensure
146
+ @audio_director&.unsubscribe
114
147
  end
115
148
 
116
149
  # One fixed simulation tick. `dt` is always the engine's fixed step, so the
@@ -130,11 +163,9 @@ module RGame
130
163
  # one `pressed?`, which is what a caller means.
131
164
  def update(dt)
132
165
  @players.poll(@input)
133
- # The registry, not one player's snapshot: each node resolves the actions
134
- # of whoever owns it, and a node that claims nobody gets the primary.
135
166
  @root.control(@players)
136
167
  @root.update(dt)
137
- @root.sweep_freed # flush queue_free'd nodes outside the update traversal
168
+ @root.sweep_freed
138
169
  @dirty = true
139
170
  end
140
171
 
@@ -152,14 +183,38 @@ module RGame
152
183
  # rather than a shape the platform imposes. That is also what keeps
153
184
  # `node.root` meaning the game's own root: nothing is inserted above it.
154
185
  def draw
155
- @viewports.refresh # rects from the layout, then reclamp every camera
156
- @root.draw(@renderer, @viewports.screen)
157
- @debug.draw(@renderer, @viewports.screen, fps) # last, so it layers on top
186
+ @viewports.refresh
187
+ if @presentation.scaled?
188
+ presented { draw_tree }
189
+ else
190
+ draw_tree
191
+ end
158
192
  @dirty = false
159
193
  end
160
194
 
161
- # The window changed size, so every rect and every camera clamp does too.
162
- def resize(width, height) = @viewports.resize(width, height)
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
163
218
 
164
219
  # Hot-plug is bookkeeping, not seating: a controller arriving becomes a
165
220
  # device the registry watches, and it is someone *using* it that gives it to
@@ -179,14 +234,31 @@ module RGame
179
234
 
180
235
  private
181
236
 
182
- # Teaches the asset manager the types Core cannot build for itself.
183
- #
184
- # `:tilemap` is the only one so far, and it is the reason this method
185
- # exists: `RGame::Engine::TileMap` reads the `.tmx`, `Image#tiles` slices the
186
- # tileset through the cache — so two maps sharing a tileset share one
187
- # upload — and `Core::TileMapRenderer` draws the result. Three objects, and
188
- # neither of the outer two may name the other.
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
+
189
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
+
190
262
  game = self
191
263
  assets.add_loader(:tilemap) do |path|
192
264
  map, image_path = RGame::Engine::TileMap.load(path)
@@ -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