rgame 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +228 -0
  3. data/README.md +142 -243
  4. data/docs/api/README.md +141 -90
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -166
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1045 -254
  10. data/docs/api/drawing.md +187 -119
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +165 -45
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +344 -85
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +503 -123
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +197 -64
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +400 -147
  23. data/docs/api/ui.md +927 -0
  24. data/docs/api/values.md +234 -19
  25. data/examples/assets/README.md +322 -0
  26. data/examples/assets/blip.ogg +0 -0
  27. data/examples/assets/glyphs.json +5 -0
  28. data/examples/assets/glyphs.png +0 -0
  29. data/examples/assets/hero.json +12 -0
  30. data/examples/assets/hero.png +0 -0
  31. data/examples/assets/icons.json +13 -0
  32. data/examples/assets/icons.png +0 -0
  33. data/examples/assets/music.ogg +0 -0
  34. data/examples/assets/skills.json +10 -0
  35. data/examples/assets/skills.png +0 -0
  36. data/examples/assets/tileset.png +0 -0
  37. data/examples/assets/tileset.tsx +65 -0
  38. data/examples/assets/town.tmx +26 -0
  39. data/examples/assets/ui.json +11 -0
  40. data/examples/assets/ui.png +0 -0
  41. data/examples/collision/locales/en.yml +8 -0
  42. data/examples/collision/main.rb +316 -0
  43. data/examples/collision_tiles/locales/en.yml +9 -0
  44. data/examples/collision_tiles/main.rb +274 -0
  45. data/examples/fullscreen/locales/en.yml +10 -0
  46. data/examples/fullscreen/main.rb +216 -0
  47. data/examples/game_menu/locales/en.yml +8 -0
  48. data/examples/game_menu/main.rb +170 -0
  49. data/examples/input_glyphs/locales/en.yml +14 -0
  50. data/examples/input_glyphs/main.rb +213 -0
  51. data/examples/jump_topdown/locales/en.yml +9 -0
  52. data/examples/jump_topdown/main.rb +178 -0
  53. data/examples/localization/locales/de.yml +12 -0
  54. data/examples/localization/locales/en.yml +13 -0
  55. data/examples/localization/main.rb +158 -0
  56. data/examples/menu_navigation/locales/en.yml +23 -0
  57. data/examples/menu_navigation/main.rb +365 -0
  58. data/examples/music/locales/en.yml +7 -0
  59. data/examples/music/main.rb +134 -0
  60. data/examples/pathfinding/locales/en.yml +17 -0
  61. data/examples/pathfinding/main.rb +298 -0
  62. data/examples/pooling/locales/en.yml +7 -0
  63. data/examples/pooling/main.rb +259 -0
  64. data/examples/quick_wheel/locales/en.yml +16 -0
  65. data/examples/quick_wheel/main.rb +184 -0
  66. data/examples/radial_menu/locales/en.yml +16 -0
  67. data/examples/radial_menu/main.rb +184 -0
  68. data/examples/save_load/locales/en.yml +11 -0
  69. data/examples/save_load/main.rb +207 -0
  70. data/examples/save_load_ids/locales/en.yml +11 -0
  71. data/examples/save_load_ids/main.rb +322 -0
  72. data/examples/scroll_map/locales/en.yml +4 -0
  73. data/examples/scroll_map/main.rb +140 -0
  74. data/examples/signals/locales/en.yml +6 -0
  75. data/examples/signals/main.rb +278 -0
  76. data/examples/skill_bar/locales/en.yml +14 -0
  77. data/examples/skill_bar/main.rb +159 -0
  78. data/examples/sound/locales/en.yml +6 -0
  79. data/examples/sound/main.rb +122 -0
  80. data/examples/split_screen/locales/en.yml +9 -0
  81. data/examples/split_screen/main.rb +304 -0
  82. data/examples/sprite/locales/en.yml +8 -0
  83. data/examples/sprite/main.rb +180 -0
  84. data/examples/timer/locales/en.yml +12 -0
  85. data/examples/timer/main.rb +273 -0
  86. data/examples/velocity/locales/en.yml +6 -0
  87. data/examples/velocity/main.rb +196 -0
  88. data/examples/walk/locales/en.yml +4 -0
  89. data/examples/walk/main.rb +99 -0
  90. data/exe/rgame +9 -0
  91. data/ext/README.md +6 -5
  92. data/ext/rgame_core/app/app.c +215 -11
  93. data/ext/rgame_core/app/locale.c +67 -0
  94. data/ext/rgame_core/app/locale.h +28 -0
  95. data/ext/rgame_core/audio/audio.c +113 -2
  96. data/ext/rgame_core/example.rb +18 -56
  97. data/ext/rgame_core/extconf.rb +16 -113
  98. data/ext/rgame_core/graphics/canvas.c +45 -4
  99. data/ext/rgame_core/graphics/canvas.h +65 -10
  100. data/ext/rgame_core/graphics/clip.c +22 -13
  101. data/ext/rgame_core/include/rgame/core.h +151 -5
  102. data/ext/rgame_core/input/gamepad.c +57 -3
  103. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  104. data/ext/rgame_core/ruby/core_ext.c +46 -7
  105. data/ext/rgame_core/ruby/core_ext.h +3 -0
  106. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  107. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  108. data/ext/rgame_core/ruby/renderer_ext.c +42 -19
  109. data/ext/rgame_util/color_ext.c +12 -3
  110. data/ext/rgame_util/extconf.rb +2 -20
  111. data/ext/rgame_util/route_search.c +305 -0
  112. data/ext/rgame_util/route_search.h +86 -0
  113. data/ext/rgame_util/route_search_ext.c +150 -0
  114. data/ext/rgame_util/solid_grid.c +58 -0
  115. data/ext/rgame_util/solid_grid.h +49 -0
  116. data/ext/rgame_util/solid_grid_ext.c +161 -0
  117. data/ext/rgame_util/tile_sweep.c +164 -0
  118. data/ext/rgame_util/tile_sweep.h +62 -0
  119. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  120. data/ext/rgame_util/util_ext.c +3 -0
  121. data/ext/rgame_util/util_ext.h +15 -0
  122. data/lib/rgame/boot.rb +0 -10
  123. data/lib/rgame/cli/new_project.rb +139 -0
  124. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  125. data/lib/rgame/cli/templates/README.md.tt +93 -0
  126. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  127. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  128. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  129. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  130. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  131. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  132. data/lib/rgame/cli/templates/rspec.tt +2 -0
  133. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  134. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  135. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  136. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  137. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  138. data/lib/rgame/cli.rb +66 -0
  139. data/lib/rgame/core/app.rb +6 -42
  140. data/lib/rgame/core/asset_manager.rb +13 -31
  141. data/lib/rgame/core/audio.rb +37 -16
  142. data/lib/rgame/core/font.rb +0 -3
  143. data/lib/rgame/core/input.rb +35 -41
  144. data/lib/rgame/core/locale.rb +22 -0
  145. data/lib/rgame/core/nine_slice.rb +0 -21
  146. data/lib/rgame/core/recording.rb +3 -1
  147. data/lib/rgame/core/renderer.rb +75 -84
  148. data/lib/rgame/core/sprite_sheet.rb +0 -3
  149. data/lib/rgame/core/tile_map_renderer.rb +77 -65
  150. data/lib/rgame/core/ui_atlas.rb +28 -13
  151. data/lib/rgame/core.rb +1 -8
  152. data/lib/rgame/engine/actor_blockers.rb +131 -0
  153. data/lib/rgame/engine/animation_set.rb +1 -0
  154. data/lib/rgame/engine/audio_director.rb +36 -6
  155. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  156. data/lib/rgame/engine/camera.rb +55 -10
  157. data/lib/rgame/engine/circle_collider.rb +4 -2
  158. data/lib/rgame/engine/collision_box.rb +26 -1
  159. data/lib/rgame/engine/collision_system.rb +110 -22
  160. data/lib/rgame/engine/component.rb +35 -1
  161. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  162. data/lib/rgame/engine/components/animated_sprite.rb +31 -23
  163. data/lib/rgame/engine/components/box_collider.rb +99 -0
  164. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  165. data/lib/rgame/engine/components/character_body.rb +21 -41
  166. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  167. data/lib/rgame/engine/components/collision_world.rb +159 -31
  168. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  169. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  170. data/lib/rgame/engine/components/hop.rb +76 -0
  171. data/lib/rgame/engine/components/identity.rb +73 -0
  172. data/lib/rgame/engine/components/mover.rb +285 -0
  173. data/lib/rgame/engine/components/navigator.rb +145 -0
  174. data/lib/rgame/engine/components/path_follow.rb +123 -31
  175. data/lib/rgame/engine/components/player_controller.rb +5 -2
  176. data/lib/rgame/engine/components/pool.rb +1 -1
  177. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  178. data/lib/rgame/engine/components/sprite.rb +22 -6
  179. data/lib/rgame/engine/components/targeting.rb +9 -11
  180. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  181. data/lib/rgame/engine/components/tile_world.rb +70 -29
  182. data/lib/rgame/engine/components/timer.rb +1 -1
  183. data/lib/rgame/engine/components/velocity.rb +23 -7
  184. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  185. data/lib/rgame/engine/components/world.rb +133 -0
  186. data/lib/rgame/engine/contact_set.rb +74 -0
  187. data/lib/rgame/engine/culling.rb +45 -0
  188. data/lib/rgame/engine/debug_overlay.rb +23 -18
  189. data/lib/rgame/engine/i18n/plural.rb +45 -0
  190. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  191. data/lib/rgame/engine/i18n/template.rb +59 -0
  192. data/lib/rgame/engine/i18n.rb +276 -51
  193. data/lib/rgame/engine/input/action_mapper.rb +76 -22
  194. data/lib/rgame/engine/input/actions.rb +63 -12
  195. data/lib/rgame/engine/input/input_map.rb +196 -0
  196. data/lib/rgame/engine/layout.rb +82 -0
  197. data/lib/rgame/engine/nav_grid.rb +87 -0
  198. data/lib/rgame/engine/node2d.rb +364 -80
  199. data/lib/rgame/engine/path.rb +4 -6
  200. data/lib/rgame/engine/player.rb +69 -0
  201. data/lib/rgame/engine/player_layer.rb +70 -0
  202. data/lib/rgame/engine/players.rb +205 -0
  203. data/lib/rgame/engine/presentation.rb +171 -0
  204. data/lib/rgame/engine/scene/scene_stack.rb +29 -7
  205. data/lib/rgame/engine/sealed_privates.rb +54 -0
  206. data/lib/rgame/engine/spatial_hash.rb +53 -8
  207. data/lib/rgame/engine/text.rb +194 -0
  208. data/lib/rgame/engine/tile_blockers.rb +63 -0
  209. data/lib/rgame/engine/tile_map.rb +2 -3
  210. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  211. data/lib/rgame/engine/tileset.rb +2 -4
  212. data/lib/rgame/engine/timer.rb +2 -2
  213. data/lib/rgame/engine/ui/button.rb +248 -0
  214. data/lib/rgame/engine/ui/column.rb +20 -0
  215. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  216. data/lib/rgame/engine/ui/menu.rb +290 -0
  217. data/lib/rgame/engine/ui/navigation.rb +57 -0
  218. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  219. data/lib/rgame/engine/ui/option_button.rb +163 -0
  220. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  221. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  222. data/lib/rgame/engine/ui/pointing.rb +146 -0
  223. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  224. data/lib/rgame/engine/ui/ring.rb +55 -0
  225. data/lib/rgame/engine/ui/row.rb +21 -0
  226. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  227. data/lib/rgame/engine/ui/stack.rb +58 -0
  228. data/lib/rgame/engine/ui/stepping.rb +93 -0
  229. data/lib/rgame/engine/ui/text_button.rb +59 -0
  230. data/lib/rgame/engine/view.rb +76 -0
  231. data/lib/rgame/engine/viewports.rb +171 -0
  232. data/lib/rgame/engine/world_view.rb +71 -0
  233. data/lib/rgame/engine.rb +43 -25
  234. data/lib/rgame/game.rb +164 -22
  235. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  236. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  237. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  238. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  239. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  240. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  241. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  242. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  243. data/lib/rgame/rubocop/default.yml +39 -0
  244. data/lib/rgame/rubocop/plugin.rb +45 -0
  245. data/lib/rgame/rubocop.rb +11 -0
  246. data/lib/rgame/util/color.rb +20 -24
  247. data/lib/rgame/util/controls.rb +106 -44
  248. data/lib/rgame/util/route_search.rb +27 -0
  249. data/lib/rgame/util/save_file.rb +107 -0
  250. data/lib/rgame/util/solid_grid.rb +37 -0
  251. data/lib/rgame/util/tensor.rb +0 -9
  252. data/lib/rgame/util/tile_sweep.rb +36 -0
  253. data/lib/rgame/util/z.rb +123 -0
  254. data/lib/rgame/util.rb +5 -3
  255. data/lib/rgame/version.rb +1 -1
  256. data/lib/rgame.rb +0 -15
  257. metadata +176 -20
  258. data/lib/rgame/engine/actor.rb +0 -53
  259. data/lib/rgame/engine/body.rb +0 -49
  260. data/lib/rgame/engine/cached_label.rb +0 -33
  261. data/lib/rgame/engine/camera_view.rb +0 -28
  262. data/lib/rgame/engine/input/player_controller.rb +0 -14
  263. data/lib/rgame/engine/matrix.rb +0 -32
  264. data/lib/rgame/engine/resettable.rb +0 -67
  265. data/lib/rgame/engine/tile_collision.rb +0 -78
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # One viewport being drawn: a rectangle of the screen, and (for a world
6
+ # view) the camera to look through it with.
7
+ #
8
+ # view.x, view.y, view.width, view.height # the screen rect
9
+ # view.camera # nil in screen space
10
+ # view.player # whose view this is, or nil
11
+ # view.visible?(x, y, w, h) # is this worth drawing
12
+ #
13
+ # ## What a view is for
14
+ #
15
+ # `draw(renderer, view)` hands every node the view it is being drawn into,
16
+ # which answers two questions nothing else can. **Where the edges are**: a
17
+ # HUD laying itself out against the whole window is wrong the moment the
18
+ # window is a player's half of one, and until this existed there was nothing
19
+ # else to ask. And **what is worth drawing**: with the world drawn once per
20
+ # player, culling stops being an optimisation and starts being the
21
+ # difference between one frame's work and four.
22
+ #
23
+ # ## It is reused, not rebuilt
24
+ #
25
+ # Viewports owns one of these per viewport and mutates it in place each
26
+ # frame, the way ActionMapper reuses its Actions. Building fresh ones would
27
+ # allocate a handful of objects every frame — invisible by every measure
28
+ # except the frame that stutters. **Hold the player or the viewports, never
29
+ # this**: the object a node was handed last frame is the same one, with
30
+ # different numbers in it.
31
+ class View
32
+ attr_reader :x, :y, :width, :height, :camera, :player
33
+
34
+ def initialize(x: 0, y: 0, width: 0, height: 0, camera: nil, player: nil)
35
+ set(x, y, width, height, camera: camera, player: player)
36
+ end
37
+
38
+ # Mutated in place by Viewports once per frame. Not for game code.
39
+ def set(x, y, width, height, camera: nil, player: nil)
40
+ @x = x
41
+ @y = y
42
+ @width = width
43
+ @height = height
44
+ @camera = camera
45
+ @player = player
46
+ self
47
+ end
48
+
49
+ # Where this view's contents start, in the space its nodes draw in: the
50
+ # camera's offset for a world view, and the origin for a screen-space one,
51
+ # whose nodes draw relative to the view's own corner.
52
+ # hot-path
53
+ def origin_x = @camera ? @camera.x : 0
54
+ # hot-path
55
+ def origin_y = @camera ? @camera.y : 0
56
+
57
+ # Does a rectangle overlap what this view shows? Coordinates are in the
58
+ # space the caller draws in — world coordinates under a camera, view-local
59
+ # ones in screen space — which is the same space `origin_x` is in.
60
+ # hot-path
61
+ def visible?(x, y, width, height)
62
+ left = origin_x
63
+ top = origin_y
64
+ x + width > left && x < left + @width &&
65
+ y + height > top && y < top + @height
66
+ end
67
+
68
+ # The offset to translate by so that content at `origin` lands at this
69
+ # view's corner on screen. The whole of split-screen, in two numbers.
70
+ # hot-path
71
+ def offset_x = @x - origin_x
72
+ # hot-path
73
+ def offset_y = @y - origin_y
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # How the screen is divided, as a root-scoped system.
6
+ #
7
+ # node.system(Viewports).views # one View per active player
8
+ # node.system(Viewports).screen # the whole window, no camera
9
+ # node.system(Viewports).solo!(cam) # collapse to one view — cutscene
10
+ #
11
+ # It holds the mutable half of the question — which mode is current, who is
12
+ # playing, how big the window is — while Layout holds the arithmetic. That
13
+ # split is deliberate: the rect maths is pure and gets specced with no tree
14
+ # and no window, and only the state that genuinely changes lives in a
15
+ # component.
16
+ #
17
+ # It is a Component on the root, so any node reaches it by walking the tree
18
+ # rather than having it threaded through a constructor — the same shape
19
+ # CollisionWorld and Players use.
20
+ #
21
+ # ## Mode changes are deferred
22
+ #
23
+ # `solo!` and `split!` record a request; it is applied in `update`, which
24
+ # runs in the root's component phase. That matters because this is reachable
25
+ # from anywhere, including from a `draw` — and a `draw` now runs once per
26
+ # view, so a mode change made there would fire several times and tear the
27
+ # frame it was made in. Deferring is the same shape `queue_free` uses, and it
28
+ # means a change takes effect on the next tick.
29
+ class Viewports < Component
30
+ # `views` is the list drawn through this frame — one per active player
31
+ # while split, exactly one while solo. Reused, like the Views in it.
32
+ attr_reader :screen, :width, :height, :views
33
+
34
+ def initialize(players, width: 0, height: 0)
35
+ super()
36
+ @players = players
37
+ @width = width
38
+ @height = height
39
+ @solo_camera = nil
40
+ @pending = nil
41
+ @pool = []
42
+ @screen_pool = []
43
+ @views = []
44
+ @screen = View.new
45
+ refresh
46
+ end
47
+
48
+ # The window changed size. Rects are recomputed from it on the next
49
+ # refresh, and every camera reclamps against its new rect — which is why
50
+ # a camera does not carry one.
51
+ def resize(width, height)
52
+ @width = width
53
+ @height = height
54
+ refresh
55
+ end
56
+
57
+ def solo? = !@solo_camera.nil?
58
+
59
+ # The screen-space region belonging to `player`: the same rectangle their
60
+ # world view is drawn into, with **no camera**, so its contents are laid
61
+ # out against their own corner rather than the window's. Their HUD and
62
+ # their menus live here.
63
+ #
64
+ # `nil` when they have no region to draw into, which is two cases and one
65
+ # answer. An **empty seat** has no viewport at all. And while the split is
66
+ # **collapsed**, nobody has a half of the screen to own: a cutscene is
67
+ # everyone looking at one thing, so per-player UI has no place to be, and
68
+ # a game wanting something on screen through it draws in the global
69
+ # overlay band instead.
70
+ #
71
+ # Returning nil rather than an empty rectangle is deliberate: one check at
72
+ # the one caller that needs it beats every caller relying on a zero-sized
73
+ # clip happening to draw nothing.
74
+ def screen_for(player)
75
+ return nil if player.nil? || @solo_camera
76
+
77
+ world = @views.find { |view| view.player.equal?(player) }
78
+ return nil if world.nil?
79
+
80
+ pooled_screen(@players.list.index(player))
81
+ .set(world.x, world.y, world.width, world.height, player: player)
82
+ end
83
+
84
+ # Collapse to a single screen-wide view through `camera`.
85
+ #
86
+ # The camera is **required**: promoting one player's would silently give
87
+ # everyone else their view, and deciding what is on screen is what a
88
+ # cutscene is for. A game points an ordinary Camera wherever it likes —
89
+ # with a CameraFollow on a cutscene actor, or its own component framing
90
+ # every player at once — and hands it here.
91
+ def solo!(camera)
92
+ raise ArgumentError, 'solo! needs a camera to look through' if camera.nil?
93
+
94
+ @pending = camera
95
+ self
96
+ end
97
+
98
+ # Back to one view per player.
99
+ def split! = @pending = :split
100
+
101
+ # Applies a pending mode change, then rebuilds the rects. Runs in the
102
+ # root's component phase, so a change requested during a tick lands on the
103
+ # next one.
104
+ def update(_dt)
105
+ apply_pending
106
+ refresh
107
+ end
108
+
109
+ # Recompute every rect from the current mode and window, and reclamp each
110
+ # camera against the rect it is about to be drawn into.
111
+ #
112
+ # Allocation-free once the pool has grown: Layout yields its rects rather
113
+ # than building them, and the Views are mutated in place.
114
+ #
115
+ # @api private
116
+ def refresh
117
+ @screen.set(0, 0, @width, @height)
118
+ @solo_camera ? refresh_solo : refresh_split
119
+ @views.each { |view| view.camera&.resolve(view.width, view.height) }
120
+ self
121
+ end
122
+
123
+ private
124
+
125
+ def apply_pending
126
+ return if @pending.nil?
127
+
128
+ @solo_camera = @pending == :split ? nil : @pending
129
+ @pending = nil
130
+ end
131
+
132
+ def refresh_solo
133
+ @views.clear
134
+ @views << pooled(0).set(0, 0, @width, @height, camera: @solo_camera)
135
+ end
136
+
137
+ def refresh_split
138
+ @views.clear
139
+ count = @players.active_count
140
+ return if count.zero?
141
+
142
+ index = 0
143
+ Layout.each_rect(count, @width, @height) do |i, x, y, w, h|
144
+ player = active_at(i)
145
+ @views << pooled(index).set(x, y, w, h, camera: player.camera, player: player)
146
+ index += 1
147
+ end
148
+ end
149
+
150
+ def active_at(index)
151
+ found = nil
152
+ i = 0
153
+ @players.each do |player|
154
+ next unless player.active?
155
+
156
+ found = player if i == index
157
+ i += 1
158
+ end
159
+ found
160
+ end
161
+
162
+ def pooled(index)
163
+ @pool[index] ||= View.new
164
+ end
165
+
166
+ def pooled_screen(index)
167
+ @screen_pool[index] ||= View.new
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The node that marks where world space begins.
6
+ #
7
+ # view = scene.add_node(WorldView.new)
8
+ # view.add_node(player) # draws at its own world coordinates
9
+ #
10
+ # Its children live in **world** coordinates and are drawn once per active
11
+ # viewport: for each one, clip to that viewport's rectangle and translate by
12
+ # its camera, then run the same subtree again. Everything outside a
13
+ # WorldView is screen space and draws once.
14
+ #
15
+ # That is the whole of split-screen, and it is why the transform and clip
16
+ # stacks in Core are proper push/pop stacks — a clip always narrows, so a
17
+ # child can never draw outside the region its parent allowed.
18
+ #
19
+ # ## The subtree does not know how many times it is drawn
20
+ #
21
+ # Children draw in their own local space and never see a camera. This node
22
+ # supplies the view — one clip and one translate around the whole subtree —
23
+ # so the same world serves one player or four with nothing below it changing,
24
+ # which is exactly what a camera owned by a node *inside* the world could not
25
+ # do, and why cameras belong to players.
26
+ #
27
+ # ## Only `draw` multiplies, and deliberately
28
+ #
29
+ # `control` and `update` still run **once** for this subtree, however many
30
+ # viewports draw it. That is what keeps simulation cost independent of player
31
+ # count, and it is also a safety property: a world node's `on_update` is
32
+ # where mutation belongs, so per-view updating would move an actor at twice
33
+ # the speed with two players — correctly in single player, silently wrong the
34
+ # moment somebody joins.
35
+ #
36
+ # It does make the standing "draw renders state" rule load-bearing rather
37
+ # than stylistic: a `draw` with a side effect now happens once per player,
38
+ # and an allocation in one costs that many times.
39
+ #
40
+ # A node that genuinely needs per-view work at draw time can read
41
+ # `system(Viewports).views` itself. Anything needing per-view state *over
42
+ # time* — a screen shake, a hit flash — is per-*player* rather than per-view,
43
+ # and belongs on that player's camera or their own subtree, which tick once.
44
+ class WorldView < Node2D
45
+ # World content, which is the default anyway — stated because this is the
46
+ # node that marks where world space begins, and a reader looking for
47
+ # "which band is the world in" should find the answer here.
48
+ def initialize(**)
49
+ super(band: :world, **)
50
+ end
51
+
52
+ # Drawn once per viewport, so this overrides the whole of `draw` rather
53
+ # than just `draw_children`: the node's own visuals belong inside the
54
+ # viewport too, not once outside all of them.
55
+ #
56
+ # `view` is the screen-space view this node was reached with, and is
57
+ # deliberately ignored — a WorldView asks the system which viewports exist
58
+ # rather than being told, so a game can place one anywhere in the tree.
59
+ def draw(renderer, _view = nil)
60
+ viewports = system(Viewports)
61
+ viewports.views.each do |world_view|
62
+ renderer.clipped(world_view.x, world_view.y, world_view.width, world_view.height) do
63
+ renderer.translated(world_view.offset_x, world_view.offset_y) do
64
+ super(renderer, world_view)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
data/lib/rgame/engine.rb CHANGED
@@ -1,61 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # RGame::Engine — the scene graph a game is written against: nodes, components,
4
- # signals, scenes, collision, tile maps, pathing, pooling.
5
- #
6
- # Pure Ruby, and it links nothing. It may hold `RGame::Util` values but may not
7
- # name `RGame::Core` at all, reaching the platform only through objects handed
8
- # to it — a `renderer` passed into `draw`, an audio server behind
9
- # `AudioDirector`. See CLAUDE.md, "The three layers, and who may talk to whom".
10
- #
11
- # That is what lets the whole layer be specified with no window, no GPU and no
12
- # clock: `spec/` drives `update(dt)` directly and can run a simulated hour in
13
- # milliseconds.
14
- #
15
- # Loaded by `require "rgame"` along with Util, since between them they are
16
- # everything that runs without a window. Requirable on its own too — hence the
17
- # `util` require below rather than relying on `rgame.rb` to have got there
18
- # first: `TileMap` holds a `Util::Tensor`, which is exactly the dependency the
19
- # three-layer rule allows and this file therefore has to declare.
20
3
  require_relative 'util'
