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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +251 -0
- data/LICENSE +26 -0
- data/README.md +325 -0
- data/docs/api/README.md +224 -0
- data/docs/api/app.md +249 -0
- data/docs/api/assets.md +463 -0
- data/docs/api/audio.md +249 -0
- data/docs/api/cli.md +259 -0
- data/docs/api/components.md +1112 -0
- data/docs/api/drawing.md +398 -0
- data/docs/api/examples.md +263 -0
- data/docs/api/game.md +219 -0
- data/docs/api/images.md +118 -0
- data/docs/api/input.md +476 -0
- data/docs/api/internals.md +289 -0
- data/docs/api/localization.md +285 -0
- data/docs/api/scene_graph.md +539 -0
- data/docs/api/signals.md +147 -0
- data/docs/api/systems.md +231 -0
- data/docs/api/text.md +133 -0
- data/docs/api/tile_maps.md +237 -0
- data/docs/api/toolbox.md +493 -0
- data/docs/api/ui.md +927 -0
- data/docs/api/values.md +316 -0
- data/examples/assets/README.md +322 -0
- data/examples/assets/blip.ogg +0 -0
- data/examples/assets/glyphs.json +5 -0
- data/examples/assets/glyphs.png +0 -0
- data/examples/assets/hero.json +12 -0
- data/examples/assets/hero.png +0 -0
- data/examples/assets/icons.json +13 -0
- data/examples/assets/icons.png +0 -0
- data/examples/assets/music.ogg +0 -0
- data/examples/assets/skills.json +10 -0
- data/examples/assets/skills.png +0 -0
- data/examples/assets/tileset.png +0 -0
- data/examples/assets/tileset.tsx +65 -0
- data/examples/assets/town.tmx +26 -0
- data/examples/assets/ui.json +11 -0
- data/examples/assets/ui.png +0 -0
- data/examples/collision/locales/en.yml +8 -0
- data/examples/collision/main.rb +316 -0
- data/examples/collision_tiles/locales/en.yml +9 -0
- data/examples/collision_tiles/main.rb +274 -0
- data/examples/fullscreen/locales/en.yml +10 -0
- data/examples/fullscreen/main.rb +216 -0
- data/examples/game_menu/locales/en.yml +8 -0
- data/examples/game_menu/main.rb +170 -0
- data/examples/input_glyphs/locales/en.yml +14 -0
- data/examples/input_glyphs/main.rb +213 -0
- data/examples/jump_topdown/locales/en.yml +9 -0
- data/examples/jump_topdown/main.rb +178 -0
- data/examples/localization/locales/de.yml +12 -0
- data/examples/localization/locales/en.yml +13 -0
- data/examples/localization/main.rb +158 -0
- data/examples/menu_navigation/locales/en.yml +23 -0
- data/examples/menu_navigation/main.rb +365 -0
- data/examples/music/locales/en.yml +7 -0
- data/examples/music/main.rb +134 -0
- data/examples/pathfinding/locales/en.yml +17 -0
- data/examples/pathfinding/main.rb +298 -0
- data/examples/pooling/locales/en.yml +7 -0
- data/examples/pooling/main.rb +259 -0
- data/examples/quick_wheel/locales/en.yml +16 -0
- data/examples/quick_wheel/main.rb +184 -0
- data/examples/radial_menu/locales/en.yml +16 -0
- data/examples/radial_menu/main.rb +184 -0
- data/examples/save_load/locales/en.yml +11 -0
- data/examples/save_load/main.rb +207 -0
- data/examples/save_load_ids/locales/en.yml +11 -0
- data/examples/save_load_ids/main.rb +322 -0
- data/examples/scroll_map/locales/en.yml +4 -0
- data/examples/scroll_map/main.rb +140 -0
- data/examples/signals/locales/en.yml +6 -0
- data/examples/signals/main.rb +278 -0
- data/examples/skill_bar/locales/en.yml +14 -0
- data/examples/skill_bar/main.rb +159 -0
- data/examples/sound/locales/en.yml +6 -0
- data/examples/sound/main.rb +122 -0
- data/examples/split_screen/locales/en.yml +9 -0
- data/examples/split_screen/main.rb +304 -0
- data/examples/sprite/locales/en.yml +8 -0
- data/examples/sprite/main.rb +180 -0
- data/examples/timer/locales/en.yml +12 -0
- data/examples/timer/main.rb +273 -0
- data/examples/velocity/locales/en.yml +6 -0
- data/examples/velocity/main.rb +196 -0
- data/examples/walk/locales/en.yml +4 -0
- data/examples/walk/main.rb +99 -0
- data/exe/rgame +9 -0
- data/lib/rgame/boot.rb +3 -0
- data/lib/rgame/cli/new_project.rb +139 -0
- data/lib/rgame/cli/templates/Gemfile.tt +23 -0
- data/lib/rgame/cli/templates/README.md.tt +93 -0
- data/lib/rgame/cli/templates/Rakefile.tt +9 -0
- data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
- data/lib/rgame/cli/templates/game.rb.tt +23 -0
- data/lib/rgame/cli/templates/gitignore.tt +12 -0
- data/lib/rgame/cli/templates/main.rb.tt +11 -0
- data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
- data/lib/rgame/cli/templates/rspec.tt +2 -0
- data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
- data/lib/rgame/cli/templates/ruby-version.tt +1 -0
- data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
- data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
- data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
- data/lib/rgame/cli.rb +66 -0
- data/lib/rgame/core/app.rb +46 -0
- data/lib/rgame/core/asset_manager.rb +206 -0
- data/lib/rgame/core/audio.rb +145 -0
- data/lib/rgame/core/font.rb +46 -0
- data/lib/rgame/core/gamepad.rb +55 -0
- data/lib/rgame/core/image.rb +55 -0
- data/lib/rgame/core/input.rb +71 -0
- data/lib/rgame/core/locale.rb +22 -0
- data/lib/rgame/core/nine_slice.rb +142 -0
- data/lib/rgame/core/recording.rb +54 -0
- data/lib/rgame/core/renderer.rb +354 -0
- data/lib/rgame/core/sprite_sheet.rb +105 -0
- data/lib/rgame/core/tile_map_renderer.rb +172 -0
- data/lib/rgame/core/ui_atlas.rb +101 -0
- data/lib/rgame/core/virtual_gamepad.rb +26 -0
- data/lib/rgame/core.rb +18 -0
- data/lib/rgame/core_ext.so +0 -0
- data/lib/rgame/engine/actor_blockers.rb +131 -0
- data/lib/rgame/engine/animation_set.rb +50 -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 +59 -0
- data/lib/rgame/engine/bounds_blockers.rb +74 -0
- data/lib/rgame/engine/camera.rb +78 -0
- data/lib/rgame/engine/circle_collider.rb +34 -0
- data/lib/rgame/engine/collision_box.rb +59 -0
- data/lib/rgame/engine/collision_system.rb +132 -0
- data/lib/rgame/engine/component.rb +64 -0
- data/lib/rgame/engine/components/action_trigger.rb +40 -0
- data/lib/rgame/engine/components/animated_sprite.rb +71 -0
- data/lib/rgame/engine/components/box_collider.rb +99 -0
- data/lib/rgame/engine/components/camera_follow.rb +45 -0
- data/lib/rgame/engine/components/character_body.rb +50 -0
- data/lib/rgame/engine/components/circle_collider.rb +80 -0
- data/lib/rgame/engine/components/collision_world.rb +231 -0
- data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
- data/lib/rgame/engine/components/feet_collider.rb +61 -0
- data/lib/rgame/engine/components/hop.rb +76 -0
- data/lib/rgame/engine/components/identity.rb +73 -0
- data/lib/rgame/engine/components/mover.rb +285 -0
- data/lib/rgame/engine/components/navigator.rb +145 -0
- data/lib/rgame/engine/components/path_follow.rb +176 -0
- data/lib/rgame/engine/components/player_controller.rb +27 -0
- data/lib/rgame/engine/components/pool.rb +53 -0
- data/lib/rgame/engine/components/screen_wrap.rb +49 -0
- data/lib/rgame/engine/components/sprite.rb +47 -0
- data/lib/rgame/engine/components/targeting.rb +52 -0
- data/lib/rgame/engine/components/thrust_controller.rb +65 -0
- data/lib/rgame/engine/components/tile_world.rb +109 -0
- data/lib/rgame/engine/components/timer.rb +75 -0
- data/lib/rgame/engine/components/velocity.rb +43 -0
- data/lib/rgame/engine/components/wander_controller.rb +64 -0
- data/lib/rgame/engine/components/world.rb +133 -0
- data/lib/rgame/engine/contact_set.rb +74 -0
- data/lib/rgame/engine/culling.rb +45 -0
- data/lib/rgame/engine/debug_overlay.rb +111 -0
- data/lib/rgame/engine/i18n/plural.rb +45 -0
- data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
- data/lib/rgame/engine/i18n/template.rb +59 -0
- data/lib/rgame/engine/i18n.rb +322 -0
- data/lib/rgame/engine/input/action_mapper.rb +100 -0
- data/lib/rgame/engine/input/actions.rb +92 -0
- data/lib/rgame/engine/input/input_map.rb +196 -0
- data/lib/rgame/engine/layout.rb +82 -0
- data/lib/rgame/engine/nav_grid.rb +87 -0
- data/lib/rgame/engine/node2d.rb +555 -0
- data/lib/rgame/engine/path.rb +76 -0
- data/lib/rgame/engine/player.rb +69 -0
- data/lib/rgame/engine/player_layer.rb +70 -0
- data/lib/rgame/engine/players.rb +205 -0
- data/lib/rgame/engine/pool.rb +51 -0
- data/lib/rgame/engine/presentation.rb +171 -0
- data/lib/rgame/engine/scene/scene_stack.rb +87 -0
- data/lib/rgame/engine/sealed_privates.rb +54 -0
- data/lib/rgame/engine/signal.rb +75 -0
- data/lib/rgame/engine/spatial_hash.rb +116 -0
- data/lib/rgame/engine/text.rb +194 -0
- data/lib/rgame/engine/tile_blockers.rb +63 -0
- data/lib/rgame/engine/tile_map.rb +147 -0
- data/lib/rgame/engine/tile_map_layer.rb +82 -0
- data/lib/rgame/engine/tileset.rb +99 -0
- data/lib/rgame/engine/timer.rb +51 -0
- data/lib/rgame/engine/ui/button.rb +248 -0
- data/lib/rgame/engine/ui/column.rb +20 -0
- data/lib/rgame/engine/ui/icon_button.rb +93 -0
- data/lib/rgame/engine/ui/menu.rb +290 -0
- data/lib/rgame/engine/ui/navigation.rb +57 -0
- data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
- data/lib/rgame/engine/ui/option_button.rb +163 -0
- data/lib/rgame/engine/ui/panel_button.rb +32 -0
- data/lib/rgame/engine/ui/panel_menu.rb +36 -0
- data/lib/rgame/engine/ui/pointing.rb +146 -0
- data/lib/rgame/engine/ui/radial_menu.rb +85 -0
- data/lib/rgame/engine/ui/ring.rb +55 -0
- data/lib/rgame/engine/ui/row.rb +21 -0
- data/lib/rgame/engine/ui/shape_style.rb +102 -0
- data/lib/rgame/engine/ui/stack.rb +58 -0
- data/lib/rgame/engine/ui/stepping.rb +93 -0
- data/lib/rgame/engine/ui/text_button.rb +59 -0
- data/lib/rgame/engine/view.rb +76 -0
- data/lib/rgame/engine/viewports.rb +171 -0
- data/lib/rgame/engine/world_view.rb +71 -0
- data/lib/rgame/engine.rb +86 -0
- data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
- data/lib/rgame/fonts/OFL.txt +102 -0
- data/lib/rgame/game.rb +271 -0
- data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
- data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
- data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
- data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
- data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
- data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
- data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
- data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
- data/lib/rgame/rubocop/default.yml +39 -0
- data/lib/rgame/rubocop/plugin.rb +45 -0
- data/lib/rgame/rubocop.rb +11 -0
- data/lib/rgame/util/color.rb +23 -0
- data/lib/rgame/util/controls.rb +169 -0
- data/lib/rgame/util/route_search.rb +27 -0
- data/lib/rgame/util/save_file.rb +107 -0
- data/lib/rgame/util/solid_grid.rb +37 -0
- data/lib/rgame/util/tensor.rb +3 -0
- data/lib/rgame/util/tile_sweep.rb +36 -0
- data/lib/rgame/util/z.rb +123 -0
- data/lib/rgame/util.rb +10 -0
- data/lib/rgame/util_ext.so +0 -0
- data/lib/rgame/version.rb +12 -0
- data/lib/rgame.rb +5 -0
- data/licenses/SDL2/LICENSE.txt +18 -0
- metadata +309 -0
data/lib/rgame/game.rb
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'boot'
|
|
4
|
+
require_relative 'core'
|
|
5
|
+
require_relative 'engine'
|
|
6
|
+
|
|
7
|
+
module RGame
|
|
8
|
+
# The entry point of a game, and the one class that knows both halves.
|
|
9
|
+
#
|
|
10
|
+
# class HelloScene < RGame::Engine::Node2D
|
|
11
|
+
# def on_draw(renderer) = renderer.text('Hello world!', 250, 200)
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# RGame::Game.new(root: HelloScene.new, caption: 'Hello').start
|
|
15
|
+
#
|
|
16
|
+
# A complete game is a root node plus that. `Game` assembles the pieces
|
|
17
|
+
# around it — the window and its loop, the renderer, the asset manager, the
|
|
18
|
+
# sound device, the input mapper, the debug overlay — and drives the root
|
|
19
|
+
# node once per tick.
|
|
20
|
+
#
|
|
21
|
+
# ## Why this class is allowed to name both layers
|
|
22
|
+
#
|
|
23
|
+
# `RGame::Engine` holds game concepts and may not name `RGame::Core`;
|
|
24
|
+
# `RGame::Core` owns handles and may not know Engine exists. Two RuboCop cops
|
|
25
|
+
# say so. Something still has to introduce them, and **this is that
|
|
26
|
+
# something** — see CLAUDE.md, "The rule points both ways". Keeping the
|
|
27
|
+
# introduction to one file is what makes the rule checkable everywhere else,
|
|
28
|
+
# so wiring belongs here and only here.
|
|
29
|
+
#
|
|
30
|
+
# The tile-map loader is the clearest case. Parsing a `.tmx` is Engine's job
|
|
31
|
+
# and drawing one is Core's, and neither may call the other, so `Game`
|
|
32
|
+
# installs the loader that joins them.
|
|
33
|
+
#
|
|
34
|
+
# ## The loop
|
|
35
|
+
#
|
|
36
|
+
# `Game` is an `App`, so it inherits the fixed-timestep loop rather than
|
|
37
|
+
# running one. Its hooks do three things: sample input once per frame, drive
|
|
38
|
+
# the root once per tick, and draw. Nothing here counts steps or measures
|
|
39
|
+
# time — `frame_loop.c` does that, and `update` is called once per whole tick.
|
|
40
|
+
class Game < RGame::Core::App
|
|
41
|
+
Controls = RGame::Util::Controls
|
|
42
|
+
|
|
43
|
+
WIDTH = 640
|
|
44
|
+
HEIGHT = 480
|
|
45
|
+
|
|
46
|
+
attr_reader :root, :renderer
|
|
47
|
+
|
|
48
|
+
# `input_map:` is what physical inputs mean — one entry per action, naming
|
|
49
|
+
# ids from RGame::Util::Controls. It is merged over the universal UI set, so
|
|
50
|
+
# `ui_confirm` and friends work whether or not a game declares them, and it
|
|
51
|
+
# defaults to RGame::Engine::InputMap::DEFAULT_ACTIONS, so a game wanting
|
|
52
|
+
# eight-way movement and a fire button declares nothing.
|
|
53
|
+
#
|
|
54
|
+
# `device:` is which device drives it — the keyboard, or
|
|
55
|
+
# `Controls.gamepad(slot)` for a controller.
|
|
56
|
+
#
|
|
57
|
+
# `input:` overrides the input backend. It exists so a harness can drive a
|
|
58
|
+
# game from a script instead of from hardware — see
|
|
59
|
+
# tools/drive_test_project.rb, and CLAUDE.md's "The test projects are the
|
|
60
|
+
# acceptance test for wiring", which is why driving one has to be possible
|
|
61
|
+
# at all. A game passes nothing and gets the real thing.
|
|
62
|
+
# `players:` is how many seats the game has, and therefore the most people
|
|
63
|
+
# who can play it. Player 0 starts on `device:`; the rest start empty and
|
|
64
|
+
# are filled when someone uses a controller — see RGame::Engine::Players for
|
|
65
|
+
# why that is a press rather than a plug.
|
|
66
|
+
# `fullscreen:` opens the window fullscreen rather than switching after it is
|
|
67
|
+
# already up, so a game that always runs fullscreen never flashes a windowed
|
|
68
|
+
# frame at startup. `width` and `height` still matter: they are the size the
|
|
69
|
+
# window takes when it leaves fullscreen, whether or not this game offers a
|
|
70
|
+
# way to do that.
|
|
71
|
+
#
|
|
72
|
+
# `scale_mode:` decides what `width` and `height` *mean*, and the default
|
|
73
|
+
# answer is "the resolution the game is designed in". Under `:letterbox` the
|
|
74
|
+
# view a node draws into is always that size, whatever the window is doing,
|
|
75
|
+
# so a layout written against fixed numbers keeps working at any window size
|
|
76
|
+
# and in fullscreen — which is what almost every game wants and what nothing
|
|
77
|
+
# in the engine can supply for it afterwards.
|
|
78
|
+
#
|
|
79
|
+
# `:disabled` is the opt-out, and it is the right answer for something that
|
|
80
|
+
# should genuinely use whatever space it is given: a tool, a HUD-shaped
|
|
81
|
+
# program, an editor. It hands the window straight through as the view, and
|
|
82
|
+
# skips the clip, translate and scale that every other mode pushes. See
|
|
83
|
+
# RGame::Engine::Presentation.
|
|
84
|
+
#
|
|
85
|
+
# `locales:` is the directory the translation tables are in, relative to
|
|
86
|
+
# `media_root` unless absolute. Every `.yml` under it is loaded through the
|
|
87
|
+
# asset manager here, in sorted order, and the language is chosen from the
|
|
88
|
+
# player's OS preferences — so a game writes no i18n setup, and a language
|
|
89
|
+
# the player saved is set after `new` and before `start`. A directory that
|
|
90
|
+
# does not exist loads nothing, and every key shows as itself.
|
|
91
|
+
def initialize(root:, width: WIDTH, height: HEIGHT, caption: 'RGame',
|
|
92
|
+
media_root: 'media', input_map: nil, device: Controls::KEYBOARD,
|
|
93
|
+
players: 1, input: nil, fullscreen: false, scale_mode: :letterbox,
|
|
94
|
+
locales: 'locales')
|
|
95
|
+
super(width: width, height: height, caption: caption, media_root: media_root,
|
|
96
|
+
fullscreen: fullscreen)
|
|
97
|
+
|
|
98
|
+
@root = root
|
|
99
|
+
@renderer = RGame::Core::Renderer.new(self)
|
|
100
|
+
@input = input || RGame::Core::Input.new(self)
|
|
101
|
+
@players = RGame::Engine::Players.new(
|
|
102
|
+
Array.new(players) do |id|
|
|
103
|
+
RGame::Engine::Player.new(id: id, device: id.zero? ? device : nil,
|
|
104
|
+
input_map: input_map)
|
|
105
|
+
end
|
|
106
|
+
)
|
|
107
|
+
@presentation = RGame::Engine::Presentation.new(width: width, height: height,
|
|
108
|
+
mode: scale_mode)
|
|
109
|
+
@presentation.fit(self.width, self.height)
|
|
110
|
+
@viewports = RGame::Engine::Viewports.new(@players, width: @presentation.width,
|
|
111
|
+
height: @presentation.height)
|
|
112
|
+
@debug = RGame::Engine::DebugOverlay.new
|
|
113
|
+
@dirty = true
|
|
114
|
+
|
|
115
|
+
install_asset_loaders
|
|
116
|
+
load_locales(locales)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The player registry, also reachable from any node as
|
|
120
|
+
# `node.system(RGame::Engine::Players)` — which is how a scene gets at a
|
|
121
|
+
# camera to follow, without anything being threaded into its constructor.
|
|
122
|
+
#
|
|
123
|
+
# One player exists from the start, so a single-player game never mentions
|
|
124
|
+
# players at all: it is `players.primary` that an unowned node reads from,
|
|
125
|
+
# and `players.primary.camera` that a scene points at its hero.
|
|
126
|
+
attr_reader :players
|
|
127
|
+
|
|
128
|
+
# How the screen is divided. Reachable as `node.system(RGame::Engine::Viewports)`,
|
|
129
|
+
# which is how a cutscene deep in a scene collapses the split without
|
|
130
|
+
# anything being handed to it.
|
|
131
|
+
attr_reader :viewports
|
|
132
|
+
|
|
133
|
+
# Brings the tree live and runs until the window closes.
|
|
134
|
+
#
|
|
135
|
+
# The root gets this object as its `context`, which is how a node deep in
|
|
136
|
+
# the tree reaches the asset manager (`node.root.context.assets`) without
|
|
137
|
+
# anything being threaded through its constructor.
|
|
138
|
+
def start
|
|
139
|
+
@root.context = self
|
|
140
|
+
@root.add_component(@players)
|
|
141
|
+
@root.add_component(@viewports)
|
|
142
|
+
@audio_director = Engine::AudioDirector.new(audio).subscribe
|
|
143
|
+
@root.enter_tree
|
|
144
|
+
run
|
|
145
|
+
ensure
|
|
146
|
+
@audio_director&.unsubscribe
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# One fixed simulation tick. `dt` is always the engine's fixed step, so the
|
|
150
|
+
# tree never sees variable frame time.
|
|
151
|
+
#
|
|
152
|
+
# **Input is polled here, per tick, not in `frame_begin` per frame.** That
|
|
153
|
+
# is not where it started, and the reason is edge detection: `pressed?` is
|
|
154
|
+
# "held now, not held at the previous poll", so whatever polls decides what
|
|
155
|
+
# a press *is*. `frame_begin` runs once per rendered frame, and a loop that
|
|
156
|
+
# renders faster than it simulates runs it many times between two ticks —
|
|
157
|
+
# each one shifting the previous state, so the press is consumed by a poll
|
|
158
|
+
# no tick ever reads. Menus stop responding, and only on fast machines.
|
|
159
|
+
#
|
|
160
|
+
# Polling per tick costs nothing extra and loses nothing: the C layer
|
|
161
|
+
# snapshots the keyboard once per frame, so several ticks inside one frame
|
|
162
|
+
# read identical state, and the edge lands on the first of them — one press,
|
|
163
|
+
# one `pressed?`, which is what a caller means.
|
|
164
|
+
def update(dt)
|
|
165
|
+
@players.poll(@input)
|
|
166
|
+
@root.control(@players)
|
|
167
|
+
@root.update(dt)
|
|
168
|
+
@root.sweep_freed
|
|
169
|
+
@dirty = true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Only the simulation advancing makes the frame stale. While the overlay is
|
|
173
|
+
# up, redraw anyway, so its numbers stay live even when nothing is moving.
|
|
174
|
+
def needs_redraw? = @dirty || @debug.visible?
|
|
175
|
+
|
|
176
|
+
# The tree is drawn once, with the whole window as its view. Screen-space
|
|
177
|
+
# content — a HUD, a menu, a title card — lands there and is drawn exactly
|
|
178
|
+
# once, as it always was.
|
|
179
|
+
#
|
|
180
|
+
# **World content multiplies inside the tree, not here.** An
|
|
181
|
+
# RGame::Engine::WorldView draws its subtree once per viewport, clipping and
|
|
182
|
+
# translating for each, so where the world begins is the game's choice
|
|
183
|
+
# rather than a shape the platform imposes. That is also what keeps
|
|
184
|
+
# `node.root` meaning the game's own root: nothing is inserted above it.
|
|
185
|
+
def draw
|
|
186
|
+
@viewports.refresh
|
|
187
|
+
if @presentation.scaled?
|
|
188
|
+
presented { draw_tree }
|
|
189
|
+
else
|
|
190
|
+
draw_tree
|
|
191
|
+
end
|
|
192
|
+
@dirty = false
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# How `width` and `height` are mapped onto the window: `:disabled`,
|
|
196
|
+
# `:stretch`, `:letterbox` or `:integer`. See RGame::Engine::Presentation.
|
|
197
|
+
def scale_mode = @presentation.mode
|
|
198
|
+
|
|
199
|
+
# Switchable while the game runs, for a settings screen.
|
|
200
|
+
#
|
|
201
|
+
# The viewports are resized as well as the presentation refitted, because
|
|
202
|
+
# the two modes disagree about what the logical size *is*: leaving `:integer`
|
|
203
|
+
# for `:disabled` turns a fixed 640x480 back into the window's own size, and
|
|
204
|
+
# a viewport still holding the old one would lay out into a corner.
|
|
205
|
+
def scale_mode=(mode)
|
|
206
|
+
@presentation.mode = mode
|
|
207
|
+
@viewports.resize(@presentation.width, @presentation.height)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The window changed size, so the presentation is refitted and every rect and
|
|
211
|
+
# camera clamp follows. Under a scaling mode the logical size does not move,
|
|
212
|
+
# so the viewports are handed the same numbers again and only the transform
|
|
213
|
+
# in `draw` changes — which is the whole point of the mode.
|
|
214
|
+
def resize(width, height)
|
|
215
|
+
@presentation.fit(width, height)
|
|
216
|
+
@viewports.resize(@presentation.width, @presentation.height)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Hot-plug is bookkeeping, not seating: a controller arriving becomes a
|
|
220
|
+
# device the registry watches, and it is someone *using* it that gives it to
|
|
221
|
+
# a player. Leaving takes it back off whoever had it.
|
|
222
|
+
def gamepad_connected(slot) = @players.device_connected(slot)
|
|
223
|
+
def gamepad_disconnected(slot) = @players.device_disconnected(slot)
|
|
224
|
+
|
|
225
|
+
# The two development keys, both function keys on purpose: **Escape is
|
|
226
|
+
# deliberately not bound here**, because it is the natural `cancel`/`back`
|
|
227
|
+
# button for a game's own menus, and a debug shortcut has no business taking
|
|
228
|
+
# the one key every player expects to close a dialog. F1 shows the overlay,
|
|
229
|
+
# F2 quits.
|
|
230
|
+
def button_down(id)
|
|
231
|
+
close if id == Controls::KEY_F2
|
|
232
|
+
@debug.toggle if id == Controls::KEY_F1
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def draw_tree
|
|
238
|
+
@root.draw(@renderer, @viewports.screen)
|
|
239
|
+
@debug.draw(@renderer, @viewports.screen, fps)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def presented(&)
|
|
243
|
+
@renderer.clipped(@presentation.offset_x, @presentation.offset_y,
|
|
244
|
+
(@presentation.width * @presentation.scale_x).round,
|
|
245
|
+
(@presentation.height * @presentation.scale_y).round) do
|
|
246
|
+
@renderer.translated(@presentation.offset_x, @presentation.offset_y) do
|
|
247
|
+
@renderer.scaled(@presentation.scale_x, @presentation.scale_y, &)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def load_locales(directory)
|
|
253
|
+
assets.glob(File.join(directory, '**', '*.yml')).each { |path| assets.locale(path) }
|
|
254
|
+
RGame::Engine::I18n.locale = RGame::Engine::I18n.choose(RGame::Core.preferred_locales)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def install_asset_loaders
|
|
258
|
+
assets.add_loader(:locale) do |path|
|
|
259
|
+
RGame::Engine::I18n.load(File.read(path), source: path)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
game = self
|
|
263
|
+
assets.add_loader(:tilemap) do |path|
|
|
264
|
+
map, image_path = RGame::Engine::TileMap.load(path)
|
|
265
|
+
tiles = game.assets.image(image_path)
|
|
266
|
+
.tiles(map.tileset.tile_width, map.tileset.tile_height)
|
|
267
|
+
RGame::Core::TileMapRenderer.new(map, tiles)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Game
|
|
6
|
+
# A node's draw methods must not read the node's own position — in either
|
|
7
|
+
# space, because both are already applied by the time they run.
|
|
8
|
+
#
|
|
9
|
+
# `Node2D#draw` pushes the node's transform onto the renderer and runs
|
|
10
|
+
# everything below it inside that, so within `on_draw` the origin *is* the
|
|
11
|
+
# node, turned the way the node is turned. Drawing is done at (0, 0), or at
|
|
12
|
+
# an offset from it that means something to the node itself.
|
|
13
|
+
#
|
|
14
|
+
# Both spellings of "where am I" are therefore wrong there, and differ only
|
|
15
|
+
# in how far off they put the result:
|
|
16
|
+
#
|
|
17
|
+
# - `world_x` is where the node sits on the map, which every ancestor's
|
|
18
|
+
# transform — the camera's included — has already contributed to. Passing
|
|
19
|
+
# it applies the whole chain twice.
|
|
20
|
+
# - `x` is the node's offset inside its parent, which its own transform has
|
|
21
|
+
# already applied. Passing it offsets by that much a second time.
|
|
22
|
+
#
|
|
23
|
+
# Both are silent: they draw, they just draw in the wrong place, and only
|
|
24
|
+
# once something is nested under an offset or rotated parent — which is
|
|
25
|
+
# exactly the case a spec at the origin cannot see.
|
|
26
|
+
#
|
|
27
|
+
# A **component** drawing for its node is on this path too, and asks the
|
|
28
|
+
# node by name: `node.world_x` for a cull test is a different object's
|
|
29
|
+
# coordinate and is not flagged. Only a bare, receiverless read of the
|
|
30
|
+
# node's own transform is.
|
|
31
|
+
#
|
|
32
|
+
# `width` and `height` are not positions and are ordinary here — a node
|
|
33
|
+
# drawing its own box says `renderer.rect(0, 0, width, height)`.
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# # bad — the traversal has already placed the renderer on this node
|
|
37
|
+
# def on_draw(renderer, _view)
|
|
38
|
+
# renderer.rect(world_x, world_y, width, height)
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# # bad — same mistake, a smaller distance
|
|
42
|
+
# def on_draw(renderer, _view)
|
|
43
|
+
# renderer.rect(x, y, width, height)
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# # good — its own origin
|
|
47
|
+
# def on_draw(renderer, _view)
|
|
48
|
+
# renderer.rect(0, 0, width, height)
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# # good — a component culling against the camera, in world space, by name
|
|
52
|
+
# def draw(renderer, view)
|
|
53
|
+
# return if culled?(view, node.world_x, node.world_y, node.width, node.height)
|
|
54
|
+
#
|
|
55
|
+
# renderer.image(@id, 0, 0)
|
|
56
|
+
# end
|
|
57
|
+
class DrawInLocalSpace < RuboCop::Cop::Base
|
|
58
|
+
MSG = 'Draw in local space: `%{name}` is already applied by the time ' \
|
|
59
|
+
'`%{method}` runs, so passing it places this %{distance} a second ' \
|
|
60
|
+
'time. Draw at your own origin (0, 0), or an offset from it.'
|
|
61
|
+
|
|
62
|
+
METHODS = %i[draw on_draw _draw_content draw_children].freeze
|
|
63
|
+
|
|
64
|
+
RELATIVE = %i[x y angle].freeze
|
|
65
|
+
WORLD = %i[world_x world_y world_angle].freeze
|
|
66
|
+
IVARS = { :@rel_x => :x, :@rel_y => :y, :@rel_angle => :angle,
|
|
67
|
+
:@world_x => :world_x, :@world_y => :world_y,
|
|
68
|
+
:@world_angle => :world_angle }.freeze
|
|
69
|
+
|
|
70
|
+
def on_def(node)
|
|
71
|
+
return unless METHODS.include?(node.method_name)
|
|
72
|
+
|
|
73
|
+
node.each_descendant(:ivar, :send) do |read|
|
|
74
|
+
name = own_transform_read(read)
|
|
75
|
+
next unless name
|
|
76
|
+
|
|
77
|
+
add_offense(read, message: format(MSG, name: name, method: node.method_name,
|
|
78
|
+
distance: distance_for(name)))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def own_transform_read(node)
|
|
85
|
+
if node.ivar_type?
|
|
86
|
+
IVARS[node.children.first]
|
|
87
|
+
elsif self_read?(node)
|
|
88
|
+
name = node.method_name
|
|
89
|
+
name if RELATIVE.include?(name) || WORLD.include?(name)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self_read?(node)
|
|
94
|
+
node.send_type? && node.receiver.nil? && node.arguments.empty? && !node.block_literal?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def distance_for(name)
|
|
98
|
+
WORLD.include?(name) ? 'on the map' : 'inside its parent'
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Game
|
|
6
|
+
# Shared definition of the per-frame "hot path" for the allocation-guard cops.
|
|
7
|
+
#
|
|
8
|
+
# The hot path is the set of methods that run every frame: the engine's six
|
|
9
|
+
# lifecycle hooks, plus any method an author opts into with a `# hot-path` magic
|
|
10
|
+
# comment on the line directly above its `def`. The opt-in covers per-frame
|
|
11
|
+
# *helpers* the lifecycle methods call, where an allocation is just as costly but
|
|
12
|
+
# the method name alone can't reveal it.
|
|
13
|
+
module HotPath
|
|
14
|
+
METHODS = %i[update control draw draw_children _draw_content
|
|
15
|
+
on_update on_draw on_control].freeze
|
|
16
|
+
|
|
17
|
+
# True for a `def` that runs per frame: a lifecycle method by name, or one tagged
|
|
18
|
+
# `# hot-path` on the line directly above it.
|
|
19
|
+
def hot_path_def?(node)
|
|
20
|
+
return false unless node.def_type?
|
|
21
|
+
|
|
22
|
+
METHODS.include?(node.method_name) || hot_path_tagged?(node)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def hot_path_tagged?(node)
|
|
28
|
+
line_above = node.source_range.first_line - 1
|
|
29
|
+
processed_source.comments.any? do |comment|
|
|
30
|
+
comment.location.line == line_above && comment.text.match?(/\A#\s*hot-path\b/)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Game
|
|
6
|
+
# Shared by the two cops that police a layer boundary —
|
|
7
|
+
# `NoCoreInEngineLayer` and `NoEngineInCoreLayer`. They are mirror images:
|
|
8
|
+
# each forbids one namespace inside the other's files, and the only thing
|
|
9
|
+
# that differs is which namespace and which message.
|
|
10
|
+
#
|
|
11
|
+
# Kept in one place because the interesting part is not the comparison but
|
|
12
|
+
# the *walk*: `RGame::Core::Renderer` is three nested `const` nodes, and a
|
|
13
|
+
# naive check reports the same reference three times. Getting that right
|
|
14
|
+
# once beats getting it right twice and then fixing only one of them.
|
|
15
|
+
module LayerBoundary
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def const_path(node)
|
|
19
|
+
names = []
|
|
20
|
+
current = node
|
|
21
|
+
while current&.const_type?
|
|
22
|
+
names.unshift(current.short_name.to_s)
|
|
23
|
+
current = current.namespace
|
|
24
|
+
end
|
|
25
|
+
names
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def opens_namespace?(node, prefixes)
|
|
29
|
+
return false unless under?(node, prefixes)
|
|
30
|
+
|
|
31
|
+
!(node.parent&.const_type? && under?(node.parent, prefixes))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def under?(node, prefixes)
|
|
35
|
+
return false unless node.const_type?
|
|
36
|
+
|
|
37
|
+
path = const_path(node)
|
|
38
|
+
prefixes.any? { |prefix| path.first(prefix.length) == prefix }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'layer_boundary'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Game
|
|
8
|
+
# The engine layer must never name `RGame::Core`.
|
|
9
|
+
#
|
|
10
|
+
# `RGame::Engine` holds the game concepts — scene tree, signals, sprites,
|
|
11
|
+
# tile maps — and its whole value is that it can be specified with no
|
|
12
|
+
# window, no GPU and no clock. It reaches the platform only through
|
|
13
|
+
# objects handed to it: a node's `draw` receives a `renderer` and calls
|
|
14
|
+
# methods on it by name, never storing it and never asking its class.
|
|
15
|
+
#
|
|
16
|
+
# Engine specs enforce this at runtime by simply never loading
|
|
17
|
+
# `rgame/core`, so a stray reference raises `NameError`. That only catches
|
|
18
|
+
# code a test run actually executes, though — this cop covers the
|
|
19
|
+
# branches it doesn't reach.
|
|
20
|
+
#
|
|
21
|
+
# A game has the same line: its nodes and specs run headless, and only its
|
|
22
|
+
# glue class loads the window. So `require 'rgame/game'` is refused too,
|
|
23
|
+
# since it loads everything `rgame/core` does.
|
|
24
|
+
#
|
|
25
|
+
# The short spelling `Core::Image` is flagged only inside `module RGame`,
|
|
26
|
+
# the one place it resolves to `RGame::Core`. Anywhere else it names the
|
|
27
|
+
# project's own `Core`.
|
|
28
|
+
#
|
|
29
|
+
# `RGame::Util` is fine anywhere: those are shareable value types with no
|
|
30
|
+
# OS handle behind them, which is exactly why they live in Util.
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# # bad — names the class
|
|
34
|
+
# def draw(renderer)
|
|
35
|
+
# RGame::Core::Renderer.new
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# # bad — the same thing, resolved through the enclosing RGame
|
|
39
|
+
# module RGame
|
|
40
|
+
# Core::Image.new(app, path)
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# # bad — a require pulls SDL into the process
|
|
44
|
+
# require 'rgame/core'
|
|
45
|
+
#
|
|
46
|
+
# # good — duck-typed against whatever it is handed
|
|
47
|
+
# def draw(renderer, _view)
|
|
48
|
+
# renderer.sprite(:hero, 0, 0, 0, 0)
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# # good — Util types may be held as attributes
|
|
52
|
+
# @grid = RGame::Util::Tensor.new(w, h, d)
|
|
53
|
+
class NoCoreInEngineLayer < RuboCop::Cop::Base
|
|
54
|
+
include LayerBoundary
|
|
55
|
+
|
|
56
|
+
MSG = 'Headless code must not name `RGame::Core`; receive the object ' \
|
|
57
|
+
'and call it by method name instead.'
|
|
58
|
+
MSG_REQUIRE = 'Headless code must not require `%{path}` — that loads ' \
|
|
59
|
+
'SDL/OpenGL and breaks headless specs.'
|
|
60
|
+
|
|
61
|
+
QUALIFIED = [%w[RGame Core]].freeze
|
|
62
|
+
WITHIN_RGAME = [%w[RGame Core], %w[Core]].freeze
|
|
63
|
+
RESTRICTED_REQUIRE = %r{\Argame/(core|game)(/|\z)|\Argame/core_ext\z}
|
|
64
|
+
|
|
65
|
+
# `require "rgame/core"` and friends.
|
|
66
|
+
# @!method core_require(node)
|
|
67
|
+
def_node_matcher :core_require, <<~PATTERN
|
|
68
|
+
(send nil? {:require :require_relative} (str $_))
|
|
69
|
+
PATTERN
|
|
70
|
+
|
|
71
|
+
def on_send(node)
|
|
72
|
+
core_require(node) do |path|
|
|
73
|
+
next unless RESTRICTED_REQUIRE.match?(path.to_s.delete_prefix('./'))
|
|
74
|
+
|
|
75
|
+
add_offense(node, message: format(MSG_REQUIRE, path: path))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Any constant path under RGame::Core. The walk, and reporting one
|
|
80
|
+
# offence per written reference rather than one per path segment, is in
|
|
81
|
+
# LayerBoundary — shared with this cop's mirror.
|
|
82
|
+
def on_const(node)
|
|
83
|
+
add_offense(node) if opens_namespace?(node, prefixes_for(node))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def prefixes_for(node)
|
|
89
|
+
within_rgame?(node) ? WITHIN_RGAME : QUALIFIED
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def within_rgame?(node)
|
|
93
|
+
node.each_ancestor(:module, :class).any? do |scope|
|
|
94
|
+
const_path(scope.identifier).first == 'RGame'
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'layer_boundary'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Game
|
|
8
|
+
# `RGame::Core` must never name `RGame::Engine`. The mirror of
|
|
9
|
+
# `NoCoreInEngineLayer`, and the more easily broken of the two.
|
|
10
|
+
#
|
|
11
|
+
# Engine is built *on top of* Core, so Core should not know it exists.
|
|
12
|
+
# Core owns windows, textures and sound devices; Engine owns scene
|
|
13
|
+
# concepts. A Core class that reaches upward makes the lower layer
|
|
14
|
+
# unusable without the higher one, and inverts a dependency that the whole
|
|
15
|
+
# three-layer split exists to keep pointing one way.
|
|
16
|
+
#
|
|
17
|
+
# Where Core genuinely needs something Engine has — a tile map's grid, say
|
|
18
|
+
# — it takes the object and calls it by method name, exactly as the engine
|
|
19
|
+
# layer does with a renderer. The one place allowed to name both sides is
|
|
20
|
+
# the glue class directly under `RGame`, which is what a glue class is
|
|
21
|
+
# for.
|
|
22
|
+
#
|
|
23
|
+
# Unlike its mirror, nothing catches this at runtime: loading Engine into
|
|
24
|
+
# a Core spec would work fine and the inversion would go unnoticed until
|
|
25
|
+
# someone tried to use Core on its own. This cop is the only guard.
|
|
26
|
+
#
|
|
27
|
+
# Both spellings are flagged — `RGame::Engine` and the bare `Engine` the
|
|
28
|
+
# layer still has before it is ported — because the interim is exactly
|
|
29
|
+
# when the mistake gets made.
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# # bad — Core parsing a file format Engine owns
|
|
33
|
+
# def self.load(app, path)
|
|
34
|
+
# map = Engine::TileMap.parse(File.read(path))
|
|
35
|
+
# new(app, map)
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# # bad — a require, in either spelling
|
|
39
|
+
# require 'rgame/engine'
|
|
40
|
+
#
|
|
41
|
+
# # good — hand Core the parsed thing and call it by name
|
|
42
|
+
# def initialize(map, tiles)
|
|
43
|
+
# @columns = map.width
|
|
44
|
+
# end
|
|
45
|
+
class NoEngineInCoreLayer < RuboCop::Cop::Base
|
|
46
|
+
include LayerBoundary
|
|
47
|
+
|
|
48
|
+
MSG = 'RGame::Core must not name `%{name}`; take the object and call it ' \
|
|
49
|
+
'by method name, and let the glue layer wire the two together.'
|
|
50
|
+
MSG_REQUIRE = 'RGame::Core must not require `%{path}` — Engine is built on ' \
|
|
51
|
+
'top of Core, not the other way round.'
|
|
52
|
+
|
|
53
|
+
PREFIXES = [%w[RGame Engine], %w[Engine]].freeze
|
|
54
|
+
RESTRICTED_REQUIRE = %r{(\A|/)engine(/|\z)}
|
|
55
|
+
|
|
56
|
+
# @!method engine_require(node)
|
|
57
|
+
def_node_matcher :engine_require, <<~PATTERN
|
|
58
|
+
(send nil? {:require :require_relative} (str $_))
|
|
59
|
+
PATTERN
|
|
60
|
+
|
|
61
|
+
def on_send(node)
|
|
62
|
+
engine_require(node) do |path|
|
|
63
|
+
next unless RESTRICTED_REQUIRE.match?(path.to_s.delete_prefix('./'))
|
|
64
|
+
|
|
65
|
+
add_offense(node, message: format(MSG_REQUIRE, path: path))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def on_const(node)
|
|
70
|
+
return unless opens_namespace?(node, PREFIXES)
|
|
71
|
+
|
|
72
|
+
add_offense(node, message: format(MSG, name: matched_prefix(node).join('::')))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def matched_prefix(node)
|
|
78
|
+
path = const_path(node)
|
|
79
|
+
PREFIXES.find { |prefix| path.first(prefix.length) == prefix }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'hot_path'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Game
|
|
8
|
+
# Disallow string interpolation inside per-frame methods — the engine's lifecycle
|
|
9
|
+
# hooks (`update`/`control`/`draw`/`on_update`/`on_draw`/`on_control`) and any method
|
|
10
|
+
# tagged `# hot-path`.
|
|
11
|
+
#
|
|
12
|
+
# These run every frame (~60/s), and string interpolation builds a brand-new
|
|
13
|
+
# `String` each time it is evaluated (the `frozen_string_literal` pragma freezes
|
|
14
|
+
# *literals*, not interpolated results). So a `"Score: #{n}"` here is a per-frame
|
|
15
|
+
# allocation. Build the string once — on construction, or when the value changes
|
|
16
|
+
# (see `Engine::Text`) — and draw the cached copy.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# # bad
|
|
20
|
+
# def draw(renderer)
|
|
21
|
+
# renderer.text("Score: #{@score}", 10, 10)
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# # good (rebuilt only when @score changes)
|
|
25
|
+
# def draw(renderer)
|
|
26
|
+
# renderer.text(@score_label, 10, 10)
|
|
27
|
+
# end
|
|
28
|
+
class NoInterpolationInHotPath < RuboCop::Cop::Base
|
|
29
|
+
include HotPath
|
|
30
|
+
|
|
31
|
+
MSG = 'Avoid string interpolation in a per-frame method: it allocates a String ' \
|
|
32
|
+
'every frame. Build the string once (on change) and use the cached value.'
|
|
33
|
+
|
|
34
|
+
def on_def(node)
|
|
35
|
+
return unless hot_path_def?(node)
|
|
36
|
+
|
|
37
|
+
node.each_descendant(:dstr) do |dstr|
|
|
38
|
+
add_offense(dstr) if interpolated?(dstr)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def interpolated?(dstr)
|
|
45
|
+
dstr.children.any?(&:begin_type?)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|