rgame 0.2.0 → 0.3.1

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 (253) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -1
  3. data/README.md +67 -65
  4. data/docs/api/README.md +86 -82
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -165
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1032 -265
  10. data/docs/api/drawing.md +160 -160
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +149 -50
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +226 -148
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +397 -244
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +186 -73
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +395 -146
  23. data/docs/api/ui.md +883 -54
  24. data/docs/api/values.md +216 -33
  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/rgame_core/app/app.c +33 -3
  92. data/ext/rgame_core/app/locale.c +67 -0
  93. data/ext/rgame_core/app/locale.h +28 -0
  94. data/ext/rgame_core/audio/audio.c +39 -2
  95. data/ext/rgame_core/example.rb +0 -49
  96. data/ext/rgame_core/extconf.rb +0 -125
  97. data/ext/rgame_core/include/rgame/core.h +38 -2
  98. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  99. data/ext/rgame_core/ruby/core_ext.c +30 -7
  100. data/ext/rgame_core/ruby/core_ext.h +3 -0
  101. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  102. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  103. data/ext/rgame_core/ruby/renderer_ext.c +20 -20
  104. data/ext/rgame_util/extconf.rb +2 -20
  105. data/ext/rgame_util/route_search.c +305 -0
  106. data/ext/rgame_util/route_search.h +86 -0
  107. data/ext/rgame_util/route_search_ext.c +150 -0
  108. data/ext/rgame_util/solid_grid.c +58 -0
  109. data/ext/rgame_util/solid_grid.h +49 -0
  110. data/ext/rgame_util/solid_grid_ext.c +161 -0
  111. data/ext/rgame_util/tile_sweep.c +164 -0
  112. data/ext/rgame_util/tile_sweep.h +62 -0
  113. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  114. data/ext/rgame_util/util_ext.c +3 -0
  115. data/ext/rgame_util/util_ext.h +15 -0
  116. data/lib/rgame/boot.rb +0 -10
  117. data/lib/rgame/cli/new_project.rb +139 -0
  118. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  119. data/lib/rgame/cli/templates/README.md.tt +93 -0
  120. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  121. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  122. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  123. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  124. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  125. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  126. data/lib/rgame/cli/templates/rspec.tt +2 -0
  127. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  128. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  129. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  130. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  131. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  132. data/lib/rgame/cli.rb +66 -0
  133. data/lib/rgame/core/app.rb +6 -44
  134. data/lib/rgame/core/asset_manager.rb +13 -31
  135. data/lib/rgame/core/audio.rb +37 -16
  136. data/lib/rgame/core/font.rb +0 -3
  137. data/lib/rgame/core/locale.rb +22 -0
  138. data/lib/rgame/core/nine_slice.rb +0 -21
  139. data/lib/rgame/core/renderer.rb +6 -63
  140. data/lib/rgame/core/sprite_sheet.rb +0 -3
  141. data/lib/rgame/core/tile_map_renderer.rb +2 -19
  142. data/lib/rgame/core/ui_atlas.rb +28 -13
  143. data/lib/rgame/core.rb +1 -8
  144. data/lib/rgame/engine/actor_blockers.rb +131 -0
  145. data/lib/rgame/engine/animation_set.rb +1 -0
  146. data/lib/rgame/engine/audio_director.rb +36 -6
  147. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  148. data/lib/rgame/engine/camera.rb +3 -3
  149. data/lib/rgame/engine/circle_collider.rb +4 -2
  150. data/lib/rgame/engine/collision_box.rb +26 -1
  151. data/lib/rgame/engine/collision_system.rb +110 -22
  152. data/lib/rgame/engine/component.rb +34 -10
  153. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  154. data/lib/rgame/engine/components/animated_sprite.rb +27 -25
  155. data/lib/rgame/engine/components/box_collider.rb +99 -0
  156. data/lib/rgame/engine/components/camera_follow.rb +6 -5
  157. data/lib/rgame/engine/components/character_body.rb +20 -61
  158. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  159. data/lib/rgame/engine/components/collision_world.rb +159 -31
  160. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  161. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  162. data/lib/rgame/engine/components/hop.rb +76 -0
  163. data/lib/rgame/engine/components/identity.rb +73 -0
  164. data/lib/rgame/engine/components/mover.rb +285 -0
  165. data/lib/rgame/engine/components/navigator.rb +145 -0
  166. data/lib/rgame/engine/components/path_follow.rb +123 -31
  167. data/lib/rgame/engine/components/player_controller.rb +5 -2
  168. data/lib/rgame/engine/components/pool.rb +1 -1
  169. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  170. data/lib/rgame/engine/components/sprite.rb +12 -6
  171. data/lib/rgame/engine/components/targeting.rb +9 -11
  172. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  173. data/lib/rgame/engine/components/tile_world.rb +41 -13
  174. data/lib/rgame/engine/components/timer.rb +1 -1
  175. data/lib/rgame/engine/components/velocity.rb +23 -7
  176. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  177. data/lib/rgame/engine/components/world.rb +133 -0
  178. data/lib/rgame/engine/contact_set.rb +74 -0
  179. data/lib/rgame/engine/culling.rb +4 -6
  180. data/lib/rgame/engine/debug_overlay.rb +5 -11
  181. data/lib/rgame/engine/i18n/plural.rb +45 -0
  182. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  183. data/lib/rgame/engine/i18n/template.rb +59 -0
  184. data/lib/rgame/engine/i18n.rb +276 -51
  185. data/lib/rgame/engine/input/action_mapper.rb +0 -26
  186. data/lib/rgame/engine/input/actions.rb +2 -8
  187. data/lib/rgame/engine/input/input_map.rb +39 -21
  188. data/lib/rgame/engine/nav_grid.rb +87 -0
  189. data/lib/rgame/engine/node2d.rb +248 -133
  190. data/lib/rgame/engine/path.rb +4 -6
  191. data/lib/rgame/engine/players.rb +6 -13
  192. data/lib/rgame/engine/presentation.rb +171 -0
  193. data/lib/rgame/engine/scene/scene_stack.rb +4 -4
  194. data/lib/rgame/engine/sealed_privates.rb +54 -0
  195. data/lib/rgame/engine/spatial_hash.rb +51 -19
  196. data/lib/rgame/engine/text.rb +194 -0
  197. data/lib/rgame/engine/tile_blockers.rb +63 -0
  198. data/lib/rgame/engine/tile_map.rb +3 -5
  199. data/lib/rgame/engine/tile_map_layer.rb +0 -2
  200. data/lib/rgame/engine/tileset.rb +2 -4
  201. data/lib/rgame/engine/timer.rb +2 -2
  202. data/lib/rgame/engine/ui/button.rb +248 -0
  203. data/lib/rgame/engine/ui/column.rb +20 -0
  204. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  205. data/lib/rgame/engine/ui/menu.rb +246 -71
  206. data/lib/rgame/engine/ui/navigation.rb +57 -0
  207. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  208. data/lib/rgame/engine/ui/option_button.rb +163 -0
  209. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  210. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  211. data/lib/rgame/engine/ui/pointing.rb +146 -0
  212. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  213. data/lib/rgame/engine/ui/ring.rb +55 -0
  214. data/lib/rgame/engine/ui/row.rb +21 -0
  215. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  216. data/lib/rgame/engine/ui/stack.rb +58 -0
  217. data/lib/rgame/engine/ui/stepping.rb +93 -0
  218. data/lib/rgame/engine/ui/text_button.rb +59 -0
  219. data/lib/rgame/engine/viewports.rb +2 -5
  220. data/lib/rgame/engine/world_view.rb +5 -4
  221. data/lib/rgame/engine.rb +31 -25
  222. data/lib/rgame/game.rb +99 -27
  223. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  224. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  225. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  226. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  227. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  228. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  229. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  230. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  231. data/lib/rgame/rubocop/default.yml +39 -0
  232. data/lib/rgame/rubocop/plugin.rb +45 -0
  233. data/lib/rgame/rubocop.rb +11 -0
  234. data/lib/rgame/util/color.rb +20 -24
  235. data/lib/rgame/util/controls.rb +14 -28
  236. data/lib/rgame/util/route_search.rb +27 -0
  237. data/lib/rgame/util/save_file.rb +107 -0
  238. data/lib/rgame/util/solid_grid.rb +37 -0
  239. data/lib/rgame/util/tensor.rb +0 -9
  240. data/lib/rgame/util/tile_sweep.rb +36 -0
  241. data/lib/rgame/util/z.rb +4 -14
  242. data/lib/rgame/util.rb +4 -3
  243. data/lib/rgame/version.rb +1 -1
  244. data/lib/rgame.rb +0 -15
  245. metadata +167 -12
  246. data/lib/rgame/engine/actor.rb +0 -53
  247. data/lib/rgame/engine/body.rb +0 -49
  248. data/lib/rgame/engine/cached_label.rb +0 -33
  249. data/lib/rgame/engine/input/player_controller.rb +0 -14
  250. data/lib/rgame/engine/matrix.rb +0 -32
  251. data/lib/rgame/engine/resettable.rb +0 -67
  252. data/lib/rgame/engine/tile_collision.rb +0 -78
  253. data/lib/rgame/engine/ui/menu_item.rb +0 -84
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # How big the world is — the contract, not an implementation.
7
+ #
8
+ # Two systems answer it: `World` below, which is nothing *but* the answer,
9
+ # and `TileWorld`, which derives it from the map it parsed. A component
10
+ # that needs bounds asks for the contract — `node.system(WorldBounds)` —
11
+ # and gets whichever of the two the scene mounted. That works because
12
+ # Node2D#get_component matches with `is_a?`, which matches an included
13
+ # module as readily as a class.
14
+ #
15
+ # Naming the contract is what keeps the two from drifting: ScreenWrap does
16
+ # not know which kind of world it is wrapping inside, and must not need to.
17
+ #
18
+ # ## The bounds are in world coordinates
19
+ #
20
+ # The world runs from (0, 0) to (world_width, world_height) in **world**
21
+ # space — the space `Node2D#world_x` answers in, and the one the tile grid,
22
+ # the collision world and the camera already use. So everything that
23
+ # compares a node against these bounds compares `world_x`/`world_y`, never
24
+ # the node's local `x`/`y`: an entity grouped under an offset container is
25
+ # still inside the world when it is, whatever its position in the container.
26
+ # spec/support/shared_examples/a_world_edge_response.rb holds every reader to it.
27
+ module WorldBounds
28
+ def world_width = raise(NotImplementedError, "#{self.class} must define #world_width")
29
+ def world_height = raise(NotImplementedError, "#{self.class} must define #world_height")
30
+
31
+ # The bounds a component should use: whatever it was handed, falling back
32
+ # to the world system on `node` for either axis left nil. The single place
33
+ # that fallback is written, so the components sharing it cannot drift.
34
+ #
35
+ # Call it from `on_attach`, not `initialize` — a node has no scene to ask
36
+ # until it is in the tree, and a pooled entity is built long before it is.
37
+ def self.resolve(node, width, height)
38
+ return [width, height] if width && height
39
+
40
+ bounds = node.system(self)
41
+ if bounds.nil?
42
+ raise 'no world bounds in scope: mount a World (or TileWorld) system on the scene, ' \
43
+ 'or pass explicit width:/height:'
44
+ end
45
+
46
+ [width || bounds.world_width, height || bounds.world_height]
47
+ end
48
+
49
+ # Refuse a node carrying more than one response to the edge of the world:
50
+ # ScreenWrap, DespawnOffscreen, or a Mover declaring `blocked_by: [:bounds]`.
51
+ #
52
+ # Stopping at the edge, wrapping past it and being freed beyond it are three
53
+ # answers to one question, and no two of them mean anything together. Stop and
54
+ # wrap disagree about where the node ends up; wrap and despawn race on which
55
+ # margin is reached first; and the stop works on the collision box while the
56
+ # other two test the node's origin, so a box with an offset holds the origin
57
+ # just past the edge and the other response fires on a node that was held.
58
+ # Rather than a rule each game has to remember, it raises.
59
+ #
60
+ # Folding wrap and despawn into Mover as one `at_edge:` option would make two
61
+ # responses inexpressible too, but they act on the node however it moved — a reset,
62
+ # an ancestor, a direct write — and every option there is a branch on the free
63
+ # step's path. A refusal at attach costs no frame anything. It is also the place
64
+ # to change if a game wants wrapping on one axis and stopping on the other: the
65
+ # rule then becomes one response per axis.
66
+ #
67
+ # Every response calls this from its own on_attach, which is what makes it
68
+ # order-free: a node assembled outside the tree already holds all its
69
+ # components when the first attaches, and on a live node each attaches on
70
+ # arrival, so the second response always finds the first.
71
+ #
72
+ # A pooled node re-attaches on every spawn, so this walks the components
73
+ # without allocating, and builds a message only when it raises.
74
+ def self.one_response!(node)
75
+ first = nil
76
+ node.components.each do |component|
77
+ next unless edge_response?(component)
78
+ next first = component if first.nil?
79
+
80
+ raise "#{node.class} has two responses to the edge of the world: " \
81
+ "#{describe_response(first)} and #{describe_response(component)}. " \
82
+ 'A node can stop at the edge, wrap past it or be freed beyond it, but ' \
83
+ 'only one of those — keep the one this node is for.'
84
+ end
85
+ end
86
+
87
+ def self.edge_response?(component)
88
+ component.is_a?(ScreenWrap) || component.is_a?(DespawnOffscreen) ||
89
+ (component.is_a?(Mover) && component.blocked_by?(Mover::BOUNDS))
90
+ end
91
+ private_class_method :edge_response?
92
+
93
+ def self.describe_response(component)
94
+ name = component.class.name&.split('::')&.last || component.class.inspect
95
+ component.is_a?(Mover) ? "#{name} (blocked_by :bounds)" : name
96
+ end
97
+ private_class_method :describe_response
98
+ end
99
+
100
+ # The scene-scoped world: how big it is, and nothing else yet.
101
+ #
102
+ # A game whose world is a plain rectangle mounts one on its scene, and the
103
+ # components that need bounds — ScreenWrap, DespawnOffscreen — find it
104
+ # rather than having the numbers threaded through every constructor between
105
+ # the scene and the entity. That threading is what this replaces, and it was
106
+ # worse than it looks: a pooled entity is built outside the tree, so its
107
+ # factory had to close over the size, and every scene above the factory had
108
+ # to carry the size in order to build it.
109
+ #
110
+ # It is deliberately **not** the window size. The two coincide in a
111
+ # single-screen game, which is exactly what makes the mistake easy to make
112
+ # and hard to see: bind wrapping to the viewport and the world silently
113
+ # changes shape when the window is resized, or when the screen is split and
114
+ # each half is its own viewport. Ask `Viewports` (or the `View` a draw is
115
+ # handed) how big the *window* is; ask this how big the *world* is.
116
+ #
117
+ # The bounds are immutable, which is what lets the components resolve them
118
+ # once at attach rather than re-reading them every frame. A world that
119
+ # genuinely changes size is a new scene.
120
+ class World < Engine::Component
121
+ include WorldBounds
122
+
123
+ attr_reader :world_width, :world_height
124
+
125
+ def initialize(width:, height:)
126
+ super()
127
+ @world_width = width
128
+ @world_height = height
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The colliders one collider is touching, this step and last. CollisionWorld owns
6
+ # one of these per registered collider and drives it, and together they are what
7
+ # turns a per-step overlap test into the two edges a game actually wants: the step
8
+ # a contact starts and the step it ends. Pure logic; no graphics.
9
+ #
10
+ # Nothing here is about overlapping, though, and it has a second user:
11
+ # every Components::Mover keeps one of *what stopped its step*, and gets on_blocked
12
+ # and on_unblocked out of it on exactly the same terms. What this class is, underneath
13
+ # its names, is "the set of things that were true this step and last".
14
+ #
15
+ # Two arrays, swapped rather than reallocated. `begin_frame` makes this step's list
16
+ # into last step's and empties the other one for refilling, so after the first few
17
+ # frames nothing here allocates — `Array#clear` keeps the capacity it grew to, the
18
+ # same trick SpatialHash#clear relies on.
19
+ #
20
+ # Membership is a linear scan by identity, and deliberately so. A Set or a Hash
21
+ # allocates on insert, and both are slower than scanning a handful of entries; a
22
+ # collider touching more than a handful of things at once is a design problem
23
+ # somewhere else, not a reason to index this.
24
+ class ContactSet
25
+ def initialize
26
+ @current = []
27
+ @previous = []
28
+ end
29
+
30
+ # Start a step: this step's contacts become the ones to compare against, and the
31
+ # list being filled is emptied. Swapped through a temporary rather than by
32
+ # `@current, @previous = @previous, @current`, which would allocate an Array every
33
+ # step — see .rubocop.yml on Style/SwapValues.
34
+ def begin_frame
35
+ filled = @current
36
+ @current = @previous
37
+ @previous = filled
38
+ @current.clear
39
+ end
40
+
41
+ # Forget everything, both steps. CollisionWorld calls this when a collider
42
+ # registers, so a pooled entity that died mid-contact cannot come back still
43
+ # holding the contacts it had in its previous life — which would otherwise
44
+ # surface as one spurious `on_separated` on its first step.
45
+ def reset
46
+ @current.clear
47
+ @previous.clear
48
+ end
49
+
50
+ # Record a contact for this step.
51
+ def add(other) = @current << other
52
+
53
+ # Already recorded this step? The broadphase offers a pair once per cell the two
54
+ # share, so this is what keeps one overlap from being counted twice.
55
+ def touching?(other) = @current.include?(other)
56
+
57
+ # Is this the *start* of a contact — a pair that was not touching last step? It
58
+ # reads only last step's list, so it gives the same answer either side of #add,
59
+ # and asking before recording is what reads as a guard.
60
+ def started?(other) = !@previous.include?(other)
61
+
62
+ # Yield every collider that was in contact last step and is not in contact now —
63
+ # the ending edge. Call it once every pair for the step has been recorded.
64
+ def each_ended
65
+ i = 0
66
+ while i < @previous.size
67
+ other = @previous[i]
68
+ i += 1
69
+ yield other unless @current.include?(other)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -22,22 +22,20 @@ module RGame
22
22
  #