21
4
 
22
- require_relative 'engine/matrix'
5
+ require_relative 'engine/culling'
23
6
  require_relative 'engine/signal'
7
+ require_relative 'engine/sealed_privates'
24
8
  require_relative 'engine/node2d'
25
9
  require_relative 'engine/component'
26
10
  require_relative 'engine/animation_set'
27
11
  require_relative 'engine/animator'
28
12
  require_relative 'engine/camera'
29
- require_relative 'engine/camera_view'
13
+ require_relative 'engine/layout'
14
+ require_relative 'engine/presentation'
15
+ require_relative 'engine/view'
16
+ require_relative 'engine/world_view'
17
+ require_relative 'engine/player_layer'
18
+ require_relative 'engine/ui/button'
19
+ require_relative 'engine/ui/nine_slice_style'
20
+ require_relative 'engine/ui/shape_style'
21
+ require_relative 'engine/ui/text_button'
22
+ require_relative 'engine/ui/panel_button'
23
+ require_relative 'engine/ui/option_button'
24
+ require_relative 'engine/ui/icon_button'
25
+ require_relative 'engine/ui/stack'
26
+ require_relative 'engine/ui/column'
27
+ require_relative 'engine/ui/row'
28
+ require_relative 'engine/ui/ring'
29
+ require_relative 'engine/ui/navigation'
30
+ require_relative 'engine/ui/stepping'
31
+ require_relative 'engine/ui/pointing'
32
+ require_relative 'engine/ui/menu'
33
+ require_relative 'engine/ui/panel_menu'
34
+ require_relative 'engine/ui/radial_menu'
30
35
  require_relative 'engine/path'
