rgame 0.1.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 +7 -0
- data/LICENSE +26 -0
- data/README.md +406 -0
- data/docs/api/README.md +167 -0
- data/docs/api/app.md +192 -0
- data/docs/api/assets.md +426 -0
- data/docs/api/audio.md +208 -0
- data/docs/api/components.md +321 -0
- data/docs/api/drawing.md +330 -0
- data/docs/api/game.md +99 -0
- data/docs/api/images.md +118 -0
- data/docs/api/input.md +179 -0
- data/docs/api/internals.md +110 -0
- data/docs/api/scene_graph.md +159 -0
- data/docs/api/signals.md +142 -0
- data/docs/api/systems.md +98 -0
- data/docs/api/text.md +116 -0
- data/docs/api/toolbox.md +240 -0
- data/docs/api/values.md +101 -0
- data/ext/README.md +225 -0
- data/ext/rgame_core/app/app.c +721 -0
- data/ext/rgame_core/app/app_gl.h +64 -0
- data/ext/rgame_core/app/frame_loop.c +42 -0
- data/ext/rgame_core/app/frame_loop.h +54 -0
- data/ext/rgame_core/audio/audio.c +466 -0
- data/ext/rgame_core/audio/audio_internal.h +45 -0
- data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
- data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
- data/ext/rgame_core/example.rb +188 -0
- data/ext/rgame_core/extconf.rb +167 -0
- data/ext/rgame_core/graphics/backend.c +52 -0
- data/ext/rgame_core/graphics/backend.h +64 -0
- data/ext/rgame_core/graphics/canvas.c +247 -0
- data/ext/rgame_core/graphics/canvas.h +143 -0
- data/ext/rgame_core/graphics/clip.c +87 -0
- data/ext/rgame_core/graphics/clip.h +89 -0
- data/ext/rgame_core/graphics/draw_queue.c +216 -0
- data/ext/rgame_core/graphics/draw_queue.h +174 -0
- data/ext/rgame_core/graphics/gl_backend.c +122 -0
- data/ext/rgame_core/graphics/gl_backend.h +43 -0
- data/ext/rgame_core/graphics/image.c +304 -0
- data/ext/rgame_core/graphics/image_internal.h +30 -0
- data/ext/rgame_core/graphics/primitives.c +189 -0
- data/ext/rgame_core/graphics/primitives.h +111 -0
- data/ext/rgame_core/graphics/recording.c +119 -0
- data/ext/rgame_core/graphics/recording.h +88 -0
- data/ext/rgame_core/graphics/texture.c +181 -0
- data/ext/rgame_core/graphics/texture.h +165 -0
- data/ext/rgame_core/graphics/transform.c +128 -0
- data/ext/rgame_core/graphics/transform.h +106 -0
- data/ext/rgame_core/include/rgame/core.h +577 -0
- data/ext/rgame_core/input/device_slots.c +103 -0
- data/ext/rgame_core/input/device_slots.h +93 -0
- data/ext/rgame_core/input/gamepad.c +145 -0
- data/ext/rgame_core/input/gamepad.h +63 -0
- data/ext/rgame_core/input/input.c +109 -0
- data/ext/rgame_core/input/input.h +99 -0
- data/ext/rgame_core/ruby/audio_ext.c +321 -0
- data/ext/rgame_core/ruby/core_ext.c +513 -0
- data/ext/rgame_core/ruby/core_ext.h +51 -0
- data/ext/rgame_core/ruby/font_ext.c +168 -0
- data/ext/rgame_core/ruby/image_ext.c +230 -0
- data/ext/rgame_core/ruby/recording_ext.c +186 -0
- data/ext/rgame_core/ruby/renderer_ext.c +376 -0
- data/ext/rgame_core/text/atlas.c +59 -0
- data/ext/rgame_core/text/atlas.h +85 -0
- data/ext/rgame_core/text/font.c +281 -0
- data/ext/rgame_core/text/font.h +139 -0
- data/ext/rgame_core/text/font_atlas.c +385 -0
- data/ext/rgame_core/text/font_internal.h +47 -0
- data/ext/rgame_core/text/glyph_cache.c +142 -0
- data/ext/rgame_core/text/glyph_cache.h +89 -0
- data/ext/rgame_core/vendor/README.md +159 -0
- data/ext/rgame_core/vendor/miniaudio.h +95864 -0
- data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
- data/ext/rgame_core/vendor/stb_image.h +7988 -0
- data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
- data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
- data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
- data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
- data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
- data/ext/rgame_util/color.c +19 -0
- data/ext/rgame_util/color.h +60 -0
- data/ext/rgame_util/color_ext.c +156 -0
- data/ext/rgame_util/extconf.rb +27 -0
- data/ext/rgame_util/tensor.c +186 -0
- data/ext/rgame_util/util_ext.c +27 -0
- data/ext/rgame_util/util_ext.h +16 -0
- data/lib/rgame/boot.rb +13 -0
- data/lib/rgame/core/app.rb +82 -0
- data/lib/rgame/core/asset_manager.rb +224 -0
- data/lib/rgame/core/audio.rb +124 -0
- data/lib/rgame/core/font.rb +49 -0
- data/lib/rgame/core/gamepad.rb +55 -0
- data/lib/rgame/core/image.rb +55 -0
- data/lib/rgame/core/input.rb +77 -0
- data/lib/rgame/core/nine_slice.rb +163 -0
- data/lib/rgame/core/recording.rb +52 -0
- data/lib/rgame/core/renderer.rb +363 -0
- data/lib/rgame/core/sprite_sheet.rb +108 -0
- data/lib/rgame/core/tile_map_renderer.rb +160 -0
- data/lib/rgame/core/ui_atlas.rb +86 -0
- data/lib/rgame/core.rb +24 -0
- data/lib/rgame/engine/actor.rb +53 -0
- data/lib/rgame/engine/animation_set.rb +49 -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 +29 -0
- data/lib/rgame/engine/body.rb +49 -0
- data/lib/rgame/engine/cached_label.rb +33 -0
- data/lib/rgame/engine/camera.rb +33 -0
- data/lib/rgame/engine/camera_view.rb +28 -0
- data/lib/rgame/engine/circle_collider.rb +32 -0
- data/lib/rgame/engine/collision_box.rb +34 -0
- data/lib/rgame/engine/collision_system.rb +44 -0
- data/lib/rgame/engine/component.rb +30 -0
- data/lib/rgame/engine/components/action_trigger.rb +41 -0
- data/lib/rgame/engine/components/animated_sprite.rb +63 -0
- data/lib/rgame/engine/components/character_body.rb +70 -0
- data/lib/rgame/engine/components/circle_collider.rb +44 -0
- data/lib/rgame/engine/components/collision_world.rb +103 -0
- data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
- data/lib/rgame/engine/components/path_follow.rb +84 -0
- data/lib/rgame/engine/components/player_controller.rb +24 -0
- data/lib/rgame/engine/components/pool.rb +53 -0
- data/lib/rgame/engine/components/screen_wrap.rb +27 -0
- data/lib/rgame/engine/components/sprite.rb +31 -0
- data/lib/rgame/engine/components/targeting.rb +54 -0
- data/lib/rgame/engine/components/thrust_controller.rb +65 -0
- data/lib/rgame/engine/components/tile_world.rb +68 -0
- data/lib/rgame/engine/components/timer.rb +75 -0
- data/lib/rgame/engine/components/velocity.rb +27 -0
- data/lib/rgame/engine/components/wander_controller.rb +60 -0
- data/lib/rgame/engine/debug_overlay.rb +106 -0
- data/lib/rgame/engine/i18n.rb +97 -0
- data/lib/rgame/engine/input/action_mapper.rb +46 -0
- data/lib/rgame/engine/input/actions.rb +41 -0
- data/lib/rgame/engine/input/player_controller.rb +14 -0
- data/lib/rgame/engine/matrix.rb +32 -0
- data/lib/rgame/engine/node2d.rb +271 -0
- data/lib/rgame/engine/path.rb +78 -0
- data/lib/rgame/engine/pool.rb +51 -0
- data/lib/rgame/engine/resettable.rb +67 -0
- data/lib/rgame/engine/scene/scene_stack.rb +65 -0
- data/lib/rgame/engine/signal.rb +75 -0
- data/lib/rgame/engine/spatial_hash.rb +71 -0
- data/lib/rgame/engine/tile_collision.rb +78 -0
- data/lib/rgame/engine/tile_map.rb +149 -0
- data/lib/rgame/engine/tileset.rb +101 -0
- data/lib/rgame/engine/timer.rb +51 -0
- data/lib/rgame/engine.rb +68 -0
- data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
- data/lib/rgame/fonts/OFL.txt +102 -0
- data/lib/rgame/game.rb +129 -0
- data/lib/rgame/util/color.rb +27 -0
- data/lib/rgame/util/controls.rb +107 -0
- data/lib/rgame/util/tensor.rb +12 -0
- data/lib/rgame/util.rb +8 -0
- data/lib/rgame/version.rb +12 -0
- data/lib/rgame.rb +20 -0
- metadata +215 -0
|
@@ -0,0 +1,224 @@
|
|
|
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`. 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
|
+
# Owner of every ungrouped load. Never released, only cleared.
|
|
59
|
+
PERMANENT = :__permanent__
|
|
60
|
+
|
|
61
|
+
# `app` is what images are loaded into and where the audio device comes
|
|
62
|
+
# from; `root` is what every path is resolved against.
|
|
63
|
+
def initialize(root:, app:, loaders: nil)
|
|
64
|
+
@root = root
|
|
65
|
+
@app = app
|
|
66
|
+
@loaders = {}
|
|
67
|
+
@cache = {}
|
|
68
|
+
@owners = {} # cache key => Set of groups holding it
|
|
69
|
+
|
|
70
|
+
(loaders || default_loaders).each { |type, loader| add_loader(type, &loader) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Teaches this manager a new asset type, and gives it an accessor:
|
|
74
|
+
#
|
|
75
|
+
# assets.add_loader(:tilemap) { |path| ... }
|
|
76
|
+
# assets.tilemap('map/island.tmx')
|
|
77
|
+
#
|
|
78
|
+
# The built-in types go through this too, at construction — there is one
|
|
79
|
+
# mechanism, not a privileged set plus an extension point.
|
|
80
|
+
#
|
|
81
|
+
# It exists because some asset types cannot be built from inside
|
|
82
|
+
# `RGame::Core` at all. A tile map is the case that forced it: parsing a
|
|
83
|
+
# `.tmx` belongs to the engine layer, and Core may not name that layer
|
|
84
|
+
# (see CLAUDE.md, "The rule points both ways"). So the glue installs the
|
|
85
|
+
# loader, and Core never learns what a tile map is.
|
|
86
|
+
#
|
|
87
|
+
# Defining the accessor rather than routing everything through a generic
|
|
88
|
+
# `load(type, path)` keeps `assets.tilemap(path)` reading like the
|
|
89
|
+
# built-ins — and makes `respond_to?(:tilemap)` false until a loader
|
|
90
|
+
# exists, which is exactly what `Renderer#resolve_asset` asks before
|
|
91
|
+
# offering it an id.
|
|
92
|
+
def add_loader(type, &loader)
|
|
93
|
+
@loaders[type] = loader
|
|
94
|
+
# A singleton method rather than a class-level one: two managers may
|
|
95
|
+
# know different types, and a game that adds `:tilemap` should not be
|
|
96
|
+
# teaching it to everyone else's.
|
|
97
|
+
define_singleton_method(type) { |path, group = PERMANENT| leaf(type, path, group) }
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The types this manager can load. `:image`, `:sound`, `:song` and `:read`
|
|
102
|
+
# are built in; anything else came from #add_loader.
|
|
103
|
+
def types = @loaders.keys
|
|
104
|
+
|
|
105
|
+
# A sprite sheet, assembled through the cache: its descriptor is a cached
|
|
106
|
+
# `read` and its image a cached `image`, so nothing is loaded twice.
|
|
107
|
+
def sheet(path, group = PERMANENT)
|
|
108
|
+
fetch(:sheet, path, group) { build_sprite_sheet(path, group) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A UI atlas, assembled the same way.
|
|
112
|
+
def ui_atlas(path, group = PERMANENT)
|
|
113
|
+
fetch(:ui_atlas, path, group) { build_ui_atlas(path, group) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Loads a set of assets under one group, so they can be released together:
|
|
117
|
+
#
|
|
118
|
+
# assets.preload(:level1, image: ['lvl1/bg.png'], sheet: ['lvl1/foes.json'])
|
|
119
|
+
def preload(group, **manifest)
|
|
120
|
+
manifest.each do |type, paths|
|
|
121
|
+
unless respond_to?(type)
|
|
122
|
+
raise ArgumentError, "unknown asset type #{type.inspect} in preload(#{group.inspect})"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
Array(paths).each { |path| public_send(type, path, group) }
|
|
126
|
+
end
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Takes `group` off every asset's owner set and drops whatever no group
|
|
131
|
+
# still holds.
|
|
132
|
+
def release(group)
|
|
133
|
+
# Without this, `release(PERMANENT)` would empty every ungrouped
|
|
134
|
+
# asset's owner set and drop the lot — the exact opposite of what the
|
|
135
|
+
# constant's name promises, and silent. It is only reachable by naming
|
|
136
|
+
# the sentinel, so saying what to use instead beats ignoring the call.
|
|
137
|
+
raise ArgumentError, 'ungrouped assets are dropped by #clear, not #release' if group == PERMANENT
|
|
138
|
+
|
|
139
|
+
@owners.each_value { |groups| groups.delete(group) }
|
|
140
|
+
@owners.reject! do |key, groups|
|
|
141
|
+
next false unless groups.empty?
|
|
142
|
+
|
|
143
|
+
@cache.delete(key)
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
self
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Drops everything, PERMANENT included.
|
|
150
|
+
def clear
|
|
151
|
+
@cache.clear
|
|
152
|
+
@owners.clear
|
|
153
|
+
self
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# How many assets are cached. For tests and for a debug overlay; a game
|
|
157
|
+
# has no reason to ask.
|
|
158
|
+
def size = @cache.size
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
# The app is reached through the ivar rather than captured, so
|
|
163
|
+
# `app.audio` is not called until a sound is actually asked for — loading
|
|
164
|
+
# an image opens no sound device.
|
|
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
|
+
# Where a path actually is on disk.
|
|
179
|
+
#
|
|
180
|
+
# `expand_path` rather than `join` for two reasons. An **absolute** path
|
|
181
|
+
# is used as it stands, which is what lets a loader hand one back — the
|
|
182
|
+
# tile-map loader gets its tileset image that way, since the path comes
|
|
183
|
+
# out of a `.tsx` that was itself found on disk. And `'a/./b.png'`,
|
|
184
|
+
# `'a/../b.png'` and `'b.png'` all land on one cache key rather than
|
|
185
|
+
# three, so a file cannot be loaded twice by being named twice.
|
|
186
|
+
def resolve(path) = File.expand_path(path, @root)
|
|
187
|
+
|
|
188
|
+
# Memoise on miss, then record the owning group — so a cache *hit* under a
|
|
189
|
+
# new group is tagged too. Tagging after the load rather than before is
|
|
190
|
+
# what leaves a failed load with no owner behind it, which makes a retry a
|
|
191
|
+
# clean retry rather than a permanently half-registered asset.
|
|
192
|
+
def fetch(type, path, group)
|
|
193
|
+
key = [type, resolve(path)]
|
|
194
|
+
object = (@cache[key] ||= yield)
|
|
195
|
+
(@owners[key] ||= Set.new) << group
|
|
196
|
+
object
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def build_sprite_sheet(relative_path, group)
|
|
200
|
+
SpriteSheet.new(*composite_parts(relative_path, group))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def build_ui_atlas(relative_path, group)
|
|
204
|
+
UiAtlas.new(*composite_parts(relative_path, group))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The two halves a composite is made of, both through the cache: the
|
|
208
|
+
# descriptor as a cached `read`, and the image it names as a cached
|
|
209
|
+
# `image` resolved beside it.
|
|
210
|
+
def composite_parts(relative_path, group)
|
|
211
|
+
data = JSON.parse(read(relative_path, group), symbolize_names: true)
|
|
212
|
+
[image(sibling(relative_path, data[:image]), group), data]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# A path next to `relative_path`, still relative to the root — so a
|
|
216
|
+
# descriptor's image lands on the same `[:image, path]` cache key a
|
|
217
|
+
# standalone `image` of that file would.
|
|
218
|
+
def sibling(relative_path, name)
|
|
219
|
+
directory = File.dirname(relative_path)
|
|
220
|
+
directory == '.' ? name : File.join(directory, name)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
# Loads a short sound to play over itself. The same thing as
|
|
23
|
+
# `Sample.new(audio, path)`, and the form to prefer: it reads in the
|
|
24
|
+
# direction the objects depend, and it is the form a stand-in device can
|
|
25
|
+
# implement, which `Sample.new` is not.
|
|
26
|
+
def sample(path) = Sample.new(self, path)
|
|
27
|
+
|
|
28
|
+
# Loads a long one to stream. See {Song}.
|
|
29
|
+
def song(path) = Song.new(self, path)
|
|
30
|
+
|
|
31
|
+
# --- play-by-id -----------------------------------------------------
|
|
32
|
+
#
|
|
33
|
+
# The same boundary the renderer's draw-by-id serves: game logic emits
|
|
34
|
+
# facts — "the ship was hit" — and names the sound, because a scene may
|
|
35
|
+
# not hold a `Sample`. Registration only, unlike the renderer: a sound id
|
|
36
|
+
# is whatever a game wants to call it, and there is no per-frame path to
|
|
37
|
+
# make resolving one worth caching.
|
|
38
|
+
#
|
|
39
|
+
# audio.register_sound(:hit, app.assets.sound('example 09/hurt.ogg'))
|
|
40
|
+
# audio.play_sound(:hit)
|
|
41
|
+
|
|
42
|
+
def register_sound(id, sample)
|
|
43
|
+
samples[id] = sample
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def register_music(id, song)
|
|
48
|
+
songs[id] = song
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Plays a registered sample. Each call is another voice, layered over the
|
|
53
|
+
# ones already sounding.
|
|
54
|
+
def play_sound(id)
|
|
55
|
+
samples.fetch(id).play
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Starts a registered song looping, and does **nothing** if it is already
|
|
59
|
+
# playing — so a scene that re-emits the same request every time it is
|
|
60
|
+
# entered never restarts the music mid-loop.
|
|
61
|
+
def play_music(id)
|
|
62
|
+
song = songs.fetch(id)
|
|
63
|
+
return song if song.playing?
|
|
64
|
+
|
|
65
|
+
@playing_song = song
|
|
66
|
+
song.play(looping: true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Stops the song this registry started.
|
|
70
|
+
#
|
|
71
|
+
# Deliberately not "stop whatever is playing": the layer being replaced
|
|
72
|
+
# reached for a process-wide `current_song`, and there is no such global
|
|
73
|
+
# here by the decision that one-song-at-a-time is a game's policy rather
|
|
74
|
+
# than the engine's. A `Song` a game started by hand is its own to stop.
|
|
75
|
+
def stop_music
|
|
76
|
+
@playing_song&.stop
|
|
77
|
+
@playing_song = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def samples = @samples ||= {}
|
|
83
|
+
def songs = @songs ||= {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A short sound, decoded once and played many times over.
|
|
87
|
+
#
|
|
88
|
+
# hit = RGame::Core::Sample.new(audio, 'assets/hit.ogg')
|
|
89
|
+
# hit.volume = 0.5
|
|
90
|
+
# hit.play
|
|
91
|
+
# hit.play # layers a second voice over the first
|
|
92
|
+
#
|
|
93
|
+
# Playing a sample that is already sounding starts *another* copy rather
|
|
94
|
+
# than restarting it, which is what makes footsteps and gunfire sound like
|
|
95
|
+
# themselves. There is no handle for a single play and no way to stop one;
|
|
96
|
+
# a sample is fire-and-forget. Volume belongs to the sample and applies to
|
|
97
|
+
# every voice it has out, including the ones already sounding.
|
|
98
|
+
#
|
|
99
|
+
# Ogg Vorbis and WAV are the formats the engine reads. Anything else, or an
|
|
100
|
+
# unreadable file, raises {Sample::LoadError}.
|
|
101
|
+
class Sample
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A long piece of music, streamed from disk rather than decoded up front.
|
|
105
|
+
#
|
|
106
|
+
# music = RGame::Core::Song.new(audio, 'assets/theme.ogg')
|
|
107
|
+
# music.play(looping: true)
|
|
108
|
+
# music.playing? # => true
|
|
109
|
+
# music.stop
|
|
110
|
+
#
|
|
111
|
+
# Unlike a sample, a song is one voice: playing it while it plays restarts
|
|
112
|
+
# it from the beginning. Stopping and playing again also restarts — there is
|
|
113
|
+
# no pause.
|
|
114
|
+
#
|
|
115
|
+
# "Only one song at a time" is a rule a game keeps, not one this class
|
|
116
|
+
# enforces. Two songs can play at once, which is what a crossfade is.
|
|
117
|
+
class Song
|
|
118
|
+
# `looping:` is a keyword here and positional in C, which has no keywords.
|
|
119
|
+
def play(looping: false)
|
|
120
|
+
play_looping(looping)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
# Shipped with the gem, alongside its SIL OFL 1.1 licence. Resolved from
|
|
38
|
+
# this file's location so it works the same from a checkout and from an
|
|
39
|
+
# installed gem.
|
|
40
|
+
DEFAULT_PATH = File.expand_path('../fonts/LiberationSans-Regular.ttf', __dir__)
|
|
41
|
+
|
|
42
|
+
# `path:` is a keyword for callers but positional for the C initialize,
|
|
43
|
+
# which has no business knowing where a gem installs its data.
|
|
44
|
+
def self.new(app, pixel_height, path: DEFAULT_PATH)
|
|
45
|
+
super(app, pixel_height, path)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgame/core_ext'
|
|
4
|
+
require_relative '../util/controls'
|
|
5
|
+
|
|
6
|
+
module RGame
|
|
7
|
+
module Core
|
|
8
|
+
# Translates the game's symbolic actions (:fire, :confirm) into the physical
|
|
9
|
+
# button ids the engine understands, and asks the app whether they're held.
|
|
10
|
+
#
|
|
11
|
+
# input = RGame::Core::Input.new(app)
|
|
12
|
+
# input.down?(:fire) # keyboard, the single-player default
|
|
13
|
+
# input.down?(:fire, device: 1) # the pad in player slot 0
|
|
14
|
+
# input.axis(:move_x, device: 1) # => Float, -1.0..1.0
|
|
15
|
+
#
|
|
16
|
+
# The id vocabulary itself lives in RGame::Util::Controls, not here: ids are
|
|
17
|
+
# values, and the engine layer must be able to name one without touching
|
|
18
|
+
# Core. That is also what makes rebinding possible — a game builds its own
|
|
19
|
+
# table from those constants and passes it in:
|
|
20
|
+
#
|
|
21
|
+
# controls = RGame::Util::Controls
|
|
22
|
+
# input = RGame::Core::Input.new(
|
|
23
|
+
# app, bindings: controls::DEFAULT_KEYBOARD.merge(fire: controls::KEY_J)
|
|
24
|
+
# )
|
|
25
|
+
#
|
|
26
|
+
# Devices are numbered keyboard-first: Controls::KEYBOARD is 0, and
|
|
27
|
+
# Controls.gamepad(slot) is the pad in that player slot. Defaulting
|
|
28
|
+
# `device:` to the keyboard is what lets single-player call sites stay
|
|
29
|
+
# `input.down?(:fire)` with no ceremony.
|
|
30
|
+
#
|
|
31
|
+
# **There is no pointer or mouse support, by design.** The layer this
|
|
32
|
+
# replaced had a cursor position and a click button riding the same "is
|
|
33
|
+
# held" path as keys, and none of it was carried over: this engine's input
|
|
34
|
+
# is keyboard and controllers. A game wanting click-based UI has to build
|
|
35
|
+
# hit-testing on top rather than find it here, and the intended answer for
|
|
36
|
+
# menus is keyboard and controller navigation instead.
|
|
37
|
+
class Input
|
|
38
|
+
Controls = RGame::Util::Controls
|
|
39
|
+
|
|
40
|
+
# `bindings` covers the keyboard, `pad_bindings` the controllers, and
|
|
41
|
+
# `axis_bindings` the analog axes. All default to the standard tables.
|
|
42
|
+
def initialize(app,
|
|
43
|
+
bindings: Controls::DEFAULT_KEYBOARD,
|
|
44
|
+
pad_bindings: Controls::DEFAULT_PAD,
|
|
45
|
+
axis_bindings: Controls::DEFAULT_AXES)
|
|
46
|
+
@app = app
|
|
47
|
+
@bindings = bindings
|
|
48
|
+
@pad_bindings = pad_bindings
|
|
49
|
+
@axis_bindings = axis_bindings
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Is the action's physical button held on `device`?
|
|
53
|
+
#
|
|
54
|
+
# Reads the engine's per-frame input snapshot, so the answer is identical
|
|
55
|
+
# for every simulation tick within one frame — a key held for a single
|
|
56
|
+
# frame behaves the same whether that frame ran one catch-up tick or five.
|
|
57
|
+
# hot-path
|
|
58
|
+
def down?(action, device: Controls::KEYBOARD)
|
|
59
|
+
@app.input_down?(device, bindings_for(device).fetch(action))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Current value of an analog axis: sticks -1.0..1.0, triggers 0.0..1.0.
|
|
63
|
+
# The keyboard has no axes, so it always reads 0.0.
|
|
64
|
+
# hot-path
|
|
65
|
+
def axis(action, device: Controls::KEYBOARD)
|
|
66
|
+
@app.input_axis(device, @axis_bindings.fetch(action))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# hot-path
|
|
72
|
+
def bindings_for(device)
|
|
73
|
+
device == Controls::KEYBOARD ? @bindings : @pad_bindings
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|