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.
- checksums.yaml +7 -0
- data/LICENSE +26 -0
- data/README.md +406 -0
- data/docs/api/README.md +167 -0
- data/docs/api/app.md +192 -0
- data/docs/api/assets.md +426 -0
- data/docs/api/audio.md +208 -0
- data/docs/api/components.md +321 -0
- data/docs/api/drawing.md +330 -0
- data/docs/api/game.md +99 -0
- data/docs/api/images.md +118 -0
- data/docs/api/input.md +179 -0
- data/docs/api/internals.md +110 -0
- data/docs/api/scene_graph.md +159 -0
- data/docs/api/signals.md +142 -0
- data/docs/api/systems.md +98 -0
- data/docs/api/text.md +116 -0
- data/docs/api/toolbox.md +240 -0
- data/docs/api/values.md +101 -0
- data/ext/README.md +225 -0
- data/ext/rgame_core/app/app.c +721 -0
- data/ext/rgame_core/app/app_gl.h +64 -0
- data/ext/rgame_core/app/frame_loop.c +42 -0
- data/ext/rgame_core/app/frame_loop.h +54 -0
- data/ext/rgame_core/audio/audio.c +466 -0
- data/ext/rgame_core/audio/audio_internal.h +45 -0
- data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
- data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
- data/ext/rgame_core/example.rb +188 -0
- data/ext/rgame_core/extconf.rb +167 -0
- data/ext/rgame_core/graphics/backend.c +52 -0
- data/ext/rgame_core/graphics/backend.h +64 -0
- data/ext/rgame_core/graphics/canvas.c +247 -0
- data/ext/rgame_core/graphics/canvas.h +143 -0
- data/ext/rgame_core/graphics/clip.c +87 -0
- data/ext/rgame_core/graphics/clip.h +89 -0
- data/ext/rgame_core/graphics/draw_queue.c +216 -0
- data/ext/rgame_core/graphics/draw_queue.h +174 -0
- data/ext/rgame_core/graphics/gl_backend.c +122 -0
- data/ext/rgame_core/graphics/gl_backend.h +43 -0
- data/ext/rgame_core/graphics/image.c +304 -0
- data/ext/rgame_core/graphics/image_internal.h +30 -0
- data/ext/rgame_core/graphics/primitives.c +189 -0
- data/ext/rgame_core/graphics/primitives.h +111 -0
- data/ext/rgame_core/graphics/recording.c +119 -0
- data/ext/rgame_core/graphics/recording.h +88 -0
- data/ext/rgame_core/graphics/texture.c +181 -0
- data/ext/rgame_core/graphics/texture.h +165 -0
- data/ext/rgame_core/graphics/transform.c +128 -0
- data/ext/rgame_core/graphics/transform.h +106 -0
- data/ext/rgame_core/include/rgame/core.h +577 -0
- data/ext/rgame_core/input/device_slots.c +103 -0
- data/ext/rgame_core/input/device_slots.h +93 -0
- data/ext/rgame_core/input/gamepad.c +145 -0
- data/ext/rgame_core/input/gamepad.h +63 -0
- data/ext/rgame_core/input/input.c +109 -0
- data/ext/rgame_core/input/input.h +99 -0
- data/ext/rgame_core/ruby/audio_ext.c +321 -0
- data/ext/rgame_core/ruby/core_ext.c +513 -0
- data/ext/rgame_core/ruby/core_ext.h +51 -0
- data/ext/rgame_core/ruby/font_ext.c +168 -0
- data/ext/rgame_core/ruby/image_ext.c +230 -0
- data/ext/rgame_core/ruby/recording_ext.c +186 -0
- data/ext/rgame_core/ruby/renderer_ext.c +376 -0
- data/ext/rgame_core/text/atlas.c +59 -0
- data/ext/rgame_core/text/atlas.h +85 -0
- data/ext/rgame_core/text/font.c +281 -0
- data/ext/rgame_core/text/font.h +139 -0
- data/ext/rgame_core/text/font_atlas.c +385 -0
- data/ext/rgame_core/text/font_internal.h +47 -0
- data/ext/rgame_core/text/glyph_cache.c +142 -0
- data/ext/rgame_core/text/glyph_cache.h +89 -0
- data/ext/rgame_core/vendor/README.md +159 -0
- data/ext/rgame_core/vendor/miniaudio.h +95864 -0
- data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
- data/ext/rgame_core/vendor/stb_image.h +7988 -0
- data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
- data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
- data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
- data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
- data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
- data/ext/rgame_util/color.c +19 -0
- data/ext/rgame_util/color.h +60 -0
- data/ext/rgame_util/color_ext.c +156 -0
- data/ext/rgame_util/extconf.rb +27 -0
- data/ext/rgame_util/tensor.c +186 -0
- data/ext/rgame_util/util_ext.c +27 -0
- data/ext/rgame_util/util_ext.h +16 -0
- data/lib/rgame/boot.rb +13 -0
- data/lib/rgame/core/app.rb +82 -0
- data/lib/rgame/core/asset_manager.rb +224 -0
- data/lib/rgame/core/audio.rb +124 -0
- data/lib/rgame/core/font.rb +49 -0
- data/lib/rgame/core/gamepad.rb +55 -0
- data/lib/rgame/core/image.rb +55 -0
- data/lib/rgame/core/input.rb +77 -0
- data/lib/rgame/core/nine_slice.rb +163 -0
- data/lib/rgame/core/recording.rb +52 -0
- data/lib/rgame/core/renderer.rb +363 -0
- data/lib/rgame/core/sprite_sheet.rb +108 -0
- data/lib/rgame/core/tile_map_renderer.rb +160 -0
- data/lib/rgame/core/ui_atlas.rb +86 -0
- data/lib/rgame/core.rb +24 -0
- data/lib/rgame/engine/actor.rb +53 -0
- data/lib/rgame/engine/animation_set.rb +49 -0
- data/lib/rgame/engine/animator.rb +44 -0
- data/lib/rgame/engine/audio_bus.rb +24 -0
- data/lib/rgame/engine/audio_director.rb +29 -0
- data/lib/rgame/engine/body.rb +49 -0
- data/lib/rgame/engine/cached_label.rb +33 -0
- data/lib/rgame/engine/camera.rb +33 -0
- data/lib/rgame/engine/camera_view.rb +28 -0
- data/lib/rgame/engine/circle_collider.rb +32 -0
- data/lib/rgame/engine/collision_box.rb +34 -0
- data/lib/rgame/engine/collision_system.rb +44 -0
- data/lib/rgame/engine/component.rb +30 -0
- data/lib/rgame/engine/components/action_trigger.rb +41 -0
- data/lib/rgame/engine/components/animated_sprite.rb +63 -0
- data/lib/rgame/engine/components/character_body.rb +70 -0
- data/lib/rgame/engine/components/circle_collider.rb +44 -0
- data/lib/rgame/engine/components/collision_world.rb +103 -0
- data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
- data/lib/rgame/engine/components/path_follow.rb +84 -0
- data/lib/rgame/engine/components/player_controller.rb +24 -0
- data/lib/rgame/engine/components/pool.rb +53 -0
- data/lib/rgame/engine/components/screen_wrap.rb +27 -0
- data/lib/rgame/engine/components/sprite.rb +31 -0
- data/lib/rgame/engine/components/targeting.rb +54 -0
- data/lib/rgame/engine/components/thrust_controller.rb +65 -0
- data/lib/rgame/engine/components/tile_world.rb +68 -0
- data/lib/rgame/engine/components/timer.rb +75 -0
- data/lib/rgame/engine/components/velocity.rb +27 -0
- data/lib/rgame/engine/components/wander_controller.rb +60 -0
- data/lib/rgame/engine/debug_overlay.rb +106 -0
- data/lib/rgame/engine/i18n.rb +97 -0
- data/lib/rgame/engine/input/action_mapper.rb +46 -0
- data/lib/rgame/engine/input/actions.rb +41 -0
- data/lib/rgame/engine/input/player_controller.rb +14 -0
- data/lib/rgame/engine/matrix.rb +32 -0
- data/lib/rgame/engine/node2d.rb +271 -0
- data/lib/rgame/engine/path.rb +78 -0
- data/lib/rgame/engine/pool.rb +51 -0
- data/lib/rgame/engine/resettable.rb +67 -0
- data/lib/rgame/engine/scene/scene_stack.rb +65 -0
- data/lib/rgame/engine/signal.rb +75 -0
- data/lib/rgame/engine/spatial_hash.rb +71 -0
- data/lib/rgame/engine/tile_collision.rb +78 -0
- data/lib/rgame/engine/tile_map.rb +149 -0
- data/lib/rgame/engine/tileset.rb +101 -0
- data/lib/rgame/engine/timer.rb +51 -0
- data/lib/rgame/engine.rb +68 -0
- data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
- data/lib/rgame/fonts/OFL.txt +102 -0
- data/lib/rgame/game.rb +129 -0
- data/lib/rgame/util/color.rb +27 -0
- data/lib/rgame/util/controls.rb +107 -0
- data/lib/rgame/util/tensor.rb +12 -0
- data/lib/rgame/util.rb +8 -0
- data/lib/rgame/version.rb +12 -0
- data/lib/rgame.rb +20 -0
- 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
|