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
@@ -3,6 +3,7 @@
3
3
  require 'rgame/core_ext'
4
4
  require_relative 'font'
5
5
  require_relative '../util/color'
6
+ require_relative '../util/z'
6
7
 
7
8
  module RGame
8
9
  module Core
@@ -25,14 +26,27 @@ module RGame
25
26
  # raises. That is on purpose: the frame is not open, so the vertices would
26
27
  # be silently discarded, and an invisible failure is the worst kind.
27
28
  #
28
- # Nothing is drawn immediately. Calls accumulate and are z-sorted when the
29
- # frame closes, so `z:` decides what ends up on top not call order. Equal
30
- # z keeps call order, which is what stops same-layer sprites flickering
31
- # between frames.
29
+ # Nothing is drawn immediately. Calls accumulate and are sorted when the
30
+ # frame closes, so what ends up on top is decided by z rather than by call
31
+ # order. Equal z keeps call order, which is what stops same-layer sprites
32
+ # flickering between frames.
33
+ #
34
+ # ## `z:` is an offset inside the current layer, not a global number
35
+ #
36
+ # A `z:` is added to whatever `#layered` most recently pushed, and must be
37
+ # within RGame::Util::Z::Z_MIN..Z_MAX — one *node's* worth of room. It
38
+ # orders that node's own drawing (its panel under its label, a shadow under
39
+ # its sprite) and can reach nothing else. Which node comes before which is
40
+ # the scene graph's business, resolved by the traversal and handed here as a
41
+ # layer; see RGame::Util::Z.
42
+ #
43
+ # A caller outside any `#layered` block draws at the base layer 0, which is
44
+ # what a spec or a bare script gets.
32
45
  #
33
46
  # The C half of this class (ext/rgame_core/ruby/renderer_ext.c) has the `draw_*`
34
- # and `push_*` primitives; everything here is the comfortable surface over
35
- # them.
47
+ # and `push_*` primitives, registered private; everything here is the
48
+ # surface over them, so a draw cannot skip the z offset or the colour
49
+ # coercion, and a push cannot go without its pop.
36
50
  #
37
51
  # ## Colours
38
52
  #
@@ -43,22 +57,16 @@ module RGame
43
57
  class Renderer
44
58
  Color = RGame::Util::Color
45
59
 
46
- # Shapes default above sprites, so a debug box or a health bar drawn
47
- # without a `z:` lands on top of the scene rather than under it. The
48
- # values match the layer this replaces.
60
+ Z = RGame::Util::Z
61
+
49
62
  SHAPE_Z = 50
50
63
  IMAGE_Z = 0
51
64
 
52
- # Enough segments that a circle reads as round at the sizes a 2D game
53
- # draws one, and few enough that a screenful of them is still one batch.
54
65
  CIRCLE_SEGMENTS = 64
55
66
 
56
- # Text defaults above sprites but below shapes, and the size matches what
57
- # the layer this replaces used, so ported UI lays out unchanged.
58
67
  TEXT_Z = 10
59
68
  FONT_SIZE = 18
60
69
 
61
- # Translucent red, for #debug_box.
62
70
  DEBUG_BOX_COLOR = Color.new(255, 40, 40, 120)
63
71
 
64
72
  # `assets:` is where a draw id that is not registered gets resolved from,
@@ -76,32 +84,16 @@ module RGame
76
84
 
77
85
  attr_accessor :assets
78
86
 
79
- # --- draw-by-id ---------------------------------------------------------
80
- #
81
- # Game logic names assets, it does not hold them: the engine layer may
82
- # hold `RGame::Util` values but no `RGame::Core` handle at all, so a
83
- # Symbol or a path is the only thing a node *can* carry. Resolving it is
84
- # this side of the boundary's job.
85
- #
86
- # An id is normally a **root-relative path**, resolved through the asset
87
- # manager and then remembered, so a per-frame draw neither re-resolves nor
88
- # allocates a lookup key:
89
- #
90
- # renderer.sprite('example 09/player.json', row, col, x, y)
91
- #
92
- # `register_*` pre-binds an id to a chosen object, for the two things a
93
- # path cannot name: an id that is not a file (nine-slice ids are *atlas
94
- # element* names) and an object the game assembled itself.
95
-
96
87
  def register_image(id, image) = registry(:image)[id] = image
