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,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Points a camera at the node it is attached to.
|
|
7
|
+
#
|
|
8
|
+
# player_node.add_component(CameraFollow.new(camera: players.primary.camera))
|
|
9
|
+
#
|
|
10
|
+
# ## Ownership and behaviour are different questions
|
|
11
|
+
#
|
|
12
|
+
# The camera cannot be *owned* by a node in the world — with several
|
|
13
|
+
# viewers there are several cameras, and a world that holds one has to know
|
|
14
|
+
# how many times it is being drawn. But deciding *where a camera points* is
|
|
15
|
+
# exactly a per-node concern, so it belongs here: the player owns the
|
|
16
|
+
# camera, and a component in the world moves it.
|
|
17
|
+
#
|
|
18
|
+
# That also makes "player two's camera follows player two" nothing more
|
|
19
|
+
# than attaching this to their node with their camera.
|
|
20
|
+
#
|
|
21
|
+
# `offset_x` / `offset_y` shift the point being centred on, for a node
|
|
22
|
+
# whose origin is not what should be in the middle of the screen — a
|
|
23
|
+
# bottom-anchored sprite usually wants its feet, not its head.
|
|
24
|
+
class CameraFollow < Engine::Component
|
|
25
|
+
def initialize(camera:, offset_x: 0.0, offset_y: 0.0)
|
|
26
|
+
super()
|
|
27
|
+
@camera = camera
|
|
28
|
+
@offset_x = offset_x
|
|
29
|
+
@offset_y = offset_y
|
|
30
|
+
end
|
|
31
|
+
|
|
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
|
|
35
|
+
# pixels at walking speed). That is deliberate and uniform: everything
|
|
36
|
+
# drawn through this camera trails equally, so nothing drifts apart on
|
|
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.
|
|
39
|
+
def update(_dt)
|
|
40
|
+
@camera.center_on(node.world_x + @offset_x, node.world_y + @offset_y)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
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).
|
|
10
|
+
#
|
|
11
|
+
# The intent doubles as the mover's heading, which is what AnimatedSprite faces by, so
|
|
12
|
+
# a character is just CharacterBody + a controller + AnimatedSprite.
|
|
13
|
+
#
|
|
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
|
+
attr_reader :move_x, :move_y
|
|
22
|
+
|
|
23
|
+
def initialize(speed:, blocked_by: [])
|
|
24
|
+
super(blocked_by: blocked_by)
|
|
25
|
+
@speed = speed
|
|
26
|
+
@move_x = 0.0
|
|
27
|
+
@move_y = 0.0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The heading is the intent as set, blocked or not.
|
|
31
|
+
def heading_x = @move_x
|
|
32
|
+
def heading_y = @move_y
|
|
33
|
+
|
|
34
|
+
# Set this step's movement intent; each axis is in -1..1.
|
|
35
|
+
def set_intent(intent_x, intent_y)
|
|
36
|
+
@move_x = intent_x
|
|
37
|
+
@move_y = intent_y
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def take_step(dt)
|
|
43
|
+
return if @move_x.zero? && @move_y.zero?
|
|
44
|
+
|
|
45
|
+
apply_move(@move_x * @speed * dt, @move_y * @speed * dt)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# A circular collision shape on a node. It registers itself with the scene's
|
|
7
|
+
# CollisionWorld system when it enters the tree and unregisters on leaving —
|
|
8
|
+
# the engine fires both hooks, so a spawned/despawned entity can't leak a
|
|
9
|
+
# registration. Its world centre is the node's world origin; the
|
|
10
|
+
# `layer` is an opaque tag the game reads in its on_hit handler to decide what a
|
|
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.
|
|
15
|
+
class CircleCollider < Engine::Component
|
|
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.
|
|
20
|
+
signal :on_hit, Engine::Signal.define(:other)
|
|
21
|
+
signal :on_separated, Engine::Signal.define(:other)
|
|
22
|
+
|
|
23
|
+
# radius is writable so a pooled entity (e.g. a multi-tier rock) can retune its
|
|
24
|
+
# shape on reset; CollisionWorld reads it fresh each frame, so no re-registration.
|
|
25
|
+
attr_accessor :radius
|
|
26
|
+
attr_reader :layer
|
|
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
|
+
|
|
32
|
+
def initialize(radius:, layer: :default)
|
|
33
|
+
super()
|
|
34
|
+
@radius = radius
|
|
35
|
+
@layer = layer
|
|
36
|
+
@contacts = Engine::ContactSet.new
|
|
37
|
+
end
|
|
38
|
+
|
|
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)
|
|
43
|
+
def on_detach = node.system(CollisionWorld)&.unregister(self)
|
|
44
|
+
|
|
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
|
|
68
|
+
|
|
69
|
+
# @api private
|
|
70
|
+
def overlap_box?(x, y, w, h)
|
|
71
|
+
Engine::CollisionBox.overlap_circle?(x, y, w, h, cx, cy, @radius)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Called by CollisionWorld on each edge (the signals' emit is otherwise private).
|
|
75
|
+
def emit_hit(other) = on_hit_signal.emit(other)
|
|
76
|
+
def emit_separated(other) = on_separated_signal.emit(other)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Scene-scoped broadphase collision system: a Component that lives on the scene
|
|
7
|
+
# node (so it is born and torn down with the scene, and rides the normal update
|
|
8
|
+
# traversal). 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.
|
|
33
|
+
class CollisionWorld < Engine::Component
|
|
34
|
+
def initialize(cell_size:)
|
|
35
|
+
super()
|
|
36
|
+
@hash = Engine::SpatialHash.new(cell_size: cell_size)
|
|
37
|
+
@colliders = []
|
|
38
|
+
end
|
|
39
|
+
|
|
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.
|
|
50
|
+
def unregister(collider) = @colliders.delete(collider)
|
|
51
|
+
|
|
52
|
+
# Yield every registered collider whose centre lies within `r` of (x, y), using
|
|
53
|
+
# the spatial index built by the most recent #update. The narrowphase is a
|
|
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
|
|
56
|
+
# removal are skipped. As with SpatialHash#query a collider spanning several cells
|
|
57
|
+
# may be yielded more than once, so callers that *select* (e.g. #nearest) are
|
|
58
|
+
# written dup-insensitively. Layer-agnostic — filter by `collider.layer` in the
|
|
59
|
+
# block. Allocation-free.
|
|
60
|
+
def query_circle(x, y, r)
|
|
61
|
+
r2 = r * r
|
|
62
|
+
@hash.query_circle(x, y, r) do |collider|
|
|
63
|
+
next if collider.node.freed?
|
|
64
|
+
|
|
65
|
+
dx = collider.cx - x
|
|
66
|
+
dy = collider.cy - y
|
|
67
|
+
yield collider if (dx * dx) + (dy * dy) <= r2
|
|
68
|
+
end
|
|
69
|
+
end
|
|
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
|
+
|
|
110
|
+
# The registered collider nearest to (x, y) within range `r`, or nil when none
|
|
111
|
+
# qualifies. Restrict to a single `layer:` (the common case: a turret targeting only
|
|
112
|
+
# :enemy). Dup-safe — it keeps the running minimum, so #query_circle's possible
|
|
113
|
+
# multi-cell repeats don't matter. Allocation-free.
|
|
114
|
+
def nearest(x, y, r, layer: nil)
|
|
115
|
+
best = nil
|
|
116
|
+
best_d2 = nil
|
|
117
|
+
query_circle(x, y, r) do |collider|
|
|
118
|
+
next if layer && collider.layer != layer
|
|
119
|
+
|
|
120
|
+
dx = collider.cx - x
|
|
121
|
+
dy = collider.cy - y
|
|
122
|
+
d2 = (dx * dx) + (dy * dy)
|
|
123
|
+
next unless best_d2.nil? || d2 < best_d2
|
|
124
|
+
|
|
125
|
+
best = collider
|
|
126
|
+
best_d2 = d2
|
|
127
|
+
end
|
|
128
|
+
best
|
|
129
|
+
end
|
|
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.
|
|
171
|
+
def update(_dt)
|
|
172
|
+
@hash.clear
|
|
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
|
|
183
|
+
|
|
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
|
|
189
|
+
count = @colliders.size
|
|
190
|
+
i = 0
|
|
191
|
+
while i < count
|
|
192
|
+
a = @colliders[i]
|
|
193
|
+
i += 1
|
|
194
|
+
next if a.node.freed?
|
|
195
|
+
|
|
196
|
+
pair_up(a)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
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)
|
|
206
|
+
|
|
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
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
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.
|
|
18
|
+
class DespawnOffscreen < Engine::Component
|
|
19
|
+
def initialize(width: nil, height: nil, margin: 0.0)
|
|
20
|
+
super()
|
|
21
|
+
@given_width = width
|
|
22
|
+
@given_height = height
|
|
23
|
+
@margin = margin
|
|
24
|
+
end
|
|
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
|
+
|
|
33
|
+
def update(_dt)
|
|
34
|
+
x = node.world_x
|
|
35
|
+
y = node.world_y
|
|
36
|
+
offscreen = x < -@margin || x > @width + @margin || y < -@margin || y > @height + @margin
|
|
37
|
+
node.queue_free if offscreen
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
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
|