rgame 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -1
  3. data/README.md +67 -65
  4. data/docs/api/README.md +86 -82
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -165
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1032 -265
  10. data/docs/api/drawing.md +160 -160
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +149 -50
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +226 -148
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +397 -244
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +186 -73
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +395 -146
  23. data/docs/api/ui.md +883 -54
  24. data/docs/api/values.md +216 -33
  25. data/examples/assets/README.md +322 -0
  26. data/examples/assets/blip.ogg +0 -0
  27. data/examples/assets/glyphs.json +5 -0
  28. data/examples/assets/glyphs.png +0 -0
  29. data/examples/assets/hero.json +12 -0
  30. data/examples/assets/hero.png +0 -0
  31. data/examples/assets/icons.json +13 -0
  32. data/examples/assets/icons.png +0 -0
  33. data/examples/assets/music.ogg +0 -0
  34. data/examples/assets/skills.json +10 -0
  35. data/examples/assets/skills.png +0 -0
  36. data/examples/assets/tileset.png +0 -0
  37. data/examples/assets/tileset.tsx +65 -0
  38. data/examples/assets/town.tmx +26 -0
  39. data/examples/assets/ui.json +11 -0
  40. data/examples/assets/ui.png +0 -0
  41. data/examples/collision/locales/en.yml +8 -0
  42. data/examples/collision/main.rb +316 -0
  43. data/examples/collision_tiles/locales/en.yml +9 -0
  44. data/examples/collision_tiles/main.rb +274 -0
  45. data/examples/fullscreen/locales/en.yml +10 -0
  46. data/examples/fullscreen/main.rb +216 -0
  47. data/examples/game_menu/locales/en.yml +8 -0
  48. data/examples/game_menu/main.rb +170 -0
  49. data/examples/input_glyphs/locales/en.yml +14 -0
  50. data/examples/input_glyphs/main.rb +213 -0
  51. data/examples/jump_topdown/locales/en.yml +9 -0
  52. data/examples/jump_topdown/main.rb +178 -0
  53. data/examples/localization/locales/de.yml +12 -0
  54. data/examples/localization/locales/en.yml +13 -0
  55. data/examples/localization/main.rb +158 -0
  56. data/examples/menu_navigation/locales/en.yml +23 -0
  57. data/examples/menu_navigation/main.rb +365 -0
  58. data/examples/music/locales/en.yml +7 -0
  59. data/examples/music/main.rb +134 -0
  60. data/examples/pathfinding/locales/en.yml +17 -0
  61. data/examples/pathfinding/main.rb +298 -0
  62. data/examples/pooling/locales/en.yml +7 -0
  63. data/examples/pooling/main.rb +259 -0
  64. data/examples/quick_wheel/locales/en.yml +16 -0
  65. data/examples/quick_wheel/main.rb +184 -0
  66. data/examples/radial_menu/locales/en.yml +16 -0
  67. data/examples/radial_menu/main.rb +184 -0
  68. data/examples/save_load/locales/en.yml +11 -0
  69. data/examples/save_load/main.rb +207 -0
  70. data/examples/save_load_ids/locales/en.yml +11 -0
  71. data/examples/save_load_ids/main.rb +322 -0
  72. data/examples/scroll_map/locales/en.yml +4 -0
  73. data/examples/scroll_map/main.rb +140 -0
  74. data/examples/signals/locales/en.yml +6 -0
  75. data/examples/signals/main.rb +278 -0
  76. data/examples/skill_bar/locales/en.yml +14 -0
  77. data/examples/skill_bar/main.rb +159 -0
  78. data/examples/sound/locales/en.yml +6 -0
  79. data/examples/sound/main.rb +122 -0
  80. data/examples/split_screen/locales/en.yml +9 -0
  81. data/examples/split_screen/main.rb +304 -0
  82. data/examples/sprite/locales/en.yml +8 -0
  83. data/examples/sprite/main.rb +180 -0
  84. data/examples/timer/locales/en.yml +12 -0
  85. data/examples/timer/main.rb +273 -0
  86. data/examples/velocity/locales/en.yml +6 -0
  87. data/examples/velocity/main.rb +196 -0
  88. data/examples/walk/locales/en.yml +4 -0
  89. data/examples/walk/main.rb +99 -0
  90. data/exe/rgame +9 -0
  91. data/ext/rgame_core/app/app.c +33 -3
  92. data/ext/rgame_core/app/locale.c +67 -0
  93. data/ext/rgame_core/app/locale.h +28 -0
  94. data/ext/rgame_core/audio/audio.c +39 -2
  95. data/ext/rgame_core/example.rb +0 -49
  96. data/ext/rgame_core/extconf.rb +0 -125
  97. data/ext/rgame_core/include/rgame/core.h +38 -2
  98. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  99. data/ext/rgame_core/ruby/core_ext.c +30 -7
  100. data/ext/rgame_core/ruby/core_ext.h +3 -0
  101. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  102. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  103. data/ext/rgame_core/ruby/renderer_ext.c +20 -20
  104. data/ext/rgame_util/extconf.rb +2 -20
  105. data/ext/rgame_util/route_search.c +305 -0
  106. data/ext/rgame_util/route_search.h +86 -0
  107. data/ext/rgame_util/route_search_ext.c +150 -0
  108. data/ext/rgame_util/solid_grid.c +58 -0
  109. data/ext/rgame_util/solid_grid.h +49 -0
  110. data/ext/rgame_util/solid_grid_ext.c +161 -0
  111. data/ext/rgame_util/tile_sweep.c +164 -0
  112. data/ext/rgame_util/tile_sweep.h +62 -0
  113. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  114. data/ext/rgame_util/util_ext.c +3 -0
  115. data/ext/rgame_util/util_ext.h +15 -0
  116. data/lib/rgame/boot.rb +0 -10
  117. data/lib/rgame/cli/new_project.rb +139 -0
  118. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  119. data/lib/rgame/cli/templates/README.md.tt +93 -0
  120. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  121. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  122. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  123. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  124. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  125. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  126. data/lib/rgame/cli/templates/rspec.tt +2 -0
  127. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  128. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  129. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  130. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  131. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  132. data/lib/rgame/cli.rb +66 -0
  133. data/lib/rgame/core/app.rb +6 -44
  134. data/lib/rgame/core/asset_manager.rb +13 -31
  135. data/lib/rgame/core/audio.rb +37 -16
  136. data/lib/rgame/core/font.rb +0 -3
  137. data/lib/rgame/core/locale.rb +22 -0
  138. data/lib/rgame/core/nine_slice.rb +0 -21
  139. data/lib/rgame/core/renderer.rb +6 -63
  140. data/lib/rgame/core/sprite_sheet.rb +0 -3
  141. data/lib/rgame/core/tile_map_renderer.rb +2 -19
  142. data/lib/rgame/core/ui_atlas.rb +28 -13
  143. data/lib/rgame/core.rb +1 -8
  144. data/lib/rgame/engine/actor_blockers.rb +131 -0
  145. data/lib/rgame/engine/animation_set.rb +1 -0
  146. data/lib/rgame/engine/audio_director.rb +36 -6
  147. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  148. data/lib/rgame/engine/camera.rb +3 -3
  149. data/lib/rgame/engine/circle_collider.rb +4 -2
  150. data/lib/rgame/engine/collision_box.rb +26 -1
  151. data/lib/rgame/engine/collision_system.rb +110 -22
  152. data/lib/rgame/engine/component.rb +34 -10
  153. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  154. data/lib/rgame/engine/components/animated_sprite.rb +27 -25
  155. data/lib/rgame/engine/components/box_collider.rb +99 -0
  156. data/lib/rgame/engine/components/camera_follow.rb +6 -5
  157. data/lib/rgame/engine/components/character_body.rb +20 -61
  158. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  159. data/lib/rgame/engine/components/collision_world.rb +159 -31
  160. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  161. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  162. data/lib/rgame/engine/components/hop.rb +76 -0
  163. data/lib/rgame/engine/components/identity.rb +73 -0
  164. data/lib/rgame/engine/components/mover.rb +285 -0
  165. data/lib/rgame/engine/components/navigator.rb +145 -0
  166. data/lib/rgame/engine/components/path_follow.rb +123 -31
  167. data/lib/rgame/engine/components/player_controller.rb +5 -2
  168. data/lib/rgame/engine/components/pool.rb +1 -1
  169. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  170. data/lib/rgame/engine/components/sprite.rb +12 -6
  171. data/lib/rgame/engine/components/targeting.rb +9 -11
  172. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  173. data/lib/rgame/engine/components/tile_world.rb +41 -13
  174. data/lib/rgame/engine/components/timer.rb +1 -1
  175. data/lib/rgame/engine/components/velocity.rb +23 -7
  176. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  177. data/lib/rgame/engine/components/world.rb +133 -0
  178. data/lib/rgame/engine/contact_set.rb +74 -0
  179. data/lib/rgame/engine/culling.rb +4 -6
  180. data/lib/rgame/engine/debug_overlay.rb +5 -11
  181. data/lib/rgame/engine/i18n/plural.rb +45 -0
  182. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  183. data/lib/rgame/engine/i18n/template.rb +59 -0
  184. data/lib/rgame/engine/i18n.rb +276 -51
  185. data/lib/rgame/engine/input/action_mapper.rb +0 -26
  186. data/lib/rgame/engine/input/actions.rb +2 -8
  187. data/lib/rgame/engine/input/input_map.rb +39 -21
  188. data/lib/rgame/engine/nav_grid.rb +87 -0
  189. data/lib/rgame/engine/node2d.rb +248 -133
  190. data/lib/rgame/engine/path.rb +4 -6
  191. data/lib/rgame/engine/players.rb +6 -13
  192. data/lib/rgame/engine/presentation.rb +171 -0
  193. data/lib/rgame/engine/scene/scene_stack.rb +4 -4
  194. data/lib/rgame/engine/sealed_privates.rb +54 -0
  195. data/lib/rgame/engine/spatial_hash.rb +51 -19
  196. data/lib/rgame/engine/text.rb +194 -0
  197. data/lib/rgame/engine/tile_blockers.rb +63 -0
  198. data/lib/rgame/engine/tile_map.rb +2 -3
  199. data/lib/rgame/engine/tile_map_layer.rb +0 -2
  200. data/lib/rgame/engine/tileset.rb +2 -4
  201. data/lib/rgame/engine/timer.rb +2 -2
  202. data/lib/rgame/engine/ui/button.rb +248 -0
  203. data/lib/rgame/engine/ui/column.rb +20 -0
  204. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  205. data/lib/rgame/engine/ui/menu.rb +246 -71
  206. data/lib/rgame/engine/ui/navigation.rb +57 -0
  207. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  208. data/lib/rgame/engine/ui/option_button.rb +163 -0
  209. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  210. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  211. data/lib/rgame/engine/ui/pointing.rb +146 -0
  212. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  213. data/lib/rgame/engine/ui/ring.rb +55 -0
  214. data/lib/rgame/engine/ui/row.rb +21 -0
  215. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  216. data/lib/rgame/engine/ui/stack.rb +58 -0
  217. data/lib/rgame/engine/ui/stepping.rb +93 -0
  218. data/lib/rgame/engine/ui/text_button.rb +59 -0
  219. data/lib/rgame/engine/viewports.rb +2 -5
  220. data/lib/rgame/engine/world_view.rb +5 -4
  221. data/lib/rgame/engine.rb +31 -25
  222. data/lib/rgame/game.rb +99 -27
  223. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  224. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  225. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  226. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  227. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  228. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  229. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  230. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  231. data/lib/rgame/rubocop/default.yml +39 -0
  232. data/lib/rgame/rubocop/plugin.rb +45 -0
  233. data/lib/rgame/rubocop.rb +11 -0
  234. data/lib/rgame/util/color.rb +20 -24
  235. data/lib/rgame/util/controls.rb +14 -28
  236. data/lib/rgame/util/route_search.rb +27 -0
  237. data/lib/rgame/util/save_file.rb +107 -0
  238. data/lib/rgame/util/solid_grid.rb +37 -0
  239. data/lib/rgame/util/tensor.rb +0 -9
  240. data/lib/rgame/util/tile_sweep.rb +36 -0
  241. data/lib/rgame/util/z.rb +4 -14
  242. data/lib/rgame/util.rb +4 -3
  243. data/lib/rgame/version.rb +1 -1
  244. data/lib/rgame.rb +0 -15
  245. metadata +152 -11
  246. data/lib/rgame/engine/actor.rb +0 -53
  247. data/lib/rgame/engine/body.rb +0 -49
  248. data/lib/rgame/engine/cached_label.rb +0 -33
  249. data/lib/rgame/engine/input/player_controller.rb +0 -14
  250. data/lib/rgame/engine/matrix.rb +0 -32
  251. data/lib/rgame/engine/resettable.rb +0 -67
  252. data/lib/rgame/engine/tile_collision.rb +0 -78
  253. data/lib/rgame/engine/ui/menu_item.rb +0 -84
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Timer — things that happen on a clock, with nobody pressing anything.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/timer/main.rb
8
+ #
9
+ # Watch the race; **Space** sets off the one-shot. It exercises:
10
+ # - Components::Timer — a repeating interval that rides the node's tick;
11
+ # - `repeating: false` — the same component as a one-shot that arms itself;
12
+ # - `as:` — two cadences on one node, because a node holds one per slot;
13
+ # - Engine::Timer — the pure accumulator underneath all of that.
14
+ #
15
+ # ## A spawner is not waiting for a key
16
+ #
17
+ # Every example before this one moved when something was pressed. A wave clock, a
18
+ # turret's rate of fire and an enemy every few seconds are none of them input, and
19
+ # they are what this is for.
20
+ #
21
+ # ## The race is the point, and the loser is the obvious implementation
22
+ #
23
+ # Both bars count beats at the same interval. The top one is a
24
+ # `Components::Timer`; the bottom one is four lines anybody would write:
25
+ #
26
+ # @elapsed += dt
27
+ # if @elapsed >= INTERVAL
28
+ # @elapsed = 0.0 # <- this
29
+ # beat
30
+ # end
31
+ #
32
+ # The top bar wins every lap, and that line is why. A frame almost never lands
33
+ # exactly on an interval boundary, so `@elapsed` is a little *past* it when the
34
+ # beat fires — and zeroing throws that overshoot away. Every beat starts late by
35
+ # whatever was discarded, and the error adds up for as long as the program runs.
36
+ #
37
+ # `Engine::Timer#consume` subtracts the interval instead of zeroing, so the
38
+ # remainder carries into the next beat and the cadence stays true to the clock
39
+ # rather than to the frame it happened to be noticed on:
40
+ #
41
+ # def consume
42
+ # @elapsed -= @interval
43
+ # end
44
+ #
45
+ # One character of difference, and the gap it opens is on screen. The interval
46
+ # here is deliberately not a whole number of frames — at a fixed sixtieth of a
47
+ # second and an interval of 0.07, a beat is 4.2 frames, and rounding that up to 5
48
+ # costs the naive bar about one lap in six.
49
+ #
50
+ # ## The catch-up half of the same idea
51
+ #
52
+ # `Components::Timer#update` fires `on_timeout` *once per whole interval* that
53
+ # elapsed, in a loop, rather than once per call. A step long enough to cover
54
+ # three intervals emits three times. Nothing in this example produces one — the
55
+ # loop is fixed-timestep — but a game that ever sees a long step wants the
56
+ # spawner to owe it three enemies rather than one.
57
+ #
58
+ # ## One component per slot, which is what `as:` is for
59
+ #
60
+ # A node holds one component per slot, and a slot defaults to the component's
61
+ # class — so a second `Components::Timer` added plainly would be refused. The top
62
+ # runner carries two: a fast `:beat` and a slow `:chime` that flips the marker
63
+ # beside it. Naming them is the whole of making that legal, and it is also how
64
+ # either is found again.
65
+ #
66
+ # ## The one-shot arms itself, which matters more than it sounds
67
+ #
68
+ # `repeating: false` fires once and goes inert, and `reset` re-arms it. The
69
+ # component calls `reset` from `on_attach`, so a node that leaves the tree and
70
+ # comes back gets a fresh countdown rather than inheriting a spent one. That line
71
+ # is what makes a pooled projectile with a lifetime work — see `examples/pooling`,
72
+ # where the same component retires what the pool hands out.
73
+ #
74
+ # ## What it does not solve
75
+ #
76
+ # Pausing a timer on its own. A `Components::Timer` stops when its node stops,
77
+ # because `paused` halts the node's whole update — which is usually what is
78
+ # wanted, and is not the same as a timer with its own switch.
79
+
80
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
81
+ require 'rgame/game'
82
+
83
+ WIDTH = 640
84
+ HEIGHT = 480
85
+ ASSETS = File.expand_path('../assets', __dir__)
86
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
87
+
88
+ # 4.2 frames at a fixed sixtieth of a second, chosen so that rounding it up to a
89
+ # whole frame is a mistake with a visible size.
90
+ BEAT = 0.07
91
+ CHIME = 1.5
92
+ FUSE = 1.2 # how long the one-shot banner stays
93
+
94
+ TRACK_X = 60
95
+ TRACK_W = 480
96
+ LAP = 70 # beats to the end of the track
97
+
98
+ # The two runners share this, so the only difference between them is how each
99
+ # decides a beat has happened.
100
+ module Runner
101
+ BAR_H = 26
102
+ TRACK = RGame::Util::Color.new(44, 50, 64)
103
+
104
+ def beats = @beats ||= 0
105
+
106
+ def draw_track(renderer, color)
107
+ renderer.rect(0, 0, TRACK_W, BAR_H, color: TRACK)
108
+ renderer.rect(0, 0, TRACK_W * (beats.to_f / LAP), BAR_H, color: color)
109
+ end
110
+ end
111
+
112
+ # Beats on a Components::Timer, which carries the remainder forward.
113
+ class TrueRunner < RGame::Engine::Node2D
114
+ include Runner
115
+
116
+ BAR = RGame::Util::Color.new(120, 210, 150)
117
+ MARK_ON = RGame::Util::Color.new(255, 226, 130)
118
+ MARK_OFF = RGame::Util::Color.new(70, 78, 96)
119
+ MARK = 18
120
+
121
+ def initialize(**)
122
+ super
123
+ @beats = 0
124
+ @chimed = false
125
+ end
126
+
127
+ # Two timers, two slots. Without the names the second would be refused, and
128
+ # `get_component(Components::Timer)` would have no single answer to give.
129
+ def on_add
130
+ add_component(RGame::Engine::Components::Timer.new(BEAT), as: :beat)
131
+ .on_timeout { @beats += 1 }
132
+ add_component(RGame::Engine::Components::Timer.new(CHIME), as: :chime)
133
+ .on_timeout { @chimed = !@chimed }
134
+ end
135
+
136
+ # Resetting the lap is this node's business rather than the timer's: the timer
137
+ # counts time and the owner decides what a count means.
138
+ def lap? = @beats >= LAP
139
+
140
+ def restart = @beats = 0
141
+
142
+ def on_draw(renderer, _view)
143
+ draw_track(renderer, BAR)
144
+ renderer.rect(TRACK_W + 14, 4, MARK, MARK, color: @chimed ? MARK_ON : MARK_OFF)
145
+ end
146
+ end
147
+
148
+ # The same cadence, written the way it comes out first. It is slower, and the
149
+ # only reason is the line that throws the overshoot away.
150
+ class NaiveRunner < RGame::Engine::Node2D
151
+ include Runner
152
+
153
+ BAR = RGame::Util::Color.new(235, 120, 100)
154
+ MARK = RGame::Util::Color.new(255, 255, 255)
155
+ MARK_W = 3
156
+
157
+ def initialize(**)
158
+ super
159
+ @beats = 0
160
+ @elapsed = 0.0
161
+ @shortfall = 0
162
+ end
163
+
164
+ def on_update(dt)
165
+ @elapsed += dt
166
+ return if @elapsed < BEAT
167
+
168
+ # Discarding the overshoot is the bug. `@elapsed -= BEAT` here and the two
169
+ # bars finish level.
170
+ @elapsed = 0.0
171
+ @beats += 1
172
+ end
173
+
174
+ # Remembered before it is cleared, because how far this bar had got when the
175
+ # other one finished *is* the size of the error. The marker lands in the same
176
+ # place every lap, which is the other half of the point: the gap is a steady
177
+ # rate, not a one-off stumble.
178
+ def restart
179
+ @shortfall = @beats
180
+ @beats = 0
181
+ @elapsed = 0.0
182
+ end
183
+
184
+ def on_draw(renderer, _view)
185
+ draw_track(renderer, BAR)
186
+ return if @shortfall.zero?
187
+
188
+ renderer.rect(TRACK_W * (@shortfall.to_f / LAP), 0, MARK_W, BAR_H, color: MARK)
189
+ end
190
+ end
191
+
192
+ # A banner that takes itself away. Nothing removes it from outside.
193
+ class Fuse < RGame::Engine::Node2D
194
+ BANNER = RGame::Util::Color.new(120, 200, 255)
195
+ INK = RGame::Util::Color.new(20, 26, 34)
196
+ BANNER_W = 260
197
+ BANNER_H = 30
198
+
199
+ def initialize(**)
200
+ super
201
+ @label = RGame::Engine::Text.new('fuse.banner')
202
+ end
203
+
204
+ def on_add
205
+ add_component(RGame::Engine::Components::Timer.new(FUSE, repeating: false))
206
+ .on_timeout { queue_free }
207
+ end
208
+
209
+ def on_draw(renderer, _view)
210
+ renderer.rect(0, 0, BANNER_W, BANNER_H, color: BANNER)
211
+ renderer.text(@label, 10, 8, z: 1, color: INK)
212
+ end
213
+ end
214
+
215
+ class Scene < RGame::Engine::Node2D
216
+ BACKDROP = RGame::Util::Color.new(28, 32, 42)
217
+ FUSE_X = 60
218
+ FUSE_Y = 370
219
+
220
+ def initialize
221
+ super
222
+ @help = RGame::Engine::Text.new('help.beat')
223
+ @true_caption = RGame::Engine::Text.new('captions.timer')
224
+ @naive_caption = RGame::Engine::Text.new('captions.naive')
225
+ @mark_caption = RGame::Engine::Text.new('captions.mark')
226
+ @blink_caption = RGame::Engine::Text.new('captions.blink')
227
+ @fuse_caption = RGame::Engine::Text.new('captions.fuse')
228
+ end
229
+
230
+ def on_add
231
+ @truth = add_node(TrueRunner.new(x: TRACK_X, y: 150))
232
+ @naive = add_node(NaiveRunner.new(x: TRACK_X, y: 210))
233
+ end
234
+
235
+ # The lap is settled here, where both runners are visible, so that neither of
236
+ # them needs to know the other exists.
237
+ def on_update(_dt)
238
+ return unless @truth.lap?
239
+
240
+ @truth.restart
241
+ @naive.restart
242
+ end
243
+
244
+ def on_control(actions)
245
+ return unless actions.pressed?(:fire)
246
+ # One at a time: a second banner would sit exactly on top of the first.
247
+ return if @fuse&.in_tree?
248
+
249
+ @fuse = add_node(Fuse.new(x: FUSE_X, y: FUSE_Y))
250
+ end
251
+
252
+ def on_draw(renderer, view)
253
+ renderer.rect(0, 0, view.width, view.height, color: BACKDROP)
254
+
255
+ renderer.text(@help, 12, 12)
256
+ renderer.text(@true_caption, TRACK_X, 126)
257
+ renderer.text(@naive_caption, TRACK_X, 244)
258
+ renderer.text(@mark_caption, TRACK_X, 264)
259
+ renderer.text(@blink_caption, TRACK_X, 296)
260
+ renderer.text(@fuse_caption, TRACK_X, 336)
261
+ end
262
+ end
263
+
264
+ game = RGame::Game.new(
265
+ root: Scene.new,
266
+ caption: 'Timer',
267
+ width: WIDTH,
268
+ height: HEIGHT,
269
+ media_root: ASSETS,
270
+ locales: LOCALES
271
+ )
272
+
273
+ game.start
@@ -0,0 +1,6 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ help:
4
+ walk: Arrows / WASD walk the pale circle — the rectangles need nobody
5
+ wrap: They wrap at the edge of the world, which stops above this text
6
+ spin: "Two of them spin: one component adding to angle, no shape the wiser"
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Velocity — movement with nobody driving.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/velocity/main.rb
8
+ #
9
+ # Arrow keys, WASD or a stick walk the pale circle. The rectangles are not
10
+ # waiting for you. It exercises:
11
+ # - Components::Velocity — a velocity integrated into the node each step;
12
+ # - Components::ScreenWrap — leaving one edge and arriving at the opposite one;
13
+ # - Components::World — how big the world is, as a service the scene mounts;
14
+ # - Components::WorldBounds — the contract that lets one wrap fit two worlds.
15
+ #
16
+ # ## Two ways to move, and the difference is who decides
17
+ #
18
+ # Every example before this one moved things with `CharacterBody`, which takes an
19
+ # *intent* — each axis in -1..1 — and turns it into a step at a fixed speed. Let
20
+ # go of the key and the intent is zero and the node stops. That is what a walking
21
+ # character wants, and it is why the circle here behaves the way you expect.
22
+ #
23
+ # A rock has no intent. It has a velocity, and something integrates it:
24
+ #
25
+ # node.x += vx * dt
26
+ # node.y += vy * dt
27
+ # node.angle += spin * dt
28
+ #
29
+ # That is the whole of Components::Velocity. Nothing writes to it after the
30
+ # rectangles are built, nothing reads input, and they drift for as long as the
31
+ # program runs. The two components are siblings rather than rivals —
32
+ # `ThrustController` is the third of the family, and it *accelerates* a velocity
33
+ # rather than setting one, which is how a ship differs from both.
34
+ #
35
+ # ## `spin` is an angle, and the shape does not know
36
+ #
37
+ # Two of the rectangles turn as they drift, and the drawing code is the same
38
+ # single `renderer.rect` the still ones use, at the same coordinates. `Node2D#draw`
39
+ # has already pushed the node's rotation, so a component that adds to `angle` is
40
+ # enough to make a shape spin — the same reason the sprite in `examples/sprite`
41
+ # turns without Components::Sprite knowing that angles exist.
42
+ #
43
+ # ## The world is not the window
44
+ #
45
+ # The world stops short of the bottom of the window, where the text is, and the
46
+ # rectangles wrap at its edge rather than the screen's. Ask the `view` a draw is
47
+ # handed how big the **window** is, and `node.system(WorldBounds)` how big the
48
+ # **world** is. `examples/scroll_map` is where the two differ in earnest.
49
+ #
50
+ # ## Why the wrap asks for a contract rather than a class
51
+ #
52
+ # `ScreenWrap` never mentions `World`. It calls `WorldBounds.resolve`, and what
53
+ # answers is whichever system the scene mounted — the plain rectangle here, or a
54
+ # `TileWorld` that works its bounds out from a parsed map. `Node2D#get_component`
55
+ # matches with `is_a?`, which matches an included module as readily as a class,
56
+ # and that is the whole mechanism.
57
+ #
58
+ # It resolves in `on_attach` rather than `initialize`, because a node has no
59
+ # scene to ask until it is in the tree — and it resolves again on every entry, so
60
+ # an entity pooled out of one scene and into a smaller one wraps against the
61
+ # smaller one.
62
+ #
63
+ # ## What it does not solve
64
+ #
65
+ # Collision. These pass straight through each other, because a velocity is only
66
+ # a position changing over time and nothing here is watching for overlaps.
67
+ # `examples/collision` is where that is the subject.
68
+
69
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
70
+ require 'rgame/game'
71
+
72
+ WIDTH = 640
73
+ HEIGHT = 480
74
+ ASSETS = File.expand_path('../assets', __dir__)
75
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
76
+
77
+ # The world is the window minus a band along the bottom for the text. It is
78
+ # shorter by more than the wrap margin, so a shape leaving through the bottom edge
79
+ # is put back before it reaches the text.
80
+ WORLD_W = WIDTH
81
+ WORLD_H = 360
82
+ TEXT_Y = 410
83
+
84
+ # How far past the edge a shape travels before it is put back on the other side.
85
+ # Without it a rectangle jumps while half of it is still showing.
86
+ WRAP_MARGIN = 40.0
87
+
88
+ WALK_SPEED = 140.0
89
+
90
+ # A rectangle that moves because it was given a velocity, and for no other
91
+ # reason. Everything that makes it move is a component; this class is a shape.
92
+ class Drifter < RGame::Engine::Node2D
93
+ BOX_W = 38
94
+ BOX_H = 20
95
+
96
+ def initialize(color:, vx:, vy:, spin: 0.0, **)
97
+ super(width: BOX_W, height: BOX_H, **)
98
+ @color = color
99
+ @vx = vx
100
+ @vy = vy
101
+ @spin = spin
102
+ end
103
+
104
+ def on_add
105
+ add_component(RGame::Engine::Components::Velocity.new(vx: @vx, vy: @vy, spin: @spin))
106
+ add_component(RGame::Engine::Components::ScreenWrap.new(margin: WRAP_MARGIN))
107
+ end
108
+
109
+ # Centred on the node's own origin, which is where the traversal has already
110
+ # put the renderer — so a spinning node turns this rectangle about its middle
111
+ # and this method never learns of it.
112
+ def on_draw(renderer, _view)
113
+ renderer.rect(-BOX_W / 2, -BOX_H / 2, BOX_W, BOX_H, color: @color)
114
+ end
115
+ end
116
+
117
+ # The one with an intent. Same wrap, same world, different reason to move.
118
+ class Walker < RGame::Engine::Node2D
119
+ BODY = RGame::Util::Color.new(236, 233, 220)
120
+ RADIUS = 13
121
+
122
+ def initialize(**)
123
+ super(width: RADIUS * 2, height: RADIUS * 2, **)
124
+ end
125
+
126
+ # CharacterBody first: PlayerController drives a sibling body and asks for it
127
+ # by class when it attaches, and a component added from `on_add` can only see
128
+ # the ones already there.
129
+ def on_add
130
+ add_component(RGame::Engine::Components::CharacterBody.new(speed: WALK_SPEED))
131
+ add_component(RGame::Engine::Components::PlayerController.new)
132
+ add_component(RGame::Engine::Components::ScreenWrap.new(margin: RADIUS))
133
+ end
134
+
135
+ def on_draw(renderer, _view) = renderer.circle(0, 0, RADIUS, color: BODY)
136
+ end
137
+
138
+ class Scene < RGame::Engine::Node2D
139
+ BACKDROP = RGame::Util::Color.new(28, 32, 42)
140
+ FLOOR = RGame::Util::Color.new(44, 50, 64)
141
+ EDGE = RGame::Util::Color.new(120, 200, 255)
142
+ THICK = 2
143
+
144
+ # vx, vy, spin (degrees per second), colour. Fixed rather than random: two runs
145
+ # of this example should be able to be compared without a seed.
146
+ DRIFTERS = [
147
+ [90.0, 34.0, 0.0, [235, 145, 90]],
148
+ [-70.0, 58.0, 110.0, [130, 210, 150]],
149
+ [48.0, -76.0, 0.0, [225, 205, 110]],
150
+ [-104.0, -26.0, -150.0, [190, 150, 235]],
151
+ [62.0, 90.0, 0.0, [120, 200, 255]]
152
+ ].freeze
153
+
154
+ # Mounted here rather than in `on_add` for a reason worth knowing: a node
155
+ # assembled outside the tree collects every component before any of them
156
+ # attaches, so nothing depends on the order. Added from `on_add` the node is
157
+ # already live, each component attaches as it arrives, and the wraps below
158
+ # would resolve their bounds before this existed.
159
+ def initialize
160
+ super
161
+ @help_walk = RGame::Engine::Text.new('help.walk')
162
+ @help_wrap = RGame::Engine::Text.new('help.wrap')
163
+ @help_spin = RGame::Engine::Text.new('help.spin')
164
+ add_component(RGame::Engine::Components::World.new(width: WORLD_W, height: WORLD_H))
165
+ end
166
+
167
+ def on_add
168
+ DRIFTERS.each_with_index do |(vx, vy, spin, rgb), index|
169
+ add_node(Drifter.new(color: RGame::Util::Color.new(*rgb), vx: vx, vy: vy, spin: spin,
170
+ x: 60 + (index * 120), y: 40 + (index * 60)))
171
+ end
172
+
173
+ add_node(Walker.new(x: WORLD_W / 2, y: WORLD_H / 2))
174
+ end
175
+
176
+ def on_draw(renderer, view)
177
+ renderer.rect(0, 0, view.width, view.height, color: BACKDROP)
178
+ renderer.rect(0, 0, WORLD_W, WORLD_H, color: FLOOR)
179
+ renderer.rect(0, WORLD_H - THICK, WORLD_W, THICK, color: EDGE)
180
+
181
+ renderer.text(@help_walk, 12, TEXT_Y)
182
+ renderer.text(@help_wrap, 12, TEXT_Y + 22)
183
+ renderer.text(@help_spin, 12, TEXT_Y + 44)
184
+ end
185
+ end
186
+
187
+ game = RGame::Game.new(
188
+ root: Scene.new,
189
+ caption: 'Velocity',
190
+ width: WIDTH,
191
+ height: HEIGHT,
192
+ media_root: ASSETS,
193
+ locales: LOCALES
194
+ )
195
+
196
+ game.start
@@ -0,0 +1,4 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ help:
4
+ walk: Arrow keys / WASD / gamepad to walk
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Walk — a player-controlled sprite, and the smallest complete game there is.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/walk/main.rb
8
+ #
9
+ # Arrow keys, WASD, a d-pad or a left stick; they all work and none of them are
10
+ # mentioned below. It exercises:
11
+ # - Node2D — the scene tree, and `on_update`/`on_draw` as blank hooks;
12
+ # - Components::AnimatedSprite — directional animation off a sprite sheet;
13
+ # - Components::CharacterBody — an intent in -1..1 becomes a step at a speed;
14
+ # - Components::PlayerController — input axes become that intent;
15
+ # - InputMap.default — physical keys become the :move_x / :move_y actions.
16
+ #
17
+ # ## The shape to take away
18
+ #
19
+ # The hero is a plain Node2D with three components on it. Nothing subclasses a
20
+ # "player" class, and the three components do not know about each other: the
21
+ # controller writes a movement intent, the body turns intent into a step, and
22
+ # the sprite reads the same intent back to pick which way to face. Swapping the
23
+ # controller for Components::WanderController is the whole of turning this into
24
+ # an NPC.
25
+ #
26
+ # The one subclass here is Hero, and it exists only to keep the walker on
27
+ # screen — see the comment on `on_update`.
28
+
29
+ # lib/ on the load path, so `require 'rgame/game'` resolves the same way it
30
+ # would from an installed gem. examples/ and lib/ are siblings in both, so this
31
+ # line is correct either way.
32
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
33
+ require 'rgame/game'
34
+
35
+ WIDTH = 640
36
+ HEIGHT = 480
37
+ ASSETS = File.expand_path('../assets', __dir__)
38
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
39
+
40
+ SPEED = 90.0 # pixels per second
41
+
42
+ # A Node2D that stays inside the window.
43
+ #
44
+ # Clamping is not something the engine does for you here: a plain CharacterBody
45
+ # moves the node wherever the intent points, because it is meant for an actor in
46
+ # a world with nothing to bump into. Giving it edges is one `on_update` — and in
47
+ # a real game it is usually Components::TileWorld doing it instead, which is
48
+ # what `examples/scroll_map` shows.
49
+ class Hero < RGame::Engine::Node2D
50
+ # `on_update` is the blank hook: Node2D#update does the bookkeeping and calls
51
+ # this, so there is no `super` to forget.
52
+ def on_update(_dt)
53
+ self.x = x.clamp(0, WIDTH - width)
54
+ self.y = y.clamp(0, HEIGHT - height)
55
+ end
56
+ end
57
+
58
+ # The root. It builds the hero in `initialize`, before anything is in the tree.
59
+ #
60
+ # That matters and is easy to get wrong the other way round: `add_component`
61
+ # attaches immediately once its node is live, and AnimatedSprite's attach looks
62
+ # for a CharacterBody sibling. Build the node whole, then add it — then the
63
+ # order components go on in cannot matter.
64
+ class Root < RGame::Engine::Node2D
65
+ def initialize
66
+ super
67
+ @help = RGame::Engine::Text.new('help.walk')
68
+ add_node(build_hero)
69
+ end
70
+
71
+ def on_draw(renderer, _view)
72
+ renderer.text(@help, 12, 12)
73
+ end
74
+
75
+ private
76
+
77
+ def build_hero
78
+ hero = Hero.new(x: (WIDTH - 16) / 2, y: (HEIGHT - 22) / 2)
79
+ # The sheet is a path relative to the asset manager's root, resolved on
80
+ # attach — nothing is loaded or registered by hand. hero.json names its own
81
+ # image and its animations; AnimatedSprite picks between them by reading the
82
+ # body's intent, so :walk_left and friends are looked up by name.
83
+ hero.add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
84
+ hero.add_component(RGame::Engine::Components::CharacterBody.new(speed: SPEED))
85
+ hero.add_component(RGame::Engine::Components::PlayerController.new)
86
+ hero
87
+ end
88
+ end
89
+
90
+ game = RGame::Game.new(
91
+ root: Root.new,
92
+ caption: 'Walk',
93
+ width: WIDTH,
94
+ height: HEIGHT,
95
+ media_root: ASSETS,
96
+ locales: LOCALES
97
+ )
98
+
99
+ game.start
data/exe/rgame ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path('../lib', __dir__)
5
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
6
+
7
+ require 'rgame/cli'
8
+
9
+ exit RGame::CLI.run(ARGV)
@@ -95,7 +95,11 @@ struct rgame_app {
95
95
  int drawing;
96
96
  };