97
88
  def register_sheet(id, sheet) = registry(:sheet)[id] = sheet
98
89
  def register_tilemap(id, tilemap) = registry(:tilemap)[id] = tilemap
99
90
  def register_nine_slice(id, nine_slice) = registry(:nine_slice)[id] = nine_slice
100
91
 
101
- # Registers every element of a UiAtlas under its own name, since those
102
- # names are what a widget asks for.
92
+ # Registers every element of a UiAtlas its nine-slices and its images —
93
+ # under its own name, since those names are what a widget asks for.
103
94
  def register_ui_atlas(atlas)
104
95
  atlas.nine_slices.each { |id, nine_slice| register_nine_slice(id, nine_slice) }
96
+ atlas.images.each { |id, image| register_image(id, image) }
105
97
  self
106
98
  end
107
99
 
@@ -117,49 +109,54 @@ module RGame
117
109
  lookup(:nine_slice, id).draw(self, x, y, width, height, z: z, color: tint)
118
110
  end
119
111
 
120
- # A tile map's below-the-actor band (ground and same-level detail).
112
+ # One layer of a tile map the layer a Tiled `.tmx` lists at `layer`,
113
+ # counting from the bottom.
114
+ #
115
+ # One layer rather than the whole map, because a scene draws its actors
116
+ # between two of them: trunks under, canopies over. Which is which is the
117
+ # scene tree's business — `RGame::Engine::TileMapLayer` mounts a node per
118
+ # layer — so this takes no `z:`.
119
+ #
120
+ # **Drawn in world coordinates**: a tile at column 3 lands at
121
+ # `3 * tile_width`, and getting it onto the screen is the caller's
122
+ # transform, like every other drawing method here. The rectangle is a
123
+ # **cull rect** — which part of the world is worth drawing — so a camera
124
+ # supplies it but does not move the result. That is what lets one map be
125
+ # drawn through several cameras in a frame.
121
126
  #
122
127
  # `elapsed` is the seconds its animated tiles have been running for, and
123
128
  # is an argument rather than a clock read on purpose — see CLAUDE.md,
124
129
  # "`draw` renders state; time enters through `update`". A scene
125
130
  # accumulates it in `update`, which is what makes pausing work.
126
- def tilemap(id, camera_x, camera_y, viewport_width, viewport_height, elapsed: 0.0)
131
+ def tilemap(id, layer, cull_x, cull_y, cull_width, cull_height, elapsed: 0.0)
127
132
  lookup(:tilemap, id)
128
- .draw(self, camera_x, camera_y, viewport_width, viewport_height, elapsed: elapsed)
129
- end
130
-
131
- # Its above-the-actor band (canopies, roofs), at a `z` the scene picks so
132
- # it lands over the actors.
133
- def tilemap_overlay(id, camera_x, camera_y, viewport_width, viewport_height,
134
- z:, elapsed: 0.0)
135
- lookup(:tilemap, id).draw_overlay(self, camera_x, camera_y, viewport_width,
136
- viewport_height, z: z, elapsed: elapsed)
133
+ .draw_layer(self, layer, cull_x, cull_y, cull_width, cull_height, elapsed: elapsed)
137
134
  end
138
135
 
139
136
  # A filled axis-aligned rectangle.
140
137
  def rect(x, y, width, height, z: SHAPE_Z, color: nil)
141
- draw_rect(x, y, width, height, z, packed(color))
138
+ draw_rect(x, y, width, height, Z.offset(z), packed(color))
142
139
  end
143
140
 
144
141
  # Four arbitrary points, in loop order: listing them in Z order gives an
145
142
  # hourglass rather than a shape.
146
143
  def quad(x1, y1, x2, y2, x3, y3, x4, y4, z: SHAPE_Z, color: nil)
147
- draw_quad(x1, y1, x2, y2, x3, y3, x4, y4, z, packed(color))
144
+ draw_quad(x1, y1, x2, y2, x3, y3, x4, y4, Z.offset(z), packed(color))
148
145
  end
149
146
 
