rgame 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +135 -1
- data/README.md +67 -65
- data/docs/api/README.md +86 -82
- data/docs/api/app.md +125 -68
- data/docs/api/assets.md +203 -165
- data/docs/api/audio.md +130 -89
- data/docs/api/cli.md +259 -0
- data/docs/api/components.md +1032 -265
- data/docs/api/drawing.md +160 -160
- data/docs/api/examples.md +263 -0
- data/docs/api/game.md +149 -50
- data/docs/api/images.md +39 -39
- data/docs/api/input.md +226 -148
- data/docs/api/internals.md +241 -62
- data/docs/api/localization.md +285 -0
- data/docs/api/scene_graph.md +397 -244
- data/docs/api/signals.md +81 -76
- data/docs/api/systems.md +186 -73
- data/docs/api/text.md +58 -41
- data/docs/api/tile_maps.md +237 -0
- data/docs/api/toolbox.md +395 -146
- data/docs/api/ui.md +883 -54
- data/docs/api/values.md +216 -33
- 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/ext/rgame_core/app/app.c +33 -3
- data/ext/rgame_core/app/locale.c +67 -0
- data/ext/rgame_core/app/locale.h +28 -0
- data/ext/rgame_core/audio/audio.c +39 -2
- data/ext/rgame_core/example.rb +0 -49
- data/ext/rgame_core/extconf.rb +0 -125
- data/ext/rgame_core/include/rgame/core.h +38 -2
- data/ext/rgame_core/ruby/audio_ext.c +10 -5
- data/ext/rgame_core/ruby/core_ext.c +30 -7
- data/ext/rgame_core/ruby/core_ext.h +3 -0
- data/ext/rgame_core/ruby/locale_ext.c +44 -0
- data/ext/rgame_core/ruby/recording_ext.c +1 -1
- data/ext/rgame_core/ruby/renderer_ext.c +20 -20
- data/ext/rgame_util/extconf.rb +2 -20
- data/ext/rgame_util/route_search.c +305 -0
- data/ext/rgame_util/route_search.h +86 -0
- data/ext/rgame_util/route_search_ext.c +150 -0
- data/ext/rgame_util/solid_grid.c +58 -0
- data/ext/rgame_util/solid_grid.h +49 -0
- data/ext/rgame_util/solid_grid_ext.c +161 -0
- data/ext/rgame_util/tile_sweep.c +164 -0
- data/ext/rgame_util/tile_sweep.h +62 -0
- data/ext/rgame_util/tile_sweep_ext.c +155 -0
- data/ext/rgame_util/util_ext.c +3 -0
- data/ext/rgame_util/util_ext.h +15 -0
- data/lib/rgame/boot.rb +0 -10
- 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 +6 -44
- data/lib/rgame/core/asset_manager.rb +13 -31
- data/lib/rgame/core/audio.rb +37 -16
- data/lib/rgame/core/font.rb +0 -3
- data/lib/rgame/core/locale.rb +22 -0
- data/lib/rgame/core/nine_slice.rb +0 -21
- data/lib/rgame/core/renderer.rb +6 -63
- data/lib/rgame/core/sprite_sheet.rb +0 -3
- data/lib/rgame/core/tile_map_renderer.rb +2 -19
- data/lib/rgame/core/ui_atlas.rb +28 -13
- data/lib/rgame/core.rb +1 -8
- data/lib/rgame/engine/actor_blockers.rb +131 -0
- data/lib/rgame/engine/animation_set.rb +1 -0
- data/lib/rgame/engine/audio_director.rb +36 -6
- data/lib/rgame/engine/bounds_blockers.rb +74 -0
- data/lib/rgame/engine/camera.rb +3 -3
- data/lib/rgame/engine/circle_collider.rb +4 -2
- data/lib/rgame/engine/collision_box.rb +26 -1
- data/lib/rgame/engine/collision_system.rb +110 -22
- data/lib/rgame/engine/component.rb +34 -10
- data/lib/rgame/engine/components/action_trigger.rb +0 -1
- data/lib/rgame/engine/components/animated_sprite.rb +27 -25
- data/lib/rgame/engine/components/box_collider.rb +99 -0
- data/lib/rgame/engine/components/camera_follow.rb +6 -5
- data/lib/rgame/engine/components/character_body.rb +20 -61
- data/lib/rgame/engine/components/circle_collider.rb +47 -11
- data/lib/rgame/engine/components/collision_world.rb +159 -31
- data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
- 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 +123 -31
- data/lib/rgame/engine/components/player_controller.rb +5 -2
- data/lib/rgame/engine/components/pool.rb +1 -1
- data/lib/rgame/engine/components/screen_wrap.rb +33 -11
- data/lib/rgame/engine/components/sprite.rb +12 -6
- data/lib/rgame/engine/components/targeting.rb +9 -11
- data/lib/rgame/engine/components/thrust_controller.rb +1 -1
- data/lib/rgame/engine/components/tile_world.rb +41 -13
- data/lib/rgame/engine/components/timer.rb +1 -1
- data/lib/rgame/engine/components/velocity.rb +23 -7
- data/lib/rgame/engine/components/wander_controller.rb +6 -2
- data/lib/rgame/engine/components/world.rb +133 -0
- data/lib/rgame/engine/contact_set.rb +74 -0
- data/lib/rgame/engine/culling.rb +4 -6
- data/lib/rgame/engine/debug_overlay.rb +5 -11
- 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 +276 -51
- data/lib/rgame/engine/input/action_mapper.rb +0 -26
- data/lib/rgame/engine/input/actions.rb +2 -8
- data/lib/rgame/engine/input/input_map.rb +39 -21
- data/lib/rgame/engine/nav_grid.rb +87 -0
- data/lib/rgame/engine/node2d.rb +248 -133
- data/lib/rgame/engine/path.rb +4 -6
- data/lib/rgame/engine/players.rb +6 -13
- data/lib/rgame/engine/presentation.rb +171 -0
- data/lib/rgame/engine/scene/scene_stack.rb +4 -4
- data/lib/rgame/engine/sealed_privates.rb +54 -0
- data/lib/rgame/engine/spatial_hash.rb +51 -19
- data/lib/rgame/engine/text.rb +194 -0
- data/lib/rgame/engine/tile_blockers.rb +63 -0
- data/lib/rgame/engine/tile_map.rb +2 -3
- data/lib/rgame/engine/tile_map_layer.rb +0 -2
- data/lib/rgame/engine/tileset.rb +2 -4
- data/lib/rgame/engine/timer.rb +2 -2
- 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 +246 -71
- 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/viewports.rb +2 -5
- data/lib/rgame/engine/world_view.rb +5 -4
- data/lib/rgame/engine.rb +31 -25
- data/lib/rgame/game.rb +99 -27
- 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 +20 -24
- data/lib/rgame/util/controls.rb +14 -28
- 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 +0 -9
- data/lib/rgame/util/tile_sweep.rb +36 -0
- data/lib/rgame/util/z.rb +4 -14
- data/lib/rgame/util.rb +4 -3
- data/lib/rgame/version.rb +1 -1
- data/lib/rgame.rb +0 -15
- metadata +152 -11
- data/lib/rgame/engine/actor.rb +0 -53
- data/lib/rgame/engine/body.rb +0 -49
- data/lib/rgame/engine/cached_label.rb +0 -33
- data/lib/rgame/engine/input/player_controller.rb +0 -14
- data/lib/rgame/engine/matrix.rb +0 -32
- data/lib/rgame/engine/resettable.rb +0 -67
- data/lib/rgame/engine/tile_collision.rb +0 -78
- data/lib/rgame/engine/ui/menu_item.rb +0 -84
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Menu navigation — more than one screen, and settings that are real.
|
|
4
|
+
#
|
|
5
|
+
# Run it:
|
|
6
|
+
#
|
|
7
|
+
# ruby examples/menu_navigation/main.rb
|
|
8
|
+
#
|
|
9
|
+
# Up and down move the focus, Enter or A chooses, Escape goes back. On the
|
|
10
|
+
# settings screen **left and right change the value under the cursor**. Quit,
|
|
11
|
+
# run it again, and the settings are where you left them. It exercises:
|
|
12
|
+
# - Scene::SceneStack — push, pop and replace, and the difference between them;
|
|
13
|
+
# - UI::OptionButton — a menu row whose value is chosen from a list;
|
|
14
|
+
# - Util::SaveFile holding settings rather than a saved game;
|
|
15
|
+
# - RGame::Game's fullscreen, scale_mode and audio volume, driven from a menu.
|
|
16
|
+
#
|
|
17
|
+
# ## Push and replace are different questions, and this file asks both
|
|
18
|
+
#
|
|
19
|
+
# A SceneStack updates only its top scene but **draws all of them**, and that is
|
|
20
|
+
# what makes it a stack rather than a variable holding the current screen. So
|
|
21
|
+
# the choice between pushing and replacing is really one question: *should the
|
|
22
|
+
# thing underneath still be there?*
|
|
23
|
+
#
|
|
24
|
+
# Settings is **pushed** over the title, and the title keeps drawing behind the
|
|
25
|
+
# panel — that is why the settings screen needs a panel at all. Choosing Back
|
|
26
|
+
# **pops**, and the title is still exactly as it was, because it was never taken
|
|
27
|
+
# apart. Play **replaces** the title, because a title screen behind a running
|
|
28
|
+
# game is nothing but a thing to draw.
|
|
29
|
+
#
|
|
30
|
+
# ## Nothing switches scenes while the tree is being walked
|
|
31
|
+
#
|
|
32
|
+
# `Shell#go` records a request and `Shell#on_update` carries it out. A menu item
|
|
33
|
+
# activates during `control`, which is the middle of a traversal of the very
|
|
34
|
+
# subtree the switch is about to take apart, and unbuilding a tree while walking
|
|
35
|
+
# it is the sort of bug that shows up somewhere else entirely.
|
|
36
|
+
#
|
|
37
|
+
# Deferring costs one line and buys a second thing for free: two requests in one
|
|
38
|
+
# tick — the Back item and Escape both firing — collapse into one transition
|
|
39
|
+
# rather than two pops.
|
|
40
|
+
#
|
|
41
|
+
# ## Settings apply now and persist immediately
|
|
42
|
+
#
|
|
43
|
+
# Each row applies its change the moment it is made: the volume you hear is the
|
|
44
|
+
# volume shown, and the window changes as you move the cursor. That is what a
|
|
45
|
+
# settings screen is *for*, and it removes the whole apply/cancel/revert
|
|
46
|
+
# question this example has no business answering.
|
|
47
|
+
#
|
|
48
|
+
# The file is written on every change too. A settings screen changes a handful
|
|
49
|
+
# of values a handful of times, and `SaveFile` writes atomically, so there is
|
|
50
|
+
# nothing to be gained by batching it and one less rule about when a save
|
|
51
|
+
# happens.
|
|
52
|
+
#
|
|
53
|
+
# ## A settings file is untrusted input
|
|
54
|
+
#
|
|
55
|
+
# `Settings#load` checks every value against the list the menu actually offers
|
|
56
|
+
# and falls back to the default for anything else. That is not defensive habit.
|
|
57
|
+
# `Game#scale_mode=` raises on a mode it does not know, so a hand-edited file,
|
|
58
|
+
# a copy from a newer version of the game, or a `null` where a number should be
|
|
59
|
+
# is otherwise a game that cannot be started — and the player's only fix is to
|
|
60
|
+
# find and delete a file nobody told them about.
|
|
61
|
+
#
|
|
62
|
+
# `UI::OptionButton#value=` has the same shape for the same reason: a value the
|
|
63
|
+
# list no longer offers leaves the row where it is instead of raising.
|
|
64
|
+
|
|
65
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
|
|
66
|
+
require 'rgame/game'
|
|
67
|
+
|
|
68
|
+
UI = RGame::Engine::UI
|
|
69
|
+
|
|
70
|
+
# The same 8:5 `examples/fullscreen` uses, and for the same reason: this screen
|
|
71
|
+
# offers a scale mode as a setting, so the four choices have to look like four
|
|
72
|
+
# different things. A logical size that matched the display's aspect ratio would
|
|
73
|
+
# make `:stretch` and `:letterbox` identical, and one that divided it evenly
|
|
74
|
+
# would bring `:integer` in with them.
|
|
75
|
+
WIDTH = 512
|
|
76
|
+
HEIGHT = 320
|
|
77
|
+
ASSETS = File.expand_path('../assets', __dir__)
|
|
78
|
+
LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
|
|
79
|
+
|
|
80
|
+
BLIP = 'blip.ogg'
|
|
81
|
+
|
|
82
|
+
# The game's settings, backed by one JSON file.
|
|
83
|
+
#
|
|
84
|
+
# Plain Ruby and no node: settings are a value a scene reads, not a thing in the
|
|
85
|
+
# tree. It is handed to the scenes that need it rather than reached for through
|
|
86
|
+
# a global, which is also what lets a spec build one over a temporary directory.
|
|
87
|
+
class Settings
|
|
88
|
+
# Everything about a setting in one place: what it is called, what it may be,
|
|
89
|
+
# what each value is written as, and what it falls back to. The menu builds
|
|
90
|
+
# its rows from this and `load` validates the file against it, so a row can
|
|
91
|
+
# never offer a value the file would reject, or the other way round.
|
|
92
|
+
#
|
|
93
|
+
# A label and what `display` returns are translation keys; a percentage is a
|
|
94
|
+
# number rather than a word, so it is a literal. The captions are made once
|
|
95
|
+
# per value, when a row is built, rather than inside a draw method — see
|
|
96
|
+
# UI::OptionButton, and Game/NoInterpolationInHotPath for why a label built
|
|
97
|
+
# while drawing is a bug rather than a style.
|
|
98
|
+
#
|
|
99
|
+
# **These live in a class rather than at the top of the file, and a proc is
|
|
100
|
+
# why.** A block written at the top level of a script captures that script's
|
|
101
|
+
# local variables — `game`, down at the bottom, among them — and a constant
|
|
102
|
+
# holding that block keeps them for the life of the process. The window is
|
|
103
|
+
# then never released, which shows up not as a leak but as a crash on the way
|
|
104
|
+
# out. Inside a class body there is no such scope to capture.
|
|
105
|
+
ROWS = {
|
|
106
|
+
fullscreen: { label: 'fullscreen', default: false,
|
|
107
|
+
values: [false, true].freeze,
|
|
108
|
+
display: ->(on) { on ? 'on' : 'off' } },
|
|
109
|
+
scale: { label: 'scale', default: :letterbox,
|
|
110
|
+
values: RGame::Engine::Presentation::MODES,
|
|
111
|
+
display: RGame::Engine::UI::OptionButton::DISPLAY },
|
|
112
|
+
volume: { label: 'volume', default: 75,
|
|
113
|
+
values: [0, 25, 50, 75, 100].freeze,
|
|
114
|
+
display: ->(percent) { RGame::Engine::Text.literal("#{percent}%") } }
|
|
115
|
+
}.freeze
|
|
116
|
+
|
|
117
|
+
def initialize(save)
|
|
118
|
+
@save = save
|
|
119
|
+
@values = load
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def [](key) = @values.fetch(key)
|
|
123
|
+
|
|
124
|
+
# Writes the whole file on every change. See the note at the top of this file.
|
|
125
|
+
def []=(key, value)
|
|
126
|
+
@values[key] = value
|
|
127
|
+
@save.write(@values)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Volume reaches the device as a gain, where 1.0 is unchanged.
|
|
131
|
+
def volume_gain = @values.fetch(:volume) / 100.0
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# Anything the menu does not offer becomes the default. See "A settings file
|
|
136
|
+
# is untrusted input".
|
|
137
|
+
def load
|
|
138
|
+
stored = @save.read
|
|
139
|
+
ROWS.to_h do |key, row|
|
|
140
|
+
value = coerce(key, stored[key])
|
|
141
|
+
[key, row.fetch(:values).include?(value) ? value : row.fetch(:default)]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# JSON has no Symbol, so `:letterbox` comes back as `"letterbox"`. Converting
|
|
146
|
+
# here rather than at the call site keeps the rest of the file working in the
|
|
147
|
+
# values the game actually uses.
|
|
148
|
+
def coerce(key, value) = key == :scale && value.is_a?(String) ? value.to_sym : value
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The root: scene navigation, and the settings every screen shares.
|
|
152
|
+
class Shell < RGame::Engine::Node2D
|
|
153
|
+
def initialize(settings:)
|
|
154
|
+
super()
|
|
155
|
+
@settings = settings
|
|
156
|
+
@stack = add_component(RGame::Engine::Scene::SceneStack.new)
|
|
157
|
+
@pending = nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def on_add = show(:title)
|
|
161
|
+
|
|
162
|
+
# `push` keeps what is under it; `replace` does not; `pop` returns to it. Each
|
|
163
|
+
# one records the request and returns, for the reason at the top of this file.
|
|
164
|
+
def show(name) = @pending = [:push, name]
|
|
165
|
+
def swap(name) = @pending = [:replace, name]
|
|
166
|
+
def back = @pending = [:pop, nil]
|
|
167
|
+
|
|
168
|
+
# Runs after the active scene's own update has unwound, so the tree being
|
|
169
|
+
# rebuilt is not one the traversal is standing in.
|
|
170
|
+
def on_update(_dt)
|
|
171
|
+
return unless @pending
|
|
172
|
+
|
|
173
|
+
action, name = @pending
|
|
174
|
+
@pending = nil
|
|
175
|
+
case action
|
|
176
|
+
when :push then @stack.push(build(name))
|
|
177
|
+
when :replace then @stack.replace(build(name))
|
|
178
|
+
when :pop then @stack.pop
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def build(name)
|
|
185
|
+
case name
|
|
186
|
+
when :title then TitleScene.new
|
|
187
|
+
when :settings then SettingsScene.new(settings: @settings)
|
|
188
|
+
when :play then PlayScene.new
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The title: a heading and three choices.
|
|
194
|
+
#
|
|
195
|
+
# The menu sits at a fixed inset rather than centred, which is the same
|
|
196
|
+
# limitation `examples/game_menu` runs into — centring needs the region's size
|
|
197
|
+
# and that only arrives at draw time. See docs/api/ui.md, "What this is not".
|
|
198
|
+
class TitleScene < RGame::Engine::Node2D
|
|
199
|
+
MENU_X = 56
|
|
200
|
+
MENU_Y = 104
|
|
201
|
+
ITEM_WIDTH = 240
|
|
202
|
+
ITEM_HEIGHT = 34
|
|
203
|
+
|
|
204
|
+
def initialize(**)
|
|
205
|
+
super
|
|
206
|
+
@heading = RGame::Engine::Text.new('title.heading')
|
|
207
|
+
@help = RGame::Engine::Text.new('title.help')
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def on_add
|
|
211
|
+
menu = add_node(UI::Menu.new(x: MENU_X, y: MENU_Y, scope: 'title',
|
|
212
|
+
layout: UI::Column.new(item_width: ITEM_WIDTH, item_height: ITEM_HEIGHT)))
|
|
213
|
+
menu.add(UI::PanelButton.new(label: 'play')).on_activated { root.swap(:play) }
|
|
214
|
+
menu.add(UI::PanelButton.new(label: 'settings')).on_activated { root.show(:settings) }
|
|
215
|
+
menu.add(UI::PanelButton.new(label: 'quit')).on_activated { root.context.close }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def on_draw(renderer, _view)
|
|
219
|
+
renderer.text(@heading, MENU_X, 44)
|
|
220
|
+
renderer.text(@help, MENU_X, 68)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The settings screen, pushed over the title.
|
|
225
|
+
#
|
|
226
|
+
# It draws a panel because the title is still drawing behind it — that is the
|
|
227
|
+
# visible half of what pushing rather than replacing means.
|
|
228
|
+
class SettingsScene < RGame::Engine::Node2D
|
|
229
|
+
PANEL_X = 28
|
|
230
|
+
PANEL_Y = 28
|
|
231
|
+
PANEL_WIDTH = WIDTH - (PANEL_X * 2)
|
|
232
|
+
PANEL_HEIGHT = HEIGHT - (PANEL_Y * 2)
|
|
233
|
+
PADDING = 18
|
|
234
|
+
ITEM_WIDTH = PANEL_WIDTH - (PADDING * 2)
|
|
235
|
+
ITEM_HEIGHT = 34
|
|
236
|
+
|
|
237
|
+
def initialize(settings:, **)
|
|
238
|
+
super(**)
|
|
239
|
+
@settings = settings
|
|
240
|
+
@heading = RGame::Engine::Text.new('settings.heading')
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def on_add
|
|
244
|
+
@menu = add_node(UI::Menu.new(x: PANEL_X + PADDING, y: PANEL_Y + PADDING + 30, scope: 'settings',
|
|
245
|
+
layout: UI::Column.new(item_width: ITEM_WIDTH, item_height: ITEM_HEIGHT)))
|
|
246
|
+
Settings::ROWS.each { |key, row| option(key, row) }
|
|
247
|
+
@menu.add(UI::PanelButton.new(label: 'back')).on_activated { root.back }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Escape does what Back does, because that is what every player will try
|
|
251
|
+
# first. Both go through the same deferred request, so pressing one on the
|
|
252
|
+
# same tick as the other is still one pop.
|
|
253
|
+
def on_control(actions)
|
|
254
|
+
root.back if actions.pressed?(:ui_cancel)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def on_draw(renderer, _view)
|
|
258
|
+
renderer.nine_slice(:panel, PANEL_X, PANEL_Y, PANEL_WIDTH, PANEL_HEIGHT)
|
|
259
|
+
renderer.text(@heading, PANEL_X + PADDING, PANEL_Y + PADDING, z: 1)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
# One row per setting, built from the same table the file is validated
|
|
265
|
+
# against, and starting on whatever is currently in force.
|
|
266
|
+
def option(key, row)
|
|
267
|
+
values = row.fetch(:values)
|
|
268
|
+
button = @menu.add(UI::OptionButton.new(label: row.fetch(:label), values: values, display: row.fetch(:display),
|
|
269
|
+
index: values.index(@settings[key]) || 0))
|
|
270
|
+
button.on_changed { |value| change(key, value) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def change(key, value)
|
|
274
|
+
@settings[key] = value
|
|
275
|
+
apply(key, value)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# `root.context` is the Game. A node may not *name* RGame::Core, but it may
|
|
279
|
+
# call methods on an object it is handed — the same duck-typing it uses on a
|
|
280
|
+
# renderer, and what `examples/fullscreen` does with the same object.
|
|
281
|
+
def apply(key, value)
|
|
282
|
+
game = root.context
|
|
283
|
+
case key
|
|
284
|
+
when :fullscreen then game.fullscreen = value
|
|
285
|
+
when :scale then game.scale_mode = value
|
|
286
|
+
when :volume then game.audio.volume = value / 100.0
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Somewhere for the settings to be true: a marker sweeping the view, and a sound
|
|
292
|
+
# on demand so the volume is something you can hear rather than read.
|
|
293
|
+
class PlayScene < RGame::Engine::Node2D
|
|
294
|
+
MARGIN = 34
|
|
295
|
+
RADIUS = 16
|
|
296
|
+
SPEED = 0.6 # sweeps per second
|
|
297
|
+
DISC = RGame::Util::Color.new(180, 160, 240)
|
|
298
|
+
EDGE = RGame::Util::Color.new(120, 200, 255)
|
|
299
|
+
THICK = 3
|
|
300
|
+
|
|
301
|
+
def initialize(**)
|
|
302
|
+
super
|
|
303
|
+
@phase = 0.0
|
|
304
|
+
@help = RGame::Engine::Text.new('play.help')
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def on_control(actions)
|
|
308
|
+
RGame::Engine::AudioBus.play_sound(BLIP) if actions.pressed?(:ui_confirm)
|
|
309
|
+
root.swap(:title) if actions.pressed?(:ui_cancel)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# The sweep is state advanced by dt, and where it lands on screen is worked
|
|
313
|
+
# out from the view at draw time — so it follows a window that grew without
|
|
314
|
+
# anything here listening for the change.
|
|
315
|
+
def on_update(dt)
|
|
316
|
+
@phase = (@phase + (dt * SPEED)) % 2.0
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def on_draw(renderer, view)
|
|
320
|
+
right = view.width - MARGIN
|
|
321
|
+
bottom = view.height - MARGIN
|
|
322
|
+
renderer.line(MARGIN, MARGIN, right, MARGIN, thickness: THICK, color: EDGE)
|
|
323
|
+
renderer.line(MARGIN, bottom, right, bottom, thickness: THICK, color: EDGE)
|
|
324
|
+
|
|
325
|
+
travelled = @phase > 1.0 ? 2.0 - @phase : @phase # there, then back
|
|
326
|
+
renderer.circle(MARGIN + (travelled * (right - MARGIN)), view.height / 2,
|
|
327
|
+
RADIUS, color: DISC)
|
|
328
|
+
|
|
329
|
+
renderer.text(@help, MARGIN, 16)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# `dir:` is normally left out and the file lands in the platform's own data
|
|
334
|
+
# directory. It is overridable so a driven run writes somewhere disposable
|
|
335
|
+
# instead of into the home directory of whoever is running it.
|
|
336
|
+
settings = Settings.new(
|
|
337
|
+
RGame::Util::SaveFile.new('settings.json', game: 'rgame-examples',
|
|
338
|
+
dir: ENV.fetch('RGAME_SAVE_DIR', nil))
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Fullscreen and the scale mode are **constructor arguments**, not something
|
|
342
|
+
# applied on the first tick: a window switched after it is up shows one windowed
|
|
343
|
+
# frame first, and that flash reads as a broken startup. See
|
|
344
|
+
# `examples/fullscreen`. Volume has no such problem — there is no first frame of
|
|
345
|
+
# sound — so it is set on the device below.
|
|
346
|
+
game = RGame::Game.new(
|
|
347
|
+
root: Shell.new(settings: settings),
|
|
348
|
+
caption: 'Menu navigation',
|
|
349
|
+
width: WIDTH,
|
|
350
|
+
height: HEIGHT,
|
|
351
|
+
media_root: ASSETS,
|
|
352
|
+
locales: LOCALES,
|
|
353
|
+
fullscreen: settings[:fullscreen],
|
|
354
|
+
scale_mode: settings[:scale]
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
game.audio.volume = settings.volume_gain
|
|
358
|
+
|
|
359
|
+
# A nine-slice id names an *element of an atlas* rather than a file, so there is
|
|
360
|
+
# nothing for the asset manager to resolve `:panel` to on demand. Registering
|
|
361
|
+
# the atlas binds every element in it, which is what the menu rows draw
|
|
362
|
+
# themselves from as well.
|
|
363
|
+
game.renderer.register_ui_atlas(game.assets.ui_atlas('ui.json'))
|
|
364
|
+
|
|
365
|
+
game.start
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Music — a looping track, started and stopped.
|
|
4
|
+
#
|
|
5
|
+
# Run it:
|
|
6
|
+
#
|
|
7
|
+
# ruby examples/music/main.rb
|
|
8
|
+
#
|
|
9
|
+
# Enter or Space starts it; Escape stops it. Press start again while it is
|
|
10
|
+
# already playing and **nothing happens** — that is deliberate, and the reason
|
|
11
|
+
# is below. It exercises:
|
|
12
|
+
# - Core::Song — a streamed track with one voice, which can be stopped and
|
|
13
|
+
# asked whether it is playing, named by its path;
|
|
14
|
+
# - Engine::AudioBus play_music / stop_music;
|
|
15
|
+
# - Engine::AudioDirector, the same one `examples/sound` uses, subscribed by
|
|
16
|
+
# RGame::Game.
|
|
17
|
+
#
|
|
18
|
+
# ## A Song is not a Sample
|
|
19
|
+
#
|
|
20
|
+
# The two types exist so that this distinction is in the type rather than in a
|
|
21
|
+
# convention. A `Sample` (see `examples/sound`) is decoded up front and gets a
|
|
22
|
+
# fresh voice per play, so it layers and `playing?` would be meaningless. A
|
|
23
|
+
# `Song` is *streamed* and has exactly one voice: it can be stopped, and it can
|
|
24
|
+
# be asked whether it is running.
|
|
25
|
+
#
|
|
26
|
+
# ## Starting it twice does not restart it
|
|
27
|
+
#
|
|
28
|
+
# `Audio#play_music` returns early when the song is already playing. That is not
|
|
29
|
+
# a nicety — a scene that emits `play_music` from `on_add` every time it is
|
|
30
|
+
# entered would otherwise chop the track back to zero each time the player
|
|
31
|
+
# walked through a door.
|
|
32
|
+
#
|
|
33
|
+
# **You confirm that by ear, not from this window.** Press Enter again while it
|
|
34
|
+
# is playing: the bar below carries on, but that only proves *this scene* did
|
|
35
|
+
# not reset its own timer. Whether the audio restarted is a fact about the
|
|
36
|
+
# device, and the only honest way to check it is to listen for the track
|
|
37
|
+
# jumping back to its opening bar.
|
|
38
|
+
#
|
|
39
|
+
# ## The bar is state, not a clock
|
|
40
|
+
#
|
|
41
|
+
# Nothing on a draw path reads a clock — `draw` is not called on a schedule and
|
|
42
|
+
# a wall clock cannot be paused or reproduced (CLAUDE.md, "`draw` renders
|
|
43
|
+
# state"). So the seconds below are accumulated from `dt` in `update`. It is a
|
|
44
|
+
# rough playhead, not a reading off the song: it says how long ago this scene
|
|
45
|
+
# *asked* for music, which is close enough to watch the 24s loop point go past
|
|
46
|
+
# and listen for whether the wrap clicks. `tools/shrink_ogg.c` measures the seam
|
|
47
|
+
# at 2.3% of the music's own largest step *and* the silence at each end at zero
|
|
48
|
+
# — this is where you find out whether those numbers were telling the truth.
|
|
49
|
+
|
|
50
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
|
|
51
|
+
require 'rgame/game'
|
|
52
|
+
|
|
53
|
+
WIDTH = 640
|
|
54
|
+
HEIGHT = 480
|
|
55
|
+
ASSETS = File.expand_path('../assets', __dir__)
|
|
56
|
+
LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
|
|
57
|
+
|
|
58
|
+
LOOP_SECONDS = 24.05 # the length of music.ogg; see examples/assets/README.md
|
|
59
|
+
|
|
60
|
+
class Scene < RGame::Engine::Node2D
|
|
61
|
+
BAR_X = 60
|
|
62
|
+
BAR_Y = 260
|
|
63
|
+
BAR_W = 520
|
|
64
|
+
BAR_H = 26
|
|
65
|
+
TRACK = RGame::Util::Color.new(40, 48, 66)
|
|
66
|
+
FILL = RGame::Util::Color.new(120, 200, 255)
|
|
67
|
+
|
|
68
|
+
def initialize
|
|
69
|
+
super
|
|
70
|
+
@playing = false
|
|
71
|
+
@elapsed = 0.0
|
|
72
|
+
@help = RGame::Engine::Text.new('help.keys')
|
|
73
|
+
@stopped = RGame::Engine::Text.new('status.stopped')
|
|
74
|
+
@started = RGame::Engine::Text.new('status.playing')
|
|
75
|
+
@status = @stopped
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def on_control(actions)
|
|
79
|
+
start if actions.pressed?(:ui_confirm)
|
|
80
|
+
stop if actions.pressed?(:ui_cancel)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def on_update(dt)
|
|
84
|
+
return unless @playing
|
|
85
|
+
|
|
86
|
+
@elapsed += dt
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def on_draw(renderer, _view)
|
|
90
|
+
renderer.text(@help, 12, 12)
|
|
91
|
+
renderer.text(@status, 12, 34)
|
|
92
|
+
|
|
93
|
+
# Where the playhead sits inside one pass of the loop. It wraps at
|
|
94
|
+
# LOOP_SECONDS, so the bar resetting is the loop point going past.
|
|
95
|
+
renderer.rect(BAR_X, BAR_Y, BAR_W, BAR_H, color: TRACK)
|
|
96
|
+
position = @elapsed % LOOP_SECONDS
|
|
97
|
+
renderer.rect(BAR_X, BAR_Y, BAR_W * (position / LOOP_SECONDS), BAR_H, color: FILL)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# The bus call is unconditional on purpose: this node has no idea whether the
|
|
103
|
+
# track is already going, and does not need one. The guard lives in
|
|
104
|
+
# Audio#play_music, which owns the state that answers it.
|
|
105
|
+
#
|
|
106
|
+
# `@playing` below is *not* that state duplicated — it only drives the bar and
|
|
107
|
+
# the label, which is why the bar carrying on through a second press proves
|
|
108
|
+
# nothing about the device.
|
|
109
|
+
def start
|
|
110
|
+
RGame::Engine::AudioBus.play_music('music.ogg')
|
|
111
|
+
return if @playing
|
|
112
|
+
|
|
113
|
+
@playing = true
|
|
114
|
+
@status = @started
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def stop
|
|
118
|
+
RGame::Engine::AudioBus.stop_music
|
|
119
|
+
@playing = false
|
|
120
|
+
@elapsed = 0.0
|
|
121
|
+
@status = @stopped
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
game = RGame::Game.new(
|
|
126
|
+
root: Scene.new,
|
|
127
|
+
caption: 'Music',
|
|
128
|
+
width: WIDTH,
|
|
129
|
+
height: HEIGHT,
|
|
130
|
+
media_root: ASSETS,
|
|
131
|
+
locales: LOCALES
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
game.start
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# The English table: every string this example draws.
|
|
2
|
+
en:
|
|
3
|
+
help:
|
|
4
|
+
cursor: Arrows / d-pad move the cursor; Return / Space / A sends the hero
|
|
5
|
+
route: "Dots: the route the search found. Lines: the route the hero walks"
|
|
6
|
+
status:
|
|
7
|
+
idle: Move the cursor to a tile and confirm
|
|
8
|
+
walking: "walking: %{waypoints}, %{cells}"
|
|
9
|
+
arrived: "arrived: %{waypoints}, %{cells}"
|
|
10
|
+
waypoints:
|
|
11
|
+
one: "%{count} waypoint"
|
|
12
|
+
other: "%{count} waypoints"
|
|
13
|
+
cells:
|
|
14
|
+
one: "%{count} cell"
|
|
15
|
+
other: "%{count} cells"
|
|
16
|
+
refused: no route there
|
|
17
|
+
refused_after: "no route there; %{route}"
|