31
36
  require_relative 'engine/timer'
32
37
  require_relative 'engine/tileset'
33
38
  require_relative 'engine/tile_map'
34
- require_relative 'engine/tile_collision'
39
+ require_relative 'engine/tile_blockers'
40
+ require_relative 'engine/nav_grid'
41
+ require_relative 'engine/actor_blockers'
42
+ require_relative 'engine/bounds_blockers'
35
43
  require_relative 'engine/collision_box'
36
44
  require_relative 'engine/collision_system'
37
45
  require_relative 'engine/circle_collider'
38
46
  require_relative 'engine/spatial_hash'
47
+ require_relative 'engine/contact_set'
39
48
  require_relative 'engine/pool'
40
- require_relative 'engine/resettable'
41
49
  require_relative 'engine/audio_bus'
42
50
  require_relative 'engine/audio_director'
43
51
  require_relative 'engine/i18n'
44
- require_relative 'engine/cached_label'
52
+ require_relative 'engine/text'
45
53
  require_relative 'engine/debug_overlay'
46
- require_relative 'engine/body'
47
- require_relative 'engine/actor'
48
54
  require_relative 'engine/input/actions'
55
+ require_relative 'engine/input/input_map'
49
56
  require_relative 'engine/input/action_mapper'
50
- require_relative 'engine/input/player_controller'
57
+ require_relative 'engine/player'
58
+ require_relative 'engine/players'
59
+ require_relative 'engine/viewports'
51
60
  require_relative 'engine/scene/scene_stack'
