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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# One player's own corner of the screen.
|
|
6
|
+
#
|
|
7
|
+
# layer = scene.add_node(PlayerLayer.new(player: players[1]))
|
|
8
|
+
# layer.add_node(inventory)
|
|
9
|
+
#
|
|
10
|
+
# Its subtree is drawn **once**, clipped to that player's viewport and
|
|
11
|
+
# translated to its corner, in screen space. That is the third kind of
|
|
12
|
+
# content a frame holds: the world is drawn once per viewport under a
|
|
13
|
+
# camera, a global overlay once across the whole window, and this once per
|
|
14
|
+
# player inside their own region.
|
|
15
|
+
#
|
|
16
|
+
# It is also the `:hud` z band's structural counterpart: everything under
|
|
17
|
+
# here draws above everything in the world, whatever either asked for.
|
|
18
|
+
#
|
|
19
|
+
# ## Its children are positioned relative to it
|
|
20
|
+
#
|
|
21
|
+
# A node at (10, 10) under this layer is ten pixels inside *that player's*
|
|
22
|
+
# region, wherever the layout has put it. The layer's translate is what does
|
|
23
|
+
# that, so the same HUD class serves either player with nothing to configure
|
|
24
|
+
# — which is the point of it being a node rather than a rect a HUD looks up.
|
|
25
|
+
#
|
|
26
|
+
# For laying out against the far edge, use the view's **size**:
|
|
27
|
+
# `view.width - margin`. `view.x` and `view.y` are where the region sits on
|
|
28
|
+
# the window and are the clip's business, not a layout origin; adding them
|
|
29
|
+
# would offset a second time.
|
|
30
|
+
#
|
|
31
|
+
# ## It says whose input its subtree reads
|
|
32
|
+
#
|
|
33
|
+
# `input_owner` is set to that player, and ownership is inherited, so a menu
|
|
34
|
+
# anywhere under here reads their controller and nobody else's. Two players
|
|
35
|
+
# with a menu open at once are independent without either one knowing the
|
|
36
|
+
# other exists — see docs/api/scene_graph.md, "Who a node answers to".
|
|
37
|
+
#
|
|
38
|
+
# ## An empty region draws nothing
|
|
39
|
+
#
|
|
40
|
+
# `Viewports#screen_for` is nil for a seat nobody is in, and for everybody
|
|
41
|
+
# while the split is collapsed — a cutscene is everyone looking at one thing,
|
|
42
|
+
# so per-player UI has no place to be. Either way this draws nothing and
|
|
43
|
+
# needs no guard at the call site.
|
|
44
|
+
class PlayerLayer < Node2D
|
|
45
|
+
# `:hud` is this player's own screen space, above every world slot and
|
|
46
|
+
# below a global overlay. It is inherited, so a menu three nodes down is
|
|
47
|
+
# in it without saying so — which is the point of the band being
|
|
48
|
+
# structural rather than a number each widget carries.
|
|
49
|
+
def initialize(player:, **)
|
|
50
|
+
super(band: :hud, **)
|
|
51
|
+
self.input_owner = player
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Whose layer this is. The same thing as `input_owner`, and stored only
|
|
55
|
+
# there: two fields would be two things to keep in step.
|
|
56
|
+
def player = input_owner
|
|
57
|
+
|
|
58
|
+
def draw(renderer, _view = nil)
|
|
59
|
+
region = system(Viewports).screen_for(player)
|
|
60
|
+
return if region.nil?
|
|
61
|
+
|
|
62
|
+
renderer.clipped(region.x, region.y, region.width, region.height) do
|
|
63
|
+
renderer.translated(region.offset_x, region.offset_y) do
|
|
64
|
+
super(renderer, region)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# Who is playing, as a root-scoped system.
|
|
6
|
+
#
|
|
7
|
+
# players = node.system(Players)
|
|
8
|
+
# players.primary.camera
|
|
9
|
+
# players.each_active { |player| ... }
|
|
10
|
+
#
|
|
11
|
+
# A Component on the root node, so any node reaches it by walking the tree
|
|
12
|
+
# rather than having it threaded through a constructor — the same shape
|
|
13
|
+
# CollisionWorld and TileWorld use (see docs/api/systems.md).
|
|
14
|
+
#
|
|
15
|
+
# It owns the list, polls every player's mapper once per tick, and decides
|
|
16
|
+
# who a newly used controller belongs to. It does **not** own the screen
|
|
17
|
+
# rects — those come from the layout, because they depend on how many
|
|
18
|
+
# players are active and change without the players doing so.
|
|
19
|
+
#
|
|
20
|
+
# ## Seats, and how a device comes to occupy one
|
|
21
|
+
#
|
|
22
|
+
# Every seat exists from the start; the unfilled ones are inactive and draw
|
|
23
|
+
# no viewport. So the number of seats is also the maximum number of players,
|
|
24
|
+
# rather than a separate cap that could disagree with the list.
|
|
25
|
+
#
|
|
26
|
+
# **A device is seated when someone uses it, not when it is plugged in.** A
|
|
27
|
+
# connect says something about hardware; seating a player creates a camera, a
|
|
28
|
+
# viewport and a screen split, and that should follow a statement of intent.
|
|
29
|
+
# Seating on connect drops a pad a solo player plugs in (no seat is free),
|
|
30
|
+
# splits the screen when a spare pad wakes up, and cannot be refused during a
|
|
31
|
+
# cutscene.
|
|
32
|
+
#
|
|
33
|
+
# players.on_unassigned_input = :join # :join | :takeover | :ignore
|
|
34
|
+
# players.accepting_joins = false # temporarily refuse either
|
|
35
|
+
#
|
|
36
|
+
# - `:join` — a press on an unassigned device fills the next free seat.
|
|
37
|
+
# Couch co-op, and the default when a game asks for more than one seat.
|
|
38
|
+
# - `:takeover` — it becomes the *primary* player's device instead. Single
|
|
39
|
+
# player, where picking up a controller is not a second person arriving,
|
|
40
|
+
# and the default when there is one seat.
|
|
41
|
+
# - `:ignore` — the game seats devices itself, with #seat.
|
|
42
|
+
#
|
|
43
|
+
# The trigger is a **`ui_confirm` press**, read through the map of whoever
|
|
44
|
+
# would receive the device. One action rather than "any input", because a
|
|
45
|
+
# stick resting slightly off centre must never seat a player, and an edge
|
|
46
|
+
# rather than held so one press does one thing.
|
|
47
|
+
class Players < Component
|
|
48
|
+
extend Engine::Signal::DSL
|
|
49
|
+
|
|
50
|
+
Controls = RGame::Util::Controls
|
|
51
|
+
|
|
52
|
+
include Enumerable
|
|
53
|
+
|
|
54
|
+
# Fires when a device is seated, with the player who got it. A scene
|
|
55
|
+
# listens to spawn that player's avatar — which is how a game gains a
|
|
56
|
+
# second character mid-session without polling for one.
|
|
57
|
+
signal :on_joined, Engine::Signal.define(:player)
|
|
58
|
+
|
|
59
|
+
attr_reader :list
|
|
60
|
+
attr_accessor :on_unassigned_input, :accepting_joins
|
|
61
|
+
|
|
62
|
+
def initialize(players = [])
|
|
63
|
+
super()
|
|
64
|
+
@list = players
|
|
65
|
+
@on_unassigned_input = players.size > 1 ? :join : :takeover
|
|
66
|
+
@accepting_joins = true
|
|
67
|
+
@connected = []
|
|
68
|
+
@confirm_held = {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The player a single-player game means, and the one an unowned node reads
|
|
72
|
+
# from. Always present: a game with no players declared still has this one,
|
|
73
|
+
# which is what keeps single-player free of ceremony.
|
|
74
|
+
def primary = @list.first
|
|
75
|
+
|
|
76
|
+
def each(&) = @list.each(&)
|
|
77
|
+
|
|
78
|
+
# Players with a device driving them. An empty seat waiting for a
|
|
79
|
+
# controller is in `list` but not here, so a viewport loop skips it.
|
|
80
|
+
def each_active(&) = @list.select(&:active?).each(&)
|
|
81
|
+
|
|
82
|
+
def active_count = @list.count(&:active?)
|
|
83
|
+
|
|
84
|
+
def [](id) = @list.find { |player| player.id == id }
|
|
85
|
+
|
|
86
|
+
def add(player)
|
|
87
|
+
@list << player
|
|
88
|
+
player
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The input a node owned by `player` should read this tick.
|
|
92
|
+
#
|
|
93
|
+
# Nobody in particular means the primary player, which is what makes the
|
|
94
|
+
# single-player path free: no node claims ownership, every node resolves
|
|
95
|
+
# to nil, and every nil resolves to the one player there is.
|
|
96
|
+
#
|
|
97
|
+
# @api private
|
|
98
|
+
# hot-path
|
|
99
|
+
def actions_for(player)
|
|
100
|
+
owner = player || primary
|
|
101
|
+
raise 'no players are registered, so nothing can read input' if owner.nil?
|
|
102
|
+
|
|
103
|
+
owner.actions
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Every player's input for this tick, in one call. Each has their own
|
|
107
|
+
# mapper and their own previous-frame state, so one player's press cannot
|
|
108
|
+
# consume another's edge.
|
|
109
|
+
# Then the devices nobody holds are checked for someone starting to use
|
|
110
|
+
# one. Here rather than in a hot-plug hook because a *press* is a per-tick
|
|
111
|
+
# idea, and this is the one place that already has the backend and runs
|
|
112
|
+
# once a tick.
|
|
113
|
+
def poll(backend)
|
|
114
|
+
@list.each { |player| player.poll(backend) }
|
|
115
|
+
admit(backend)
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A controller arrived in a slot. Recorded, not seated: this is what makes
|
|
120
|
+
# the slot *scannable*, and someone using it is what seats it.
|
|
121
|
+
#
|
|
122
|
+
# @api private
|
|
123
|
+
def device_connected(slot)
|
|
124
|
+
@connected << slot unless @connected.include?(slot)
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A controller left its slot. Whoever was on it loses it; their camera,
|
|
129
|
+
# bindings and UI stay exactly as they were, so plugging back in and
|
|
130
|
+
# pressing confirm resumes rather than restarts.
|
|
131
|
+
#
|
|
132
|
+
# Under `:takeover` there is no second player to become, so the seat falls
|
|
133
|
+
# back to the keyboard rather than the game going dead in someone's hands.
|
|
134
|
+
#
|
|
135
|
+
# @api private
|
|
136
|
+
def device_disconnected(slot)
|
|
137
|
+
@connected.delete(slot)
|
|
138
|
+
device = Controls.gamepad(slot)
|
|
139
|
+
seated = @list.find { |player| player.device == device }
|
|
140
|
+
seated&.device = @on_unassigned_input == :takeover ? Controls::KEYBOARD : nil
|
|
141
|
+
seated
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Give `device` to whoever should have it, and say who that was. The join
|
|
145
|
+
# path's own last step, and the one call a game running `:ignore` uses to
|
|
146
|
+
# seat devices on its own terms.
|
|
147
|
+
#
|
|
148
|
+
# Refused while `accepting_joins` is false — which covers taking over as
|
|
149
|
+
# well as joining, since both change who is holding what.
|
|
150
|
+
def seat(device)
|
|
151
|
+
return nil unless @accepting_joins
|
|
152
|
+
|
|
153
|
+
player = candidate
|
|
154
|
+
return nil if player.nil?
|
|
155
|
+
|
|
156
|
+
player.device = device
|
|
157
|
+
on_joined_signal.emit(player)
|
|
158
|
+
player
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def admit(backend)
|
|
164
|
+
return if @on_unassigned_input == :ignore || candidate.nil?
|
|
165
|
+
|
|
166
|
+
each_unassigned_device do |device|
|
|
167
|
+
down = confirm_down?(backend, device)
|
|
168
|
+
was_down = @confirm_held[device]
|
|
169
|
+
@confirm_held[device] = down
|
|
170
|
+
seat(device) if down && !was_down
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def candidate
|
|
175
|
+
return primary if @on_unassigned_input == :takeover
|
|
176
|
+
|
|
177
|
+
@list.find { |player| !player.active? }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# hot-path
|
|
181
|
+
def confirm_down?(backend, device)
|
|
182
|
+
player = candidate
|
|
183
|
+
return false if player.nil?
|
|
184
|
+
|
|
185
|
+
buttons = player.input_map[:ui_confirm]&.buttons
|
|
186
|
+
return false if buttons.nil?
|
|
187
|
+
|
|
188
|
+
buttons.any? { |id| backend.down?(id, device: device) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def each_unassigned_device
|
|
192
|
+
@connected.each do |slot|
|
|
193
|
+
device = Controls.gamepad(slot)
|
|
194
|
+
yield device unless assigned?(device)
|
|
195
|
+
end
|
|
196
|
+
return unless @on_unassigned_input == :takeover && !assigned?(Controls::KEYBOARD)
|
|
197
|
+
|
|
198
|
+
yield Controls::KEYBOARD
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# hot-path
|
|
202
|
+
def assigned?(device) = @list.any? { |player| player.device == device }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A reuse-don't-allocate pool for many short-lived, homogeneous objects —
|
|
6
|
+
# bullets, particles, transient enemies. Acquired objects come from a free list
|
|
7
|
+
# (or the factory when the list is empty) so steady-state spawning allocates
|
|
8
|
+
# nothing. Pure logic; no graphics.
|
|
9
|
+
#
|
|
10
|
+
# pool = Engine::Pool.new { Bullet.new }
|
|
11
|
+
# b = pool.acquire # recycled or freshly built
|
|
12
|
+
# pool.each { |b| b.update(dt) }
|
|
13
|
+
# pool.reclaim_if(&:dead?) # sweep dead → free list, once per frame
|
|
14
|
+
#
|
|
15
|
+
# The factory builds a blank object; callers re-initialise it after `acquire`.
|
|
16
|
+
class Pool
|
|
17
|
+
def initialize(&factory)
|
|
18
|
+
@factory = factory
|
|
19
|
+
@free = []
|
|
20
|
+
@active = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :active
|
|
24
|
+
|
|
25
|
+
def acquire
|
|
26
|
+
obj = @free.empty? ? @factory.call : @free.pop
|
|
27
|
+
@active.push(obj)
|
|
28
|
+
obj
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def each(&) = @active.each(&)
|
|
32
|
+
|
|
33
|
+
def size = @active.size
|
|
34
|
+
|
|
35
|
+
# No live (acquired, not-yet-reclaimed) objects.
|
|
36
|
+
def empty? = @active.empty?
|
|
37
|
+
|
|
38
|
+
# Deferred removal: sweep the active list once, moving every object for which
|
|
39
|
+
# the block returns true onto the free list. Safe to call after iterating with
|
|
40
|
+
# `each` — never mutate the active list mid-iteration; mark objects dead during
|
|
41
|
+
# the frame and reclaim them here.
|
|
42
|
+
def reclaim_if
|
|
43
|
+
@active.reject! do |obj|
|
|
44
|
+
dead = yield(obj)
|
|
45
|
+
@free.push(obj) if dead
|
|
46
|
+
dead
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# How a game's own resolution is presented on a window that may be a
|
|
6
|
+
# different size. Pure arithmetic: a size, a mode, and the scale and offset
|
|
7
|
+
# they imply. No renderer, no window, nothing to draw with.
|
|
8
|
+
#
|
|
9
|
+
# presentation = Presentation.new(width: 640, height: 480, mode: :integer)
|
|
10
|
+
# presentation.fit(1920, 1080)
|
|
11
|
+
# presentation.scale_x # => 2.0
|
|
12
|
+
# presentation.offset_x # => 320 (1920 - 640*2, halved)
|
|
13
|
+
#
|
|
14
|
+
# ## The problem it exists for
|
|
15
|
+
#
|
|
16
|
+
# A game laid out against fixed numbers — a 500x500 board centred by writing
|
|
17
|
+
# `x: 150` in a 800x600 window — keeps those numbers when the window grows.
|
|
18
|
+
# It does not re-centre; it sits in the top-left corner with the new space
|
|
19
|
+
# piled up to its right. Fullscreen makes that happen to every game that has
|
|
20
|
+
# not thought about it, because fullscreen is the screen's resolution and
|
|
21
|
+
# never the one the game asked for.
|
|
22
|
+
#
|
|
23
|
+
# There are two honest answers, and this is the second one:
|
|
24
|
+
#
|
|
25
|
+
# - **Re-layout.** Read the size you are drawing into and place things
|
|
26
|
+
# relative to its edges. Right for a HUD or a menu, where extra space is
|
|
27
|
+
# extra room. `examples/fullscreen` does this.
|
|
28
|
+
# - **Scale.** Keep the design size, and map it onto whatever the window is.
|
|
29
|
+
# Right for a play area, where extra space should mean a bigger picture of
|
|
30
|
+
# the same thing rather than more world. This class.
|
|
31
|
+
#
|
|
32
|
+
# A game usually wants both at once, which works: the scale applies to
|
|
33
|
+
# everything, and nodes still read their view's size — that size is just the
|
|
34
|
+
# *logical* one now, so it stops changing.
|
|
35
|
+
#
|
|
36
|
+
# ## The modes
|
|
37
|
+
#
|
|
38
|
+
# | | |
|
|
39
|
+
# |---|---|
|
|
40
|
+
# | `:disabled` | No scaling. The logical size *is* the window size, so it changes as the window does. |
|
|
41
|
+
# | `:stretch` | Fill the window exactly, distorting if the aspect ratios differ. |
|
|
42
|
+
# | `:letterbox` | Largest scale that fits, equal on both axes, centred. Bars on two sides. |
|
|
43
|
+
# | `:integer` | The same, rounded down to a whole number. |
|
|
44
|
+
#
|
|
45
|
+
# `:integer` is the one pixel art wants. A sprite drawn at 1.875x with nearest
|
|
46
|
+
# filtering has some source pixels covering two screen pixels and some
|
|
47
|
+
# covering one, and the unevenness crawls as anything moves. A whole-number
|
|
48
|
+
# factor makes every source pixel exactly the same square, which is what
|
|
49
|
+
# "pixel perfect" means.
|
|
50
|
+
#
|
|
51
|
+
# **It costs screen, sometimes a lot of it.** The factor is bounded by the
|
|
52
|
+
# tighter axis, and rounding that down can throw away most of a window. A
|
|
53
|
+
# 640x480 design measured against real screens:
|
|
54
|
+
#
|
|
55
|
+
# | window | `:integer` | `:letterbox` |
|
|
56
|
+
# |---|---|---|
|
|
57
|
+
# | 1920x1080 | 2x, 320px border | 2.25x |
|
|
58
|
+
# | 2560x1440 | 3x, 320px border | 3x |
|
|
59
|
+
# | 1600x900 | **1x**, 480px border | 1.875x |
|
|
60
|
+
# | 1280x720 | **1x**, 320px border | 1.5x |
|
|
61
|
+
#
|
|
62
|
+
# The 16:9 rows are the ones to look at before choosing this mode: 900 and
|
|
63
|
+
# 720 pixels of height are both short of the 960 that 2x needs, so the whole
|
|
64
|
+
# window drops to 1x and the game sits in a small island. A design height
|
|
65
|
+
# that divides common screen heights — 360 rather than 480 — is what avoids
|
|
66
|
+
# that, and it is a decision about the game's art, not about this class.
|
|
67
|
+
#
|
|
68
|
+
# ## What `:integer` does when the window is too small
|
|
69
|
+
#
|
|
70
|
+
# It scales by 1 and lets the content overrun, rather than dropping below a
|
|
71
|
+
# whole number. So the design size is a **minimum**: shrink the window under
|
|
72
|
+
# it and you see part of the game rather than all of it, smaller.
|
|
73
|
+
#
|
|
74
|
+
# That is the same choice SDL makes, and the alternative is worse for the
|
|
75
|
+
# case the mode exists for — falling back to a fractional scale would
|
|
76
|
+
# reintroduce exactly the uneven pixels the game asked to avoid, at the
|
|
77
|
+
# moment nobody is looking. Nothing here enforces a minimum window size;
|
|
78
|
+
# that would need SDL, and this class is pure.
|
|
79
|
+
class Presentation
|
|
80
|
+
MODES = %i[disabled stretch letterbox integer].freeze
|
|
81
|
+
|
|
82
|
+
# The logical size a game draws in. Under `:disabled` these follow the
|
|
83
|
+
# window; under every other mode they are fixed and the window does not
|
|
84
|
+
# matter to anything above this class.
|
|
85
|
+
attr_reader :width, :height
|
|
86
|
+
|
|
87
|
+
attr_reader :mode, :scale_x, :scale_y, :offset_x, :offset_y
|
|
88
|
+
|
|
89
|
+
def initialize(width:, height:, mode: :disabled)
|
|
90
|
+
raise ArgumentError, "unknown scale mode #{mode.inspect} — one of #{MODES.inspect}" \
|
|
91
|
+
unless MODES.include?(mode)
|
|
92
|
+
raise ArgumentError, 'a logical size needs a positive width and height' \
|
|
93
|
+
unless width.positive? && height.positive?
|
|
94
|
+
|
|
95
|
+
@logical_width = width
|
|
96
|
+
@logical_height = height
|
|
97
|
+
@mode = mode
|
|
98
|
+
fit(width, height)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Switches mode and refits against the window it last saw, so a settings
|
|
102
|
+
# screen can change this while the game runs.
|
|
103
|
+
#
|
|
104
|
+
# The window size is remembered for exactly this: a caller changing the
|
|
105
|
+
# mode knows which mode it wants and has no reason to also know how big
|
|
106
|
+
# the window currently is, and asking it to pass one would be asking it to
|
|
107
|
+
# keep a copy of something this object already has.
|
|
108
|
+
def mode=(mode)
|
|
109
|
+
raise ArgumentError, "unknown scale mode #{mode.inspect} — one of #{MODES.inspect}" \
|
|
110
|
+
unless MODES.include?(mode)
|
|
111
|
+
|
|
112
|
+
@mode = mode
|
|
113
|
+
fit(@window_width, @window_height)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Whether anything is being scaled. `:disabled` means the caller can skip
|
|
117
|
+
# pushing a transform at all rather than pushing an identity one.
|
|
118
|
+
#
|
|
119
|
+
# @api private
|
|
120
|
+
def scaled? = @mode != :disabled
|
|
121
|
+
|
|
122
|
+
# Recomputes for a window of this size. Mutates rather than returning a
|
|
123
|
+
# new object: it is read every frame and recomputed only on a resize, so
|
|
124
|
+
# there is no reason to allocate.
|
|
125
|
+
def fit(window_width, window_height)
|
|
126
|
+
@window_width = window_width
|
|
127
|
+
@window_height = window_height
|
|
128
|
+
case @mode
|
|
129
|
+
when :disabled then fit_disabled(window_width, window_height)
|
|
130
|
+
when :stretch then fit_stretch(window_width, window_height)
|
|
131
|
+
else fit_uniform(window_width, window_height)
|
|
132
|
+
end
|
|
133
|
+
self
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def fit_disabled(window_width, window_height)
|
|
139
|
+
@width = window_width
|
|
140
|
+
@height = window_height
|
|
141
|
+
@scale_x = @scale_y = 1.0
|
|
142
|
+
@offset_x = @offset_y = 0.0
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def fit_stretch(window_width, window_height)
|
|
146
|
+
@width = @logical_width
|
|
147
|
+
@height = @logical_height
|
|
148
|
+
@scale_x = window_width.to_f / @logical_width
|
|
149
|
+
@scale_y = window_height.to_f / @logical_height
|
|
150
|
+
@offset_x = @offset_y = 0.0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def fit_uniform(window_width, window_height)
|
|
154
|
+
@width = @logical_width
|
|
155
|
+
@height = @logical_height
|
|
156
|
+
|
|
157
|
+
fitting = [window_width.to_f / @logical_width, window_height.to_f / @logical_height].min
|
|
158
|
+
scale = @mode == :integer ? integer_scale(fitting) : fitting
|
|
159
|
+
|
|
160
|
+
@scale_x = @scale_y = scale
|
|
161
|
+
@offset_x = ((window_width - (@logical_width * scale)) / 2.0).floor
|
|
162
|
+
@offset_y = ((window_height - (@logical_height * scale)) / 2.0).floor
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def integer_scale(fitting)
|
|
166
|
+
floored = fitting.floor
|
|
167
|
+
floored < 1 ? 1.0 : floored.to_f
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Scene
|
|
6
|
+
class SceneStack < Engine::Component
|
|
7
|
+
def initialize
|
|
8
|
+
super
|
|
9
|
+
@stack = []
|
|
10
|
+
@players = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# See #control: the scenes this holds need the input source, and a
|
|
14
|
+
# component is only handed one player's snapshot.
|
|
15
|
+
def on_attach = @players = node.system(Engine::Players)
|
|
16
|
+
|
|
17
|
+
def push(scene)
|
|
18
|
+
@stack.push(scene)
|
|
19
|
+
scene.parent = node
|
|
20
|
+
scene.scene = scene
|
|
21
|
+
scene.enter_tree
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def pop
|
|
26
|
+
scene = @stack.pop
|
|
27
|
+
return self unless scene
|
|
28
|
+
|
|
29
|
+
scene.exit_tree
|
|
30
|
+
scene.scene = nil
|
|
31
|
+
scene.parent = nil
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def replace(scene)
|
|
36
|
+
pop
|
|
37
|
+
push(scene)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def current
|
|
41
|
+
@stack.last
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Scenes live off the host's child list, so the traversal does not reach
|
|
45
|
+
# them on its own — and what has to reach them is the input *source*,
|
|
46
|
+
# not the snapshot this component was handed.
|
|
47
|
+
#
|
|
48
|
+
# A component receives one player's resolved Actions, which is right for
|
|
49
|
+
# a component: it belongs to exactly one node. A scene is a whole subtree
|
|
50
|
+
# and may contain nodes owned by different players, so handing it a
|
|
51
|
+
# single snapshot would flatten all of them onto whoever owns the host.
|
|
52
|
+
# The registry is pulled from the tree instead, the same way any system
|
|
53
|
+
# is, and passed down so each node in the scene resolves its own.
|
|
54
|
+
#
|
|
55
|
+
# Without a registry — a spec driving a stack with a bare snapshot — the
|
|
56
|
+
# snapshot is passed on, which is exactly what it means: one answer for
|
|
57
|
+
# everyone.
|
|
58
|
+
def control(actions)
|
|
59
|
+
return unless (current_scene = current)
|
|
60
|
+
|
|
61
|
+
current_scene.control(@players || actions)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def update(dt)
|
|
65
|
+
return unless (current_scene = current)
|
|
66
|
+
|
|
67
|
+
current_scene.update(dt)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Every scene in the stack, not just the current one — that asymmetry
|
|
71
|
+
# with control/update is what lets a menu pushed on top keep the world
|
|
72
|
+
# visible underneath while freezing it.
|
|
73
|
+
def draw(renderer, view)
|
|
74
|
+
@stack.each do |scene|
|
|
75
|
+
scene.draw(renderer, view)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Scenes live in @stack, off the host's child list, so the host's #sweep_freed
|
|
80
|
+
# can't reach them — forward the sweep into the active scene's subtree.
|
|
81
|
+
def sweep_freed
|
|
82
|
+
current&.sweep_freed
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# The rule for the two classes a game subclasses, Node2D and Component: a
|
|
6
|
+
# non-public method whose name starts with `_` is the engine's machinery, and
|
|
7
|
+
# a subclass defining a method of the same name raises where it is defined.
|
|
8
|
+
#
|
|
9
|
+
# class Hop < RGame::Engine::Node2D
|
|
10
|
+
# def _draw_content(renderer, view) = ... # NameError: would replace Node2D#_draw_content
|
|
11
|
+
# def draw_children(renderer, view) = ... # fine: no underscore, meant to be overridden
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# `private` in Ruby says who may *call* a method, not who may *replace* one: a
|
|
15
|
+
# subclass method of the same name is found first, and the base class quietly
|
|
16
|
+
# stops doing what that method did — for that subclass only, with the failure
|
|
17
|
+
# somewhere else entirely. So the underscore is the opt-in. A private or
|
|
18
|
+
# protected method *without* one is a seam, meant to be overridden with
|
|
19
|
+
# `super`, and nothing guards it.
|
|
20
|
+
#
|
|
21
|
+
# Only the base class's own methods are sealed. A private hook an engine
|
|
22
|
+
# subclass writes for its own subclasses — Components::Mover's `take_step` —
|
|
23
|
+
# is not covered, and the underscore convention does not apply outside these
|
|
24
|
+
# two classes.
|
|
25
|
+
#
|
|
26
|
+
# What it cannot see: a method arriving through `include` or `prepend` of a
|
|
27
|
+
# module, which does not pass through `method_added`.
|
|
28
|
+
module SealedPrivates
|
|
29
|
+
def self.extended(base)
|
|
30
|
+
base.define_singleton_method(:sealed_base) { base }
|
|
31
|
+
base.singleton_class.send(:private, :sealed_base)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Every method a subclass may not define: the base's non-public methods
|
|
35
|
+
# whose names start with `_`.
|
|
36
|
+
def sealed_methods
|
|
37
|
+
base = sealed_base
|
|
38
|
+
(base.private_instance_methods(false) + base.protected_instance_methods(false))
|
|
39
|
+
.select { |name| name.start_with?('_') }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def method_added(name)
|
|
43
|
+
super
|
|
44
|
+
base = sealed_base
|
|
45
|
+
return if equal?(base) || !name.start_with?('_')
|
|
46
|
+
return unless base.private_method_defined?(name, false) || base.protected_method_defined?(name, false)
|
|
47
|
+
|
|
48
|
+
raise NameError.new("#{self}##{name} would replace #{base}##{name}, which #{base} calls " \
|
|
49
|
+
"itself, and #{base} would silently stop doing its job for this class. " \
|
|
50
|
+
'Give the method another name.', name)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|