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.
Files changed (161) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +26 -0
  3. data/README.md +406 -0
  4. data/docs/api/README.md +167 -0
  5. data/docs/api/app.md +192 -0
  6. data/docs/api/assets.md +426 -0
  7. data/docs/api/audio.md +208 -0
  8. data/docs/api/components.md +321 -0
  9. data/docs/api/drawing.md +330 -0
  10. data/docs/api/game.md +99 -0
  11. data/docs/api/images.md +118 -0
  12. data/docs/api/input.md +179 -0
  13. data/docs/api/internals.md +110 -0
  14. data/docs/api/scene_graph.md +159 -0
  15. data/docs/api/signals.md +142 -0
  16. data/docs/api/systems.md +98 -0
  17. data/docs/api/text.md +116 -0
  18. data/docs/api/toolbox.md +240 -0
  19. data/docs/api/values.md +101 -0
  20. data/ext/README.md +225 -0
  21. data/ext/rgame_core/app/app.c +721 -0
  22. data/ext/rgame_core/app/app_gl.h +64 -0
  23. data/ext/rgame_core/app/frame_loop.c +42 -0
  24. data/ext/rgame_core/app/frame_loop.h +54 -0
  25. data/ext/rgame_core/audio/audio.c +466 -0
  26. data/ext/rgame_core/audio/audio_internal.h +45 -0
  27. data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
  28. data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
  29. data/ext/rgame_core/example.rb +188 -0
  30. data/ext/rgame_core/extconf.rb +167 -0
  31. data/ext/rgame_core/graphics/backend.c +52 -0
  32. data/ext/rgame_core/graphics/backend.h +64 -0
  33. data/ext/rgame_core/graphics/canvas.c +247 -0
  34. data/ext/rgame_core/graphics/canvas.h +143 -0
  35. data/ext/rgame_core/graphics/clip.c +87 -0
  36. data/ext/rgame_core/graphics/clip.h +89 -0
  37. data/ext/rgame_core/graphics/draw_queue.c +216 -0
  38. data/ext/rgame_core/graphics/draw_queue.h +174 -0
  39. data/ext/rgame_core/graphics/gl_backend.c +122 -0
  40. data/ext/rgame_core/graphics/gl_backend.h +43 -0
  41. data/ext/rgame_core/graphics/image.c +304 -0
  42. data/ext/rgame_core/graphics/image_internal.h +30 -0
  43. data/ext/rgame_core/graphics/primitives.c +189 -0
  44. data/ext/rgame_core/graphics/primitives.h +111 -0
  45. data/ext/rgame_core/graphics/recording.c +119 -0
  46. data/ext/rgame_core/graphics/recording.h +88 -0
  47. data/ext/rgame_core/graphics/texture.c +181 -0
  48. data/ext/rgame_core/graphics/texture.h +165 -0
  49. data/ext/rgame_core/graphics/transform.c +128 -0
  50. data/ext/rgame_core/graphics/transform.h +106 -0
  51. data/ext/rgame_core/include/rgame/core.h +577 -0
  52. data/ext/rgame_core/input/device_slots.c +103 -0
  53. data/ext/rgame_core/input/device_slots.h +93 -0
  54. data/ext/rgame_core/input/gamepad.c +145 -0
  55. data/ext/rgame_core/input/gamepad.h +63 -0
  56. data/ext/rgame_core/input/input.c +109 -0
  57. data/ext/rgame_core/input/input.h +99 -0
  58. data/ext/rgame_core/ruby/audio_ext.c +321 -0
  59. data/ext/rgame_core/ruby/core_ext.c +513 -0
  60. data/ext/rgame_core/ruby/core_ext.h +51 -0
  61. data/ext/rgame_core/ruby/font_ext.c +168 -0
  62. data/ext/rgame_core/ruby/image_ext.c +230 -0
  63. data/ext/rgame_core/ruby/recording_ext.c +186 -0
  64. data/ext/rgame_core/ruby/renderer_ext.c +376 -0
  65. data/ext/rgame_core/text/atlas.c +59 -0
  66. data/ext/rgame_core/text/atlas.h +85 -0
  67. data/ext/rgame_core/text/font.c +281 -0
  68. data/ext/rgame_core/text/font.h +139 -0
  69. data/ext/rgame_core/text/font_atlas.c +385 -0
  70. data/ext/rgame_core/text/font_internal.h +47 -0
  71. data/ext/rgame_core/text/glyph_cache.c +142 -0
  72. data/ext/rgame_core/text/glyph_cache.h +89 -0
  73. data/ext/rgame_core/vendor/README.md +159 -0
  74. data/ext/rgame_core/vendor/miniaudio.h +95864 -0
  75. data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
  76. data/ext/rgame_core/vendor/stb_image.h +7988 -0
  77. data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
  78. data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
  79. data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
  80. data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
  81. data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
  82. data/ext/rgame_util/color.c +19 -0
  83. data/ext/rgame_util/color.h +60 -0
  84. data/ext/rgame_util/color_ext.c +156 -0
  85. data/ext/rgame_util/extconf.rb +27 -0
  86. data/ext/rgame_util/tensor.c +186 -0
  87. data/ext/rgame_util/util_ext.c +27 -0
  88. data/ext/rgame_util/util_ext.h +16 -0
  89. data/lib/rgame/boot.rb +13 -0
  90. data/lib/rgame/core/app.rb +82 -0
  91. data/lib/rgame/core/asset_manager.rb +224 -0
  92. data/lib/rgame/core/audio.rb +124 -0
  93. data/lib/rgame/core/font.rb +49 -0
  94. data/lib/rgame/core/gamepad.rb +55 -0
  95. data/lib/rgame/core/image.rb +55 -0
  96. data/lib/rgame/core/input.rb +77 -0
  97. data/lib/rgame/core/nine_slice.rb +163 -0
  98. data/lib/rgame/core/recording.rb +52 -0
  99. data/lib/rgame/core/renderer.rb +363 -0
  100. data/lib/rgame/core/sprite_sheet.rb +108 -0
  101. data/lib/rgame/core/tile_map_renderer.rb +160 -0
  102. data/lib/rgame/core/ui_atlas.rb +86 -0
  103. data/lib/rgame/core.rb +24 -0
  104. data/lib/rgame/engine/actor.rb +53 -0
  105. data/lib/rgame/engine/animation_set.rb +49 -0
  106. data/lib/rgame/engine/animator.rb +44 -0
  107. data/lib/rgame/engine/audio_bus.rb +24 -0
  108. data/lib/rgame/engine/audio_director.rb +29 -0
  109. data/lib/rgame/engine/body.rb +49 -0
  110. data/lib/rgame/engine/cached_label.rb +33 -0
  111. data/lib/rgame/engine/camera.rb +33 -0
  112. data/lib/rgame/engine/camera_view.rb +28 -0
  113. data/lib/rgame/engine/circle_collider.rb +32 -0
  114. data/lib/rgame/engine/collision_box.rb +34 -0
  115. data/lib/rgame/engine/collision_system.rb +44 -0
  116. data/lib/rgame/engine/component.rb +30 -0
  117. data/lib/rgame/engine/components/action_trigger.rb +41 -0
  118. data/lib/rgame/engine/components/animated_sprite.rb +63 -0
  119. data/lib/rgame/engine/components/character_body.rb +70 -0
  120. data/lib/rgame/engine/components/circle_collider.rb +44 -0
  121. data/lib/rgame/engine/components/collision_world.rb +103 -0
  122. data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
  123. data/lib/rgame/engine/components/path_follow.rb +84 -0
  124. data/lib/rgame/engine/components/player_controller.rb +24 -0
  125. data/lib/rgame/engine/components/pool.rb +53 -0
  126. data/lib/rgame/engine/components/screen_wrap.rb +27 -0
  127. data/lib/rgame/engine/components/sprite.rb +31 -0
  128. data/lib/rgame/engine/components/targeting.rb +54 -0
  129. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  130. data/lib/rgame/engine/components/tile_world.rb +68 -0
  131. data/lib/rgame/engine/components/timer.rb +75 -0
  132. data/lib/rgame/engine/components/velocity.rb +27 -0
  133. data/lib/rgame/engine/components/wander_controller.rb +60 -0
  134. data/lib/rgame/engine/debug_overlay.rb +106 -0
  135. data/lib/rgame/engine/i18n.rb +97 -0
  136. data/lib/rgame/engine/input/action_mapper.rb +46 -0
  137. data/lib/rgame/engine/input/actions.rb +41 -0
  138. data/lib/rgame/engine/input/player_controller.rb +14 -0
  139. data/lib/rgame/engine/matrix.rb +32 -0
  140. data/lib/rgame/engine/node2d.rb +271 -0
  141. data/lib/rgame/engine/path.rb +78 -0
  142. data/lib/rgame/engine/pool.rb +51 -0
  143. data/lib/rgame/engine/resettable.rb +67 -0
  144. data/lib/rgame/engine/scene/scene_stack.rb +65 -0
  145. data/lib/rgame/engine/signal.rb +75 -0
  146. data/lib/rgame/engine/spatial_hash.rb +71 -0
  147. data/lib/rgame/engine/tile_collision.rb +78 -0
  148. data/lib/rgame/engine/tile_map.rb +149 -0
  149. data/lib/rgame/engine/tileset.rb +101 -0
  150. data/lib/rgame/engine/timer.rb +51 -0
  151. data/lib/rgame/engine.rb +68 -0
  152. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  153. data/lib/rgame/fonts/OFL.txt +102 -0
  154. data/lib/rgame/game.rb +129 -0
  155. data/lib/rgame/util/color.rb +27 -0
  156. data/lib/rgame/util/controls.rb +107 -0
  157. data/lib/rgame/util/tensor.rb +12 -0
  158. data/lib/rgame/util.rb +8 -0
  159. data/lib/rgame/version.rb +12 -0
  160. data/lib/rgame.rb +20 -0
  161. 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