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,555 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A node in a scene graph. We currently have only 2D nodes, but the
6
+ # name reflects this should there ever be a 3D space. Nodes are
7
+ # containers for both containers and nodes. They extend the signal
8
+ # DSL to allow for easy signal usage.
9
+ class Node2D
10
+ extend Engine::Signal::DSL
11
+ extend Engine::SealedPrivates
12
+
13
+ # This node's transform **in its parent's space** — where it sits inside
14
+ # whatever contains it, and the only position a node ever sets. `x`, `y`
15
+ # and `angle` are the short spelling of the same three, because that is
16
+ # what reads well where a node moves itself:
17
+ #
18
+ # def on_update(dt) = self.x += @speed * dt
19
+ #
20
+ # Not for drawing, though it is tempting: `on_draw` runs with the renderer
21
+ # already on this node (see #_in_local_space), so drawing at `x` offsets by
22
+ # this node's own position a second time. `Game/DrawInLocalSpace` says so.
23
+ #
24
+ # The ivar carries the longer name so that `@x` does not exist. Reaching
25
+ # for a parent-relative coordinate where a world one was meant is the
26
+ # mistake this whole design is arranged against, and the spelling that used
27
+ # to make it silent is simply not there any more.
28
+ attr_reader :rel_x, :rel_y, :rel_angle
29
+ alias x rel_x
30
+ alias y rel_y
31
+ alias angle rel_angle
32
+
33
+ attr_accessor :width, :height
34
+
35
+ # Height above the ground in pixels, for a top-down view: how far this
36
+ # node's picture is drawn above the spot it stands on. Positive is up.
37
+ #
38
+ # It is **not** part of the transform. `y`, `world_y`, colliders, cameras
39
+ # and children all ignore it, which is what lets a character leave the
40
+ # ground without its feet box leaving too. Components::Sprite and
41
+ # Components::AnimatedSprite draw lifted by it, in the node's local space;
42
+ # Components::Hop is one thing that writes it.
43
+ attr_accessor :elevation
44
+ attr_writer :scene, :context
45
+
46
+ # The other way a node moves, and the only one that does not go through a
47
+ # coordinate writer: its `x`/`y` do not change, but they are now an offset
48
+ # from somewhere else. Set by #add_node and #remove_node — a game does not
49
+ # call this — and it invalidates the subtree for the same reason a move
50
+ # does.
51
+ def parent=(value)
52
+ @parent = value
53
+ _soil
54
+ end
55
+
56
+ # Moving a node invalidates the world transform of the node *and its whole
57
+ # subtree* — every one of them is now somewhere else — but computes none of
58
+ # them. Whoever reads one next pays for that one.
59
+ #
60
+ # `node.x += dx` from a component (Components::Velocity does exactly that)
61
+ # is two writes and so two invalidations, which is why #_soil returns
62
+ # immediately on a subtree that is already stale.
63
+ def rel_x=(value)
64
+ @rel_x = value
65
+ _soil
66
+ end
67
+
68
+ def rel_y=(value)
69
+ @rel_y = value
70
+ _soil
71
+ end
72
+
73
+ def rel_angle=(value)
74
+ @rel_angle = value
75
+ _soil
76
+ end
77
+
78
+ alias x= rel_x=
79
+ alias y= rel_y=
80
+ alias angle= rel_angle=
81
+
82
+ # This node's transform **in world space**, accumulated from its whole
83
+ # ancestry. A node lives in its parent's space and is moved by setting
84
+ # `x`/`y`/`angle`; `world_x=`/`world_y=` below are that same move, worked
85
+ # out from a world position for the code that thinks in one.
86
+ #
87
+ # `world_` rather than `abs_`, because the name should say which space the
88
+ # value is in. `abs_band` and `abs_input_owner` keep theirs deliberately —
89
+ # those are *inherited* from the nearest ancestor that declares one rather
90
+ # than expressed in a space, and a band is not in world coordinates.
91
+ #
92
+ # **Computed on demand and cached**, which is how Godot and Unity do it and
93
+ # why no phase resolves this any more. Moving a node marks it and its whole
94
+ # subtree stale (see #_soil); the next read walks up to the nearest node
95
+ # still current, recomputing on the way back down. Two consequences worth
96
+ # knowing:
97
+ #
98
+ # - It is never stale. There is no "resolved at the top of the phase" value
99
+ # to go out of date, so a node that moved, a node whose *ancestor* moved,
100
+ # and a paused node under a moving ancestor all answer correctly, at any
101
+ # point in any phase.
102
+ # - Nothing is computed for a node nobody asks about. A still frame costs
103
+ # nothing at all, where resolving the tree eagerly cost a full pass per
104
+ # phase whether or not anything had moved.
105
+ # hot-path
106
+ def world_x
107
+ _resolve_transform unless @world_current
108
+ @world_x
109
+ end
110
+
111
+ # hot-path
112
+ def world_y
113
+ _resolve_transform unless @world_current
114
+ @world_y
115
+ end
116
+
117
+ # Place the node at a world coordinate, leaving the other one where it is.
118
+ #
119
+ # For code that decides where a node goes in world space — the edge of the
120
+ # world, a resolved collision — and must still write the node's own, local
121
+ # position. The target is turned back into the parent's frame, so the
122
+ # answer is exact under a rotated ancestor too: there it moves the local
123
+ # position along *both* axes, which is what one world axis looks like from
124
+ # inside a turned frame.
125
+ #
126
+ # A node with no parent is pinned to the origin (see #_resolve_transform),
127
+ # so there is no local position that would put it anywhere else, and this
128
+ # changes nothing.
129
+ # hot-path
130
+ def world_x=(value)
131
+ return if @parent.nil?
132
+
133
+ pa = @parent.world_angle
134
+ if pa.zero?
135
+ self.rel_x = value - @parent.world_x
136
+ else
137
+ _place_in_rotated_parent(value - @parent.world_x, world_y - @parent.world_y, pa)
138
+ end
139
+ end
140
+
141
+ # hot-path
142
+ def world_y=(value)
143
+ return if @parent.nil?
144
+
145
+ pa = @parent.world_angle
146
+ if pa.zero?
147
+ self.rel_y = value - @parent.world_y
148
+ else
149
+ _place_in_rotated_parent(world_x - @parent.world_x, value - @parent.world_y, pa)
150
+ end
151
+ end
152
+
153
+ # hot-path
154
+ def world_angle
155
+ _resolve_transform unless @world_current
156
+ @world_angle
157
+ end
158
+
159
+ attr_reader :children, :components, :parent, :abs_input_owner, :z, :band, :abs_band
160
+
161
+ # Where this node sits among its **siblings**, and nowhere else.
162
+ #
163
+ # The tree is drawn depth-first with siblings in `z` order, so a node's
164
+ # whole subtree is drawn before or after a sibling's whole subtree —
165
+ # never interleaved with it. Clouds over birds over people is three
166
+ # children of one node at `z` 2, 1 and 0, and each of them may be built
167
+ # out of as many parts as it likes without any of those parts escaping.
168
+ #
169
+ # Only the *comparison* matters. `z` is never added to anything and never
170
+ # reaches the renderer, so its magnitude means nothing: 1 and 1_000_000
171
+ # behave identically if they are the only two children, and a negative is
172
+ # ordinary. Equal `z` keeps the order the nodes were added in.
173
+ #
174
+ # This is deliberately unlike the additive relative z it replaces
175
+ # (`abs_z = parent.abs_z + z`), where a node at z 2 with a child at z 5
176
+ # resolved to 7 and overtook a sibling at 4 — some of a node's parts in
177
+ # front of something the node itself was behind. See RGame::Util::Z.
178
+ def z=(value)
179
+ @z = value
180
+ @parent&._children_unsorted!
181
+ end
182
+
183
+ # Which band this node and everything under it draws in — `:world` (the
184
+ # default), `:hud`, `:overlay` or `:debug`. A band beats every `z` in the
185
+ # tree: nothing in `:world` can draw over anything in `:hud`.
186
+ #
187
+ # Inherited like `input_owner`, and normally set by a node that exists to
188
+ # mark one: WorldView is `:world`, PlayerLayer is `:hud`. Setting it
189
+ # directly is the escape hatch — a node inside the world that must draw
190
+ # over the HUD says `band: :overlay` and does, still clipped to whatever
191
+ # its ancestors allowed. That is explicit and named, which is the whole
192
+ # difference from the Integer bases this replaces.
193
+ def band=(value)
194
+ Util::Z.band!(value) unless value.nil?
195
+ @band = value
196
+ end
197
+
198
+ # Whose input drives this node: an RGame::Engine::Player, or nil.
199
+ #
200
+ # Inherited down the tree exactly like the transform. Set it on a node and
201
+ # its whole subtree reads that player, so `ship.input_owner = players[1]`
202
+ # is all it takes for everything under the ship to answer to player two. A
203
+ # node that sets none inherits its parent's, and a tree that sets none
204
+ # anywhere reads the primary player — which is why single player needs no
205
+ # mention of this at all.
206
+ #
207
+ # **Not `player`**, deliberately, and not `controller` either. `@player` is
208
+ # what a game's own code naturally calls its hero node, so an
209
+ # `attr_accessor :player` here would quietly claim that ivar out from
210
+ # under every scene that has one — which it did, and the symptom
211
+ # was the input system being handed a Node2D. `controller` is taken too:
212
+ # a controller is the component that produces movement intent — see
213
+ # Components::PlayerController — which is a different idea entirely. This
214
+ # name says exactly what it decides and collides with neither.
215
+ attr_accessor :input_owner
216
+
217
+ # A paused node skips `control` and `update` — and so does everything
218
+ # under it, because a subtree is only ever reached through its parent.
219
+ # It still **draws**: pausing is about time, not visibility, which is what
220
+ # lets a frozen world sit under a cutscene overlay that keeps animating.
221
+ #
222
+ # world_view.paused = true # the world stops; the overlay above it does not
223
+ #
224
+ # There is no `abs_paused` to go with `abs_input_owner`. Ownership has to
225
+ # be resolved because a node needs to know whose input it reads even when
226
+ # its parent claims nobody; pausing needs no resolution at all, because a
227
+ # paused node simply never descends.
228
+ attr_accessor :paused
229
+
230
+ def initialize(x: 0, y: 0, z: 0, angle: 0, width: 0, height: 0, input_owner: nil,
231
+ band: nil)
232
+ @input_owner = input_owner
233
+ @paused = false
234
+ @rel_x = x
235
+ @rel_y = y
236
+ @z = z
237
+ self.band = band
238
+ @rel_angle = angle
239
+ @width = width
240
+ @height = height
241
+ @elevation = 0
242
+ @world_current = false
243
+ @world_x = @world_y = @world_angle = 0
244
+ @abs_input_owner = @input_owner
245
+ @abs_band = @band || Util::Z::DEFAULT
246
+ @children = []
247
+ @child_seq = 0
248
+ @children_sorted = true
249
+ @components = []
250
+ @component_slots = {}
251
+ @parent = nil
252
+ @scene = nil
253
+ @in_tree = false
254
+ @freed = false
255
+ end
256
+
257
+ def add_node(node)
258
+ @children << node
259
+ node.parent = self
260
+ node._sibling_order = (@child_seq += 1)
261
+ @children_sorted = false
262
+ node.enter_tree if @in_tree
263
+ node
264
+ end
265
+
266
+ def remove_node(node)
267
+ node.exit_tree if @in_tree
268
+ @children.delete(node)
269
+ node.parent = nil
270
+ node
271
+ end
272
+
273
+ # Look a component up by its slot. A Class/Module is matched by ancestry across every
274
+ # component (so a base class finds a subclass instance); a Symbol names a specific slot
275
+ # (see #add_component's `as:`). A class lookup raises when it's ambiguous — two
276
+ # components share that type — so the caller reaches for the name instead. The scan is
277
+ # allocation-free, so it's safe to call on the per-frame path.
278
+ def get_component(key)
279
+ return @component_slots[key] unless key.is_a?(Module)
280
+
281
+ found = nil
282
+ @components.each do |component|
283
+ next unless component.is_a?(key)
284
+ raise ArgumentError, "Multiple components match #{key}; look one up by name" if found
285
+
286
+ found = component
287
+ end
288
+ found
289
+ end
290
+
291
+ # Attach a component in a named slot. The slot defaults to the component's class, so a
292
+ # node still holds at most one component per class — until you give them distinct
293
+ # names: `add_component(Timer.new, as: :spawn)` / `add_component(Timer.new, as: :wave)`.
294
+ # A taken slot raises, so an accidental duplicate is still caught.
295
+ def add_component(component, as: nil)
296
+ slot = as || component.class
297
+ raise ArgumentError, "Node already has a component in slot #{slot.inspect}" if @component_slots.key?(slot)
298
+
299
+ @components << component
300
+ @component_slots[slot] = component
301
+ component.node = self
302
+ component.on_attach if @in_tree
303
+ component
304
+ end
305
+
306
+ def remove_component(key)
307
+ component = get_component(key)
308
+ return nil unless component
309
+
310
+ component.on_detach if @in_tree
311
+ @components.delete(component)
312
+ @component_slots.delete(@component_slots.key(component))
313
+ component.node = nil
314
+ component
315
+ end
316
+
317
+ # The top-most node — a node with no parent is its own root. Global,
318
+ # program-lifetime systems live here as components.
319
+ def root
320
+ @parent ? @parent.root : self
321
+ end
322
+
323
+ # The nearest enclosing scene node, marked as a boundary by SceneStack
324
+ # (#scene= self). Scene-lifetime systems live on it as components.
325
+ def scene
326
+ @scene || @parent&.scene
327
+ end
328
+
329
+ def context
330
+ @context ||= root.context
331
+ end
332
+
333
+ # Nearest system of a class: scene scope first, then the global root.
334
+ def system(klass)
335
+ scene&.get_component(klass) || root.get_component(klass)
336
+ end
337
+
338
+ # `input` is an input *source*, not one player's snapshot: an
339
+ # RGame::Engine::Players registry, or a bare Actions when there is only
340
+ # ever one answer (which is what a spec usually passes).
341
+ #
342
+ # Each node asks the source for the actions of whichever player owns it,
343
+ # and hands its components and its own hook that plain Actions. So a
344
+ # component never learns there is more than one player — `control(actions)`
345
+ # means the same thing it always did — while two subtrees under one tick
346
+ # can read two different controllers.
347
+ #
348
+ # The source is what descends, not the resolved snapshot, because
349
+ # ownership can change further down.
350
+ def control(input)
351
+ return if @paused
352
+
353
+ _resolve_inherited
354
+ actions = input.actions_for(@abs_input_owner)
355
+ @components.each { it.control(actions) }
356
+ on_control(actions)
357
+ _children_in_order.each { it.control(input) }
358
+ end
359
+
360
+ # update game logic and physics (might become two calls with
361
+ # time, but for now works in one step). This runs second in a
362
+ # game tick
363
+ def update(dt)
364
+ return if @paused
365
+
366
+ @components.each { it.update(dt) }
367
+ on_update(dt)
368
+ _children_in_order.each { it.update(dt) }
369
+ end
370
+
371
+ # update visual game state, drawing the node. This runs last in
372
+ # a game tick
373
+ # `view` is the viewport being drawn into: its rectangle, and the camera
374
+ # (if any) it is seen through. Every node gets it, because a node cannot
375
+ # otherwise know where the edges of its own region are — a HUD laying out
376
+ # against the whole window is wrong the moment the window is one player's
377
+ # half of it — and because culling needs it once the world is drawn more
378
+ # than once. Most nodes ignore it and simply draw.
379
+ def draw(renderer, view)
380
+ _resolve_inherited
381
+ _in_local_space(renderer) do
382
+ renderer.layered(@abs_band) { _draw_content(renderer, view) }
383
+ draw_children(renderer, view)
384
+ end
385
+ end
386
+
387
+ def in_tree? = @in_tree
388
+
389
+ # Deferred removal (à la Godot's queue_free): mark this node for removal
390
+ # instead of detaching it now. A node that removes itself or a sibling
391
+ # mid-traversal would mutate the parent's @children while it's being iterated;
392
+ # marking instead and sweeping once after the tick (see #sweep_freed, flushed by
393
+ # the platform loop) keeps removal safe and allocation-free.
394
+ def queue_free = @freed = true
395
+ def freed? = @freed
396
+
397
+ # Detach every node marked by #queue_free, depth-first, from a point outside the
398
+ # update traversal. Components get a hook too, so a container-style component
399
+ # (e.g. SceneStack) can flush the subtree it owns off the normal child list.
400
+ def sweep_freed
401
+ @components.each(&:sweep_freed)
402
+ i = 0
403
+ while i < @children.size
404
+ child = @children[i]
405
+ if child.freed?
406
+ remove_node(child)
407
+ else
408
+ child.sweep_freed
409
+ i += 1
410
+ end
411
+ end
412
+ end
413
+
414
+ # Entered-tree cascade: anchors (root/scene) and sibling systems are now
415
+ # reachable, so components attach (register with systems) before this node's
416
+ # own on_add, and the whole subtree enters depth-first. The engine fires this
417
+ # — the user never calls it — so registration can't be forgotten. Idempotent.
418
+ def enter_tree
419
+ return if @in_tree
420
+
421
+ @in_tree = true
422
+ @freed = false
423
+ @components.each(&:on_attach)
424
+ on_add
425
+ _children_in_order.each(&:enter_tree)
426
+ end
427
+
428
+ # Leaving-tree cascade: mirror of #enter_tree (children first, then this
429
+ # node's on_remove, then component on_detach to release registrations).
430
+ def exit_tree
431
+ return unless @in_tree
432
+
433
+ _children_in_order.each(&:exit_tree)
434
+ on_remove
435
+ @components.each(&:on_detach)
436
+ @in_tree = false
437
+ end
438
+
439
+ def on_control(actions); end
440
+ def on_update(dt); end
441
+ def on_draw(renderer, view); end
442
+ def on_add; end
443
+ def on_remove; end
444
+
445
+ private
446
+
447
+ # hot-path
448
+ # rubocop:disable Style/ExplicitBlockArgument -- an explicit &block would
449
+ # allocate a Proc for every node, every frame, per viewport. `yield` is
450
+ # what keeps this path allocation-free, which culling_spec asserts.
451
+ def _in_local_space(renderer)
452
+ return yield if @parent.nil?
453
+ return yield if @rel_x.zero? && @rel_y.zero? && @rel_angle.zero?
454
+
455
+ renderer.translated(@rel_x, @rel_y) do
456
+ if @rel_angle.zero?
457
+ yield
458
+ else
459
+ renderer.rotated(@rel_angle * 180.0 / Math::PI, 0, 0) { yield }
460
+ end
461
+ end
462
+ end
463
+ # rubocop:enable Style/ExplicitBlockArgument
464
+
465
+ # hot-path
466
+ def _draw_content(renderer, view)
467
+ @components.each { it.draw(renderer, view) }
468
+ on_draw(renderer, view)
469
+ end
470
+
471
+ # hot-path
472
+ def draw_children(renderer, view)
473
+ _children_in_order.each { it.draw(renderer, view) }
474
+ end
475
+
476
+ # hot-path
477
+ def _children_in_order
478
+ _sort_children unless @children_sorted
479
+ @children
480
+ end
481
+
482
+ def _sort_children
483
+ @children_sorted = true
484
+ return if @children.size < 2
485
+
486
+ @children.sort! do |a, b|
487
+ order = a.z <=> b.z
488
+ order.zero? ? a._sibling_order <=> b._sibling_order : order
489
+ end
490
+ end
491
+
492
+ # hot-path
493
+ def _resolve_transform
494
+ @world_current = true
495
+ if @parent.nil?
496
+ @world_x = @world_y = 0
497
+ @world_angle = 0
498
+ return
499
+ end
500
+
501
+ pa = @parent.world_angle
502
+ if pa.zero?
503
+ @world_x = @parent.world_x + @rel_x
504
+ @world_y = @parent.world_y + @rel_y
505
+ else
506
+ cos = Math.cos(pa)
507
+ sin = Math.sin(pa)
508
+ @world_x = @parent.world_x + (@rel_x * cos) - (@rel_y * sin)
509
+ @world_y = @parent.world_y + (@rel_x * sin) + (@rel_y * cos)
510
+ end
511
+ @world_angle = pa + @rel_angle
512
+ end
513
+
514
+ def _place_in_rotated_parent(offset_x, offset_y, pa)
515
+ cos = Math.cos(pa)
516
+ sin = Math.sin(pa)
517
+ self.rel_x = (offset_x * cos) + (offset_y * sin)
518
+ self.rel_y = (offset_y * cos) - (offset_x * sin)
519
+ end
520
+
521
+ protected
522
+
523
+ attr_accessor :_sibling_order
524
+
525
+ def _children_unsorted! = @children_sorted = false
526
+
527
+ # hot-path
528
+ def _soil
529
+ return unless @world_current
530
+
531
+ @world_current = false
532
+ # rubocop:disable Style/SymbolProc -- `&:_soil` would call through
533
+ # Symbol#to_proc, which dispatches publicly and so cannot reach a
534
+ # protected method. An explicit receiver is the only form that works
535
+ # here, and it allocates no more than the symbol would.
536
+ @children.each { it._soil }
537
+ # rubocop:enable Style/SymbolProc
538
+ end
539
+
540
+ private
541
+
542
+ # hot-path
543
+ def _resolve_inherited
544
+ if @parent.nil?
545
+ @abs_input_owner = @input_owner
546
+ @abs_band = @band || Util::Z::DEFAULT
547
+ return
548
+ end
549
+
550
+ @abs_input_owner = @input_owner || @parent.abs_input_owner
551
+ @abs_band = @band || @parent.abs_band
552
+ end
553
+ end
554
+ end
555
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # An ordered polyline of waypoints an entity walks along — a road, a patrol
6
+ # route, a track. Pure data: it holds the waypoints and the precomputed per-segment
7
+ # lengths, so a follower walking it at runtime allocates nothing.
8
+ #
9
+ # Waypoints are stored flat (x0, y0, x1, y1, …) in one contiguous array rather than a
10
+ # pair-object per point, and read back through scalar accessors (`x_at`/`y_at`), so
11
+ # neither construction shape nor traversal leaks per-waypoint Arrays onto the hot path.
12
+ # A follower (see Components::PathFollow) reads segments by index and interpolates
13
+ # itself; Path never returns a coordinate pair.
14
+ class Path
15
+ attr_reader :length, :count
16
+
17
+ # `points` is an Array of [x, y] waypoint pairs in walk order (construction-time, so
18
+ # the pair Arrays are fine here). At least two are required — a path with one point
19
+ # has nowhere to walk.
20
+ def initialize(points)
21
+ raise ArgumentError, 'a Path needs at least two waypoints' if points.length < 2
22
+
23
+ @coords = points.flatten.freeze
24
+ @count = points.length
25
+ @segment_lengths, @length = build_segments
26
+ end
27
+
28
+ # World coordinates of waypoint `i` (0-based), as scalars (no allocation).
29
+ def x_at(index) = @coords[index * 2]
30
+ def y_at(index) = @coords[(index * 2) + 1]
31
+
32
+ # Length of the segment from waypoint `i` to waypoint `i + 1`. There are
33
+ # `count - 1` segments, indexed 0..count-2.
34
+ def segment_length(index) = @segment_lengths[index]
35
+
36
+ # Shortest distance from the point (x, y) to the polyline — e.g. how far a spot is
37
+ # from the road, so a level can keep things from being placed on or beside it.
38
+ # Pure scalar maths, allocation-free.
39
+ def distance_to(x, y)
40
+ min = Float::INFINITY
41
+ (@count - 1).times do |i|
42
+ d = segment_distance(x, y, x_at(i), y_at(i), x_at(i + 1), y_at(i + 1))
43
+ min = d if d < min
44
+ end
45
+ min
46
+ end
47
+
48
+ private
49
+
50
+ def segment_distance(px, py, ax, ay, bx, by)
51
+ abx = bx - ax
52
+ aby = by - ay
53
+ len2 = ((abx * abx) + (aby * aby)).to_f
54
+ t = len2.zero? ? 0.0 : (((px - ax) * abx) + ((py - ay) * aby)) / len2
55
+ t = 0.0 if t < 0.0
56
+ t = 1.0 if t > 1.0
57
+ dx = px - (ax + (t * abx))
58
+ dy = py - (ay + (t * aby))
59
+ Math.sqrt((dx * dx) + (dy * dy))
60
+ end
61
+
62
+ def build_segments
63
+ lengths = []
64
+ total = 0.0
65
+ (@count - 1).times do |i|
66
+ dx = x_at(i + 1) - x_at(i)
67
+ dy = y_at(i + 1) - y_at(i)
68
+ len = Math.sqrt((dx * dx) + (dy * dy))
69
+ lengths << len
70
+ total += len
71
+ end
72
+ [lengths.freeze, total]
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # One person playing: their device, their bindings, their camera, and their
6
+ # own corner of the screen.
7
+ #
8
+ # player = Player.new(id: 0, device: Controls.gamepad(0))
9
+ # player.actions.held?(:fire) # what they are doing this tick
10
+ # player.camera # where they are looking
11
+ # player.ui # their HUD and menus, in screen space
12
+ #
13
+ # ## Why the player owns these and the scene does not
14
+ #
15
+ # A world has one simulation and any number of viewers. Two players share
16
+ # every NPC and every tile, but not a camera, not a set of bindings, and not
17
+ # a menu — so those belong to the viewer. Putting the camera on the scene
18
+ # works exactly until there are two of them, and putting it on a node in the
19
+ # world is worse: it forces the world to know how many times it is drawn.
20
+ #
21
+ # This is the model Unreal calls a LocalPlayer and Unity spreads across
22
+ # PlayerInput plus a camera.
23
+ #
24
+ # ## The action *names* are shared, the bindings are not
25
+ #
26
+ # Every player reads `:fire`. What triggers it is per player: one on Space,
27
+ # one on a pad's X button, and the same InputMap can serve both because a
28
+ # device only answers for its own kind of input. Each player gets their own
29
+ # ActionMapper, so their edge queries are independent — one player's press
30
+ # cannot consume another's.
31
+ class Player
32
+ Controls = RGame::Util::Controls
33
+
34
+ attr_reader :id, :camera, :ui, :mapper
35
+ attr_accessor :name
36
+
37
+ # `device` may be nil, meaning "nobody is driving this player yet" — a
38
+ # seat waiting for a controller. Polling one reads as nothing held rather
39
+ # than raising, so a game can show "Player 2: press a button" without a
40
+ # special case.
41
+ def initialize(id: 0, device: Controls::KEYBOARD, input_map: nil, camera: nil)
42
+ @id = id
43
+ @camera = camera || Camera.new
44
+ @mapper = ActionMapper.new(input_map || InputMap.default, device: device)
45
+ @ui = Node2D.new
46
+ @name = nil
47
+ end
48
+
49
+ def device = @mapper.device
50
+ def active? = !@mapper.device.nil?
51
+
52
+ # Reassigning is how a hot-plug lands: the pad that just arrived in a slot
53
+ # becomes this player's, and their bindings and camera carry on unchanged.
54
+ def device=(value)
55
+ @mapper.device = value
56
+ end
57
+
58
+ # This player's input for the current tick. Set by #poll, and a reused
59
+ # object — hold the Player, never this.
60
+ def actions = @mapper.actions
61
+
62
+ def poll(backend) = @mapper.poll(backend)
63
+
64
+ # What this player's map can answer for. The vocabulary is the game's, so
65
+ # it is the same for every player; the bindings behind it are not.
66
+ def input_map = @mapper.map
67
+ end
68
+ end
69
+ end