150
147
  def triangle(x1, y1, x2, y2, x3, y3, z: SHAPE_Z, color: nil)
151
- draw_triangle(x1, y1, x2, y2, x3, y3, z, packed(color))
148
+ draw_triangle(x1, y1, x2, y2, x3, y3, Z.offset(z), packed(color))
152
149
  end
153
150
 
154
151
  # A line of real thickness — drawn as a quad, because GL's own line width
155
152
  # is a suggestion drivers may ignore above one pixel.
156
153
  def line(x1, y1, x2, y2, thickness: 1.0, z: SHAPE_Z, color: nil)
157
- draw_line(x1, y1, x2, y2, thickness, z, packed(color))
154
+ draw_line(x1, y1, x2, y2, thickness, Z.offset(z), packed(color))
158
155
  end
159
156
 
160
157
  # A filled circle, as a fan of triangles around its centre.
161
158
  def circle(cx, cy, radius, z: SHAPE_Z, color: nil, segments: CIRCLE_SEGMENTS)
162
- draw_circle(cx, cy, radius, segments, z, packed(color))
159
+ draw_circle(cx, cy, radius, segments, Z.offset(z), packed(color))
163
160
  end
164
161
 
165
162
  # An image centred on (cx, cy), rotated `angle` degrees clockwise about
@@ -168,7 +165,7 @@ module RGame
168
165
  #
169
166
  # Takes an `Image` or an id for one — see #resolve_image.
170
167
  def image(image, cx, cy, angle: 0, scale: 1, z: IMAGE_Z, color: nil)
171
- draw_image_rot(resolve_image(image), cx, cy, angle, scale, z, packed(color))
168
+ draw_image_rot(resolve_image(image), cx, cy, angle, scale, Z.offset(z), packed(color))
172
169
  end
173
170
 
174
171
  # An image with its top-left at (x, y), scaled independently per axis —
@@ -183,14 +180,14 @@ module RGame
183
180
  #
184
181
  # A zero scale draws nothing.
185
182
  def image_at(image, x, y, scale_x: 1, scale_y: 1, z: IMAGE_Z, color: nil)
186
- draw_image_scaled(resolve_image(image), x, y, scale_x, scale_y, z, packed(color))
183
+ draw_image_scaled(resolve_image(image), x, y, scale_x, scale_y, Z.offset(z), packed(color))
187
184
  end
188
185
 
189
186
  # An image with its top-left at (x, y), at its natural size — a
190
187
  # full-screen backdrop by default. `image_at` with both scales at 1, kept
191
188
  # because "put this at the origin" is worth a name of its own.
192
189
  def background(image, x = 0, y = 0, z: IMAGE_Z, color: nil)
193
- draw_image(resolve_image(image), x, y, z, packed(color))
190
+ draw_image(resolve_image(image), x, y, Z.offset(z), packed(color))
194
191
  end
195
192
 
196
193
  # Everything drawn in the block is rotated `angle` degrees about
@@ -206,9 +203,6 @@ module RGame
206
203
  begin
207
204
  yield
208
205
  ensure
209
- # An ensure, not a plain pop: a scene that raises mid-draw would
210
- # otherwise leave the stack deeper than it found it, and every
211
- # later frame would draw askew for a reason nothing points at.
212
206
  pop
213
207
  end
214
208
  end
@@ -239,6 +233,31 @@ module RGame
239
233
  end
240
234
  end
241
235
 
236
+ # Everything drawn in the block draws in its own layer: a fresh slot in
237
+ # `band`, which every `z:` inside is then an offset from.
238
+ #
239
+ # renderer.layered(:hud) { renderer.text(score, 12, 10) }
240
+ #
241
+ # Two things fall out of it, and they are the whole of draw order.
242
+ # **Slots are handed out in the order they are asked for**, so nesting
243
+ # this the way a scene graph is nested makes draw order tree order — a
244
+ # node drawn later is in front, and its whole subtree with it. And **a
245
+ # band is a hard partition**: every slot in `:hud` is above every slot in
246
+ # `:world`, whatever either drew, because they are 2**40 apart and a `z:`
247
+ # cannot reach out of one slot. See RGame::Util::Z.
248
+ #
249
+ # A caller that never uses this draws at layer 0 and gets exactly the z it
250
+ # passes, which is what a spec or a one-off script wants.
251
+ def layered(band = Z::DEFAULT)
252
+ index = Z.index(band)
253
+ push_layer(Z.slot_base(index, next_layer_slot(index)))
254
+ begin
255
+ yield
256
+ ensure
257
+ pop
258
+ end
259
+ end
260
+
242
261
  # Everything drawn in the block is confined to the given rectangle.