23
23
  # - **No size means no culling.** `node.width`/`height` are what a drawable's
24
24
  # footprint is measured from, and a node that never set them (nothing
25
- # requires it — `examples/14_asteroids` does not) reads as 0×0, which would
26
- # otherwise cull everything instantly. Unknown means draw.
25
+ # requires it) reads as 0×0, which would otherwise cull everything
26
+ # instantly. Unknown means draw.
27
27
  # - **A rotated node is measured generously.** `Node2D#draw` rotates about the
28
- # node's absolute origin, so a rotated footprint can reach further than its
28
+ # node's own origin, so a rotated footprint can reach further than its
29
29
  # box in any direction. The margin is `width + height`, which is always at
30
30
  # least the diagonal `hypot(width, height)` and costs no square root on a
31
31
  # path that runs once per drawable per viewport.
32
32
  module Culling
33
33
  private
34
34
 
35
- # Can this box be skipped for `view`? Coordinates are in the space the
36
- # caller draws in — world coordinates under a camera.
37
35
  # hot-path
38
36
  def culled?(view, x, y, width, height)
39
37
  return false if width.zero? || height.zero?
40
- return !view.visible?(x, y, width, height) if node.abs_angle.zero?
38
+ return !view.visible?(x, y, width, height) if node.world_angle.zero?
41
39
 