61
+ require_relative 'engine/components/identity'
62
+ require_relative 'engine/components/mover'
52
63
  require_relative 'engine/components/velocity'
53
64
  require_relative 'engine/components/pool'
54
65
  require_relative 'engine/components/path_follow'
66
+ require_relative 'engine/components/navigator'
55
67
  require_relative 'engine/components/timer'
68
+ require_relative 'engine/components/world'
56
69
  require_relative 'engine/components/screen_wrap'
57
70
  require_relative 'engine/components/despawn_offscreen'
58
71
  require_relative 'engine/components/circle_collider'
72
+ require_relative 'engine/components/box_collider'
73
+ require_relative 'engine/components/feet_collider'
59
74
  require_relative 'engine/components/collision_world'
60
75
  require_relative 'engine/components/targeting'
61
76
  require_relative 'engine/components/sprite'
@@ -64,5 +79,8 @@ require_relative 'engine/components/action_trigger'
64
79
  require_relative 'engine/components/tile_world'
65
80
  require_relative 'engine/components/character_body'
66
81
  require_relative 'engine/components/player_controller'
82
+ require_relative 'engine/components/hop'
67
83
  require_relative 'engine/components/wander_controller'
68
84
  require_relative 'engine/components/animated_sprite'
85
+ require_relative 'engine/components/camera_follow'
86
+ require_relative 'engine/tile_map_layer'
data/lib/rgame/game.rb CHANGED
@@ -43,22 +43,93 @@ module RGame
43
43
  WIDTH = 640