243
262
  #
244
263
  # A clip only ever narrows: nesting one inside another intersects them, so
@@ -271,7 +290,7 @@ module RGame
271
290
  # Newlines are not special. A caller wanting two lines draws two, stepping
272
291
  # by #text_height.
273
292
  def text(string, x, y, z: TEXT_Z, color: nil, font: nil)
274
- draw_text(font || self.font, string, x, y, z, packed(color))
293
+ draw_text(font || self.font, string, x, y, Z.offset(z), packed(color))
275
294
  end
276
295
 
277
296
  # What #text would occupy, for centring and layout. Unlike the drawing
@@ -299,9 +318,6 @@ module RGame
299
318
  yield
300
319
  completed = true
301
320
  ensure
302
- # A block that raised leaves a half-built recording open, and the
303
- # next frame would keep drawing into it. Unwinding here means the
304
- # exception is the only thing the caller has to deal with.
305
321
  cancel_record unless completed
306
322
  end
307
323
  end_record
@@ -315,44 +331,19 @@ module RGame
315
331
 
316
332
  private
317
333
 
318
- # Colours cross into C as a packed integer. `Color.coerce` is the single
319
- # place nil/array/Color are turned into one, so no drawing method has its
320
- # own idea of what a colour is.
321
334
  def packed(color) = Color.coerce(color).packed
322
335
 
323
- # An `Image` is already what it is; anything else is an id for one.
324
- #
325
- # The two callers this serves cannot be reconciled any other way. Core's
326
- # own drawing classes — SpriteSheet, NineSlice — hold real images and pass
327
- # them; the engine layer is forbidden from holding one and can only pass
328
- # an id. Dispatching on the type keeps both spellings of `#image` and
329
- # `#background` working without two parallel method names for the same
330
- # picture.
331
336
  def resolve_image(image) = image.is_a?(Image) ? image : lookup(:image, image)
332
337
 
333
338
  def registry(type) = (@registries ||= {})[type] ||= {}
334
339
 
335
- # Prefer an explicit registration, otherwise ask the asset manager, then
336
- # remember the answer — so a per-frame draw neither re-resolves nor
337
- # allocates a lookup key.
338
340
  def lookup(type, id)
339
- # `nil` is never an id, and reporting it as one ("no image registered
340
- # for nil") describes a typo when the actual bug is an asset that
341
- # resolved to nothing. The two want different fixes.
342
341
  raise TypeError, "no implicit conversion of nil into #{type}" if id.nil?
343
342
 
344
343
  table = registry(type)
345
344
  table.fetch(id) { table[id] = resolve_asset(type, id) }
346
345
  end
347
346
 
348
- # Only a String is offered to the asset manager, because only a String can
349
- # be a path. A Symbol id is a name a game chose, so a missing one is the
350
- # KeyError below — naming the id — rather than whatever the manager makes
351
- # of being handed a Symbol where it wanted a filename.
352
- #
353
- # `respond_to?` for the same reason one step along: the manager grows an
354
- # accessor per asset type, and asking for one it does not have yet should
355
- # be this error rather than a NoMethodError from inside it.
356
347
  def resolve_asset(type, id)
357
348
  resolved = @assets.public_send(type, id) if id.is_a?(String) && @assets.respond_to?(type)
