rgame 0.4.0-x86_64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +251 -0
  3. data/LICENSE +26 -0
  4. data/README.md +325 -0
  5. data/docs/api/README.md +224 -0
  6. data/docs/api/app.md +249 -0
  7. data/docs/api/assets.md +463 -0
  8. data/docs/api/audio.md +249 -0
  9. data/docs/api/cli.md +259 -0
  10. data/docs/api/components.md +1112 -0
  11. data/docs/api/drawing.md +398 -0
  12. data/docs/api/examples.md +263 -0
  13. data/docs/api/game.md +219 -0
  14. data/docs/api/images.md +118 -0
  15. data/docs/api/input.md +476 -0
  16. data/docs/api/internals.md +289 -0
  17. data/docs/api/localization.md +285 -0
  18. data/docs/api/scene_graph.md +539 -0
  19. data/docs/api/signals.md +147 -0
  20. data/docs/api/systems.md +231 -0
  21. data/docs/api/text.md +133 -0
  22. data/docs/api/tile_maps.md +237 -0
  23. data/docs/api/toolbox.md +493 -0
  24. data/docs/api/ui.md +927 -0
  25. data/docs/api/values.md +316 -0
  26. data/examples/assets/README.md +322 -0
  27. data/examples/assets/blip.ogg +0 -0
  28. data/examples/assets/glyphs.json +5 -0
  29. data/examples/assets/glyphs.png +0 -0
  30. data/examples/assets/hero.json +12 -0
  31. data/examples/assets/hero.png +0 -0
  32. data/examples/assets/icons.json +13 -0
  33. data/examples/assets/icons.png +0 -0
  34. data/examples/assets/music.ogg +0 -0
  35. data/examples/assets/skills.json +10 -0
  36. data/examples/assets/skills.png +0 -0
  37. data/examples/assets/tileset.png +0 -0
  38. data/examples/assets/tileset.tsx +65 -0
  39. data/examples/assets/town.tmx +26 -0
  40. data/examples/assets/ui.json +11 -0
  41. data/examples/assets/ui.png +0 -0
  42. data/examples/collision/locales/en.yml +8 -0
  43. data/examples/collision/main.rb +316 -0
  44. data/examples/collision_tiles/locales/en.yml +9 -0
  45. data/examples/collision_tiles/main.rb +274 -0
  46. data/examples/fullscreen/locales/en.yml +10 -0
  47. data/examples/fullscreen/main.rb +216 -0
  48. data/examples/game_menu/locales/en.yml +8 -0
  49. data/examples/game_menu/main.rb +170 -0
  50. data/examples/input_glyphs/locales/en.yml +14 -0
  51. data/examples/input_glyphs/main.rb +213 -0
  52. data/examples/jump_topdown/locales/en.yml +9 -0
  53. data/examples/jump_topdown/main.rb +178 -0
  54. data/examples/localization/locales/de.yml +12 -0
  55. data/examples/localization/locales/en.yml +13 -0
  56. data/examples/localization/main.rb +158 -0
  57. data/examples/menu_navigation/locales/en.yml +23 -0
  58. data/examples/menu_navigation/main.rb +365 -0
  59. data/examples/music/locales/en.yml +7 -0
  60. data/examples/music/main.rb +134 -0
  61. data/examples/pathfinding/locales/en.yml +17 -0
  62. data/examples/pathfinding/main.rb +298 -0
  63. data/examples/pooling/locales/en.yml +7 -0
  64. data/examples/pooling/main.rb +259 -0
  65. data/examples/quick_wheel/locales/en.yml +16 -0
  66. data/examples/quick_wheel/main.rb +184 -0
  67. data/examples/radial_menu/locales/en.yml +16 -0
  68. data/examples/radial_menu/main.rb +184 -0
  69. data/examples/save_load/locales/en.yml +11 -0
  70. data/examples/save_load/main.rb +207 -0
  71. data/examples/save_load_ids/locales/en.yml +11 -0
  72. data/examples/save_load_ids/main.rb +322 -0
  73. data/examples/scroll_map/locales/en.yml +4 -0
  74. data/examples/scroll_map/main.rb +140 -0
  75. data/examples/signals/locales/en.yml +6 -0
  76. data/examples/signals/main.rb +278 -0
  77. data/examples/skill_bar/locales/en.yml +14 -0
  78. data/examples/skill_bar/main.rb +159 -0
  79. data/examples/sound/locales/en.yml +6 -0
  80. data/examples/sound/main.rb +122 -0
  81. data/examples/split_screen/locales/en.yml +9 -0
  82. data/examples/split_screen/main.rb +304 -0
  83. data/examples/sprite/locales/en.yml +8 -0
  84. data/examples/sprite/main.rb +180 -0
  85. data/examples/timer/locales/en.yml +12 -0
  86. data/examples/timer/main.rb +273 -0
  87. data/examples/velocity/locales/en.yml +6 -0
  88. data/examples/velocity/main.rb +196 -0
  89. data/examples/walk/locales/en.yml +4 -0
  90. data/examples/walk/main.rb +99 -0
  91. data/exe/rgame +9 -0
  92. data/lib/rgame/boot.rb +3 -0
  93. data/lib/rgame/cli/new_project.rb +139 -0
  94. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  95. data/lib/rgame/cli/templates/README.md.tt +93 -0
  96. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  97. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  98. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  99. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  100. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  101. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  102. data/lib/rgame/cli/templates/rspec.tt +2 -0
  103. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  104. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  105. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  106. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  107. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  108. data/lib/rgame/cli.rb +66 -0
  109. data/lib/rgame/core/app.rb +46 -0
  110. data/lib/rgame/core/asset_manager.rb +206 -0
  111. data/lib/rgame/core/audio.rb +145 -0
  112. data/lib/rgame/core/font.rb +46 -0
  113. data/lib/rgame/core/gamepad.rb +55 -0
  114. data/lib/rgame/core/image.rb +55 -0
  115. data/lib/rgame/core/input.rb +71 -0
  116. data/lib/rgame/core/locale.rb +22 -0
  117. data/lib/rgame/core/nine_slice.rb +142 -0
  118. data/lib/rgame/core/recording.rb +54 -0
  119. data/lib/rgame/core/renderer.rb +354 -0
  120. data/lib/rgame/core/sprite_sheet.rb +105 -0
  121. data/lib/rgame/core/tile_map_renderer.rb +172 -0
  122. data/lib/rgame/core/ui_atlas.rb +101 -0
  123. data/lib/rgame/core/virtual_gamepad.rb +26 -0
  124. data/lib/rgame/core.rb +18 -0
  125. data/lib/rgame/core_ext.so +0 -0
  126. data/lib/rgame/engine/actor_blockers.rb +131 -0
  127. data/lib/rgame/engine/animation_set.rb +50 -0
  128. data/lib/rgame/engine/animator.rb +44 -0
  129. data/lib/rgame/engine/audio_bus.rb +24 -0
  130. data/lib/rgame/engine/audio_director.rb +59 -0
  131. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  132. data/lib/rgame/engine/camera.rb +78 -0
  133. data/lib/rgame/engine/circle_collider.rb +34 -0
  134. data/lib/rgame/engine/collision_box.rb +59 -0
  135. data/lib/rgame/engine/collision_system.rb +132 -0
  136. data/lib/rgame/engine/component.rb +64 -0
  137. data/lib/rgame/engine/components/action_trigger.rb +40 -0
  138. data/lib/rgame/engine/components/animated_sprite.rb +71 -0
  139. data/lib/rgame/engine/components/box_collider.rb +99 -0
  140. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  141. data/lib/rgame/engine/components/character_body.rb +50 -0
  142. data/lib/rgame/engine/components/circle_collider.rb +80 -0
  143. data/lib/rgame/engine/components/collision_world.rb +231 -0
  144. data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
  145. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  146. data/lib/rgame/engine/components/hop.rb +76 -0
  147. data/lib/rgame/engine/components/identity.rb +73 -0
  148. data/lib/rgame/engine/components/mover.rb +285 -0
  149. data/lib/rgame/engine/components/navigator.rb +145 -0
  150. data/lib/rgame/engine/components/path_follow.rb +176 -0
  151. data/lib/rgame/engine/components/player_controller.rb +27 -0
  152. data/lib/rgame/engine/components/pool.rb +53 -0
  153. data/lib/rgame/engine/components/screen_wrap.rb +49 -0
  154. data/lib/rgame/engine/components/sprite.rb +47 -0
  155. data/lib/rgame/engine/components/targeting.rb +52 -0
  156. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  157. data/lib/rgame/engine/components/tile_world.rb +109 -0
  158. data/lib/rgame/engine/components/timer.rb +75 -0
  159. data/lib/rgame/engine/components/velocity.rb +43 -0
  160. data/lib/rgame/engine/components/wander_controller.rb +64 -0
  161. data/lib/rgame/engine/components/world.rb +133 -0
  162. data/lib/rgame/engine/contact_set.rb +74 -0
  163. data/lib/rgame/engine/culling.rb +45 -0
  164. data/lib/rgame/engine/debug_overlay.rb +111 -0
  165. data/lib/rgame/engine/i18n/plural.rb +45 -0
  166. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  167. data/lib/rgame/engine/i18n/template.rb +59 -0
  168. data/lib/rgame/engine/i18n.rb +322 -0
  169. data/lib/rgame/engine/input/action_mapper.rb +100 -0
  170. data/lib/rgame/engine/input/actions.rb +92 -0
  171. data/lib/rgame/engine/input/input_map.rb +196 -0
  172. data/lib/rgame/engine/layout.rb +82 -0
  173. data/lib/rgame/engine/nav_grid.rb +87 -0
  174. data/lib/rgame/engine/node2d.rb +555 -0
  175. data/lib/rgame/engine/path.rb +76 -0
  176. data/lib/rgame/engine/player.rb +69 -0
  177. data/lib/rgame/engine/player_layer.rb +70 -0
  178. data/lib/rgame/engine/players.rb +205 -0
  179. data/lib/rgame/engine/pool.rb +51 -0
  180. data/lib/rgame/engine/presentation.rb +171 -0
  181. data/lib/rgame/engine/scene/scene_stack.rb +87 -0
  182. data/lib/rgame/engine/sealed_privates.rb +54 -0
  183. data/lib/rgame/engine/signal.rb +75 -0
  184. data/lib/rgame/engine/spatial_hash.rb +116 -0
  185. data/lib/rgame/engine/text.rb +194 -0
  186. data/lib/rgame/engine/tile_blockers.rb +63 -0
  187. data/lib/rgame/engine/tile_map.rb +147 -0
  188. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  189. data/lib/rgame/engine/tileset.rb +99 -0
  190. data/lib/rgame/engine/timer.rb +51 -0
  191. data/lib/rgame/engine/ui/button.rb +248 -0
  192. data/lib/rgame/engine/ui/column.rb +20 -0
  193. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  194. data/lib/rgame/engine/ui/menu.rb +290 -0
  195. data/lib/rgame/engine/ui/navigation.rb +57 -0
  196. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  197. data/lib/rgame/engine/ui/option_button.rb +163 -0
  198. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  199. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  200. data/lib/rgame/engine/ui/pointing.rb +146 -0
  201. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  202. data/lib/rgame/engine/ui/ring.rb +55 -0
  203. data/lib/rgame/engine/ui/row.rb +21 -0
  204. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  205. data/lib/rgame/engine/ui/stack.rb +58 -0
  206. data/lib/rgame/engine/ui/stepping.rb +93 -0
  207. data/lib/rgame/engine/ui/text_button.rb +59 -0
  208. data/lib/rgame/engine/view.rb +76 -0
  209. data/lib/rgame/engine/viewports.rb +171 -0
  210. data/lib/rgame/engine/world_view.rb +71 -0
  211. data/lib/rgame/engine.rb +86 -0
  212. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  213. data/lib/rgame/fonts/OFL.txt +102 -0
  214. data/lib/rgame/game.rb +271 -0
  215. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  216. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  217. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  218. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  219. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  220. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  221. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  222. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  223. data/lib/rgame/rubocop/default.yml +39 -0
  224. data/lib/rgame/rubocop/plugin.rb +45 -0
  225. data/lib/rgame/rubocop.rb +11 -0
  226. data/lib/rgame/util/color.rb +23 -0
  227. data/lib/rgame/util/controls.rb +169 -0
  228. data/lib/rgame/util/route_search.rb +27 -0
  229. data/lib/rgame/util/save_file.rb +107 -0
  230. data/lib/rgame/util/solid_grid.rb +37 -0
  231. data/lib/rgame/util/tensor.rb +3 -0
  232. data/lib/rgame/util/tile_sweep.rb +36 -0
  233. data/lib/rgame/util/z.rb +123 -0
  234. data/lib/rgame/util.rb +10 -0
  235. data/lib/rgame/util_ext.so +0 -0
  236. data/lib/rgame/version.rb +12 -0
  237. data/lib/rgame.rb +5 -0
  238. data/licenses/SDL2/LICENSE.txt +18 -0
  239. metadata +309 -0
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # One player's own corner of the screen.
6
+ #
7
+ # layer = scene.add_node(PlayerLayer.new(player: players[1]))
8
+ # layer.add_node(inventory)
9
+ #
10
+ # Its subtree is drawn **once**, clipped to that player's viewport and
11
+ # translated to its corner, in screen space. That is the third kind of
12
+ # content a frame holds: the world is drawn once per viewport under a
13
+ # camera, a global overlay once across the whole window, and this once per
14
+ # player inside their own region.
15
+ #
16
+ # It is also the `:hud` z band's structural counterpart: everything under
17
+ # here draws above everything in the world, whatever either asked for.
18
+ #
19
+ # ## Its children are positioned relative to it
20
+ #
21
+ # A node at (10, 10) under this layer is ten pixels inside *that player's*
22
+ # region, wherever the layout has put it. The layer's translate is what does
23
+ # that, so the same HUD class serves either player with nothing to configure
24
+ # — which is the point of it being a node rather than a rect a HUD looks up.
25
+ #
26
+ # For laying out against the far edge, use the view's **size**:
27
+ # `view.width - margin`. `view.x` and `view.y` are where the region sits on
28
+ # the window and are the clip's business, not a layout origin; adding them
29
+ # would offset a second time.
30
+ #
31
+ # ## It says whose input its subtree reads
32
+ #
33
+ # `input_owner` is set to that player, and ownership is inherited, so a menu
34
+ # anywhere under here reads their controller and nobody else's. Two players
35
+ # with a menu open at once are independent without either one knowing the
36
+ # other exists — see docs/api/scene_graph.md, "Who a node answers to".
37
+ #
38
+ # ## An empty region draws nothing
39
+ #
40
+ # `Viewports#screen_for` is nil for a seat nobody is in, and for everybody
41
+ # while the split is collapsed — a cutscene is everyone looking at one thing,
42
+ # so per-player UI has no place to be. Either way this draws nothing and
43
+ # needs no guard at the call site.
44
+ class PlayerLayer < Node2D
45
+ # `:hud` is this player's own screen space, above every world slot and
46
+ # below a global overlay. It is inherited, so a menu three nodes down is
47
+ # in it without saying so — which is the point of the band being
48
+ # structural rather than a number each widget carries.
49
+ def initialize(player:, **)
50
+ super(band: :hud, **)
51
+ self.input_owner = player
52
+ end
53
+
54
+ # Whose layer this is. The same thing as `input_owner`, and stored only
55
+ # there: two fields would be two things to keep in step.
56
+ def player = input_owner
57
+
58
+ def draw(renderer, _view = nil)
59
+ region = system(Viewports).screen_for(player)
60
+ return if region.nil?
61
+
62
+ renderer.clipped(region.x, region.y, region.width, region.height) do
63
+ renderer.translated(region.offset_x, region.offset_y) do
64
+ super(renderer, region)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Who is playing, as a root-scoped system.
6
+ #
7
+ # players = node.system(Players)
8
+ # players.primary.camera
9
+ # players.each_active { |player| ... }
10
+ #
11
+ # A Component on the root node, so any node reaches it by walking the tree
12
+ # rather than having it threaded through a constructor — the same shape
13
+ # CollisionWorld and TileWorld use (see docs/api/systems.md).
14
+ #
15
+ # It owns the list, polls every player's mapper once per tick, and decides
16
+ # who a newly used controller belongs to. It does **not** own the screen
17
+ # rects — those come from the layout, because they depend on how many
18
+ # players are active and change without the players doing so.
19
+ #
20
+ # ## Seats, and how a device comes to occupy one
21
+ #
22
+ # Every seat exists from the start; the unfilled ones are inactive and draw
23
+ # no viewport. So the number of seats is also the maximum number of players,
24
+ # rather than a separate cap that could disagree with the list.
25
+ #
26
+ # **A device is seated when someone uses it, not when it is plugged in.** A
27
+ # connect says something about hardware; seating a player creates a camera, a
28
+ # viewport and a screen split, and that should follow a statement of intent.
29
+ # Seating on connect drops a pad a solo player plugs in (no seat is free),
30
+ # splits the screen when a spare pad wakes up, and cannot be refused during a
31
+ # cutscene.
32
+ #
33
+ # players.on_unassigned_input = :join # :join | :takeover | :ignore
34
+ # players.accepting_joins = false # temporarily refuse either
35
+ #
36
+ # - `:join` — a press on an unassigned device fills the next free seat.
37
+ # Couch co-op, and the default when a game asks for more than one seat.
38
+ # - `:takeover` — it becomes the *primary* player's device instead. Single
39
+ # player, where picking up a controller is not a second person arriving,
40
+ # and the default when there is one seat.
41
+ # - `:ignore` — the game seats devices itself, with #seat.
42
+ #
43
+ # The trigger is a **`ui_confirm` press**, read through the map of whoever
44
+ # would receive the device. One action rather than "any input", because a
45
+ # stick resting slightly off centre must never seat a player, and an edge
46
+ # rather than held so one press does one thing.
47
+ class Players < Component
48
+ extend Engine::Signal::DSL
49
+
50
+ Controls = RGame::Util::Controls
51
+
52
+ include Enumerable
53
+
54
+ # Fires when a device is seated, with the player who got it. A scene
55
+ # listens to spawn that player's avatar — which is how a game gains a
56
+ # second character mid-session without polling for one.
57
+ signal :on_joined, Engine::Signal.define(:player)
58
+
59
+ attr_reader :list
60
+ attr_accessor :on_unassigned_input, :accepting_joins
61
+
62
+ def initialize(players = [])
63
+ super()
64
+ @list = players
65
+ @on_unassigned_input = players.size > 1 ? :join : :takeover
66
+ @accepting_joins = true
67
+ @connected = []
68
+ @confirm_held = {}
69
+ end
70
+
71
+ # The player a single-player game means, and the one an unowned node reads
72
+ # from. Always present: a game with no players declared still has this one,
73
+ # which is what keeps single-player free of ceremony.
74
+ def primary = @list.first
75
+
76
+ def each(&) = @list.each(&)
77
+
78
+ # Players with a device driving them. An empty seat waiting for a
79
+ # controller is in `list` but not here, so a viewport loop skips it.
80
+ def each_active(&) = @list.select(&:active?).each(&)
81
+
82
+ def active_count = @list.count(&:active?)
83
+
84
+ def [](id) = @list.find { |player| player.id == id }
85
+
86
+ def add(player)
87
+ @list << player
88
+ player
89
+ end
90
+
91
+ # The input a node owned by `player` should read this tick.
92
+ #
93
+ # Nobody in particular means the primary player, which is what makes the
94
+ # single-player path free: no node claims ownership, every node resolves
95
+ # to nil, and every nil resolves to the one player there is.
96
+ #
97
+ # @api private
98
+ # hot-path
99
+ def actions_for(player)
100
+ owner = player || primary
101
+ raise 'no players are registered, so nothing can read input' if owner.nil?
102
+
103
+ owner.actions
104
+ end
105
+
106
+ # Every player's input for this tick, in one call. Each has their own
107
+ # mapper and their own previous-frame state, so one player's press cannot
108
+ # consume another's edge.
109
+ # Then the devices nobody holds are checked for someone starting to use
110
+ # one. Here rather than in a hot-plug hook because a *press* is a per-tick
111
+ # idea, and this is the one place that already has the backend and runs
112
+ # once a tick.
113
+ def poll(backend)
114
+ @list.each { |player| player.poll(backend) }
115
+ admit(backend)
116
+ self
117
+ end
118
+
119
+ # A controller arrived in a slot. Recorded, not seated: this is what makes
120
+ # the slot *scannable*, and someone using it is what seats it.
121
+ #
122
+ # @api private
123
+ def device_connected(slot)
124
+ @connected << slot unless @connected.include?(slot)
125
+ self
126
+ end
127
+
128
+ # A controller left its slot. Whoever was on it loses it; their camera,
129
+ # bindings and UI stay exactly as they were, so plugging back in and
130
+ # pressing confirm resumes rather than restarts.
131
+ #
132
+ # Under `:takeover` there is no second player to become, so the seat falls
133
+ # back to the keyboard rather than the game going dead in someone's hands.
134
+ #
135
+ # @api private
136
+ def device_disconnected(slot)
137
+ @connected.delete(slot)
138
+ device = Controls.gamepad(slot)
139
+ seated = @list.find { |player| player.device == device }
140
+ seated&.device = @on_unassigned_input == :takeover ? Controls::KEYBOARD : nil
141
+ seated
142
+ end
143
+
144
+ # Give `device` to whoever should have it, and say who that was. The join
145
+ # path's own last step, and the one call a game running `:ignore` uses to
146
+ # seat devices on its own terms.
147
+ #
148
+ # Refused while `accepting_joins` is false — which covers taking over as
149
+ # well as joining, since both change who is holding what.
150
+ def seat(device)
151
+ return nil unless @accepting_joins
152
+
153
+ player = candidate
154
+ return nil if player.nil?
155
+
156
+ player.device = device
157
+ on_joined_signal.emit(player)
158
+ player
159
+ end
160
+
161
+ private
162
+
163
+ def admit(backend)
164
+ return if @on_unassigned_input == :ignore || candidate.nil?
165
+
166
+ each_unassigned_device do |device|
167
+ down = confirm_down?(backend, device)
168
+ was_down = @confirm_held[device]
169
+ @confirm_held[device] = down
170
+ seat(device) if down && !was_down
171
+ end
172
+ end
173
+
174
+ def candidate
175
+ return primary if @on_unassigned_input == :takeover
176
+
177
+ @list.find { |player| !player.active? }
178
+ end
179
+
180
+ # hot-path
181
+ def confirm_down?(backend, device)
182
+ player = candidate
183
+ return false if player.nil?
184
+
185
+ buttons = player.input_map[:ui_confirm]&.buttons
186
+ return false if buttons.nil?
187
+
188
+ buttons.any? { |id| backend.down?(id, device: device) }
189
+ end
190
+
191
+ def each_unassigned_device
192
+ @connected.each do |slot|
193
+ device = Controls.gamepad(slot)
194
+ yield device unless assigned?(device)
195
+ end
196
+ return unless @on_unassigned_input == :takeover && !assigned?(Controls::KEYBOARD)
197
+
198
+ yield Controls::KEYBOARD
199
+ end
200
+
201
+ # hot-path
202
+ def assigned?(device) = @list.any? { |player| player.device == device }
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A reuse-don't-allocate pool for many short-lived, homogeneous objects —
6
+ # bullets, particles, transient enemies. Acquired objects come from a free list
7
+ # (or the factory when the list is empty) so steady-state spawning allocates
8
+ # nothing. Pure logic; no graphics.
9
+ #
10
+ # pool = Engine::Pool.new { Bullet.new }
11
+ # b = pool.acquire # recycled or freshly built
12
+ # pool.each { |b| b.update(dt) }
13
+ # pool.reclaim_if(&:dead?) # sweep dead → free list, once per frame
14
+ #
15
+ # The factory builds a blank object; callers re-initialise it after `acquire`.
16
+ class Pool
17
+ def initialize(&factory)
18
+ @factory = factory
19
+ @free = []
20
+ @active = []
21
+ end
22
+
23
+ attr_reader :active
24
+
25
+ def acquire
26
+ obj = @free.empty? ? @factory.call : @free.pop
27
+ @active.push(obj)
28
+ obj
29
+ end
30
+
31
+ def each(&) = @active.each(&)
32
+
33
+ def size = @active.size
34
+
35
+ # No live (acquired, not-yet-reclaimed) objects.
36
+ def empty? = @active.empty?
37
+
38
+ # Deferred removal: sweep the active list once, moving every object for which
39
+ # the block returns true onto the free list. Safe to call after iterating with
40
+ # `each` — never mutate the active list mid-iteration; mark objects dead during
41
+ # the frame and reclaim them here.
42
+ def reclaim_if
43
+ @active.reject! do |obj|
44
+ dead = yield(obj)
45
+ @free.push(obj) if dead
46
+ dead
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # How a game's own resolution is presented on a window that may be a
6
+ # different size. Pure arithmetic: a size, a mode, and the scale and offset
7
+ # they imply. No renderer, no window, nothing to draw with.
8
+ #
9
+ # presentation = Presentation.new(width: 640, height: 480, mode: :integer)
10
+ # presentation.fit(1920, 1080)
11
+ # presentation.scale_x # => 2.0
12
+ # presentation.offset_x # => 320 (1920 - 640*2, halved)
13
+ #
14
+ # ## The problem it exists for
15
+ #
16
+ # A game laid out against fixed numbers — a 500x500 board centred by writing
17
+ # `x: 150` in a 800x600 window — keeps those numbers when the window grows.
18
+ # It does not re-centre; it sits in the top-left corner with the new space
19
+ # piled up to its right. Fullscreen makes that happen to every game that has
20
+ # not thought about it, because fullscreen is the screen's resolution and
21
+ # never the one the game asked for.
22
+ #
23
+ # There are two honest answers, and this is the second one:
24
+ #
25
+ # - **Re-layout.** Read the size you are drawing into and place things
26
+ # relative to its edges. Right for a HUD or a menu, where extra space is
27
+ # extra room. `examples/fullscreen` does this.
28
+ # - **Scale.** Keep the design size, and map it onto whatever the window is.
29
+ # Right for a play area, where extra space should mean a bigger picture of
30
+ # the same thing rather than more world. This class.
31
+ #
32
+ # A game usually wants both at once, which works: the scale applies to
33
+ # everything, and nodes still read their view's size — that size is just the
34
+ # *logical* one now, so it stops changing.
35
+ #
36
+ # ## The modes
37
+ #
38
+ # | | |
39
+ # |---|---|
40
+ # | `:disabled` | No scaling. The logical size *is* the window size, so it changes as the window does. |
41
+ # | `:stretch` | Fill the window exactly, distorting if the aspect ratios differ. |
42
+ # | `:letterbox` | Largest scale that fits, equal on both axes, centred. Bars on two sides. |
43
+ # | `:integer` | The same, rounded down to a whole number. |
44
+ #
45
+ # `:integer` is the one pixel art wants. A sprite drawn at 1.875x with nearest
46
+ # filtering has some source pixels covering two screen pixels and some
47
+ # covering one, and the unevenness crawls as anything moves. A whole-number
48
+ # factor makes every source pixel exactly the same square, which is what
49
+ # "pixel perfect" means.
50
+ #
51
+ # **It costs screen, sometimes a lot of it.** The factor is bounded by the
52
+ # tighter axis, and rounding that down can throw away most of a window. A
53
+ # 640x480 design measured against real screens:
54
+ #
55
+ # | window | `:integer` | `:letterbox` |
56
+ # |---|---|---|
57
+ # | 1920x1080 | 2x, 320px border | 2.25x |
58
+ # | 2560x1440 | 3x, 320px border | 3x |
59
+ # | 1600x900 | **1x**, 480px border | 1.875x |
60
+ # | 1280x720 | **1x**, 320px border | 1.5x |
61
+ #
62
+ # The 16:9 rows are the ones to look at before choosing this mode: 900 and
63
+ # 720 pixels of height are both short of the 960 that 2x needs, so the whole
64
+ # window drops to 1x and the game sits in a small island. A design height
65
+ # that divides common screen heights — 360 rather than 480 — is what avoids
66
+ # that, and it is a decision about the game's art, not about this class.
67
+ #
68
+ # ## What `:integer` does when the window is too small
69
+ #
70
+ # It scales by 1 and lets the content overrun, rather than dropping below a
71
+ # whole number. So the design size is a **minimum**: shrink the window under
72
+ # it and you see part of the game rather than all of it, smaller.
73
+ #
74
+ # That is the same choice SDL makes, and the alternative is worse for the
75
+ # case the mode exists for — falling back to a fractional scale would
76
+ # reintroduce exactly the uneven pixels the game asked to avoid, at the
77
+ # moment nobody is looking. Nothing here enforces a minimum window size;
78
+ # that would need SDL, and this class is pure.
79
+ class Presentation
80
+ MODES = %i[disabled stretch letterbox integer].freeze
81
+
82
+ # The logical size a game draws in. Under `:disabled` these follow the
83
+ # window; under every other mode they are fixed and the window does not
84
+ # matter to anything above this class.
85
+ attr_reader :width, :height
86
+
87
+ attr_reader :mode, :scale_x, :scale_y, :offset_x, :offset_y
88
+
89
+ def initialize(width:, height:, mode: :disabled)
90
+ raise ArgumentError, "unknown scale mode #{mode.inspect} — one of #{MODES.inspect}" \
91
+ unless MODES.include?(mode)
92
+ raise ArgumentError, 'a logical size needs a positive width and height' \
93
+ unless width.positive? && height.positive?
94
+
95
+ @logical_width = width
96
+ @logical_height = height
97
+ @mode = mode
98
+ fit(width, height)
99
+ end
100
+
101
+ # Switches mode and refits against the window it last saw, so a settings
102
+ # screen can change this while the game runs.
103
+ #
104
+ # The window size is remembered for exactly this: a caller changing the
105
+ # mode knows which mode it wants and has no reason to also know how big
106
+ # the window currently is, and asking it to pass one would be asking it to
107
+ # keep a copy of something this object already has.
108
+ def mode=(mode)
109
+ raise ArgumentError, "unknown scale mode #{mode.inspect} — one of #{MODES.inspect}" \
110
+ unless MODES.include?(mode)
111
+
112
+ @mode = mode
113
+ fit(@window_width, @window_height)
114
+ end
115
+
116
+ # Whether anything is being scaled. `:disabled` means the caller can skip
117
+ # pushing a transform at all rather than pushing an identity one.
118
+ #
119
+ # @api private
120
+ def scaled? = @mode != :disabled
121
+
122
+ # Recomputes for a window of this size. Mutates rather than returning a
123
+ # new object: it is read every frame and recomputed only on a resize, so
124
+ # there is no reason to allocate.
125
+ def fit(window_width, window_height)
126
+ @window_width = window_width
127
+ @window_height = window_height
128
+ case @mode
129
+ when :disabled then fit_disabled(window_width, window_height)
130
+ when :stretch then fit_stretch(window_width, window_height)
131
+ else fit_uniform(window_width, window_height)
132
+ end
133
+ self
134
+ end
135
+
136
+ private
137
+
138
+ def fit_disabled(window_width, window_height)
139
+ @width = window_width
140
+ @height = window_height
141
+ @scale_x = @scale_y = 1.0
142
+ @offset_x = @offset_y = 0.0
143
+ end
144
+
145
+ def fit_stretch(window_width, window_height)
146
+ @width = @logical_width
147
+ @height = @logical_height
148
+ @scale_x = window_width.to_f / @logical_width
149
+ @scale_y = window_height.to_f / @logical_height
150
+ @offset_x = @offset_y = 0.0
151
+ end
152
+
153
+ def fit_uniform(window_width, window_height)
154
+ @width = @logical_width
155
+ @height = @logical_height
156
+
157
+ fitting = [window_width.to_f / @logical_width, window_height.to_f / @logical_height].min
158
+ scale = @mode == :integer ? integer_scale(fitting) : fitting
159
+
160
+ @scale_x = @scale_y = scale
161
+ @offset_x = ((window_width - (@logical_width * scale)) / 2.0).floor
162
+ @offset_y = ((window_height - (@logical_height * scale)) / 2.0).floor
163
+ end
164
+
165
+ def integer_scale(fitting)
166
+ floored = fitting.floor
167
+ floored < 1 ? 1.0 : floored.to_f
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Scene
6
+ class SceneStack < Engine::Component
7
+ def initialize
8
+ super
9
+ @stack = []
10
+ @players = nil
11
+ end
12
+
13
+ # See #control: the scenes this holds need the input source, and a
14
+ # component is only handed one player's snapshot.
15
+ def on_attach = @players = node.system(Engine::Players)
16
+
17
+ def push(scene)
18
+ @stack.push(scene)
19
+ scene.parent = node
20
+ scene.scene = scene
21
+ scene.enter_tree
22
+ self
23
+ end
24
+
25
+ def pop
26
+ scene = @stack.pop
27
+ return self unless scene
28
+
29
+ scene.exit_tree
30
+ scene.scene = nil
31
+ scene.parent = nil
32
+ self
33
+ end
34
+
35
+ def replace(scene)
36
+ pop
37
+ push(scene)
38
+ end
39
+
40
+ def current
41
+ @stack.last
42
+ end
43
+
44
+ # Scenes live off the host's child list, so the traversal does not reach
45
+ # them on its own — and what has to reach them is the input *source*,
46
+ # not the snapshot this component was handed.
47
+ #
48
+ # A component receives one player's resolved Actions, which is right for
49
+ # a component: it belongs to exactly one node. A scene is a whole subtree
50
+ # and may contain nodes owned by different players, so handing it a
51
+ # single snapshot would flatten all of them onto whoever owns the host.
52
+ # The registry is pulled from the tree instead, the same way any system
53
+ # is, and passed down so each node in the scene resolves its own.
54
+ #
55
+ # Without a registry — a spec driving a stack with a bare snapshot — the
56
+ # snapshot is passed on, which is exactly what it means: one answer for
57
+ # everyone.
58
+ def control(actions)
59
+ return unless (current_scene = current)
60
+
61
+ current_scene.control(@players || actions)
62
+ end
63
+
64
+ def update(dt)
65
+ return unless (current_scene = current)
66
+
67
+ current_scene.update(dt)
68
+ end
69
+
70
+ # Every scene in the stack, not just the current one — that asymmetry
71
+ # with control/update is what lets a menu pushed on top keep the world
72
+ # visible underneath while freezing it.
73
+ def draw(renderer, view)
74
+ @stack.each do |scene|
75
+ scene.draw(renderer, view)
76
+ end
77
+ end
78
+
79
+ # Scenes live in @stack, off the host's child list, so the host's #sweep_freed
80
+ # can't reach them — forward the sweep into the active scene's subtree.
81
+ def sweep_freed
82
+ current&.sweep_freed
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The rule for the two classes a game subclasses, Node2D and Component: a
6
+ # non-public method whose name starts with `_` is the engine's machinery, and
7
+ # a subclass defining a method of the same name raises where it is defined.
8
+ #
9
+ # class Hop < RGame::Engine::Node2D
10
+ # def _draw_content(renderer, view) = ... # NameError: would replace Node2D#_draw_content
11
+ # def draw_children(renderer, view) = ... # fine: no underscore, meant to be overridden
12
+ # end
13
+ #
14
+ # `private` in Ruby says who may *call* a method, not who may *replace* one: a
15
+ # subclass method of the same name is found first, and the base class quietly
16
+ # stops doing what that method did — for that subclass only, with the failure
17
+ # somewhere else entirely. So the underscore is the opt-in. A private or
18
+ # protected method *without* one is a seam, meant to be overridden with
19
+ # `super`, and nothing guards it.
20
+ #
21
+ # Only the base class's own methods are sealed. A private hook an engine
22
+ # subclass writes for its own subclasses — Components::Mover's `take_step` —
23
+ # is not covered, and the underscore convention does not apply outside these
24
+ # two classes.
25
+ #
26
+ # What it cannot see: a method arriving through `include` or `prepend` of a
27
+ # module, which does not pass through `method_added`.
28
+ module SealedPrivates
29
+ def self.extended(base)
30
+ base.define_singleton_method(:sealed_base) { base }
31
+ base.singleton_class.send(:private, :sealed_base)
32
+ end
33
+
34
+ # Every method a subclass may not define: the base's non-public methods
35
+ # whose names start with `_`.
36
+ def sealed_methods
37
+ base = sealed_base
38
+ (base.private_instance_methods(false) + base.protected_instance_methods(false))
39
+ .select { |name| name.start_with?('_') }
40
+ end
41
+
42
+ def method_added(name)
43
+ super
44
+ base = sealed_base
45
+ return if equal?(base) || !name.start_with?('_')
46
+ return unless base.private_method_defined?(name, false) || base.protected_method_defined?(name, false)
47
+
48
+ raise NameError.new("#{self}##{name} would replace #{base}##{name}, which #{base} calls " \
49
+ "itself, and #{base} would silently stop doing its job for this class. " \
50
+ 'Give the method another name.', name)
51
+ end
52
+ end
53
+ end
54
+ end