44
44
  HEIGHT = 480
45
45
 
46
- attr_reader :root, :renderer, :action_mapper
46
+ attr_reader :root, :renderer
47
47
 
48
+ # `input_map:` is what physical inputs mean — one entry per action, naming
49
+ # ids from RGame::Util::Controls. It is merged over the universal UI set, so
50
+ # `ui_confirm` and friends work whether or not a game declares them, and it
51
+ # defaults to RGame::Engine::InputMap::DEFAULT_ACTIONS, so a game wanting
52
+ # eight-way movement and a fire button declares nothing.
53
+ #
54
+ # `device:` is which device drives it — the keyboard, or
55
+ # `Controls.gamepad(slot)` for a controller.
56
+ #
57
+ # `input:` overrides the input backend. It exists so a harness can drive a
58
+ # game from a script instead of from hardware — see
59
+ # tools/drive_test_project.rb, and CLAUDE.md's "The test projects are the
60
+ # acceptance test for wiring", which is why driving one has to be possible
61
+ # at all. A game passes nothing and gets the real thing.
62
+ # `players:` is how many seats the game has, and therefore the most people
63
+ # who can play it. Player 0 starts on `device:`; the rest start empty and
64
+ # are filled when someone uses a controller — see RGame::Engine::Players for
65
+ # why that is a press rather than a plug.
66
+ # `fullscreen:` opens the window fullscreen rather than switching after it is
67
+ # already up, so a game that always runs fullscreen never flashes a windowed
68
+ # frame at startup. `width` and `height` still matter: they are the size the
69
+ # window takes when it leaves fullscreen, whether or not this game offers a
70
+ # way to do that.
71
+ #
72
+ # `scale_mode:` decides what `width` and `height` *mean*, and the default
73
+ # answer is "the resolution the game is designed in". Under `:letterbox` the
74
+ # view a node draws into is always that size, whatever the window is doing,
75
+ # so a layout written against fixed numbers keeps working at any window size
76
+ # and in fullscreen — which is what almost every game wants and what nothing
77
+ # in the engine can supply for it afterwards.
78
+ #
79
+ # `:disabled` is the opt-out, and it is the right answer for something that
80
+ # should genuinely use whatever space it is given: a tool, a HUD-shaped
81
+ # program, an editor. It hands the window straight through as the view, and
82
+ # skips the clip, translate and scale that every other mode pushes. See
83
+ # RGame::Engine::Presentation.
84
+ #
85
+ # `locales:` is the directory the translation tables are in, relative to
86
+ # `media_root` unless absolute. Every `.yml` under it is loaded through the
87
+ # asset manager here, in sorted order, and the language is chosen from the
88
+ # player's OS preferences — so a game writes no i18n setup, and a language
89
+ # the player saved is set after `new` and before `start`. A directory that
90
+ # does not exist loads nothing, and every key shows as itself.
48
91
  def initialize(root:, width: WIDTH, height: HEIGHT, caption: 'RGame',
49
- media_root: 'media', action_map: {})
50
- super(width: width, height: height, caption: caption, media_root: media_root)
92
+ media_root: 'media', input_map: nil, device: Controls::KEYBOARD,
93
+ players: 1, input: nil, fullscreen: false, scale_mode: :letterbox,
94
+ locales: 'locales')
95
+ super(width: width, height: height, caption: caption, media_root: media_root,
96
+ fullscreen: fullscreen)
51
97
 
