rgame 0.4.0-x86_64-linux-gnu
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +251 -0
- data/LICENSE +26 -0
- data/README.md +325 -0
- data/docs/api/README.md +224 -0
- data/docs/api/app.md +249 -0
- data/docs/api/assets.md +463 -0
- data/docs/api/audio.md +249 -0
- data/docs/api/cli.md +259 -0
- data/docs/api/components.md +1112 -0
- data/docs/api/drawing.md +398 -0
- data/docs/api/examples.md +263 -0
- data/docs/api/game.md +219 -0
- data/docs/api/images.md +118 -0
- data/docs/api/input.md +476 -0
- data/docs/api/internals.md +289 -0
- data/docs/api/localization.md +285 -0
- data/docs/api/scene_graph.md +539 -0
- data/docs/api/signals.md +147 -0
- data/docs/api/systems.md +231 -0
- data/docs/api/text.md +133 -0
- data/docs/api/tile_maps.md +237 -0
- data/docs/api/toolbox.md +493 -0
- data/docs/api/ui.md +927 -0
- data/docs/api/values.md +316 -0
- data/examples/assets/README.md +322 -0
- data/examples/assets/blip.ogg +0 -0
- data/examples/assets/glyphs.json +5 -0
- data/examples/assets/glyphs.png +0 -0
- data/examples/assets/hero.json +12 -0
- data/examples/assets/hero.png +0 -0
- data/examples/assets/icons.json +13 -0
- data/examples/assets/icons.png +0 -0
- data/examples/assets/music.ogg +0 -0
- data/examples/assets/skills.json +10 -0
- data/examples/assets/skills.png +0 -0
- data/examples/assets/tileset.png +0 -0
- data/examples/assets/tileset.tsx +65 -0
- data/examples/assets/town.tmx +26 -0
- data/examples/assets/ui.json +11 -0
- data/examples/assets/ui.png +0 -0
- data/examples/collision/locales/en.yml +8 -0
- data/examples/collision/main.rb +316 -0
- data/examples/collision_tiles/locales/en.yml +9 -0
- data/examples/collision_tiles/main.rb +274 -0
- data/examples/fullscreen/locales/en.yml +10 -0
- data/examples/fullscreen/main.rb +216 -0
- data/examples/game_menu/locales/en.yml +8 -0
- data/examples/game_menu/main.rb +170 -0
- data/examples/input_glyphs/locales/en.yml +14 -0
- data/examples/input_glyphs/main.rb +213 -0
- data/examples/jump_topdown/locales/en.yml +9 -0
- data/examples/jump_topdown/main.rb +178 -0
- data/examples/localization/locales/de.yml +12 -0
- data/examples/localization/locales/en.yml +13 -0
- data/examples/localization/main.rb +158 -0
- data/examples/menu_navigation/locales/en.yml +23 -0
- data/examples/menu_navigation/main.rb +365 -0
- data/examples/music/locales/en.yml +7 -0
- data/examples/music/main.rb +134 -0
- data/examples/pathfinding/locales/en.yml +17 -0
- data/examples/pathfinding/main.rb +298 -0
- data/examples/pooling/locales/en.yml +7 -0
- data/examples/pooling/main.rb +259 -0
- data/examples/quick_wheel/locales/en.yml +16 -0
- data/examples/quick_wheel/main.rb +184 -0
- data/examples/radial_menu/locales/en.yml +16 -0
- data/examples/radial_menu/main.rb +184 -0
- data/examples/save_load/locales/en.yml +11 -0
- data/examples/save_load/main.rb +207 -0
- data/examples/save_load_ids/locales/en.yml +11 -0
- data/examples/save_load_ids/main.rb +322 -0
- data/examples/scroll_map/locales/en.yml +4 -0
- data/examples/scroll_map/main.rb +140 -0
- data/examples/signals/locales/en.yml +6 -0
- data/examples/signals/main.rb +278 -0
- data/examples/skill_bar/locales/en.yml +14 -0
- data/examples/skill_bar/main.rb +159 -0
- data/examples/sound/locales/en.yml +6 -0
- data/examples/sound/main.rb +122 -0
- data/examples/split_screen/locales/en.yml +9 -0
- data/examples/split_screen/main.rb +304 -0
- data/examples/sprite/locales/en.yml +8 -0
- data/examples/sprite/main.rb +180 -0
- data/examples/timer/locales/en.yml +12 -0
- data/examples/timer/main.rb +273 -0
- data/examples/velocity/locales/en.yml +6 -0
- data/examples/velocity/main.rb +196 -0
- data/examples/walk/locales/en.yml +4 -0
- data/examples/walk/main.rb +99 -0
- data/exe/rgame +9 -0
- data/lib/rgame/boot.rb +3 -0
- data/lib/rgame/cli/new_project.rb +139 -0
- data/lib/rgame/cli/templates/Gemfile.tt +23 -0
- data/lib/rgame/cli/templates/README.md.tt +93 -0
- data/lib/rgame/cli/templates/Rakefile.tt +9 -0
- data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
- data/lib/rgame/cli/templates/game.rb.tt +23 -0
- data/lib/rgame/cli/templates/gitignore.tt +12 -0
- data/lib/rgame/cli/templates/main.rb.tt +11 -0
- data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
- data/lib/rgame/cli/templates/rspec.tt +2 -0
- data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
- data/lib/rgame/cli/templates/ruby-version.tt +1 -0
- data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
- data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
- data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
- data/lib/rgame/cli.rb +66 -0
- data/lib/rgame/core/app.rb +46 -0
- data/lib/rgame/core/asset_manager.rb +206 -0
- data/lib/rgame/core/audio.rb +145 -0
- data/lib/rgame/core/font.rb +46 -0
- data/lib/rgame/core/gamepad.rb +55 -0
- data/lib/rgame/core/image.rb +55 -0
- data/lib/rgame/core/input.rb +71 -0
- data/lib/rgame/core/locale.rb +22 -0
- data/lib/rgame/core/nine_slice.rb +142 -0
- data/lib/rgame/core/recording.rb +54 -0
- data/lib/rgame/core/renderer.rb +354 -0
- data/lib/rgame/core/sprite_sheet.rb +105 -0
- data/lib/rgame/core/tile_map_renderer.rb +172 -0
- data/lib/rgame/core/ui_atlas.rb +101 -0
- data/lib/rgame/core/virtual_gamepad.rb +26 -0
- data/lib/rgame/core.rb +18 -0
- data/lib/rgame/core_ext.so +0 -0
- data/lib/rgame/engine/actor_blockers.rb +131 -0
- data/lib/rgame/engine/animation_set.rb +50 -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 +59 -0
- data/lib/rgame/engine/bounds_blockers.rb +74 -0
- data/lib/rgame/engine/camera.rb +78 -0
- data/lib/rgame/engine/circle_collider.rb +34 -0
- data/lib/rgame/engine/collision_box.rb +59 -0
- data/lib/rgame/engine/collision_system.rb +132 -0
- data/lib/rgame/engine/component.rb +64 -0
- data/lib/rgame/engine/components/action_trigger.rb +40 -0
- data/lib/rgame/engine/components/animated_sprite.rb +71 -0
- data/lib/rgame/engine/components/box_collider.rb +99 -0
- data/lib/rgame/engine/components/camera_follow.rb +45 -0
- data/lib/rgame/engine/components/character_body.rb +50 -0
- data/lib/rgame/engine/components/circle_collider.rb +80 -0
- data/lib/rgame/engine/components/collision_world.rb +231 -0
- data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
- data/lib/rgame/engine/components/feet_collider.rb +61 -0
- data/lib/rgame/engine/components/hop.rb +76 -0
- data/lib/rgame/engine/components/identity.rb +73 -0
- data/lib/rgame/engine/components/mover.rb +285 -0
- data/lib/rgame/engine/components/navigator.rb +145 -0
- data/lib/rgame/engine/components/path_follow.rb +176 -0
- data/lib/rgame/engine/components/player_controller.rb +27 -0
- data/lib/rgame/engine/components/pool.rb +53 -0
- data/lib/rgame/engine/components/screen_wrap.rb +49 -0
- data/lib/rgame/engine/components/sprite.rb +47 -0
- data/lib/rgame/engine/components/targeting.rb +52 -0
- data/lib/rgame/engine/components/thrust_controller.rb +65 -0
- data/lib/rgame/engine/components/tile_world.rb +109 -0
- data/lib/rgame/engine/components/timer.rb +75 -0
- data/lib/rgame/engine/components/velocity.rb +43 -0
- data/lib/rgame/engine/components/wander_controller.rb +64 -0
- data/lib/rgame/engine/components/world.rb +133 -0
- data/lib/rgame/engine/contact_set.rb +74 -0
- data/lib/rgame/engine/culling.rb +45 -0
- data/lib/rgame/engine/debug_overlay.rb +111 -0
- data/lib/rgame/engine/i18n/plural.rb +45 -0
- data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
- data/lib/rgame/engine/i18n/template.rb +59 -0
- data/lib/rgame/engine/i18n.rb +322 -0
- data/lib/rgame/engine/input/action_mapper.rb +100 -0
- data/lib/rgame/engine/input/actions.rb +92 -0
- data/lib/rgame/engine/input/input_map.rb +196 -0
- data/lib/rgame/engine/layout.rb +82 -0
- data/lib/rgame/engine/nav_grid.rb +87 -0
- data/lib/rgame/engine/node2d.rb +555 -0
- data/lib/rgame/engine/path.rb +76 -0
- data/lib/rgame/engine/player.rb +69 -0
- data/lib/rgame/engine/player_layer.rb +70 -0
- data/lib/rgame/engine/players.rb +205 -0
- data/lib/rgame/engine/pool.rb +51 -0
- data/lib/rgame/engine/presentation.rb +171 -0
- data/lib/rgame/engine/scene/scene_stack.rb +87 -0
- data/lib/rgame/engine/sealed_privates.rb +54 -0
- data/lib/rgame/engine/signal.rb +75 -0
- data/lib/rgame/engine/spatial_hash.rb +116 -0
- data/lib/rgame/engine/text.rb +194 -0
- data/lib/rgame/engine/tile_blockers.rb +63 -0
- data/lib/rgame/engine/tile_map.rb +147 -0
- data/lib/rgame/engine/tile_map_layer.rb +82 -0
- data/lib/rgame/engine/tileset.rb +99 -0
- data/lib/rgame/engine/timer.rb +51 -0
- data/lib/rgame/engine/ui/button.rb +248 -0
- data/lib/rgame/engine/ui/column.rb +20 -0
- data/lib/rgame/engine/ui/icon_button.rb +93 -0
- data/lib/rgame/engine/ui/menu.rb +290 -0
- data/lib/rgame/engine/ui/navigation.rb +57 -0
- data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
- data/lib/rgame/engine/ui/option_button.rb +163 -0
- data/lib/rgame/engine/ui/panel_button.rb +32 -0
- data/lib/rgame/engine/ui/panel_menu.rb +36 -0
- data/lib/rgame/engine/ui/pointing.rb +146 -0
- data/lib/rgame/engine/ui/radial_menu.rb +85 -0
- data/lib/rgame/engine/ui/ring.rb +55 -0
- data/lib/rgame/engine/ui/row.rb +21 -0
- data/lib/rgame/engine/ui/shape_style.rb +102 -0
- data/lib/rgame/engine/ui/stack.rb +58 -0
- data/lib/rgame/engine/ui/stepping.rb +93 -0
- data/lib/rgame/engine/ui/text_button.rb +59 -0
- data/lib/rgame/engine/view.rb +76 -0
- data/lib/rgame/engine/viewports.rb +171 -0
- data/lib/rgame/engine/world_view.rb +71 -0
- data/lib/rgame/engine.rb +86 -0
- data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
- data/lib/rgame/fonts/OFL.txt +102 -0
- data/lib/rgame/game.rb +271 -0
- data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
- data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
- data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
- data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
- data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
- data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
- data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
- data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
- data/lib/rgame/rubocop/default.yml +39 -0
- data/lib/rgame/rubocop/plugin.rb +45 -0
- data/lib/rgame/rubocop.rb +11 -0
- data/lib/rgame/util/color.rb +23 -0
- data/lib/rgame/util/controls.rb +169 -0
- data/lib/rgame/util/route_search.rb +27 -0
- data/lib/rgame/util/save_file.rb +107 -0
- data/lib/rgame/util/solid_grid.rb +37 -0
- data/lib/rgame/util/tensor.rb +3 -0
- data/lib/rgame/util/tile_sweep.rb +36 -0
- data/lib/rgame/util/z.rb +123 -0
- data/lib/rgame/util.rb +10 -0
- data/lib/rgame/util_ext.so +0 -0
- data/lib/rgame/version.rb +12 -0
- data/lib/rgame.rb +5 -0
- data/licenses/SDL2/LICENSE.txt +18 -0
- metadata +309 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# The edges of the world as a blocker source: a box may not leave the region the
|
|
6
|
+
# scene's Components::WorldBounds describes. Pure — it is four numbers and the same
|
|
7
|
+
# snapping arithmetic every other source uses.
|
|
8
|
+
#
|
|
9
|
+
# It answers the blocker-source question Engine::CollisionSystem asks of every source
|
|
10
|
+
# it holds; see that class's header for the protocol.
|
|
11
|
+
#
|
|
12
|
+
# ## Why the world edge is declared rather than automatic
|
|
13
|
+
#
|
|
14
|
+
# Stopping at the edge is one of three responses to it. ScreenWrap and DespawnOffscreen
|
|
15
|
+
# are the other two, and a node may carry only one of them
|
|
16
|
+
# (Components::WorldBounds.one_response!). A clamp applied to every step would be a
|
|
17
|
+
# response nobody chose, contradicting a wrap or a despawn on every node that has one.
|
|
18
|
+
# So a game that wants the world edge to stop an actor says so, and gets an ordinary
|
|
19
|
+
# blocker with a layer like any other.
|
|
20
|
+
class BoundsBlockers
|
|
21
|
+
# What a BoundsBlockers reports as having stopped a step: one object for the life of
|
|
22
|
+
# the process, answering the same two questions a collider does, so a handler reads
|
|
23
|
+
# `by.layer` whatever stopped it. The world's edge has no node.
|
|
24
|
+
class Bounds
|
|
25
|
+
def layer = :bounds
|
|
26
|
+
def node = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
BOUNDS = Bounds.new.freeze
|
|
30
|
+
|
|
31
|
+
# `bounds` is anything answering world_width / world_height — a Components::World or
|
|
32
|
+
# a Components::TileWorld. The two numbers are read once, here, because WorldBounds
|
|
33
|
+
# is documented immutable: a world that genuinely changes size is a new scene.
|
|
34
|
+
def initialize(bounds:)
|
|
35
|
+
@world_width = bounds.world_width
|
|
36
|
+
@world_height = bounds.world_height
|
|
37
|
+
@blocker = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The world's edge stopped the last resolve on this axis, or nil when it did not.
|
|
41
|
+
attr_reader :blocker
|
|
42
|
+
|
|
43
|
+
# The far axis is nothing to a rectangle: a box leaves the world on one axis
|
|
44
|
+
# independently of where it is on the other, so `_y` and `_h` go unread here and
|
|
45
|
+
# `_x` and `_w` in the mirror. The five arguments are the protocol's, not this
|
|
46
|
+
# source's need.
|
|
47
|
+
def resolve_x(x, _y, w, _h, dx) = resolve(x + dx, w, @world_width, dx)
|
|
48
|
+
def resolve_y(_x, y, _w, h, dy) = resolve(y + dy, h, @world_height, dy)
|
|
49
|
+
|
|
50
|
+
# The world does not move, so there is nothing to re-index.
|
|
51
|
+
def moved(_actor, _from_x, _from_y, _w, _h) = nil
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def resolve(landed, span, extent, delta)
|
|
56
|
+
@blocker = nil
|
|
57
|
+
return landed if delta.zero?
|
|
58
|
+
|
|
59
|
+
limit = extent - span
|
|
60
|
+
limit = 0.0 if limit.negative?
|
|
61
|
+
|
|
62
|
+
if landed.negative?
|
|
63
|
+
@blocker = BOUNDS
|
|
64
|
+
0.0
|
|
65
|
+
elsif landed > limit
|
|
66
|
+
@blocker = BOUNDS
|
|
67
|
+
limit.to_f
|
|
68
|
+
else
|
|
69
|
+
landed
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A 2D camera: a point in the world to look at, plus the world bounds it may
|
|
6
|
+
# not show past. Pure; the offset it produces is applied as a draw-time
|
|
7
|
+
# transform, never baked into a node.
|
|
8
|
+
#
|
|
9
|
+
# camera = Camera.new(world_width: map.pixel_width, world_height: map.pixel_height)
|
|
10
|
+
# camera.center_on(player.world_x, player.world_y) # in update, every tick
|
|
11
|
+
# camera.resolve(view_width, view_height) # at draw, for one viewport
|
|
12
|
+
# camera.x, camera.y # the offset to translate by
|
|
13
|
+
#
|
|
14
|
+
# ## Why the viewport size is an argument and not state
|
|
15
|
+
#
|
|
16
|
+
# A camera used to be built with its viewport size and clamp against it in
|
|
17
|
+
# `center_on`. That cannot survive split-screen: the same world is drawn
|
|
18
|
+
# through several viewports whose rects come from the layout and change when
|
|
19
|
+
# a player joins or the window resizes. Clamping has to happen against the
|
|
20
|
+
# rect actually being drawn into, and the difference is visible rather than
|
|
21
|
+
# theoretical — near a world edge, the same target sits at a different place
|
|
22
|
+
# on screen in a half-width viewport than in a full-width one.
|
|
23
|
+
#
|
|
24
|
+
# So `center_on` records *intent* and `resolve` computes the offset. Nothing
|
|
25
|
+
# calls `resolve` by hand: the platform resolves each camera against the
|
|
26
|
+
# viewport it is about to draw, which is what keeps the two from drifting.
|
|
27
|
+
#
|
|
28
|
+
# ## The camera belongs to a player, not to a scene
|
|
29
|
+
#
|
|
30
|
+
# A scene may have any number of viewers, so it cannot own "the" camera.
|
|
31
|
+
# RGame::Engine::Player owns one; a scene sets its world bounds when it
|
|
32
|
+
# loads a map, and a CameraFollow component in the world points it.
|
|
33
|
+
class Camera
|
|
34
|
+
attr_reader :x, :y, :target_x, :target_y
|
|
35
|
+
attr_accessor :world_width, :world_height
|
|
36
|
+
|
|
37
|
+
# `world_width` / `world_height` bound what the camera may show. Left nil
|
|
38
|
+
# the camera is unbounded and follows its target exactly — which is the
|
|
39
|
+
# right default: a game that has not declared its world yet gets a camera
|
|
40
|
+
# that visibly works, rather than one silently pinned to the origin.
|
|
41
|
+
def initialize(world_width: nil, world_height: nil)
|
|
42
|
+
@world_width = world_width
|
|
43
|
+
@world_height = world_height
|
|
44
|
+
@target_x = 0.0
|
|
45
|
+
@target_y = 0.0
|
|
46
|
+
@x = 0.0
|
|
47
|
+
@y = 0.0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Look at this world point. Records the target; the offset is worked out
|
|
51
|
+
# by #resolve, which is the only place that knows how big the view is.
|
|
52
|
+
def center_on(world_x, world_y)
|
|
53
|
+
@target_x = world_x
|
|
54
|
+
@target_y = world_y
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Work out the draw offset for a viewport of this size, clamped so the
|
|
59
|
+
# view never shows past the world's edges.
|
|
60
|
+
def resolve(view_width, view_height)
|
|
61
|
+
@x = clamp(@target_x - (view_width / 2.0), @world_width, view_width)
|
|
62
|
+
@y = clamp(@target_y - (view_height / 2.0), @world_height, view_height)
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def clamp(value, world_size, view_size)
|
|
69
|
+
return value.to_f if world_size.nil?
|
|
70
|
+
|
|
71
|
+
max = world_size - view_size
|
|
72
|
+
return 0.0 if max <= 0
|
|
73
|
+
|
|
74
|
+
value.clamp(0.0, max.to_f)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A circular collision shape, the sibling of CollisionBox for entities that
|
|
6
|
+
# rotate (rocks, ship): a circle is rotation-invariant, so a spinning rock needs
|
|
7
|
+
# no per-frame box recompute. The collider owns only the radius; the entity owns
|
|
8
|
+
# its centre position. Pure logic; no graphics.
|
|
9
|
+
class CircleCollider
|
|
10
|
+
attr_reader :radius
|
|
11
|
+
|
|
12
|
+
# Squared-distance overlap test (no sqrt): true when two circles overlap in area.
|
|
13
|
+
# Circles that exactly graze are apart, the same half-open convention CollisionBox
|
|
14
|
+
# uses — one meaning of "contact" across both shapes and the pairs that mix them.
|
|
15
|
+
def self.overlap?(x1, y1, r1, x2, y2, r2)
|
|
16
|
+
dx = x2 - x1
|
|
17
|
+
dy = y2 - y1
|
|
18
|
+
sum = r1 + r2
|
|
19
|
+
(dx * dx) + (dy * dy) < sum * sum
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(radius:)
|
|
23
|
+
@radius = radius
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Bounding AABB [x, y, w, h] for a circle centred at (cx, cy) — what the
|
|
27
|
+
# spatial hash buckets on.
|
|
28
|
+
def aabb(cx, cy)
|
|
29
|
+
d = @radius * 2
|
|
30
|
+
[cx - @radius, cy - @radius, d, d]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
#
|
|
9
|
+
# It is also where rectangle geometry lives, the way CircleCollider owns circle
|
|
10
|
+
# geometry: the overlap tests below are class methods over plain numbers, so the
|
|
11
|
+
# per-frame collision path can call them without building a box first.
|
|
12
|
+
class CollisionBox
|
|
13
|
+
attr_reader :offset_x, :offset_y, :width, :height
|
|
14
|
+
|
|
15
|
+
# Centre the box horizontally and anchor it to the bottom of the sprite (feet).
|
|
16
|
+
def self.bottom_anchored(sprite_width:, sprite_height:, width:, height:)
|
|
17
|
+
new(
|
|
18
|
+
offset_x: (sprite_width - width) / 2,
|
|
19
|
+
offset_y: sprite_height - height,
|
|
20
|
+
width: width,
|
|
21
|
+
height: height
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Rect-vs-rect: true when two world-space AABBs overlap in *area*. A shape spans
|
|
26
|
+
# the half-open box [x, x + w), so shapes that merely share an edge are apart —
|
|
27
|
+
# the same convention SDL_HasIntersection uses, and the one thing that makes a
|
|
28
|
+
# grid work: pieces on neighbouring squares border each other constantly, and an
|
|
29
|
+
# inclusive test would report every one of those as a contact.
|
|
30
|
+
def self.overlap?(x1, y1, w1, h1, x2, y2, w2, h2)
|
|
31
|
+
x1 < x2 + w2 && x2 < x1 + w1 && y1 < y2 + h2 && y2 < y1 + h1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Rect-vs-circle: true when the circle at (cx, cy) overlaps the rect. Clamping
|
|
35
|
+
# the centre into the rect gives the rect's nearest point to it, so the corner
|
|
36
|
+
# case and the edge case are the same two lines — no per-region analysis, and
|
|
37
|
+
# no sqrt (compare squared distances). Grazing is not overlapping, as above.
|
|
38
|
+
def self.overlap_circle?(x, y, w, h, cx, cy, r)
|
|
39
|
+
nx = cx.clamp(x, x + w)
|
|
40
|
+
ny = cy.clamp(y, y + h)
|
|
41
|
+
dx = cx - nx
|
|
42
|
+
dy = cy - ny
|
|
43
|
+
(dx * dx) + (dy * dy) < r * r
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(width:, height:, offset_x: 0, offset_y: 0)
|
|
47
|
+
@offset_x = offset_x
|
|
48
|
+
@offset_y = offset_y
|
|
49
|
+
@width = width
|
|
50
|
+
@height = height
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# World-space AABB [x, y, w, h] for an actor whose origin is at (x, y).
|
|
54
|
+
def aabb(x, y)
|
|
55
|
+
[x + @offset_x, y + @offset_y, @width, @height]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
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 every blocker source it holds. Reusable by
|
|
7
|
+
# the player and any NPC.
|
|
8
|
+
#
|
|
9
|
+
# **Everything that can stop a step is a source**, including the edge of the world —
|
|
10
|
+
# see Engine::BoundsBlockers, and its header for why that used to be an unconditional
|
|
11
|
+
# clamp here and is not any more. Nothing holds an actor anywhere it did not ask to
|
|
12
|
+
# be held.
|
|
13
|
+
#
|
|
14
|
+
# ## What a blocker source is
|
|
15
|
+
#
|
|
16
|
+
# A blocker source answers one question, on one axis, over plain numbers:
|
|
17
|
+
#
|
|
18
|
+
# source.resolve_x(x, y, w, h, dx) # -> where the box's left edge lands moving dx
|
|
19
|
+
# source.resolve_y(x, y, w, h, dy) # -> where the box's top edge lands moving dy
|
|
20
|
+
#
|
|
21
|
+
# Engine::TileBlockers divides by the tile size and snaps flush against a solid tile;
|
|
22
|
+
# Engine::ActorBlockers queries a broadphase and snaps flush against another
|
|
23
|
+
# collider's edge. Neither needs to know the other exists, because this system takes
|
|
24
|
+
# the **most restrictive** answer on each axis: a box hemmed in by a wall on one side
|
|
25
|
+
# and something else nearer stops at whichever is nearer.
|
|
26
|
+
#
|
|
27
|
+
# Two more questions a source answers, and both are asked here so that no caller has
|
|
28
|
+
# anything to remember:
|
|
29
|
+
#
|
|
30
|
+
# source.blocker # -> what produced the edge the last resolve returned, or nil
|
|
31
|
+
# source.moved(actor, from_x, from_y, w, h) # -> the step has been written back
|
|
32
|
+
#
|
|
33
|
+
# `blocker` is how #blocked_x / #blocked_y name what stopped a step; `moved` is how a
|
|
34
|
+
# source over a moving index re-buckets the mover, with the box the step started from.
|
|
35
|
+
#
|
|
36
|
+
# A fourth question is optional, and only Engine::TileBlockers answers it today:
|
|
37
|
+
#
|
|
38
|
+
# source.travel?(x, y, w, h, dx, dy) # -> can the box move (dx, dy) without being stopped?
|
|
39
|
+
#
|
|
40
|
+
# "Stopped" means what it means here: a resolve landing *short* of where the step was
|
|
41
|
+
# heading. A landing past it is ignored below, so it does not stop a travel either. Read
|
|
42
|
+
# that way the question splits by source — a box travels past several sources exactly when
|
|
43
|
+
# it travels past each one alone — so no source needs to know the others exist for this
|
|
44
|
+
# question any more than for the other three. The shared example group "a blocker source
|
|
45
|
+
# answering travel?" states it.
|
|
46
|
+
#
|
|
47
|
+
# Holding the sources here rather than inside any one of them is what puts the
|
|
48
|
+
# axis-separated order — resolve X, then resolve Y fed the resolved X — in exactly
|
|
49
|
+
# one place. That order is what produces wall-sliding (a diagonal push into a wall
|
|
50
|
+
# keeps the component that is still free), so every source shares one feel rather
|
|
51
|
+
# than each implementing it.
|
|
52
|
+
class CollisionSystem
|
|
53
|
+
# What stopped the last #move on each axis, or nil when that axis was free. Whatever
|
|
54
|
+
# a source reports, so a handler reads `blocked_x.layer` without asking which kind
|
|
55
|
+
# of thing it was. Set by every #move, so read it straight after one.
|
|
56
|
+
attr_reader :blocked_x, :blocked_y
|
|
57
|
+
|
|
58
|
+
def initialize(blockers: [])
|
|
59
|
+
@blockers = Array(blockers)
|
|
60
|
+
@blocked_x = nil
|
|
61
|
+
@blocked_y = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Move `actor` by (dx, dy), writing the resolved position back to it.
|
|
65
|
+
def move(actor, dx, dy)
|
|
66
|
+
box = actor.collision_box
|
|
67
|
+
from_x = actor.x + box.offset_x
|
|
68
|
+
from_y = actor.y + box.offset_y
|
|
69
|
+
bw = box.width
|
|
70
|
+
bh = box.height
|
|
71
|
+
|
|
72
|
+
bx = resolve_x(from_x, from_y, bw, bh, dx)
|
|
73
|
+
by = resolve_y(bx, from_y, bw, bh, dy)
|
|
74
|
+
|
|
75
|
+
actor.x = bx - box.offset_x
|
|
76
|
+
actor.y = by - box.offset_y
|
|
77
|
+
|
|
78
|
+
i = 0
|
|
79
|
+
while i < @blockers.size
|
|
80
|
+
@blockers[i].moved(actor, from_x, from_y, bw, bh)
|
|
81
|
+
i += 1
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Where the box's left edge lands moving dx, against every source at once. The most
|
|
86
|
+
# restrictive answer is the *smallest* landing for a rightward step and the largest
|
|
87
|
+
# for a leftward one; with no sources at all the box simply moves.
|
|
88
|
+
#
|
|
89
|
+
# A plain index loop rather than @blockers.map/min: this runs per actor per axis per
|
|
90
|
+
# frame, and both the block and the intermediate array would allocate.
|
|
91
|
+
def resolve_x(x, y, w, h, dx)
|
|
92
|
+
@blocked_x = nil
|
|
93
|
+
nx = x + dx
|
|
94
|
+
return nx if dx.zero?
|
|
95
|
+
|
|
96
|
+
winner = nil
|
|
97
|
+
i = 0
|
|
98
|
+
while i < @blockers.size
|
|
99
|
+
source = @blockers[i]
|
|
100
|
+
landed = source.resolve_x(x, y, w, h, dx)
|
|
101
|
+
if dx.positive? ? landed < nx : landed > nx
|
|
102
|
+
nx = landed
|
|
103
|
+
winner = source
|
|
104
|
+
end
|
|
105
|
+
i += 1
|
|
106
|
+
end
|
|
107
|
+
@blocked_x = winner&.blocker
|
|
108
|
+
nx
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_y(x, y, w, h, dy)
|
|
112
|
+
@blocked_y = nil
|
|
113
|
+
ny = y + dy
|
|
114
|
+
return ny if dy.zero?
|
|
115
|
+
|
|
116
|
+
winner = nil
|
|
117
|
+
i = 0
|
|
118
|
+
while i < @blockers.size
|
|
119
|
+
source = @blockers[i]
|
|
120
|
+
landed = source.resolve_y(x, y, w, h, dy)
|
|
121
|
+
if dy.positive? ? landed < ny : landed > ny
|
|
122
|
+
ny = landed
|
|
123
|
+
winner = source
|
|
124
|
+
end
|
|
125
|
+
i += 1
|
|
126
|
+
end
|
|
127
|
+
@blocked_y = winner&.blocker
|
|
128
|
+
ny
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
class Component
|
|
6
|
+
extend Signal::DSL
|
|
7
|
+
extend SealedPrivates
|
|
8
|
+
|
|
9
|
+
attr_accessor :node
|
|
10
|
+
|
|
11
|
+
def context
|
|
12
|
+
@context ||= node.context
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Tree-lifecycle hooks. on_attach fires once the node is in the live tree, so
|
|
16
|
+
# node.root / node.scene and sibling systems are reachable — pull and register
|
|
17
|
+
# shared systems here, not in initialize (where the node has no anchors yet).
|
|
18
|
+
# on_detach mirrors it: release those registrations.
|
|
19
|
+
def on_attach; end
|
|
20
|
+
def on_detach; end
|
|
21
|
+
|
|
22
|
+
# The sibling component this one drives, or a raise naming both — for the
|
|
23
|
+
# `@body = require_sibling(CharacterBody)` line an `on_attach` opens with.
|
|
24
|
+
#
|
|
25
|
+
# Worth a helper rather than a bare `get_component` because the nil it returns
|
|
26
|
+
# is silent, and stays silent until the first frame calls a method on it: the
|
|
27
|
+
# error you see is a NoMethodError on nil, in `control`, naming neither the
|
|
28
|
+
# component that is missing nor the one that wanted it.
|
|
29
|
+
#
|
|
30
|
+
# And the cause is nearly always the same, which is why the message says it.
|
|
31
|
+
# A node assembled *outside* the tree collects every component before any
|
|
32
|
+
# on_attach runs (Node2D#enter_tree), so add order does not matter there. A
|
|
33
|
+
# node that adds components from its own `on_add` is already in the tree, so
|
|
34
|
+
# each one attaches as it arrives and can only see the ones before it. Same
|
|
35
|
+
# two lines, opposite outcome, depending on where they were written.
|
|
36
|
+
#
|
|
37
|
+
# A class several components answer to — `Components::Mover` has three — can match
|
|
38
|
+
# twice on one node, and then there is no telling which one was meant. That raises
|
|
39
|
+
# too, naming each match, rather than quietly taking the first.
|
|
40
|
+
def require_sibling(klass)
|
|
41
|
+
matches = node.components.grep(klass)
|
|
42
|
+
if matches.length > 1
|
|
43
|
+
raise ArgumentError, "#{self.class} reads one #{klass} on the same node, and this node has " \
|
|
44
|
+
"#{matches.length}: #{matches.map(&:class).join(', ')}. " \
|
|
45
|
+
'Keep one of them on this node.'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
matches.first ||
|
|
49
|
+
raise("#{self.class} needs a #{klass} on the same node, and there is none. If you did " \
|
|
50
|
+
'add one, add it before this component: a node that is already in the tree ' \
|
|
51
|
+
'attaches each component as it arrives, so a sibling added after this one is ' \
|
|
52
|
+
'not there yet when this one attaches.')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def control(actions); end
|
|
56
|
+
def update(dt); end
|
|
57
|
+
def draw(renderer, view); end
|
|
58
|
+
|
|
59
|
+
# Container components (e.g. SceneStack) that hold nodes off the normal child
|
|
60
|
+
# list override this to forward the deferred-free sweep into them.
|
|
61
|
+
def sweep_freed; end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
@timers = cooldowns.transform_values { 0.0 }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def update(dt)
|
|
26
|
+
@timers.each { |action, remaining| @timers[action] = remaining - dt if remaining.positive? }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def control(actions)
|
|
30
|
+
@cooldowns.each_key do |action|
|
|
31
|
+
next unless actions.held?(action) && @timers[action] <= 0.0
|
|
32
|
+
|
|
33
|
+
@timers[action] = @cooldowns[action]
|
|
34
|
+
on_triggered_signal.emit(action)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
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 its
|
|
7
|
+
# Mover sibling's heading: walk_left/right/up/down while moving, stand when still. The
|
|
8
|
+
# larger axis of the heading picks the direction and a tie goes horizontal, so a
|
|
9
|
+
# keyboard diagonal walks sideways and a route running mostly downhill walks down.
|
|
10
|
+
# Any mover will do — a CharacterBody faces its intent, a PathFollow the road it is
|
|
11
|
+
# on — and a node with two movers raises at attach, since there would be no telling
|
|
12
|
+
# which way it faces. Owns its Animator + the pure AnimationSet built from the sheet's
|
|
13
|
+
# animation table.
|
|
14
|
+
#
|
|
15
|
+
# Like Sprite, it passes NO angle and NO position: it draws at (0, 0), which
|
|
16
|
+
# Node2D#draw has already made mean "at this node, correctly rotated", and a
|
|
17
|
+
# WorldView ancestor has already made mean "through the camera". `z` is the
|
|
18
|
+
# render layer (kept as @layer, distinct from the node's transform z); it must
|
|
19
|
+
# sit between the tile map's ground and canopy z bands, so canopies draw in
|
|
20
|
+
# front.
|
|
21
|
+
#
|
|
22
|
+
# `sheet` is the asset's relative path. The component resolves it from the game's
|
|
23
|
+
# asset manager on attach — via node.root.context.assets (the platform seam) — to
|
|
24
|
+
# build its animation table and to size the node to the sprite's frame, so siblings
|
|
25
|
+
# like FeetCollider can read node.width/height. The renderer resolves the same
|
|
26
|
+
# symbol when drawing, so nothing is registered or passed in by hand.
|
|
27
|
+
class AnimatedSprite < Engine::Component
|
|
28
|
+
include Engine::Culling
|
|
29
|
+
|
|
30
|
+
def initialize(sheet:, z: 0)
|
|
31
|
+
super()
|
|
32
|
+
@sheet = sheet
|
|
33
|
+
@layer = z
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def on_attach
|
|
37
|
+
sheet = context.assets.sheet(@sheet)
|
|
38
|
+
@animator = Engine::Animator.new(Engine::AnimationSet.new(sheet.animations))
|
|
39
|
+
node.width = sheet.frame_width
|
|
40
|
+
node.height = sheet.frame_height
|
|
41
|
+
@mover = require_sibling(Mover)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def update(dt)
|
|
45
|
+
@animator.play(walk_animation(@mover.heading_x, @mover.heading_y))
|
|
46
|
+
@animator.update(dt)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Top-left anchored, sized by the sheet's frame and lifted by the node's
|
|
50
|
+
# elevation — so the footprint to cull against is the node's box, raised
|
|
51
|
+
# by the same amount the picture is.
|
|
52
|
+
def draw(renderer, view)
|
|
53
|
+
lift = node.elevation
|
|
54
|
+
return if culled?(view, node.world_x, node.world_y - lift, node.width, node.height)
|
|
55
|
+
|
|
56
|
+
renderer.sprite(@sheet, @animator.row, @animator.col, 0, -lift,
|
|
57
|
+
flip_x: @animator.flip_x, z: @layer)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def walk_animation(heading_x, heading_y)
|
|
63
|
+
if heading_x.zero? && heading_y.zero? then :stand
|
|
64
|
+
elsif heading_x.abs >= heading_y.abs then heading_x.negative? ? :walk_left : :walk_right
|
|
65
|
+
else heading_y.negative? ? :walk_up : :walk_down
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# A rectangular collision shape on a node — the sibling of CircleCollider for
|
|
7
|
+
# entities that are honestly box-shaped (a crate, a platform, a wall segment).
|
|
8
|
+
# It registers itself with the scene's CollisionWorld when it enters the tree
|
|
9
|
+
# and unregisters on leaving, so a spawned/despawned entity can't leak a
|
|
10
|
+
# registration. A scene with no world mounted leaves it a bare shape (see
|
|
11
|
+
# on_attach).
|
|
12
|
+
#
|
|
13
|
+
# The rectangle is an Engine::CollisionBox: an offset + size relative to the
|
|
14
|
+
# node's origin, so a 32x32 sprite can carry a small box at its feet —
|
|
15
|
+
# FeetCollider is the subclass that works that offset out for you. The `layer`
|
|
16
|
+
# is an opaque tag the game reads in its on_hit handler to decide what a contact
|
|
17
|
+
# means. See docs/api/systems.md.
|
|
18
|
+
#
|
|
19
|
+
# The box stays axis-aligned in world space: it does not rotate with the node.
|
|
20
|
+
# That is what an AABB buys — a spinning entity wants a CircleCollider, which
|
|
21
|
+
# is rotation-invariant, rather than a per-frame box recompute.
|
|
22
|
+
class BoxCollider < Engine::Component
|
|
23
|
+
# The two edges of a contact, fired by CollisionWorld: on_hit on the step this
|
|
24
|
+
# collider starts overlapping another, on_separated on the step it stops. Each
|
|
25
|
+
# fires once per pair, so a handler may count, play a sound or spend a life.
|
|
26
|
+
# The listener gets the other collider and reads its #layer / #node to react.
|
|
27
|
+
signal :on_hit, Engine::Signal.define(:other)
|
|
28
|
+
signal :on_separated, Engine::Signal.define(:other)
|
|
29
|
+
|
|
30
|
+
# box is writable so a pooled entity can retune its shape on reset — assign any
|
|
31
|
+
# CollisionBox, CollisionBox.bottom_anchored(...) included. CollisionWorld reads
|
|
32
|
+
# it fresh each frame, so no re-registration.
|
|
33
|
+
attr_accessor :box
|
|
34
|
+
attr_reader :layer
|
|
35
|
+
|
|
36
|
+
# CollisionWorld's per-collider bookkeeping: who this was touching this step and
|
|
37
|
+
# last. The world owns what goes in it; nothing else should write to it.
|
|
38
|
+
attr_reader :contacts
|
|
39
|
+
|
|
40
|
+
def initialize(width:, height:, offset_x: 0, offset_y: 0, layer: :default)
|
|
41
|
+
super()
|
|
42
|
+
@box = Engine::CollisionBox.new(width:, height:, offset_x:, offset_y:)
|
|
43
|
+
@layer = layer
|
|
44
|
+
@contacts = Engine::ContactSet.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A collider is a *shape*; a CollisionWorld is what turns shapes into contacts. So
|
|
48
|
+
# a scene with no world mounted leaves this a bare shape instead of raising, which
|
|
49
|
+
# is what a tile-only game wants: its character's feet box stops its steps (that is
|
|
50
|
+
# a Mover's `blocked_by`) and there are no pairs to report to anyone.
|
|
51
|
+
#
|
|
52
|
+
# The cost is that an on_hit handler in such a scene never fires and nothing says
|
|
53
|
+
# so. That is the trade taken deliberately: what declares "I expect to be stopped"
|
|
54
|
+
# is `blocked_by`, and that *does* raise for a system it cannot find.
|
|
55
|
+
def on_attach = node.system(CollisionWorld)&.register(self)
|
|
56
|
+
def on_detach = node.system(CollisionWorld)&.unregister(self)
|
|
57
|
+
|
|
58
|
+
# The broadphase AABB in world space, one component per call rather than
|
|
59
|
+
# CollisionBox#aabb's Array: CollisionWorld reads these for every collider
|
|
60
|
+
# every frame, and that path may not allocate.
|
|
61
|
+
#
|
|
62
|
+
# These go through `box` rather than @box so a subclass that builds its shape
|
|
63
|
+
# lazily — FeetCollider, which cannot know the node's size until the tree is
|
|
64
|
+
# live — is seen by the broadphase too. An attr_reader call allocates nothing.
|
|
65
|
+
def aabb_x = node.world_x + box.offset_x
|
|
66
|
+
def aabb_y = node.world_y + box.offset_y
|
|
67
|
+
def aabb_w = box.width
|
|
68
|
+
def aabb_h = box.height
|
|
69
|
+
|
|
70
|
+
# World-space centre of the box — what CollisionWorld's range queries measure
|
|
71
|
+
# from, so a box collider is targetable on the same terms as a circle. Note it
|
|
72
|
+
# is the box's centre, not the node's origin, which is where a circle's is.
|
|
73
|
+
def cx = aabb_x + (box.width / 2.0)
|
|
74
|
+
def cy = aabb_y + (box.height / 2.0)
|
|
75
|
+
|
|
76
|
+
# Narrowphase, first half of the double dispatch: hand this shape's numbers to
|
|
77
|
+
# the *other* collider and let it pick the test, so neither side has to ask what
|
|
78
|
+
# kind the other is. CircleCollider#overlap? is the mirror image.
|
|
79
|
+
def overlap?(other) = other.overlap_box?(aabb_x, aabb_y, aabb_w, aabb_h)
|
|
80
|
+
|
|
81
|
+
# Second half: the two tests another collider dispatches into.
|
|
82
|
+
#
|
|
83
|
+
# @api private
|
|
84
|
+
def overlap_box?(x, y, w, h)
|
|
85
|
+
Engine::CollisionBox.overlap?(aabb_x, aabb_y, aabb_w, aabb_h, x, y, w, h)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @api private
|
|
89
|
+
def overlap_circle?(x, y, r)
|
|
90
|
+
Engine::CollisionBox.overlap_circle?(aabb_x, aabb_y, aabb_w, aabb_h, x, y, r)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Called by CollisionWorld on each edge (the signals' emit is otherwise private).
|
|
94
|
+
def emit_hit(other) = on_hit_signal.emit(other)
|
|
95
|
+
def emit_separated(other) = on_separated_signal.emit(other)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|