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,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RGame
|
|
6
|
+
module Core
|
|
7
|
+
# The one place file-backed assets are loaded and cached.
|
|
8
|
+
#
|
|
9
|
+
# app.assets.image('space.png')
|
|
10
|
+
# app.assets.sheet('example 09/player.json')
|
|
11
|
+
# app.assets.preload(:level1, image: ['lvl1/bg.png'], sound: ['lvl1/hit.ogg'])
|
|
12
|
+
# app.assets.release(:level1)
|
|
13
|
+
#
|
|
14
|
+
# The built-in types are `image`, `sound`, `song` and `read`, plus the
|
|
15
|
+
# composites `sheet` and `ui_atlas`; `glob` lists what is there to load. A game or its glue adds more with
|
|
16
|
+
# {#add_loader}, which is how a tile map gets loaded without Core having to
|
|
17
|
+
# know what one is.
|
|
18
|
+
#
|
|
19
|
+
# Every accessor takes a path **relative to the media root** — or an
|
|
20
|
+
# absolute one, which is used as it stands — and returns the same object
|
|
21
|
+
# every time, so a file asked for twice is read, decoded and uploaded once.
|
|
22
|
+
# Two spellings of one path are one entry, not two. That is the whole point: loading stops being scattered
|
|
23
|
+
# across a game's setup code, building paths ad hoc and constructing images
|
|
24
|
+
# inline, and becomes one object that knows what is loaded.
|
|
25
|
+
#
|
|
26
|
+
# A game does not build one of these. `App#assets` does, rooted at the
|
|
27
|
+
# app's `media_root:`, on first use — see RGame::Core::App.
|
|
28
|
+
#
|
|
29
|
+
# ## Groups, and what `release` frees
|
|
30
|
+
#
|
|
31
|
+
# Each cached asset remembers the **set of groups** that asked for it.
|
|
32
|
+
# Ungrouped loads belong to {PERMANENT} and survive every `release`; only
|
|
33
|
+
# `clear` drops those. A grouped load is reference counted, so an asset two
|
|
34
|
+
# levels both loaded stays until both release it:
|
|
35
|
+
#
|
|
36
|
+
# assets.image('ui/buttons.png') # PERMANENT
|
|
37
|
+
# assets.preload(:level1, image: ['lvl1/bg.png']) # :level1
|
|
38
|
+
# assets.release(:level1) # drops lvl1/bg.png only
|
|
39
|
+
#
|
|
40
|
+
# Releasing drops this cache's reference. The GPU texture goes when the last
|
|
41
|
+
# reference anywhere goes, which is the collector's business, not this
|
|
42
|
+
# class's — see `Image.debug_live_textures` if you need to watch it happen.
|
|
43
|
+
#
|
|
44
|
+
# ## Composites share their parts
|
|
45
|
+
#
|
|
46
|
+
# A sprite sheet is a descriptor plus an image, and both are pulled *through
|
|
47
|
+
# this same cache*. So `assets.sheet('hero.json')` and
|
|
48
|
+
# `assets.image('hero.png')` hand back one upload between them, and the
|
|
49
|
+
# sheet's PNG is released with the sheet's group.
|
|
50
|
+
#
|
|
51
|
+
# ## Loaders are injectable, and that is deliberate
|
|
52
|
+
#
|
|
53
|
+
# Each asset type maps to a proc, and the defaults name `Image`, `Audio` and
|
|
54
|
+
# friends only *inside* their bodies — never at load time. Passing your own
|
|
55
|
+
# `loaders:` is what lets the caching, path resolution and grouping be
|
|
56
|
+
# specced with no window, no GL context and no files at all.
|
|
57
|
+
class AssetManager
|
|
58
|
+
PERMANENT = :__permanent__
|
|
59
|
+
|
|
60
|
+
# `app` is what images are loaded into and where the audio device comes
|
|
61
|
+
# from; `root` is what every path is resolved against.
|
|
62
|
+
def initialize(root:, app:, loaders: nil)
|
|
63
|
+
@root = root
|
|
64
|
+
@app = app
|
|
65
|
+
@loaders = {}
|
|
66
|
+
@cache = {}
|
|
67
|
+
@owners = {}
|
|
68
|
+
|
|
69
|
+
(loaders || default_loaders).each { |type, loader| add_loader(type, &loader) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Teaches this manager a new asset type, and gives it an accessor:
|
|
73
|
+
#
|
|
74
|
+
# assets.add_loader(:tilemap) { |path| ... }
|
|
75
|
+
# assets.tilemap('map/island.tmx')
|
|
76
|
+
#
|
|
77
|
+
# The built-in types go through this too, at construction — there is one
|
|
78
|
+
# mechanism, not a privileged set plus an extension point.
|
|
79
|
+
#
|
|
80
|
+
# It exists because some asset types cannot be built from inside
|
|
81
|
+
# `RGame::Core` at all. A tile map is the case that forced it: parsing a
|
|
82
|
+
# `.tmx` belongs to the engine layer, and Core may not name that layer
|
|
83
|
+
# (see CLAUDE.md, "The rule points both ways"). So the glue installs the
|
|
84
|
+
# loader, and Core never learns what a tile map is.
|
|
85
|
+
#
|
|
86
|
+
# Defining the accessor rather than routing everything through a generic
|
|
87
|
+
# `load(type, path)` keeps `assets.tilemap(path)` reading like the
|
|
88
|
+
# built-ins — and makes `respond_to?(:tilemap)` false until a loader
|
|
89
|
+
# exists, which is exactly what `Renderer#resolve_asset` asks before
|
|
90
|
+
# offering it an id.
|
|
91
|
+
def add_loader(type, &loader)
|
|
92
|
+
@loaders[type] = loader
|
|
93
|
+
define_singleton_method(type) { |path, group = PERMANENT| leaf(type, path, group) }
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The types this manager can load. `:image`, `:sound`, `:song` and `:read`
|
|
98
|
+
# are built in; anything else came from #add_loader.
|
|
99
|
+
def types = @loaders.keys
|
|
100
|
+
|
|
101
|
+
# A sprite sheet, assembled through the cache: its descriptor is a cached
|
|
102
|
+
# `read` and its image a cached `image`, so nothing is loaded twice.
|
|
103
|
+
def sheet(path, group = PERMANENT)
|
|
104
|
+
fetch(:sheet, path, group) { build_sprite_sheet(path, group) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A UI atlas, assembled the same way.
|
|
108
|
+
def ui_atlas(path, group = PERMANENT)
|
|
109
|
+
fetch(:ui_atlas, path, group) { build_ui_atlas(path, group) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Loads a set of assets under one group, so they can be released together:
|
|
113
|
+
#
|
|
114
|
+
# assets.preload(:level1, image: ['lvl1/bg.png'], sheet: ['lvl1/foes.json'])
|
|
115
|
+
def preload(group, **manifest)
|
|
116
|
+
manifest.each do |type, paths|
|
|
117
|
+
unless respond_to?(type)
|
|
118
|
+
raise ArgumentError, "unknown asset type #{type.inspect} in preload(#{group.inspect})"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
Array(paths).each { |path| public_send(type, path, group) }
|
|
122
|
+
end
|
|
123
|
+
self
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Takes `group` off every asset's owner set and drops whatever no group
|
|
127
|
+
# still holds.
|
|
128
|
+
def release(group)
|
|
129
|
+
raise ArgumentError, 'ungrouped assets are dropped by #clear, not #release' if group == PERMANENT
|
|
130
|
+
|
|
131
|
+
@owners.each_value { |groups| groups.delete(group) }
|
|
132
|
+
@owners.reject! do |key, groups|
|
|
133
|
+
next false unless groups.empty?
|
|
134
|
+
|
|
135
|
+
@cache.delete(key)
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
self
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Drops everything, PERMANENT included.
|
|
142
|
+
def clear
|
|
143
|
+
@cache.clear
|
|
144
|
+
@owners.clear
|
|
145
|
+
self
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The paths under the media root that match `pattern`, relative to the
|
|
149
|
+
# root and sorted, so what a caller loads from the list — and the order it
|
|
150
|
+
# merges in — is the same on every platform and file system:
|
|
151
|
+
#
|
|
152
|
+
# assets.glob('locales/**/*.yml') # => ["locales/de.yml", "locales/en.yml"]
|
|
153
|
+
#
|
|
154
|
+
# A directory that does not exist matches nothing, so a game without one
|
|
155
|
+
# gets `[]` rather than an error. An absolute pattern is used as it
|
|
156
|
+
# stands, and its matches come back absolute. Nothing is loaded or cached.
|
|
157
|
+
def glob(pattern) = Dir.glob(pattern, base: File.expand_path(@root)).sort
|
|
158
|
+
|
|
159
|
+
# How many assets are cached. For tests and for a debug overlay; a game
|
|
160
|
+
# has no reason to ask.
|
|
161
|
+
def size = @cache.size
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def default_loaders
|
|
166
|
+
{
|
|
167
|
+
image: ->(path) { Image.new(@app, path) },
|
|
168
|
+
sound: ->(path) { @app.audio.sample(path) },
|
|
169
|
+
song: ->(path) { @app.audio.song(path) },
|
|
170
|
+
read: ->(path) { File.read(path) }
|
|
171
|
+
}.freeze
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def leaf(type, path, group)
|
|
175
|
+
fetch(type, path, group) { @loaders.fetch(type).call(resolve(path)) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def resolve(path) = File.expand_path(path, @root)
|
|
179
|
+
|
|
180
|
+
def fetch(type, path, group)
|
|
181
|
+
key = [type, resolve(path)]
|
|
182
|
+
object = (@cache[key] ||= yield)
|
|
183
|
+
(@owners[key] ||= Set.new) << group
|
|
184
|
+
object
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_sprite_sheet(relative_path, group)
|
|
188
|
+
SpriteSheet.new(*composite_parts(relative_path, group))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def build_ui_atlas(relative_path, group)
|
|
192
|
+
UiAtlas.new(*composite_parts(relative_path, group))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def composite_parts(relative_path, group)
|
|
196
|
+
data = JSON.parse(read(relative_path, group), symbolize_names: true)
|
|
197
|
+
[image(sibling(relative_path, data[:image]), group), data]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def sibling(relative_path, name)
|
|
201
|
+
directory = File.dirname(relative_path)
|
|
202
|
+
directory == '.' ? name : File.join(directory, name)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgame/core_ext'
|
|
4
|
+
|
|
5
|
+
module RGame
|
|
6
|
+
module Core
|
|
7
|
+
# The sound device: one per game, and the thing samples and songs are made
|
|
8
|
+
# from.
|
|
9
|
+
#
|
|
10
|
+
# audio = RGame::Core::Audio.new
|
|
11
|
+
# audio.volume = 0.8
|
|
12
|
+
#
|
|
13
|
+
# It is not tied to a window. Sound outlives a resized or recreated window
|
|
14
|
+
# and has nothing to do with a GL context, so nothing here takes an `app`.
|
|
15
|
+
#
|
|
16
|
+
# A machine with no sound hardware still gets a working `Audio` — it opens a
|
|
17
|
+
# null device and plays silently. That is deliberate: a game should run on a
|
|
18
|
+
# server, in a container, or on a laptop with the sound card disabled, and
|
|
19
|
+
# the alternative is a crash at startup for something nobody asked for.
|
|
20
|
+
# `#backend` says which one was chosen.
|
|
21
|
+
class Audio
|
|
22
|
+
# `assets` is what a path id is resolved through, and may be nil — a
|
|
23
|
+
# device built by hand plays only what it is handed. `RGame::Core::App`
|
|
24
|
+
# passes its own manager, so a game never sets this.
|
|
25
|
+
#
|
|
26
|
+
# A Ruby `self.new` because the C `initialize` takes no arguments and has
|
|
27
|
+
# no business knowing what an asset manager is — the same shape
|
|
28
|
+
# `Renderer.new` uses for the same reason.
|
|
29
|
+
def self.new(assets: nil)
|
|
30
|
+
audio = super()
|
|
31
|
+
audio.assets = assets
|
|
32
|
+
audio
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_accessor :assets
|
|
36
|
+
|
|
37
|
+
# Loads a short sound to play over itself. The same thing as
|
|
38
|
+
# `Sample.new(audio, path)`, and the form to prefer: it reads in the
|
|
39
|
+
# direction the objects depend, and it is the form a stand-in device can
|
|
40
|
+
# implement, which `Sample.new` is not.
|
|
41
|
+
def sample(path) = Sample.new(self, path)
|
|
42
|
+
|
|
43
|
+
# Loads a long one to stream. See {Song}.
|
|
44
|
+
def song(path) = Song.new(self, path)
|
|
45
|
+
|
|
46
|
+
# audio.play_sound(:hit)
|
|
47
|
+
|
|
48
|
+
def register_sound(id, sample)
|
|
49
|
+
samples[id] = sample
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def register_music(id, song)
|
|
54
|
+
songs[id] = song
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Plays a sample by id. Each call is another voice, layered over the ones
|
|
59
|
+
# already sounding.
|
|
60
|
+
def play_sound(id)
|
|
61
|
+
lookup(:sound, id).play
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Starts a song looping, and does **nothing** if it is already playing —
|
|
65
|
+
# so a scene that re-emits the same request every time it is entered never
|
|
66
|
+
# restarts the music mid-loop.
|
|
67
|
+
def play_music(id)
|
|
68
|
+
song = lookup(:song, id)
|
|
69
|
+
return song if song.playing?
|
|
70
|
+
|
|
71
|
+
@playing_song = song
|
|
72
|
+
song.play(looping: true)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Stops the song this registry started.
|
|
76
|
+
#
|
|
77
|
+
# Deliberately not "stop whatever is playing": the layer being replaced
|
|
78
|
+
# reached for a process-wide `current_song`, and there is no such global
|
|
79
|
+
# here by the decision that one-song-at-a-time is a game's policy rather
|
|
80
|
+
# than the engine's. A `Song` a game started by hand is its own to stop.
|
|
81
|
+
def stop_music
|
|
82
|
+
@playing_song&.stop
|
|
83
|
+
@playing_song = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def samples = @samples ||= {}
|
|
89
|
+
def songs = @songs ||= {}
|
|
90
|
+
|
|
91
|
+
def registry(type) = type == :sound ? samples : songs
|
|
92
|
+
|
|
93
|
+
def lookup(type, id)
|
|
94
|
+
raise TypeError, "no implicit conversion of nil into #{type}" if id.nil?
|
|
95
|
+
|
|
96
|
+
table = registry(type)
|
|
97
|
+
table.fetch(id) { table[id] = resolve_asset(type, id) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def resolve_asset(type, id)
|
|
101
|
+
resolved = @assets.public_send(type, id) if id.is_a?(String) && @assets.respond_to?(type)
|
|
102
|
+
resolved || raise(KeyError, "no #{type} registered for #{id.inspect} " \
|
|
103
|
+
'and no AssetManager to resolve it')
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A short sound, decoded once and played many times over.
|
|
108
|
+
#
|
|
109
|
+
# hit = RGame::Core::Sample.new(audio, 'assets/hit.ogg')
|
|
110
|
+
# hit.volume = 0.5
|
|
111
|
+
# hit.play
|
|
112
|
+
# hit.play # layers a second voice over the first
|
|
113
|
+
#
|
|
114
|
+
# Playing a sample that is already sounding starts *another* copy rather
|
|
115
|
+
# than restarting it, which is what makes footsteps and gunfire sound like
|
|
116
|
+
# themselves. There is no handle for a single play and no way to stop one;
|
|
117
|
+
# a sample is fire-and-forget. Volume belongs to the sample and applies to
|
|
118
|
+
# every voice it has out, including the ones already sounding.
|
|
119
|
+
#
|
|
120
|
+
# Ogg Vorbis and WAV are the formats the engine reads. Anything else, or an
|
|
121
|
+
# unreadable file, raises {Sample::LoadError}.
|
|
122
|
+
class Sample
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A long piece of music, streamed from disk rather than decoded up front.
|
|
126
|
+
#
|
|
127
|
+
# music = RGame::Core::Song.new(audio, 'assets/theme.ogg')
|
|
128
|
+
# music.play(looping: true)
|
|
129
|
+
# music.playing? # => true
|
|
130
|
+
# music.stop
|
|
131
|
+
#
|
|
132
|
+
# Unlike a sample, a song is one voice: playing it while it plays restarts
|
|
133
|
+
# it from the beginning. Stopping and playing again also restarts — there is
|
|
134
|
+
# no pause.
|
|
135
|
+
#
|
|
136
|
+
# "Only one song at a time" is a rule a game keeps, not one this class
|
|
137
|
+
# enforces. Two songs can play at once, which is what a crossfade is.
|
|
138
|
+
class Song
|
|
139
|
+
# `looping:` is a keyword here and positional in C, which has no keywords.
|
|
140
|
+
def play(looping: false)
|
|
141
|
+
play_looping(looping)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgame/core_ext'
|
|
4
|
+
|
|
5
|
+
module RGame
|
|
6
|
+
module Core
|
|
7
|
+
# A typeface at one pixel size, with a glyph atlas behind it.
|
|
8
|
+
#
|
|
9
|
+
# font = RGame::Core::Font.new(app, 18)
|
|
10
|
+
# font.height # => 18
|
|
11
|
+
# font.text_width('Score: 1200') # => 78.4
|
|
12
|
+
#
|
|
13
|
+
# renderer.text('Score: 1200', 10, 10, font: font)
|
|
14
|
+
#
|
|
15
|
+
# Two sizes are two fonts. Glyphs are rasterised the first time they are
|
|
16
|
+
# drawn and kept afterwards, so what a font costs is bounded by the
|
|
17
|
+
# characters a game actually uses — a score that changes every frame is free
|
|
18
|
+
# after the first ten digits.
|
|
19
|
+
#
|
|
20
|
+
# Measuring works anywhere; drawing, like everything else, only inside
|
|
21
|
+
# `draw`. That split is deliberate: laying out a menu happens while
|
|
22
|
+
# updating.
|
|
23
|
+
#
|
|
24
|
+
# ## The default font
|
|
25
|
+
#
|
|
26
|
+
# Passing no path uses the one the engine ships — Liberation Sans, which
|
|
27
|
+
# covers Western European languages including `ß`, `ẞ`, accents, `«»` and
|
|
28
|
+
# `€`. There is no font-*name* lookup and no system font database: a font is
|
|
29
|
+
# a file. That is what makes text render identically on every machine, which
|
|
30
|
+
# a system lookup cannot promise.
|
|
31
|
+
#
|
|
32
|
+
# RGame::Core::Font.new(app, 18, path: 'assets/pixel.ttf')
|
|
33
|
+
#
|
|
34
|
+
# Scripts outside the shipped font's coverage — CJK, Arabic, Hebrew — need
|
|
35
|
+
# their own file. No font of this size covers them.
|
|
36
|
+
class Font
|
|
37
|
+
DEFAULT_PATH = File.expand_path('../fonts/LiberationSans-Regular.ttf', __dir__)
|
|
38
|
+
|
|
39
|
+
# `path:` is a keyword for callers but positional for the C initialize,
|
|
40
|
+
# which has no business knowing where a gem installs its data.
|
|
41
|
+
def self.new(app, pixel_height, path: DEFAULT_PATH)
|
|
42
|
+
super(app, pixel_height, path)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgame/core_ext'
|
|
4
|
+
require_relative '../util/controls'
|
|
5
|
+
|
|
6
|
+
module RGame
|
|
7
|
+
module Core
|
|
8
|
+
# Which controllers are plugged in, and what they are called.
|
|
9
|
+
#
|
|
10
|
+
# pads = RGame::Core::Gamepad.new(app)
|
|
11
|
+
# pads.count # => 1
|
|
12
|
+
# pads.connected?(0) # => true
|
|
13
|
+
# pads.name(0) # => "Xbox Controller"
|
|
14
|
+
#
|
|
15
|
+
# This is a readout for menus — "Player 2: connect a controller" — not part
|
|
16
|
+
# of the frame path; reading a *button* goes through RGame::Core::Input.
|
|
17
|
+
#
|
|
18
|
+
# Slots are player slots, stable across a momentary unplug: a pad that falls
|
|
19
|
+
# out and comes back returns to the slot it had, so player 2 stays player 2.
|
|
20
|
+
# The engine reports arrivals and departures through the App's
|
|
21
|
+
# `gamepad_connected` / `gamepad_disconnected` callbacks; this class answers
|
|
22
|
+
# the same question by polling, which is what a menu redraw wants.
|
|
23
|
+
class Gamepad
|
|
24
|
+
Controls = RGame::Util::Controls
|
|
25
|
+
|
|
26
|
+
def initialize(app)
|
|
27
|
+
@app = app
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# How many controllers are currently connected.
|
|
31
|
+
def count = @app.gamepad_count
|
|
32
|
+
|
|
33
|
+
# The number of player slots the engine supports, connected or not — the
|
|
34
|
+
# bound for a "controller setup" screen's loop.
|
|
35
|
+
def max_slots = Controls::MAX_GAMEPADS
|
|
36
|
+
|
|
37
|
+
def connected?(slot) = @app.gamepad_present?(slot)
|
|
38
|
+
|
|
39
|
+
# Human-readable name of the pad in `slot`, or nil when the slot is empty.
|
|
40
|
+
def name(slot) = @app.gamepad_name(slot)
|
|
41
|
+
|
|
42
|
+
# The input device id for a slot, so a menu that just found a pad can hand
|
|
43
|
+
# the right device to Input without knowing how devices are numbered.
|
|
44
|
+
def device(slot) = Controls.gamepad(slot)
|
|
45
|
+
|
|
46
|
+
# Yields [slot, name] for each connected pad, lowest slot first. Allocates
|
|
47
|
+
# nothing, so it is safe to call from a menu that redraws every frame.
|
|
48
|
+
def each_connected
|
|
49
|
+
return enum_for(:each_connected) unless block_given?
|
|
50
|
+
|
|
51
|
+
max_slots.times { |slot| yield slot, name(slot) if connected?(slot) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgame/core_ext'
|
|
4
|
+
|
|
5
|
+
module RGame
|
|
6
|
+
module Core
|
|
7
|
+
# A picture on the GPU, and the sprites cut out of it.
|
|
8
|
+
#
|
|
9
|
+
# img = RGame::Core::Image.new(app, 'hero.png')
|
|
10
|
+
# img.width # => 64
|
|
11
|
+
# frame = img.subimage(0, 0, 16, 16)
|
|
12
|
+
# walk = RGame::Core::Image.load_tiles(app, 'hero.png', 16, 16)
|
|
13
|
+
#
|
|
14
|
+
# Everything below the constructor is a *view*: `subimage`, `tile` and
|
|
15
|
+
# `load_tiles` share the one texture the file was decoded into, so slicing a
|
|
16
|
+
# sheet into a hundred frames costs a hundred small objects and no extra
|
|
17
|
+
# video memory. The texture is released when the last view of it is
|
|
18
|
+
# collected, in whatever order that happens.
|
|
19
|
+
#
|
|
20
|
+
# Images are always sampled nearest-neighbour: this engine draws pixel art,
|
|
21
|
+
# and there is no setting to blur it.
|
|
22
|
+
#
|
|
23
|
+
# The class itself is defined in C (ext/rgame_core/ruby/image_ext.c); what is
|
|
24
|
+
# added here is the sheet-slicing convenience, which is a loop and belongs
|
|
25
|
+
# in Ruby.
|
|
26
|
+
class Image
|
|
27
|
+
# Every whole `tile_width` x `tile_height` tile of an image file, in
|
|
28
|
+
# reading order: left to right, then top to bottom. A partial tile along
|
|
29
|
+
# the right or bottom edge is padding and is skipped.
|
|
30
|
+
#
|
|
31
|
+
# The file is decoded and uploaded exactly once however many tiles come
|
|
32
|
+
# out of it — the returned images are views of that single texture.
|
|
33
|
+
def self.load_tiles(app, path, tile_width, tile_height)
|
|
34
|
+
new(app, path).tiles(tile_width, tile_height)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# This image sliced into whole tiles, as an Array. `load_tiles` is the
|
|
38
|
+
# same thing starting from a path.
|
|
39
|
+
def tiles(tile_width, tile_height)
|
|
40
|
+
Array.new(tile_count(tile_width, tile_height)) do |index|
|
|
41
|
+
tile(tile_width, tile_height, index)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Yields each tile in turn, without building the Array.
|
|
46
|
+
def each_tile(tile_width, tile_height)
|
|
47
|
+
return enum_for(:each_tile, tile_width, tile_height) unless block_given?
|
|
48
|
+
|
|
49
|
+
tile_count(tile_width, tile_height).times do |index|
|
|
50
|
+
yield tile(tile_width, tile_height, index)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgame/core_ext'
|
|
4
|
+
require_relative '../util/controls'
|
|
5
|
+
|
|
6
|
+
module RGame
|
|
7
|
+
module Core
|
|
8
|
+
# Asks the engine whether a physical input is active, on a given device.
|
|
9
|
+
#
|
|
10
|
+
# input = RGame::Core::Input.new(app)
|
|
11
|
+
# input.down?(Controls::KEY_SPACE) # keyboard
|
|
12
|
+
# input.down?(Controls::PAD_A, device: Controls.gamepad(0)) # player 1's pad
|
|
13
|
+
# input.axis(Controls::AXIS_LEFT_X, device: Controls.gamepad(0))
|
|
14
|
+
#
|
|
15
|
+
# **This is the raw query, and deliberately nothing more.** It used to carry
|
|
16
|
+
# binding tables of its own — `down?(:fire)` resolved `:fire` to a scancode
|
|
17
|
+
# through one of three tables passed to the constructor. Those are gone, and
|
|
18
|
+
# binding now lives one layer up in RGame::Engine::InputMap, for two
|
|
19
|
+
# reasons. A game's rebinding screen has to be able to edit the table, and
|
|
20
|
+
# the engine layer may not name RGame::Core at all; and with a player per
|
|
21
|
+
# device, the table is a per-player value rather than a property of the one
|
|
22
|
+
# object that talks to the hardware.
|
|
23
|
+
#
|
|
24
|
+
# What is left is an argument-order adapter over the app's own
|
|
25
|
+
# `input_down?(device, id)` / `input_axis(device, axis_id)`. It stays a named
|
|
26
|
+
# class rather than collapsing into those, because it is what gets handed to
|
|
27
|
+
# the engine layer as an input backend — and handing it a whole App, which
|
|
28
|
+
# can also close the window and rename it, would be a worse seam.
|
|
29
|
+
#
|
|
30
|
+
# Ids come from RGame::Util::Controls. They are values with nothing behind
|
|
31
|
+
# them, so both layers can name one.
|
|
32
|
+
#
|
|
33
|
+
# **There is no pointer or mouse support, by design.** The layer this
|
|
34
|
+
# replaced had a cursor position and a click button riding the same "is
|
|
35
|
+
# held" path as keys, and none of it was carried over: this engine's input
|
|
36
|
+
# is keyboard and controllers. The intended answer for menus is keyboard and
|
|
37
|
+
# controller navigation instead.
|
|
38
|
+
class Input
|
|
39
|
+
Controls = RGame::Util::Controls
|
|
40
|
+
|
|
41
|
+
def initialize(app)
|
|
42
|
+
@app = app
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Is `id` held on `device`?
|
|
46
|
+
#
|
|
47
|
+
# Reads the engine's per-frame input snapshot, so the answer is identical
|
|
48
|
+
# for every simulation tick within one frame — a key held for a single
|
|
49
|
+
# frame behaves the same whether that frame ran one catch-up tick or five.
|
|
50
|
+
#
|
|
51
|
+
# A device only answers for its own kind of input: asking a gamepad about
|
|
52
|
+
# a keyboard scancode is `false`, never the keyboard's answer. That is
|
|
53
|
+
# what lets one binding table list a key and a pad button for the same
|
|
54
|
+
# action and still keep player two's pad from echoing player one.
|
|
55
|
+
# hot-path
|
|
56
|
+
def down?(id, device: Controls::KEYBOARD)
|
|
57
|
+
@app.input_down?(device, id)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Current value of an analog axis: sticks -1.0..1.0, triggers 0.0..1.0.
|
|
61
|
+
# The keyboard has no axes, so it always reads 0.0.
|
|
62
|
+
#
|
|
63
|
+
# No dead zone is applied here — this is the hardware's answer. Ignoring
|
|
64
|
+
# a resting stick's jitter is RGame::Engine::ActionMapper's job.
|
|
65
|
+
# hot-path
|
|
66
|
+
def axis(axis_id, device: Controls::KEYBOARD)
|
|
67
|
+
@app.input_axis(device, axis_id)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgame/core_ext'
|
|
4
|
+
|
|
5
|
+
module RGame
|
|
6
|
+
# `RGame::Core.preferred_locales` is C (`ext/rgame_core/app/locale.c`, bound in
|
|
7
|
+
# `ruby/locale_ext.c`): the user's preferred locales as the operating system
|
|
8
|
+
# reports them through SDL, most preferred first.
|
|
9
|
+
#
|
|
10
|
+
# RGame::Core.preferred_locales # => ["de-AT", "en"]
|
|
11
|
+
#
|
|
12
|
+
# Each is a language, then a hyphen and a country when the OS names one. The
|
|
13
|
+
# list is `[]` when the OS names none — on Linux, under `LANG=C`. It needs no
|
|
14
|
+
# app: SDL reads it without being initialised, so it is a module function
|
|
15
|
+
# rather than an `App` method.
|
|
16
|
+
#
|
|
17
|
+
# What it returns is taken as given. SDL on Linux reads `LANG` and then
|
|
18
|
+
# `LANGUAGE`, and ignores `LC_ALL`, so the list can repeat a locale; choosing
|
|
19
|
+
# among them is `RGame::Engine::I18n.choose`, which the glue calls.
|
|
20
|
+
module Core
|
|
21
|
+
end
|
|
22
|
+
end
|