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,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Signal
|
|
6
|
+
IDENTIFIER = /\A[a-z_][a-zA-Z0-9_]*\z/
|
|
7
|
+
|
|
8
|
+
# Class-level DSL for declaring signal "slots" so a host class need not hand-write
|
|
9
|
+
# the connect-forwarding boilerplate. `extend Engine::Signal::DSL`, then:
|
|
10
|
+
#
|
|
11
|
+
# signal :on_clicked # a no-arg signal
|
|
12
|
+
# signal :on_changed, ChangeSignal # a typed signal (Signal.define(:index, :value))
|
|
13
|
+
#
|
|
14
|
+
# For :on_clicked this generates:
|
|
15
|
+
# def on_clicked(&block) = on_clicked_signal.connect(&block) # public: subscribe, returns handle
|
|
16
|
+
# def on_clicked_signal = (@on_clicked ||= type.new) # private: the Signal, to emit on
|
|
17
|
+
#
|
|
18
|
+
# The Signal is built lazily on first use, so the host wires nothing in #initialize;
|
|
19
|
+
# emit from inside the host via the private `<name>_signal` reader.
|
|
20
|
+
module DSL
|
|
21
|
+
def signal(name, type = Signal.define)
|
|
22
|
+
ivar = :"@#{name}"
|
|
23
|
+
reader = :"#{name}_signal"
|
|
24
|
+
|
|
25
|
+
define_method(reader) do
|
|
26
|
+
instance_variable_get(ivar) || instance_variable_set(ivar, type.new)
|
|
27
|
+
end
|
|
28
|
+
private reader
|
|
29
|
+
|
|
30
|
+
define_method(name) { |&block| send(reader).connect(&block) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.define(*fields)
|
|
35
|
+
fields.each do |field|
|
|
36
|
+
raise ArgumentError, "invalid field name: #{field.inspect}" unless field.to_s.match?(IDENTIFIER)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
params = fields.size == 1 ? fields.first : fields.map { |f| "#{f}:" }.join(', ')
|
|
40
|
+
args = fields.join(', ')
|
|
41
|
+
|
|
42
|
+
Class.new do
|
|
43
|
+
class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
|
|
44
|
+
# For fields [:x, :y] this generates:
|
|
45
|
+
# def initialize
|
|
46
|
+
# @listeners = []
|
|
47
|
+
# end
|
|
48
|
+
# def connect(&callback)
|
|
49
|
+
# @listeners << callback
|
|
50
|
+
# callback
|
|
51
|
+
# end
|
|
52
|
+
# def disconnect(handle) = @listeners.delete(handle)
|
|
53
|
+
# def emit(x:, y:)
|
|
54
|
+
# @listeners.each { it.call(x, y) }
|
|
55
|
+
# end
|
|
56
|
+
|
|
57
|
+
def initialize
|
|
58
|
+
@listeners = []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def connect(&callback)
|
|
62
|
+
@listeners << callback
|
|
63
|
+
callback
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def disconnect(handle) = @listeners.delete(handle)
|
|
67
|
+
def emit(#{params})
|
|
68
|
+
@listeners.each { it.call(#{args}) }
|
|
69
|
+
end
|
|
70
|
+
RUBY
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A uniform-grid spatial hash for broadphase collision: bucket colliders into
|
|
6
|
+
# fixed-size cells, then test only candidates that share a cell instead of every
|
|
7
|
+
# pair. Pure logic; no graphics.
|
|
8
|
+
#
|
|
9
|
+
# Typical per-frame use: `clear`, `insert` every collider of the static set
|
|
10
|
+
# (here: rocks), then `query` around each moving collider (bullets, the ship).
|
|
11
|
+
#
|
|
12
|
+
# hash.clear
|
|
13
|
+
# rocks.each { |r| hash.insert(r, *r.aabb) }
|
|
14
|
+
# hash.query(*bullet.aabb) { |rock| ...narrowphase... }
|
|
15
|
+
class SpatialHash
|
|
16
|
+
OFFSET = 1 << 13
|
|
17
|
+
STRIDE = 1 << 14
|
|
18
|
+
|
|
19
|
+
def initialize(cell_size:)
|
|
20
|
+
@cell_size = cell_size
|
|
21
|
+
@buckets = Hash.new { |h, key| h[key] = [] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Reuse the bucket arrays across frames (clear contents, keep capacity).
|
|
25
|
+
def clear
|
|
26
|
+
@buckets.each_value(&:clear)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def insert(item, x, y, w, h)
|
|
30
|
+
each_cell(x, y, w, h) { |key| @buckets[key] << item }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Un-bucket an item from the cells the given box covers — the exact inverse of
|
|
34
|
+
# #insert, so the caller passes the box the item was **inserted at**, not where it
|
|
35
|
+
# is now. That is what keeps this stateless: nothing here remembers where anything
|
|
36
|
+
# was bucketed, and a mover that knows where it started can undo its own insert.
|
|
37
|
+
#
|
|
38
|
+
# An item that was never inserted (or a box that covers none of its cells) is a
|
|
39
|
+
# no-op rather than an error — removing something twice, or removing something that
|
|
40
|
+
# left the tree between two steps, is ordinary rather than a mistake.
|
|
41
|
+
#
|
|
42
|
+
# Allocation-free: `fetch` sidesteps the bucket Hash's default block, which would
|
|
43
|
+
# *create* an empty bucket for every cell walked.
|
|
44
|
+
def remove(item, x, y, w, h)
|
|
45
|
+
each_cell(x, y, w, h) do |key|
|
|
46
|
+
bucket = @buckets.fetch(key, nil)
|
|
47
|
+
bucket&.delete(item)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Yield every item whose buckets overlap the region. Dedup contract: an item
|
|
52
|
+
# spanning several cells may be yielded more than once. Narrowphase callers
|
|
53
|
+
# must already guard with `next if a.dead? || b.dead?` to make hits idempotent,
|
|
54
|
+
# so we skip a per-query visited set and stay allocation-free.
|
|
55
|
+
def query(x, y, w, h, &)
|
|
56
|
+
each_cell(x, y, w, h) do |key|
|
|
57
|
+
bucket = @buckets[key]
|
|
58
|
+
bucket.each(&) unless bucket.empty?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Broadphase a circle: yield every item bucketed in a cell the circle's bounding
|
|
63
|
+
# box covers — the radial counterpart to #query, for range/nearest lookups. Same
|
|
64
|
+
# dedup contract (an item may be yielded more than once; the narrowphase caller
|
|
65
|
+
# refines by true distance). Allocation-free.
|
|
66
|
+
def query_circle(cx, cy, r, &)
|
|
67
|
+
d = r * 2
|
|
68
|
+
query(cx - r, cy - r, d, d, &)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Is the one cell containing the point (x, y) holding nothing? A point, not a
|
|
72
|
+
# region: pass any coordinate inside the cell you mean.
|
|
73
|
+
#
|
|
74
|
+
# An item is bucketed by its *bounding box*, and the cell walk is half-open on the
|
|
75
|
+
# far edge (see #last_cell), so "bucketed here" means "overlaps this cell's area"
|
|
76
|
+
# exactly: a box filling one cell fills that cell's bucket and no other, and one
|
|
77
|
+
# covering four cells fills four. So this answers occupancy, not just candidacy —
|
|
78
|
+
# it is true when nothing overlaps the cell at all. What it cannot know is whether
|
|
79
|
+
# an occupant still counts; `CollisionWorld#cell_empty?` adds that, skipping
|
|
80
|
+
# colliders whose node is queued for removal.
|
|
81
|
+
#
|
|
82
|
+
# Reads whatever the most recent inserts left behind: after `clear` every cell is
|
|
83
|
+
# empty until something is inserted again. Allocation-free — `fetch` deliberately
|
|
84
|
+
# sidesteps the bucket Hash's default block, which would *create* the bucket.
|
|
85
|
+
def cell_empty?(x, y)
|
|
86
|
+
bucket = @buckets.fetch(cell_key((x / @cell_size).floor, (y / @cell_size).floor), nil)
|
|
87
|
+
bucket.nil? || bucket.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def cell_key(col, row) = (col + OFFSET) * STRIDE + (row + OFFSET)
|
|
93
|
+
|
|
94
|
+
def each_cell(x, y, w, h)
|
|
95
|
+
col0 = (x / @cell_size).floor
|
|
96
|
+
row0 = (y / @cell_size).floor
|
|
97
|
+
col1 = last_cell(x + w, col0)
|
|
98
|
+
row1 = last_cell(y + h, row0)
|
|
99
|
+
row0.upto(row1) do |row|
|
|
100
|
+
col0.upto(col1) do |col|
|
|
101
|
+
yield cell_key(col, row)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def last_cell(edge, first)
|
|
107
|
+
cell = (edge / @cell_size).floor
|
|
108
|
+
cell -= 1 if (edge % @cell_size).zero?
|
|
109
|
+
# rubocop:disable Style/MinMaxComparison -- the [cell, first].max it asks for is
|
|
110
|
+
# what the project's own Game/NoNeedlessAllocation rejects on a per-frame path.
|
|
111
|
+
cell < first ? first : cell
|
|
112
|
+
# rubocop:enable Style/MinMaxComparison
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rexml/document'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
|
|
6
|
+
require_relative '../util'
|
|
7
|
+
require_relative 'tileset'
|
|
8
|
+
|
|
9
|
+
module RGame
|
|
10
|
+
module Engine
|
|
11
|
+
# An orthogonal tile map parsed from a Tiled .tmx. Holds the per-layer gid
|
|
12
|
+
# arrays and geometry, and answers collision queries via its Tileset.
|
|
13
|
+
#
|
|
14
|
+
# `parse` takes a *string*, so the parsing itself needs no filesystem at all;
|
|
15
|
+
# `load` adds the file plumbing on top — reading the .tmx, following it to the
|
|
16
|
+
# .tsx it names, and working out where the tileset image sits relative to
|
|
17
|
+
# that. Both belong here. What does *not* is the image: a texture is a GPU
|
|
18
|
+
# handle, and the renderer that owns one lives a layer below and may not name
|
|
19
|
+
# this class (see CLAUDE.md, "The rule points both ways"). So `load` hands
|
|
20
|
+
# back a path and stops there.
|
|
21
|
+
class TileMap
|
|
22
|
+
FLIP_MASK = 0x1FFFFFFF
|
|
23
|
+
|
|
24
|
+
attr_reader :width, :height, :tile_width, :tile_height,
|
|
25
|
+
:pixel_width, :pixel_height, :tileset_source, :firstgid
|
|
26
|
+
attr_accessor :tileset
|
|
27
|
+
|
|
28
|
+
# Reads a .tmx and everything it points at, returning `[map, image_path]`.
|
|
29
|
+
#
|
|
30
|
+
# Two values rather than one because they are two kinds of thing: the map is
|
|
31
|
+
# the grid, and the path is where its pixels happen to live. Keeping the
|
|
32
|
+
# second off the map means a stand-in map in a spec has one less method to
|
|
33
|
+
# answer, and the renderer's protocol stays "things about the grid".
|
|
34
|
+
#
|
|
35
|
+
# Every path is resolved relative to the file that named it — the .tsx
|
|
36
|
+
# relative to the .tmx, the image relative to the .tsx — which is what Tiled
|
|
37
|
+
# itself writes and what lets a map be moved as a set.
|
|
38
|
+
def self.load(tmx_path)
|
|
39
|
+
map = parse(File.read(tmx_path))
|
|
40
|
+
|
|
41
|
+
tsx_path = File.join(File.dirname(tmx_path), map.tileset_source)
|
|
42
|
+
map.tileset = Tileset.parse(File.read(tsx_path), firstgid: map.firstgid)
|
|
43
|
+
|
|
44
|
+
[map, File.join(File.dirname(tsx_path), map.tileset.image_source)]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.parse(tmx_string)
|
|
48
|
+
root = REXML::Document.new(tmx_string).root
|
|
49
|
+
tileset_el = root.elements['tileset']
|
|
50
|
+
|
|
51
|
+
layers = []
|
|
52
|
+
above = []
|
|
53
|
+
root.each_element('layer') do |layer_el|
|
|
54
|
+
raw = layer_el.elements['data'].text.strip.unpack1('m')
|
|
55
|
+
gids = Zlib::Inflate.inflate(raw).unpack('V*')
|
|
56
|
+
gids.map! { |g| g & FLIP_MASK }
|
|
57
|
+
layers << gids
|
|
58
|
+
above << layer_flag?(layer_el, 'above')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
new(
|
|
62
|
+
width: root.attributes['width'].to_i,
|
|
63
|
+
height: root.attributes['height'].to_i,
|
|
64
|
+
tile_width: root.attributes['tilewidth'].to_i,
|
|
65
|
+
tile_height: root.attributes['tileheight'].to_i,
|
|
66
|
+
tileset_source: tileset_el.attributes['source'],
|
|
67
|
+
firstgid: tileset_el.attributes['firstgid'].to_i,
|
|
68
|
+
layers: layers,
|
|
69
|
+
above: above
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.layer_flag?(layer_el, name)
|
|
74
|
+
props = layer_el.elements['properties']
|
|
75
|
+
return false unless props
|
|
76
|
+
|
|
77
|
+
props.each_element('property') do |property|
|
|
78
|
+
return property.attributes['value'] == 'true' if property.attributes['name'] == name
|
|
79
|
+
end
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
private_class_method :layer_flag?
|
|
83
|
+
|
|
84
|
+
def initialize(width:, height:, tile_width:, tile_height:, tileset_source:, firstgid:, layers:, above: [])
|
|
85
|
+
@width = width
|
|
86
|
+
@height = height
|
|
87
|
+
@tile_width = tile_width
|
|
88
|
+
@tile_height = tile_height
|
|
89
|
+
@pixel_width = width * tile_width
|
|
90
|
+
@pixel_height = height * tile_height
|
|
91
|
+
@tileset_source = tileset_source
|
|
92
|
+
@firstgid = firstgid
|
|
93
|
+
@above = above
|
|
94
|
+
@tileset = nil
|
|
95
|
+
@tiles = build_tiles(layers)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def layer_count
|
|
99
|
+
@tiles.depth
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Whether a layer is drawn *above* the actors (a tree canopy, roof, etc.) rather
|
|
103
|
+
# than beneath them. Marked in Tiled with a bool layer property `above`; layers
|
|
104
|
+
# without it default to below. Purely a rendering distinction (collision still
|
|
105
|
+
# considers every layer).
|
|
106
|
+
def above_layer?(index)
|
|
107
|
+
@above[index] || false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def in_bounds?(col, row)
|
|
111
|
+
col >= 0 && row >= 0 && col < @width && row < @height
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def gid(layer_index, col, row)
|
|
115
|
+
return 0 unless in_bounds?(col, row)
|
|
116
|
+
|
|
117
|
+
@tiles[col, row, layer_index]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Solid if any layer has a solid tile at (col, row). Out of bounds is not solid
|
|
121
|
+
# — the camera/bounds clamp keeps the player inside the map.
|
|
122
|
+
def solid_tile?(col, row)
|
|
123
|
+
return false unless in_bounds?(col, row)
|
|
124
|
+
|
|
125
|
+
@tiles.depth.times { |layer| return true if @tileset.solid?(@tiles[col, row, layer]) }
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def solid_at?(world_x, world_y)
|
|
130
|
+
solid_tile?((world_x / @tile_width).floor, (world_y / @tile_height).floor)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def build_tiles(layers)
|
|
136
|
+
tiles = Util::Tensor.new(@width, @height, layers.length, initial: 0)
|
|
137
|
+
layers.each_with_index do |gids, layer|
|
|
138
|
+
@height.times do |row|
|
|
139
|
+
base = row * @width
|
|
140
|
+
@width.times { |col| tiles[col, row, layer] = gids[base + col] }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
tiles
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -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
|