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,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A fixed-size 2-D grid backed by a single flat (row-major) array, addressed as
|
|
6
|
+
# [x, y]. The flat backing keeps the whole grid in one contiguous allocation —
|
|
7
|
+
# cheaper than an array-of-arrays and a straightforward shape to later move into a
|
|
8
|
+
# C-level buffer. Pure; no bounds checking on the hot path (callers stay in range).
|
|
9
|
+
#
|
|
10
|
+
# This stays Ruby, unlike the engine's old `Tensor`, which was deleted in
|
|
11
|
+
# favour of the C `RGame::Util::Tensor`. The reason is only that there is no
|
|
12
|
+
# C `Util::Matrix` to swap to — `Util` holds `Tensor`, `Controls` and
|
|
13
|
+
# `Color`. If one is ever written, this is the caller to point at it.
|
|
14
|
+
class Matrix
|
|
15
|
+
attr_reader :width, :height
|
|
16
|
+
|
|
17
|
+
def initialize(width, height, initial: nil)
|
|
18
|
+
@width = width
|
|
19
|
+
@height = height
|
|
20
|
+
@data = Array.new(width * height, initial)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def [](x, y)
|
|
24
|
+
@data[(y * @width) + x]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def []=(x, y, value)
|
|
28
|
+
@data[(y * @width) + x] = value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A node in a scene graph. We currently have only 2D nodes, but the
|
|
6
|
+
# name reflects this should there ever be a 3D space. Nodes are
|
|
7
|
+
# containers for both containers and nodes. They extend the signal
|
|
8
|
+
# DSL to allow for easy signal usage.
|
|
9
|
+
class Node2D
|
|
10
|
+
extend Engine::Signal::DSL
|
|
11
|
+
|
|
12
|
+
attr_accessor :x, :y, :z, :angle, :width, :height, :parent
|
|
13
|
+
attr_writer :scene, :context
|
|
14
|
+
attr_reader :children, :components, :abs_x, :abs_y, :abs_z, :abs_angle
|
|
15
|
+
|
|
16
|
+
def initialize(x: 0, y: 0, z: 0, angle: 0, width: 0, height: 0)
|
|
17
|
+
@x = x
|
|
18
|
+
@y = y
|
|
19
|
+
@z = z
|
|
20
|
+
@angle = angle
|
|
21
|
+
@width = width
|
|
22
|
+
@height = height
|
|
23
|
+
@children = []
|
|
24
|
+
@components = []
|
|
25
|
+
@component_slots = {} # slot (Class by default, or a Symbol name) => component
|
|
26
|
+
@parent = nil
|
|
27
|
+
@scene = nil
|
|
28
|
+
@in_tree = false
|
|
29
|
+
@freed = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add_node(node)
|
|
33
|
+
@children << node
|
|
34
|
+
node.parent = self
|
|
35
|
+
# Defer the entered-tree cascade until this node is itself live; otherwise it
|
|
36
|
+
# fires when an ancestor enters (see #enter_tree). This is the construct-vs-enter
|
|
37
|
+
# split — a node built inside another node's initialize is not yet in the tree.
|
|
38
|
+
node.enter_tree if @in_tree
|
|
39
|
+
node
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def remove_node(node)
|
|
43
|
+
node.exit_tree if @in_tree
|
|
44
|
+
@children.delete(node)
|
|
45
|
+
node.parent = nil
|
|
46
|
+
node
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Look a component up by its slot. A Class/Module is matched by ancestry across every
|
|
50
|
+
# component (so a base class finds a subclass instance); a Symbol names a specific slot
|
|
51
|
+
# (see #add_component's `as:`). A class lookup raises when it's ambiguous — two
|
|
52
|
+
# components share that type — so the caller reaches for the name instead. The scan is
|
|
53
|
+
# allocation-free, so it's safe to call on the per-frame path.
|
|
54
|
+
def get_component(key)
|
|
55
|
+
return @component_slots[key] unless key.is_a?(Module)
|
|
56
|
+
|
|
57
|
+
found = nil
|
|
58
|
+
@components.each do |component|
|
|
59
|
+
next unless component.is_a?(key)
|
|
60
|
+
raise ArgumentError, "Multiple components match #{key}; look one up by name" if found
|
|
61
|
+
|
|
62
|
+
found = component
|
|
63
|
+
end
|
|
64
|
+
found
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Attach a component in a named slot. The slot defaults to the component's class, so a
|
|
68
|
+
# node still holds at most one component per class — until you give them distinct
|
|
69
|
+
# names: `add_component(Timer.new, as: :spawn)` / `add_component(Timer.new, as: :wave)`.
|
|
70
|
+
# A taken slot raises, so an accidental duplicate is still caught.
|
|
71
|
+
def add_component(component, as: nil)
|
|
72
|
+
slot = as || component.class
|
|
73
|
+
raise ArgumentError, "Node already has a component in slot #{slot.inspect}" if @component_slots.key?(slot)
|
|
74
|
+
|
|
75
|
+
@components << component
|
|
76
|
+
@component_slots[slot] = component
|
|
77
|
+
component.node = self
|
|
78
|
+
component.on_attach if @in_tree
|
|
79
|
+
component
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def remove_component(key)
|
|
83
|
+
component = get_component(key)
|
|
84
|
+
return nil unless component
|
|
85
|
+
|
|
86
|
+
component.on_detach if @in_tree
|
|
87
|
+
@components.delete(component)
|
|
88
|
+
@component_slots.delete(@component_slots.key(component))
|
|
89
|
+
component.node = nil
|
|
90
|
+
component
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Anchors, resolved by walking parents so they can never go stale (a cached
|
|
94
|
+
# back-link set at add-time breaks when children are built before the node is
|
|
95
|
+
# in the tree). Shared systems live as components on an anchor node and are
|
|
96
|
+
# reached through these, not threaded through constructors.
|
|
97
|
+
|
|
98
|
+
# The top-most node — a node with no parent is its own root. Global,
|
|
99
|
+
# program-lifetime systems live here as components.
|
|
100
|
+
def root
|
|
101
|
+
@parent ? @parent.root : self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The nearest enclosing scene node, marked as a boundary by SceneStack
|
|
105
|
+
# (#scene= self). Scene-lifetime systems live on it as components.
|
|
106
|
+
def scene
|
|
107
|
+
@scene || @parent&.scene
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def context
|
|
111
|
+
@context ||= root.context
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Nearest system of a class: scene scope first, then the global root.
|
|
115
|
+
def system(klass)
|
|
116
|
+
scene&.get_component(klass) || root.get_component(klass)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# updates input, both from player (readings actions) as well as
|
|
120
|
+
# AI-driven node control. This run first in a game tick
|
|
121
|
+
# Each phase settles this node first (components, then the node's own
|
|
122
|
+
# hook), then descends into children. Self-before-subtree keeps the
|
|
123
|
+
# transform flowing downward: a component or hook that moves this node
|
|
124
|
+
# does so before children resolve their origin from it.
|
|
125
|
+
|
|
126
|
+
def control(actions)
|
|
127
|
+
resolve_origin
|
|
128
|
+
@components.each { it.control(actions) }
|
|
129
|
+
on_control(actions)
|
|
130
|
+
@children.each { it.control(actions) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# update game logic and physics (might become two calls with
|
|
134
|
+
# time, but for now works in one step). This runs second in a
|
|
135
|
+
# game tick
|
|
136
|
+
def update(dt)
|
|
137
|
+
resolve_origin
|
|
138
|
+
@components.each { it.update(dt) }
|
|
139
|
+
on_update(dt)
|
|
140
|
+
@children.each { it.update(dt) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# update visual game state, drawing the node. This runs last in
|
|
144
|
+
# a game tick
|
|
145
|
+
def draw(renderer)
|
|
146
|
+
resolve_origin
|
|
147
|
+
# Draw this node's own visuals oriented by its absolute angle, then descend.
|
|
148
|
+
# Children resolve their own world transform (resolve_origin already baked this
|
|
149
|
+
# node's rotation into their abs_x/abs_y), so they draw in flat world space and
|
|
150
|
+
# must NOT be nested inside this node's rotation — nesting would apply that
|
|
151
|
+
# rotation to them a second time. Unrotated nodes skip the wrapper entirely.
|
|
152
|
+
if abs_angle.zero?
|
|
153
|
+
draw_content(renderer)
|
|
154
|
+
else
|
|
155
|
+
renderer.rotated(abs_angle * 180.0 / Math::PI, abs_x, abs_y) { draw_content(renderer) }
|
|
156
|
+
end
|
|
157
|
+
draw_children(renderer)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def in_tree? = @in_tree
|
|
161
|
+
|
|
162
|
+
# Deferred removal (à la Godot's queue_free): mark this node for removal
|
|
163
|
+
# instead of detaching it now. A node that removes itself or a sibling
|
|
164
|
+
# mid-traversal would mutate the parent's @children while it's being iterated;
|
|
165
|
+
# marking instead and sweeping once after the tick (see #sweep_freed, flushed by
|
|
166
|
+
# the platform loop) keeps removal safe and allocation-free.
|
|
167
|
+
def queue_free = @freed = true
|
|
168
|
+
def freed? = @freed
|
|
169
|
+
|
|
170
|
+
# Detach every node marked by #queue_free, depth-first, from a point outside the
|
|
171
|
+
# update traversal. Components get a hook too, so a container-style component
|
|
172
|
+
# (e.g. SceneStack) can flush the subtree it owns off the normal child list.
|
|
173
|
+
def sweep_freed
|
|
174
|
+
@components.each(&:sweep_freed)
|
|
175
|
+
i = 0
|
|
176
|
+
while i < @children.size
|
|
177
|
+
child = @children[i]
|
|
178
|
+
if child.freed?
|
|
179
|
+
remove_node(child) # detaches + exit_tree; @children shrinks, so don't advance i
|
|
180
|
+
else
|
|
181
|
+
child.sweep_freed
|
|
182
|
+
i += 1
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Entered-tree cascade: anchors (root/scene) and sibling systems are now
|
|
188
|
+
# reachable, so components attach (register with systems) before this node's
|
|
189
|
+
# own on_add, and the whole subtree enters depth-first. The engine fires this
|
|
190
|
+
# — the user never calls it — so registration can't be forgotten. Idempotent.
|
|
191
|
+
def enter_tree
|
|
192
|
+
return if @in_tree
|
|
193
|
+
|
|
194
|
+
@in_tree = true
|
|
195
|
+
@freed = false # revive: a pooled node reacquired after death re-enters here
|
|
196
|
+
@components.each(&:on_attach)
|
|
197
|
+
on_add
|
|
198
|
+
@children.each(&:enter_tree)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Leaving-tree cascade: mirror of #enter_tree (children first, then this
|
|
202
|
+
# node's on_remove, then component on_detach to release registrations).
|
|
203
|
+
def exit_tree
|
|
204
|
+
return unless @in_tree
|
|
205
|
+
|
|
206
|
+
@children.each(&:exit_tree)
|
|
207
|
+
on_remove
|
|
208
|
+
@components.each(&:on_detach)
|
|
209
|
+
@in_tree = false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Lifecycle hooks: Subclasses should implement these instead of
|
|
213
|
+
# overwriting the public interface draw/update/add etc. on_add/on_remove
|
|
214
|
+
# fire when the node enters/leaves the live tree (see #enter_tree), not at
|
|
215
|
+
# construction — so anchors and systems are available inside them.
|
|
216
|
+
|
|
217
|
+
def on_control(actions); end
|
|
218
|
+
def on_update(dt); end
|
|
219
|
+
def on_draw(renderer); end
|
|
220
|
+
def on_add; end
|
|
221
|
+
def on_remove; end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
# This node's own drawing: its components and its draw hook, in that order.
|
|
226
|
+
# Wrapped in renderer.rotated by #draw when the node carries an absolute angle.
|
|
227
|
+
def draw_content(renderer)
|
|
228
|
+
@components.each { it.draw(renderer) }
|
|
229
|
+
on_draw(renderer)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Draw the child subtrees. Its own method so a node can wrap the whole subtree's
|
|
233
|
+
# draw in a transform — e.g. CameraView wraps it in renderer.translated to apply a
|
|
234
|
+
# camera offset, without each child knowing about the camera.
|
|
235
|
+
def draw_children(renderer)
|
|
236
|
+
@children.each { it.draw(renderer) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# TODO: Calculate (and cache?) depth
|
|
240
|
+
# depth = z + highest z of children, maybe +1?
|
|
241
|
+
|
|
242
|
+
# Resolve this node's absolute transform from the parent origin passed down by the
|
|
243
|
+
# traversal. Relative x/y/z/angle accumulate, so a nested Node offsets, re-layers
|
|
244
|
+
# and rotates its whole subtree: a child's local (x, y) is rotated by the parent's
|
|
245
|
+
# accumulated angle before being added to the parent's origin.
|
|
246
|
+
# TODO: Do not recalculate every time, but use a @dirty flag
|
|
247
|
+
# TODO: Handle z better - offset not by Z of the parent, but their
|
|
248
|
+
# depth
|
|
249
|
+
def resolve_origin
|
|
250
|
+
if @parent.nil?
|
|
251
|
+
@abs_x = @abs_y = @abs_z = 0
|
|
252
|
+
@abs_angle = 0 # root pinned to identity, like its position
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
pa = @parent.abs_angle
|
|
257
|
+
if pa.zero? # fast path: parent unrotated -> plain translation, no trig
|
|
258
|
+
@abs_x = @parent.abs_x + @x
|
|
259
|
+
@abs_y = @parent.abs_y + @y
|
|
260
|
+
else
|
|
261
|
+
cos = Math.cos(pa)
|
|
262
|
+
sin = Math.sin(pa)
|
|
263
|
+
@abs_x = @parent.abs_x + (@x * cos) - (@y * sin)
|
|
264
|
+
@abs_y = @parent.abs_y + (@x * sin) + (@y * cos)
|
|
265
|
+
end
|
|
266
|
+
@abs_z = @parent.abs_z + @z
|
|
267
|
+
@abs_angle = pa + @angle
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# An ordered polyline of waypoints an entity walks along — the "road" of a tower
|
|
6
|
+
# defense level. Pure data: it holds the waypoints and the precomputed per-segment
|
|
7
|
+
# lengths, so a follower walking it at runtime allocates nothing.
|
|
8
|
+
#
|
|
9
|
+
# Waypoints are stored flat (x0, y0, x1, y1, …) in one contiguous array rather than a
|
|
10
|
+
# pair-object per point, and read back through scalar accessors (`x_at`/`y_at`), so
|
|
11
|
+
# neither construction shape nor traversal leaks per-waypoint Arrays onto the hot path.
|
|
12
|
+
# A follower (see Components::PathFollow) reads segments by index and interpolates
|
|
13
|
+
# itself; Path never returns a coordinate pair.
|
|
14
|
+
class Path
|
|
15
|
+
attr_reader :length, :count
|
|
16
|
+
|
|
17
|
+
# `points` is an Array of [x, y] waypoint pairs in walk order (construction-time, so
|
|
18
|
+
# the pair Arrays are fine here). At least two are required — a path with one point
|
|
19
|
+
# has nowhere to walk.
|
|
20
|
+
def initialize(points)
|
|
21
|
+
raise ArgumentError, 'a Path needs at least two waypoints' if points.length < 2
|
|
22
|
+
|
|
23
|
+
@coords = points.flatten.freeze
|
|
24
|
+
@count = points.length
|
|
25
|
+
@segment_lengths, @length = build_segments
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# World coordinates of waypoint `i` (0-based), as scalars (no allocation).
|
|
29
|
+
def x_at(index) = @coords[index * 2]
|
|
30
|
+
def y_at(index) = @coords[(index * 2) + 1]
|
|
31
|
+
|
|
32
|
+
# Length of the segment from waypoint `i` to waypoint `i + 1`. There are
|
|
33
|
+
# `count - 1` segments, indexed 0..count-2.
|
|
34
|
+
def segment_length(index) = @segment_lengths[index]
|
|
35
|
+
|
|
36
|
+
# Shortest distance from the point (x, y) to the polyline — e.g. how far a spot is
|
|
37
|
+
# from the road, so a tower-defense level can mask placement cells that sit on it.
|
|
38
|
+
# Pure scalar maths, allocation-free.
|
|
39
|
+
def distance_to(x, y)
|
|
40
|
+
min = Float::INFINITY
|
|
41
|
+
(@count - 1).times do |i|
|
|
42
|
+
d = segment_distance(x, y, x_at(i), y_at(i), x_at(i + 1), y_at(i + 1))
|
|
43
|
+
min = d if d < min
|
|
44
|
+
end
|
|
45
|
+
min
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Distance from (px, py) to the segment (ax, ay)-(bx, by): project the point onto the
|
|
51
|
+
# segment, clamp the projection to the segment's ends, and measure to that foot.
|
|
52
|
+
def segment_distance(px, py, ax, ay, bx, by)
|
|
53
|
+
abx = bx - ax
|
|
54
|
+
aby = by - ay
|
|
55
|
+
len2 = (abx * abx) + (aby * aby)
|
|
56
|
+
t = len2.zero? ? 0.0 : (((px - ax) * abx) + ((py - ay) * aby)) / len2
|
|
57
|
+
t = 0.0 if t < 0.0
|
|
58
|
+
t = 1.0 if t > 1.0
|
|
59
|
+
dx = px - (ax + (t * abx))
|
|
60
|
+
dy = py - (ay + (t * aby))
|
|
61
|
+
Math.sqrt((dx * dx) + (dy * dy))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_segments
|
|
65
|
+
lengths = []
|
|
66
|
+
total = 0.0
|
|
67
|
+
(@count - 1).times do |i|
|
|
68
|
+
dx = x_at(i + 1) - x_at(i)
|
|
69
|
+
dy = y_at(i + 1) - y_at(i)
|
|
70
|
+
len = Math.sqrt((dx * dx) + (dy * dy))
|
|
71
|
+
lengths << len
|
|
72
|
+
total += len
|
|
73
|
+
end
|
|
74
|
+
[lengths.freeze, total]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A reuse-don't-allocate pool for many short-lived, homogeneous objects —
|
|
6
|
+
# bullets, particles, transient enemies. Acquired objects come from a free list
|
|
7
|
+
# (or the factory when the list is empty) so steady-state spawning allocates
|
|
8
|
+
# nothing. Pure logic; no graphics.
|
|
9
|
+
#
|
|
10
|
+
# pool = Engine::Pool.new { Bullet.new }
|
|
11
|
+
# b = pool.acquire # recycled or freshly built
|
|
12
|
+
# pool.each { |b| b.update(dt) }
|
|
13
|
+
# pool.reclaim_if(&:dead?) # sweep dead → free list, once per frame
|
|
14
|
+
#
|
|
15
|
+
# The factory builds a blank object; callers re-initialise it after `acquire`.
|
|
16
|
+
class Pool
|
|
17
|
+
def initialize(&factory)
|
|
18
|
+
@factory = factory
|
|
19
|
+
@free = []
|
|
20
|
+
@active = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :active
|
|
24
|
+
|
|
25
|
+
def acquire
|
|
26
|
+
obj = @free.empty? ? @factory.call : @free.pop
|
|
27
|
+
@active.push(obj)
|
|
28
|
+
obj
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def each(&) = @active.each(&)
|
|
32
|
+
|
|
33
|
+
def size = @active.size
|
|
34
|
+
|
|
35
|
+
# No live (acquired, not-yet-reclaimed) objects.
|
|
36
|
+
def empty? = @active.empty?
|
|
37
|
+
|
|
38
|
+
# Deferred removal: sweep the active list once, moving every object for which
|
|
39
|
+
# the block returns true onto the free list. Safe to call after iterating with
|
|
40
|
+
# `each` — never mutate the active list mid-iteration; mark objects dead during
|
|
41
|
+
# the frame and reclaim them here.
|
|
42
|
+
def reclaim_if
|
|
43
|
+
@active.reject! do |obj|
|
|
44
|
+
dead = yield(obj)
|
|
45
|
+
@free.push(obj) if dead
|
|
46
|
+
dead
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# Builds value-object classes for pooling. Like `Data.define`, instances expose
|
|
6
|
+
# read-only accessors and carry their fields as a unit — but where a `Data` value
|
|
7
|
+
# is fully immutable (every change means a fresh allocation), these add exactly
|
|
8
|
+
# one mutation: `reset`, which overwrites all fields at once and returns self.
|
|
9
|
+
#
|
|
10
|
+
# That is the only mutability a pool needs: acquire a recycled instance and
|
|
11
|
+
# `reset` it, without exposing the per-field setters a `Struct` would. The methods
|
|
12
|
+
# are generated fixed-arity with direct ivar assignment (as Struct/Data do), so
|
|
13
|
+
# `reset` is allocation-free and recycling stays zero-allocation in steady state.
|
|
14
|
+
#
|
|
15
|
+
# Positional by default; pass `keyword_init: true` for keyword fields. Keywords
|
|
16
|
+
# are *also* allocation-free here because they are generated as named parameters
|
|
17
|
+
# (`reset(x:, y:)`), not a `**kwargs` splat — the splat is the one form that builds
|
|
18
|
+
# a Hash per call.
|
|
19
|
+
#
|
|
20
|
+
# Point = Engine::Resettable.define(:x, :y)
|
|
21
|
+
# p = Point.new(3, 4); p.x # => 3
|
|
22
|
+
# p.reset(5, 6) # same object, new values; returns p
|
|
23
|
+
#
|
|
24
|
+
# Event = Engine::Resettable.define(:type, :payload, keyword_init: true)
|
|
25
|
+
# e = Event.new(type: :hit, payload: 10)
|
|
26
|
+
# e.reset(type: :lose, payload: nil) # same object; returns e
|
|
27
|
+
# e.respond_to?(:type=) # => false (no setters)
|
|
28
|
+
#
|
|
29
|
+
# Field names are generated into real methods, so they must be plain identifiers.
|
|
30
|
+
module Resettable
|
|
31
|
+
IDENTIFIER = /\A[a-z_][a-zA-Z0-9_]*\z/
|
|
32
|
+
|
|
33
|
+
def self.define(*fields, keyword_init: false)
|
|
34
|
+
fields.each do |field|
|
|
35
|
+
raise ArgumentError, "invalid field name: #{field.inspect}" unless field.to_s.match?(IDENTIFIER)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
params = fields.map { |f| keyword_init ? "#{f}:" : f.to_s }.join(', ')
|
|
39
|
+
assign = fields.map { |f| "@#{f} = #{f}" }.join("\n")
|
|
40
|
+
|
|
41
|
+
Class.new do
|
|
42
|
+
class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
|
|
43
|
+
# For fields [:x, :y] this generates (keyword_init makes `x, y` -> `x:, y:`):
|
|
44
|
+
# attr_reader :x, :y
|
|
45
|
+
# def initialize(x, y)
|
|
46
|
+
# @x = x; @y = y
|
|
47
|
+
# end
|
|
48
|
+
# def reset(x, y)
|
|
49
|
+
# @x = x; @y = y
|
|
50
|
+
# self
|
|
51
|
+
# end
|
|
52
|
+
attr_reader #{fields.map { |f| ":#{f}" }.join(', ')}
|
|
53
|
+
|
|
54
|
+
def initialize(#{params})
|
|
55
|
+
#{assign}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reset(#{params})
|
|
59
|
+
#{assign}
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
RUBY
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Scene
|
|
6
|
+
class SceneStack < Engine::Component
|
|
7
|
+
def initialize
|
|
8
|
+
super
|
|
9
|
+
@stack = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def push(scene)
|
|
13
|
+
@stack.push(scene)
|
|
14
|
+
scene.parent = node # so scene.root resolves up to the host
|
|
15
|
+
scene.scene = scene # mark the scene as its subtree's scene boundary
|
|
16
|
+
scene.enter_tree # cascades on_attach/on_add through the scene
|
|
17
|
+
self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pop
|
|
21
|
+
scene = @stack.pop
|
|
22
|
+
return self unless scene
|
|
23
|
+
|
|
24
|
+
scene.exit_tree # cascades on_remove/on_detach, releasing systems
|
|
25
|
+
scene.scene = nil
|
|
26
|
+
scene.parent = nil
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def replace(scene)
|
|
31
|
+
pop
|
|
32
|
+
push(scene)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def current
|
|
36
|
+
@stack.last
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def control(actions)
|
|
40
|
+
return unless (current_scene = current)
|
|
41
|
+
|
|
42
|
+
current_scene.control(actions)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def update(dt)
|
|
46
|
+
return unless (current_scene = current)
|
|
47
|
+
|
|
48
|
+
current_scene.update(dt)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def draw(renderer)
|
|
52
|
+
@stack.each do |scene|
|
|
53
|
+
scene.draw(renderer)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Scenes live in @stack, off the host's child list, so the host's #sweep_freed
|
|
58
|
+
# can't reach them — forward the sweep into the active scene's subtree.
|
|
59
|
+
def sweep_freed
|
|
60
|
+
current&.sweep_freed
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Signal
|
|
6
|
+
IDENTIFIER = /\A[a-z_][a-zA-Z0-9_]*\z/
|
|
7
|
+
|
|
8
|
+
# Class-level DSL for declaring signal "slots" so a host class need not hand-write
|
|
9
|
+
# the connect-forwarding boilerplate. `extend Engine::Signal::DSL`, then:
|
|
10
|
+
#
|
|
11
|
+
# signal :on_clicked # a no-arg signal
|
|
12
|
+
# signal :on_changed, ChangeSignal # a typed signal (Signal.define(:index, :value))
|
|
13
|
+
#
|
|
14
|
+
# For :on_clicked this generates:
|
|
15
|
+
# def on_clicked(&block) = on_clicked_signal.connect(&block) # public: subscribe, returns handle
|
|
16
|
+
# def on_clicked_signal = (@on_clicked ||= type.new) # private: the Signal, to emit on
|
|
17
|
+
#
|
|
18
|
+
# The Signal is built lazily on first use, so the host wires nothing in #initialize;
|
|
19
|
+
# emit from inside the host via the private `<name>_signal` reader.
|
|
20
|
+
module DSL
|
|
21
|
+
def signal(name, type = Signal.define)
|
|
22
|
+
ivar = :"@#{name}"
|
|
23
|
+
reader = :"#{name}_signal"
|
|
24
|
+
|
|
25
|
+
define_method(reader) do
|
|
26
|
+
instance_variable_get(ivar) || instance_variable_set(ivar, type.new)
|
|
27
|
+
end
|
|
28
|
+
private reader
|
|
29
|
+
|
|
30
|
+
define_method(name) { |&block| send(reader).connect(&block) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.define(*fields)
|
|
35
|
+
fields.each do |field|
|
|
36
|
+
raise ArgumentError, "invalid field name: #{field.inspect}" unless field.to_s.match?(IDENTIFIER)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
params = fields.size == 1 ? fields.first : fields.map { |f| "#{f}:" }.join(', ')
|
|
40
|
+
args = fields.join(', ')
|
|
41
|
+
|
|
42
|
+
Class.new do
|
|
43
|
+
class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
|
|
44
|
+
# For fields [:x, :y] this generates:
|
|
45
|
+
# def initialize
|
|
46
|
+
# @listeners = []
|
|
47
|
+
# end
|
|
48
|
+
# def connect(&callback)
|
|
49
|
+
# @listeners << callback
|
|
50
|
+
# callback
|
|
51
|
+
# end
|
|
52
|
+
# def disconnect(handle) = @listeners.delete(handle)
|
|
53
|
+
# def emit(x:, y:)
|
|
54
|
+
# @listeners.each { it.call(x, y) }
|
|
55
|
+
# end
|
|
56
|
+
|
|
57
|
+
def initialize
|
|
58
|
+
@listeners = []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def connect(&callback)
|
|
62
|
+
@listeners << callback
|
|
63
|
+
callback
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def disconnect(handle) = @listeners.delete(handle)
|
|
67
|
+
def emit(#{params})
|
|
68
|
+
@listeners.each { it.call(#{args}) }
|
|
69
|
+
end
|
|
70
|
+
RUBY
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|