52
98
  @root = root
53
99
  @renderer = RGame::Core::Renderer.new(self)
54
- @input = RGame::Core::Input.new(self)
55
- @action_mapper = RGame::Engine::ActionMapper.new(action_map)
56
- @overlay = RGame::Engine::DebugOverlay.new # always wired up; F1 reveals it
57
- @dirty = true # draw the first frame
100
+ @input = input || RGame::Core::Input.new(self)
101
+ @players = RGame::Engine::Players.new(
102
+ Array.new(players) do |id|
103
+ RGame::Engine::Player.new(id: id, device: id.zero? ? device : nil,
104
+ input_map: input_map)
105
+ end
106
+ )
107
+ @presentation = RGame::Engine::Presentation.new(width: width, height: height,
108
+ mode: scale_mode)
109
+ @presentation.fit(self.width, self.height)
110
+ @viewports = RGame::Engine::Viewports.new(@players, width: @presentation.width,
111
+ height: @presentation.height)
112
+ @debug = RGame::Engine::DebugOverlay.new
113
+ @dirty = true
58
114
 
59
115
  install_asset_loaders
116
+ load_locales(locales)
60
117
  end
61
118
 
119
+ # The player registry, also reachable from any node as
120
+ # `node.system(RGame::Engine::Players)` — which is how a scene gets at a
121
+ # camera to follow, without anything being threaded into its constructor.
122
+ #
123
+ # One player exists from the start, so a single-player game never mentions
124
+ # players at all: it is `players.primary` that an unowned node reads from,
125
+ # and `players.primary.camera` that a scene points at its hero.
126
+ attr_reader :players
127
+
128
+ # How the screen is divided. Reachable as `node.system(RGame::Engine::Viewports)`,
129
+ # which is how a cutscene deep in a scene collapses the split without
130
+ # anything being handed to it.
131
+ attr_reader :viewports
132
+
62
133
  # Brings the tree live and runs until the window closes.
