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,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Wraps the node's position toroidally within the world bounds (plus margin), so
|
|
7
|
+
# an entity leaving one edge reappears on the opposite one.
|
|
8
|
+
#
|
|
9
|
+
# The bounds come from the scene's world system — anything answering the
|
|
10
|
+
# WorldBounds contract, so Components::World or Components::TileWorld — and are
|
|
11
|
+
# resolved when the node enters the tree. Passing `width:`/`height:` overrides
|
|
12
|
+
# that for a node whose wrap region is not the whole world.
|
|
13
|
+
#
|
|
14
|
+
# The node is tested and placed in **world space**, which is the frame WorldBounds
|
|
15
|
+
# is stated in: a node under an offset container wraps at the world's edge, not at
|
|
16
|
+
# an edge shifted by wherever the container sits. The write goes through
|
|
17
|
+
# Node2D#world_x=, so the node's local position is what actually changes.
|
|
18
|
+
#
|
|
19
|
+
# A wrap is a placement, not a step: it does not ask a sibling Mover's `blocked_by`
|
|
20
|
+
# whether the far edge is free, so a node wrapped onto a blocker is left pressed
|
|
21
|
+
# against it.
|
|
22
|
+
class ScreenWrap < Engine::Component
|
|
23
|
+
def initialize(width: nil, height: nil, margin: 0.0)
|
|
24
|
+
super()
|
|
25
|
+
@given_width = width
|
|
26
|
+
@given_height = height
|
|
27
|
+
@margin = margin
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Re-resolved on every entry rather than cached from the first, so a pooled
|
|
31
|
+
# entity recycled into a differently sized scene wraps against that scene.
|
|
32
|
+
def on_attach
|
|
33
|
+
WorldBounds.one_response!(node)
|
|
34
|
+
@width, @height = WorldBounds.resolve(node, @given_width, @given_height)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update(_dt)
|
|
38
|
+
x = node.world_x
|
|
39
|
+
node.world_x = @width + @margin if x < -@margin
|
|
40
|
+
node.world_x = -@margin if x > @width + @margin
|
|
41
|
+
|
|
42
|
+
y = node.world_y
|
|
43
|
+
node.world_y = @height + @margin if y < -@margin
|
|
44
|
+
node.world_y = -@margin if y > @height + @margin
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Draws a single registered image centred on the node's own origin. It passes
|
|
7
|
+
# NO angle and NO position to the renderer: Node2D#draw has already pushed the
|
|
8
|
+
# node's transform, so drawing at (0, 0) *is* drawing at the node, correctly
|
|
9
|
+
# rotated. Passing either would apply it a second time.
|
|
10
|
+
#
|
|
11
|
+
# `scale` is writable so a pooled entity (e.g. a multi-tier rock) can retune it on
|
|
12
|
+
# reset. `z` is the render layer (NOT the node's transform z / abs_z) — kept under
|
|
13
|
+
# @layer to say so. For an animated sprite (sheet + frame index driving
|
|
14
|
+
# renderer.sprite), add a sibling AnimatedSprite component later.
|
|
15
|
+
class Sprite < Engine::Component
|
|
16
|
+
include Engine::Culling
|
|
17
|
+
|
|
18
|
+
attr_accessor :scale
|
|
19
|
+
|
|
20
|
+
def initialize(id:, scale: 1.0, z: 0)
|
|
21
|
+
super()
|
|
22
|
+
@id = id
|
|
23
|
+
@scale = scale
|
|
24
|
+
@layer = z
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Centred on the node's origin and scaled, so the footprint to cull
|
|
28
|
+
# against is the node's box scaled and offset back by half of itself. A
|
|
29
|
+
# node that never set a size is never culled — see Culling.
|
|
30
|
+
#
|
|
31
|
+
# Culling is the one thing here still stated in **world** coordinates:
|
|
32
|
+
# it compares against the camera, which is nowhere near this node's
|
|
33
|
+
# local space. Drawing is local, culling is world, and the two are
|
|
34
|
+
# deliberately different arguments.
|
|
35
|
+
def draw(renderer, view)
|
|
36
|
+
width = node.width * @scale
|
|
37
|
+
height = node.height * @scale
|
|
38
|
+
lift = node.elevation
|
|
39
|
+
return if culled?(view, node.world_x - (width / 2.0), node.world_y - lift - (height / 2.0),
|
|
40
|
+
width, height)
|
|
41
|
+
|
|
42
|
+
renderer.image(@id, 0, -lift, scale: @scale, z: @layer)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Picks a node for the owning node to aim at: each update it queries the scene's
|
|
7
|
+
# CollisionWorld around the node's world origin and exposes the chosen target via
|
|
8
|
+
# #target (the candidate's node, or nil). The owner reads it to act — Targeting only
|
|
9
|
+
# selects, it never moves or shoots.
|
|
10
|
+
#
|
|
11
|
+
# The CollisionWorld it queries is the same broadphase every collider registers with,
|
|
12
|
+
# so targeting needs no candidate list of its own. The `layer` restricts candidates
|
|
13
|
+
# (pass :enemy to pick only colliders on that layer, not allies or projectiles).
|
|
14
|
+
#
|
|
15
|
+
# Policies (how to choose among the candidates in range):
|
|
16
|
+
# - :nearest — the closest candidate (the default; one broadphase nearest-lookup).
|
|
17
|
+
# More policies (e.g. furthest-along-path) arrive with the state they need.
|
|
18
|
+
class Targeting < Engine::Component
|
|
19
|
+
POLICIES = %i[nearest].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(range:, policy: :nearest, layer: nil)
|
|
22
|
+
super()
|
|
23
|
+
raise ArgumentError, "unknown targeting policy #{policy.inspect}" unless POLICIES.include?(policy)
|
|
24
|
+
|
|
25
|
+
@range = range
|
|
26
|
+
@policy = policy
|
|
27
|
+
@layer = layer
|
|
28
|
+
@target = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The chosen node, or nil when nothing is in range. Refreshed every update.
|
|
32
|
+
attr_reader :target
|
|
33
|
+
|
|
34
|
+
# Pull the broadphase once it's reachable (scene scope, resolved up the tree).
|
|
35
|
+
def on_attach = @world = node.system(CollisionWorld)
|
|
36
|
+
|
|
37
|
+
def update(_dt)
|
|
38
|
+
collider = pick
|
|
39
|
+
@target = collider&.node
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def pick
|
|
45
|
+
case @policy
|
|
46
|
+
when :nearest then @world.nearest(node.world_x, node.world_y, @range, layer: @layer)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Inertial "ship" flight on top of a Velocity sibling: a turn axis rotates the
|
|
7
|
+
# node (angular velocity) and a thrust axis accelerates it along its heading,
|
|
8
|
+
# with optional drag and a top-speed clamp. Reads intent in `control` and
|
|
9
|
+
# integrates it in `update`, so it composes with the normal phase order.
|
|
10
|
+
#
|
|
11
|
+
# Heading convention: angle 0 points along +x ("right"), so forward is
|
|
12
|
+
# (cos θ, sin θ) — consistent with the renderer's clockwise rotation under a y-down screen
|
|
13
|
+
# (e.g. +90° faces down). Firing is intentionally NOT here — see ActionTrigger +
|
|
14
|
+
# the owning node, which knows its muzzle geometry.
|
|
15
|
+
class ThrustController < Engine::Component
|
|
16
|
+
def initialize(turn_speed:, accel:, max_speed:, drag: 0.0,
|
|
17
|
+
turn_action: :turn, thrust_action: :thrust)
|
|
18
|
+
super()
|
|
19
|
+
@turn_speed = turn_speed
|
|
20
|
+
@accel = accel
|
|
21
|
+
@max_speed = max_speed
|
|
22
|
+
@drag = drag
|
|
23
|
+
@turn_action = turn_action
|
|
24
|
+
@thrust_action = thrust_action
|
|
25
|
+
@thrust = 0.0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The Velocity sibling is only guaranteed present once attached to a node.
|
|
29
|
+
def on_attach = @velocity = require_sibling(Velocity)
|
|
30
|
+
|
|
31
|
+
def control(actions)
|
|
32
|
+
@velocity.spin = actions.axis(@turn_action) * @turn_speed
|
|
33
|
+
@thrust = actions.axis(@thrust_action)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update(dt)
|
|
37
|
+
if @thrust != 0.0
|
|
38
|
+
@velocity.vx += Math.cos(node.angle) * @accel * @thrust * dt
|
|
39
|
+
@velocity.vy += Math.sin(node.angle) * @accel * @thrust * dt
|
|
40
|
+
end
|
|
41
|
+
apply_drag(dt) if @drag.positive?
|
|
42
|
+
clamp_speed
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def apply_drag(dt)
|
|
48
|
+
factor = 1.0 - (@drag * dt)
|
|
49
|
+
factor = 0.0 if factor.negative?
|
|
50
|
+
@velocity.vx *= factor
|
|
51
|
+
@velocity.vy *= factor
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def clamp_speed
|
|
55
|
+
speed = Math.hypot(@velocity.vx, @velocity.vy)
|
|
56
|
+
return unless speed > @max_speed
|
|
57
|
+
|
|
58
|
+
scale = @max_speed / speed
|
|
59
|
+
@velocity.vx *= scale
|
|
60
|
+
@velocity.vy *= scale
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# The scene-scoped tile world: a system component (it lives on the scene node and
|
|
7
|
+
# is found with node.system(TileWorld)) that owns the parsed Engine::TileMap and
|
|
8
|
+
# everything an actor needs from it — collision against the solid tiles, the world
|
|
9
|
+
# bounds, and drawing the map through the scene's camera.
|
|
10
|
+
#
|
|
11
|
+
# **It does not resolve a step.** What it owns is the map's solid tiles as a blocker
|
|
12
|
+
# source (#blockers, an Engine::TileBlockers); the actor that wants to be stopped by
|
|
13
|
+
# them borrows it and resolves against it — see Components::Mover, which
|
|
14
|
+
# builds its own Engine::CollisionSystem out of the sources its `blocked_by` names.
|
|
15
|
+
# A mover may be blocked by tiles, by other actors, or by both, and only the mover
|
|
16
|
+
# knows which, so the resolver is the mover's and the grid is this system's. The tile
|
|
17
|
+
# solidity itself is whatever the map's tileset reports (baked per-tile in Tiled).
|
|
18
|
+
# The same solidity, viewed as a graph for planning routes, is #nav_grid.
|
|
19
|
+
#
|
|
20
|
+
# **Solidity is read from the map once**, into one Util::SolidGrid, the first time
|
|
21
|
+
# anything asks — and #blockers, #nav_grid and #solid? all read that store, never the
|
|
22
|
+
# map. So they cannot disagree about a cell, and a resolve costs a byte lookup rather
|
|
23
|
+
# than a walk through the map's layers and tileset.
|
|
24
|
+
#
|
|
25
|
+
# **It does not draw.** Drawing the map is RGame::Engine::TileMapLayer, one
|
|
26
|
+
# node per Tiled layer, mounted inside the WorldView so the map is drawn
|
|
27
|
+
# once per viewport like the rest of the world. This stays a system — the
|
|
28
|
+
# thing actors ask about collision and bounds — and a system that also
|
|
29
|
+
# drew was always the odd part of it.
|
|
30
|
+
#
|
|
31
|
+
# It owns the map's **animation clock**. Nothing below reads a wall clock —
|
|
32
|
+
# see CLAUDE.md, "`draw` renders state; time enters through `update`" — so the
|
|
33
|
+
# elapsed seconds animated tiles run on are accumulated here and handed down at
|
|
34
|
+
# draw time. Stop calling `update` and the water freezes, which is what pausing
|
|
35
|
+
# should look like.
|
|
36
|
+
class TileWorld < Engine::Component
|
|
37
|
+
include WorldBounds
|
|
38
|
+
|
|
39
|
+
attr_reader :tilemap_id, :elapsed
|
|
40
|
+
|
|
41
|
+
# `cameras` are the cameras this map bounds — every player's, normally.
|
|
42
|
+
# A camera may not show past the world's edges, and this is what knows
|
|
43
|
+
# how big the world is; the cameras themselves are owned by players.
|
|
44
|
+
def initialize(map:, tilemap_id:, cameras: [])
|
|
45
|
+
super()
|
|
46
|
+
@map = map
|
|
47
|
+
@tilemap_id = tilemap_id
|
|
48
|
+
@elapsed = 0.0
|
|
49
|
+
Array(cameras).each { |camera| bound(camera) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The map's solid tiles as a blocker source, for a body that declared
|
|
53
|
+
# `blocked_by: [:tiles]`. The same source every time, so every body on the map
|
|
54
|
+
# shares one.
|
|
55
|
+
def blockers
|
|
56
|
+
@blockers ||= Engine::TileBlockers.new(grid: solid_grid, tile_width: @map.tile_width,
|
|
57
|
+
tile_height: @map.tile_height)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def world_width = @map.pixel_width
|
|
61
|
+
def world_height = @map.pixel_height
|
|
62
|
+
|
|
63
|
+
# The size of one cell, in pixels — what turns a world position into the cell
|
|
64
|
+
# #nav_grid plans over.
|
|
65
|
+
def tile_width = @map.tile_width
|
|
66
|
+
def tile_height = @map.tile_height
|
|
67
|
+
|
|
68
|
+
def layer_count = @map.layer_count
|
|
69
|
+
|
|
70
|
+
# The first layer Tiled flags `above`, or the layer count if none is —
|
|
71
|
+
# which is where TileMapLayer.mount leaves the gap for the actors, so a
|
|
72
|
+
# map with no flag puts them over everything. Read once at mount rather
|
|
73
|
+
# than per frame: which layers cover the actors is a fact about the
|
|
74
|
+
# scene's arrangement, and the arrangement is made once.
|
|
75
|
+
def first_above_layer
|
|
76
|
+
layer_count.times.find { |index| @map.above_layer?(index) } || layer_count
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Clamp a camera to this map's edges. Called for each camera the scene
|
|
80
|
+
# hands over, and again for one that arrives later (a player joining).
|
|
81
|
+
def bound(camera)
|
|
82
|
+
camera.world_width = @map.pixel_width
|
|
83
|
+
camera.world_height = @map.pixel_height
|
|
84
|
+
camera
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def solid?(col, row) = solid_grid.solid?(col, row)
|
|
88
|
+
|
|
89
|
+
# The map's solid tiles as an Engine::NavGrid, for planning a route rather than
|
|
90
|
+
# resolving a step — over the same store #blockers reads, as a second view. Built on
|
|
91
|
+
# first ask, and the same grid every time after.
|
|
92
|
+
def nav_grid
|
|
93
|
+
@nav_grid ||= Engine::NavGrid.new(grid: solid_grid)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Advances the tile animations. Seconds, like every other duration here.
|
|
97
|
+
def update(dt)
|
|
98
|
+
@elapsed += dt
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def solid_grid
|
|
104
|
+
@solid_grid ||= Util::SolidGrid.build(@map.width, @map.height) { |col, row| @map.solid_tile?(col, row) }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# A node-driven interval timer: it rides the node's update tick — so nothing can
|
|
7
|
+
# forget to advance it — and emits `on_timeout` each time a whole interval elapses.
|
|
8
|
+
# It wraps the pure Engine::Timer, reusing its carry-forward `consume` (the remainder
|
|
9
|
+
# rolls over, so the cadence doesn't drift); this only adds the per-frame drive and
|
|
10
|
+
# the signal.
|
|
11
|
+
#
|
|
12
|
+
# timer = node.add_component(Engine::Components::Timer.new(0.8), as: :spawn)
|
|
13
|
+
# timer.on_timeout { spawn_enemy }
|
|
14
|
+
#
|
|
15
|
+
# `repeating: false` makes it a one-shot: it fires `on_timeout` exactly once and then
|
|
16
|
+
# goes inert — the fixed-board despawn case (a projectile that vanishes after N
|
|
17
|
+
# seconds, where DespawnOffscreen doesn't apply):
|
|
18
|
+
#
|
|
19
|
+
# life = node.add_component(Engine::Components::Timer.new(2.0, repeating: false), as: :despawn)
|
|
20
|
+
# life.on_timeout { node.queue_free }
|
|
21
|
+
#
|
|
22
|
+
# The countdown restarts in `on_attach`, so a pooled node reacquired and re-added
|
|
23
|
+
# starts fresh — a recycled projectile gets its full interval (and a one-shot can fire
|
|
24
|
+
# again) rather than inheriting the previous life's elapsed time.
|
|
25
|
+
#
|
|
26
|
+
# When a node needs more than one (a spawn cadence and a wave cadence), give them
|
|
27
|
+
# distinct `as:` names — a node holds one component per slot.
|
|
28
|
+
class Timer < Engine::Component
|
|
29
|
+
signal :on_timeout
|
|
30
|
+
|
|
31
|
+
def initialize(interval, repeating: true)
|
|
32
|
+
super()
|
|
33
|
+
@timer = Engine::Timer.new(interval)
|
|
34
|
+
@repeating = repeating
|
|
35
|
+
@done = false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def interval = @timer.interval
|
|
39
|
+
|
|
40
|
+
def interval=(seconds)
|
|
41
|
+
@timer.interval = seconds
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Start the countdown fresh whenever the node (re-)enters the tree, so a pooled node
|
|
45
|
+
# never inherits a previous life's accumulated time or spent one-shot.
|
|
46
|
+
def on_attach = reset
|
|
47
|
+
|
|
48
|
+
# Advance one step and fire on_timeout once per whole interval that elapsed — so a
|
|
49
|
+
# single long step still emits the right number of times (catch-up, not drift). A
|
|
50
|
+
# one-shot (`repeating: false`) fires once and then stops accumulating.
|
|
51
|
+
def update(dt)
|
|
52
|
+
return if @done
|
|
53
|
+
|
|
54
|
+
@timer.update(dt)
|
|
55
|
+
while @timer.ready?
|
|
56
|
+
@timer.consume
|
|
57
|
+
on_timeout_signal.emit
|
|
58
|
+
unless @repeating
|
|
59
|
+
@done = true
|
|
60
|
+
break
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Back to a fresh timer: drop accumulated time and re-arm a spent one-shot (e.g.
|
|
66
|
+
# after retuning the interval, or when a pooled node is reused). Returns self.
|
|
67
|
+
def reset
|
|
68
|
+
@timer.reset
|
|
69
|
+
@done = false
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Integrates linear and angular velocity into the node's transform each step.
|
|
7
|
+
# Free-moving entities (rocks, bullets) use it alone; a controller component
|
|
8
|
+
# (or the node's own control hook) writes vx/vy/spin as intent.
|
|
9
|
+
#
|
|
10
|
+
# A Mover, so `blocked_by:` stops it the way it stops a CharacterBody — see Mover's
|
|
11
|
+
# header. A blocked step keeps `vx`/`vy` as they were: they are the intent, and what
|
|
12
|
+
# a stop should do to them (nothing, zero them, bounce) is the game's call, made in
|
|
13
|
+
# an `on_blocked` handler.
|
|
14
|
+
class Velocity < Mover
|
|
15
|
+
attr_accessor :vx, :vy, :spin
|
|
16
|
+
|
|
17
|
+
def initialize(vx: 0.0, vy: 0.0, spin: 0.0, blocked_by: [])
|
|
18
|
+
super(blocked_by: blocked_by)
|
|
19
|
+
@vx = vx
|
|
20
|
+
@vy = vy
|
|
21
|
+
@spin = spin
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The velocity scaled so its larger axis is ±1 — the direction, without the square
|
|
25
|
+
# root a unit vector would cost.
|
|
26
|
+
def heading_x = scale_to_heading(@vx)
|
|
27
|
+
def heading_y = scale_to_heading(@vy)
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def scale_to_heading(axis)
|
|
32
|
+
larger = [@vx.abs, @vy.abs].max
|
|
33
|
+
larger.zero? ? 0.0 : axis / larger.to_f
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def take_step(dt)
|
|
37
|
+
apply_move(@vx * dt, @vy * dt)
|
|
38
|
+
node.angle += @spin * dt
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# A simple AI driver for a CharacterBody sibling: every so often it rolls a new
|
|
7
|
+
# direction (one of eight, or idle) and holds it until a timer elapses — or until a
|
|
8
|
+
# wall blocks it, at which point it re-rolls early instead of pushing into the wall.
|
|
9
|
+
# The RNG is injected so behaviour is deterministic in tests.
|
|
10
|
+
#
|
|
11
|
+
# "Blocked" is measured as *the node did not move while intending to*, not asked of
|
|
12
|
+
# a collision world — so this works over any CharacterBody, and simply never fires
|
|
13
|
+
# for one whose steps always land.
|
|
14
|
+
class WanderController < Engine::Component
|
|
15
|
+
MOVED_EPS = 1e-6
|
|
16
|
+
DIRECTIONS = [
|
|
17
|
+
[-1, 0], [1, 0], [0, -1], [0, 1],
|
|
18
|
+
[-1, -1], [1, -1], [-1, 1], [1, 1]
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(rng: Random.new, change_interval: 1.0..3.0, idle_chance: 0.25)
|
|
22
|
+
super()
|
|
23
|
+
@rng = rng
|
|
24
|
+
@change_interval = change_interval
|
|
25
|
+
@idle_chance = idle_chance
|
|
26
|
+
@timer = 0.0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def on_attach
|
|
30
|
+
@body = require_sibling(CharacterBody)
|
|
31
|
+
@last_x = node.x
|
|
32
|
+
@last_y = node.y
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def update(dt)
|
|
36
|
+
blocked = intending_to_move? && !moved_since_last?
|
|
37
|
+
@last_x = node.x
|
|
38
|
+
@last_y = node.y
|
|
39
|
+
|
|
40
|
+
@timer -= dt
|
|
41
|
+
reroll if blocked || @timer <= 0.0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def intending_to_move? = !@body.move_x.zero? || !@body.move_y.zero?
|
|
47
|
+
|
|
48
|
+
def moved_since_last?
|
|
49
|
+
(node.x - @last_x).abs > MOVED_EPS || (node.y - @last_y).abs > MOVED_EPS
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reroll
|
|
53
|
+
if @rng.rand < @idle_chance
|
|
54
|
+
@body.set_intent(0.0, 0.0)
|
|
55
|
+
else
|
|
56
|
+
dx, dy = DIRECTIONS.sample(random: @rng)
|
|
57
|
+
@body.set_intent(dx.to_f, dy.to_f)
|
|
58
|
+
end
|
|
59
|
+
@timer = @rng.rand(@change_interval)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# How big the world is — the contract, not an implementation.
|
|
7
|
+
#
|
|
8
|
+
# Two systems answer it: `World` below, which is nothing *but* the answer,
|
|
9
|
+
# and `TileWorld`, which derives it from the map it parsed. A component
|
|
10
|
+
# that needs bounds asks for the contract — `node.system(WorldBounds)` —
|
|
11
|
+
# and gets whichever of the two the scene mounted. That works because
|
|
12
|
+
# Node2D#get_component matches with `is_a?`, which matches an included
|
|
13
|
+
# module as readily as a class.
|
|
14
|
+
#
|
|
15
|
+
# Naming the contract is what keeps the two from drifting: ScreenWrap does
|
|
16
|
+
# not know which kind of world it is wrapping inside, and must not need to.
|
|
17
|
+
#
|
|
18
|
+
# ## The bounds are in world coordinates
|
|
19
|
+
#
|
|
20
|
+
# The world runs from (0, 0) to (world_width, world_height) in **world**
|
|
21
|
+
# space — the space `Node2D#world_x` answers in, and the one the tile grid,
|
|
22
|
+
# the collision world and the camera already use. So everything that
|
|
23
|
+
# compares a node against these bounds compares `world_x`/`world_y`, never
|
|
24
|
+
# the node's local `x`/`y`: an entity grouped under an offset container is
|
|
25
|
+
# still inside the world when it is, whatever its position in the container.
|
|
26
|
+
# spec/support/shared_examples/a_world_edge_response.rb holds every reader to it.
|
|
27
|
+
module WorldBounds
|
|
28
|
+
def world_width = raise(NotImplementedError, "#{self.class} must define #world_width")
|
|
29
|
+
def world_height = raise(NotImplementedError, "#{self.class} must define #world_height")
|
|
30
|
+
|
|
31
|
+
# The bounds a component should use: whatever it was handed, falling back
|
|
32
|
+
# to the world system on `node` for either axis left nil. The single place
|
|
33
|
+
# that fallback is written, so the components sharing it cannot drift.
|
|
34
|
+
#
|
|
35
|
+
# Call it from `on_attach`, not `initialize` — a node has no scene to ask
|
|
36
|
+
# until it is in the tree, and a pooled entity is built long before it is.
|
|
37
|
+
def self.resolve(node, width, height)
|
|
38
|
+
return [width, height] if width && height
|
|
39
|
+
|
|
40
|
+
bounds = node.system(self)
|
|
41
|
+
if bounds.nil?
|
|
42
|
+
raise 'no world bounds in scope: mount a World (or TileWorld) system on the scene, ' \
|
|
43
|
+
'or pass explicit width:/height:'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
[width || bounds.world_width, height || bounds.world_height]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Refuse a node carrying more than one response to the edge of the world:
|
|
50
|
+
# ScreenWrap, DespawnOffscreen, or a Mover declaring `blocked_by: [:bounds]`.
|
|
51
|
+
#
|
|
52
|
+
# Stopping at the edge, wrapping past it and being freed beyond it are three
|
|
53
|
+
# answers to one question, and no two of them mean anything together. Stop and
|
|
54
|
+
# wrap disagree about where the node ends up; wrap and despawn race on which
|
|
55
|
+
# margin is reached first; and the stop works on the collision box while the
|
|
56
|
+
# other two test the node's origin, so a box with an offset holds the origin
|
|
57
|
+
# just past the edge and the other response fires on a node that was held.
|
|
58
|
+
# Rather than a rule each game has to remember, it raises.
|
|
59
|
+
#
|
|
60
|
+
# Folding wrap and despawn into Mover as one `at_edge:` option would make two
|
|
61
|
+
# responses inexpressible too, but they act on the node however it moved — a reset,
|
|
62
|
+
# an ancestor, a direct write — and every option there is a branch on the free
|
|
63
|
+
# step's path. A refusal at attach costs no frame anything. It is also the place
|
|
64
|
+
# to change if a game wants wrapping on one axis and stopping on the other: the
|
|
65
|
+
# rule then becomes one response per axis.
|
|
66
|
+
#
|
|
67
|
+
# Every response calls this from its own on_attach, which is what makes it
|
|
68
|
+
# order-free: a node assembled outside the tree already holds all its
|
|
69
|
+
# components when the first attaches, and on a live node each attaches on
|
|
70
|
+
# arrival, so the second response always finds the first.
|
|
71
|
+
#
|
|
72
|
+
# A pooled node re-attaches on every spawn, so this walks the components
|
|
73
|
+
# without allocating, and builds a message only when it raises.
|
|
74
|
+
def self.one_response!(node)
|
|
75
|
+
first = nil
|
|
76
|
+
node.components.each do |component|
|
|
77
|
+
next unless edge_response?(component)
|
|
78
|
+
next first = component if first.nil?
|
|
79
|
+
|
|
80
|
+
raise "#{node.class} has two responses to the edge of the world: " \
|
|
81
|
+
"#{describe_response(first)} and #{describe_response(component)}. " \
|
|
82
|
+
'A node can stop at the edge, wrap past it or be freed beyond it, but ' \
|
|
83
|
+
'only one of those — keep the one this node is for.'
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.edge_response?(component)
|
|
88
|
+
component.is_a?(ScreenWrap) || component.is_a?(DespawnOffscreen) ||
|
|
89
|
+
(component.is_a?(Mover) && component.blocked_by?(Mover::BOUNDS))
|
|
90
|
+
end
|
|
91
|
+
private_class_method :edge_response?
|
|
92
|
+
|
|
93
|
+
def self.describe_response(component)
|
|
94
|
+
name = component.class.name&.split('::')&.last || component.class.inspect
|
|
95
|
+
component.is_a?(Mover) ? "#{name} (blocked_by :bounds)" : name
|
|
96
|
+
end
|
|
97
|
+
private_class_method :describe_response
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The scene-scoped world: how big it is, and nothing else yet.
|
|
101
|
+
#
|
|
102
|
+
# A game whose world is a plain rectangle mounts one on its scene, and the
|
|
103
|
+
# components that need bounds — ScreenWrap, DespawnOffscreen — find it
|
|
104
|
+
# rather than having the numbers threaded through every constructor between
|
|
105
|
+
# the scene and the entity. That threading is what this replaces, and it was
|
|
106
|
+
# worse than it looks: a pooled entity is built outside the tree, so its
|
|
107
|
+
# factory had to close over the size, and every scene above the factory had
|
|
108
|
+
# to carry the size in order to build it.
|
|
109
|
+
#
|
|
110
|
+
# It is deliberately **not** the window size. The two coincide in a
|
|
111
|
+
# single-screen game, which is exactly what makes the mistake easy to make
|
|
112
|
+
# and hard to see: bind wrapping to the viewport and the world silently
|
|
113
|
+
# changes shape when the window is resized, or when the screen is split and
|
|
114
|
+
# each half is its own viewport. Ask `Viewports` (or the `View` a draw is
|
|
115
|
+
# handed) how big the *window* is; ask this how big the *world* is.
|
|
116
|
+
#
|
|
117
|
+
# The bounds are immutable, which is what lets the components resolve them
|
|
118
|
+
# once at attach rather than re-reading them every frame. A world that
|
|
119
|
+
# genuinely changes size is a new scene.
|
|
120
|
+
class World < Engine::Component
|
|
121
|
+
include WorldBounds
|
|
122
|
+
|
|
123
|
+
attr_reader :world_width, :world_height
|
|
124
|
+
|
|
125
|
+
def initialize(width:, height:)
|
|
126
|
+
super()
|
|
127
|
+
@world_width = width
|
|
128
|
+
@world_height = height
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|