rgame 0.1.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 +228 -0
- data/README.md +142 -243
- data/docs/api/README.md +141 -90
- data/docs/api/app.md +125 -68
- data/docs/api/assets.md +203 -166
- data/docs/api/audio.md +130 -89
- data/docs/api/cli.md +259 -0
- data/docs/api/components.md +1045 -254
- data/docs/api/drawing.md +187 -119
- data/docs/api/examples.md +263 -0
- data/docs/api/game.md +165 -45
- data/docs/api/images.md +39 -39
- data/docs/api/input.md +344 -85
- data/docs/api/internals.md +241 -62
- data/docs/api/localization.md +285 -0
- data/docs/api/scene_graph.md +503 -123
- data/docs/api/signals.md +81 -76
- data/docs/api/systems.md +197 -64
- data/docs/api/text.md +58 -41
- data/docs/api/tile_maps.md +237 -0
- data/docs/api/toolbox.md +400 -147
- data/docs/api/ui.md +927 -0
- data/docs/api/values.md +234 -19
- 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/README.md +6 -5
- data/ext/rgame_core/app/app.c +215 -11
- 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 +113 -2
- data/ext/rgame_core/example.rb +18 -56
- data/ext/rgame_core/extconf.rb +16 -113
- data/ext/rgame_core/graphics/canvas.c +45 -4
- data/ext/rgame_core/graphics/canvas.h +65 -10
- data/ext/rgame_core/graphics/clip.c +22 -13
- data/ext/rgame_core/include/rgame/core.h +151 -5
- data/ext/rgame_core/input/gamepad.c +57 -3
- data/ext/rgame_core/ruby/audio_ext.c +10 -5
- data/ext/rgame_core/ruby/core_ext.c +46 -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 +42 -19
- data/ext/rgame_util/color_ext.c +12 -3
- 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 -42
- 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/input.rb +35 -41
- data/lib/rgame/core/locale.rb +22 -0
- data/lib/rgame/core/nine_slice.rb +0 -21
- data/lib/rgame/core/recording.rb +3 -1
- data/lib/rgame/core/renderer.rb +75 -84
- data/lib/rgame/core/sprite_sheet.rb +0 -3
- data/lib/rgame/core/tile_map_renderer.rb +77 -65
- 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 +55 -10
- 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 +35 -1
- data/lib/rgame/engine/components/action_trigger.rb +0 -1
- data/lib/rgame/engine/components/animated_sprite.rb +31 -23
- 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 +21 -41
- 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 +22 -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 +70 -29
- 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 +45 -0
- data/lib/rgame/engine/debug_overlay.rb +23 -18
- 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 +76 -22
- data/lib/rgame/engine/input/actions.rb +63 -12
- 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 +364 -80
- data/lib/rgame/engine/path.rb +4 -6
- 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/presentation.rb +171 -0
- data/lib/rgame/engine/scene/scene_stack.rb +29 -7
- data/lib/rgame/engine/sealed_privates.rb +54 -0
- data/lib/rgame/engine/spatial_hash.rb +53 -8
- 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 +82 -0
- 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 +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 +43 -25
- data/lib/rgame/game.rb +164 -22
- 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 +106 -44
- 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 +123 -0
- data/lib/rgame/util.rb +5 -3
- data/lib/rgame/version.rb +1 -1
- data/lib/rgame.rb +0 -15
- metadata +176 -20
- 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/camera_view.rb +0 -28
- 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
|
@@ -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
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# A jump in a top-down view. Pressing the action lifts the node along a
|
|
7
|
+
# parabola that peaks at `peak` pixels halfway through `duration` seconds,
|
|
8
|
+
# then puts it back on the ground.
|
|
9
|
+
#
|
|
10
|
+
# The height is written to Node2D#elevation, never to `y`. In a top-down view
|
|
11
|
+
# a jump is a *drawing* offset: the sprite arcs above the spot the character
|
|
12
|
+
# stands on, while that spot, and every collider and camera reading it, stays
|
|
13
|
+
# on the ground. So a hop does not carry anyone over a wall. What it
|
|
14
|
+
# crosses is the game's decision, made by reading `airborne?`.
|
|
15
|
+
#
|
|
16
|
+
# node.add_component(Engine::Components::Hop.new(peak: 18, duration: 0.5))
|
|
17
|
+
#
|
|
18
|
+
# The arc is a function of the time accumulated in `update`, not of a clock,
|
|
19
|
+
# so a paused node hangs in the air and a spec can ask for the height at 0.25s.
|
|
20
|
+
# It starts on the action's press edge, so holding the button hops once.
|
|
21
|
+
# `action: nil` leaves only #jump, for something that is not a player.
|
|
22
|
+
class Hop < Engine::Component
|
|
23
|
+
attr_reader :peak, :duration, :height
|
|
24
|
+
|
|
25
|
+
def initialize(peak:, duration:, action: :jump)
|
|
26
|
+
super()
|
|
27
|
+
raise ArgumentError, "peak must be positive, got #{peak.inspect}" unless peak.positive?
|
|
28
|
+
raise ArgumentError, "duration must be positive, got #{duration.inspect}" unless duration.positive?
|
|
29
|
+
|
|
30
|
+
@peak = peak
|
|
31
|
+
@duration = duration
|
|
32
|
+
@action = action
|
|
33
|
+
@height = 0.0
|
|
34
|
+
@elapsed = 0.0
|
|
35
|
+
@airborne = false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Attaching lands the node, so a pooled node reused mid-hop starts on the ground.
|
|
39
|
+
def on_attach = land
|
|
40
|
+
|
|
41
|
+
def airborne? = @airborne
|
|
42
|
+
|
|
43
|
+
# Leave the ground. Does nothing while already off it; `airborne?` says which.
|
|
44
|
+
def jump
|
|
45
|
+
return if @airborne
|
|
46
|
+
|
|
47
|
+
@airborne = true
|
|
48
|
+
@elapsed = 0.0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def control(actions)
|
|
52
|
+
jump if @action && actions.pressed?(@action)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def update(dt)
|
|
56
|
+
return unless @airborne
|
|
57
|
+
|
|
58
|
+
@elapsed += dt
|
|
59
|
+
return land if @elapsed >= @duration
|
|
60
|
+
|
|
61
|
+
@height = 4.0 * @peak * @elapsed * (@duration - @elapsed) / (@duration * @duration)
|
|
62
|
+
node.elevation = @height
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def land
|
|
68
|
+
@airborne = false
|
|
69
|
+
@elapsed = 0.0
|
|
70
|
+
@height = 0.0
|
|
71
|
+
node.elevation = 0
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# A stable name for one node, so something outside the tree can refer to it.
|
|
7
|
+
#
|
|
8
|
+
# sheep.add_component(Identity.new(id: 7))
|
|
9
|
+
# Identity.of(sheep) # => 7
|
|
10
|
+
#
|
|
11
|
+
# ## What it is for, and what does not need it
|
|
12
|
+
#
|
|
13
|
+
# Most saving needs no identity at all. A scene is a recipe and a save file
|
|
14
|
+
# is state: the scene rebuilds itself identically, and the save supplies the
|
|
15
|
+
# few facts that differ. A *singular* thing needs no id because the variable
|
|
16
|
+
# holding it is one — `@dog` is set where the dog is built and written
|
|
17
|
+
# straight to on load. *Interchangeable* things need none either, because
|
|
18
|
+
# their order will do: an array of positions restored in order is correct
|
|
19
|
+
# precisely when swapping two of them changes nothing observable.
|
|
20
|
+
#
|
|
21
|
+
# This is for the case those two do not cover:
|
|
22
|
+
#
|
|
23
|
+
# - **members of a collection that can die**, where each survivor keeps
|
|
24
|
+
# state of its own. An array index stops meaning anything the first time
|
|
25
|
+
# the middle of the list is removed;
|
|
26
|
+
# - **a reference from one saved thing to another** — a dog chasing a
|
|
27
|
+
# particular sheep. A node reference cannot be written to a file, and this
|
|
28
|
+
# is what it is written *as*.
|
|
29
|
+
#
|
|
30
|
+
# The second is the one that genuinely forces ids. A collection could always
|
|
31
|
+
# be respawned from its own records; a reference between two of them could
|
|
32
|
+
# not.
|
|
33
|
+
#
|
|
34
|
+
# ## The engine supplies the mechanism, a game supplies the meaning
|
|
35
|
+
#
|
|
36
|
+
# What gets an id, what the ids are, and how they are handed out are the
|
|
37
|
+
# game's business — the same division as `Timer`, which counts down without
|
|
38
|
+
# an opinion about what happens next. Two things follow from that, and both
|
|
39
|
+
# are the game's to get right:
|
|
40
|
+
#
|
|
41
|
+
# **Ids must be unique among the things that can refer to each other.** This
|
|
42
|
+
# component checks nothing; a duplicate is a save that restores the wrong
|
|
43
|
+
# object, silently.
|
|
44
|
+
#
|
|
45
|
+
# **The allocator belongs in the save.** A counter that restarts at 1 on
|
|
46
|
+
# load will reissue ids that the restored objects are already using, and the
|
|
47
|
+
# collision surfaces later as a reference pointing at the wrong thing. Save
|
|
48
|
+
# the next id alongside the objects and restore it too — it is one number,
|
|
49
|
+
# and forgetting it is the classic way this goes wrong.
|
|
50
|
+
class Identity < Engine::Component
|
|
51
|
+
attr_reader :id
|
|
52
|
+
|
|
53
|
+
def initialize(id:)
|
|
54
|
+
super()
|
|
55
|
+
raise ArgumentError, 'an identity needs an id' if id.nil?
|
|
56
|
+
|
|
57
|
+
@id = id
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The id of a node, or nil for one that carries no identity.
|
|
61
|
+
#
|
|
62
|
+
# This is the direction that matters at save time: a component holding a
|
|
63
|
+
# *node* — `Targeting#target` is the worked example — has to turn it into
|
|
64
|
+
# something writable, and it has only the node to go on. Going the other
|
|
65
|
+
# way is a game's own lookup, because only the game knows which
|
|
66
|
+
# collection to search.
|
|
67
|
+
def self.of(node)
|
|
68
|
+
node&.get_component(self)&.id
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# What every component that moves its node has in common: a step computed some way
|
|
7
|
+
# of its own, landing either straight on the node or against whatever may stop it.
|
|
8
|
+
# CharacterBody, Velocity and PathFollow are the three, and they are three classes
|
|
9
|
+
# because walking an intent, integrating a velocity and following a path are three
|
|
10
|
+
# different jobs. What they share is what happens *after* a step is computed, and
|
|
11
|
+
# that is this class.
|
|
12
|
+
#
|
|
13
|
+
# A mover fills in one private hook, `take_step(dt)`, and calls `apply_move(dx, dy)`
|
|
14
|
+
# from it. `update` is not for overriding: it opens the step, calls the hook and
|
|
15
|
+
# reports what stopped being in the way, so no mover can forget either edge.
|
|
16
|
+
#
|
|
17
|
+
# **Why a base class, and not a sibling component or a Node2D method.** A separate
|
|
18
|
+
# `Blocking` component that movers write through was tried, and it is order-dependent:
|
|
19
|
+
# closing the step has to happen after the mover's step, and a sibling can only do
|
|
20
|
+
# that from its own `update`, which runs wherever it sits in the component list — two
|
|
21
|
+
# add orders fired on_unblocked on two different ticks. A `Node2D#move_by` owning
|
|
22
|
+
# `blocked_by` is order-free, but puts collision into the base of every node, HUDs and
|
|
23
|
+
# menus included. A base class is order-free and touches only what moves.
|
|
24
|
+
#
|
|
25
|
+
# ## What stops a step is declared, not subclassed
|
|
26
|
+
#
|
|
27
|
+
# `blocked_by:` lists what a step may not pass through, and the default is nothing:
|
|
28
|
+
# the mover writes the node's position directly and needs no collider and no system
|
|
29
|
+
# on the scene. That free step is not quite free: `update` → `take_step` →
|
|
30
|
+
# `apply_move` is one dispatch more than a component writing `node.x` itself, which
|
|
31
|
+
# measured about 12% on a bare Velocity step (tens of nanoseconds). Inlining the free
|
|
32
|
+
# write into each subclass wins it back, at the cost of every mover copying
|
|
33
|
+
# `apply_move`'s free branch and reading this class's ivars — not taken.
|
|
34
|
+
#
|
|
35
|
+
# Velocity.new(vx: 120) # flies wherever it points
|
|
36
|
+
# Velocity.new(vx: 120, blocked_by: [:wall]) # stops flush against a :wall collider
|
|
37
|
+
# CharacterBody.new(speed: 80, blocked_by: [:tiles]) # slides along the map's solid tiles
|
|
38
|
+
# CharacterBody.new(speed: 80, blocked_by: %i[tiles npc]) # ...and does not walk through NPCs
|
|
39
|
+
# CharacterBody.new(speed: 80, blocked_by: %i[npc bounds]) # ...and cannot leave the world
|
|
40
|
+
#
|
|
41
|
+
# Two names are reserved: **`:tiles`** is the scene's TileWorld, and **`:bounds`** is the
|
|
42
|
+
# edge of the region the scene's WorldBounds describes. **Every other name is a collider
|
|
43
|
+
# layer**, resolved against the scene's CollisionWorld: a mover declaring `:npc` is
|
|
44
|
+
# stopped by any BoxCollider whose `layer` is `:npc`, flush against its edge, exactly the
|
|
45
|
+
# way a solid tile stops it. A layer that is empty, or whose colliders all leave, is not
|
|
46
|
+
# an error — the declaration says what *may* stop this mover, not what does.
|
|
47
|
+
#
|
|
48
|
+
# `:bounds` is declared rather than automatic, and a mover that does not declare it
|
|
49
|
+
# leaves the world. Stopping at the edge is one of three responses to it, beside
|
|
50
|
+
# ScreenWrap and DespawnOffscreen, and a node may carry only one — declaring `:bounds`
|
|
51
|
+
# beside either raises at attach (WorldBounds.one_response!).
|
|
52
|
+
#
|
|
53
|
+
# Blocking is box-versus-box: a CircleCollider on a declared layer reports its contacts
|
|
54
|
+
# as usual and stops nothing, and a mover that declares anything needs a BoxCollider of
|
|
55
|
+
# its own.
|
|
56
|
+
#
|
|
57
|
+
# The two are not alternatives. `blocked_by` and `on_hit` answer different questions —
|
|
58
|
+
# what may I pass through, and what am I touching — and a flush-blocked pair does not
|
|
59
|
+
# overlap, so a mover that must both stop and react needs both.
|
|
60
|
+
#
|
|
61
|
+
# A declaration this scene cannot honour raises at on_attach rather than quietly
|
|
62
|
+
# falling back to free movement — a mover passing through walls looks like a collision
|
|
63
|
+
# bug, and the cause would be a scene three files away that never mounted the system.
|
|
64
|
+
#
|
|
65
|
+
# ## A blocked step slides
|
|
66
|
+
#
|
|
67
|
+
# Where a step lands is decided by Engine::CollisionSystem, which resolves X and then Y,
|
|
68
|
+
# so a diagonal push into a wall keeps the component that is still free. That is the
|
|
69
|
+
# right feel for a character and the wrong one for a bullet — and a bullet does not
|
|
70
|
+
# need a different resolver for it, it needs to react: `on_blocked` fires on the step
|
|
71
|
+
# it hits, and a bullet that queue-frees itself there is gone before it slides anywhere.
|
|
72
|
+
# One that bounces reads which axis was stopped and turns that half of its velocity:
|
|
73
|
+
#
|
|
74
|
+
# velocity = Velocity.new(vx: 120, vy: 80, blocked_by: %i[wall bounds])
|
|
75
|
+
# velocity.on_blocked do |_by, axis|
|
|
76
|
+
# velocity.vx = -velocity.vx unless axis == :y
|
|
77
|
+
# velocity.vy = -velocity.vy unless axis == :x
|
|
78
|
+
# end
|
|
79
|
+
#
|
|
80
|
+
# ## The shape has one owner, and it is not this
|
|
81
|
+
#
|
|
82
|
+
# A blocked step is resolved against the node's **collider** box, read from the
|
|
83
|
+
# sibling BoxCollider (FeetCollider is the one a walking character wants). The box is
|
|
84
|
+
# therefore given once, to the component that *is* a shape, and the same rectangle both
|
|
85
|
+
# stops the step and reports contacts — there is nothing to hand from one component to
|
|
86
|
+
# the other and nothing to keep in sync.
|
|
87
|
+
class Mover < Engine::Component
|
|
88
|
+
# The two edges of being stopped: on_blocked on the step this mover starts being
|
|
89
|
+
# stopped by something, on_unblocked on the step it stops. Each fires once per
|
|
90
|
+
# blocker, so a handler may spend a life or play a sound — the spiky ball that
|
|
91
|
+
# both stops the player and hurts them is the two signals plus an on_hit.
|
|
92
|
+
#
|
|
93
|
+
# The listener gets whatever stopped the step and reads its #layer and #node, so
|
|
94
|
+
# one handler covers every kind: a collider answers its own layer and its owning
|
|
95
|
+
# node, the map's solid tiles answer :tiles and nil (Engine::TileBlockers::TILES),
|
|
96
|
+
# and the world's edge answers :bounds and nil.
|
|
97
|
+
#
|
|
98
|
+
# on_blocked also says which axis of the step it stopped — :x, :y, or :both when one
|
|
99
|
+
# blocker stopped the two at once — which is what a bounce branches on. A listener
|
|
100
|
+
# that names only the blocker, `{ |by| ... }`, never sees it: a block drops the
|
|
101
|
+
# arguments it does not name.
|
|
102
|
+
#
|
|
103
|
+
# on_unblocked has no axis, because what ends is a blocker stopping this mover, not
|
|
104
|
+
# an axis. A blocker that stopped x on one step and y on the next was in the way the
|
|
105
|
+
# whole time, and ends once.
|
|
106
|
+
signal :on_blocked, Engine::Signal.define(:by, :axis)
|
|
107
|
+
signal :on_unblocked, Engine::Signal.define(:by)
|
|
108
|
+
|
|
109
|
+
TILES = :tiles
|
|
110
|
+
BOUNDS = :bounds
|
|
111
|
+
RESERVED = [TILES, BOUNDS].freeze
|
|
112
|
+
|
|
113
|
+
def initialize(blocked_by: [])
|
|
114
|
+
super()
|
|
115
|
+
@blocked_by = Array(blocked_by)
|
|
116
|
+
@collider = nil
|
|
117
|
+
@collision = nil
|
|
118
|
+
@last_move_blocked = false
|
|
119
|
+
@stopped_by = Engine::ContactSet.new
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Resolve each declared blocker and build the resolver that runs them, once the node
|
|
123
|
+
# is in the tree and both the scene's systems and this node's other components are
|
|
124
|
+
# reachable.
|
|
125
|
+
#
|
|
126
|
+
# **The resolver is the mover's own**, rather than something borrowed off the scene.
|
|
127
|
+
# It has to be: a source over other colliders holds this mover's collider and this
|
|
128
|
+
# mover's layer list, so two movers declaring different `blocked_by` cannot share
|
|
129
|
+
# one — and a scene may mount a CollisionWorld with no TileWorld at all, so there is
|
|
130
|
+
# not always a scene-level resolver to borrow in the first place. What is shared is
|
|
131
|
+
# what can be: the TileWorld's own source is borrowed, not rebuilt.
|
|
132
|
+
#
|
|
133
|
+
# Nothing here runs on a frame. The list is built once, and a step only walks it.
|
|
134
|
+
#
|
|
135
|
+
# A subclass that needs its own attach work calls `super` first — PathFollow does,
|
|
136
|
+
# to place its node before walking.
|
|
137
|
+
def on_attach
|
|
138
|
+
@stopped_by.reset
|
|
139
|
+
return if @blocked_by.empty?
|
|
140
|
+
|
|
141
|
+
WorldBounds.one_response!(node) if blocked_by?(BOUNDS)
|
|
142
|
+
|
|
143
|
+
@collider = require_sibling(BoxCollider)
|
|
144
|
+
@collision = Engine::CollisionSystem.new(blockers: resolve_blockers)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Take this step, then report what stopped being in the way.
|
|
148
|
+
#
|
|
149
|
+
# **The set advances once per update, not once per apply_move**, and that is what
|
|
150
|
+
# makes standing still an unblocking: a mover that stops pushing into something
|
|
151
|
+
# records nothing this step, so what it was pressing against ends and on_unblocked
|
|
152
|
+
# fires. It also keeps the bookkeeping where a subclass cannot lose it — a mover
|
|
153
|
+
# that resolves a step in several moves, or overrides apply_move, still opens the
|
|
154
|
+
# step once and still reports its edges.
|
|
155
|
+
def update(dt)
|
|
156
|
+
return take_step(dt) unless @collision
|
|
157
|
+
|
|
158
|
+
@stopped_by.begin_frame
|
|
159
|
+
take_step(dt)
|
|
160
|
+
@stopped_by.each_ended { on_unblocked_signal.emit(it) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Which way this mover's step is going, each axis in -1..1, and 0, 0 when it is not
|
|
164
|
+
# trying to move. A facing rather than a velocity: a mover pressed into a wall still
|
|
165
|
+
# heads into it, so an AnimatedSprite keeps walking against the wall rather than
|
|
166
|
+
# standing. Each subclass answers from what its step is computed out of.
|
|
167
|
+
def heading_x = 0.0
|
|
168
|
+
def heading_y = 0.0
|
|
169
|
+
|
|
170
|
+
# Whether `name` is one of the things this mover declared it may be stopped by.
|
|
171
|
+
def blocked_by?(name) = @blocked_by.include?(name)
|
|
172
|
+
|
|
173
|
+
# Where a step lands. Public, and kept separate from `take_step`, so a mover that
|
|
174
|
+
# resolves a step some other way — a platformer's CharacterBody, with gravity and a
|
|
175
|
+
# jump — inherits everything around it rather than restating it.
|
|
176
|
+
#
|
|
177
|
+
# The branch is on what was declared rather than on a subclass: an unblocked mover
|
|
178
|
+
# writes straight to the node, and a blocked one hands *itself* to its resolver as
|
|
179
|
+
# the actor being moved (see the adapter below).
|
|
180
|
+
def apply_move(dx, dy)
|
|
181
|
+
unless @collision
|
|
182
|
+
node.x += dx
|
|
183
|
+
node.y += dy
|
|
184
|
+
return
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
@collision.move(self, dx, dy)
|
|
188
|
+
blocked_x = @collision.blocked_x
|
|
189
|
+
blocked_y = @collision.blocked_y
|
|
190
|
+
@last_move_blocked = !(blocked_x.nil? && blocked_y.nil?)
|
|
191
|
+
if blocked_x.equal?(blocked_y)
|
|
192
|
+
record_blocker(blocked_x, :both)
|
|
193
|
+
else
|
|
194
|
+
record_blocker(blocked_x, :x)
|
|
195
|
+
record_blocker(blocked_y, :y)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The actor adapter CollisionSystem#move drives: it reads x/y/collision_box, works
|
|
200
|
+
# out where the step lands, and writes the resolved position back. The box is the
|
|
201
|
+
# sibling collider's — the node's one shape, so retuning `collider.box` retunes
|
|
202
|
+
# what a step collides with.
|
|
203
|
+
#
|
|
204
|
+
# ## The adapter is in world space
|
|
205
|
+
#
|
|
206
|
+
# x and y are the node's **world** position, because that is the space everything
|
|
207
|
+
# else about collision is already in: the tile grid is a world-coordinate grid, and
|
|
208
|
+
# BoxCollider#aabb_x reports `node.world_x + box.offset_x`. A mover under an offset
|
|
209
|
+
# ancestor that reported its local position would resolve against a map shifted by
|
|
210
|
+
# the ancestor, and would be compared against other colliders in a different frame
|
|
211
|
+
# entirely.
|
|
212
|
+
#
|
|
213
|
+
# Writing goes back through Node2D#world_x= / #world_y=, which turn the resolved world
|
|
214
|
+
# position into the local one the node actually lives in.
|
|
215
|
+
#
|
|
216
|
+
# A mover under a rotated ancestor is still outside what an axis-aligned box supports
|
|
217
|
+
# (docs/api/components.md: a thing that spins wants a circle) — the position it lands
|
|
218
|
+
# at is exact, but the box it was resolved with does not turn with the frame.
|
|
219
|
+
def collision_box = @collider.box
|
|
220
|
+
def x = node.world_x
|
|
221
|
+
def y = node.world_y
|
|
222
|
+
|
|
223
|
+
def x=(value)
|
|
224
|
+
node.world_x = value
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def y=(value)
|
|
228
|
+
node.world_y = value
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def take_step(_dt) = nil
|
|
234
|
+
|
|
235
|
+
def blocking? = !@collision.nil?
|
|
236
|
+
|
|
237
|
+
def last_move_blocked? = @last_move_blocked
|
|
238
|
+
|
|
239
|
+
def record_blocker(by, axis)
|
|
240
|
+
return if by.nil? || @stopped_by.touching?(by)
|
|
241
|
+
|
|
242
|
+
started = @stopped_by.started?(by)
|
|
243
|
+
@stopped_by.add(by)
|
|
244
|
+
on_blocked_signal.emit(by:, axis:) if started
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def resolve_blockers
|
|
248
|
+
sources = []
|
|
249
|
+
sources << tile_blockers if @blocked_by.include?(TILES)
|
|
250
|
+
sources << bounds_blockers if @blocked_by.include?(BOUNDS)
|
|
251
|
+
layers = @blocked_by.reject { RESERVED.include?(it) }
|
|
252
|
+
sources << actor_blockers(layers) unless layers.empty?
|
|
253
|
+
sources
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def tile_blockers
|
|
257
|
+
world = node.system(TileWorld) ||
|
|
258
|
+
raise("#{mover_name} is blocked_by :tiles, and the scene has no TileWorld " \
|
|
259
|
+
'system to resolve a step against. Mount one, or drop blocked_by for ' \
|
|
260
|
+
'a mover with nothing to collide with.')
|
|
261
|
+
world.blockers
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def bounds_blockers
|
|
265
|
+
bounds = node.system(WorldBounds) ||
|
|
266
|
+
raise("#{mover_name} is blocked_by :bounds, and the scene has no world " \
|
|
267
|
+
'bounds to stop at. Mount a World (or a TileWorld, which is one), or ' \
|
|
268
|
+
'drop :bounds for a mover that may leave the world.')
|
|
269
|
+
Engine::BoundsBlockers.new(bounds: bounds)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def actor_blockers(layers)
|
|
273
|
+
world = node.system(CollisionWorld) ||
|
|
274
|
+
raise("#{mover_name} is blocked_by #{layers.map(&:inspect).join(', ')}, which " \
|
|
275
|
+
'names collider layers, and the scene has no CollisionWorld system to ' \
|
|
276
|
+
'find them in. Mount one, or drop those names for a mover that only ' \
|
|
277
|
+
'the map stops.')
|
|
278
|
+
Engine::ActorBlockers.new(world: world, owner: @collider, layers: layers)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def mover_name = self.class.name&.split('::')&.last || self.class.inspect
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|