rgame 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +135 -1
- data/README.md +67 -65
- data/docs/api/README.md +86 -82
- data/docs/api/app.md +125 -68
- data/docs/api/assets.md +203 -165
- data/docs/api/audio.md +130 -89
- data/docs/api/cli.md +259 -0
- data/docs/api/components.md +1032 -265
- data/docs/api/drawing.md +160 -160
- data/docs/api/examples.md +263 -0
- data/docs/api/game.md +149 -50
- data/docs/api/images.md +39 -39
- data/docs/api/input.md +226 -148
- data/docs/api/internals.md +241 -62
- data/docs/api/localization.md +285 -0
- data/docs/api/scene_graph.md +397 -244
- data/docs/api/signals.md +81 -76
- data/docs/api/systems.md +186 -73
- data/docs/api/text.md +58 -41
- data/docs/api/tile_maps.md +237 -0
- data/docs/api/toolbox.md +395 -146
- data/docs/api/ui.md +883 -54
- data/docs/api/values.md +216 -33
- 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/ext/rgame_core/app/app.c +33 -3
- data/ext/rgame_core/app/locale.c +67 -0
- data/ext/rgame_core/app/locale.h +28 -0
- data/ext/rgame_core/audio/audio.c +39 -2
- data/ext/rgame_core/example.rb +0 -49
- data/ext/rgame_core/extconf.rb +0 -125
- data/ext/rgame_core/include/rgame/core.h +38 -2
- data/ext/rgame_core/ruby/audio_ext.c +10 -5
- data/ext/rgame_core/ruby/core_ext.c +30 -7
- data/ext/rgame_core/ruby/core_ext.h +3 -0
- data/ext/rgame_core/ruby/locale_ext.c +44 -0
- data/ext/rgame_core/ruby/recording_ext.c +1 -1
- data/ext/rgame_core/ruby/renderer_ext.c +20 -20
- data/ext/rgame_util/extconf.rb +2 -20
- data/ext/rgame_util/route_search.c +305 -0
- data/ext/rgame_util/route_search.h +86 -0
- data/ext/rgame_util/route_search_ext.c +150 -0
- data/ext/rgame_util/solid_grid.c +58 -0
- data/ext/rgame_util/solid_grid.h +49 -0
- data/ext/rgame_util/solid_grid_ext.c +161 -0
- data/ext/rgame_util/tile_sweep.c +164 -0
- data/ext/rgame_util/tile_sweep.h +62 -0
- data/ext/rgame_util/tile_sweep_ext.c +155 -0
- data/ext/rgame_util/util_ext.c +3 -0
- data/ext/rgame_util/util_ext.h +15 -0
- data/lib/rgame/boot.rb +0 -10
- 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 +6 -44
- data/lib/rgame/core/asset_manager.rb +13 -31
- data/lib/rgame/core/audio.rb +37 -16
- data/lib/rgame/core/font.rb +0 -3
- data/lib/rgame/core/locale.rb +22 -0
- data/lib/rgame/core/nine_slice.rb +0 -21
- data/lib/rgame/core/renderer.rb +6 -63
- data/lib/rgame/core/sprite_sheet.rb +0 -3
- data/lib/rgame/core/tile_map_renderer.rb +2 -19
- data/lib/rgame/core/ui_atlas.rb +28 -13
- data/lib/rgame/core.rb +1 -8
- data/lib/rgame/engine/actor_blockers.rb +131 -0
- data/lib/rgame/engine/animation_set.rb +1 -0
- data/lib/rgame/engine/audio_director.rb +36 -6
- data/lib/rgame/engine/bounds_blockers.rb +74 -0
- data/lib/rgame/engine/camera.rb +3 -3
- data/lib/rgame/engine/circle_collider.rb +4 -2
- data/lib/rgame/engine/collision_box.rb +26 -1
- data/lib/rgame/engine/collision_system.rb +110 -22
- data/lib/rgame/engine/component.rb +34 -10
- data/lib/rgame/engine/components/action_trigger.rb +0 -1
- data/lib/rgame/engine/components/animated_sprite.rb +27 -25
- data/lib/rgame/engine/components/box_collider.rb +99 -0
- data/lib/rgame/engine/components/camera_follow.rb +6 -5
- data/lib/rgame/engine/components/character_body.rb +20 -61
- data/lib/rgame/engine/components/circle_collider.rb +47 -11
- data/lib/rgame/engine/components/collision_world.rb +159 -31
- data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
- 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 +123 -31
- data/lib/rgame/engine/components/player_controller.rb +5 -2
- data/lib/rgame/engine/components/pool.rb +1 -1
- data/lib/rgame/engine/components/screen_wrap.rb +33 -11
- data/lib/rgame/engine/components/sprite.rb +12 -6
- data/lib/rgame/engine/components/targeting.rb +9 -11
- data/lib/rgame/engine/components/thrust_controller.rb +1 -1
- data/lib/rgame/engine/components/tile_world.rb +41 -13
- data/lib/rgame/engine/components/timer.rb +1 -1
- data/lib/rgame/engine/components/velocity.rb +23 -7
- data/lib/rgame/engine/components/wander_controller.rb +6 -2
- data/lib/rgame/engine/components/world.rb +133 -0
- data/lib/rgame/engine/contact_set.rb +74 -0
- data/lib/rgame/engine/culling.rb +4 -6
- data/lib/rgame/engine/debug_overlay.rb +5 -11
- 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 +276 -51
- data/lib/rgame/engine/input/action_mapper.rb +0 -26
- data/lib/rgame/engine/input/actions.rb +2 -8
- data/lib/rgame/engine/input/input_map.rb +39 -21
- data/lib/rgame/engine/nav_grid.rb +87 -0
- data/lib/rgame/engine/node2d.rb +248 -133
- data/lib/rgame/engine/path.rb +4 -6
- data/lib/rgame/engine/players.rb +6 -13
- data/lib/rgame/engine/presentation.rb +171 -0
- data/lib/rgame/engine/scene/scene_stack.rb +4 -4
- data/lib/rgame/engine/sealed_privates.rb +54 -0
- data/lib/rgame/engine/spatial_hash.rb +51 -19
- data/lib/rgame/engine/text.rb +194 -0
- data/lib/rgame/engine/tile_blockers.rb +63 -0
- data/lib/rgame/engine/tile_map.rb +2 -3
- data/lib/rgame/engine/tile_map_layer.rb +0 -2
- data/lib/rgame/engine/tileset.rb +2 -4
- data/lib/rgame/engine/timer.rb +2 -2
- 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 +246 -71
- 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/viewports.rb +2 -5
- data/lib/rgame/engine/world_view.rb +5 -4
- data/lib/rgame/engine.rb +31 -25
- data/lib/rgame/game.rb +99 -27
- 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 +20 -24
- data/lib/rgame/util/controls.rb +14 -28
- 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 +0 -9
- data/lib/rgame/util/tile_sweep.rb +36 -0
- data/lib/rgame/util/z.rb +4 -14
- data/lib/rgame/util.rb +4 -3
- data/lib/rgame/version.rb +1 -1
- data/lib/rgame.rb +0 -15
- metadata +152 -11
- data/lib/rgame/engine/actor.rb +0 -53
- data/lib/rgame/engine/body.rb +0 -49
- data/lib/rgame/engine/cached_label.rb +0 -33
- data/lib/rgame/engine/input/player_controller.rb +0 -14
- data/lib/rgame/engine/matrix.rb +0 -32
- data/lib/rgame/engine/resettable.rb +0 -67
- data/lib/rgame/engine/tile_collision.rb +0 -78
- data/lib/rgame/engine/ui/menu_item.rb +0 -84
|
@@ -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
|
|
@@ -29,14 +29,15 @@ module RGame
|
|
|
29
29
|
@offset_y = offset_y
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
#
|
|
33
|
-
#
|
|
32
|
+
# A node runs its components before its own `on_update`, so this reads
|
|
33
|
+
# the node's world position from before whatever moves it this tick, and
|
|
34
|
+
# the camera trails the node's own movement by one step (a couple of
|
|
34
35
|
# pixels at walking speed). That is deliberate and uniform: everything
|
|
35
36
|
# drawn through this camera trails equally, so nothing drifts apart on
|
|
36
|
-
# screen
|
|
37
|
-
# component's ordering among its siblings on show.
|
|
37
|
+
# screen. Reading it later — from the node's own hook, say — would put
|
|
38
|
+
# this component's ordering among its siblings on show instead.
|
|
38
39
|
def update(_dt)
|
|
39
|
-
@camera.center_on(node.
|
|
40
|
+
@camera.center_on(node.world_x + @offset_x, node.world_y + @offset_y)
|
|
40
41
|
end
|
|
41
42
|
end
|
|
42
43
|
end
|
|
@@ -3,48 +3,33 @@
|
|
|
3
3
|
module RGame
|
|
4
4
|
module Engine
|
|
5
5
|
module Components
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# integrates blindly): here every step is collision-checked.
|
|
6
|
+
# Direct, per-step movement for a walking actor (player or NPC). A controller writes a
|
|
7
|
+
# movement intent — each axis in -1..1 — and this component turns it into a real move
|
|
8
|
+
# each update, at a fixed speed and with no inertia (unlike Velocity, which integrates
|
|
9
|
+
# a velocity the controller sets, and ThrustController, which accelerates one).
|
|
11
10
|
#
|
|
12
|
-
# The intent doubles as the
|
|
11
|
+
# The intent doubles as the mover's heading, which is what AnimatedSprite faces by, so
|
|
13
12
|
# a character is just CharacterBody + a controller + AnimatedSprite.
|
|
14
13
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
14
|
+
# What may stop a step — `blocked_by:`, `on_blocked` / `on_unblocked`, and the
|
|
15
|
+
# `apply_move` seam — is Mover's, and shared with every other component that moves a
|
|
16
|
+
# node; see its header. What is this class's own is the intent and the speed.
|
|
17
|
+
#
|
|
18
|
+
# CharacterBody.new(speed: 80) # walks wherever the intent points
|
|
19
|
+
# CharacterBody.new(speed: 80, blocked_by: %i[tiles npc]) # stopped by the map and by NPCs
|
|
20
|
+
class CharacterBody < Mover
|
|
21
21
|
attr_reader :move_x, :move_y
|
|
22
22
|
|
|
23
|
-
def initialize(
|
|
24
|
-
super()
|
|
25
|
-
@feet_width = feet_width
|
|
26
|
-
@feet_height = feet_height
|
|
23
|
+
def initialize(speed:, blocked_by: [])
|
|
24
|
+
super(blocked_by: blocked_by)
|
|
27
25
|
@speed = speed
|
|
28
26
|
@move_x = 0.0
|
|
29
27
|
@move_y = 0.0
|
|
30
|
-
@collision_box = nil
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# The feet box, derived from the node's sprite size and memoised.
|
|
34
|
-
#
|
|
35
|
-
# **Only valid once the node is in the tree**, and it says so rather than
|
|
36
|
-
# letting you find out later. The size comes from AnimatedSprite#on_attach,
|
|
37
|
-
# so a read from a constructor sees a 0x0 node and bakes a box anchored to
|
|
38
|
-
# nothing — permanently, because this memoises, and for the collision
|
|
39
|
-
# system too, because it reads the same box. The symptom is an actor that
|
|
40
|
-
# walks through walls it should not, a long way from the call that caused
|
|
41
|
-
# it. Guarding costs one comparison, once.
|
|
42
|
-
def collision_box
|
|
43
|
-
@collision_box ||= build_collision_box
|
|
44
28
|
end
|
|
45
29
|
|
|
46
|
-
# The
|
|
47
|
-
def
|
|
30
|
+
# The heading is the intent as set, blocked or not.
|
|
31
|
+
def heading_x = @move_x
|
|
32
|
+
def heading_y = @move_y
|
|
48
33
|
|
|
49
34
|
# Set this step's movement intent; each axis is in -1..1.
|
|
50
35
|
def set_intent(intent_x, intent_y)
|
|
@@ -52,38 +37,12 @@ module RGame
|
|
|
52
37
|
@move_y = intent_y
|
|
53
38
|
end
|
|
54
39
|
|
|
55
|
-
def update(dt)
|
|
56
|
-
return if @move_x.zero? && @move_y.zero?
|
|
57
|
-
|
|
58
|
-
@world.move(self, @move_x * @speed * dt, @move_y * @speed * dt)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# CollisionSystem#move drives an "actor" through x/y/collision_box and writes the
|
|
62
|
-
# resolved position back — back those by the owning node's transform.
|
|
63
|
-
def x = node.x
|
|
64
|
-
def y = node.y
|
|
65
|
-
|
|
66
|
-
def x=(value)
|
|
67
|
-
node.x = value
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def y=(value)
|
|
71
|
-
node.y = value
|
|
72
|
-
end
|
|
73
|
-
|
|
74
40
|
private
|
|
75
41
|
|
|
76
|
-
def
|
|
77
|
-
if
|
|
78
|
-
raise "collision_box needs the node's sprite size, but it is " \
|
|
79
|
-
"#{node.width}x#{node.height}. AnimatedSprite sets that when it attaches, so " \
|
|
80
|
-
'read this after the node is in the tree, not while building it.'
|
|
81
|
-
end
|
|
42
|
+
def take_step(dt)
|
|
43
|
+
return if @move_x.zero? && @move_y.zero?
|
|
82
44
|
|
|
83
|
-
|
|
84
|
-
sprite_width: node.width, sprite_height: node.height,
|
|
85
|
-
width: @feet_width, height: @feet_height
|
|
86
|
-
)
|
|
45
|
+
apply_move(@move_x * @speed * dt, @move_y * @speed * dt)
|
|
87
46
|
end
|
|
88
47
|
end
|
|
89
48
|
end
|
|
@@ -6,38 +6,74 @@ module RGame
|
|
|
6
6
|
# A circular collision shape on a node. It registers itself with the scene's
|
|
7
7
|
# CollisionWorld system when it enters the tree and unregisters on leaving —
|
|
8
8
|
# the engine fires both hooks, so a spawned/despawned entity can't leak a
|
|
9
|
-
# registration. Its world centre is the node's
|
|
9
|
+
# registration. Its world centre is the node's world origin; the
|
|
10
10
|
# `layer` is an opaque tag the game reads in its on_hit handler to decide what a
|
|
11
|
-
# contact means
|
|
11
|
+
# contact means. See docs/api/systems.md.
|
|
12
|
+
#
|
|
13
|
+
# BoxCollider is the rectangular sibling, and the two collide with each other:
|
|
14
|
+
# both answer the same broadphase (#aabb_*) and narrowphase (#overlap?) protocol.
|
|
12
15
|
class CircleCollider < Engine::Component
|
|
13
|
-
#
|
|
14
|
-
#
|
|
16
|
+
# The two edges of a contact, fired by CollisionWorld: on_hit on the step this
|
|
17
|
+
# collider starts overlapping another, on_separated on the step it stops. Each
|
|
18
|
+
# fires once per pair, so a handler may count, play a sound or spend a life.
|
|
19
|
+
# The listener gets the other collider and reads its #layer / #node to react.
|
|
15
20
|
signal :on_hit, Engine::Signal.define(:other)
|
|
21
|
+
signal :on_separated, Engine::Signal.define(:other)
|
|
16
22
|
|
|
17
23
|
# radius is writable so a pooled entity (e.g. a multi-tier rock) can retune its
|
|
18
24
|
# shape on reset; CollisionWorld reads it fresh each frame, so no re-registration.
|
|
19
25
|
attr_accessor :radius
|
|
20
26
|
attr_reader :layer
|
|
21
27
|
|
|
28
|
+
# CollisionWorld's per-collider bookkeeping: who this was touching this step and
|
|
29
|
+
# last. The world owns what goes in it; nothing else should write to it.
|
|
30
|
+
attr_reader :contacts
|
|
31
|
+
|
|
22
32
|
def initialize(radius:, layer: :default)
|
|
23
33
|
super()
|
|
24
34
|
@radius = radius
|
|
25
35
|
@layer = layer
|
|
36
|
+
@contacts = Engine::ContactSet.new
|
|
26
37
|
end
|
|
27
38
|
|
|
28
|
-
|
|
39
|
+
# A collider is a *shape*; a CollisionWorld is what turns shapes into contacts.
|
|
40
|
+
# A scene with no world mounted therefore leaves this a bare shape rather than
|
|
41
|
+
# raising — BoxCollider#on_attach says why, and it is the same trade here.
|
|
42
|
+
def on_attach = node.system(CollisionWorld)&.register(self)
|
|
29
43
|
def on_detach = node.system(CollisionWorld)&.unregister(self)
|
|
30
44
|
|
|
31
|
-
# World-space centre — the node's
|
|
32
|
-
def cx = node.
|
|
33
|
-
def cy = node.
|
|
45
|
+
# World-space centre — the node's own origin, in world coordinates.
|
|
46
|
+
def cx = node.world_x
|
|
47
|
+
def cy = node.world_y
|
|
48
|
+
|
|
49
|
+
# The circle's bounding box, one component per call rather than an Array:
|
|
50
|
+
# CollisionWorld reads these for every collider every frame, and that path may
|
|
51
|
+
# not allocate.
|
|
52
|
+
def aabb_x = cx - @radius
|
|
53
|
+
def aabb_y = cy - @radius
|
|
54
|
+
def aabb_w = @radius * 2
|
|
55
|
+
def aabb_h = @radius * 2
|
|
56
|
+
|
|
57
|
+
# Narrowphase, first half of the double dispatch: hand this shape's numbers to
|
|
58
|
+
# the *other* collider and let it pick the test, so neither side has to ask what
|
|
59
|
+
# kind the other is. BoxCollider#overlap? is the mirror image.
|
|
60
|
+
def overlap?(other) = other.overlap_circle?(cx, cy, @radius)
|
|
61
|
+
|
|
62
|
+
# Second half: the two tests another collider dispatches into.
|
|
63
|
+
#
|
|
64
|
+
# @api private
|
|
65
|
+
def overlap_circle?(x, y, r)
|
|
66
|
+
Engine::CircleCollider.overlap?(cx, cy, @radius, x, y, r)
|
|
67
|
+
end
|
|
34
68
|
|
|
35
|
-
|
|
36
|
-
|
|
69
|
+
# @api private
|
|
70
|
+
def overlap_box?(x, y, w, h)
|
|
71
|
+
Engine::CollisionBox.overlap_circle?(x, y, w, h, cx, cy, @radius)
|
|
37
72
|
end
|
|
38
73
|
|
|
39
|
-
# Called by CollisionWorld on
|
|
74
|
+
# Called by CollisionWorld on each edge (the signals' emit is otherwise private).
|
|
40
75
|
def emit_hit(other) = on_hit_signal.emit(other)
|
|
76
|
+
def emit_separated(other) = on_separated_signal.emit(other)
|
|
41
77
|
end
|
|
42
78
|
end
|
|
43
79
|
end
|
|
@@ -5,10 +5,31 @@ module RGame
|
|
|
5
5
|
module Components
|
|
6
6
|
# Scene-scoped broadphase collision system: a Component that lives on the scene
|
|
7
7
|
# node (so it is born and torn down with the scene, and rides the normal update
|
|
8
|
-
# traversal).
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# traversal). Colliders register/unregister with it via their tree lifecycle;
|
|
9
|
+
# each update it buckets them in a SpatialHash and reports every overlapping
|
|
10
|
+
# pair. It is layer-agnostic — it reports contacts and lets the colliders'
|
|
11
|
+
# owners decide meaning. See docs/api/systems.md.
|
|
12
|
+
#
|
|
13
|
+
# **A contact is reported as two edges, not as a state.** `on_hit` fires on the
|
|
14
|
+
# step a pair starts overlapping and `on_separated` on the step it stops, each
|
|
15
|
+
# once, on both colliders. Nothing fires in between, so a handler is free to
|
|
16
|
+
# count, to play a sound, or to do anything else that must happen once — which
|
|
17
|
+
# is the whole reason the world keeps a ContactSet per collider rather than
|
|
18
|
+
# simply forwarding what the broadphase found.
|
|
19
|
+
#
|
|
20
|
+
# It is also *shape*-agnostic, which is what lets CircleCollider and BoxCollider
|
|
21
|
+
# share it (and collide with each other). A collider is anything answering:
|
|
22
|
+
#
|
|
23
|
+
# aabb_x, aabb_y, aabb_w, aabb_h its world-space bounding box, for bucketing
|
|
24
|
+
# cx, cy its centre, for the range queries below
|
|
25
|
+
# overlap?(other) the narrowphase, which the two colliders
|
|
26
|
+
# settle between themselves by double dispatch
|
|
27
|
+
# layer, node the tag and the owner
|
|
28
|
+
# contacts an Engine::ContactSet, which this drives
|
|
29
|
+
# emit_hit(other) the contact's two edges
|
|
30
|
+
# emit_separated(other)
|
|
31
|
+
#
|
|
32
|
+
# None of those may allocate: they run per collider per frame.
|
|
12
33
|
class CollisionWorld < Engine::Component
|
|
13
34
|
def initialize(cell_size:)
|
|
14
35
|
super()
|
|
@@ -16,13 +37,22 @@ module RGame
|
|
|
16
37
|
@colliders = []
|
|
17
38
|
end
|
|
18
39
|
|
|
19
|
-
|
|
40
|
+
# Reset rather than merely add: the collider may be a pooled one coming back
|
|
41
|
+
# from the dead, still carrying the contacts it held when it was freed.
|
|
42
|
+
def register(collider)
|
|
43
|
+
collider.contacts.reset
|
|
44
|
+
@colliders << collider
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The partners of a collider that leaves are told on their next step, by the
|
|
48
|
+
# ordinary separation pass — it is gone from the index, so the pair no longer
|
|
49
|
+
# overlaps. Nothing has to be emitted here.
|
|
20
50
|
def unregister(collider) = @colliders.delete(collider)
|
|
21
51
|
|
|
22
52
|
# Yield every registered collider whose centre lies within `r` of (x, y), using
|
|
23
53
|
# the spatial index built by the most recent #update. The narrowphase is a
|
|
24
|
-
# centre-distance test — the query is a point + range (a
|
|
25
|
-
# the collider's own
|
|
54
|
+
# centre-distance test — the query is a point + range (a turret's range ring), so
|
|
55
|
+
# the collider's own size isn't added in. Colliders whose node is queued for
|
|
26
56
|
# removal are skipped. As with SpatialHash#query a collider spanning several cells
|
|
27
57
|
# may be yielded more than once, so callers that *select* (e.g. #nearest) are
|
|
28
58
|
# written dup-insensitively. Layer-agnostic — filter by `collider.layer` in the
|
|
@@ -38,8 +68,47 @@ module RGame
|
|
|
38
68
|
end
|
|
39
69
|
end
|
|
40
70
|
|
|
71
|
+
# Yield every registered collider bucketed in a cell the region covers, skipping
|
|
72
|
+
# those whose node is queued for removal. The rectangular counterpart to
|
|
73
|
+
# #query_circle, and what a blocker source asks when it resolves a step: "what is
|
|
74
|
+
# near this box".
|
|
75
|
+
#
|
|
76
|
+
# It inherits #query_circle's dedup contract — a collider spanning several cells
|
|
77
|
+
# may be yielded more than once — so a caller that *selects* is written
|
|
78
|
+
# dup-insensitively, the way #nearest and Engine::ActorBlockers both are. Unlike
|
|
79
|
+
# #query_circle there is no narrowphase here at all: the bucket walk is the whole
|
|
80
|
+
# answer, and refining it is the caller's business. Layer-agnostic; filter by
|
|
81
|
+
# `collider.layer` in the block. Allocation-free.
|
|
82
|
+
def query_box(x, y, w, h)
|
|
83
|
+
@hash.query(x, y, w, h) do |collider|
|
|
84
|
+
next if collider.node.freed?
|
|
85
|
+
|
|
86
|
+
yield collider
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Re-bucket a collider that has moved since #update built the index, so a query
|
|
91
|
+
# later in the same step still finds it where it now is. `from_*` is the box it
|
|
92
|
+
# was bucketed at — the caller knows it, which is what lets the index keep no
|
|
93
|
+
# per-item state of its own.
|
|
94
|
+
#
|
|
95
|
+
# This is what makes a mid-step query exact rather than nearly right. Buckets are
|
|
96
|
+
# filled once per step and a collider that moves afterwards is still bucketed
|
|
97
|
+
# where it was, so a query over cells it has left does not reach it. Measured over
|
|
98
|
+
# 60,000 queries at two hundred actors: 116 misses left stale, 0 re-indexed, and
|
|
99
|
+
# padding the query instead is not exact at any pad — it compensates for the
|
|
100
|
+
# *other* actor's staleness by inflating the *mover's* step. Engine::CollisionSystem
|
|
101
|
+
# calls this through a blocker source's #moved, so nothing a game writes has to
|
|
102
|
+
# remember it; anything else that moves a collider mid-step may call it directly.
|
|
103
|
+
#
|
|
104
|
+
# Allocation-free, so a resolver may call it every step.
|
|
105
|
+
def reindex(collider, from_x, from_y, from_w, from_h)
|
|
106
|
+
@hash.remove(collider, from_x, from_y, from_w, from_h)
|
|
107
|
+
insert(collider)
|
|
108
|
+
end
|
|
109
|
+
|
|
41
110
|
# 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
|
|
111
|
+
# qualifies. Restrict to a single `layer:` (the common case: a turret targeting only
|
|
43
112
|
# :enemy). Dup-safe — it keeps the running minimum, so #query_circle's possible
|
|
44
113
|
# multi-cell repeats don't matter. Allocation-free.
|
|
45
114
|
def nearest(x, y, r, layer: nil)
|
|
@@ -59,15 +128,64 @@ module RGame
|
|
|
59
128
|
best
|
|
60
129
|
end
|
|
61
130
|
|
|
131
|
+
# Is the cell containing the *world* point (x, y) free — "may a pickup spawn on
|
|
132
|
+
# this square?" A point, not a region: pass any coordinate inside the square you
|
|
133
|
+
# mean. The cells are the broadphase's own, so set `cell_size` to the game's
|
|
134
|
+
# square and the two grids line up.
|
|
135
|
+
#
|
|
136
|
+
# Two coordinate gotchas, both easy to get wrong from a node that has neither in
|
|
137
|
+
# mind:
|
|
138
|
+
#
|
|
139
|
+
# - The index holds **world** coordinates, because that is what a collider
|
|
140
|
+
# reports. A node whose own grid starts somewhere else adds its world origin
|
|
141
|
+
# before asking (`node.world_x + col * cell_size`) — and puts that origin on a
|
|
142
|
+
# multiple of `cell_size`, since the cells are the hash's own lattice anchored
|
|
143
|
+
# at the world origin. A board off that lattice has each square straddling two
|
|
144
|
+
# cells, and both read occupied.
|
|
145
|
+
# - It answers about the index the most recent #update built, exactly as
|
|
146
|
+
# #query_circle and #nearest do. Before the first update every cell is empty.
|
|
147
|
+
#
|
|
148
|
+
# The bucket is the answer, geometry and all: the broadphase's cell walk is
|
|
149
|
+
# half-open exactly like CollisionBox.overlap?, so a collider is bucketed in a
|
|
150
|
+
# cell if and only if it overlaps that cell's area. All this adds is the one
|
|
151
|
+
# thing the index cannot know — that a collider queued for removal no longer
|
|
152
|
+
# occupies anything, the same rule the queries above follow, so a corpse cannot
|
|
153
|
+
# reserve a square.
|
|
154
|
+
#
|
|
155
|
+
# Allocation-free, including the miss: SpatialHash#cell_empty? is asked first
|
|
156
|
+
# because it is the only way to look at a cell without materialising a bucket for
|
|
157
|
+
# it, and a caller scanning a board for a free square asks mostly about empty
|
|
158
|
+
# ones.
|
|
159
|
+
def cell_empty?(x, y)
|
|
160
|
+
return true if @hash.cell_empty?(x, y)
|
|
161
|
+
|
|
162
|
+
free = true
|
|
163
|
+
@hash.query(x, y, 0, 0) { |collider| free &&= collider.node.freed? }
|
|
164
|
+
free
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Rebuild the index, then report the step's two kinds of edge. Starting edges
|
|
168
|
+
# come first and every one of them for the step is emitted before the first
|
|
169
|
+
# ending one, because a separation is only knowable once every pair has been
|
|
170
|
+
# looked at.
|
|
62
171
|
def update(_dt)
|
|
63
172
|
@hash.clear
|
|
64
|
-
@colliders.each
|
|
173
|
+
@colliders.each do |collider|
|
|
174
|
+
collider.contacts.begin_frame
|
|
175
|
+
insert(collider)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
report_contacts
|
|
179
|
+
report_separations
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
65
183
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
184
|
+
def insert(collider)
|
|
185
|
+
@hash.insert(collider, collider.aabb_x, collider.aabb_y, collider.aabb_w, collider.aabb_h)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def report_contacts
|
|
71
189
|
count = @colliders.size
|
|
72
190
|
i = 0
|
|
73
191
|
while i < count
|
|
@@ -75,27 +193,37 @@ module RGame
|
|
|
75
193
|
i += 1
|
|
76
194
|
next if a.node.freed?
|
|
77
195
|
|
|
78
|
-
|
|
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
|
|
196
|
+
pair_up(a)
|
|
90
197
|
end
|
|
91
198
|
end
|
|
92
199
|
|
|
93
|
-
|
|
200
|
+
def pair_up(a)
|
|
201
|
+
contacts = a.contacts
|
|
202
|
+
@hash.query(a.aabb_x, a.aabb_y, a.aabb_w, a.aabb_h) do |b|
|
|
203
|
+
next if a.node.freed? || b.node.freed? || a.object_id >= b.object_id
|
|
204
|
+
next if contacts.touching?(b)
|
|
205
|
+
next unless a.overlap?(b)
|
|
94
206
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
207
|
+
started = contacts.started?(b)
|
|
208
|
+
contacts.add(b)
|
|
209
|
+
b.contacts.add(a)
|
|
210
|
+
next unless started
|
|
211
|
+
|
|
212
|
+
a.emit_hit(b)
|
|
213
|
+
b.emit_hit(a)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def report_separations
|
|
218
|
+
count = @colliders.size
|
|
219
|
+
i = 0
|
|
220
|
+
while i < count
|
|
221
|
+
collider = @colliders[i]
|
|
222
|
+
i += 1
|
|
223
|
+
next if collider.node.freed?
|
|
224
|
+
|
|
225
|
+
collider.contacts.each_ended { |other| collider.emit_separated(other) }
|
|
226
|
+
end
|
|
99
227
|
end
|
|
100
228
|
end
|
|
101
229
|
end
|
|
@@ -3,20 +3,36 @@
|
|
|
3
3
|
module RGame
|
|
4
4
|
module Engine
|
|
5
5
|
module Components
|
|
6
|
-
# Queues the node for removal once
|
|
7
|
-
#
|
|
8
|
-
# queue_free so it is safe to trigger from inside the update traversal.
|
|
6
|
+
# Queues the node for removal once its origin is further than `margin` past an
|
|
7
|
+
# edge of the world bounds. Used by short-lived projectiles; removal is deferred
|
|
8
|
+
# via queue_free so it is safe to trigger from inside the update traversal.
|
|
9
|
+
#
|
|
10
|
+
# The margin is what stands in for the node's size: a node drawn centred on its
|
|
11
|
+
# origin has fully left once the margin is at least its half-extent.
|
|
12
|
+
#
|
|
13
|
+
# Bounds resolve the same way ScreenWrap's do: from the scene's world system,
|
|
14
|
+
# at attach time, with `width:`/`height:` as an override. And like ScreenWrap it
|
|
15
|
+
# tests the node's **world** position, so a projectile spawned as the child of an
|
|
16
|
+
# offset emitter leaves at the world's edge rather than at one shifted by the
|
|
17
|
+
# emitter.
|
|
9
18
|
class DespawnOffscreen < Engine::Component
|
|
10
|
-
def initialize(width
|
|
19
|
+
def initialize(width: nil, height: nil, margin: 0.0)
|
|
11
20
|
super()
|
|
12
|
-
@
|
|
13
|
-
@
|
|
21
|
+
@given_width = width
|
|
22
|
+
@given_height = height
|
|
14
23
|
@margin = margin
|
|
15
24
|
end
|
|
16
25
|
|
|
26
|
+
# See ScreenWrap#on_attach: resolved per entry, so a recycled node is correct
|
|
27
|
+
# after a scene change.
|
|
28
|
+
def on_attach
|
|
29
|
+
WorldBounds.one_response!(node)
|
|
30
|
+
@width, @height = WorldBounds.resolve(node, @given_width, @given_height)
|
|
31
|
+
end
|
|
32
|
+
|
|
17
33
|
def update(_dt)
|
|
18
|
-
x = node.
|
|
19
|
-
y = node.
|
|
34
|
+
x = node.world_x
|
|
35
|
+
y = node.world_y
|
|
20
36
|
offscreen = x < -@margin || x > @width + @margin || y < -@margin || y > @height + @margin
|
|
21
37
|
node.queue_free if offscreen
|
|
22
38
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# A BoxCollider whose rectangle is the node's *feet*: horizontally centred in the
|
|
7
|
+
# node's dimensions and anchored to their bottom. That is the shape a top-down
|
|
8
|
+
# character collides with — a 16x22 hero standing on a floor occupies the 12x6
|
|
9
|
+
# patch under them, not the whole sprite, which is what stops their head from
|
|
10
|
+
# bumping into a wall a tile away.
|
|
11
|
+
#
|
|
12
|
+
# It is an ordinary BoxCollider in every other respect, so it registers with the
|
|
13
|
+
# scene's CollisionWorld, collides with circles and boxes alike, and
|
|
14
|
+
# `get_component(BoxCollider)` finds it. See docs/api/components.md.
|
|
15
|
+
#
|
|
16
|
+
# The box is derived from `node.width`/`node.height`, which AnimatedSprite sets
|
|
17
|
+
# from the sprite frame, rather than from a sprite size passed in — so the caller
|
|
18
|
+
# gives the feet box and nothing else, and the two can never disagree.
|
|
19
|
+
class FeetCollider < BoxCollider
|
|
20
|
+
def initialize(width:, height:, layer: :default)
|
|
21
|
+
super
|
|
22
|
+
@feet_width = width
|
|
23
|
+
@feet_height = height
|
|
24
|
+
@box = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The feet box, derived from the node's sprite size and memoised.
|
|
28
|
+
#
|
|
29
|
+
# **Only valid once the node is in the tree**, and it says so rather than letting
|
|
30
|
+
# you find out later. The size comes from AnimatedSprite#on_attach, so a read from
|
|
31
|
+
# a constructor sees a 0x0 node and bakes a box anchored to nothing — permanently,
|
|
32
|
+
# because this memoises. The symptom is an actor that walks through walls it
|
|
33
|
+
# should not, a long way from the call that caused it. Guarding costs one
|
|
34
|
+
# comparison, once.
|
|
35
|
+
#
|
|
36
|
+
# Building it here rather than in on_attach is what makes the order components
|
|
37
|
+
# were added in irrelevant: the first read is a frame later, by which time every
|
|
38
|
+
# sibling has attached. Assigning `box =` still wins, since that leaves nothing
|
|
39
|
+
# to memoise.
|
|
40
|
+
def box
|
|
41
|
+
@box ||= build_box
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def build_box
|
|
47
|
+
if node.width.zero? || node.height.zero?
|
|
48
|
+
raise "FeetCollider needs the node's sprite size, but it is " \
|
|
49
|
+
"#{node.width}x#{node.height}. AnimatedSprite sets that when it attaches, so " \
|
|
50
|
+
'read this after the node is in the tree, not while building it.'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Engine::CollisionBox.bottom_anchored(
|
|
54
|
+
sprite_width: node.width, sprite_height: node.height,
|
|
55
|
+
width: @feet_width, height: @feet_height
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|