358
349
  resolved || raise(KeyError, "no #{type} registered for #{id.inspect} " \
@@ -97,9 +97,6 @@ module RGame
97
97
  end
98
98
  end
99
99
 
100
- # A typo'd or missing key would otherwise surface as a NoMethodError on nil
101
- # from inside the slicing arithmetic, which says nothing about the file
102
- # that is actually wrong.
103
100
  def missing(key)
104
101
  raise ArgumentError, "sprite sheet descriptor has no #{key}"
105
102
  end
@@ -3,31 +3,53 @@
3
3
  module RGame
4
4
  module Core
5
5
  # Draws a tile map: the static layers baked once, the animated tiles drawn
6
- # per frame and culled to the viewport.
6
+ # per frame, both culled to a rectangle of the world.
7
7
  #
8
8
  # tiles = RGame::Core::TileMapRenderer.new(map, tileset_images)
9
9
  #
10
- # tiles.draw(renderer, camera_x, camera_y, view_w, view_h, elapsed: seconds)
11
- # tiles.draw_overlay(renderer, camera_x, camera_y, view_w, view_h, z: 20,
10
+ # map.layer_count.times do |layer|
11
+ # tiles.draw_layer(renderer, layer, cull_x, cull_y, cull_w, cull_h,
12
12
  # elapsed: seconds)
13
+ # end
13
14
  #
14
- # ## Two bands, with the actors between them
15
+ # ## It draws in world coordinates
15
16
  #
16
- # Layers split by the map's own `above_layer?` flag. The **below** band
17
- # ground, and detail on the actors' levelis drawn under them by `#draw`;
18
- # the **above** band tree canopies, roofs over them by `#draw_overlay`,
19
- # at a `z` the scene picks. Two calls rather than one because the scene
20
- # draws its actors in between, and collapsing them would put every canopy
21
- # behind every character.
17
+ # A tile at column 3 is drawn at `3 * tile_width`, and getting it onto the
18
+ # screen is the caller's transformthe same deal every other drawable
19
+ # gets. The rectangle passed in is therefore a **cull rect** and nothing
20
+ # else: which part of the world is worth drawing.
21
+ #
22
+ # It used to be both, offsetting the output by `-camera` as well as culling
23
+ # to it, which worked exactly as long as there was one camera. Under
24
+ # split-screen the same map is drawn through several, so a call that bakes
25
+ # placement into its output can only be right for one of them. Culling is
26
+ # genuinely per-camera; placement is the transform stack's job.
27
+ #
28
+ # ## One call per layer, because the actors go between them
29
+ #
30
+ # A layer is drawn on its own, and the order they are drawn in is the
31
+ # caller's. That is what lets a scene put its actors between two of them —
32
+ # tree trunks below, canopies above — which is the whole reason this does
33
+ # not simply draw the map in one go.
34
+ #
35
+ # It is also why nothing here consults the map's `above_layer?` flag any
36
+ # more: which layers cover the actors is a question about where the actors
37
+ # are in the scene, and Tiled already answers "in what order do the layers
38
+ # go" by listing them. `RGame::Engine::TileMapLayer` mounts one node per
39
+ # layer and the tree does the rest.
22
40
  #
23
41
  # ## What is baked and what is not
24
42
  #
25
- # Within each band, every tile that is *not* animated is baked into one
26
- # recording, the first time that band is drawn. Scrolling a baked layer is
43
+ # Within each layer, every tile that is *not* animated is baked into one
44
+ # recording, the first time that layer is drawn. Scrolling a baked layer is
27
45
  # then one call per texture however many thousand tiles went into it. The
28
46
  # handful that *are* animated are drawn individually each frame, culled to
29
47
  # the viewport — a map far larger than the screen costs only what is on it.
30
48
  #
49
+ # Splitting per layer rather than into two bands bakes the same tiles into
50
+ # more recordings, not more vertices: the partition changed, the contents
51
+ # did not.
52
+ #
31
53
  # ## It loads nothing and holds no clock
32
54
  #
33
55
  # The tiles arrive already sliced, so two maps sharing a tileset share one
@@ -44,9 +66,9 @@ module RGame
44
66
  # It never names the map's class — the tile map lives a layer *above* this
45
67
  # one and Core may not reach up (CLAUDE.md, "The rule points both ways").
46
68
  # What it calls is the 'a tile map' contract in
47
- # `spec/support/shared_examples/`: `layer_count`, `above_layer?`, `width`,
48
- # `height`, `tile_width`, `tile_height`, `gid`, and a `tileset` answering
49
- # `local_id`, `animations` and `frame_local_id`.
69
+ # `spec/support/shared_examples/`: `layer_count`, `width`, `height`,
70
+ # `tile_width`, `tile_height`, `gid`, and a `tileset` answering `local_id`,
71
+ # `animations` and `frame_local_id`.
50
72
  class TileMapRenderer
51
73
  # The map this was built from. A scene reads it for collision and world
52
74
  # bounds, which are its business rather than this class's.
@@ -58,50 +80,51 @@ module RGame
58
80
  @map = map
59
81
  @tileset = map.tileset
60
82
  @tiles = tiles
61
- @animated_below, @animated_above = collect_animated_tiles
62
- # Baked on first draw, not here: recording needs a live frame, and there
63
- # is no renderer at construction.
64
- @static_below = nil
65
- @static_above = nil
83
+ @animated = collect_animated_tiles
84
+ @static = Array.new(map.layer_count)
66
85
  end
67
86
 
68
- def draw(renderer, camera_x, camera_y, viewport_width, viewport_height, elapsed: 0.0)
69
- @static_below ||= bake(renderer) { |layer| !@map.above_layer?(layer) }
70
- @static_below.draw(-camera_x, -camera_y, z: BELOW_Z)
71
- draw_animated(renderer, @animated_below, camera_x, camera_y,
72
- viewport_width, viewport_height, BELOW_Z, elapsed)
73
- end
87
+ def layer_count = @map.layer_count
88
+
89
+ # One layer, culled to `(cull_x, cull_y, cull_width, cull_height)` in world
90
+ # coordinates and drawn in them.
91
+ #
92
+ # The recording is replayed at its own origin, so it lands wherever the
93
+ # caller's transform puts it. That also makes it **view-independent**: one
94
+ # bake serves every viewport, which is what keeps split-screen affordable
95
+ # and is why the bake is not keyed on a camera. Baking happens on the
96
+ # first draw, and it is safe to do that inside a transform or a clip —
97
+ # recording runs on its own canvas, begun at identity, and captures
98
+ # neither.
99
+ #
100
+ # No `z:`. A layer is drawn by a node of its own, so where it sits is the
101
+ # scene tree's answer; everything this issues belongs to that one node and
102
+ # goes in its slot.
103
+ #
104
+ # @api private
105
+ def draw_layer(renderer, index, cull_x, cull_y, cull_width, cull_height, elapsed: 0.0)
106
+ unless index.is_a?(Integer) && index >= 0 && index < @static.size
107
+ raise ArgumentError, "no layer #{index.inspect} in this map (it has #{@static.size})"
108
+ end
74
109
 
75
- def draw_overlay(renderer, camera_x, camera_y, viewport_width, viewport_height,
76
- z:, elapsed: 0.0)
77
- @static_above ||= bake(renderer) { |layer| @map.above_layer?(layer) }
78
- @static_above.draw(-camera_x, -camera_y, z: z)
79
- draw_animated(renderer, @animated_above, camera_x, camera_y,
80
- viewport_width, viewport_height, z, elapsed)
110
+ @static[index] ||= bake(renderer, index)
111
+ @static[index].draw
112
+ draw_animated(renderer, @animated[index], cull_x, cull_y,
113
+ cull_width, cull_height, elapsed)
81
114
  end
82
115
 
83
- # The ground band sits at the bottom; the scene chooses where the overlay
84
- # goes, because only it knows what its actors are drawn at.
85
- BELOW_Z = 0
86
-
87
116
  private
88
117
 
89
- # [col, row, local_id] for every animated tile, split into the two bands.
90
- # Walked once at construction: a map is thousands of tiles and a handful
91
- # of animated ones, and finding them again each frame would be the whole
92
- # cost this class exists to avoid.
93
118
  def collect_animated_tiles
94
- below = []
95
- above = []
119
+ found = Array.new(@map.layer_count) { [] }
96
120
  each_tile do |layer, col, row, local|
97
121
  next unless @tileset.animations.key?(local)
98
122
 
99
- (@map.above_layer?(layer) ? above : below) << [col, row, local]
123
+ found[layer] << [col, row, local]
100
124
  end
101
- [below, above]
125
+ found
102
126
  end
103
127
 
104
- # Every non-empty tile of every layer, as [layer, col, row, local_id].
105
128
  def each_tile
106
129
  @map.layer_count.times do |layer|
107
130
  @map.height.times do |row|
@@ -115,44 +138,33 @@ module RGame
115
138
  end
116
139
  end
117
140
 
118
- # Bakes the static tiles of the layers the block accepts into one
119
- # recording, in layer order so a later layer covers an earlier one.
120
- def bake(renderer)
141
+ def bake(renderer, index)
121
142
  renderer.record do
122
143
  each_tile do |layer, col, row, local|
123
- next unless yield(layer)
144
+ next unless layer == index
124
145
  next if @tileset.animations.key?(local)
125
146
 
126
- renderer.image_at(@tiles[local], col * @map.tile_width, row * @map.tile_height,
127
- z: BELOW_Z)
147
+ renderer.image_at(@tiles[local], col * @map.tile_width, row * @map.tile_height)
128
148
  end
129
149
  end
130
150
  end
131
151
 
132
- def draw_animated(renderer, tiles, camera_x, camera_y, viewport_width, viewport_height,
133
- z, elapsed)
152
+ def draw_animated(renderer, tiles, cull_x, cull_y, cull_width, cull_height, elapsed)
134
153
  tile_width = @map.tile_width
135
154
  tile_height = @map.tile_height
136
155
 
137
- # Tiled writes frame durations in milliseconds; everything here counts
138
- # in seconds. Converted once per draw rather than once per tile.
139
156
  ms = (elapsed * 1000.0).to_i
140
157
 
141
- # fdiv, not `/`. With two Integers — an integer camera position, which
142
- # is entirely ordinary — `/` floors first and the `.ceil` below becomes
143
- # a no-op, leaving the last column of tiles undrawn: a one-tile strip of
144
- # nothing along the right and bottom edges of the screen, and only when
145
- # the camera happens to be on a whole pixel.
146
- col_start = camera_x.fdiv(tile_width).floor
147
- row_start = camera_y.fdiv(tile_height).floor
148
- col_end = (camera_x + viewport_width).fdiv(tile_width).ceil
149
- row_end = (camera_y + viewport_height).fdiv(tile_height).ceil
158
+ col_start = cull_x.fdiv(tile_width).floor
159
+ row_start = cull_y.fdiv(tile_height).floor
160
+ col_end = (cull_x + cull_width).fdiv(tile_width).ceil
161
+ row_end = (cull_y + cull_height).fdiv(tile_height).ceil
150
162
 
151
163
  tiles.each do |col, row, local|
152
164
  next if col < col_start || col >= col_end || row < row_start || row >= row_end
153
165
 
154
166
  renderer.image_at(@tiles[@tileset.frame_local_id(local, ms)],
155
- (col * tile_width) - camera_x, (row * tile_height) - camera_y, z: z)
167
+ col * tile_width, row * tile_height)
156
168
  end
