rgame 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +26 -0
  3. data/README.md +406 -0
  4. data/docs/api/README.md +167 -0
  5. data/docs/api/app.md +192 -0
  6. data/docs/api/assets.md +426 -0
  7. data/docs/api/audio.md +208 -0
  8. data/docs/api/components.md +321 -0
  9. data/docs/api/drawing.md +330 -0
  10. data/docs/api/game.md +99 -0
  11. data/docs/api/images.md +118 -0
  12. data/docs/api/input.md +179 -0
  13. data/docs/api/internals.md +110 -0
  14. data/docs/api/scene_graph.md +159 -0
  15. data/docs/api/signals.md +142 -0
  16. data/docs/api/systems.md +98 -0
  17. data/docs/api/text.md +116 -0
  18. data/docs/api/toolbox.md +240 -0
  19. data/docs/api/values.md +101 -0
  20. data/ext/README.md +225 -0
  21. data/ext/rgame_core/app/app.c +721 -0
  22. data/ext/rgame_core/app/app_gl.h +64 -0
  23. data/ext/rgame_core/app/frame_loop.c +42 -0
  24. data/ext/rgame_core/app/frame_loop.h +54 -0
  25. data/ext/rgame_core/audio/audio.c +466 -0
  26. data/ext/rgame_core/audio/audio_internal.h +45 -0
  27. data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
  28. data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
  29. data/ext/rgame_core/example.rb +188 -0
  30. data/ext/rgame_core/extconf.rb +167 -0
  31. data/ext/rgame_core/graphics/backend.c +52 -0
  32. data/ext/rgame_core/graphics/backend.h +64 -0
  33. data/ext/rgame_core/graphics/canvas.c +247 -0
  34. data/ext/rgame_core/graphics/canvas.h +143 -0
  35. data/ext/rgame_core/graphics/clip.c +87 -0
  36. data/ext/rgame_core/graphics/clip.h +89 -0
  37. data/ext/rgame_core/graphics/draw_queue.c +216 -0
  38. data/ext/rgame_core/graphics/draw_queue.h +174 -0
  39. data/ext/rgame_core/graphics/gl_backend.c +122 -0
  40. data/ext/rgame_core/graphics/gl_backend.h +43 -0
  41. data/ext/rgame_core/graphics/image.c +304 -0
  42. data/ext/rgame_core/graphics/image_internal.h +30 -0
  43. data/ext/rgame_core/graphics/primitives.c +189 -0
  44. data/ext/rgame_core/graphics/primitives.h +111 -0
  45. data/ext/rgame_core/graphics/recording.c +119 -0
  46. data/ext/rgame_core/graphics/recording.h +88 -0
  47. data/ext/rgame_core/graphics/texture.c +181 -0
  48. data/ext/rgame_core/graphics/texture.h +165 -0
  49. data/ext/rgame_core/graphics/transform.c +128 -0
  50. data/ext/rgame_core/graphics/transform.h +106 -0
  51. data/ext/rgame_core/include/rgame/core.h +577 -0
  52. data/ext/rgame_core/input/device_slots.c +103 -0
  53. data/ext/rgame_core/input/device_slots.h +93 -0
  54. data/ext/rgame_core/input/gamepad.c +145 -0
  55. data/ext/rgame_core/input/gamepad.h +63 -0
  56. data/ext/rgame_core/input/input.c +109 -0
  57. data/ext/rgame_core/input/input.h +99 -0
  58. data/ext/rgame_core/ruby/audio_ext.c +321 -0
  59. data/ext/rgame_core/ruby/core_ext.c +513 -0
  60. data/ext/rgame_core/ruby/core_ext.h +51 -0
  61. data/ext/rgame_core/ruby/font_ext.c +168 -0
  62. data/ext/rgame_core/ruby/image_ext.c +230 -0
  63. data/ext/rgame_core/ruby/recording_ext.c +186 -0
  64. data/ext/rgame_core/ruby/renderer_ext.c +376 -0
  65. data/ext/rgame_core/text/atlas.c +59 -0
  66. data/ext/rgame_core/text/atlas.h +85 -0
  67. data/ext/rgame_core/text/font.c +281 -0
  68. data/ext/rgame_core/text/font.h +139 -0
  69. data/ext/rgame_core/text/font_atlas.c +385 -0
  70. data/ext/rgame_core/text/font_internal.h +47 -0
  71. data/ext/rgame_core/text/glyph_cache.c +142 -0
  72. data/ext/rgame_core/text/glyph_cache.h +89 -0
  73. data/ext/rgame_core/vendor/README.md +159 -0
  74. data/ext/rgame_core/vendor/miniaudio.h +95864 -0
  75. data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
  76. data/ext/rgame_core/vendor/stb_image.h +7988 -0
  77. data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
  78. data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
  79. data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
  80. data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
  81. data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
  82. data/ext/rgame_util/color.c +19 -0
  83. data/ext/rgame_util/color.h +60 -0
  84. data/ext/rgame_util/color_ext.c +156 -0
  85. data/ext/rgame_util/extconf.rb +27 -0
  86. data/ext/rgame_util/tensor.c +186 -0
  87. data/ext/rgame_util/util_ext.c +27 -0
  88. data/ext/rgame_util/util_ext.h +16 -0
  89. data/lib/rgame/boot.rb +13 -0
  90. data/lib/rgame/core/app.rb +82 -0
  91. data/lib/rgame/core/asset_manager.rb +224 -0
  92. data/lib/rgame/core/audio.rb +124 -0
  93. data/lib/rgame/core/font.rb +49 -0
  94. data/lib/rgame/core/gamepad.rb +55 -0
  95. data/lib/rgame/core/image.rb +55 -0
  96. data/lib/rgame/core/input.rb +77 -0
  97. data/lib/rgame/core/nine_slice.rb +163 -0
  98. data/lib/rgame/core/recording.rb +52 -0
  99. data/lib/rgame/core/renderer.rb +363 -0
  100. data/lib/rgame/core/sprite_sheet.rb +108 -0
  101. data/lib/rgame/core/tile_map_renderer.rb +160 -0
  102. data/lib/rgame/core/ui_atlas.rb +86 -0
  103. data/lib/rgame/core.rb +24 -0
  104. data/lib/rgame/engine/actor.rb +53 -0
  105. data/lib/rgame/engine/animation_set.rb +49 -0
  106. data/lib/rgame/engine/animator.rb +44 -0
  107. data/lib/rgame/engine/audio_bus.rb +24 -0
  108. data/lib/rgame/engine/audio_director.rb +29 -0
  109. data/lib/rgame/engine/body.rb +49 -0
  110. data/lib/rgame/engine/cached_label.rb +33 -0
  111. data/lib/rgame/engine/camera.rb +33 -0
  112. data/lib/rgame/engine/camera_view.rb +28 -0
  113. data/lib/rgame/engine/circle_collider.rb +32 -0
  114. data/lib/rgame/engine/collision_box.rb +34 -0
  115. data/lib/rgame/engine/collision_system.rb +44 -0
  116. data/lib/rgame/engine/component.rb +30 -0
  117. data/lib/rgame/engine/components/action_trigger.rb +41 -0
  118. data/lib/rgame/engine/components/animated_sprite.rb +63 -0
  119. data/lib/rgame/engine/components/character_body.rb +70 -0
  120. data/lib/rgame/engine/components/circle_collider.rb +44 -0
  121. data/lib/rgame/engine/components/collision_world.rb +103 -0
  122. data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
  123. data/lib/rgame/engine/components/path_follow.rb +84 -0
  124. data/lib/rgame/engine/components/player_controller.rb +24 -0
  125. data/lib/rgame/engine/components/pool.rb +53 -0
  126. data/lib/rgame/engine/components/screen_wrap.rb +27 -0
  127. data/lib/rgame/engine/components/sprite.rb +31 -0
  128. data/lib/rgame/engine/components/targeting.rb +54 -0
  129. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  130. data/lib/rgame/engine/components/tile_world.rb +68 -0
  131. data/lib/rgame/engine/components/timer.rb +75 -0
  132. data/lib/rgame/engine/components/velocity.rb +27 -0
  133. data/lib/rgame/engine/components/wander_controller.rb +60 -0
  134. data/lib/rgame/engine/debug_overlay.rb +106 -0
  135. data/lib/rgame/engine/i18n.rb +97 -0
  136. data/lib/rgame/engine/input/action_mapper.rb +46 -0
  137. data/lib/rgame/engine/input/actions.rb +41 -0
  138. data/lib/rgame/engine/input/player_controller.rb +14 -0
  139. data/lib/rgame/engine/matrix.rb +32 -0
  140. data/lib/rgame/engine/node2d.rb +271 -0
  141. data/lib/rgame/engine/path.rb +78 -0
  142. data/lib/rgame/engine/pool.rb +51 -0
  143. data/lib/rgame/engine/resettable.rb +67 -0
  144. data/lib/rgame/engine/scene/scene_stack.rb +65 -0
  145. data/lib/rgame/engine/signal.rb +75 -0
  146. data/lib/rgame/engine/spatial_hash.rb +71 -0
  147. data/lib/rgame/engine/tile_collision.rb +78 -0
  148. data/lib/rgame/engine/tile_map.rb +149 -0
  149. data/lib/rgame/engine/tileset.rb +101 -0
  150. data/lib/rgame/engine/timer.rb +51 -0
  151. data/lib/rgame/engine.rb +68 -0
  152. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  153. data/lib/rgame/fonts/OFL.txt +102 -0
  154. data/lib/rgame/game.rb +129 -0
  155. data/lib/rgame/util/color.rb +27 -0
  156. data/lib/rgame/util/controls.rb +107 -0
  157. data/lib/rgame/util/tensor.rb +12 -0
  158. data/lib/rgame/util.rb +8 -0
  159. data/lib/rgame/version.rb +12 -0
  160. data/lib/rgame.rb +20 -0
  161. metadata +215 -0
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Picks an enemy for the owning node (a tower) to aim at: each update it queries the
7
+ # scene's CollisionWorld around the node's world origin and exposes the chosen target
8
+ # via #target (the enemy node, or nil). The owner reads it to fire — Targeting only
9
+ # selects, it never moves or shoots.
10
+ #
11
+ # The CollisionWorld it queries is the same broadphase enemies register with through
12
+ # their CircleCollider, so targeting needs no enemy list of its own. The `layer`
13
+ # restricts candidates (a tower wants :enemy, not other towers or projectiles).
14
+ #
15
+ # Policies (how to choose among the candidates in range):
16
+ # - :nearest — the closest enemy (the default; one broadphase nearest-lookup).
17
+ # More policies (e.g. furthest-along-path) arrive with the state they need.
18
+ class Targeting < Engine::Component
19
+ POLICIES = %i[nearest].freeze
20
+
21
+ def initialize(range:, policy: :nearest, layer: nil)
22
+ super()
23
+ raise ArgumentError, "unknown targeting policy #{policy.inspect}" unless POLICIES.include?(policy)
24
+
25
+ @range = range
26
+ @policy = policy
27
+ @layer = layer
28
+ @target = nil
29
+ end
30
+
31
+ # The chosen enemy node, or nil when nothing is in range. Refreshed every update.
32
+ attr_reader :target
33
+
34
+ # Pull the broadphase once it's reachable (scene scope, resolved up the tree).
35
+ def on_attach = @world = node.system(CollisionWorld)
36
+
37
+ def update(_dt)
38
+ collider = pick
39
+ @target = collider&.node
40
+ end
41
+
42
+ private
43
+
44
+ # Allocation-free: a symbol dispatch over a known-small policy set, each delegating
45
+ # to an allocation-free CollisionWorld query.
46
+ def pick
47
+ case @policy
48
+ when :nearest then @world.nearest(node.abs_x, node.abs_y, @range, layer: @layer)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Inertial "ship" flight on top of a Velocity sibling: a turn axis rotates the
7
+ # node (angular velocity) and a thrust axis accelerates it along its heading,
8
+ # with optional drag and a top-speed clamp. Reads intent in `control` and
9
+ # integrates it in `update`, so it composes with the normal phase order.
10
+ #
11
+ # Heading convention: angle 0 points along +x ("right"), so forward is
12
+ # (cos θ, sin θ) — consistent with the renderer's clockwise rotation under a y-down screen
13
+ # (e.g. +90° faces down). Firing is intentionally NOT here — see ActionTrigger +
14
+ # the owning node, which knows its muzzle geometry.
15
+ class ThrustController < Engine::Component
16
+ def initialize(turn_speed:, accel:, max_speed:, drag: 0.0,
17
+ turn_action: :turn, thrust_action: :thrust)
18
+ super()
19
+ @turn_speed = turn_speed
20
+ @accel = accel
21
+ @max_speed = max_speed
22
+ @drag = drag
23
+ @turn_action = turn_action
24
+ @thrust_action = thrust_action
25
+ @thrust = 0.0
26
+ end
27
+
28
+ # The Velocity sibling is only guaranteed present once attached to a node.
29
+ def on_attach = @velocity = node.get_component(Velocity)
30
+
31
+ def control(actions)
32
+ @velocity.spin = actions.axis(@turn_action) * @turn_speed
33
+ @thrust = actions.axis(@thrust_action)
34
+ end
35
+
36
+ def update(dt)
37
+ if @thrust != 0.0
38
+ @velocity.vx += Math.cos(node.angle) * @accel * @thrust * dt
39
+ @velocity.vy += Math.sin(node.angle) * @accel * @thrust * dt
40
+ end
41
+ apply_drag(dt) if @drag.positive?
42
+ clamp_speed
43
+ end
44
+
45
+ private
46
+
47
+ def apply_drag(dt)
48
+ factor = 1.0 - (@drag * dt)
49
+ factor = 0.0 if factor.negative?
50
+ @velocity.vx *= factor
51
+ @velocity.vy *= factor
52
+ end
53
+
54
+ def clamp_speed
55
+ speed = Math.hypot(@velocity.vx, @velocity.vy)
56
+ return unless speed > @max_speed
57
+
58
+ scale = @max_speed / speed
59
+ @velocity.vx *= scale
60
+ @velocity.vy *= scale
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # The scene-scoped tile world: a system component (it lives on the scene node and
7
+ # is found with node.system(TileWorld)) that owns the parsed Engine::TileMap and
8
+ # everything an actor needs from it — collision against the solid tiles, the world
9
+ # bounds, and drawing the map through the scene's camera.
10
+ #
11
+ # Collision reuses Engine::CollisionSystem (TileCollision + a world-bounds clamp);
12
+ # the tile solidity is whatever the map's tileset reports (baked per-tile in Tiled).
13
+ #
14
+ # Drawing splits into two z bands so actors can sit between them: the below band
15
+ # (ground, same-level detail) at GROUND_Z and the above band (canopies, roofs) at
16
+ # OVERLAY_Z. Actors draw at a z in between (the renderer sorts by z, so the
17
+ # draw-call order doesn't matter). The camera is centred by the scene before any
18
+ # drawing.
19
+ #
20
+ # It also owns the map's **animation clock**. Nothing below reads a wall clock —
21
+ # see CLAUDE.md, "`draw` renders state; time enters through `update`" — so the
22
+ # elapsed seconds animated tiles run on are accumulated here and handed down at
23
+ # draw time. Stop calling `update` and the water freezes, which is what pausing
24
+ # should look like.
25
+ class TileWorld < Engine::Component
26
+ GROUND_Z = 0
27
+ OVERLAY_Z = 20
28
+
29
+ def initialize(map:, tilemap_id:, camera:)
30
+ super()
31
+ @map = map
32
+ @tilemap_id = tilemap_id
33
+ @camera = camera
34
+ @elapsed = 0.0
35
+ @collision = Engine::CollisionSystem.new(
36
+ tile_collision: Engine::TileCollision.new(
37
+ tile_width: map.tile_width, tile_height: map.tile_height,
38
+ solid: ->(col, row) { map.solid_tile?(col, row) }
39
+ ),
40
+ world_width: map.pixel_width, world_height: map.pixel_height
41
+ )
42
+ end
43
+
44
+ def world_width = @map.pixel_width
45
+ def world_height = @map.pixel_height
46
+
47
+ # Move an actor (responds to x/y/collision_box) by (dx, dy), sliding along solids
48
+ # and clamped inside the world. Delegates to the reused collision system.
49
+ def move(actor, dx, dy) = @collision.move(actor, dx, dy)
50
+
51
+ def solid?(col, row) = @map.solid_tile?(col, row)
52
+
53
+ # Advances the tile animations. Seconds, like every other duration here.
54
+ def update(dt)
55
+ @elapsed += dt
56
+ end
57
+
58
+ def draw(renderer)
59
+ renderer.tilemap(@tilemap_id, @camera.x, @camera.y,
60
+ @camera.viewport_width, @camera.viewport_height, elapsed: @elapsed)
61
+ renderer.tilemap_overlay(@tilemap_id, @camera.x, @camera.y,
62
+ @camera.viewport_width, @camera.viewport_height,
63
+ z: OVERLAY_Z, elapsed: @elapsed)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A node-driven interval timer: it rides the node's update tick — so nothing can
7
+ # forget to advance it — and emits `on_timeout` each time a whole interval elapses.
8
+ # It wraps the pure Engine::Timer, reusing its carry-forward `consume` (the remainder
9
+ # rolls over, so the cadence doesn't drift); this only adds the per-frame drive and
10
+ # the signal.
11
+ #
12
+ # timer = node.add_component(Engine::Components::Timer.new(0.8), as: :spawn)
13
+ # timer.on_timeout { spawn_enemy }
14
+ #
15
+ # `repeating: false` makes it a one-shot: it fires `on_timeout` exactly once and then
16
+ # goes inert — the fixed-board despawn case (a projectile that vanishes after N
17
+ # seconds, where DespawnOffscreen doesn't apply):
18
+ #
19
+ # life = node.add_component(Engine::Components::Timer.new(2.0, repeating: false), as: :despawn)
20
+ # life.on_timeout { node.queue_free }
21
+ #
22
+ # The countdown restarts in `on_attach`, so a pooled node reacquired and re-added
23
+ # starts fresh — a recycled projectile gets its full interval (and a one-shot can fire
24
+ # again) rather than inheriting the previous life's elapsed time.
25
+ #
26
+ # When a node needs more than one (a spawn cadence and a wave cadence), give them
27
+ # distinct `as:` names — a node holds one component per slot.
28
+ class Timer < Engine::Component
29
+ signal :on_timeout # emits no payload
30
+
31
+ def initialize(interval, repeating: true)
32
+ super()
33
+ @timer = Engine::Timer.new(interval)
34
+ @repeating = repeating
35
+ @done = false
36
+ end
37
+
38
+ def interval = @timer.interval
39
+
40
+ def interval=(seconds)
41
+ @timer.interval = seconds
42
+ end
43
+
44
+ # Start the countdown fresh whenever the node (re-)enters the tree, so a pooled node
45
+ # never inherits a previous life's accumulated time or spent one-shot.
46
+ def on_attach = reset
47
+
48
+ # Advance one step and fire on_timeout once per whole interval that elapsed — so a
49
+ # single long step still emits the right number of times (catch-up, not drift). A
50
+ # one-shot (`repeating: false`) fires once and then stops accumulating.
51
+ def update(dt)
52
+ return if @done
53
+
54
+ @timer.update(dt)
55
+ while @timer.ready?
56
+ @timer.consume
57
+ on_timeout_signal.emit
58
+ unless @repeating
59
+ @done = true
60
+ break
61
+ end
62
+ end
63
+ end
64
+
65
+ # Back to a fresh timer: drop accumulated time and re-arm a spent one-shot (e.g.
66
+ # after retuning the interval, or when a pooled node is reused). Returns self.
67
+ def reset
68
+ @timer.reset
69
+ @done = false
70
+ self
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Integrates linear and angular velocity into the node's transform each step.
7
+ # Free-moving entities (rocks, bullets) use it alone; a controller component
8
+ # (or the node's own control hook) writes vx/vy/spin as intent. From Body#integrate.
9
+ class Velocity < Engine::Component
10
+ attr_accessor :vx, :vy, :spin
11
+
12
+ def initialize(vx: 0.0, vy: 0.0, spin: 0.0)
13
+ super()
14
+ @vx = vx
15
+ @vy = vy
16
+ @spin = spin
17
+ end
18
+
19
+ def update(dt)
20
+ node.x += @vx * dt
21
+ node.y += @vy * dt
22
+ node.angle += @spin * dt
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A simple AI driver for a CharacterBody sibling: every so often it rolls a new
7
+ # direction (one of eight, or idle) and holds it until a timer elapses — or until a
8
+ # wall blocks it, at which point it re-rolls early instead of pushing into the wall.
9
+ # The RNG is injected so behaviour is deterministic in tests.
10
+ class WanderController < Engine::Component
11
+ MOVED_EPS = 1e-6
12
+ DIRECTIONS = [
13
+ [-1, 0], [1, 0], [0, -1], [0, 1],
14
+ [-1, -1], [1, -1], [-1, 1], [1, 1]
15
+ ].freeze
16
+
17
+ def initialize(rng: Random.new, change_interval: 1.0..3.0, idle_chance: 0.25)
18
+ super()
19
+ @rng = rng
20
+ @change_interval = change_interval
21
+ @idle_chance = idle_chance
22
+ @timer = 0.0 # rolls a direction on the first update
23
+ end
24
+
25
+ def on_attach
26
+ @body = node.get_component(CharacterBody)
27
+ @last_x = node.x
28
+ @last_y = node.y
29
+ end
30
+
31
+ def update(dt)
32
+ blocked = intending_to_move? && !moved_since_last?
33
+ @last_x = node.x
34
+ @last_y = node.y
35
+
36
+ @timer -= dt
37
+ reroll if blocked || @timer <= 0.0
38
+ end
39
+
40
+ private
41
+
42
+ def intending_to_move? = !@body.move_x.zero? || !@body.move_y.zero?
43
+
44
+ def moved_since_last?
45
+ (node.x - @last_x).abs > MOVED_EPS || (node.y - @last_y).abs > MOVED_EPS
46
+ end
47
+
48
+ def reroll
49
+ if @rng.rand < @idle_chance
50
+ @body.set_intent(0.0, 0.0)
51
+ else
52
+ dx, dy = DIRECTIONS.sample(random: @rng)
53
+ @body.set_intent(dx.to_f, dy.to_f)
54
+ end
55
+ @timer = @rng.rand(@change_interval)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,106 @@
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 # frozen so the renderer caches the resolved colour
39
+ Z = 1_000_000 # above any scene content
40
+ PAD = 8 # margin from the screen edge
41
+ GAP = 8 # space between a label and its number
42
+
43
+ def initialize
44
+ @visible = false
45
+ @prev_allocated = 0
46
+ @digit_widths = Array.new(10) # measured once, then reused
47
+ @label_widths = {} # measured once per label, then reused
48
+ end
49
+
50
+ def visible? = @visible
51
+
52
+ def toggle
53
+ @visible = !@visible
54
+ # Baseline the counter on reveal so the first Δ/f isn't the whole run's history.
55
+ @prev_allocated = GC.stat(:total_allocated_objects) if @visible
56
+ end
57
+
58
+ def draw(renderer, width, height, fps)
59
+ return unless @visible
60
+
61
+ allocated = GC.stat(:total_allocated_objects)
62
+ delta = allocated - @prev_allocated
63
+ @prev_allocated = allocated
64
+
65
+ line_h = renderer.text_height
66
+ right_x = width - PAD
67
+ top_y = height - PAD - (line_h * 3)
68
+
69
+ draw_line(renderer, FPS_LABEL, fps, right_x, top_y)
70
+ draw_line(renderer, OBJ_LABEL, allocated, right_x, top_y + line_h)
71
+ draw_line(renderer, DELTA_LABEL, delta, right_x, top_y + (line_h * 2))
72
+ end
73
+
74
+ private
75
+
76
+ # A right-aligned "label number" row ending at right_x.
77
+ def draw_line(renderer, label, value, right_x, y)
78
+ number_left = draw_uint(renderer, value, right_x, y)
79
+ renderer.text(label, number_left - GAP - label_width(renderer, label), y, z: Z, color: COLOR)
80
+ end
81
+
82
+ # Draw a non-negative integer right-aligned ending at right_x, digit by digit so no
83
+ # String is built per frame. Returns the x of the leftmost digit.
84
+ def draw_uint(renderer, value, right_x, y)
85
+ x = right_x
86
+ more = true
87
+ while more
88
+ digit = value % 10
89
+ value /= 10
90
+ x -= digit_width(renderer, digit)
91
+ renderer.text(DIGITS[digit], x, y, z: Z, color: COLOR)
92
+ more = value.positive?
93
+ end
94
+ x
95
+ end
96
+
97
+ def digit_width(renderer, digit)
98
+ @digit_widths[digit] ||= renderer.text_width(DIGITS[digit])
99
+ end
100
+
101
+ def label_width(renderer, label)
102
+ @label_widths[label] ||= renderer.text_width(label)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module RGame
6
+ module Engine
7
+ # Minimal localization: per-locale translation tables (loaded from YAML or a Hash),
8
+ # `t(key)` with `%{var}` interpolation and a fallback locale, and a `generation`
9
+ # counter that ticks whenever the locale changes — so cached UI text knows when to
10
+ # re-resolve without polling every frame. A global module (like EventDispatcher),
11
+ # so `t` is reachable anywhere. Pure Ruby; YAML is the only (stdlib) dependency.
12
+ module I18n
13
+ class << self
14
+ attr_reader :generation
15
+
16
+ # The locale `t` falls back to when the current locale lacks a key.
17
+ def default = @fallback
18
+
19
+ def default=(locale)
20
+ @fallback = locale.to_sym
21
+ end
22
+
23
+ def reset
24
+ @locales = {}
25
+ @current = :en
26
+ @fallback = :en
27
+ @generation = 0
28
+ end
29
+
30
+ # Register a locale's translations (nested Hashes allowed). Keys are symbolized
31
+ # so YAML ("string keys") and inline symbol keys look the same to `t`.
32
+ def load(locale, translations)
33
+ @locales[locale.to_sym] = symbolize(translations)
34
+ self
35
+ end
36
+
37
+ def load_file(locale, path)
38
+ load(locale, YAML.load_file(path))
39
+ end
40
+
41
+ def locale = @current
42
+
43
+ # Switching the locale bumps the generation so observers re-resolve their text.
44
+ def locale=(locale)
45
+ locale = locale.to_sym
46
+ return if locale == @current
47
+
48
+ @current = locale
49
+ @generation += 1
50
+ end
51
+
52
+ def available = @locales.keys
53
+
54
+ # Resolve a dotted key ("menu.title" or :menu_title) in the current locale, then
55
+ # the fallback locale, then the key itself; interpolate %{var} from `vars`.
56
+ #
57
+ # Pass `count:` to pluralize: the key's value is then a table of forms
58
+ # ({ one:, other:, optionally zero: }), and `count` is also exposed to
59
+ # interpolation as %{count}. English/German use the one/other rule.
60
+ def t(key, count: nil, **vars)
61
+ value = lookup(@current, key) || lookup(@fallback, key)
62
+ value = pluralize(value, count) if count && value.is_a?(Hash)
63
+ return key.to_s unless value.is_a?(String)
64
+
65
+ vars = vars.merge(count: count) if count
66
+ vars.empty? ? value : (value % vars)
67
+ end
68
+
69
+ private
70
+
71
+ def pluralize(forms, count)
72
+ return forms[:zero] if count.zero? && forms.key?(:zero)
73
+
74
+ forms[count == 1 ? :one : :other] || forms[:other]
75
+ end
76
+
77
+ def lookup(locale, key)
78
+ node = @locales[locale]
79
+ key.to_s.split('.').each do |segment|
80
+ return nil unless node.is_a?(Hash)
81
+
82
+ node = node[segment.to_sym]
83
+ end
84
+ node
85
+ end
86
+
87
+ def symbolize(value)
88
+ return value unless value.is_a?(Hash)
89
+
90
+ value.to_h { |k, v| [k.to_sym, symbolize(v)] }
91
+ end
92
+ end
93
+
94
+ reset
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Polls an InputBackend through a binding map and produces an Actions snapshot.
6
+ # The map is `{ action_name => { axis: %i[neg pos] } | { button: %i[ids] } }`;
7
+ # physical ids are decoupled from any backend's constants (the backend resolves
8
+ # them), so remapping is just swapping this data.
9
+ #
10
+ # Pure logic: the backend is duck-typed — the whole interface is
11
+ # `down?(physical_id)` — so a test passes a fake and a game passes
12
+ # `RGame::Core::Input`.
13
+ class ActionMapper
14
+ attr_accessor :map
15
+
16
+ def initialize(map)
17
+ @map = map
18
+ # One reusable snapshot: there's exactly one input state per tick, so we
19
+ # mutate these in place each poll instead of allocating (after the first
20
+ # poll warms the hash keys, steady-state polling allocates nothing).
21
+ # `@prev_held` carries last frame's button state so Actions can answer edge
22
+ # queries (pressed?/released?).
23
+ @held = {}
24
+ @prev_held = {}
25
+ @axes = {}
26
+ @actions = Actions.new(held: @held, axes: @axes, prev_held: @prev_held)
27
+ end
28
+
29
+ def poll(backend)
30
+ # Snapshot this frame's held state as "previous" before recomputing it
31
+ # (in-place copy: no allocation once the keys exist).
32
+ @held.each { |name, down| @prev_held[name] = down }
33
+
34
+ @map.each do |name, binding|
35
+ if (axis = binding[:axis])
36
+ neg, pos = axis
37
+ @axes[name] = (backend.down?(pos) ? 1.0 : 0.0) - (backend.down?(neg) ? 1.0 : 0.0)
38
+ end
39
+ @held[name] = binding[:button].any? { |b| backend.down?(b) } if binding[:button]
40
+ end
41
+
42
+ @actions
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # An immutable per-frame snapshot of abstract action state. Game logic reads
6
+ # this, never physical keys. Built by ActionMapper (or constructed directly in
7
+ # tests with a fake).
8
+ #
9
+ # Edge queries (`pressed?`/`released?`) compare against the previous frame's held
10
+ # state, so a one-shot action (menu confirm, jump) fires exactly once per press
11
+ # rather than every frame it's held.
12
+ class Actions
13
+ # `held`, `axes` and `prev_held` are mutable hashes the mapper updates in place
14
+ # each poll, so the snapshot stays a single reused, allocation-free object.
15
+ def initialize(held: {}, axes: {}, prev_held: {})
16
+ @held = held
17
+ @axes = axes
18
+ @prev_held = prev_held
19
+ end
20
+
21
+ def held?(name)
22
+ @held.fetch(name, false)
23
+ end
24
+
25
+ # True only on the frame the action transitions up→down.
26
+ def pressed?(name)
27
+ @held.fetch(name, false) && !@prev_held.fetch(name, false)
28
+ end
29
+
30
+ # True only on the frame the action transitions down→up.
31
+ def released?(name)
32
+ !@held.fetch(name, false) && @prev_held.fetch(name, false)
33
+ end
34
+
35
+ # Analog value in [-1.0, 1.0]; 0.0 if unbound/neutral.
36
+ def axis(name)
37
+ @axes.fetch(name, 0.0)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Drives an Actor from player input: turns the move_x/move_y actions into a
6
+ # movement intent. NPC controllers implement the same `intent(dt, input)` seam
7
+ # and ignore `input`.
8
+ class PlayerController
9
+ def intent(_dt, input)
10
+ [input.axis(:move_x), input.axis(:move_y)]
11
+ end
12
+ end
13
+ end
14
+ end