63
134
  #
64
135
  # The root gets this object as its `context`, which is how a node deep in
@@ -66,8 +137,13 @@ module RGame
66
137
  # anything being threaded through its constructor.
67
138
  def start
68
139
  @root.context = self
69
- @root.enter_tree # components attach, then on_add
140
+ @root.add_component(@players)
141
+ @root.add_component(@viewports)
142
+ @audio_director = Engine::AudioDirector.new(audio).subscribe
143
+ @root.enter_tree
70
144
  run
145
+ ensure
146
+ @audio_director&.unsubscribe
71
147
  end
72
148
 
73
149
  # One fixed simulation tick. `dt` is always the engine's fixed step, so the
@@ -86,37 +162,103 @@ module RGame
86
162
  # read identical state, and the edge lands on the first of them — one press,
87
163
  # one `pressed?`, which is what a caller means.
88
164
  def update(dt)
89
- @root.control(@action_mapper.poll(@input))
165
+ @players.poll(@input)
166
+ @root.control(@players)
90
167
  @root.update(dt)
91
- @root.sweep_freed # flush queue_free'd nodes outside the update traversal
168
+ @root.sweep_freed
92
169
  @dirty = true
93
170
  end
94
171
 
95
172
  # Only the simulation advancing makes the frame stale. While the overlay is