157
169
  end
158
170
  end
@@ -7,11 +7,12 @@ require_relative 'nine_slice'
7
7
 
8
8
  module RGame
9
9
  module Core
10
- # One sheet of UI chrome, cut into named nine-slices.
10
+ # One sheet of UI chrome, cut into named nine-slices and named images.
11
11
  #
12
12
  # atlas = RGame::Core::UiAtlas.load(app, 'media/ui.json')
13
13
  # renderer.register_ui_atlas(atlas)
14
14
  # renderer.nine_slice(:button_idle, x, y, width, height)
15
+ # renderer.image(:home, cx, cy)
15
16
  #
16
17
  # A button has four states, a panel has one, a scrollbar has three pieces —
17
18
  # all of them small, and all of them cheaper as sub-rectangles of a single
@@ -29,27 +30,38 @@ module RGame
29
30
  # "panel": { "x": 0, "y": 0, "w": 32, "h": 32,
30
31
  # "border": { "left": 4, "right": 4, "top": 8, "bottom": 4 },
31
32
  # "scale": 2 }
33
+ # },
34
+ # "images": {
35
+ # "home": { "x": 0, "y": 96, "w": 50, "h": 50 },
36
+ # "gear": { "x": 50, "y": 96, "w": 50, "h": 50 }
32
37
  # }
