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,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+ require 'rubygems/version'
6
+
7
+ require_relative '../version'
8
+
9
+ module RGame
10
+ module CLI
11
+ # Writes a new game project: `rgame new tictactoe`.
12
+ #
13
+ # The layout it produces is the project's own architecture in miniature, and
14
+ # the generated comments say so at each file:
15
+ #
16
+ # - `game.rb` is the only file that requires `rgame/game`, so it is the
17
+ # only one that loads SDL and OpenGL. It is the game's glue class, the
18
+ # local counterpart of RGame::Game.
19
+ # - `nodes/` requires `rgame` and names only RGame::Engine, so every node
20
+ # runs with no window.
21
+ # - `spec/` therefore loads `rgame` too, and the whole suite runs headless.
22
+ #
23
+ # Getting a newcomer to that shape by default is most of the point: it is
24
+ # the arrangement that keeps game logic spec-able, and it is not one anybody
25
+ # would arrive at by guessing.
26
+ #
27
+ # @api private
28
+ class NewProject
29
+ # Anything the caller did wrong: a bad name, a directory in the way.
30
+ # RGame::CLI turns it into a message and a non-zero status.
31
+ class Error < StandardError; end
32
+
33
+ TEMPLATE_ROOT = File.expand_path('templates', __dir__)
34
+
35
+ DOTFILES = {
36
+ 'gitignore' => '.gitignore',
37
+ 'rspec' => '.rspec',
38
+ 'ruby-version' => '.ruby-version',
39
+ 'rubocop.yml' => '.rubocop.yml'
40
+ }.freeze
41
+
42
+ NAME_PATTERN = /\A[a-z][a-z0-9_-]*\z/i
43
+
44
+ def initialize(name, out: $stdout, root: Dir.pwd)
45
+ @name = name
46
+ @out = out
47
+ @target = File.expand_path(name, root)
48
+
49
+ validate_name!
50
+ end
51
+
52
+ def generate
53
+ check_target!
54
+
55
+ templates.each { |source, destination| write(destination, render(source)) }
56
+
57
+ report_next_steps
58
+ end
59
+
60
+ # `tic_tac_toe` and `tic-tac-toe` both give `TicTacToe`. Used for the
61
+ # window caption and, with `Game` appended, for the game class.
62
+ def caption = @name.split(/[_-]+/).map(&:capitalize).join
63
+
64
+ def game_class = "#{caption}Game"
65
+
66
+ def app_name = @name
67
+
68
+ # "~> 0.2" for a 0.2.0 generator, so a project tracks whatever version
69
+ # created it rather than a number frozen into a template.
70
+ def rgame_requirement = Gem::Version.new(RGame::VERSION).approximate_recommendation
71
+
72
+ # The Ruby running `rgame new`, written to .ruby-version and pointed at
73
+ # from the Gemfile. Whichever Ruby generated the project is the one it was
74
+ # known to work on, which is the only version this can honestly claim.
75
+ #
76
+ # The bare `4.0.5` form rather than mise's `ruby 4.0.5`: every version
77
+ # manager reads it, and so does Bundler's `ruby file:`.
78
+ def ruby_version = RUBY_VERSION
79
+
80
+ private
81
+
82
+ def templates
83
+ Dir.glob('**/*.tt', base: TEMPLATE_ROOT).sort.map do |source|
84
+ [source, destination_for(source)]
85
+ end
86
+ end
87
+
88
+ def destination_for(source)
89
+ dir = File.dirname(source)
90
+ base = File.basename(source, '.tt')
91
+ base = DOTFILES.fetch(base, base)
92
+
93
+ dir == '.' ? base : File.join(dir, base)
94
+ end
95
+
96
+ def render(source)
97
+ erb = ERB.new(File.read(File.join(TEMPLATE_ROOT, source)), trim_mode: '-')
98
+ erb.filename = source
99
+ erb.result(binding)
100
+ end
101
+
102
+ def write(destination, content)
103
+ path = File.join(@target, destination)
104
+
105
+ FileUtils.mkdir_p(File.dirname(path))
106
+ File.write(path, content)
107
+
108
+ @out.puts(" create #{File.join(@name, destination)}")
109
+ end
110
+
111
+ def validate_name!
112
+ return if @name.is_a?(String) && @name.match?(NAME_PATTERN)
113
+
114
+ raise Error, "#{@name.inspect} is not a valid project name — use letters, digits, " \
115
+ 'underscores and dashes, starting with a letter'
116
+ end
117
+
118
+ def check_target!
119
+ return unless File.exist?(@target)
120
+ raise Error, "#{@name} exists and is not a directory" unless File.directory?(@target)
121
+ return if (Dir.children(@target) - ['.', '..']).empty?
122
+
123
+ raise Error, "#{@name} already exists and is not empty"
124
+ end
125
+
126
+ def report_next_steps
127
+ @out.puts(<<~NEXT)
128
+
129
+ Created #{@name}. Next:
130
+
131
+ cd #{@name}
132
+ bundle install
133
+ bundle exec rspec # the game logic, headless — no window needed
134
+ ruby main.rb # the game itself
135
+ NEXT
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # The Ruby this project was generated with, kept in one place so bundler and
6
+ # your version manager cannot disagree about it. Bundler reads .ruby-version as
7
+ # an exact requirement and refuses to install on anything else; for a range,
8
+ # replace this line with something like `ruby '~> <%= ruby_version.split('.').first(2).join('.') %>'`.
9
+ ruby file: '.ruby-version'
10
+
11
+ # The engine. It has no runtime dependencies of its own — SDL2 and OpenGL are
12
+ # system libraries rather than gems — but installing it compiles two C
13
+ # extensions, so a C compiler, `make`, `pkg-config` and the SDL2/OpenGL
14
+ # development headers have to be present. See the rgame README.
15
+ gem 'rgame', '<%= rgame_requirement %>'
16
+
17
+ group :development, :test do
18
+ gem 'rake', require: false
19
+ gem 'rspec'
20
+ gem 'rubocop', require: false
21
+ gem 'rubocop-performance', require: false
22
+ gem 'rubocop-rspec', require: false
23
+ end
@@ -0,0 +1,93 @@
1
+ # <%= caption %>
2
+
3
+ A game built with [rgame](https://github.com/psuessenb/rgame).
4
+
5
+ ## Running it
6
+
7
+ ```
8
+ bundle install
9
+ ruby main.rb
10
+ ```
11
+
12
+ `F1` toggles the debug overlay, `F2` closes the window.
13
+
14
+ This project was generated on Ruby <%= ruby_version %>, which is what
15
+ `.ruby-version` holds and what the `Gemfile` points at. Bundler treats that as
16
+ an exact requirement; see the note in the `Gemfile` to loosen it.
17
+
18
+ ## Testing it
19
+
20
+ ```
21
+ bundle exec rspec
22
+ ```
23
+
24
+ The suite runs **headless** — no window, no GPU, no clock. That is not an
25
+ accident of this project being small; it is the arrangement the layout below is
26
+ for, and it holds however large the game gets.
27
+
28
+ ```
29
+ bundle exec rubocop
30
+ bundle exec rake # both of the above
31
+ ```
32
+
33
+ ## Layout
34
+
35
+ ```
36
+ main.rb boots the game and nothing else
37
+ game.rb the window, its size, its caption — and the ONLY file that
38
+ loads SDL and OpenGL
39
+ nodes/ the scene tree: game logic, graphics-free
40
+ root.rb the root node, drawn every frame
41
+ assets/ images, sounds, maps — RGame::Game's media_root
42
+ locales/ translation tables, one file per language
43
+ spec/ specs for nodes/, run with no display
44
+ locales_spec.rb every language has every key en.yml has
45
+ ```
46
+
47
+ ### Why `game.rb` and `nodes/` are separate
48
+
49
+ rgame comes in two halves, and they are worth keeping apart.
50
+
51
+ `RGame::Core` owns the window, the GPU textures and the sound device — real
52
+ operating-system handles. `RGame::Engine` is the scene graph: nodes, components,
53
+ signals, sprites, tile maps. It holds no handles at all, and it never names
54
+ `RGame::Core`. A node is *handed* a renderer at draw time and calls methods on
55
+ it by name.
56
+
57
+ So `require 'rgame'` loads the engine and the value types with no graphics
58
+ library in the process, while `require 'rgame/game'` loads everything. `game.rb`
59
+ does the latter, `nodes/*.rb` and `spec/*` do the former — and that one
60
+ difference is what lets the entire game logic be tested in milliseconds, with a
61
+ spy standing in for the renderer, on a machine with no display.
62
+
63
+ Keep new game logic under `nodes/`. When something there needs to draw, give it
64
+ an `on_draw(renderer, view)` and call the renderer by name.
65
+
66
+ ## Text on screen
67
+
68
+ Text the game draws lives in `assets/locales/en.yml`, not in the code. A node
69
+ builds an `RGame::Engine::Text` from a key once and draws it every frame:
70
+
71
+ ```ruby
72
+ @greeting = RGame::Engine::Text.new('root.greeting') # in initialize
73
+ renderer.text(@greeting, 20, 20) # in on_draw
74
+ ```
75
+
76
+ - **To add text,** add a key to `en.yml`.
77
+ - **To add a language,** add a file beside it, such as `de.yml` starting with
78
+ `de:`, holding the same keys. The game loads every file in `assets/locales/`
79
+ and picks the player's language from their operating system.
80
+ - **`bundle exec rspec` fails** when a language lacks a key `en.yml` has, and
81
+ when a spec draws a key no table has. In the game, a missing key shows the
82
+ English text instead.
83
+
84
+ `spec/spec_helper.rb` loads the tables from `assets/locales/` itself, since specs
85
+ cannot load `game.rb`. Move that directory and update both.
86
+
87
+ The [localization guide](https://github.com/psuessenb/rgame/blob/main/docs/api/localization.md)
88
+ covers plurals, variables and the fallback between languages.
89
+
90
+ ## Where to look next
91
+
92
+ The [rgame API guide](https://github.com/psuessenb/rgame/blob/main/docs/api/README.md)
93
+ covers the scene graph, components, input, drawing, audio and the asset layer.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core/rake_task'
4
+ require 'rubocop/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+ RuboCop::RakeTask.new
8
+
9
+ task default: %i[spec rubocop]
@@ -0,0 +1,10 @@
1
+ # The English table: every piece of text the game draws, by key. Rails' format,
2
+ # so the top-level key is the locale and everything below it nests.
3
+ #
4
+ # To add a language, add a file beside this one (de.yml, starting `de:`) with
5
+ # the same keys. RGame::Game loads every .yml in this directory and picks the
6
+ # player's language from their operating system; spec/locales_spec.rb fails
7
+ # while a language lacks a key this file has.
8
+ en:
9
+ root:
10
+ greeting: "Hello from <%= app_name %>!"
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgame/game'
4
+
5
+ require_relative 'nodes/root'
6
+
7
+ class <%= game_class %> < RGame::Game
8
+ WIDTH = 640
9
+ HEIGHT = 480
10
+
11
+ # The bare `**` forwards every keyword straight through to RGame::Game, so
12
+ # anything it accepts still works here — `players: 2` for split-screen, or
13
+ # `input:` to hand the game a scripted input backend and drive it from a test
14
+ # harness with no hardware attached.
15
+ def initialize(**)
16
+ super(root: Root.new,
17
+ caption: '<%= caption %>',
18
+ width: WIDTH,
19
+ height: HEIGHT,
20
+ media_root: File.join(__dir__, 'assets'),
21
+ **)
22
+ end
23
+ end
@@ -0,0 +1,12 @@
1
+ # Bundler
2
+ /.bundle/
3
+ /vendor/bundle/
4
+
5
+ # Gem packaging, if this project ever becomes one
6
+ /pkg/
7
+ *.gem
8
+
9
+ # Editor / OS
10
+ .DS_Store
11
+ *.swp
12
+ /.ruby-lsp/
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point: `ruby main.rb`.
4
+ #
5
+ # Deliberately nothing but a boot. Everything the game *is* lives in game.rb and
6
+ # nodes/, which is what lets the spec suite load the interesting half without
7
+ # also starting a window.
8
+
9
+ require_relative 'game'
10
+
11
+ <%= game_class %>.new.start
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgame'
4
+
5
+ # The root of the scene tree. Everything the game draws hangs below it.
6
+ #
7
+ # The three hooks to override are `on_control(actions)`, `on_update(dt)` and
8
+ # `on_draw(renderer, view)`. Override those rather than `control`, `update` or
9
+ # `draw`: the engine does its own bookkeeping in the outer methods and calls
10
+ # these, so there is no `super` to forget.
11
+ #
12
+ # Text on screen is a key into assets/locales/, not a String in the code. A
13
+ # `Text` is built once and read every frame: it looks the key up again only when
14
+ # the language changes, so drawing it costs nothing.
15
+ class Root < RGame::Engine::Node2D
16
+ def initialize
17
+ super
18
+ @greeting = RGame::Engine::Text.new('root.greeting')
19
+ end
20
+
21
+ def on_draw(renderer, _view)
22
+ renderer.text(@greeting, 20, 20)
23
+ end
24
+ end
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format documentation
@@ -0,0 +1,75 @@
1
+ # rgame's own cops come with the gem. They catch what a game gets wrong without
2
+ # noticing: a String built every frame, a node drawn at its own position twice,
3
+ # text a translation cannot reach. Each explains itself in its message.
4
+ plugins:
5
+ - rubocop-performance
6
+ - rubocop-rspec
7
+ - rgame/rubocop:
8
+ plugin_class_name: RuboCop::Game::Plugin
9
+
10
+ AllCops:
11
+ NewCops: enable
12
+ TargetRubyVersion: 4.0
13
+
14
+ # This is a game, not a web app: update/draw methods naturally run long, and
15
+ # short coordinate and loop variable names (x, y, dt, id) are idiomatic in the
16
+ # maths they contain. Relax the size and naming cops accordingly.
17
+ Metrics/AbcSize:
18
+ Enabled: false
19
+ Metrics/BlockLength:
20
+ Enabled: false
21
+ Metrics/ClassLength:
22
+ Enabled: false
23
+ Metrics/CyclomaticComplexity:
24
+ Enabled: false
25
+ Metrics/MethodLength:
26
+ Enabled: false
27
+ Metrics/ModuleLength:
28
+ Enabled: false
29
+ Metrics/ParameterLists:
30
+ Enabled: false
31
+ Metrics/PerceivedComplexity:
32
+ Enabled: false
33
+ Naming/MethodParameterName:
34
+ Enabled: false
35
+
36
+ # Maths-heavy code reads fine on standard operator precedence
37
+ # (row * cols + col, x + w / 2); requiring parentheses everywhere is just noise.
38
+ Lint/AmbiguousOperatorPrecedence:
39
+ Enabled: false
40
+
41
+ # nodes/ and spec/ run with no window, and only game.rb loads one. This keeps it
42
+ # so: naming RGame::Core, or requiring rgame/core or rgame/game, fails the lint
43
+ # rather than the spec run on a machine without a display.
44
+ Game/NoCoreInEngineLayer:
45
+ Enabled: true
46
+ Include:
47
+ - 'nodes/**/*.rb'
48
+ - 'spec/**/*.rb'
49
+
50
+ Style/Documentation:
51
+ Enabled: false
52
+
53
+ # Integration-flavoured specs in a game naturally run longer than five lines and
54
+ # assert more than one thing about the frame they drove.
55
+ RSpec/ExampleLength:
56
+ Enabled: false
57
+ RSpec/MultipleExpectations:
58
+ Enabled: false
59
+
60
+ # A node is handed a renderer and calls it by name — it never learns what class
61
+ # answered, which is exactly what lets the spec suite run with no window.
62
+ # Verifying a double against RGame::Core::Renderer would mean loading SDL and
63
+ # OpenGL into the spec process, so these doubles are deliberately unverified.
64
+ RSpec/VerifiedDoubles:
65
+ Enabled: false
66
+
67
+ # Spec paths mirror the source tree, and a class with a trailing dimension maps
68
+ # to node2d_spec.rb rather than the cop's default node2_d_spec.rb.
69
+ RSpec/SpecFilePathFormat:
70
+ CustomTransform:
71
+ Node2D: node2d
72
+ # It checks the tables in assets/locales/, and describes I18n only because
73
+ # that is what it asks, so there is no source file for its path to mirror.
74
+ Exclude:
75
+ - spec/locales_spec.rb
@@ -0,0 +1 @@
1
+ <%= ruby_version %>
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The translation tables in assets/locales/, checked against each other. A key
4
+ # added to en.yml and forgotten in another language fails here, rather than
5
+ # showing a player English text in the middle of their own language.
6
+ RSpec.describe RGame::Engine::I18n do
7
+ # Without this, deleting en.yml would leave nothing to compare against, and
8
+ # the example below would pass for every language.
9
+ it 'has a table for the default locale' do
10
+ expect(described_class.available).to include(described_class.default)
11
+ end
12
+
13
+ it 'gives every locale every key the default locale has' do
14
+ missing = described_class.available.to_h { |locale| [locale, described_class.missing_keys(locale)] }
15
+
16
+ expect(missing.reject { |_locale, keys| keys.empty? }).to be_empty
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What a node spec looks like: build the node, drive one of its hooks, assert.
4
+ # No window is opened, and none is needed — see spec/spec_helper.rb for why.
5
+ RSpec.describe Root do
6
+ describe '#on_draw' do
7
+ it 'draws its greeting from the English table' do
8
+ # A node calls a renderer by name and never asks what class it is, so a
9
+ # plain spy stands in for one. The real RGame::Core::Renderer lives on the
10
+ # other side of a line this suite deliberately does not cross.
11
+ renderer = spy('renderer')
12
+
13
+ described_class.new.on_draw(renderer, nil)
14
+
15
+ expect(renderer).to have_received(:text).with('Hello from <%= app_name %>!', 20, 20)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `rgame` and never `rgame/game`. It loads RGame::Util and RGame::Engine —
4
+ # everything that runs without a window — so no SDL and no OpenGL enter this
5
+ # process, and the whole suite runs with no display: in CI, over SSH, anywhere.
6
+ #
7
+ # It also means `RGame::Core` is an undefined constant here. A spec that reached
8
+ # for it would fail loudly rather than quietly opening a window, which is the
9
+ # point: game logic that can only be tested with a GPU attached is game logic
10
+ # that stops being tested.
11
+ require 'rgame'
12
+
13
+ # The project root, so `require 'nodes/root'` works from anywhere in the suite.
14
+ $LOAD_PATH.unshift File.expand_path('..', __dir__)
15
+
16
+ Dir[File.expand_path('../nodes/**/*.rb', __dir__)].each { |file| require file }
17
+
18
+ # The translation tables, read from disk once. This is the directory RGame::Game
19
+ # loads in the game: `locales` under game.rb's `media_root`. Move one and move
20
+ # the other, or every key a spec draws raises.
21
+ LOCALES = Dir[File.expand_path('../assets/locales/**/*.yml', __dir__)]
22
+ .to_h { |path| [path, File.read(path)] }.freeze
23
+
24
+ RSpec.configure do |config|
25
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
26
+ config.disable_monkey_patching!
27
+
28
+ # RGame::Engine::I18n is global, so every example starts from the game's own
29
+ # tables in the default language, whatever the example before it loaded or
30
+ # switched to. A key no table has raises instead of drawing itself: found by
31
+ # `rake` rather than by a player.
32
+ config.before do
33
+ RGame::Engine::I18n.reset
34
+ LOCALES.each { |path, yaml| RGame::Engine::I18n.load(yaml, source: path) }
35
+ RGame::Engine::I18n.missing = :raise
36
+ end
37
+ end
data/lib/rgame/cli.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'version'
4
+ require_relative 'cli/new_project'
5
+
6
+ module RGame
7
+ # Argument parsing and dispatch. The work is in RGame::CLI::NewProject.
8
+ module CLI
9
+ USAGE = <<~USAGE
10
+ Usage: rgame COMMAND [ARGS]
11
+
12
+ Commands:
13
+ new NAME Create a new game project in the directory NAME
14
+ version Print the rgame version
15
+ help Print this message
16
+
17
+ Example:
18
+ rgame new tictactoe
19
+ USAGE
20
+
21
+ # Runs one command and returns the process exit status, rather than calling
22
+ # `exit` itself — which is what lets a spec drive it in-process and assert on
23
+ # what it wrote and what it printed.
24
+ #
25
+ # `out` and `err` are injectable for the same reason.
26
+ def self.run(argv, out: $stdout, err: $stderr)
27
+ command, *rest = argv
28
+
29
+ case command
30
+ when 'new' then new_project(rest, out: out, err: err)
31
+ when 'version', '--version', '-v' then version(out: out)
32
+ when 'help', '--help', '-h', nil then help(out: out)
33
+ else
34
+ err.puts("rgame: unknown command #{command.inspect}", '', USAGE)
35
+ 1
36
+ end
37
+ end
38
+
39
+ def self.help(out:)
40
+ out.puts(USAGE)
41
+ 0
42
+ end
43
+
44
+ def self.version(out:)
45
+ out.puts("rgame #{RGame::VERSION}")
46
+ 0
47
+ end
48
+
49
+ def self.new_project(args, out:, err:)
50
+ name = args.first
51
+
52
+ if name.nil? || name.start_with?('-')
53
+ err.puts('rgame new: expected a project name', '', USAGE)
54
+ return 1
55
+ end
56
+
57
+ NewProject.new(name, out: out).generate
58
+ 0
59
+ rescue NewProject::Error => e
60
+ err.puts("rgame new: #{e.message}")
61
+ 1
62
+ end
63
+
64
+ private_class_method :help, :version, :new_project
65
+ end
66
+ end
@@ -1,46 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # RGame::Core::App is implemented in C — see ext/rgame_core/. It wraps the
4
- # engine's public API (ext/rgame_core/include/rgame/core.h): an SDL window plus
5
- # OpenGL context, and a fixed-timestep main loop that drives callbacks.
6
- #
7
- # App is meant to be subclassed. The engine calls back into methods on the
8
- # object itself, so a game overrides the hooks it needs and inherits no-ops for
9
- # the rest:
10
- #
11
- # class MyGame < RGame::Core::App
12
- # def initialize = super(width: 800, height: 600, caption: 'my game')
13
- #
14
- # def frame_begin; end # once per frame, before that frame's ticks
15
- # def update(dt); end # one fixed simulation tick; dt is always the
16
- # # fixed step, never wall-clock frame time
17
- # def needs_redraw?; end # false skips the draw (simulation still runs)
18
- # def draw; end # render one frame
19
- # def button_down(id); end # discrete key press (no repeats)
20
- # def button_up(id); end
21
- # def resize(w, h); end
22
- # end
23
- #
24
- # MyGame.new.run
25
- #
26
- # Also inherited: #close (stops the loop; safe from inside a callback), #width,
27
- # #height, #caption, #caption=, #ticks_ms (monotonic ms since startup) and #fps.
28
- #
29
- # The loop owns the fixed-timestep accumulator, so #update is called once per
30
- # whole tick and may run zero or several times per rendered frame. #frame_begin
31
- # is the place to sample input once and reuse it across every tick of that
32
- # frame.
33
- #
34
- # If a callback raises, the exception is carried out of #run with its original
35
- # class, message and backtrace, and the loop shuts down cleanly first. A
36
- # non-local exit (throw/break/return) out of a callback cannot be carried
37
- # across the C loop and is reported as a RuntimeError instead — use #close to
38
- # stop the loop.
39
- #
40
- # Loading this .so pulls SDL2 and OpenGL into the process — see
41
- # lib/rgame/core.rb for why that's kept off the default `require "rgame"`.
42
- # It loads from lib/rgame/core_ext.so, which the build (`make ext-core`)
43
- # copies out of ext/rgame_core/.
44
3
  require 'rgame/core_ext'
45
4
 
46
5
  module RGame
@@ -71,7 +30,12 @@ module RGame
71
30
  # This app's sound device. Not tied to the window in any way — audio has
72
31
  # no GL context and survives one being recreated — it lives here because
73
32
  # a game wants exactly one, the same way it wants one asset manager.
74
- def audio = @audio ||= Audio.new
33
+ # It is handed this app's asset manager, so `audio.play_sound('hurt.ogg')`
34
+ # resolves a path the same way `renderer.sprite('hero.json', …)` does.
35
+ # Building the manager does not touch audio — its loaders are lambdas,
36
+ # called at load time — so there is no cycle here despite the manager
37
+ # reaching back through `app.audio` to make a sound.
38
+ def audio = @audio ||= Audio.new(assets: assets)
75
39
 
76
40
  # Where #assets resolves relative paths from. Set once, as a keyword to
77
41
  # the constructor; there is deliberately no writer, because changing it