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,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Core
|
|
5
|
+
# Draws a tile map: the static layers baked once, the animated tiles drawn
|
|
6
|
+
# per frame and culled to the viewport.
|
|
7
|
+
#
|
|
8
|
+
# tiles = RGame::Core::TileMapRenderer.new(map, tileset_images)
|
|
9
|
+
#
|
|
10
|
+
# tiles.draw(renderer, camera_x, camera_y, view_w, view_h, elapsed: seconds)
|
|
11
|
+
# tiles.draw_overlay(renderer, camera_x, camera_y, view_w, view_h, z: 20,
|
|
12
|
+
# elapsed: seconds)
|
|
13
|
+
#
|
|
14
|
+
# ## Two bands, with the actors between them
|
|
15
|
+
#
|
|
16
|
+
# Layers split by the map's own `above_layer?` flag. The **below** band —
|
|
17
|
+
# ground, and detail on the actors' level — is drawn under them by `#draw`;
|
|
18
|
+
# the **above** band — tree canopies, roofs — over them by `#draw_overlay`,
|
|
19
|
+
# at a `z` the scene picks. Two calls rather than one because the scene
|
|
20
|
+
# draws its actors in between, and collapsing them would put every canopy
|
|
21
|
+
# behind every character.
|
|
22
|
+
#
|
|
23
|
+
# ## What is baked and what is not
|
|
24
|
+
#
|
|
25
|
+
# Within each band, every tile that is *not* animated is baked into one
|
|
26
|
+
# recording, the first time that band is drawn. Scrolling a baked layer is
|
|
27
|
+
# then one call per texture however many thousand tiles went into it. The
|
|
28
|
+
# handful that *are* animated are drawn individually each frame, culled to
|
|
29
|
+
# the viewport — a map far larger than the screen costs only what is on it.
|
|
30
|
+
#
|
|
31
|
+
# ## It loads nothing and holds no clock
|
|
32
|
+
#
|
|
33
|
+
# The tiles arrive already sliced, so two maps sharing a tileset share one
|
|
34
|
+
# GPU upload — which is only true if something above pulled the image
|
|
35
|
+
# through the asset manager, and is why this class does not load its own.
|
|
36
|
+
#
|
|
37
|
+
# And `elapsed` is an argument rather than a clock read, so animation is
|
|
38
|
+
# something the caller advances. Pausing is "stop accumulating"; a spec
|
|
39
|
+
# picks the frame it wants. See CLAUDE.md, "`draw` renders state; time
|
|
40
|
+
# enters through `update`".
|
|
41
|
+
#
|
|
42
|
+
# ## What it needs of a map
|
|
43
|
+
#
|
|
44
|
+
# It never names the map's class — the tile map lives a layer *above* this
|
|
45
|
+
# one and Core may not reach up (CLAUDE.md, "The rule points both ways").
|
|
46
|
+
# What it calls is the 'a tile map' contract in
|
|
47
|
+
# `spec/support/shared_examples/`: `layer_count`, `above_layer?`, `width`,
|
|
48
|
+
# `height`, `tile_width`, `tile_height`, `gid`, and a `tileset` answering
|
|
49
|
+
# `local_id`, `animations` and `frame_local_id`.
|
|
50
|
+
class TileMapRenderer
|
|
51
|
+
# The map this was built from. A scene reads it for collision and world
|
|
52
|
+
# bounds, which are its business rather than this class's.
|
|
53
|
+
attr_reader :map
|
|
54
|
+
|
|
55
|
+
# `tiles` is the tileset image sliced into an Array indexed by local tile
|
|
56
|
+
# id — what `Image#tiles` returns.
|
|
57
|
+
def initialize(map, tiles)
|
|
58
|
+
@map = map
|
|
59
|
+
@tileset = map.tileset
|
|
60
|
+
@tiles = tiles
|
|
61
|
+
@animated_below, @animated_above = collect_animated_tiles
|
|
62
|
+
# Baked on first draw, not here: recording needs a live frame, and there
|
|
63
|
+
# is no renderer at construction.
|
|
64
|
+
@static_below = nil
|
|
65
|
+
@static_above = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def draw(renderer, camera_x, camera_y, viewport_width, viewport_height, elapsed: 0.0)
|
|
69
|
+
@static_below ||= bake(renderer) { |layer| !@map.above_layer?(layer) }
|
|
70
|
+
@static_below.draw(-camera_x, -camera_y, z: BELOW_Z)
|
|
71
|
+
draw_animated(renderer, @animated_below, camera_x, camera_y,
|
|
72
|
+
viewport_width, viewport_height, BELOW_Z, elapsed)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def draw_overlay(renderer, camera_x, camera_y, viewport_width, viewport_height,
|
|
76
|
+
z:, elapsed: 0.0)
|
|
77
|
+
@static_above ||= bake(renderer) { |layer| @map.above_layer?(layer) }
|
|
78
|
+
@static_above.draw(-camera_x, -camera_y, z: z)
|
|
79
|
+
draw_animated(renderer, @animated_above, camera_x, camera_y,
|
|
80
|
+
viewport_width, viewport_height, z, elapsed)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The ground band sits at the bottom; the scene chooses where the overlay
|
|
84
|
+
# goes, because only it knows what its actors are drawn at.
|
|
85
|
+
BELOW_Z = 0
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# [col, row, local_id] for every animated tile, split into the two bands.
|
|
90
|
+
# Walked once at construction: a map is thousands of tiles and a handful
|
|
91
|
+
# of animated ones, and finding them again each frame would be the whole
|
|
92
|
+
# cost this class exists to avoid.
|
|
93
|
+
def collect_animated_tiles
|
|
94
|
+
below = []
|
|
95
|
+
above = []
|
|
96
|
+
each_tile do |layer, col, row, local|
|
|
97
|
+
next unless @tileset.animations.key?(local)
|
|
98
|
+
|
|
99
|
+
(@map.above_layer?(layer) ? above : below) << [col, row, local]
|
|
100
|
+
end
|
|
101
|
+
[below, above]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Every non-empty tile of every layer, as [layer, col, row, local_id].
|
|
105
|
+
def each_tile
|
|
106
|
+
@map.layer_count.times do |layer|
|
|
107
|
+
@map.height.times do |row|
|
|
108
|
+
@map.width.times do |col|
|
|
109
|
+
gid = @map.gid(layer, col, row)
|
|
110
|
+
next if gid.zero?
|
|
111
|
+
|
|
112
|
+
yield(layer, col, row, @tileset.local_id(gid))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Bakes the static tiles of the layers the block accepts into one
|
|
119
|
+
# recording, in layer order so a later layer covers an earlier one.
|
|
120
|
+
def bake(renderer)
|
|
121
|
+
renderer.record do
|
|
122
|
+
each_tile do |layer, col, row, local|
|
|
123
|
+
next unless yield(layer)
|
|
124
|
+
next if @tileset.animations.key?(local)
|
|
125
|
+
|
|
126
|
+
renderer.image_at(@tiles[local], col * @map.tile_width, row * @map.tile_height,
|
|
127
|
+
z: BELOW_Z)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def draw_animated(renderer, tiles, camera_x, camera_y, viewport_width, viewport_height,
|
|
133
|
+
z, elapsed)
|
|
134
|
+
tile_width = @map.tile_width
|
|
135
|
+
tile_height = @map.tile_height
|
|
136
|
+
|
|
137
|
+
# Tiled writes frame durations in milliseconds; everything here counts
|
|
138
|
+
# in seconds. Converted once per draw rather than once per tile.
|
|
139
|
+
ms = (elapsed * 1000.0).to_i
|
|
140
|
+
|
|
141
|
+
# fdiv, not `/`. With two Integers — an integer camera position, which
|
|
142
|
+
# is entirely ordinary — `/` floors first and the `.ceil` below becomes
|
|
143
|
+
# a no-op, leaving the last column of tiles undrawn: a one-tile strip of
|
|
144
|
+
# nothing along the right and bottom edges of the screen, and only when
|
|
145
|
+
# the camera happens to be on a whole pixel.
|
|
146
|
+
col_start = camera_x.fdiv(tile_width).floor
|
|
147
|
+
row_start = camera_y.fdiv(tile_height).floor
|
|
148
|
+
col_end = (camera_x + viewport_width).fdiv(tile_width).ceil
|
|
149
|
+
row_end = (camera_y + viewport_height).fdiv(tile_height).ceil
|
|
150
|
+
|
|
151
|
+
tiles.each do |col, row, local|
|
|
152
|
+
next if col < col_start || col >= col_end || row < row_start || row >= row_end
|
|
153
|
+
|
|
154
|
+
renderer.image_at(@tiles[@tileset.frame_local_id(local, ms)],
|
|
155
|
+
(col * tile_width) - camera_x, (row * tile_height) - camera_y, z: z)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
require_relative 'image'
|
|
6
|
+
require_relative 'nine_slice'
|
|
7
|
+
|
|
8
|
+
module RGame
|
|
9
|
+
module Core
|
|
10
|
+
# One sheet of UI chrome, cut into named nine-slices.
|
|
11
|
+
#
|
|
12
|
+
# atlas = RGame::Core::UiAtlas.load(app, 'media/ui.json')
|
|
13
|
+
# renderer.register_ui_atlas(atlas)
|
|
14
|
+
# renderer.nine_slice(:button_idle, x, y, width, height)
|
|
15
|
+
#
|
|
16
|
+
# A button has four states, a panel has one, a scrollbar has three pieces —
|
|
17
|
+
# all of them small, and all of them cheaper as sub-rectangles of a single
|
|
18
|
+
# texture than as a dozen separate files. This is that texture plus a
|
|
19
|
+
# descriptor naming what is where.
|
|
20
|
+
#
|
|
21
|
+
# ## The descriptor
|
|
22
|
+
#
|
|
23
|
+
# {
|
|
24
|
+
# "image": "buttons.png",
|
|
25
|
+
# "scale": 3,
|
|
26
|
+
# "nine_slices": {
|
|
27
|
+
# "button_idle": { "x": 11, "y": 59, "w": 26, "h": 28, "border": 7 },
|
|
28
|
+
# "button_focus": { "x": 43, "y": 59, "w": 26, "h": 28, "border": 7 },
|
|
29
|
+
# "panel": { "x": 0, "y": 0, "w": 32, "h": 32,
|
|
30
|
+
# "border": { "left": 4, "right": 4, "top": 8, "bottom": 4 },
|
|
31
|
+
# "scale": 2 }
|
|
32
|
+
# }
|
|
33
|
+
# }
|
|
34
|
+
#
|
|
35
|
+
# `image` is resolved next to the descriptor. Each entry is a source
|
|
36
|
+
# rectangle plus a border — a uniform integer or one value per side — and an
|
|
37
|
+
# optional `scale` overriding the sheet default. See
|
|
38
|
+
# RGame::Core::NineSlice for what those mean when it is drawn.
|
|
39
|
+
#
|
|
40
|
+
# ## Element names, not filenames
|
|
41
|
+
#
|
|
42
|
+
# `nine_slices` is keyed by whatever the descriptor calls each element, and
|
|
43
|
+
# those names are what a widget asks for. That is why nine-slices are the
|
|
44
|
+
# one asset the renderer resolves by registration only: `:button_focus` is
|
|
45
|
+
# not a file and never can be. `Renderer#register_ui_atlas` registers every
|
|
46
|
+
# element in one call.
|
|
47
|
+
#
|
|
48
|
+
# Parsing happens once, at load. Nothing here is touched again per frame.
|
|
49
|
+
class UiAtlas
|
|
50
|
+
# The elements, by name. Values are `NineSlice`s.
|
|
51
|
+
attr_reader :nine_slices
|
|
52
|
+
|
|
53
|
+
# Loads a descriptor and the sheet beside it.
|
|
54
|
+
#
|
|
55
|
+
# The `AssetManager` uses `.new` instead, with an image it has already
|
|
56
|
+
# cached, so the sheet is shared with any other use of the same file.
|
|
57
|
+
def self.load(app, atlas_path)
|
|
58
|
+
data = JSON.parse(File.read(atlas_path), symbolize_names: true)
|
|
59
|
+
directory = File.dirname(File.expand_path(atlas_path))
|
|
60
|
+
new(Image.new(app, File.join(directory, data[:image])), data)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# `data` is the parsed descriptor; `image` an already-loaded sheet.
|
|
64
|
+
def initialize(image, data)
|
|
65
|
+
sheet_scale = data[:scale] || 1
|
|
66
|
+
|
|
67
|
+
@nine_slices = (data[:nine_slices] || {}).to_h do |id, spec|
|
|
68
|
+
[id, build(image, id, spec, sheet_scale)]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# A descriptor holds many elements, and a mistake in one of them surfaces
|
|
75
|
+
# from inside NineSlice's arithmetic — "undefined method '-' for nil" says
|
|
76
|
+
# nothing about *which* button is wrong. Naming the element is the whole
|
|
77
|
+
# value this wrapper adds, so it catches broadly on purpose.
|
|
78
|
+
def build(image, id, spec, sheet_scale)
|
|
79
|
+
NineSlice.new(image, x: spec[:x], y: spec[:y], w: spec[:w], h: spec[:h],
|
|
80
|
+
border: spec[:border], scale: spec[:scale] || sheet_scale)
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
raise ArgumentError, "ui atlas element #{id.inspect}: #{e.message}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/rgame/core.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RGame::Core — everything that depends on SDL, OpenGL, or anything built on
|
|
4
|
+
# them. Requiring this file loads those libraries into the process, which is why
|
|
5
|
+
# it is NOT required from lib/rgame.rb; ask for it explicitly:
|
|
6
|
+
#
|
|
7
|
+
# require "rgame/core"
|
|
8
|
+
#
|
|
9
|
+
# Its counterpart is RGame::Util (see lib/rgame/util.rb), which stays free of
|
|
10
|
+
# graphics dependencies.
|
|
11
|
+
require_relative 'version'
|
|
12
|
+
require_relative 'core/app'
|
|
13
|
+
require_relative 'core/input'
|
|
14
|
+
require_relative 'core/gamepad'
|
|
15
|
+
require_relative 'core/image'
|
|
16
|
+
require_relative 'core/renderer'
|
|
17
|
+
require_relative 'core/recording'
|
|
18
|
+
require_relative 'core/font'
|
|
19
|
+
require_relative 'core/audio'
|
|
20
|
+
require_relative 'core/sprite_sheet'
|
|
21
|
+
require_relative 'core/nine_slice'
|
|
22
|
+
require_relative 'core/ui_atlas'
|
|
23
|
+
require_relative 'core/tile_map_renderer'
|
|
24
|
+
require_relative 'core/asset_manager'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A character in the world. Composes the pieces it owns — a collision box, an
|
|
6
|
+
# animator, a sprite id (the renderer maps it to a sheet), and a controller that
|
|
7
|
+
# decides movement — rather than baking them in. Pure logic; no graphics.
|
|
8
|
+
#
|
|
9
|
+
# The controller responds to `intent(dt, input) -> [ix, iy]` with each axis in
|
|
10
|
+
# [-1, 1]; PlayerController reads input, AI/scripted controllers ignore it.
|
|
11
|
+
class Actor
|
|
12
|
+
attr_accessor :x, :y
|
|
13
|
+
attr_reader :sprite_id, :collision_box, :animator
|
|
14
|
+
|
|
15
|
+
def initialize(x:, y:, sprite_id:, collision_box:, animator:, controller:, speed: 120.0)
|
|
16
|
+
@x = x
|
|
17
|
+
@y = y
|
|
18
|
+
@sprite_id = sprite_id
|
|
19
|
+
@collision_box = collision_box
|
|
20
|
+
@animator = animator
|
|
21
|
+
@controller = controller
|
|
22
|
+
@speed = speed
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def update(dt, collision_system, input)
|
|
26
|
+
intent_x, intent_y = @controller.intent(dt, input)
|
|
27
|
+
|
|
28
|
+
collision_system.move(self, intent_x * @speed * dt, intent_y * @speed * dt)
|
|
29
|
+
|
|
30
|
+
@animator.play(walk_animation(intent_x, intent_y))
|
|
31
|
+
@animator.update(dt)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# [row, col, flip_x] for the current frame.
|
|
35
|
+
def frame
|
|
36
|
+
@animator.frame
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Facing follows movement intent (so we still "walk" while pushing a wall);
|
|
42
|
+
# horizontal wins over vertical.
|
|
43
|
+
def walk_animation(intent_x, intent_y)
|
|
44
|
+
if intent_x.negative? then :walk_left
|
|
45
|
+
elsif intent_x.positive? then :walk_right
|
|
46
|
+
elsif intent_y.negative? then :walk_up
|
|
47
|
+
elsif intent_y.positive? then :walk_down
|
|
48
|
+
else :stand
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# Pure animation math: given an atlas's animation table and an elapsed time,
|
|
6
|
+
# resolve which sprite-sheet cell (row, col) to show. No renderer, no images —
|
|
7
|
+
# fully testable.
|
|
8
|
+
#
|
|
9
|
+
# animations: { name => { row:, col:, frames:, fps:, flip_x: }, ... }
|
|
10
|
+
# row – sheet row
|
|
11
|
+
# col – starting column (default 0); lets an animation begin mid-row,
|
|
12
|
+
# e.g. a 1-frame "stand" pointing at the neutral middle pose
|
|
13
|
+
# frames – number of frames, advancing across columns from `col`
|
|
14
|
+
# fps – frames per second
|
|
15
|
+
# flip_x – draw mirrored horizontally
|
|
16
|
+
class AnimationSet
|
|
17
|
+
Anim = Struct.new(:row, :first_col, :frames, :frame_secs, :flip_x)
|
|
18
|
+
|
|
19
|
+
def initialize(animations)
|
|
20
|
+
@anims = animations.each_with_object({}) do |(name, a), table|
|
|
21
|
+
table[name.to_sym] = Anim.new(a[:row], a[:col] || 0, a[:frames], 1.0 / a[:fps], a[:flip_x] || false)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Sheet row of `name` (fixed per animation).
|
|
26
|
+
def row(name)
|
|
27
|
+
@anims.fetch(name).row
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Sheet column of `name` at `elapsed` seconds — the only part that animates.
|
|
31
|
+
def col(name, elapsed)
|
|
32
|
+
anim = @anims.fetch(name)
|
|
33
|
+
anim.first_col + ((elapsed / anim.frame_secs).floor % anim.frames)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether `name` draws mirrored (fixed per animation).
|
|
37
|
+
def flip_x(name)
|
|
38
|
+
@anims.fetch(name).flip_x
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# [row, col, flip_x] for the given animation at `elapsed` seconds. A convenience
|
|
42
|
+
# for tests/inspection — it allocates an Array, so the per-frame draw path reads the
|
|
43
|
+
# row/col/flip_x accessors above directly instead.
|
|
44
|
+
def frame(name, elapsed)
|
|
45
|
+
[row(name), col(name, elapsed), flip_x(name)]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# Owns animation playback state for one actor: the AnimationSet plus the current
|
|
6
|
+
# animation name and elapsed time. Pure logic — the renderer asks it for the
|
|
7
|
+
# current [row, col, flip_x] cell. This is what makes an actor own its own
|
|
8
|
+
# animations instead of the scene passing an AnimationSet in every frame.
|
|
9
|
+
class Animator
|
|
10
|
+
def initialize(animation_set, initial: :stand)
|
|
11
|
+
@animation_set = animation_set
|
|
12
|
+
@current = initial
|
|
13
|
+
@elapsed = 0.0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :current, :elapsed
|
|
17
|
+
|
|
18
|
+
# Switch to `name`; restarts elapsed time on an actual change (a no-op if the
|
|
19
|
+
# animation is already playing, so a continuing walk keeps cycling).
|
|
20
|
+
def play(name)
|
|
21
|
+
return if name == @current
|
|
22
|
+
|
|
23
|
+
@current = name
|
|
24
|
+
@elapsed = 0.0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def update(dt)
|
|
28
|
+
@elapsed += dt
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Current animation's row/col/flip_x, resolved without allocating — the per-frame
|
|
32
|
+
# draw path reads these directly.
|
|
33
|
+
def row = @animation_set.row(@current)
|
|
34
|
+
def col = @animation_set.col(@current, @elapsed)
|
|
35
|
+
def flip_x = @animation_set.flip_x(@current)
|
|
36
|
+
|
|
37
|
+
# [row, col, flip_x] for the current animation at the current elapsed time. A
|
|
38
|
+
# convenience for tests/inspection; it allocates, so draw uses the accessors above.
|
|
39
|
+
def frame
|
|
40
|
+
@animation_set.frame(@current, @elapsed)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A global, always-present audio bus: gameplay emits audio *facts* here (decoupled
|
|
6
|
+
# from any actual playback), and an AudioDirector turns them into sound. A module
|
|
7
|
+
# rather than an instance so any node can reach it without wiring.
|
|
8
|
+
module AudioBus
|
|
9
|
+
@on_play_sound = Signal.define(:id).new
|
|
10
|
+
@on_play_music = Signal.define(:id).new
|
|
11
|
+
@on_stop_music = Signal.define.new
|
|
12
|
+
|
|
13
|
+
def self.on_play_sound = @on_play_sound
|
|
14
|
+
def self.on_play_music = @on_play_music
|
|
15
|
+
def self.on_stop_music = @on_stop_music
|
|
16
|
+
|
|
17
|
+
# Emit shims so callers say `AudioBus.play_sound(:boom)` rather than reaching
|
|
18
|
+
# through the signal. The signal is still the subscription seam (AudioDirector).
|
|
19
|
+
def self.play_sound(id) = @on_play_sound.emit(id)
|
|
20
|
+
def self.play_music(id) = @on_play_music.emit(id)
|
|
21
|
+
def self.stop_music = @on_stop_music.emit
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# The registered handler that turns audio *events* into audio *playback*.
|
|
6
|
+
# Gameplay emits facts — `:play_sound`/`:play_music`/`:stop_music` with the asset
|
|
7
|
+
# id as payload — through the EventDispatcher, never touching the backend; this
|
|
8
|
+
# observer forwards each to an injected audio server (anything responding to
|
|
9
|
+
# #play_sound/#play_music/#stop_music: `RGame::Core::Audio` in a game, a recording
|
|
10
|
+
# fake in specs). Pure logic — it names no audio class, and the 'an audio server'
|
|
11
|
+
# contract in spec/support/shared_examples/ is the interface it calls, so a
|
|
12
|
+
# stand-in is checked against what the real device does.
|
|
13
|
+
class AudioDirector
|
|
14
|
+
def initialize(audio)
|
|
15
|
+
@audio = audio
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Subscribe to the audio events on a hub (the global EventDispatcher by default,
|
|
19
|
+
# but any object with #on). Returns self so callers can build-and-subscribe in one
|
|
20
|
+
# line. The director lives for the whole app, so nothing unsubscribes it.
|
|
21
|
+
def subscribe(audio_bus = Engine::AudioBus)
|
|
22
|
+
audio_bus.on_play_sound.connect { @audio.play_sound(it) }
|
|
23
|
+
audio_bus.on_play_music.connect { @audio.play_music(it) }
|
|
24
|
+
audio_bus.on_stop_music.connect { @audio.stop_music }
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# Reusable kinematics for free-moving, rotating entities (ship, rocks, bullets):
|
|
6
|
+
# position, linear velocity, facing angle and angular velocity. `integrate`
|
|
7
|
+
# advances them by a fixed dt; `wrap!` implements toroidal screen-wrap and
|
|
8
|
+
# `offscreen?` the despawn test. Pure logic; no graphics.
|
|
9
|
+
#
|
|
10
|
+
# Angle is in radians, 0 = pointing right (+x), increasing clockwise on screen
|
|
11
|
+
# (since y grows downward). A heading's unit vector is therefore (cos, sin).
|
|
12
|
+
class Body
|
|
13
|
+
attr_accessor :x, :y, :vx, :vy, :angle, :spin
|
|
14
|
+
|
|
15
|
+
def initialize(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0, angle: 0.0, spin: 0.0)
|
|
16
|
+
@x = x
|
|
17
|
+
@y = y
|
|
18
|
+
@vx = vx
|
|
19
|
+
@vy = vy
|
|
20
|
+
@angle = angle
|
|
21
|
+
@spin = spin
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def integrate(dt)
|
|
25
|
+
@x += @vx * dt
|
|
26
|
+
@y += @vy * dt
|
|
27
|
+
@angle += @spin * dt
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Toroidal wrap: once the centre passes `margin` beyond an edge it reappears on
|
|
31
|
+
# the opposite side. Using margin = the entity's radius means an object spawned
|
|
32
|
+
# flush against an edge (centre at -radius) is exactly *at* the threshold, not
|
|
33
|
+
# past it, so it never wraps on the frame it spawns.
|
|
34
|
+
def wrap!(width, height, margin)
|
|
35
|
+
span_x = width + (2 * margin)
|
|
36
|
+
span_y = height + (2 * margin)
|
|
37
|
+
@x += span_x while @x < -margin
|
|
38
|
+
@x -= span_x while @x > width + margin
|
|
39
|
+
@y += span_y while @y < -margin
|
|
40
|
+
@y -= span_y while @y > height + margin
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Fully past an edge by `margin` (e.g. a bullet that left the screen).
|
|
44
|
+
def offscreen?(width, height, margin)
|
|
45
|
+
@x < -margin || @x > width + margin || @y < -margin || @y > height + margin
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# Holds a display string and rebuilds it only when its source value changes, so a
|
|
6
|
+
# per-frame draw can show the cached copy without interpolating (allocating) a String
|
|
7
|
+
# every frame. The format block runs only on an actual change.
|
|
8
|
+
#
|
|
9
|
+
# @score_label = Engine::CachedLabel.new { |score| "Score: #{score}" } # build the block once
|
|
10
|
+
# renderer.text(@score_label[@score], 12, 10) # in on_draw: cached, no alloc
|
|
11
|
+
#
|
|
12
|
+
# `@score_label[value]` returns the cached string when `value` is unchanged, and
|
|
13
|
+
# otherwise rebuilds it through the block. Construct it (and its block) outside the
|
|
14
|
+
# per-frame path — e.g. in `on_add` — so the interpolation isn't itself a per-frame cost.
|
|
15
|
+
class CachedLabel
|
|
16
|
+
def initialize(&format)
|
|
17
|
+
raise ArgumentError, 'CachedLabel needs a format block' unless format
|
|
18
|
+
|
|
19
|
+
@format = format
|
|
20
|
+
@value = nil
|
|
21
|
+
@text = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The cached string for `value`, rebuilt only when `value` differs from last time.
|
|
25
|
+
def [](value)
|
|
26
|
+
return @text if !@text.nil? && value == @value
|
|
27
|
+
|
|
28
|
+
@value = value
|
|
29
|
+
@text = @format.call(value)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A 2D camera: follows a target point and clamps to world bounds so it never
|
|
6
|
+
# shows past the map edges. Pure; the platform applies (x, y) as a draw offset.
|
|
7
|
+
class Camera
|
|
8
|
+
attr_reader :x, :y, :viewport_width, :viewport_height
|
|
9
|
+
|
|
10
|
+
def initialize(viewport_width:, viewport_height:, world_width:, world_height:)
|
|
11
|
+
@viewport_width = viewport_width
|
|
12
|
+
@viewport_height = viewport_height
|
|
13
|
+
@world_width = world_width
|
|
14
|
+
@world_height = world_height
|
|
15
|
+
@x = 0.0
|
|
16
|
+
@y = 0.0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def center_on(world_x, world_y)
|
|
20
|
+
@x = clamp(world_x - @viewport_width / 2.0, @world_width - @viewport_width)
|
|
21
|
+
@y = clamp(world_y - @viewport_height / 2.0, @world_height - @viewport_height)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def clamp(value, max)
|
|
27
|
+
return 0.0 if max <= 0 # world smaller than the viewport → pin to origin
|
|
28
|
+
|
|
29
|
+
value.clamp(0.0, max.to_f)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A node whose children live in *world* space and are drawn through a Camera: it
|
|
6
|
+
# wraps their draw in renderer.translated(-camera.x, -camera.y), so the camera's
|
|
7
|
+
# offset maps the world onto the screen. The children never know about the camera —
|
|
8
|
+
# they draw at their own absolute (world) origin, and the view transform does the
|
|
9
|
+
# rest. The owning scene drives the camera (e.g. centring it on the player); this
|
|
10
|
+
# node only applies it.
|
|
11
|
+
#
|
|
12
|
+
# The offset is a draw-time transform, not a position baked into a node, so the same
|
|
13
|
+
# world can later be drawn through several cameras (split-screen) by repeating the
|
|
14
|
+
# pass under different offsets/clips.
|
|
15
|
+
class CameraView < Node2D
|
|
16
|
+
def initialize(camera:, **)
|
|
17
|
+
super(**)
|
|
18
|
+
@camera = camera
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def draw_children(renderer)
|
|
24
|
+
renderer.translated(-@camera.x, -@camera.y) { super }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A circular collision shape, the sibling of CollisionBox for entities that
|
|
6
|
+
# rotate (rocks, ship): a circle is rotation-invariant, so a spinning rock needs
|
|
7
|
+
# no per-frame box recompute. The collider owns only the radius; the entity owns
|
|
8
|
+
# its centre position. Pure logic; no graphics.
|
|
9
|
+
class CircleCollider
|
|
10
|
+
attr_reader :radius
|
|
11
|
+
|
|
12
|
+
# Squared-distance overlap test (no sqrt): true when two circles touch.
|
|
13
|
+
def self.overlap?(x1, y1, r1, x2, y2, r2)
|
|
14
|
+
dx = x2 - x1
|
|
15
|
+
dy = y2 - y1
|
|
16
|
+
sum = r1 + r2
|
|
17
|
+
(dx * dx) + (dy * dy) <= sum * sum
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(radius:)
|
|
21
|
+
@radius = radius
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Bounding AABB [x, y, w, h] for a circle centred at (cx, cy) — what the
|
|
25
|
+
# spatial hash buckets on.
|
|
26
|
+
def aabb(cx, cy)
|
|
27
|
+
d = @radius * 2
|
|
28
|
+
[cx - @radius, cy - @radius, d, d]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|