42
40
  margin = width + height
43
41
  !view.visible?(x - margin, y - margin, width + (margin * 2), height + (margin * 2))
@@ -35,22 +35,21 @@ module RGame
35
35
  OBJ_LABEL = 'OBJ'
36
36
  DELTA_LABEL = 'Δ/f'
37
37
 
38
- COLOR = [80, 255, 120].freeze # frozen so the renderer caches the resolved colour
39
- PAD = 8 # margin from the screen edge
40
- GAP = 8 # space between a label and its number
38
+ COLOR = [80, 255, 120].freeze
39
+ PAD = 8
40
+ GAP = 8
41
41
 
42
42
  def initialize
43
43
  @visible = false
44
44
  @prev_allocated = 0
45
- @digit_widths = Array.new(10) # measured once, then reused
46
- @label_widths = {} # measured once per label, then reused
45
+ @digit_widths = Array.new(10)
46
+ @label_widths = {}
47
47
  end
48
48
 
49
49
  def visible? = @visible
50
50
 
51
51
  def toggle
52
52
  @visible = !@visible
53
- # Baseline the counter on reveal so the first Δ/f isn't the whole run's history.
54
53
  @prev_allocated = GC.stat(:total_allocated_objects) if @visible
55
54
  end
56
55
 
@@ -70,8 +69,6 @@ module RGame
70
69
  @prev_allocated = allocated
