rgame 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +228 -0
- data/README.md +142 -243
- data/docs/api/README.md +141 -90
- data/docs/api/app.md +125 -68
- data/docs/api/assets.md +203 -166
- data/docs/api/audio.md +130 -89
- data/docs/api/cli.md +259 -0
- data/docs/api/components.md +1045 -254
- data/docs/api/drawing.md +187 -119
- data/docs/api/examples.md +263 -0
- data/docs/api/game.md +165 -45
- data/docs/api/images.md +39 -39
- data/docs/api/input.md +344 -85
- data/docs/api/internals.md +241 -62
- data/docs/api/localization.md +285 -0
- data/docs/api/scene_graph.md +503 -123
- data/docs/api/signals.md +81 -76
- data/docs/api/systems.md +197 -64
- data/docs/api/text.md +58 -41
- data/docs/api/tile_maps.md +237 -0
- data/docs/api/toolbox.md +400 -147
- data/docs/api/ui.md +927 -0
- data/docs/api/values.md +234 -19
- data/examples/assets/README.md +322 -0
- data/examples/assets/blip.ogg +0 -0
- data/examples/assets/glyphs.json +5 -0
- data/examples/assets/glyphs.png +0 -0
- data/examples/assets/hero.json +12 -0
- data/examples/assets/hero.png +0 -0
- data/examples/assets/icons.json +13 -0
- data/examples/assets/icons.png +0 -0
- data/examples/assets/music.ogg +0 -0
- data/examples/assets/skills.json +10 -0
- data/examples/assets/skills.png +0 -0
- data/examples/assets/tileset.png +0 -0
- data/examples/assets/tileset.tsx +65 -0
- data/examples/assets/town.tmx +26 -0
- data/examples/assets/ui.json +11 -0
- data/examples/assets/ui.png +0 -0
- data/examples/collision/locales/en.yml +8 -0
- data/examples/collision/main.rb +316 -0
- data/examples/collision_tiles/locales/en.yml +9 -0
- data/examples/collision_tiles/main.rb +274 -0
- data/examples/fullscreen/locales/en.yml +10 -0
- data/examples/fullscreen/main.rb +216 -0
- data/examples/game_menu/locales/en.yml +8 -0
- data/examples/game_menu/main.rb +170 -0
- data/examples/input_glyphs/locales/en.yml +14 -0
- data/examples/input_glyphs/main.rb +213 -0
- data/examples/jump_topdown/locales/en.yml +9 -0
- data/examples/jump_topdown/main.rb +178 -0
- data/examples/localization/locales/de.yml +12 -0
- data/examples/localization/locales/en.yml +13 -0
- data/examples/localization/main.rb +158 -0
- data/examples/menu_navigation/locales/en.yml +23 -0
- data/examples/menu_navigation/main.rb +365 -0
- data/examples/music/locales/en.yml +7 -0
- data/examples/music/main.rb +134 -0
- data/examples/pathfinding/locales/en.yml +17 -0
- data/examples/pathfinding/main.rb +298 -0
- data/examples/pooling/locales/en.yml +7 -0
- data/examples/pooling/main.rb +259 -0
- data/examples/quick_wheel/locales/en.yml +16 -0
- data/examples/quick_wheel/main.rb +184 -0
- data/examples/radial_menu/locales/en.yml +16 -0
- data/examples/radial_menu/main.rb +184 -0
- data/examples/save_load/locales/en.yml +11 -0
- data/examples/save_load/main.rb +207 -0
- data/examples/save_load_ids/locales/en.yml +11 -0
- data/examples/save_load_ids/main.rb +322 -0
- data/examples/scroll_map/locales/en.yml +4 -0
- data/examples/scroll_map/main.rb +140 -0
- data/examples/signals/locales/en.yml +6 -0
- data/examples/signals/main.rb +278 -0
- data/examples/skill_bar/locales/en.yml +14 -0
- data/examples/skill_bar/main.rb +159 -0
- data/examples/sound/locales/en.yml +6 -0
- data/examples/sound/main.rb +122 -0
- data/examples/split_screen/locales/en.yml +9 -0
- data/examples/split_screen/main.rb +304 -0
- data/examples/sprite/locales/en.yml +8 -0
- data/examples/sprite/main.rb +180 -0
- data/examples/timer/locales/en.yml +12 -0
- data/examples/timer/main.rb +273 -0
- data/examples/velocity/locales/en.yml +6 -0
- data/examples/velocity/main.rb +196 -0
- data/examples/walk/locales/en.yml +4 -0
- data/examples/walk/main.rb +99 -0
- data/exe/rgame +9 -0
- data/ext/README.md +6 -5
- data/ext/rgame_core/app/app.c +215 -11
- data/ext/rgame_core/app/locale.c +67 -0
- data/ext/rgame_core/app/locale.h +28 -0
- data/ext/rgame_core/audio/audio.c +113 -2
- data/ext/rgame_core/example.rb +18 -56
- data/ext/rgame_core/extconf.rb +16 -113
- data/ext/rgame_core/graphics/canvas.c +45 -4
- data/ext/rgame_core/graphics/canvas.h +65 -10
- data/ext/rgame_core/graphics/clip.c +22 -13
- data/ext/rgame_core/include/rgame/core.h +151 -5
- data/ext/rgame_core/input/gamepad.c +57 -3
- data/ext/rgame_core/ruby/audio_ext.c +10 -5
- data/ext/rgame_core/ruby/core_ext.c +46 -7
- data/ext/rgame_core/ruby/core_ext.h +3 -0
- data/ext/rgame_core/ruby/locale_ext.c +44 -0
- data/ext/rgame_core/ruby/recording_ext.c +1 -1
- data/ext/rgame_core/ruby/renderer_ext.c +42 -19
- data/ext/rgame_util/color_ext.c +12 -3
- data/ext/rgame_util/extconf.rb +2 -20
- data/ext/rgame_util/route_search.c +305 -0
- data/ext/rgame_util/route_search.h +86 -0
- data/ext/rgame_util/route_search_ext.c +150 -0
- data/ext/rgame_util/solid_grid.c +58 -0
- data/ext/rgame_util/solid_grid.h +49 -0
- data/ext/rgame_util/solid_grid_ext.c +161 -0
- data/ext/rgame_util/tile_sweep.c +164 -0
- data/ext/rgame_util/tile_sweep.h +62 -0
- data/ext/rgame_util/tile_sweep_ext.c +155 -0
- data/ext/rgame_util/util_ext.c +3 -0
- data/ext/rgame_util/util_ext.h +15 -0
- data/lib/rgame/boot.rb +0 -10
- data/lib/rgame/cli/new_project.rb +139 -0
- data/lib/rgame/cli/templates/Gemfile.tt +23 -0
- data/lib/rgame/cli/templates/README.md.tt +93 -0
- data/lib/rgame/cli/templates/Rakefile.tt +9 -0
- data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
- data/lib/rgame/cli/templates/game.rb.tt +23 -0
- data/lib/rgame/cli/templates/gitignore.tt +12 -0
- data/lib/rgame/cli/templates/main.rb.tt +11 -0
- data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
- data/lib/rgame/cli/templates/rspec.tt +2 -0
- data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
- data/lib/rgame/cli/templates/ruby-version.tt +1 -0
- data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
- data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
- data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
- data/lib/rgame/cli.rb +66 -0
- data/lib/rgame/core/app.rb +6 -42
- data/lib/rgame/core/asset_manager.rb +13 -31
- data/lib/rgame/core/audio.rb +37 -16
- data/lib/rgame/core/font.rb +0 -3
- data/lib/rgame/core/input.rb +35 -41
- data/lib/rgame/core/locale.rb +22 -0
- data/lib/rgame/core/nine_slice.rb +0 -21
- data/lib/rgame/core/recording.rb +3 -1
- data/lib/rgame/core/renderer.rb +75 -84
- data/lib/rgame/core/sprite_sheet.rb +0 -3
- data/lib/rgame/core/tile_map_renderer.rb +77 -65
- data/lib/rgame/core/ui_atlas.rb +28 -13
- data/lib/rgame/core.rb +1 -8
- data/lib/rgame/engine/actor_blockers.rb +131 -0
- data/lib/rgame/engine/animation_set.rb +1 -0
- data/lib/rgame/engine/audio_director.rb +36 -6
- data/lib/rgame/engine/bounds_blockers.rb +74 -0
- data/lib/rgame/engine/camera.rb +55 -10
- data/lib/rgame/engine/circle_collider.rb +4 -2
- data/lib/rgame/engine/collision_box.rb +26 -1
- data/lib/rgame/engine/collision_system.rb +110 -22
- data/lib/rgame/engine/component.rb +35 -1
- data/lib/rgame/engine/components/action_trigger.rb +0 -1
- data/lib/rgame/engine/components/animated_sprite.rb +31 -23
- data/lib/rgame/engine/components/box_collider.rb +99 -0
- data/lib/rgame/engine/components/camera_follow.rb +45 -0
- data/lib/rgame/engine/components/character_body.rb +21 -41
- data/lib/rgame/engine/components/circle_collider.rb +47 -11
- data/lib/rgame/engine/components/collision_world.rb +159 -31
- data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
- data/lib/rgame/engine/components/feet_collider.rb +61 -0
- data/lib/rgame/engine/components/hop.rb +76 -0
- data/lib/rgame/engine/components/identity.rb +73 -0
- data/lib/rgame/engine/components/mover.rb +285 -0
- data/lib/rgame/engine/components/navigator.rb +145 -0
- data/lib/rgame/engine/components/path_follow.rb +123 -31
- data/lib/rgame/engine/components/player_controller.rb +5 -2
- data/lib/rgame/engine/components/pool.rb +1 -1
- data/lib/rgame/engine/components/screen_wrap.rb +33 -11
- data/lib/rgame/engine/components/sprite.rb +22 -6
- data/lib/rgame/engine/components/targeting.rb +9 -11
- data/lib/rgame/engine/components/thrust_controller.rb +1 -1
- data/lib/rgame/engine/components/tile_world.rb +70 -29
- data/lib/rgame/engine/components/timer.rb +1 -1
- data/lib/rgame/engine/components/velocity.rb +23 -7
- data/lib/rgame/engine/components/wander_controller.rb +6 -2
- data/lib/rgame/engine/components/world.rb +133 -0
- data/lib/rgame/engine/contact_set.rb +74 -0
- data/lib/rgame/engine/culling.rb +45 -0
- data/lib/rgame/engine/debug_overlay.rb +23 -18
- data/lib/rgame/engine/i18n/plural.rb +45 -0
- data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
- data/lib/rgame/engine/i18n/template.rb +59 -0
- data/lib/rgame/engine/i18n.rb +276 -51
- data/lib/rgame/engine/input/action_mapper.rb +76 -22
- data/lib/rgame/engine/input/actions.rb +63 -12
- data/lib/rgame/engine/input/input_map.rb +196 -0
- data/lib/rgame/engine/layout.rb +82 -0
- data/lib/rgame/engine/nav_grid.rb +87 -0
- data/lib/rgame/engine/node2d.rb +364 -80
- data/lib/rgame/engine/path.rb +4 -6
- data/lib/rgame/engine/player.rb +69 -0
- data/lib/rgame/engine/player_layer.rb +70 -0
- data/lib/rgame/engine/players.rb +205 -0
- data/lib/rgame/engine/presentation.rb +171 -0
- data/lib/rgame/engine/scene/scene_stack.rb +29 -7
- data/lib/rgame/engine/sealed_privates.rb +54 -0
- data/lib/rgame/engine/spatial_hash.rb +53 -8
- data/lib/rgame/engine/text.rb +194 -0
- data/lib/rgame/engine/tile_blockers.rb +63 -0
- data/lib/rgame/engine/tile_map.rb +2 -3
- data/lib/rgame/engine/tile_map_layer.rb +82 -0
- data/lib/rgame/engine/tileset.rb +2 -4
- data/lib/rgame/engine/timer.rb +2 -2
- data/lib/rgame/engine/ui/button.rb +248 -0
- data/lib/rgame/engine/ui/column.rb +20 -0
- data/lib/rgame/engine/ui/icon_button.rb +93 -0
- data/lib/rgame/engine/ui/menu.rb +290 -0
- data/lib/rgame/engine/ui/navigation.rb +57 -0
- data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
- data/lib/rgame/engine/ui/option_button.rb +163 -0
- data/lib/rgame/engine/ui/panel_button.rb +32 -0
- data/lib/rgame/engine/ui/panel_menu.rb +36 -0
- data/lib/rgame/engine/ui/pointing.rb +146 -0
- data/lib/rgame/engine/ui/radial_menu.rb +85 -0
- data/lib/rgame/engine/ui/ring.rb +55 -0
- data/lib/rgame/engine/ui/row.rb +21 -0
- data/lib/rgame/engine/ui/shape_style.rb +102 -0
- data/lib/rgame/engine/ui/stack.rb +58 -0
- data/lib/rgame/engine/ui/stepping.rb +93 -0
- data/lib/rgame/engine/ui/text_button.rb +59 -0
- data/lib/rgame/engine/view.rb +76 -0
- data/lib/rgame/engine/viewports.rb +171 -0
- data/lib/rgame/engine/world_view.rb +71 -0
- data/lib/rgame/engine.rb +43 -25
- data/lib/rgame/game.rb +164 -22
- data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
- data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
- data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
- data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
- data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
- data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
- data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
- data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
- data/lib/rgame/rubocop/default.yml +39 -0
- data/lib/rgame/rubocop/plugin.rb +45 -0
- data/lib/rgame/rubocop.rb +11 -0
- data/lib/rgame/util/color.rb +20 -24
- data/lib/rgame/util/controls.rb +106 -44
- data/lib/rgame/util/route_search.rb +27 -0
- data/lib/rgame/util/save_file.rb +107 -0
- data/lib/rgame/util/solid_grid.rb +37 -0
- data/lib/rgame/util/tensor.rb +0 -9
- data/lib/rgame/util/tile_sweep.rb +36 -0
- data/lib/rgame/util/z.rb +123 -0
- data/lib/rgame/util.rb +5 -3
- data/lib/rgame/version.rb +1 -1
- data/lib/rgame.rb +0 -15
- metadata +176 -20
- data/lib/rgame/engine/actor.rb +0 -53
- data/lib/rgame/engine/body.rb +0 -49
- data/lib/rgame/engine/cached_label.rb +0 -33
- data/lib/rgame/engine/camera_view.rb +0 -28
- data/lib/rgame/engine/input/player_controller.rb +0 -14
- data/lib/rgame/engine/matrix.rb +0 -32
- data/lib/rgame/engine/resettable.rb +0 -67
- data/lib/rgame/engine/tile_collision.rb +0 -78
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'i18n'
|
|
4
|
+
|
|
5
|
+
module RGame
|
|
6
|
+
module Engine
|
|
7
|
+
# The string a node draws: a translation key, resolved through `I18n` and
|
|
8
|
+
# kept until one of its inputs changes — a variable, or the language.
|
|
9
|
+
#
|
|
10
|
+
# @score = Engine::Text.new('hud.score', :score) # built once, e.g. in initialize
|
|
11
|
+
# renderer.text(@score.with(score: @points), 12, 10) # every frame: cached, no allocation
|
|
12
|
+
#
|
|
13
|
+
# The names a `Text` is built with become the keywords of its `with`, so a
|
|
14
|
+
# forgotten or misspelt variable is Ruby's own `ArgumentError`. `to_s` reads
|
|
15
|
+
# a `Text` with the values its last `with` was given — so whatever draws it,
|
|
16
|
+
# a button's label among them, needs no values of its own — and raises
|
|
17
|
+
# before the first `with`. A `Text` with no names is read with `to_s`
|
|
18
|
+
# alone. Reading one whose variables and
|
|
19
|
+
# `I18n.generation` are unchanged returns the same frozen String and
|
|
20
|
+
# allocates nothing; otherwise it renders again. A `Text` never subscribes to
|
|
21
|
+
# `I18n`: it compares one Integer, so it holds nothing that could keep it
|
|
22
|
+
# alive.
|
|
23
|
+
#
|
|
24
|
+
# A `Text` answers `to_str`, so a renderer takes one where it takes a String,
|
|
25
|
+
# and it is `==` to the String it reads.
|
|
26
|
+
#
|
|
27
|
+
# `Text.literal` is a string that is never translated, and `Text.computed` a
|
|
28
|
+
# string a block builds. Both answer `with` and `to_s` the same way, so
|
|
29
|
+
# whatever draws a `Text` never asks which kind it holds.
|
|
30
|
+
class Text
|
|
31
|
+
NAME = /\A[a-z_][A-Za-z0-9_]*\z/
|
|
32
|
+
private_constant :NAME
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# A `Text` that shows `string` in every language.
|
|
36
|
+
def literal(string) = Literal.new(string)
|
|
37
|
+
|
|
38
|
+
# A `Text` whose string the block builds from the keywords `names`, for
|
|
39
|
+
# text that is formatted rather than looked up. The block runs when a
|
|
40
|
+
# keyword or `I18n.generation` changes, never on an unchanged read, so a
|
|
41
|
+
# block that calls `I18n.t` follows the language too:
|
|
42
|
+
#
|
|
43
|
+
# @clock = Engine::Text.computed(:seconds) { |seconds:| format_clock(seconds) }
|
|
44
|
+
def computed(*names, &block) = Computed.new(names, block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Generates the module that gives a `Text` its `with` for one list of
|
|
48
|
+
# names, the first time that list is seen, and shares it after.
|
|
49
|
+
module Accessors
|
|
50
|
+
@cache = {}
|
|
51
|
+
|
|
52
|
+
def self.for(names)
|
|
53
|
+
@cache[names] ||= generate(names)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def generate(names)
|
|
60
|
+
names.each do |name|
|
|
61
|
+
raise ArgumentError, "#{name.inspect} cannot be a variable name" unless NAME.match?(name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Module.new.tap { |mod| mod.module_eval(accessor_source(names), __FILE__, __LINE__) }
|
|
65
|
+
rescue SyntaxError
|
|
66
|
+
raise ArgumentError, "#{names.inspect} cannot all be variable names"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def accessor_source(names)
|
|
70
|
+
return <<~RUBY if names.empty?
|
|
71
|
+
def with = to_s
|
|
72
|
+
|
|
73
|
+
def to_s
|
|
74
|
+
return @string if @generation == ::RGame::Engine::I18n.generation
|
|
75
|
+
|
|
76
|
+
refresh({})
|
|
77
|
+
end
|
|
78
|
+
RUBY
|
|
79
|
+
|
|
80
|
+
<<~RUBY
|
|
81
|
+
def with(#{names.map { "#{it}:" }.join(', ')})
|
|
82
|
+
if @generation == ::RGame::Engine::I18n.generation && #{names.map { "@_var_#{it} == #{it}" }.join(' && ')}
|
|
83
|
+
return @string
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
#{names.map { "@_var_#{it} = #{it}" }.join('; ')}
|
|
87
|
+
refresh({ #{names.map { "#{it}: #{it}" }.join(', ')} })
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_s
|
|
91
|
+
return @string if @generation == ::RGame::Engine::I18n.generation
|
|
92
|
+
unless defined?(@_var_#{names.first})
|
|
93
|
+
raise ArgumentError, "\#{describe} needs #{names.map { "#{it}:" }.join(', ')}; call with first"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
refresh({ #{names.map { "#{it}: @_var_#{it}" }.join(', ')} })
|
|
97
|
+
end
|
|
98
|
+
RUBY
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
private_constant :Accessors
|
|
103
|
+
|
|
104
|
+
# The key as given, without its scope.
|
|
105
|
+
attr_reader :key
|
|
106
|
+
|
|
107
|
+
# The variable names `with` takes, sorted.
|
|
108
|
+
attr_reader :names
|
|
109
|
+
|
|
110
|
+
attr_reader :scope
|
|
111
|
+
|
|
112
|
+
# A `Text` for `key`, whose translation prints the variables `names`.
|
|
113
|
+
# `scope: 'title_menu'` with key `'play'` resolves `'title_menu.play'`.
|
|
114
|
+
def initialize(key, *names, scope: nil)
|
|
115
|
+
@key = key.to_s.freeze
|
|
116
|
+
self.scope = scope
|
|
117
|
+
declare(names)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Changes the scope the key resolves under; the next read resolves again.
|
|
121
|
+
def scope=(scope)
|
|
122
|
+
@scope = scope&.to_s&.freeze
|
|
123
|
+
@path = (@scope ? "#{@scope}.#{@key}" : @key).freeze
|
|
124
|
+
@generation = nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The same String as `to_s`, so a `Text` goes wherever a String is
|
|
128
|
+
# expected: `renderer.text(@title, 12, 10)`. Before the first `with`, a
|
|
129
|
+
# `Text` with names raises `ArgumentError` here too.
|
|
130
|
+
def to_str = to_s
|
|
131
|
+
|
|
132
|
+
# Equal to a String that reads the same, from either side, so a spy that
|
|
133
|
+
# recorded a `Text` matches the String it drew. Against anything else,
|
|
134
|
+
# including another `Text`, it compares by identity.
|
|
135
|
+
def ==(other) = other.is_a?(String) ? to_s == other : super
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def declare(names)
|
|
140
|
+
@names = names.map(&:to_sym).sort.freeze
|
|
141
|
+
raise ArgumentError, "#{describe} declares a variable twice" if @names.uniq.size != @names.size
|
|
142
|
+
|
|
143
|
+
extend(Accessors.for(@names))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def refresh(vars)
|
|
147
|
+
generation = I18n.generation
|
|
148
|
+
@string = render(vars)
|
|
149
|
+
@generation = generation
|
|
150
|
+
@string
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def render(vars) = I18n.render(@path, @names, vars).freeze
|
|
154
|
+
|
|
155
|
+
def describe = "Text #{@path.inspect}"
|
|
156
|
+
|
|
157
|
+
# A `Text` that is not translated: the same `with` and `to_s`, answering
|
|
158
|
+
# with one String.
|
|
159
|
+
class Literal < Text
|
|
160
|
+
def initialize(string) # rubocop:disable Lint/MissingSuper -- a literal has no key to resolve
|
|
161
|
+
@string = -string.to_s
|
|
162
|
+
@names = [].freeze
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def with = @string
|
|
166
|
+
def to_s = @string
|
|
167
|
+
|
|
168
|
+
# A literal ignores a scope: there is no key to put one in front of.
|
|
169
|
+
def scope=(_scope); end
|
|
170
|
+
end
|
|
171
|
+
private_constant :Literal
|
|
172
|
+
|
|
173
|
+
# A `Text` built by a block from its keywords instead of from a key.
|
|
174
|
+
class Computed < Text
|
|
175
|
+
def initialize(names, block) # rubocop:disable Lint/MissingSuper -- a computed text has no key to resolve
|
|
176
|
+
raise ArgumentError, 'Text.computed needs a block' unless block
|
|
177
|
+
|
|
178
|
+
@block = block
|
|
179
|
+
declare(names)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A computed text ignores a scope: there is no key to put one in front of.
|
|
183
|
+
def scope=(_scope); end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def render(vars) = @block.call(**vars)
|
|
188
|
+
|
|
189
|
+
def describe = 'Text.computed'
|
|
190
|
+
end
|
|
191
|
+
private_constant :Computed
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# The tile grid as a blocker source: axis-separated AABB-vs-tile collision
|
|
6
|
+
# resolution over a Util::SolidGrid, at a tile size. Assumes per-step movement smaller
|
|
7
|
+
# than a tile (no tunneling), which holds for our speeds.
|
|
8
|
+
#
|
|
9
|
+
# It answers the blocker-source question — "where does this box land moving dx" —
|
|
10
|
+
# which CollisionSystem asks of every source it holds; see its header for the
|
|
11
|
+
# protocol. Resolving X then Y (with the X result) is what gives wall-sliding, and
|
|
12
|
+
# that ordering lives in the system rather than here, so every source shares it.
|
|
13
|
+
#
|
|
14
|
+
# It also answers the protocol's optional fourth question, #travel? — whether a box can
|
|
15
|
+
# move along a segment without any resolve falling short — which is how a route is
|
|
16
|
+
# checked against the resolver that will stop the walker on it. It is the only source
|
|
17
|
+
# that answers it today.
|
|
18
|
+
#
|
|
19
|
+
# The arithmetic for both is Util::TileSweep's, in C: this class is its blocker-source
|
|
20
|
+
# face, adding what the protocol asks beyond the numbers. The grid is read, never copied,
|
|
21
|
+
# so a cell changed with SolidGrid#set_solid stops the next step.
|
|
22
|
+
class TileBlockers
|
|
23
|
+
# What a TileBlockers reports as having stopped a step. One object for the life of
|
|
24
|
+
# the process, answering the same two questions a collider does — so a handler reads
|
|
25
|
+
# `by.layer` whatever stopped it and never branches on what kind of thing it was. A
|
|
26
|
+
# tile has no node, so `node` is nil.
|
|
27
|
+
#
|
|
28
|
+
# It holds no state at all, which is what makes one TileBlockers safe to share
|
|
29
|
+
# between every body on the map: there is nothing here for two of them to race over.
|
|
30
|
+
class Tiles
|
|
31
|
+
def layer = :tiles
|
|
32
|
+
def node = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
TILES = Tiles.new.freeze
|
|
36
|
+
|
|
37
|
+
def initialize(grid:, tile_width:, tile_height:)
|
|
38
|
+
@sweep = Util::TileSweep.new(grid, tile_width, tile_height)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def grid = @sweep.grid
|
|
42
|
+
|
|
43
|
+
# Move an AABB (top-left x, y; size w, h) by dx, snapping flush against solids. A Float.
|
|
44
|
+
def resolve_x(x, y, w, h, dx) = @sweep.resolve_x(x, y, w, h, dx)
|
|
45
|
+
|
|
46
|
+
def resolve_y(x, y, w, h, dy) = @sweep.resolve_y(x, y, w, h, dy)
|
|
47
|
+
|
|
48
|
+
# Whether the box travels (dx, dy) without any resolve along the way falling short of
|
|
49
|
+
# where it was heading — so a walker resolving its own steps against this source, each
|
|
50
|
+
# under a quarter tile, is never stopped on that segment. A landing *past* the intended
|
|
51
|
+
# one does not count as stopped, which is how CollisionSystem reads every source.
|
|
52
|
+
def travel?(x, y, w, h, dx, dy) = @sweep.travel?(x, y, w, h, dx, dy)
|
|
53
|
+
|
|
54
|
+
# The grid stopped it, and the grid is the same for everybody — so this is a
|
|
55
|
+
# constant rather than a record of the last step. See TILES.
|
|
56
|
+
def blocker = TILES
|
|
57
|
+
|
|
58
|
+
# A grid does not move, so there is nothing to re-index. Part of the blocker-source
|
|
59
|
+
# protocol Engine::CollisionSystem calls after every step; see its header.
|
|
60
|
+
def moved(_actor, _from_x, _from_y, _w, _h) = nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -20,7 +20,7 @@ module RGame
|
|
|
20
20
|
# this class (see CLAUDE.md, "The rule points both ways"). So `load` hands
|
|
21
21
|
# back a path and stops there.
|
|
22
22
|
class TileMap
|
|
23
|
-
FLIP_MASK = 0x1FFFFFFF
|
|
23
|
+
FLIP_MASK = 0x1FFFFFFF
|
|
24
24
|
|
|
25
25
|
attr_reader :width, :height, :tile_width, :tile_height,
|
|
26
26
|
:pixel_width, :pixel_height, :tileset_source, :firstgid
|
|
@@ -71,7 +71,6 @@ module RGame
|
|
|
71
71
|
)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
# True if a layer carries a Tiled bool custom property `name` set to "true".
|
|
75
74
|
def self.layer_flag?(layer_el, name)
|
|
76
75
|
props = layer_el.elements['properties']
|
|
77
76
|
return false unless props
|
|
@@ -81,6 +80,7 @@ module RGame
|
|
|
81
80
|
end
|
|
82
81
|
false
|
|
83
82
|
end
|
|
83
|
+
private_class_method :layer_flag?
|
|
84
84
|
|
|
85
85
|
def initialize(width:, height:, tile_width:, tile_height:, tileset_source:, firstgid:, layers:, above: [])
|
|
86
86
|
@width = width
|
|
@@ -133,7 +133,6 @@ module RGame
|
|
|
133
133
|
|
|
134
134
|
private
|
|
135
135
|
|
|
136
|
-
# Pack the parsed per-layer gid rows into one flat Tensor ([col, row, layer]).
|
|
137
136
|
def build_tiles(layers)
|
|
138
137
|
tiles = Util::Tensor.new(@width, @height, layers.length, initial: 0)
|
|
139
138
|
layers.each_with_index do |gids, layer|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# One layer of the scene's tile map, drawn in world space, once per viewport.
|
|
6
|
+
#
|
|
7
|
+
# world = scene.add_node(WorldView.new)
|
|
8
|
+
# actors = TileMapLayer.mount(world)
|
|
9
|
+
# actors.add_node(player)
|
|
10
|
+
#
|
|
11
|
+
# A node per layer, and the layers Tiled lists are the layers you get. The
|
|
12
|
+
# scene tree is then what says what covers what: everything mounted before
|
|
13
|
+
# the actors draws under them, everything after draws over them, and the
|
|
14
|
+
# gap `mount` leaves is where the actors go.
|
|
15
|
+
#
|
|
16
|
+
# It belongs **inside a WorldView**, which is the whole point of it existing
|
|
17
|
+
# separately from Components::TileWorld. The map is world content: it
|
|
18
|
+
# scrolls under a camera and every player sees their own part of it, so it
|
|
19
|
+
# has to be drawn where the rest of the world is drawn rather than once for
|
|
20
|
+
# the frame.
|
|
21
|
+
#
|
|
22
|
+
# It carries no state. The map id and the animation clock come from the
|
|
23
|
+
# scene's TileWorld system, and the region worth drawing comes from the view.
|
|
24
|
+
#
|
|
25
|
+
# ## Why a node per layer, rather than two passes
|
|
26
|
+
#
|
|
27
|
+
# There used to be one of these, drawing a "below" band and an "above" band
|
|
28
|
+
# in one go and relying on a global z to slot the actors between them. Draw
|
|
29
|
+
# order is tree order now (see RGame::Util::Z), so a node's drawing is
|
|
30
|
+
# contiguous and "between them" has to mean "between two nodes".
|
|
31
|
+
#
|
|
32
|
+
# That turned out to be the better shape anyway. A designer already orders
|
|
33
|
+
# layers in Tiled and can see the result there; content can go between *any*
|
|
34
|
+
# two of them rather than at one flagged boundary; and the `above` property
|
|
35
|
+
# stops being something to remember on every layer — it is read once, by
|
|
36
|
+
# `mount`, to decide where the gap goes.
|
|
37
|
+
class TileMapLayer < Node2D
|
|
38
|
+
# Mounts one node per layer of the scene's map under `parent`, and returns
|
|
39
|
+
# an empty node sitting in the gap between them — what the scene hangs its
|
|
40
|
+
# actors on. Nothing here picks a z by hand, and neither does the caller.
|
|
41
|
+
#
|
|
42
|
+
# `under` names the first layer that should cover the actors, as a layer
|
|
43
|
+
# index. It defaults to the first layer the map flags `above` in Tiled, so
|
|
44
|
+
# a map that already marks its canopies needs nothing said; a map that
|
|
45
|
+
# marks none puts the actors on top of everything.
|
|
46
|
+
#
|
|
47
|
+
# `parent` must be inside a WorldView, like the nodes themselves.
|
|
48
|
+
def self.mount(parent, under: nil)
|
|
49
|
+
world = parent.system(Components::TileWorld)
|
|
50
|
+
gap = under || world.first_above_layer
|
|
51
|
+
|
|
52
|
+
world.layer_count.times do |index|
|
|
53
|
+
parent.add_node(new(layer: index, z: index < gap ? index : index + 1))
|
|
54
|
+
end
|
|
55
|
+
parent.add_node(Node2D.new(z: gap))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(layer:, **)
|
|
59
|
+
super(**)
|
|
60
|
+
@layer = layer
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def on_add = @world = system(Components::TileWorld)
|
|
64
|
+
|
|
65
|
+
# The view supplies the cull rect: which part of the world this viewport
|
|
66
|
+
# can see. The map draws in world coordinates and the WorldView's
|
|
67
|
+
# translate puts it on screen, so nothing here does camera arithmetic —
|
|
68
|
+
# which is exactly what lets the same map serve every viewport.
|
|
69
|
+
#
|
|
70
|
+
# A screen-space view has no camera and nothing to cull against, so
|
|
71
|
+
# there is nothing sensible to draw; that is a misplaced layer rather than
|
|
72
|
+
# a state to handle, and it says so.
|
|
73
|
+
def on_draw(renderer, view)
|
|
74
|
+
camera = view.camera
|
|
75
|
+
raise 'TileMapLayer must be inside a WorldView — this view has no camera' if camera.nil?
|
|
76
|
+
|
|
77
|
+
renderer.tilemap(@world.tilemap_id, @layer, camera.x, camera.y,
|
|
78
|
+
view.width, view.height, elapsed: @world.elapsed)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/rgame/engine/tileset.rb
CHANGED
|
@@ -15,7 +15,7 @@ module RGame
|
|
|
15
15
|
Frame = Struct.new(:tile_id, :duration)
|
|
16
16
|
|
|
17
17
|
attr_reader :firstgid, :columns, :tile_width, :tile_height, :image_source, :animations
|
|
18
|
-
attr_accessor :solid_ids
|
|
18
|
+
attr_accessor :solid_ids
|
|
19
19
|
|
|
20
20
|
def self.parse(tsx_string, firstgid:)
|
|
21
21
|
root = REXML::Document.new(tsx_string).root
|
|
@@ -40,7 +40,6 @@ module RGame
|
|
|
40
40
|
)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
# Frames for an animated tile, or nil (compacted away) for a static one.
|
|
44
43
|
def self.parse_animation(tile_el)
|
|
45
44
|
anim = tile_el.elements['animation']
|
|
46
45
|
return unless anim
|
|
@@ -50,12 +49,11 @@ module RGame
|
|
|
50
49
|
end
|
|
51
50
|
end
|
|
52
51
|
|
|
53
|
-
# A tile is solid if it carries a Tiled collision shape — an `<objectgroup>`
|
|
54
|
-
# with at least one object (the per-tile collision editor's output).
|
|
55
52
|
def self.collision_shape?(tile_el)
|
|
56
53
|
group = tile_el.elements['objectgroup']
|
|
57
54
|
!group.nil? && !group.elements['object'].nil?
|
|
58
55
|
end
|
|
56
|
+
private_class_method :parse_animation, :collision_shape?
|
|
59
57
|
|
|
60
58
|
def initialize(firstgid:, columns:, tile_width:, tile_height:, image_source:, animations:, solid_ids: Set.new)
|
|
61
59
|
@firstgid = firstgid
|
data/lib/rgame/engine/timer.rb
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
module RGame
|
|
4
4
|
module Engine
|
|
5
5
|
# A repeating interval timer for periodic events that aren't driven by input — a
|
|
6
|
-
# spawner emitting an enemy every N seconds, a
|
|
6
|
+
# spawner emitting an enemy every N seconds, a turret's fire rate, a wave clock.
|
|
7
7
|
#
|
|
8
8
|
# It only accumulates time; the owner decides what each elapsed interval means. That
|
|
9
9
|
# split lets one primitive serve both styles: "act automatically" (consume every ready
|
|
10
10
|
# interval) and "stay loaded until conditions allow" (check `ready?`, but `consume`
|
|
11
|
-
# only when actually acting — so a
|
|
11
|
+
# only when actually acting — so a turret with no target keeps its shot ready instead of
|
|
12
12
|
# wasting it). Pure and allocation-free, so it ticks on the per-frame path.
|
|
13
13
|
#
|
|
14
14
|
# timer = Engine::Timer.new(0.8)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module UI
|
|
6
|
+
# Anything a Menu can hold: the state a button has — enabled, focused,
|
|
7
|
+
# pressed — and a signal for when it is chosen, with no look at all.
|
|
8
|
+
#
|
|
9
|
+
# class Swatch < UI::Button
|
|
10
|
+
# def initialize(color:, **)
|
|
11
|
+
# super(**)
|
|
12
|
+
# @color = color
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# def on_draw(renderer, _view)
|
|
16
|
+
# inset = state == :focused ? 0 : 4
|
|
17
|
+
# renderer.rect(inset, inset, width - (inset * 2), height - (inset * 2), color: @color)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# The look is a subclass's `on_draw`, and it reads `state` to decide what
|
|
22
|
+
# to draw. Everything the menu needs from a button — being told it is
|
|
23
|
+
# focused or pressed, being activated, being asked to `adjust` — lives
|
|
24
|
+
# here, so a subclass that only draws cannot leave any of it out.
|
|
25
|
+
#
|
|
26
|
+
# There is no hover, because there is no pointer: what a mouse-driven
|
|
27
|
+
# control would get from the cursor being over it, this gets from the Menu
|
|
28
|
+
# telling it it is the focused one.
|
|
29
|
+
#
|
|
30
|
+
# Its position is its own, resolved through the tree like any node's, so a
|
|
31
|
+
# Menu inside a PlayerLayer puts its buttons inside that player's region
|
|
32
|
+
# without either of them arranging it.
|
|
33
|
+
#
|
|
34
|
+
# A Button with no `on_draw` of its own draws nothing.
|
|
35
|
+
#
|
|
36
|
+
# ## The label is a translation key
|
|
37
|
+
#
|
|
38
|
+
# `label: 'play'` is a key, and the button holds it as an Engine::Text, so
|
|
39
|
+
# what is drawn follows `I18n.locale` without the button being rebuilt.
|
|
40
|
+
# Text that must not be translated — a player's name — is passed as one:
|
|
41
|
+
#
|
|
42
|
+
# UI::TextButton.new(label: 'play') # the key 'play'
|
|
43
|
+
# UI::TextButton.new(label: Engine::Text.literal(name)) # the name, in every language
|
|
44
|
+
#
|
|
45
|
+
# A label is drawn as it reads, so a `Text` with variables shows the values
|
|
46
|
+
# its last `with` was given. The node that owns them calls `with` in
|
|
47
|
+
# `update`, and the label follows:
|
|
48
|
+
#
|
|
49
|
+
# @continue = Engine::Text.new('continue', :saves)
|
|
50
|
+
# menu.add(UI::PanelButton.new(label: @continue))
|
|
51
|
+
# def on_update(_dt) = @continue.with(saves: @save_count)
|
|
52
|
+
#
|
|
53
|
+
# One given no `with` yet raises on the first draw, naming the keywords.
|
|
54
|
+
#
|
|
55
|
+
# `label_scope` puts a scope in front of a label given as a key, and
|
|
56
|
+
# UI::Menu sets it from its own `scope:` as the button is added. A label
|
|
57
|
+
# given as a `Text` is the caller's, scope and all, and nothing changes it.
|
|
58
|
+
#
|
|
59
|
+
# ## When a press activates
|
|
60
|
+
#
|
|
61
|
+
# `activate_on:` is `:release` by default: confirm draws the button pressed
|
|
62
|
+
# while it is held and activates it when it is let go, so a player who
|
|
63
|
+
# moves focus away while still holding cancels. `:press` activates on the
|
|
64
|
+
# way down — what a skill bar wants — and keeps the button drawn pressed
|
|
65
|
+
# for at least `PRESS_FEEDBACK` seconds, because a tap is otherwise a
|
|
66
|
+
# single frame of pressed that nobody sees. Its 0.1 s is Unity's, which
|
|
67
|
+
# holds a submitted button pressed that long; Godot holds a shortcut's for
|
|
68
|
+
# 0.2 s.
|
|
69
|
+
#
|
|
70
|
+
# Either way a button only acts on a press it saw start. A press whose
|
|
71
|
+
# release it did not see — its menu was paused or covered in between — is
|
|
72
|
+
# dropped rather than finished, so a menu that closes itself from
|
|
73
|
+
# `on_activated` and is opened again does not come back pressed. See
|
|
74
|
+
# UI::Menu for the other half, a menu that has not yet seen confirm up.
|
|
75
|
+
#
|
|
76
|
+
# ## A hotkey
|
|
77
|
+
#
|
|
78
|
+
# `hotkey:` names an action that presses this button whether or not it is
|
|
79
|
+
# focused, and without moving focus — a skill bar's number keys:
|
|
80
|
+
#
|
|
81
|
+
# UI::IconButton.new(image: :torch, hotkey: :skill3)
|
|
82
|
+
#
|
|
83
|
+
# A hotkey always activates on the press, whatever `activate_on:` says,
|
|
84
|
+
# and draws pressed for `PRESS_FEEDBACK` or while held. Its release
|
|
85
|
+
# activates nothing. The menu reads it, so the action has to be declared
|
|
86
|
+
# in the player's InputMap; an undeclared one raises on the first frame.
|
|
87
|
+
#
|
|
88
|
+
# **A press belongs to the source that started it.** The menu passes
|
|
89
|
+
# `:confirm` or `:hotkey` to `press`, `release` and `cancel_press`, and
|
|
90
|
+
# while one source holds the button a press from the other is ignored and
|
|
91
|
+
# its release ends nothing. So a player holding confirm on a `:release`
|
|
92
|
+
# button who taps that button's hotkey activates it once, when confirm is
|
|
93
|
+
# let go; and losing focus ends a confirm hold but not a hotkey one.
|
|
94
|
+
class Button < Node2D
|
|
95
|
+
signal :on_activated
|
|
96
|
+
|
|
97
|
+
PRESS_FEEDBACK = 0.1
|
|
98
|
+
STATES = %i[idle focused pressed disabled].freeze
|
|
99
|
+
ACTIVATE_ON = %i[release press].freeze
|
|
100
|
+
SOURCES = %i[confirm hotkey].freeze
|
|
101
|
+
|
|
102
|
+
attr_accessor :enabled
|
|
103
|
+
# The Engine::Text drawn for this button, or nil.
|
|
104
|
+
attr_reader :label
|
|
105
|
+
# The scope a label given as a key resolves under, or nil.
|
|
106
|
+
attr_reader :label_scope
|
|
107
|
+
# `hotkey` is an action name, or nil.
|
|
108
|
+
attr_reader :activate_on, :hotkey
|
|
109
|
+
|
|
110
|
+
def initialize(label: nil, enabled: true, activate_on: :release, hotkey: nil, **)
|
|
111
|
+
super(**)
|
|
112
|
+
unless ACTIVATE_ON.include?(activate_on)
|
|
113
|
+
raise ArgumentError, "activate_on: must be :release or :press, not #{activate_on.inspect}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@label_scope = nil
|
|
117
|
+
self.label = label
|
|
118
|
+
@enabled = enabled
|
|
119
|
+
@activate_on = activate_on
|
|
120
|
+
@hotkey = hotkey
|
|
121
|
+
@focused = false
|
|
122
|
+
@holder = nil
|
|
123
|
+
@feedback = 0.0
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Sets the label: a key String or Symbol, which the button makes an
|
|
127
|
+
# Engine::Text of under `label_scope`; a `Text`, used as it is; or nil.
|
|
128
|
+
def label=(label)
|
|
129
|
+
@label = label && text_for(label)
|
|
130
|
+
@label_from_key = !(label.nil? || label.is_a?(Text))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Sets the scope a label given as a key resolves under — `'title_menu'`
|
|
134
|
+
# makes the key `'play'` read `'title_menu.play'` — and resolves it again
|
|
135
|
+
# on the next draw.
|
|
136
|
+
def label_scope=(scope)
|
|
137
|
+
@label_scope = scope&.to_s
|
|
138
|
+
@label.scope = @label_scope if @label_from_key
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def enabled? = @enabled
|
|
142
|
+
def focused? = @focused
|
|
143
|
+
def pressed? = !@holder.nil? || @feedback.positive?
|
|
144
|
+
|
|
145
|
+
# What to draw: `:disabled` whenever the button is disabled, whatever
|
|
146
|
+
# else is true; then `:pressed`, `:focused`, and otherwise `:idle`.
|
|
147
|
+
# Pressed does not require focus: a press can outlast the focus that
|
|
148
|
+
# started it by its `PRESS_FEEDBACK`.
|
|
149
|
+
def state
|
|
150
|
+
return :disabled unless @enabled
|
|
151
|
+
return :pressed if pressed?
|
|
152
|
+
|
|
153
|
+
@focused ? :focused : :idle
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Called by the Menu. Calls `on_focus_changed` when the value actually
|
|
157
|
+
# changes, and never for a repeated assignment, so a menu that reasserts
|
|
158
|
+
# focus every frame does not replay a focus sound every frame. Losing
|
|
159
|
+
# focus lets go of a confirm press without activating it; a hotkey press
|
|
160
|
+
# was never about focus, and is kept.
|
|
161
|
+
def focused=(value)
|
|
162
|
+
return if @focused == value
|
|
163
|
+
|
|
164
|
+
@focused = value
|
|
165
|
+
@holder = nil if !value && @holder == :confirm
|
|
166
|
+
on_focus_changed(value)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Called by the Menu when a press from `source` — `:confirm` or
|
|
170
|
+
# `:hotkey` — starts on this button. A hotkey press, or any press under
|
|
171
|
+
# `activate_on: :press`, activates at once and returns what `activate`
|
|
172
|
+
# did; a confirm press under `:release` holds and returns nil. Ignored,
|
|
173
|
+
# returning nil, while disabled or while any source already holds it.
|
|
174
|
+
def press(source = :confirm)
|
|
175
|
+
unless SOURCES.include?(source)
|
|
176
|
+
raise ArgumentError, "source must be :confirm or :hotkey, not #{source.inspect}"
|
|
177
|
+
end
|
|
178
|
+
return nil unless @enabled && @holder.nil?
|
|
179
|
+
|
|
180
|
+
@holder = source
|
|
181
|
+
return nil unless source == :hotkey || @activate_on == :press
|
|
182
|
+
|
|
183
|
+
activate_with_feedback
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Called by the Menu when a press `source` started is let go. Activates
|
|
187
|
+
# a confirm press under `activate_on: :release`, and returns what
|
|
188
|
+
# `activate` did; ends nothing another source started.
|
|
189
|
+
def release(source = :confirm)
|
|
190
|
+
return nil unless @holder == source
|
|
191
|
+
|
|
192
|
+
@holder = nil
|
|
193
|
+
activate if source == :confirm && @activate_on == :release
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Called by the Menu for a press from `source` whose release this button
|
|
197
|
+
# never saw. Drops it, and the feedback with it, activating nothing.
|
|
198
|
+
def cancel_press(source = :confirm)
|
|
199
|
+
return unless @holder == source
|
|
200
|
+
|
|
201
|
+
@holder = nil
|
|
202
|
+
@feedback = 0.0
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The instant press: activates, and shows pressed for `PRESS_FEEDBACK`
|
|
206
|
+
# though nothing holds the button. What a hotkey press and a
|
|
207
|
+
# `:press` confirm do, available on its own for a press that ends
|
|
208
|
+
# somewhere other than on this button. Returns what `activate` did.
|
|
209
|
+
def activate_with_feedback
|
|
210
|
+
return nil unless @enabled
|
|
211
|
+
|
|
212
|
+
@feedback = PRESS_FEEDBACK
|
|
213
|
+
activate
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# What the focused button does with `ui_left` / `ui_right`, which the
|
|
217
|
+
# menu hands down without knowing what kind of button it is talking to.
|
|
218
|
+
# A plain button has nothing to change, and answers nil the way a
|
|
219
|
+
# disabled `activate` does. UI::OptionButton is the one that overrides it.
|
|
220
|
+
def adjust(_delta) = nil
|
|
221
|
+
|
|
222
|
+
# Fires the signal and returns the button, or nil if it is disabled — so
|
|
223
|
+
# a caller never has to check first, and a disabled button cannot be
|
|
224
|
+
# activated by any route.
|
|
225
|
+
def activate
|
|
226
|
+
return nil unless @enabled
|
|
227
|
+
|
|
228
|
+
on_activated_signal.emit
|
|
229
|
+
self
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Counts down the pressed feedback, then does what every node does.
|
|
233
|
+
def update(dt)
|
|
234
|
+
@feedback -= dt if @feedback.positive? && !@paused
|
|
235
|
+
super
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Hook: override to react to gaining or losing focus — a sound, the
|
|
239
|
+
# start of an animation. Called only on a change.
|
|
240
|
+
def on_focus_changed(focused); end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def text_for(shown) = shown.is_a?(Text) ? shown : Text.new(shown, scope: @label_scope)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module UI
|
|
6
|
+
# A Menu layout: buttons stacked downwards from the menu's origin, all the
|
|
7
|
+
# same size, `spacing` pixels apart. A UI::Stack on the vertical axis, so
|
|
8
|
+
# UI::Stepping moves through it with `ui_up` and `ui_down`.
|
|
9
|
+
#
|
|
10
|
+
# UI::Menu.new(layout: UI::Column.new(item_width: 220, item_height: 44))
|
|
11
|
+
#
|
|
12
|
+
# It takes no `axis:` — a column that is not vertical is a UI::Row.
|
|
13
|
+
class Column < Stack
|
|
14
|
+
def initialize(item_width:, item_height:, spacing: 8)
|
|
15
|
+
super(axis: :vertical, item_width: item_width, item_height: item_height, spacing: spacing)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|