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,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A character's collision rectangle, expressed as an offset + size relative to
|
|
6
|
+
# the actor's top-left origin (the sprite's top-left). Decoupled from sprite
|
|
7
|
+
# size: a 32x32 sprite can have a small 16x16 box at its feet.
|
|
8
|
+
class CollisionBox
|
|
9
|
+
attr_reader :offset_x, :offset_y, :width, :height
|
|
10
|
+
|
|
11
|
+
# Centre the box horizontally and anchor it to the bottom of the sprite (feet).
|
|
12
|
+
def self.bottom_anchored(sprite_width:, sprite_height:, width:, height:)
|
|
13
|
+
new(
|
|
14
|
+
offset_x: (sprite_width - width) / 2,
|
|
15
|
+
offset_y: sprite_height - height,
|
|
16
|
+
width: width,
|
|
17
|
+
height: height
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(offset_x:, offset_y:, width:, height:)
|
|
22
|
+
@offset_x = offset_x
|
|
23
|
+
@offset_y = offset_y
|
|
24
|
+
@width = width
|
|
25
|
+
@height = height
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# World-space AABB [x, y, w, h] for an actor whose origin is at (x, y).
|
|
29
|
+
def aabb(x, y)
|
|
30
|
+
[x + @offset_x, y + @offset_y, @width, @height]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# The actor-facing collision system: moves any actor by a delta, resolving its
|
|
6
|
+
# *collision box* (not its sprite) against the tiles via TileCollision, then
|
|
7
|
+
# clamping the box inside the world as a backstop. Reusable by the player and
|
|
8
|
+
# any NPC.
|
|
9
|
+
#
|
|
10
|
+
class CollisionSystem
|
|
11
|
+
def initialize(tile_collision:, world_width:, world_height:)
|
|
12
|
+
@tiles = tile_collision
|
|
13
|
+
@world_width = world_width
|
|
14
|
+
@world_height = world_height
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Move `actor` by (dx, dy), writing the resolved position back to it.
|
|
18
|
+
def move(actor, dx, dy)
|
|
19
|
+
box = actor.collision_box
|
|
20
|
+
# Read the box AABB into locals directly rather than via box.aabb, which would
|
|
21
|
+
# allocate an Array on this per-frame path.
|
|
22
|
+
bx = actor.x + box.offset_x
|
|
23
|
+
by = actor.y + box.offset_y
|
|
24
|
+
bw = box.width
|
|
25
|
+
bh = box.height
|
|
26
|
+
|
|
27
|
+
bx = @tiles.resolve_x(bx, by, bw, bh, dx)
|
|
28
|
+
by = @tiles.resolve_y(bx, by, bw, bh, dy)
|
|
29
|
+
|
|
30
|
+
# Clamp the box inside the world. Floor each upper bound at 0 without a [span, 0]
|
|
31
|
+
# array (this runs per actor per frame).
|
|
32
|
+
max_x = @world_width - bw
|
|
33
|
+
max_x = 0 if max_x.negative?
|
|
34
|
+
max_y = @world_height - bh
|
|
35
|
+
max_y = 0 if max_y.negative?
|
|
36
|
+
bx = bx.clamp(0.0, max_x.to_f)
|
|
37
|
+
by = by.clamp(0.0, max_y.to_f)
|
|
38
|
+
|
|
39
|
+
actor.x = bx - box.offset_x
|
|
40
|
+
actor.y = by - box.offset_y
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
class Component
|
|
6
|
+
extend Signal::DSL
|
|
7
|
+
|
|
8
|
+
attr_accessor :node
|
|
9
|
+
|
|
10
|
+
def context
|
|
11
|
+
@context ||= node.context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Tree-lifecycle hooks. on_attach fires once the node is in the live tree, so
|
|
15
|
+
# node.root / node.scene and sibling systems are reachable — pull and register
|
|
16
|
+
# shared systems here, not in initialize (where the node has no anchors yet).
|
|
17
|
+
# on_detach mirrors it: release those registrations.
|
|
18
|
+
def on_attach; end
|
|
19
|
+
def on_detach; end
|
|
20
|
+
|
|
21
|
+
def control(actions); end
|
|
22
|
+
def update(dt); end
|
|
23
|
+
def draw(renderer); end
|
|
24
|
+
|
|
25
|
+
# Container components (e.g. SceneStack) that hold nodes off the normal child
|
|
26
|
+
# list override this to forward the deferred-free sweep into them.
|
|
27
|
+
def sweep_freed; end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Maps held input actions to an `on_triggered(action)` signal, rate-limited by a
|
|
7
|
+
# per-action cooldown. One instance covers several actions (the engine allows
|
|
8
|
+
# only one component of a class per node), so it emits the action name and lets
|
|
9
|
+
# listeners filter — reusable for "fire" here, or "jump"/"fire" in a platformer.
|
|
10
|
+
#
|
|
11
|
+
# trigger = node.add_component(ActionTrigger.new(fire: 0.22, dash: 0.5))
|
|
12
|
+
# trigger.on_triggered { |action| fire if action == :fire }
|
|
13
|
+
#
|
|
14
|
+
# Semantics: while an action is held and its cooldown has elapsed, it fires and
|
|
15
|
+
# the cooldown restarts — i.e. auto-repeat at the cooldown rate.
|
|
16
|
+
class ActionTrigger < Engine::Component
|
|
17
|
+
signal :on_triggered, Engine::Signal.define(:action)
|
|
18
|
+
|
|
19
|
+
def initialize(cooldowns)
|
|
20
|
+
super()
|
|
21
|
+
@cooldowns = cooldowns
|
|
22
|
+
# action => seconds remaining until it may fire again (0 = ready).
|
|
23
|
+
@timers = cooldowns.transform_values { 0.0 }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def update(dt)
|
|
27
|
+
@timers.each { |action, remaining| @timers[action] = remaining - dt if remaining.positive? }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def control(actions)
|
|
31
|
+
@cooldowns.each_key do |action|
|
|
32
|
+
next unless actions.held?(action) && @timers[action] <= 0.0
|
|
33
|
+
|
|
34
|
+
@timers[action] = @cooldowns[action]
|
|
35
|
+
on_triggered_signal.emit(action)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Draws a sprite-sheet animation for a walking actor, picking the animation from a
|
|
7
|
+
# CharacterBody sibling's movement intent: walk_left/right/up/down while moving
|
|
8
|
+
# (horizontal wins on a diagonal), stand when still. Owns its Animator + the pure
|
|
9
|
+
# AnimationSet built from the sheet's animation table.
|
|
10
|
+
#
|
|
11
|
+
# Like Sprite, it passes NO angle and draws at the node's *world* origin
|
|
12
|
+
# (node.abs_x/abs_y): a CameraView ancestor wraps the draw in renderer.translated to
|
|
13
|
+
# map world → screen, so this component never touches the camera. `z` is the render
|
|
14
|
+
# layer (kept as @layer, distinct from the node's transform z); it must sit between
|
|
15
|
+
# the tile world's ground band and its overlay band so canopies draw in front.
|
|
16
|
+
#
|
|
17
|
+
# `sheet` is the asset's relative path. The component resolves it from the game's
|
|
18
|
+
# asset manager on attach — via node.root.context.assets (the platform seam) — to
|
|
19
|
+
# build its animation table and to size the node to the sprite's frame, so siblings
|
|
20
|
+
# like CharacterBody can read node.width/height. The renderer resolves the same
|
|
21
|
+
# symbol when drawing, so nothing is registered or passed in by hand.
|
|
22
|
+
class AnimatedSprite < Engine::Component
|
|
23
|
+
def initialize(sheet:, z: 0)
|
|
24
|
+
super()
|
|
25
|
+
@sheet = sheet
|
|
26
|
+
@layer = z
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def on_attach
|
|
30
|
+
sheet = context.assets.sheet(@sheet)
|
|
31
|
+
@animator = Engine::Animator.new(Engine::AnimationSet.new(sheet.animations))
|
|
32
|
+
node.width = sheet.frame_width
|
|
33
|
+
node.height = sheet.frame_height
|
|
34
|
+
@body = node.get_component(CharacterBody)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update(dt)
|
|
38
|
+
@animator.play(walk_animation(@body.move_x, @body.move_y))
|
|
39
|
+
@animator.update(dt)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def draw(renderer)
|
|
43
|
+
# Read row/col/flip_x separately (not @animator.frame, which allocates an Array
|
|
44
|
+
# every call) to keep the draw path allocation-free.
|
|
45
|
+
renderer.sprite(@sheet, @animator.row, @animator.col, node.abs_x, node.abs_y,
|
|
46
|
+
flip_x: @animator.flip_x, z: @layer)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Horizontal movement wins over vertical, so a diagonal walk faces left/right.
|
|
52
|
+
def walk_animation(move_x, move_y)
|
|
53
|
+
if move_x.negative? then :walk_left
|
|
54
|
+
elsif move_x.positive? then :walk_right
|
|
55
|
+
elsif move_y.negative? then :walk_up
|
|
56
|
+
elsif move_y.positive? then :walk_down
|
|
57
|
+
else :stand
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Walking movement for a tile-bound actor (player or NPC). A controller writes a
|
|
7
|
+
# per-step movement intent — each axis in -1..1 — and this component turns it into
|
|
8
|
+
# a real move each update, resolved against the scene's TileWorld so the actor
|
|
9
|
+
# slides along walls and stays inside the map. Distinct from Velocity (which
|
|
10
|
+
# integrates blindly): here every step is collision-checked.
|
|
11
|
+
#
|
|
12
|
+
# The intent doubles as the facing for AnimatedSprite (move_x / move_y readers), so
|
|
13
|
+
# a character is just CharacterBody + a controller + AnimatedSprite.
|
|
14
|
+
#
|
|
15
|
+
# The feet box is built from the node's dimensions (which AnimatedSprite sets from the
|
|
16
|
+
# sprite frame), not a passed-in sprite size — `feet_width`/`feet_height` are the box,
|
|
17
|
+
# centred horizontally and anchored to the node's bottom. It's built lazily on first
|
|
18
|
+
# use (the first update, after every on_attach has run), so the order components are
|
|
19
|
+
# added in doesn't matter. A body with no sprite must set the node's width/height.
|
|
20
|
+
class CharacterBody < Engine::Component
|
|
21
|
+
attr_reader :move_x, :move_y
|
|
22
|
+
|
|
23
|
+
def initialize(feet_width:, feet_height:, speed:)
|
|
24
|
+
super()
|
|
25
|
+
@feet_width = feet_width
|
|
26
|
+
@feet_height = feet_height
|
|
27
|
+
@speed = speed
|
|
28
|
+
@move_x = 0.0
|
|
29
|
+
@move_y = 0.0
|
|
30
|
+
@collision_box = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def collision_box
|
|
34
|
+
@collision_box ||= Engine::CollisionBox.bottom_anchored(
|
|
35
|
+
sprite_width: node.width, sprite_height: node.height,
|
|
36
|
+
width: @feet_width, height: @feet_height
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The TileWorld is a scene-scoped system, reachable once we're in the tree.
|
|
41
|
+
def on_attach = @world = node.system(TileWorld)
|
|
42
|
+
|
|
43
|
+
# Set this step's movement intent; each axis is in -1..1.
|
|
44
|
+
def set_intent(intent_x, intent_y)
|
|
45
|
+
@move_x = intent_x
|
|
46
|
+
@move_y = intent_y
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def update(dt)
|
|
50
|
+
return if @move_x.zero? && @move_y.zero?
|
|
51
|
+
|
|
52
|
+
@world.move(self, @move_x * @speed * dt, @move_y * @speed * dt)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# CollisionSystem#move drives an "actor" through x/y/collision_box and writes the
|
|
56
|
+
# resolved position back — back those by the owning node's transform.
|
|
57
|
+
def x = node.x
|
|
58
|
+
def y = node.y
|
|
59
|
+
|
|
60
|
+
def x=(value)
|
|
61
|
+
node.x = value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def y=(value)
|
|
65
|
+
node.y = value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# A circular collision shape on a node. It registers itself with the scene's
|
|
7
|
+
# CollisionWorld system when it enters the tree and unregisters on leaving —
|
|
8
|
+
# the engine fires both hooks, so a spawned/despawned entity can't leak a
|
|
9
|
+
# registration. Its world centre is the node's resolved absolute origin; the
|
|
10
|
+
# `layer` is an opaque tag the game reads in its on_hit handler to decide what a
|
|
11
|
+
# contact means (bullet-vs-rock, etc.). See docs/api/systems.md.
|
|
12
|
+
class CircleCollider < Engine::Component
|
|
13
|
+
# Fired by CollisionWorld for each overlapping collider; the listener gets the
|
|
14
|
+
# other collider and reads its #layer / #node to react.
|
|
15
|
+
signal :on_hit, Engine::Signal.define(:other)
|
|
16
|
+
|
|
17
|
+
# radius is writable so a pooled entity (e.g. a multi-tier rock) can retune its
|
|
18
|
+
# shape on reset; CollisionWorld reads it fresh each frame, so no re-registration.
|
|
19
|
+
attr_accessor :radius
|
|
20
|
+
attr_reader :layer
|
|
21
|
+
|
|
22
|
+
def initialize(radius:, layer: :default)
|
|
23
|
+
super()
|
|
24
|
+
@radius = radius
|
|
25
|
+
@layer = layer
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on_attach = node.system(CollisionWorld).register(self)
|
|
29
|
+
def on_detach = node.system(CollisionWorld)&.unregister(self)
|
|
30
|
+
|
|
31
|
+
# World-space centre — the node's resolved absolute origin.
|
|
32
|
+
def cx = node.abs_x
|
|
33
|
+
def cy = node.abs_y
|
|
34
|
+
|
|
35
|
+
def overlap?(other)
|
|
36
|
+
Engine::CircleCollider.overlap?(cx, cy, @radius, other.cx, other.cy, other.radius)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Called by CollisionWorld on contact (the signal's emit is otherwise private).
|
|
40
|
+
def emit_hit(other) = on_hit_signal.emit(other)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Scene-scoped broadphase collision system: a Component that lives on the scene
|
|
7
|
+
# node (so it is born and torn down with the scene, and rides the normal update
|
|
8
|
+
# traversal). CircleColliders register/unregister with it via their tree
|
|
9
|
+
# lifecycle; each update it buckets them in a SpatialHash and fires on_hit on
|
|
10
|
+
# every overlapping pair. It is layer-agnostic — it reports contacts and lets
|
|
11
|
+
# the colliders' owners decide meaning. See docs/api/systems.md.
|
|
12
|
+
class CollisionWorld < Engine::Component
|
|
13
|
+
def initialize(cell_size:)
|
|
14
|
+
super()
|
|
15
|
+
@hash = Engine::SpatialHash.new(cell_size: cell_size)
|
|
16
|
+
@colliders = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register(collider) = @colliders << collider
|
|
20
|
+
def unregister(collider) = @colliders.delete(collider)
|
|
21
|
+
|
|
22
|
+
# Yield every registered collider whose centre lies within `r` of (x, y), using
|
|
23
|
+
# the spatial index built by the most recent #update. The narrowphase is a
|
|
24
|
+
# centre-distance test — the query is a point + range (a tower's range ring), so
|
|
25
|
+
# the collider's own radius isn't added in. Colliders whose node is queued for
|
|
26
|
+
# removal are skipped. As with SpatialHash#query a collider spanning several cells
|
|
27
|
+
# may be yielded more than once, so callers that *select* (e.g. #nearest) are
|
|
28
|
+
# written dup-insensitively. Layer-agnostic — filter by `collider.layer` in the
|
|
29
|
+
# block. Allocation-free.
|
|
30
|
+
def query_circle(x, y, r)
|
|
31
|
+
r2 = r * r
|
|
32
|
+
@hash.query_circle(x, y, r) do |collider|
|
|
33
|
+
next if collider.node.freed?
|
|
34
|
+
|
|
35
|
+
dx = collider.cx - x
|
|
36
|
+
dy = collider.cy - y
|
|
37
|
+
yield collider if (dx * dx) + (dy * dy) <= r2
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The registered collider nearest to (x, y) within range `r`, or nil when none
|
|
42
|
+
# qualifies. Restrict to a single `layer:` (the common case: a tower targeting only
|
|
43
|
+
# :enemy). Dup-safe — it keeps the running minimum, so #query_circle's possible
|
|
44
|
+
# multi-cell repeats don't matter. Allocation-free.
|
|
45
|
+
def nearest(x, y, r, layer: nil)
|
|
46
|
+
best = nil
|
|
47
|
+
best_d2 = nil
|
|
48
|
+
query_circle(x, y, r) do |collider|
|
|
49
|
+
next if layer && collider.layer != layer
|
|
50
|
+
|
|
51
|
+
dx = collider.cx - x
|
|
52
|
+
dy = collider.cy - y
|
|
53
|
+
d2 = (dx * dx) + (dy * dy)
|
|
54
|
+
next unless best_d2.nil? || d2 < best_d2
|
|
55
|
+
|
|
56
|
+
best = collider
|
|
57
|
+
best_d2 = d2
|
|
58
|
+
end
|
|
59
|
+
best
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def update(_dt)
|
|
63
|
+
@hash.clear
|
|
64
|
+
@colliders.each { |c| insert(c) }
|
|
65
|
+
|
|
66
|
+
# Index-bounded over the count at frame start: an on_hit handler may spawn
|
|
67
|
+
# entities (a rock splitting), which `register`s new colliders mid-loop; those
|
|
68
|
+
# appended ones are skipped this frame (processed next) rather than mutating
|
|
69
|
+
# the array being iterated. The hash was built before the loop, so they're
|
|
70
|
+
# absent from queries too — consistent.
|
|
71
|
+
count = @colliders.size
|
|
72
|
+
i = 0
|
|
73
|
+
while i < count
|
|
74
|
+
a = @colliders[i]
|
|
75
|
+
i += 1
|
|
76
|
+
next if a.node.freed?
|
|
77
|
+
|
|
78
|
+
r = a.radius
|
|
79
|
+
d = r * 2
|
|
80
|
+
@hash.query(a.cx - r, a.cy - r, d, d) do |b|
|
|
81
|
+
# object_id ordering visits each unordered pair once (and skips self);
|
|
82
|
+
# the freed? guards skip nodes already queued for removal, so a dead
|
|
83
|
+
# entity stops colliding and duplicate (multi-cell) yields are ignored.
|
|
84
|
+
next if a.node.freed? || b.node.freed? || a.object_id >= b.object_id
|
|
85
|
+
next unless a.overlap?(b)
|
|
86
|
+
|
|
87
|
+
a.emit_hit(b)
|
|
88
|
+
b.emit_hit(a)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def insert(collider)
|
|
96
|
+
r = collider.radius
|
|
97
|
+
d = r * 2
|
|
98
|
+
@hash.insert(collider, collider.cx - r, collider.cy - r, d, d)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Queues the node for removal once it has fully left the bounds (plus margin).
|
|
7
|
+
# From Body#offscreen?. Used by short-lived projectiles; removal is deferred via
|
|
8
|
+
# queue_free so it is safe to trigger from inside the update traversal.
|
|
9
|
+
class DespawnOffscreen < Engine::Component
|
|
10
|
+
def initialize(width:, height:, margin: 0.0)
|
|
11
|
+
super()
|
|
12
|
+
@width = width
|
|
13
|
+
@height = height
|
|
14
|
+
@margin = margin
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update(_dt)
|
|
18
|
+
x = node.x
|
|
19
|
+
y = node.y
|
|
20
|
+
offscreen = x < -@margin || x > @width + @margin || y < -@margin || y > @height + @margin
|
|
21
|
+
node.queue_free if offscreen
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Walks the owning node along an Engine::Path at a constant speed, segment by
|
|
7
|
+
# segment, and emits `on_finished` once it reaches the final waypoint — the seam a
|
|
8
|
+
# tower defense game uses to leak a life when an enemy reaches the base.
|
|
9
|
+
#
|
|
10
|
+
# The walk is allocation-free: it tracks the current segment and the distance into
|
|
11
|
+
# it, advancing through as many segments as one step crosses (so a fast mover over
|
|
12
|
+
# short segments still lands correctly), then interpolates the node's position from
|
|
13
|
+
# the segment endpoints. Movement is driven purely by `speed * dt`; this is not a
|
|
14
|
+
# Velocity integrator and ignores the node's angle.
|
|
15
|
+
class PathFollow < Engine::Component
|
|
16
|
+
signal :on_finished # emits no payload; the owning node identifies which follower finished
|
|
17
|
+
|
|
18
|
+
attr_accessor :speed
|
|
19
|
+
|
|
20
|
+
def initialize(path:, speed:)
|
|
21
|
+
super()
|
|
22
|
+
@path = path
|
|
23
|
+
@speed = speed
|
|
24
|
+
@segment = 0 # walking from waypoint @segment to @segment + 1
|
|
25
|
+
@distance = 0.0 # distance travelled into the current segment
|
|
26
|
+
@finished = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def finished? = @finished
|
|
30
|
+
|
|
31
|
+
# Restart the walk as the node enters the tree — back to the first waypoint, with
|
|
32
|
+
# progress cleared — so a pooled follower reacquired and re-added begins a fresh walk
|
|
33
|
+
# rather than resuming (or staying finished) where its previous life ended.
|
|
34
|
+
def on_attach
|
|
35
|
+
@segment = 0
|
|
36
|
+
@distance = 0.0
|
|
37
|
+
@finished = false
|
|
38
|
+
place_at(0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def update(dt)
|
|
42
|
+
return if @finished
|
|
43
|
+
|
|
44
|
+
remaining = @speed * dt
|
|
45
|
+
while remaining.positive?
|
|
46
|
+
left = @path.segment_length(@segment) - @distance
|
|
47
|
+
if remaining < left
|
|
48
|
+
@distance += remaining
|
|
49
|
+
break
|
|
50
|
+
end
|
|
51
|
+
# Consume the rest of this segment and step onto the next waypoint.
|
|
52
|
+
remaining -= left
|
|
53
|
+
@segment += 1
|
|
54
|
+
@distance = 0.0
|
|
55
|
+
return finish if @segment >= @path.count - 1
|
|
56
|
+
end
|
|
57
|
+
place_along_segment
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def finish
|
|
63
|
+
place_at(@path.count - 1)
|
|
64
|
+
@finished = true
|
|
65
|
+
on_finished_signal.emit
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def place_at(waypoint)
|
|
69
|
+
node.x = @path.x_at(waypoint)
|
|
70
|
+
node.y = @path.y_at(waypoint)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def place_along_segment
|
|
74
|
+
seg_len = @path.segment_length(@segment)
|
|
75
|
+
t = seg_len.zero? ? 0.0 : @distance / seg_len
|
|
76
|
+
sx = @path.x_at(@segment)
|
|
77
|
+
sy = @path.y_at(@segment)
|
|
78
|
+
node.x = sx + ((@path.x_at(@segment + 1) - sx) * t)
|
|
79
|
+
node.y = sy + ((@path.y_at(@segment + 1) - sy) * t)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Drives a CharacterBody sibling from two input axes — direct 8-way walking, no
|
|
7
|
+
# inertia (unlike ThrustController). Each frame it copies the axis snapshot into
|
|
8
|
+
# the body's movement intent; the body does the collision-checked move.
|
|
9
|
+
class PlayerController < Engine::Component
|
|
10
|
+
def initialize(x_axis: :move_x, y_axis: :move_y)
|
|
11
|
+
super()
|
|
12
|
+
@x_axis = x_axis
|
|
13
|
+
@y_axis = y_axis
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def on_attach = @body = node.get_component(CharacterBody)
|
|
17
|
+
|
|
18
|
+
def control(actions)
|
|
19
|
+
@body.set_intent(actions.axis(@x_axis), actions.axis(@y_axis))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Wraps an Engine::Pool of nodes and folds the tree bookkeeping into the frame tick:
|
|
7
|
+
# `spawn` takes a node from the pool (recycled or freshly built) and adds it as a
|
|
8
|
+
# child of the owner, and `update` returns freed nodes to the pool — so a game only
|
|
9
|
+
# ever writes `pool.spawn` and the ordinary `node.queue_free`, never a hand-written
|
|
10
|
+
# acquire/add/reclaim bridge. Pooled nodes are normal children, so the scene's usual
|
|
11
|
+
# traversal updates and draws them; this component only manages their pool membership.
|
|
12
|
+
#
|
|
13
|
+
# pool = add_component(Engine::Components::Pool.new { Enemy.new(path: @path) })
|
|
14
|
+
# pool.spawn # acquire + add as a child
|
|
15
|
+
# pool.spawn { |b| b.reset(x, y) } # reset before it enters the tree (projectiles)
|
|
16
|
+
# enemy.queue_free # → reclaimed back to the pool automatically
|
|
17
|
+
#
|
|
18
|
+
# Add it named (`as:`) like any component when a node needs more than one pool.
|
|
19
|
+
class Pool < Engine::Component
|
|
20
|
+
def initialize(&)
|
|
21
|
+
super()
|
|
22
|
+
@pool = Engine::Pool.new(&)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Take a node from the pool, let the caller re-initialise it before it goes live
|
|
26
|
+
# (optional block — runs before on_attach), and add it as a child of the owner.
|
|
27
|
+
def spawn
|
|
28
|
+
child = @pool.acquire
|
|
29
|
+
yield child if block_given?
|
|
30
|
+
node.add_node(child)
|
|
31
|
+
child
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Ride the tick: return every freed pooled node to the free list, detaching any that
|
|
35
|
+
# are still in the tree — so a queue_free elsewhere recycles with no game-side wiring.
|
|
36
|
+
def update(_dt)
|
|
37
|
+
@pool.reclaim_if do |child|
|
|
38
|
+
next false unless child.freed?
|
|
39
|
+
|
|
40
|
+
node.remove_node(child) # idempotent if the deferred-free sweep already detached it
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def size = @pool.size
|
|
46
|
+
|
|
47
|
+
# True when no spawned node is currently live (all reclaimed) — a scene reads this to
|
|
48
|
+
# tell when a wave/round has been fully cleared.
|
|
49
|
+
def empty? = @pool.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Wraps the node's position toroidally within a rectangle (plus margin), so an
|
|
7
|
+
# entity leaving one edge reappears on the opposite one. From Body#wrap!. The
|
|
8
|
+
# bounds are passed in; in a scrolling game they'd come from a scene-scoped
|
|
9
|
+
# World system instead.
|
|
10
|
+
class ScreenWrap < Engine::Component
|
|
11
|
+
def initialize(width:, height:, margin: 0.0)
|
|
12
|
+
super()
|
|
13
|
+
@width = width
|
|
14
|
+
@height = height
|
|
15
|
+
@margin = margin
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def update(_dt)
|
|
19
|
+
node.x = @width + @margin if node.x < -@margin
|
|
20
|
+
node.x = -@margin if node.x > @width + @margin
|
|
21
|
+
node.y = @height + @margin if node.y < -@margin
|
|
22
|
+
node.y = -@margin if node.y > @height + @margin
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Draws a single registered image centered on the node's absolute origin. It
|
|
7
|
+
# passes NO angle to the renderer: Node2D#draw already wraps a node's own draws
|
|
8
|
+
# in renderer.rotated(abs_angle, abs_x, abs_y), so the node's rotation orients
|
|
9
|
+
# the sprite — passing an angle here too would rotate it twice.
|
|
10
|
+
#
|
|
11
|
+
# `scale` is writable so a pooled entity (e.g. a multi-tier rock) can retune it on
|
|
12
|
+
# reset. `z` is the render layer (NOT the node's transform z / abs_z) — kept under
|
|
13
|
+
# @layer to say so. For an animated sprite (sheet + frame index driving
|
|
14
|
+
# renderer.sprite), add a sibling AnimatedSprite component later.
|
|
15
|
+
class Sprite < Engine::Component
|
|
16
|
+
attr_accessor :scale
|
|
17
|
+
|
|
18
|
+
def initialize(id:, scale: 1.0, z: 0)
|
|
19
|
+
super()
|
|
20
|
+
@id = id
|
|
21
|
+
@scale = scale
|
|
22
|
+
@layer = z
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def draw(renderer)
|
|
26
|
+
renderer.image(@id, node.abs_x, node.abs_y, scale: @scale, z: @layer)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|