33
38
  # }
34
39
  #
35
- # `image` is resolved next to the descriptor. Each entry is a source
36
- # rectangle plus a border — a uniform integer or one value per side — and an
37
- # optional `scale` overriding the sheet default. See
40
+ # `image` is resolved next to the descriptor. Each nine-slice entry is a
41
+ # source rectangle plus a border — a uniform integer or one value per side —
42
+ # and an optional `scale` overriding the sheet default. See
38
43
  # RGame::Core::NineSlice for what those mean when it is drawn.
39
44
  #
45
+ # Each `images` entry is a rectangle and nothing else: an icon is drawn
46
+ # whole, so it has no border, and no scale either, because how large to draw
47
+ # it is the draw call's `scale:`. Either section may be absent or `null`.
48
+ #
40
49
  # ## Element names, not filenames
41
50
  #
42
- # `nine_slices` is keyed by whatever the descriptor calls each element, and
51
+ # Both sections are keyed by whatever the descriptor calls each element, and
43
52
  # those names are what a widget asks for. That is why nine-slices are the
44
53
  # one asset the renderer resolves by registration only: `:button_focus` is
45
54
  # not a file and never can be. `Renderer#register_ui_atlas` registers every
46
- # element in one call.
55
+ # element of both kinds in one call.
47
56
  #
