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.
Files changed (239) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +251 -0
  3. data/LICENSE +26 -0
  4. data/README.md +325 -0
  5. data/docs/api/README.md +224 -0
  6. data/docs/api/app.md +249 -0
  7. data/docs/api/assets.md +463 -0
  8. data/docs/api/audio.md +249 -0
  9. data/docs/api/cli.md +259 -0
  10. data/docs/api/components.md +1112 -0
  11. data/docs/api/drawing.md +398 -0
  12. data/docs/api/examples.md +263 -0
  13. data/docs/api/game.md +219 -0
  14. data/docs/api/images.md +118 -0
  15. data/docs/api/input.md +476 -0
  16. data/docs/api/internals.md +289 -0
  17. data/docs/api/localization.md +285 -0
  18. data/docs/api/scene_graph.md +539 -0
  19. data/docs/api/signals.md +147 -0
  20. data/docs/api/systems.md +231 -0
  21. data/docs/api/text.md +133 -0
  22. data/docs/api/tile_maps.md +237 -0
  23. data/docs/api/toolbox.md +493 -0
  24. data/docs/api/ui.md +927 -0
  25. data/docs/api/values.md +316 -0
  26. data/examples/assets/README.md +322 -0
  27. data/examples/assets/blip.ogg +0 -0
  28. data/examples/assets/glyphs.json +5 -0
  29. data/examples/assets/glyphs.png +0 -0
  30. data/examples/assets/hero.json +12 -0
  31. data/examples/assets/hero.png +0 -0
  32. data/examples/assets/icons.json +13 -0
  33. data/examples/assets/icons.png +0 -0
  34. data/examples/assets/music.ogg +0 -0
  35. data/examples/assets/skills.json +10 -0
  36. data/examples/assets/skills.png +0 -0
  37. data/examples/assets/tileset.png +0 -0
  38. data/examples/assets/tileset.tsx +65 -0
  39. data/examples/assets/town.tmx +26 -0
  40. data/examples/assets/ui.json +11 -0
  41. data/examples/assets/ui.png +0 -0
  42. data/examples/collision/locales/en.yml +8 -0
  43. data/examples/collision/main.rb +316 -0
  44. data/examples/collision_tiles/locales/en.yml +9 -0
  45. data/examples/collision_tiles/main.rb +274 -0
  46. data/examples/fullscreen/locales/en.yml +10 -0
  47. data/examples/fullscreen/main.rb +216 -0
  48. data/examples/game_menu/locales/en.yml +8 -0
  49. data/examples/game_menu/main.rb +170 -0
  50. data/examples/input_glyphs/locales/en.yml +14 -0
  51. data/examples/input_glyphs/main.rb +213 -0
  52. data/examples/jump_topdown/locales/en.yml +9 -0
  53. data/examples/jump_topdown/main.rb +178 -0
  54. data/examples/localization/locales/de.yml +12 -0
  55. data/examples/localization/locales/en.yml +13 -0
  56. data/examples/localization/main.rb +158 -0
  57. data/examples/menu_navigation/locales/en.yml +23 -0
  58. data/examples/menu_navigation/main.rb +365 -0
  59. data/examples/music/locales/en.yml +7 -0
  60. data/examples/music/main.rb +134 -0
  61. data/examples/pathfinding/locales/en.yml +17 -0
  62. data/examples/pathfinding/main.rb +298 -0
  63. data/examples/pooling/locales/en.yml +7 -0
  64. data/examples/pooling/main.rb +259 -0
  65. data/examples/quick_wheel/locales/en.yml +16 -0
  66. data/examples/quick_wheel/main.rb +184 -0
  67. data/examples/radial_menu/locales/en.yml +16 -0
  68. data/examples/radial_menu/main.rb +184 -0
  69. data/examples/save_load/locales/en.yml +11 -0
  70. data/examples/save_load/main.rb +207 -0
  71. data/examples/save_load_ids/locales/en.yml +11 -0
  72. data/examples/save_load_ids/main.rb +322 -0
  73. data/examples/scroll_map/locales/en.yml +4 -0
  74. data/examples/scroll_map/main.rb +140 -0
  75. data/examples/signals/locales/en.yml +6 -0
  76. data/examples/signals/main.rb +278 -0
  77. data/examples/skill_bar/locales/en.yml +14 -0
  78. data/examples/skill_bar/main.rb +159 -0
  79. data/examples/sound/locales/en.yml +6 -0
  80. data/examples/sound/main.rb +122 -0
  81. data/examples/split_screen/locales/en.yml +9 -0
  82. data/examples/split_screen/main.rb +304 -0
  83. data/examples/sprite/locales/en.yml +8 -0
  84. data/examples/sprite/main.rb +180 -0
  85. data/examples/timer/locales/en.yml +12 -0
  86. data/examples/timer/main.rb +273 -0
  87. data/examples/velocity/locales/en.yml +6 -0
  88. data/examples/velocity/main.rb +196 -0
  89. data/examples/walk/locales/en.yml +4 -0
  90. data/examples/walk/main.rb +99 -0
  91. data/exe/rgame +9 -0
  92. data/lib/rgame/boot.rb +3 -0
  93. data/lib/rgame/cli/new_project.rb +139 -0
  94. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  95. data/lib/rgame/cli/templates/README.md.tt +93 -0
  96. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  97. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  98. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  99. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  100. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  101. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  102. data/lib/rgame/cli/templates/rspec.tt +2 -0
  103. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  104. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  105. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  106. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  107. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  108. data/lib/rgame/cli.rb +66 -0
  109. data/lib/rgame/core/app.rb +46 -0
  110. data/lib/rgame/core/asset_manager.rb +206 -0
  111. data/lib/rgame/core/audio.rb +145 -0
  112. data/lib/rgame/core/font.rb +46 -0
  113. data/lib/rgame/core/gamepad.rb +55 -0
  114. data/lib/rgame/core/image.rb +55 -0
  115. data/lib/rgame/core/input.rb +71 -0
  116. data/lib/rgame/core/locale.rb +22 -0
  117. data/lib/rgame/core/nine_slice.rb +142 -0
  118. data/lib/rgame/core/recording.rb +54 -0
  119. data/lib/rgame/core/renderer.rb +354 -0
  120. data/lib/rgame/core/sprite_sheet.rb +105 -0
  121. data/lib/rgame/core/tile_map_renderer.rb +172 -0
  122. data/lib/rgame/core/ui_atlas.rb +101 -0
  123. data/lib/rgame/core/virtual_gamepad.rb +26 -0
  124. data/lib/rgame/core.rb +18 -0
  125. data/lib/rgame/core_ext.so +0 -0
  126. data/lib/rgame/engine/actor_blockers.rb +131 -0
  127. data/lib/rgame/engine/animation_set.rb +50 -0
  128. data/lib/rgame/engine/animator.rb +44 -0
  129. data/lib/rgame/engine/audio_bus.rb +24 -0
  130. data/lib/rgame/engine/audio_director.rb +59 -0
  131. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  132. data/lib/rgame/engine/camera.rb +78 -0
  133. data/lib/rgame/engine/circle_collider.rb +34 -0
  134. data/lib/rgame/engine/collision_box.rb +59 -0
  135. data/lib/rgame/engine/collision_system.rb +132 -0
  136. data/lib/rgame/engine/component.rb +64 -0
  137. data/lib/rgame/engine/components/action_trigger.rb +40 -0
  138. data/lib/rgame/engine/components/animated_sprite.rb +71 -0
  139. data/lib/rgame/engine/components/box_collider.rb +99 -0
  140. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  141. data/lib/rgame/engine/components/character_body.rb +50 -0
  142. data/lib/rgame/engine/components/circle_collider.rb +80 -0
  143. data/lib/rgame/engine/components/collision_world.rb +231 -0
  144. data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
  145. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  146. data/lib/rgame/engine/components/hop.rb +76 -0
  147. data/lib/rgame/engine/components/identity.rb +73 -0
  148. data/lib/rgame/engine/components/mover.rb +285 -0
  149. data/lib/rgame/engine/components/navigator.rb +145 -0
  150. data/lib/rgame/engine/components/path_follow.rb +176 -0
  151. data/lib/rgame/engine/components/player_controller.rb +27 -0
  152. data/lib/rgame/engine/components/pool.rb +53 -0
  153. data/lib/rgame/engine/components/screen_wrap.rb +49 -0
  154. data/lib/rgame/engine/components/sprite.rb +47 -0
  155. data/lib/rgame/engine/components/targeting.rb +52 -0
  156. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  157. data/lib/rgame/engine/components/tile_world.rb +109 -0
  158. data/lib/rgame/engine/components/timer.rb +75 -0
  159. data/lib/rgame/engine/components/velocity.rb +43 -0
  160. data/lib/rgame/engine/components/wander_controller.rb +64 -0
  161. data/lib/rgame/engine/components/world.rb +133 -0
  162. data/lib/rgame/engine/contact_set.rb +74 -0
  163. data/lib/rgame/engine/culling.rb +45 -0
  164. data/lib/rgame/engine/debug_overlay.rb +111 -0
  165. data/lib/rgame/engine/i18n/plural.rb +45 -0
  166. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  167. data/lib/rgame/engine/i18n/template.rb +59 -0
  168. data/lib/rgame/engine/i18n.rb +322 -0
  169. data/lib/rgame/engine/input/action_mapper.rb +100 -0
  170. data/lib/rgame/engine/input/actions.rb +92 -0
  171. data/lib/rgame/engine/input/input_map.rb +196 -0
  172. data/lib/rgame/engine/layout.rb +82 -0
  173. data/lib/rgame/engine/nav_grid.rb +87 -0
  174. data/lib/rgame/engine/node2d.rb +555 -0
  175. data/lib/rgame/engine/path.rb +76 -0
  176. data/lib/rgame/engine/player.rb +69 -0
  177. data/lib/rgame/engine/player_layer.rb +70 -0
  178. data/lib/rgame/engine/players.rb +205 -0
  179. data/lib/rgame/engine/pool.rb +51 -0
  180. data/lib/rgame/engine/presentation.rb +171 -0
  181. data/lib/rgame/engine/scene/scene_stack.rb +87 -0
  182. data/lib/rgame/engine/sealed_privates.rb +54 -0
  183. data/lib/rgame/engine/signal.rb +75 -0
  184. data/lib/rgame/engine/spatial_hash.rb +116 -0
  185. data/lib/rgame/engine/text.rb +194 -0
  186. data/lib/rgame/engine/tile_blockers.rb +63 -0
  187. data/lib/rgame/engine/tile_map.rb +147 -0
  188. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  189. data/lib/rgame/engine/tileset.rb +99 -0
  190. data/lib/rgame/engine/timer.rb +51 -0
  191. data/lib/rgame/engine/ui/button.rb +248 -0
  192. data/lib/rgame/engine/ui/column.rb +20 -0
  193. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  194. data/lib/rgame/engine/ui/menu.rb +290 -0
  195. data/lib/rgame/engine/ui/navigation.rb +57 -0
  196. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  197. data/lib/rgame/engine/ui/option_button.rb +163 -0
  198. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  199. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  200. data/lib/rgame/engine/ui/pointing.rb +146 -0
  201. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  202. data/lib/rgame/engine/ui/ring.rb +55 -0
  203. data/lib/rgame/engine/ui/row.rb +21 -0
  204. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  205. data/lib/rgame/engine/ui/stack.rb +58 -0
  206. data/lib/rgame/engine/ui/stepping.rb +93 -0
  207. data/lib/rgame/engine/ui/text_button.rb +59 -0
  208. data/lib/rgame/engine/view.rb +76 -0
  209. data/lib/rgame/engine/viewports.rb +171 -0
  210. data/lib/rgame/engine/world_view.rb +71 -0
  211. data/lib/rgame/engine.rb +86 -0
  212. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  213. data/lib/rgame/fonts/OFL.txt +102 -0
  214. data/lib/rgame/game.rb +271 -0
  215. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  216. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  217. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  218. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  219. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  220. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  221. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  222. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  223. data/lib/rgame/rubocop/default.yml +39 -0
  224. data/lib/rgame/rubocop/plugin.rb +45 -0
  225. data/lib/rgame/rubocop.rb +11 -0
  226. data/lib/rgame/util/color.rb +23 -0
  227. data/lib/rgame/util/controls.rb +169 -0
  228. data/lib/rgame/util/route_search.rb +27 -0
  229. data/lib/rgame/util/save_file.rb +107 -0
  230. data/lib/rgame/util/solid_grid.rb +37 -0
  231. data/lib/rgame/util/tensor.rb +3 -0
  232. data/lib/rgame/util/tile_sweep.rb +36 -0
  233. data/lib/rgame/util/z.rb +123 -0
  234. data/lib/rgame/util.rb +10 -0
  235. data/lib/rgame/util_ext.so +0 -0
  236. data/lib/rgame/version.rb +12 -0
  237. data/lib/rgame.rb +5 -0
  238. data/licenses/SDL2/LICENSE.txt +18 -0
  239. 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