97
97
 
98
- rgame_app *rgame_app_create(int width, int height, const char *title) {
98
+ /* Desktop fullscreen, never a mode change see the header for why. Named once
99
+ * so the creation flags and the runtime toggle cannot disagree. */
100
+ #define RGAME_FULLSCREEN_FLAG SDL_WINDOW_FULLSCREEN_DESKTOP
101
+
102
+ rgame_app *rgame_app_create(int width, int height, const char *title, int fullscreen) {
99
103
  if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) {
100
104
  SDL_Log("SDL_Init failed: %s", SDL_GetError());
101
105
  return NULL;
@@ -114,7 +118,8 @@ rgame_app *rgame_app_create(int width, int height, const char *title) {
114
118
  title,
115
119
  SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
116
120
  width, height,
117
- SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN);
121
+ SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN |
122
+ (fullscreen ? RGAME_FULLSCREEN_FLAG : 0u));
118
123
  if (!app->window) {
119
124
  SDL_Log("SDL_CreateWindow failed: %s", SDL_GetError());
120
125
  free(app);
@@ -467,7 +472,13 @@ static void rgame_app_poll_events(rgame_app *app, const rgame_app_callbacks *cb)
467
472
  break;
468
473
  }
469
474
  case SDL_WINDOWEVENT:
470
- if (event.window.event == SDL_WINDOWEVENT_RESIZED) {
475
+ /* SIZE_CHANGED rather than RESIZED, and the difference matters:
476
+ * SDL sends RESIZED only when something *outside* the program
477
+ * resized the window, and SIZE_CHANGED for that case as well as for
478
+ * a size this program asked for. Toggling fullscreen is the second
479
+ * kind, so a listener on RESIZED alone never hears about it and the
480
+ * game carries on laying out for the old size. */
481
+ if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
471
482
  /* The viewport is set from the current size every frame, in the
472
483
  * backend's begin_frame, so there is nothing to update here. */
473
484
  if (cb->resize) {
@@ -865,6 +876,25 @@ void rgame_app_set_title(rgame_app *app, const char *title) {
865
876
  SDL_SetWindowTitle(app->window, title);
866
877
  }
867
878
 
879
+ void rgame_app_set_fullscreen(rgame_app *app, int fullscreen) {
880
+ if (!app) {
881
+ return;
882
+ }
883
+ SDL_SetWindowFullscreen(app->window, fullscreen ? RGAME_FULLSCREEN_FLAG : 0u);
884
+ }
885
+
886
+ int rgame_app_fullscreen(const rgame_app *app) {
887
+ if (!app) {
888
+ return 0;
889
+ }
890
+ /* Tested against the whole mask rather than for any bit in it:
891
+ * SDL_WINDOW_FULLSCREEN_DESKTOP is SDL_WINDOW_FULLSCREEN plus one more,
892
+ * so `flags & FLAG` is also true of an exclusive-fullscreen window — which
893
+ * this engine never asks for, but a caller could have set behind our back. */
894
+ Uint32 flags = SDL_GetWindowFlags(app->window);
895
+ return (flags & RGAME_FULLSCREEN_FLAG) == RGAME_FULLSCREEN_FLAG;
896
+ }
897
+
868
898
  int rgame_app_input_down(const rgame_app *app, int device, int button_id) {
869
899
  return rgame_input_state_down(&app->input, device, button_id);
870
900
  }