71
70
 
72
71
  line_h = renderer.text_height
73
- # The view's size, not its position: inside a region that has been
74
- # translated to its corner, adding `view.x` would offset a second time.
75
72
  right_x = view.width - PAD
76
73
  top_y = view.height - PAD - (line_h * 3)
77
74
 
@@ -84,14 +81,11 @@ module RGame
84
81
 
85
82
  private
86
83
 
87
- # A right-aligned "label number" row ending at right_x.
88
84
  def draw_line(renderer, label, value, right_x, y)
89
85
  number_left = draw_uint(renderer, value, right_x, y)
90
86
  renderer.text(label, number_left - GAP - label_width(renderer, label), y, color: COLOR)
91
87
  end
92
88
 
93
- # Draw a non-negative integer right-aligned ending at right_x, digit by digit so no
94
- # String is built per frame. Returns the x of the leftmost digit.
95
89
  def draw_uint(renderer, value, right_x, y)
96
90
  x = right_x
97
91
  more = true
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module I18n
6
+ # A translation that varies with `count`: one `Template` per CLDR category
7
+ # the table spells out, and the locale of the table it came from, whose
8
+ # language decides which category a count falls into.
9
+ #
10
+ # Internal to `I18n`. A nested Hash in a table compiles to a Plural when
11
+ # every key is a category, every value is text, and `other` is among them;
12
+ # any other Hash is a level of nesting.
13
+ #
14
+ # @api private
15
+ class Plural
16
+ CATEGORIES = %i[zero one two few many other].freeze
17
+ CATEGORY_NAMES = CATEGORIES.map(&:name).freeze
18
+
19
+ attr_reader :locale
20
+
21
+ # The variable names any form uses, each once, and `count`, which every
22
+ # form is chosen by whether it prints it or not.
23
+ attr_reader :names
24
+
25
+ def self.forms?(entries)
26
+ entries.key?('other') &&
27
+ entries.all? { |name, value| CATEGORY_NAMES.include?(name) && value.is_a?(String) }
28
+ end
29
+
30
+ def initialize(locale, entries)
31
+ @locale = locale
32
+ @forms = entries.to_h { |name, source| [name.to_sym, Template.compile(source)] }.freeze
33
+ @names = @forms.values.flat_map(&:names).push(:count).uniq.freeze
34
+ end
35
+
36
+ # The template for `count`, whose category under this table's language
37
+ # is `category`. An explicit `zero` form wins for 0 in every language,
38
+ # and a category the table leaves out reads `other`.
39
+ def template_for(count, category)
40
+ (count == 0 && @forms[:zero]) || @forms[category] || @forms.fetch(:other) # rubocop:disable Style/NumericPredicate -- count may be any object
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module I18n
6
+ # CLDR plural categories for whole numbers, one rule per language. A rule
7
+ # takes the `count` a caller passed and returns one of `Plural::CATEGORIES`.
8
+ # Every built-in rule answers `:other` for a count that is not an Integer,
9
+ # because fractional counts have categories of their own that no built-in
10
+ # table spells out.
11
+ #
12
+ # Rules are keyed by language, and `I18n.plural_rule` adds or replaces one;
13
+ # a language with no rule counts like English.
14
+ #
15
+ # Source: the CLDR plural rules, https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html
16
+ module PluralRules
17
+ TWO_TO_FOUR = (2..4)
18
+ TWELVE_TO_FOURTEEN = (12..14)
19
+
20
+ def self.whole(&rule) = ->(count) { count.is_a?(Integer) ? rule.call(count.abs) : :other } # rubocop:disable Performance/RedundantBlockCall -- the rule runs after whole has returned, where yield has no block
21
+
22
+ def self.slavic_few?(count) = TWO_TO_FOUR.cover?(count % 10) && !TWELVE_TO_FOURTEEN.cover?(count % 100)
23
+
24
+ def self.million(count) = count.positive? && (count % 1_000_000).zero? ? :many : :other
25
+
26
+ private_class_method :whole, :slavic_few?, :million
27
+
28
+ ONE_OTHER = whole { |n| n == 1 ? :one : :other }
29
+ ONE_MILLION_OTHER = whole { |n| n == 1 ? :one : million(n) }
30
+ ZERO_ONE_MILLION_OTHER = whole { |n| n <= 1 ? :one : million(n) }
31
+
32
+ EAST_SLAVIC = whole do |n|
33
+ if n % 10 == 1 && n % 100 != 11 then :one
34
+ elsif slavic_few?(n) then :few
35
+ else :many
36
+ end
37
+ end
38
+
39
+ POLISH = whole do |n|
40
+ if n == 1 then :one
41
+ elsif slavic_few?(n) then :few
42
+ else :many
43
+ end
44
+ end
45
+
46
+ CZECH = whole do |n|
47
+ if n == 1 then :one
48
+ elsif TWO_TO_FOUR.cover?(n) then :few
49
+ else :other
50
+ end
51
+ end
52
+
53
+ ARABIC = whole do |n|
54
+ case n
55
+ when 0 then :zero
56
+ when 1 then :one
57
+ when 2 then :two
58
+ else
59
+ case n % 100
60
+ when 3..10 then :few
61
+ when 11..99 then :many
62
+ else :other
63
+ end
64
+ end
65
+ end
66
+
67
+ OTHER = ->(_count) { :other }
68
+
69
+ BUILT_IN = {
70
+ en: ONE_OTHER, de: ONE_OTHER, nl: ONE_OTHER, sv: ONE_OTHER, da: ONE_OTHER, nb: ONE_OTHER, fi: ONE_OTHER,
71
+ it: ONE_MILLION_OTHER, es: ONE_MILLION_OTHER,
72
+ fr: ZERO_ONE_MILLION_OTHER, pt: ZERO_ONE_MILLION_OTHER,
73
+ ru: EAST_SLAVIC, uk: EAST_SLAVIC, pl: POLISH, cs: CZECH,
74
+ ja: OTHER, zh: OTHER, ko: OTHER,
75
+ ar: ARABIC
76
+ }.freeze
77
+
78
+ DEFAULT = ONE_OTHER
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module I18n
6
+ # One translated string, compiled once at load: the literal runs and the
7
+ # `%{name}` placeholders between them, so rendering is an append per part
8
+ # rather than a parse. `%%{` in the source is a literal `%{`.
9
+ #
10
+ # Internal to `I18n`; a game reaches translations through `I18n.t` or a
11
+ # `Text`, never through a Template.
12
+ #
13
+ # @api private
14
+ class Template
15
+ PLACEHOLDER = /%%\{|%\{([A-Za-z_]\w*)\}/
16
+
17
+ # The variable names the source uses, each once, in order of appearance.
18
+ attr_reader :names
19
+
20
+ def self.compile(source)
21
+ parts = []
22
+ literal = +''
23
+ position = 0
24
+ while (match = PLACEHOLDER.match(source, position))
25
+ literal << source[position...match.begin(0)]
26
+ if match[1]
27
+ parts << literal.freeze unless literal.empty?
28
+ parts << match[1].to_sym
29
+ literal = +''
30
+ else
31
+ literal << '%{'
32
+ end
33
+ position = match.end(0)
34
+ end
35
+ literal << source[position..]
36
+ parts << literal.freeze unless literal.empty?
37
+ new(parts)
38
+ end
39
+
40
+ def initialize(parts)
41
+ @parts = parts.freeze
42
+ @names = parts.grep(Symbol).uniq.freeze
43
+ @constant = @names.empty? ? -parts.join : nil
44
+ end
45
+
46
+ # The string with each placeholder replaced by `vars[name].to_s`. A
47
+ # template without placeholders returns the same frozen String every
48
+ # time. Every name in `names` must be a key of `vars`.
49
+ def render(vars)
50
+ return @constant if @constant
51
+
52
+ @parts.each_with_object(+'') do |part, out|
53
+ out << (part.is_a?(Symbol) ? vars.fetch(part).to_s : part)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end