48
57
  # Parsing happens once, at load. Nothing here is touched again per frame.
49
58
  class UiAtlas
50
- # The elements, by name. Values are `NineSlice`s.
59
+ # The nine-slice elements, by name. Values are `NineSlice`s.
51
60
  attr_reader :nine_slices
52
61
 
62
+ # The image elements, by name. Values are `Image`s cut from the sheet.
63
+ attr_reader :images
64
+
53
65
  # Loads a descriptor and the sheet beside it.
54
66
  #
55
67
  # The `AssetManager` uses `.new` instead, with an image it has already
@@ -65,19 +77,22 @@ module RGame
65
77
  sheet_scale = data[:scale] || 1
66
78
 
67
79
  @nine_slices = (data[:nine_slices] || {}).to_h do |id, spec|
68
- [id, build(image, id, spec, sheet_scale)]
80
+ [id, element(id) { build_nine_slice(image, spec, sheet_scale) }]
81
+ end
82
+ @images = (data[:images] || {}).to_h do |id, spec|
83
+ [id, element(id) { image.subimage(spec[:x], spec[:y], spec[:w], spec[:h]) }]
69
84
  end
70
85
  end
71
86
 
72
87
  private
73
88
 
74
- # A descriptor holds many elements, and a mistake in one of them surfaces
75
- # from inside NineSlice's arithmetic — "undefined method '-' for nil" says
76
- # nothing about *which* button is wrong. Naming the element is the whole
77
- # value this wrapper adds, so it catches broadly on purpose.
78
- def build(image, id, spec, sheet_scale)
89
+ def build_nine_slice(image, spec, sheet_scale)
79
90
  NineSlice.new(image, x: spec[:x], y: spec[:y], w: spec[:w], h: spec[:h],
80
91
  border: spec[:border], scale: spec[:scale] || sheet_scale)
92
+ end
93
+
94
+ def element(id)
95
+ yield
81
96
  rescue StandardError => e
82
97
  raise ArgumentError, "ui atlas element #{id.inspect}: #{e.message}"
83
98
  end
data/lib/rgame/core.rb CHANGED
@@ -1,15 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # RGame::Core — everything that depends on SDL, OpenGL, or anything built on
4
- # them. Requiring this file loads those libraries into the process, which is why
5
- # it is NOT required from lib/rgame.rb; ask for it explicitly:
6
- #
7
- # require "rgame/core"
8
- #
9
- # Its counterpart is RGame::Util (see lib/rgame/util.rb), which stays free of
10
- # graphics dependencies.
11
3
  require_relative 'version'
12
4
  require_relative 'core/app'
5
+ require_relative 'core/locale'
13
6
  require_relative 'core/input'
14
7
  require_relative 'core/gamepad'
15
8
  require_relative 'core/image'