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,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
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Skipping a drawable that the viewport cannot show.
6
+ #
7
+ # Mixed into the components that draw a node's own footprint, which are the
8
+ # ones that know both where they put it and how big it is.
9
+ #
10
+ # ## Why it is worth doing at all
11
+ #
12
+ # It was not, while the world was drawn once: a handful of skipped draws
13
+ # against a frame's worth of work. Split-screen changes the arithmetic —
14
+ # the world is walked once per viewport, and an actor at one player's end of
15
+ # the map is off the *other* player's view every frame. What used to be a
16
+ # small saving becomes a saving multiplied by the number of people playing.
17
+ #
18
+ # ## Conservative on purpose
19
+ #
20
+ # Culling one frame too eagerly is a sprite popping in at the edge of the
21
+ # screen, which is worse than the draw it saved. So two rules:
22
+ #
23
+ # - **No size means no culling.** `node.width`/`height` are what a drawable's
24
+ # footprint is measured from, and a node that never set them (nothing
25
+ # requires it) reads as 0×0, which would otherwise cull everything
26
+ # instantly. Unknown means draw.
27
+ # - **A rotated node is measured generously.** `Node2D#draw` rotates about the
28
+ # node's own origin, so a rotated footprint can reach further than its
29
+ # box in any direction. The margin is `width + height`, which is always at
30
+ # least the diagonal `hypot(width, height)` and costs no square root on a
31
+ # path that runs once per drawable per viewport.
32
+ module Culling
33
+ private
34
+
35
+ # hot-path
36
+ def culled?(view, x, y, width, height)
37
+ return false if width.zero? || height.zero?
38
+ return !view.visible?(x, y, width, height) if node.world_angle.zero?
39
+
40
+ margin = width + height
41
+ !view.visible?(x - margin, y - margin, width + (margin * 2), height + (margin * 2))
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A hard-wired development overlay, toggled by F1 (see RGame::Game), reporting
6
+ # runtime health in the bottom-right corner: frame rate (FPS), the process'
7
+ # cumulative allocated-object count (OBJ), and the objects allocated since the last
8
+ # frame (Δ/f).
9
+ #
10
+ # **Δ/f is the one to watch, and it is a standing guard rather than a
11
+ # diagnostic for one past bug.** A clean per-frame path holds it near zero; a
12
+ # steady nonzero number is a garbage collection being scheduled. The cost is
13
+ # invisible by every other measure — nothing looks wrong, nothing is slower,
14
+ # until a pause lands mid-frame — so without a number on screen it is not
15
+ # noticed at all.
16
+ #
17
+ # Every layer can break it and each has its own way of doing so. Core can
18
+ # allocate in a binding, the engine layer in a component's `draw`, and game
19
+ # code in a scene that builds a string or an array per frame. RuboCop's
20
+ # `Game/NoInterpolationInHotPath` and `Game/NoNeedlessAllocation` catch the
21
+ # shapes they can see in *this* repo; they cannot see a game built on top,
22
+ # and they cannot see an allocation that happens inside a method they think
23
+ # is cheap. This can.
24
+ #
25
+ # Pure: it reads only GC.stat and draws against the renderer interface, so it stays
26
+ # headless-testable. The crux is that it must not allocate while drawing — the values
27
+ # change every frame, so the usual "cache the string, rebuild on change" trick would
28
+ # allocate a String per frame, and the meter would be measuring itself. Instead
29
+ # numbers are drawn digit-by-digit from a fixed set of pre-built single-character
30
+ # strings, which the font caches per glyph.
31
+ class DebugOverlay
32
+ DIGITS = %w[0 1 2 3 4 5 6 7 8 9].freeze
33
+
34
+ FPS_LABEL = 'FPS'
35
+ OBJ_LABEL = 'OBJ'
36
+ DELTA_LABEL = 'Δ/f'
37
+
38
+ COLOR = [80, 255, 120].freeze
39
+ PAD = 8
40
+ GAP = 8
41
+
42
+ def initialize
43
+ @visible = false
44
+ @prev_allocated = 0
45
+ @digit_widths = Array.new(10)
46
+ @label_widths = {}
47
+ end
48
+
49
+ def visible? = @visible
50
+
51
+ def toggle
52
+ @visible = !@visible
53
+ @prev_allocated = GC.stat(:total_allocated_objects) if @visible
54
+ end
55
+
56
+ # Laid out against the view it is drawn into rather than against the
57
+ # window, so it stays in the corner of whatever region it is given. It
58
+ # takes `fps` rather than reading it, for the same reason nothing here
59
+ # reads a clock: the number is measured by the shell that owns the loop
60
+ # and handed down. See CLAUDE.md, "`draw` renders state".
61
+ # Its own `:debug` band, which is the last one, so this lands over every
62
+ # other thing in the frame however the scene is arranged. Not a node, so
63
+ # it opens its own layer rather than being given one by the traversal.
64
+ def draw(renderer, view, fps)
65
+ return unless @visible
66
+
67
+ allocated = GC.stat(:total_allocated_objects)
68
+ delta = allocated - @prev_allocated
69
+ @prev_allocated = allocated
70
+
71
+ line_h = renderer.text_height
72
+ right_x = view.width - PAD
73
+ top_y = view.height - PAD - (line_h * 3)
74
+
75
+ renderer.layered(:debug) do
76
+ draw_line(renderer, FPS_LABEL, fps, right_x, top_y)
77
+ draw_line(renderer, OBJ_LABEL, allocated, right_x, top_y + line_h)
78
+ draw_line(renderer, DELTA_LABEL, delta, right_x, top_y + (line_h * 2))
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def draw_line(renderer, label, value, right_x, y)
85
+ number_left = draw_uint(renderer, value, right_x, y)
86
+ renderer.text(label, number_left - GAP - label_width(renderer, label), y, color: COLOR)
87
+ end
88
+
89
+ def draw_uint(renderer, value, right_x, y)
90
+ x = right_x
91
+ more = true
92
+ while more
93
+ digit = value % 10
94
+ value /= 10
95
+ x -= digit_width(renderer, digit)
96
+ renderer.text(DIGITS[digit], x, y, color: COLOR)
97
+ more = value.positive?
98
+ end
99
+ x
100
+ end
101
+
102
+ def digit_width(renderer, digit)
103
+ @digit_widths[digit] ||= renderer.text_width(DIGITS[digit])
104
+ end
105
+
106
+ def label_width(renderer, label)
107
+ @label_widths[label] ||= renderer.text_width(label)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -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