96
173
  # up, redraw anyway, so its numbers stay live even when nothing is moving.
97
- def needs_redraw? = @dirty || @overlay.visible?
174
+ def needs_redraw? = @dirty || @debug.visible?
98
175
 
176
+ # The tree is drawn once, with the whole window as its view. Screen-space
177
+ # content — a HUD, a menu, a title card — lands there and is drawn exactly
178
+ # once, as it always was.
179
+ #
180
+ # **World content multiplies inside the tree, not here.** An
181
+ # RGame::Engine::WorldView draws its subtree once per viewport, clipping and
182
+ # translating for each, so where the world begins is the game's choice
183
+ # rather than a shape the platform imposes. That is also what keeps
184
+ # `node.root` meaning the game's own root: nothing is inserted above it.
99
185
  def draw
100
- @root.draw(@renderer)
101
- @overlay.draw(@renderer, width, height, fps) # last, so it layers on top
186
+ @viewports.refresh
187
+ if @presentation.scaled?
188
+ presented { draw_tree }
189
+ else
190
+ draw_tree
191
+ end
102
192
  @dirty = false
103
193
  end
104
194
 
195
+ # How `width` and `height` are mapped onto the window: `:disabled`,
196
+ # `:stretch`, `:letterbox` or `:integer`. See RGame::Engine::Presentation.
197
+ def scale_mode = @presentation.mode
198
+
199
+ # Switchable while the game runs, for a settings screen.
200
+ #
201
+ # The viewports are resized as well as the presentation refitted, because
202
+ # the two modes disagree about what the logical size *is*: leaving `:integer`
203
+ # for `:disabled` turns a fixed 640x480 back into the window's own size, and
204
+ # a viewport still holding the old one would lay out into a corner.
205
+ def scale_mode=(mode)
206
+ @presentation.mode = mode
207
+ @viewports.resize(@presentation.width, @presentation.height)
208
+ end
209
+
210
+ # The window changed size, so the presentation is refitted and every rect and
211
+ # camera clamp follows. Under a scaling mode the logical size does not move,
212
+ # so the viewports are handed the same numbers again and only the transform
213
+ # in `draw` changes — which is the whole point of the mode.
214
+ def resize(width, height)
215
+ @presentation.fit(width, height)
216
+ @viewports.resize(@presentation.width, @presentation.height)
217
+ end
218
+
219
+ # Hot-plug is bookkeeping, not seating: a controller arriving becomes a
220
+ # device the registry watches, and it is someone *using* it that gives it to
221
+ # a player. Leaving takes it back off whoever had it.
222
+ def gamepad_connected(slot) = @players.device_connected(slot)
223
+ def gamepad_disconnected(slot) = @players.device_disconnected(slot)
224
+
225
+ # The two development keys, both function keys on purpose: **Escape is
226
+ # deliberately not bound here**, because it is the natural `cancel`/`back`
227
+ # button for a game's own menus, and a debug shortcut has no business taking
228
+ # the one key every player expects to close a dialog. F1 shows the overlay,
229
+ # F2 quits.
105
230
  def button_down(id)
106
- close if id == Controls::KEY_ESCAPE
107
- @overlay.toggle if id == Controls::KEY_F1
231
+ close if id == Controls::KEY_F2
232
+ @debug.toggle if id == Controls::KEY_F1
108
233
  end
109
234
 
110
235
  private
111
236
 
112
- # Teaches the asset manager the types Core cannot build for itself.
113
- #
114
- # `:tilemap` is the only one so far, and it is the reason this method
115
- # exists: `RGame::Engine::TileMap` reads the `.tmx`, `Image#tiles` slices the
116
- # tileset through the cache — so two maps sharing a tileset share one
117
- # upload — and `Core::TileMapRenderer` draws the result. Three objects, and
118
- # neither of the outer two may name the other.
237
+ def draw_tree
238
+ @root.draw(@renderer, @viewports.screen)
239
+ @debug.draw(@renderer, @viewports.screen, fps)
240
+ end
241
+
242
+ def presented(&)
243
+ @renderer.clipped(@presentation.offset_x, @presentation.offset_y,
244
+ (@presentation.width * @presentation.scale_x).round,
245
+ (@presentation.height * @presentation.scale_y).round) do
246
+ @renderer.translated(@presentation.offset_x, @presentation.offset_y) do
247
+ @renderer.scaled(@presentation.scale_x, @presentation.scale_y, &)
248
+ end
249
+ end
250
+ end
251
+
252
+ def load_locales(directory)
253
+ assets.glob(File.join(directory, '**', '*.yml')).each { |path| assets.locale(path) }
254
+ RGame::Engine::I18n.locale = RGame::Engine::I18n.choose(RGame::Core.preferred_locales)
255
+ end
256
+
119
257
  def install_asset_loaders
258
+ assets.add_loader(:locale) do |path|
259
+ RGame::Engine::I18n.load(File.read(path), source: path)
260
+ end
261
+
120
262
  game = self
121
263
  assets.add_loader(:tilemap) do |path|
122
264
  map, image_path = RGame::Engine::TileMap.load(path)