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,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Core
5
+ # A bordered texture drawn at any size, by cutting it into nine pieces and
6
+ # treating each differently.
7
+ #
8
+ # panel = RGame::Core::NineSlice.new(image, x: 0, y: 0, w: 26, h: 28,
9
+ # border: 7, scale: 3)
10
+ # panel.draw(renderer, x, y, width, height, z: 0)
11
+ #
12
+ # The four corners keep their size, the four edges stretch along one axis
13
+ # and the centre along both — so one small piece of art fills a button, a
14
+ # dialog or a health bar without the corners smearing.
15
+ #
16
+ # ```
17
+ # ┌──┬────────┬──┐ corners: fixed
18
+ # │tl│ top │tr│ top / bottom: tiled across
19
+ # ├──┼────────┼──┤ left / right: tiled down
20
+ # │l │ centre │ r│ centre: tiled both ways
21
+ # ├──┼────────┼──┤
22
+ # │bl│ bottom │br│
23
+ # └──┴────────┴──┘
24
+ # ```
25
+ #
26
+ # **Edges and the centre are *tiled*, not stretched.** Repeating a 7-pixel
27
+ # motif keeps pixel art crisp at any widget size; stretching it would blur
28
+ # exactly the detail the art was drawn for. Each band is clipped to itself,
29
+ # so the last tile in a row is cropped cleanly rather than spilling over the
30
+ # corner next to it.
31
+ #
32
+ # `scale` is an integer pixel scale for the chrome itself. Source art is
33
+ # small — corners are often 7 pixels — so a scale of 2 or 3 gives legible
34
+ # borders on a 640x480 screen without any blurring, because every source
35
+ # pixel becomes a whole square of screen pixels.
36
+ #
37
+ # The nine pieces are cut once at construction, so `#draw` allocates
38
+ # nothing. It is called once per widget per frame.
39
+ class NineSlice
40
+ # `border` is a uniform integer or a hash of `left`/`right`/`top`/`bottom`.
41
+ # (x, y, w, h) is the source rectangle inside `image`, so one sheet can
42
+ # hold many of these — which is what UiAtlas does with it.
43
+ def initialize(image, x:, y:, w:, h:, border:, scale: 1)
44
+ raise ArgumentError, "nine-slice scale must be positive, got #{scale}" unless scale.positive?
45
+
46
+ @scale = scale
47
+ border = normalize_border(border)
48
+ @l = border.fetch(:left)
49
+ @r = border.fetch(:right)
50
+ @t = border.fetch(:top)
51
+ @b = border.fetch(:bottom)
52
+
53
+ cut_pieces(image, x, y, w, h)
54
+ end
55
+
56
+ # Fills the rectangle (dx, dy, dw, dh).
57
+ #
58
+ # `color` tints every piece — a focus highlight, a disabled grey. A
59
+ # rectangle smaller than its own borders draws its corners and nothing
60
+ # else, which is the least misleading thing a widget too small for its own
61
+ # chrome can look like.
62
+ def draw(renderer, dx, dy, dw, dh, z: 0, color: nil)
63
+ s = @scale
64
+ l = @l * s
65
+ r = @r * s
66
+ t = @t * s
67
+ b = @b * s
68
+ inner_w = dw - l - r
69
+ inner_h = dh - t - b
70
+
71
+ band(renderer, @centre, dx + l, dy + t, inner_w, inner_h, z, color)
72
+ band(renderer, @top, dx + l, dy, inner_w, t, z, color)
73
+ band(renderer, @bottom, dx + l, dy + dh - b, inner_w, b, z, color)
74
+ band(renderer, @left, dx, dy + t, l, inner_h, z, color)
75
+ band(renderer, @right, dx + dw - r, dy + t, r, inner_h, z, color)
76
+
77
+ piece(renderer, @tl, dx, dy, z, color)
78
+ piece(renderer, @tr, dx + dw - r, dy, z, color)
79
+ piece(renderer, @bl, dx, dy + dh - b, z, color)
80
+ piece(renderer, @br, dx + dw - r, dy + dh - b, z, color)
81
+ end
82
+
83
+ private
84
+
85
+ def normalize_border(border)
86
+ return border.transform_keys(&:to_sym) unless border.is_a?(Integer)
87
+
88
+ { left: border, right: border, top: border, bottom: border }
89
+ end
90
+
91
+ def cut_pieces(image, x, y, w, h)
92
+ centre_w = w - @l - @r
93
+ centre_h = h - @t - @b
94
+ if centre_w.negative? || centre_h.negative?
95
+ raise ArgumentError,
96
+ "nine-slice borders (#{@l}, #{@r}, #{@t}, #{@b}) do not fit in a #{w}x#{h} rect"
97
+ end
98
+
99
+ @tl = cut(image, x, y, @l, @t)
100
+ @tr = cut(image, x + w - @r, y, @r, @t)
101
+ @bl = cut(image, x, y + h - @b, @l, @b)
102
+ @br = cut(image, x + w - @r, y + h - @b, @r, @b)
103
+
104
+ @top = cut(image, x + @l, y, centre_w, @t)
105
+ @bottom = cut(image, x + @l, y + h - @b, centre_w, @b)
106
+ @left = cut(image, x, y + @t, @l, centre_h)
107
+ @right = cut(image, x + w - @r, y + @t, @r, centre_h)
108
+ @centre = cut(image, x + @l, y + @t, centre_w, centre_h)
109
+ end
110
+
111
+ def cut(image, x, y, width, height)
112
+ return nil unless width.positive? && height.positive?
113
+
114
+ image.subimage(x, y, width, height)
115
+ end
116
+
117
+ def piece(renderer, image, x, y, z, color)
118
+ return unless image
119
+
120
+ renderer.image_at(image, x, y, scale_x: @scale, scale_y: @scale, z: z, color: color)
121
+ end
122
+
123
+ def band(renderer, image, bx, by, bw, bh, z, color)
124
+ return if image.nil? || bw <= 0 || bh <= 0
125
+
126
+ step_x = image.width * @scale
127
+ step_y = image.height * @scale
128
+ renderer.clipped(bx, by, bw, bh) do
129
+ y = by
130
+ while y < by + bh
131
+ x = bx
132
+ while x < bx + bw
133
+ piece(renderer, image, x, y, z, color)
134
+ x += step_x
135
+ end
136
+ y += step_y
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgame/core_ext'
4
+ require_relative '../util/color'
5
+ require_relative '../util/z'
6
+
7
+ module RGame
8
+ module Core
9
+ # A block of drawing baked once and replayed cheaply.
10
+ #
11
+ # ground = renderer.record do
12
+ # tiles.each { |tile| renderer.image(tile.image, tile.x, tile.y) }
13
+ # end
14
+ #
15
+ # def draw
16
+ # ground.draw(-camera.x, -camera.y)
17
+ # end
18
+ #
19
+ # A screen of tiles is a couple of thousand quads that have not changed
20
+ # since the level loaded. Baking them turns the per-frame cost from "walk
21
+ # every tile and transform four corners" into one call per texture — the
22
+ # work happens at bake time and never again.
23
+ #
24
+ # Recordings come from `Renderer#record`; there is no `new`.
25
+ #
26
+ # ## What is baked in and what is not
27
+ #
28
+ # Positions, texture coordinates and colours are baked, and so are any
29
+ # transforms applied *inside* the block. The transform in effect when the
30
+ # recording is *drawn* is applied on top, which is what lets a baked layer
31
+ # scroll under a camera without being rebuilt.
32
+ #
33
+ # Clipping is not baked — it happens when pixels are rasterised, so a clip
34
+ # rectangle captured in one place would be wrong everywhere else the
35
+ # recording is drawn. Pushing a clip inside a `record` block raises. Clip
36
+ # the replay instead:
37
+ #
38
+ # renderer.clipped(0, 0, 400, 600) { ground.draw(0, 0) }
39
+ class Recording
40
+ Color = RGame::Util::Color
41
+ Z = RGame::Util::Z
42
+
43
+ # Replays everything that was recorded, with the recording's origin at
44
+ # (x, y).
45
+ #
46
+ # `color` tints what was baked: each recorded colour is multiplied by it,
47
+ # so white (the default) draws the recording unchanged and a colour with
48
+ # alpha fades the whole layer out at once.
49
+ def draw(x = 0, y = 0, z: 0, color: nil)
50
+ draw_at(x, y, Z.offset(z), Color.coerce(color).packed)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgame/core_ext'
4
+ require_relative 'font'
5
+ require_relative '../util/color'
6
+ require_relative '../util/z'
7
+
8
+ module RGame
9
+ module Core
10
+ # What a game draws with.
11
+ #
12
+ # class MyGame < RGame::Core::App
13
+ # def initialize
14
+ # super(width: 800, height: 600, caption: 'demo')
15
+ # @renderer = RGame::Core::Renderer.new(self)
16
+ # @hero = RGame::Core::Image.new(self, 'hero.png')
17
+ # end
18
+ #
19
+ # def draw
20
+ # @renderer.rect(10, 10, 100, 40, color: RGame::Util::Color::WHITE)
21
+ # @renderer.image(@hero, 400, 300, angle: 45)
22
+ # end
23
+ # end
24
+ #
25
+ # Drawing is only legal inside `draw`, and calling one of these outside it
26
+ # raises. That is on purpose: the frame is not open, so the vertices would
27
+ # be silently discarded, and an invisible failure is the worst kind.
28
+ #
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.
45
+ #
46
+ # The C half of this class (ext/rgame_core/ruby/renderer_ext.c) has the `draw_*`
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.
50
+ #
51
+ # ## Colours
52
+ #
53
+ # Every drawing method takes `color:`, accepting whatever `Color.coerce`
54
+ # does: `nil` (white — an untinted draw), `[r, g, b]`, `[r, g, b, a]`, or a
55
+ # `RGame::Util::Color`. Passing a `Color` is the allocation-free path and is
56
+ # what per-frame code should do; an array allocates one colour per call.
57
+ class Renderer
58
+ Color = RGame::Util::Color
59
+
60
+ Z = RGame::Util::Z
61
+
62
+ SHAPE_Z = 50
63
+ IMAGE_Z = 0
64
+
65
+ CIRCLE_SEGMENTS = 64
66
+
67
+ TEXT_Z = 10
68
+ FONT_SIZE = 18
69
+
70
+ DEBUG_BOX_COLOR = Color.new(255, 40, 40, 120)
71
+
72
+ # `assets:` is where a draw id that is not registered gets resolved from,
73
+ # and defaults to the app's own manager — so the common case wires itself
74
+ # and `renderer.sprite('hero.json', …)` works with nothing set up.
75
+ #
76
+ # A Ruby `self.new` because the C `initialize` has fixed arity and no
77
+ # business knowing what an asset manager is; the same shape `Font`'s
78
+ # `path:` uses.
79
+ def self.new(app, assets: nil)
80
+ renderer = super(app)
81
+ renderer.assets = assets.nil? ? app.assets : assets
82
+ renderer
83
+ end
84
+
85
+ attr_accessor :assets
86
+
87
+ def register_image(id, image) = registry(:image)[id] = image
88
+ def register_sheet(id, sheet) = registry(:sheet)[id] = sheet
89
+ def register_tilemap(id, tilemap) = registry(:tilemap)[id] = tilemap
90
+ def register_nine_slice(id, nine_slice) = registry(:nine_slice)[id] = nine_slice
91
+
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.
94
+ def register_ui_atlas(atlas)
95
+ atlas.nine_slices.each { |id, nine_slice| register_nine_slice(id, nine_slice) }
96
+ atlas.images.each { |id, image| register_image(id, image) }
97
+ self
98
+ end
99
+
100
+ # One frame of a registered or resolvable sprite sheet, top-left at (x, y).
101
+ def sprite(id, row, col, x, y, flip_x: false, z: IMAGE_Z)
102
+ lookup(:sheet, id).draw(self, row, col, x, y, flip_x: flip_x, z: z)
103
+ end
104
+
105
+ # A nine-slice filling (x, y, width, height), tinted by `tint` if given.
106
+ # Registration only: a nine-slice id names an element of an atlas, not a
107
+ # file, so there is nothing for the asset manager to resolve it to.
108
+ def nine_slice(id, x, y, width, height, z: IMAGE_Z, tint: nil)
109
+ lookup(:nine_slice, id).draw(self, x, y, width, height, z: z, color: tint)
110
+ end
111
+
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.
126
+ #
127
+ # `elapsed` is the seconds its animated tiles have been running for, and
128
+ # is an argument rather than a clock read on purpose — see CLAUDE.md,
129
+ # "`draw` renders state; time enters through `update`". A scene
130
+ # accumulates it in `update`, which is what makes pausing work.
131
+ def tilemap(id, layer, cull_x, cull_y, cull_width, cull_height, elapsed: 0.0)
132
+ lookup(:tilemap, id)
133
+ .draw_layer(self, layer, cull_x, cull_y, cull_width, cull_height, elapsed: elapsed)
134
+ end
135
+
136
+ # A filled axis-aligned rectangle.
137
+ def rect(x, y, width, height, z: SHAPE_Z, color: nil)
138
+ draw_rect(x, y, width, height, Z.offset(z), packed(color))
139
+ end
140
+
141
+ # Four arbitrary points, in loop order: listing them in Z order gives an
142
+ # hourglass rather than a shape.
143
+ def quad(x1, y1, x2, y2, x3, y3, x4, y4, z: SHAPE_Z, color: nil)
144
+ draw_quad(x1, y1, x2, y2, x3, y3, x4, y4, Z.offset(z), packed(color))
145
+ end
146
+
147
+ def triangle(x1, y1, x2, y2, x3, y3, z: SHAPE_Z, color: nil)
148
+ draw_triangle(x1, y1, x2, y2, x3, y3, Z.offset(z), packed(color))
149
+ end
150
+
151
+ # A line of real thickness — drawn as a quad, because GL's own line width
152
+ # is a suggestion drivers may ignore above one pixel.
153
+ def line(x1, y1, x2, y2, thickness: 1.0, z: SHAPE_Z, color: nil)
154
+ draw_line(x1, y1, x2, y2, thickness, Z.offset(z), packed(color))
155
+ end
156
+
157
+ # A filled circle, as a fan of triangles around its centre.
158
+ def circle(cx, cy, radius, z: SHAPE_Z, color: nil, segments: CIRCLE_SEGMENTS)
159
+ draw_circle(cx, cy, radius, segments, Z.offset(z), packed(color))
160
+ end
161
+
162
+ # An image centred on (cx, cy), rotated `angle` degrees clockwise about
163
+ # that centre and uniformly scaled. Unrotated and unscaled is a fast path
164
+ # that skips the transform stack entirely.
165
+ #
166
+ # Takes an `Image` or an id for one — see #resolve_image.
167
+ def image(image, cx, cy, angle: 0, scale: 1, z: IMAGE_Z, color: nil)
168
+ draw_image_rot(resolve_image(image), cx, cy, angle, scale, Z.offset(z), packed(color))
169
+ end
170
+
171
+ # An image with its top-left at (x, y), scaled independently per axis —
172
+ # a tile, a nine-slice corner, a sprite-sheet frame.
173
+ #
174
+ # A **negative scale mirrors the image inside the same rectangle**; it
175
+ # does not move it. So a frame drawn at (x, y) covers the same pixels
176
+ # whichever way it faces, and `scale_x: -1` means "facing the other way"
177
+ # rather than "one width to the left":
178
+ #
179
+ # renderer.image_at(frame, x, y, scale_x: facing_left ? -1 : 1)
180
+ #
181
+ # A zero scale draws nothing.
182
+ def image_at(image, x, y, scale_x: 1, scale_y: 1, z: IMAGE_Z, color: nil)
183
+ draw_image_scaled(resolve_image(image), x, y, scale_x, scale_y, Z.offset(z), packed(color))
184
+ end
185
+
186
+ # An image with its top-left at (x, y), at its natural size — a
187
+ # full-screen backdrop by default. `image_at` with both scales at 1, kept
188
+ # because "put this at the origin" is worth a name of its own.
189
+ def background(image, x = 0, y = 0, z: IMAGE_Z, color: nil)
190
+ draw_image(resolve_image(image), x, y, Z.offset(z), packed(color))
191
+ end
192
+
193
+ # Everything drawn in the block is rotated `angle` degrees about
194
+ # (pivot_x, pivot_y), so a node can spin all of its parts coherently
195
+ # around one point.
196
+ #
197
+ # A zero angle skips the push entirely — unrotated drawing pays nothing,
198
+ # which matters because most drawing is unrotated.
199
+ def rotated(angle, pivot_x, pivot_y)
200
+ return yield if angle.zero?
201
+
202
+ push_rotate(angle, pivot_x, pivot_y)
203
+ begin
204
+ yield
205
+ ensure
206
+ pop
207
+ end
208
+ end
209
+
210
+ # Everything drawn in the block is shifted by (dx, dy) screen pixels —
211
+ # the camera's view transform. Because it is a draw-time transform rather
212
+ # than something baked into positions, the same world can be drawn again
213
+ # under a different offset and clip, which is what split-screen is.
214
+ def translated(dx, dy)
215
+ return yield if dx.zero? && dy.zero?
216
+
217
+ push_translate(dx, dy)
218
+ begin
219
+ yield
220
+ ensure
221
+ pop
222
+ end
223
+ end
224
+
225
+ def scaled(sx, sy = sx)
226
+ return yield if sx == 1 && sy == 1
227
+
228
+ push_scale(sx, sy)
229
+ begin
230
+ yield
231
+ ensure
232
+ pop
233
+ end
234
+ end
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
+
261
+ # Everything drawn in the block is confined to the given rectangle.
262
+ #
263
+ # A clip only ever narrows: nesting one inside another intersects them, so
264
+ # a child cannot draw outside the region its parent allowed. Give each
265
+ # player a clipped block and you have split-screen.
266
+ def clipped(x, y, width, height)
267
+ push_clip(x, y, width, height)
268
+ begin
269
+ yield
270
+ ensure
271
+ pop
272
+ end
273
+ end
274
+
275
+ # The font this renderer draws with when a call does not name one.
276
+ #
277
+ # Built on first use rather than in the constructor: creating a font needs
278
+ # a GL context, and a renderer is often built before there is one. Set
279
+ # your own with #font= to change what every unqualified #text call uses.
280
+ def font
281
+ @font ||= Font.new(app, FONT_SIZE)
282
+ end
283
+
284
+ attr_writer :font
285
+
286
+ # One line of text, with its top-left corner at (x, y) — the same corner
287
+ # every other drawing method takes, rather than the baseline typography
288
+ # would use.
289
+ #
290
+ # Newlines are not special. A caller wanting two lines draws two, stepping
291
+ # by #text_height.
292
+ def text(string, x, y, z: TEXT_Z, color: nil, font: nil)
293
+ draw_text(font || self.font, string, x, y, Z.offset(z), packed(color))
294
+ end
295
+
296
+ # What #text would occupy, for centring and layout. Unlike the drawing
297
+ # methods this works outside `draw`, because measuring touches no GL.
298
+ def text_width(string, font: nil) = (font || self.font).text_width(string)
299
+
300
+ # The line height: what to step y by for a second line.
301
+ def text_height(font: nil) = (font || self.font).height
302
+
303
+ # Bakes everything the block draws into a RGame::Core::Recording, which
304
+ # can then be replayed for the cost of one call per texture however many
305
+ # draws went into it. Nothing is drawn *now* — the block's output goes
306
+ # into the recording instead of into this frame.
307
+ #
308
+ # ground = renderer.record { tiles.each { |t| renderer.image(t.img, t.x, t.y) } }
309
+ # ground.draw(-camera.x, -camera.y)
310
+ #
311
+ # Recording happens inside `draw` like everything else, and does not
312
+ # nest. A clip pushed inside the block raises: clipping cannot be baked,
313
+ # so clip the replay instead. See RGame::Core::Recording.
314
+ def record
315
+ begin_record
316
+ completed = false
317
+ begin
318
+ yield
319
+ completed = true
320
+ ensure
321
+ cancel_record unless completed
322
+ end
323
+ end_record
324
+ end
325
+
326
+ # A translucent overlay for visualising a collision box, so a scene can
327
+ # ask for one without knowing what colour "debug" is.
328
+ def debug_box(x, y, width, height, z: SHAPE_Z)
329
+ rect(x, y, width, height, z: z, color: DEBUG_BOX_COLOR)
330
+ end
331
+
332
+ private
333
+
334
+ def packed(color) = Color.coerce(color).packed
335
+
336
+ def resolve_image(image) = image.is_a?(Image) ? image : lookup(:image, image)
337
+
338
+ def registry(type) = (@registries ||= {})[type] ||= {}
339
+
340
+ def lookup(type, id)
341
+ raise TypeError, "no implicit conversion of nil into #{type}" if id.nil?
342
+
343
+ table = registry(type)
344
+ table.fetch(id) { table[id] = resolve_asset(type, id) }
345
+ end
346
+
347
+ def resolve_asset(type, id)
348
+ resolved = @assets.public_send(type, id) if id.is_a?(String) && @assets.respond_to?(type)
349
+ resolved || raise(KeyError, "no #{type} registered for #{id.inspect} " \
350
+ 'and no AssetManager to resolve it')
351
+ end
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'image'
6
+
7
+ module RGame
8
+ module Core
9
+ # A texture atlas — one image plus a JSON descriptor — sliced into frames and
10
+ # drawn one at a time.
11
+ #
12
+ # sheet = RGame::Core::SpriteSheet.load(app, 'media/hero.json')
13
+ # sheet.draw(renderer, row, col, x, y, flip_x: facing_left, z: 10)
14
+ #
15
+ # ## The descriptor
16
+ #
17
+ # {
18
+ # "image": "hero.png",
19
+ # "frame_width": 16, "frame_height": 24,
20
+ # "cell_width": 32, "cell_height": 32,
21
+ # "origin_x": 8, "origin_y": 4,
22
+ # "animations": { "walk_left": { "row": 1, "frames": 4, "fps": 8 } }
23
+ # }
24
+ #
25
+ # `image` is resolved next to the descriptor. The rest describes the grid.
26
+ #
27
+ # **A frame can be smaller than its cell.** Cells are laid out on a fixed
28
+ # `cell_width` x `cell_height` grid, but what is *drawn* is a
29
+ # `frame_width` x `frame_height` rectangle offset by `origin_x`/`origin_y`
30
+ # inside the cell. That is what lets a sheet whose cells are sized for the
31
+ # widest pose — an attack, a swing — still expose a tight, centred box for
32
+ # walking, so a character does not appear to change size between animations.
33
+ # Leave the cell and origin keys out and frame == cell, which is what a
34
+ # simple sheet wants.
35
+ #
36
+ # `animations` is handed back untouched by `#animations`. This class knows
37
+ # nothing about time; the scene layer builds its own animation table from
38
+ # that hash, which is why the raw form is what gets exposed.
39
+ #
40
+ # ## Slicing costs nothing
41
+ #
42
+ # Every frame is cut once, at construction, as a view onto the one upload —
43
+ # so a sheet of two hundred frames is two hundred small objects and a single
44
+ # texture, and `#draw` is an array index plus one call.
45
+ class SpriteSheet
46
+ attr_reader :frame_width, :frame_height, :animations
47
+
48
+ # Loads a descriptor and the image beside it.
49
+ #
50
+ # The `AssetManager` does not use this — it calls `.new` with an image it
51
+ # has already cached, so a sheet's PNG is shared with a standalone load of
52
+ # the same file. This is the standalone path, for a game with one sheet
53
+ # and no asset manager.
54
+ def self.load(app, atlas_path)
55
+ atlas = JSON.parse(File.read(atlas_path), symbolize_names: true)
56
+ directory = File.dirname(File.expand_path(atlas_path))
57
+ new(Image.new(app, File.join(directory, atlas[:image])), atlas)
58
+ end
59
+
60
+ # `atlas` is the parsed descriptor; `image` an already-loaded sheet image.
61
+ def initialize(image, atlas)
62
+ @frame_width = atlas.fetch(:frame_width) { missing(:frame_width) }
63
+ @frame_height = atlas.fetch(:frame_height) { missing(:frame_height) }
64
+ @animations = atlas[:animations] || {}
65
+
66
+ @frames = slice(image, atlas)
67
+ end
68
+
69
+ # Draws one frame with its top-left at (x, y).
70
+ #
71
+ # `flip_x` mirrors the frame within that same rectangle, so a character
72
+ # occupies the same pixels whichever way it faces — see
73
+ # RGame::Core::Renderer#image_at.
74
+ def draw(renderer, row, col, x, y, flip_x: false, z: 0)
75
+ renderer.image_at(@frames[row][col], x, y, scale_x: flip_x ? -1 : 1, z: z)
76
+ end
77
+
78
+ # How many frames the sheet was cut into, as [rows, columns].
79
+ def grid = [@frames.length, @frames.empty? ? 0 : @frames[0].length]
80
+
81
+ private
82
+
83
+ def slice(image, atlas)
84
+ cell_width = atlas[:cell_width] || @frame_width
85
+ cell_height = atlas[:cell_height] || @frame_height
86
+ origin_x = atlas[:origin_x] || 0
87
+ origin_y = atlas[:origin_y] || 0
88
+
89
+ columns = image.width / cell_width
90
+ rows = image.height / cell_height
91
+
92
+ Array.new(rows) do |row|
93
+ Array.new(columns) do |col|
94
+ image.subimage((col * cell_width) + origin_x, (row * cell_height) + origin_y,
95
+ @frame_width, @frame_height)
96
+ end
97
+ end
98
+ end
99
+
100
+ def missing(key)
101
+ raise ArgumentError, "sprite sheet descriptor has no #{key}"
102
+ end
103
+ end
104
+ end
105
+ end