rgame 0.2.0 → 0.3.1
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 +144 -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 +3 -5
- 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 +167 -12
- 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,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Collision tiles — walking into a wall.
|
|
4
|
+
#
|
|
5
|
+
# Run it:
|
|
6
|
+
#
|
|
7
|
+
# ruby examples/collision_tiles/main.rb
|
|
8
|
+
#
|
|
9
|
+
# Arrow keys, WASD, a d-pad or a left stick walk the hero. The trees and the
|
|
10
|
+
# fence are solid; hold a diagonal against the fence and watch what happens.
|
|
11
|
+
# East of the start is a spiky ball that stops the hero and costs a life. It
|
|
12
|
+
# exercises:
|
|
13
|
+
# - Components::TileWorld — the scene-scoped system that owns the map, and
|
|
14
|
+
# answers every question about what is solid;
|
|
15
|
+
# - Components::CollisionWorld — the broadphase the ball's box lives in;
|
|
16
|
+
# - Components::FeetCollider — the shape at the hero's feet, derived from the
|
|
17
|
+
# sprite's size, and the one shape both of those stop;
|
|
18
|
+
# - Components::CharacterBody with `blocked_by: %i[tiles spike]` — steps
|
|
19
|
+
# resolved against the map *and* the ball, using that shape;
|
|
20
|
+
# - CharacterBody's `on_blocked` — what stopped a step, as an event;
|
|
21
|
+
# - Components::CameraFollow — a camera on the feet rather than on the head;
|
|
22
|
+
# - Engine::Text — the life count, a key with a variable, rendered only when it changes;
|
|
23
|
+
# - the `:tilemap` asset loader and TileMapLayer, both from `examples/scroll_map`.
|
|
24
|
+
#
|
|
25
|
+
# ## Two indexes, one list of names
|
|
26
|
+
#
|
|
27
|
+
# The walls are not objects. A character against a grid of solid tiles is not a
|
|
28
|
+
# pairwise overlap problem, because there are no pairs to find — a tile map
|
|
29
|
+
# already knows what is where, so the wall a step would hit is arithmetic on the
|
|
30
|
+
# step: divide by the tile size and ask the grid. Bucketing forty thousand tiles
|
|
31
|
+
# into a spatial hash would be building a second index of something already
|
|
32
|
+
# indexed, and then paying for it every frame.
|
|
33
|
+
#
|
|
34
|
+
# The spiky ball is an object, and it is in one. The scene mounts a
|
|
35
|
+
# `CollisionWorld`, the ball wears a box on the `:spike` layer, and the hero
|
|
36
|
+
# names that layer beside `:tiles`. So a step here is resolved against a grid and
|
|
37
|
+
# against a broadphase at once, one axis at a time, stopping at whichever answer
|
|
38
|
+
# is nearer — and the only place in this file where that shows is the list of
|
|
39
|
+
# names in `blocked_by`.
|
|
40
|
+
#
|
|
41
|
+
# `examples/collision` is the third case, and the one neither of those is: shapes
|
|
42
|
+
# that are told when they start and stop *overlapping*, with nothing stopped.
|
|
43
|
+
#
|
|
44
|
+
# ## What is solid is decided in Tiled, not in code
|
|
45
|
+
#
|
|
46
|
+
# A tile is solid because it carries a collision shape in the tileset — an
|
|
47
|
+
# `<objectgroup>` on that tile in `tileset.tsx` — and `Engine::Tileset` reads
|
|
48
|
+
# that. Nothing in this file lists tile ids, and adding a solid tile to the map
|
|
49
|
+
# means drawing it in Tiled.
|
|
50
|
+
#
|
|
51
|
+
# ## A feet box, not the sprite's box
|
|
52
|
+
#
|
|
53
|
+
# The translucent rectangle drawn over the hero is the thing that actually
|
|
54
|
+
# collides. It is twelve by six at the bottom of a sixteen by twenty-two sprite,
|
|
55
|
+
# and everything above it is a picture that nothing tests.
|
|
56
|
+
#
|
|
57
|
+
# A top-down view needs that. The sprite is drawn as though seen from in front,
|
|
58
|
+
# so its upper half is *height* rather than floor, and a character whose whole
|
|
59
|
+
# frame collided could not stand with their head overlapping the fence behind
|
|
60
|
+
# them — which is what standing close to it looks like from this angle.
|
|
61
|
+
#
|
|
62
|
+
# `FeetCollider` builds it from the node's own dimensions, which
|
|
63
|
+
# `AnimatedSprite` fills in from the sprite frame when it attaches. So the box
|
|
64
|
+
# follows the art: re-export the hero at a different size and the feet stay at
|
|
65
|
+
# the feet. Note what the `Hero` does *not* pass — a sprite size, an offset, or
|
|
66
|
+
# the box to anybody else. One component owns the shape and the body reads it.
|
|
67
|
+
#
|
|
68
|
+
# ## Sliding is the whole feel
|
|
69
|
+
#
|
|
70
|
+
# Walk diagonally into the fence and the hero keeps moving. The step is resolved
|
|
71
|
+
# one axis at a time, so the blocked half is dropped and the free half is kept,
|
|
72
|
+
# and a player pressing down-left against a wall travels left along it.
|
|
73
|
+
#
|
|
74
|
+
# It is worth doing deliberately: put the hero against the fence, hold down and
|
|
75
|
+
# left together, and you arrive at the one gap in it without ever steering. A
|
|
76
|
+
# version that stopped dead on contact would be correct by the letter — you did
|
|
77
|
+
# walk into a wall — and would feel broken in a way a player cannot articulate.
|
|
78
|
+
#
|
|
79
|
+
# ## Being stopped is an event
|
|
80
|
+
#
|
|
81
|
+
# `on_blocked` fires on the step something starts stopping the body and
|
|
82
|
+
# `on_unblocked` on the step it stops, each once per blocker. That is what spends
|
|
83
|
+
# a life here: walk into the ball and lose one, keep pushing and lose no more,
|
|
84
|
+
# back off and walk in again and lose another.
|
|
85
|
+
#
|
|
86
|
+
# It has to be that signal rather than `on_hit`, and this is the part worth
|
|
87
|
+
# reading twice. Blocking leaves the two boxes exactly *touching*, and an overlap
|
|
88
|
+
# test is half-open, so a successfully blocked pair does not overlap and reports
|
|
89
|
+
# no contact at all. A ball that hurt through `on_hit` would have to be walked
|
|
90
|
+
# into — which is exactly what blocking it prevents.
|
|
91
|
+
#
|
|
92
|
+
# The handler is given whatever stopped the step, and reads `layer` and `node`
|
|
93
|
+
# off it the same way whichever kind it was. So the hero's one handler asks
|
|
94
|
+
# `by.layer == :spike` and ignores the fence, which arrives as `:tiles` with no
|
|
95
|
+
# node behind it.
|
|
96
|
+
#
|
|
97
|
+
# ## The system is not optional, and says so
|
|
98
|
+
#
|
|
99
|
+
# `blocked_by: [:tiles]` raises at attach when the scene has no `TileWorld`, and
|
|
100
|
+
# again when the node has no collider to resolve, rather than falling back to
|
|
101
|
+
# free movement. The fallback would look like a collision bug — an actor walking
|
|
102
|
+
# through walls — and the cause would be a scene three files away that never
|
|
103
|
+
# mounted the system.
|
|
104
|
+
#
|
|
105
|
+
# ## What this scene does not solve
|
|
106
|
+
#
|
|
107
|
+
# Nothing here moves but the hero. The ball is an ordinary actor with a box in
|
|
108
|
+
# the broadphase, so it could walk about and block just the same — a crowd whose
|
|
109
|
+
# every walker declares `%i[tiles hero npc]` is that, and shoulders past itself. A hazard that holds still is
|
|
110
|
+
# simply the smaller thing to read.
|
|
111
|
+
#
|
|
112
|
+
# Nor is anything here overlapping. Blocking and contact are reports of two
|
|
113
|
+
# different facts and a game usually wants both: a trigger walked through, a
|
|
114
|
+
# pickup that vanishes, a puddle that slows whoever stands in it. Every one of
|
|
115
|
+
# those is `on_hit` on a box nobody named in a `blocked_by`, which is
|
|
116
|
+
# `examples/collision`.
|
|
117
|
+
|
|
118
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
|
|
119
|
+
require 'rgame/game'
|
|
120
|
+
|
|
121
|
+
WIDTH = 640
|
|
122
|
+
HEIGHT = 480
|
|
123
|
+
ASSETS = File.expand_path('../assets', __dir__)
|
|
124
|
+
LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
|
|
125
|
+
|
|
126
|
+
MAP = 'town.tmx'
|
|
127
|
+
SPEED = 80.0 # px/s
|
|
128
|
+
|
|
129
|
+
# The feet: twelve wide and six tall, centred at the bottom of the 16x22 sprite.
|
|
130
|
+
FEET_WIDTH = 12
|
|
131
|
+
FEET_HEIGHT = 6
|
|
132
|
+
|
|
133
|
+
# Where the camera looks: the middle of that box rather than the node's origin,
|
|
134
|
+
# which is the sprite's top-left corner. Without it the hero drifts below the
|
|
135
|
+
# centre of the screen by half a sprite, which reads as the camera lagging.
|
|
136
|
+
CAMERA_OFFSET_X = 8
|
|
137
|
+
CAMERA_OFFSET_Y = 19
|
|
138
|
+
|
|
139
|
+
# North of the fence and a few tiles east of its gap, so that holding a diagonal
|
|
140
|
+
# into it arrives there.
|
|
141
|
+
START_X = 384.0
|
|
142
|
+
START_Y = 272.0
|
|
143
|
+
|
|
144
|
+
# The spiky ball: three tiles east of the hero, on the open ground between the
|
|
145
|
+
# trees, and low enough that its box sits across the hero's feet.
|
|
146
|
+
BALL_X = 434.0
|
|
147
|
+
BALL_Y = 284.0
|
|
148
|
+
BALL_SIZE = 12
|
|
149
|
+
|
|
150
|
+
LIVES = 3
|
|
151
|
+
|
|
152
|
+
# A sprite, a feet box, a body that knows about walls, and a camera. The only
|
|
153
|
+
# thing this class writes itself is the drawing of that box, so what collides is
|
|
154
|
+
# visible.
|
|
155
|
+
class Hero < RGame::Engine::Node2D
|
|
156
|
+
FEET = RGame::Util::Color.rgba(255, 110, 110, 120)
|
|
157
|
+
|
|
158
|
+
attr_reader :lives
|
|
159
|
+
|
|
160
|
+
def initialize(camera:, **)
|
|
161
|
+
super(**)
|
|
162
|
+
@lives = LIVES
|
|
163
|
+
add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
|
|
164
|
+
# The two lines that differ from `examples/walk`: a shape, and a body told
|
|
165
|
+
# what that shape may not pass through. Everything about the intent — the
|
|
166
|
+
# controller writing it, the sprite reading it back as a facing, the speed it
|
|
167
|
+
# is scaled by — is the same CharacterBody as there.
|
|
168
|
+
@collider = add_component(RGame::Engine::Components::FeetCollider.new(
|
|
169
|
+
width: FEET_WIDTH, height: FEET_HEIGHT
|
|
170
|
+
))
|
|
171
|
+
body = add_component(RGame::Engine::Components::CharacterBody.new(
|
|
172
|
+
speed: SPEED, blocked_by: %i[tiles spike]
|
|
173
|
+
))
|
|
174
|
+
# One handler for everything that can stop a step, because everything that
|
|
175
|
+
# can stop a step reports the same two things. The fence arrives here too, as
|
|
176
|
+
# :tiles with no node behind it, and is ignored.
|
|
177
|
+
body.on_blocked { |by| @lives = [@lives - 1, 0].max if by.layer == :spike }
|
|
178
|
+
add_component(RGame::Engine::Components::PlayerController.new)
|
|
179
|
+
add_component(RGame::Engine::Components::CameraFollow.new(
|
|
180
|
+
camera: camera, offset_x: CAMERA_OFFSET_X, offset_y: CAMERA_OFFSET_Y
|
|
181
|
+
))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Components draw first, so this lands over the sprite. The box's offsets are
|
|
185
|
+
# relative to the node's origin, which is exactly where the renderer already
|
|
186
|
+
# is — a collision box and local space agree about what (0, 0) means.
|
|
187
|
+
def on_draw(renderer, _view)
|
|
188
|
+
box = @collider.box
|
|
189
|
+
renderer.rect(box.offset_x, box.offset_y, box.width, box.height, color: FEET)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The hazard: a box on the `:spike` layer, and a drawing of a ball with spikes on
|
|
194
|
+
# it. It has no body and no controller — it never moves — so the only thing it
|
|
195
|
+
# contributes to a step is its rectangle, sitting in the broadphase waiting to be
|
|
196
|
+
# found.
|
|
197
|
+
class SpikyBall < RGame::Engine::Node2D
|
|
198
|
+
BODY = RGame::Util::Color.rgba(190, 90, 210, 255)
|
|
199
|
+
SPIKE = RGame::Util::Color.rgba(120, 40, 140, 255)
|
|
200
|
+
RADIUS = BALL_SIZE / 2.0
|
|
201
|
+
SPIKE_LENGTH = 5
|
|
202
|
+
|
|
203
|
+
def initialize(**)
|
|
204
|
+
super
|
|
205
|
+
add_component(RGame::Engine::Components::BoxCollider.new(
|
|
206
|
+
width: BALL_SIZE, height: BALL_SIZE, layer: :spike
|
|
207
|
+
))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Local space, like every other on_draw: the node's transform is already
|
|
211
|
+
# pushed, so the ball is drawn around its own origin and lands wherever the
|
|
212
|
+
# node is.
|
|
213
|
+
def on_draw(renderer, _view)
|
|
214
|
+
renderer.line(RADIUS, -SPIKE_LENGTH, RADIUS, BALL_SIZE + SPIKE_LENGTH, thickness: 2.0, color: SPIKE)
|
|
215
|
+
renderer.line(-SPIKE_LENGTH, RADIUS, BALL_SIZE + SPIKE_LENGTH, RADIUS, thickness: 2.0, color: SPIKE)
|
|
216
|
+
renderer.circle(RADIUS, RADIUS, RADIUS, color: BODY)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The scene: mount the map, mount the world, put the hero in it. The same three
|
|
221
|
+
# steps as `examples/scroll_map`, with an actor that collides instead of a rig
|
|
222
|
+
# that does not.
|
|
223
|
+
class Scene < RGame::Engine::Node2D
|
|
224
|
+
def on_add
|
|
225
|
+
map = root.context.assets.tilemap(MAP).map
|
|
226
|
+
players = root.system(RGame::Engine::Players)
|
|
227
|
+
|
|
228
|
+
# Solidity and the world's size come from the same system, because they are
|
|
229
|
+
# the same fact about the same map. Handing it the cameras bounds them to
|
|
230
|
+
# the map's edges.
|
|
231
|
+
add_component(RGame::Engine::Components::TileWorld.new(
|
|
232
|
+
map: map, tilemap_id: MAP, cameras: players.map(&:camera)
|
|
233
|
+
))
|
|
234
|
+
# The second index, for the things the map knows nothing about. Its cells are
|
|
235
|
+
# sized to the actors rather than to the 16px tiles: a broadphase cell wants
|
|
236
|
+
# to hold a handful of the things it buckets, and these are a dozen pixels
|
|
237
|
+
# across.
|
|
238
|
+
add_component(RGame::Engine::Components::CollisionWorld.new(cell_size: 32))
|
|
239
|
+
|
|
240
|
+
view = add_node(RGame::Engine::WorldView.new)
|
|
241
|
+
# The node handed back is the gap between the ground layers and anything
|
|
242
|
+
# Tiled flags `above` — where things that walk around belong.
|
|
243
|
+
actors = RGame::Engine::TileMapLayer.mount(view)
|
|
244
|
+
actors.add_node(SpikyBall.new(x: BALL_X, y: BALL_Y))
|
|
245
|
+
@hero = actors.add_node(Hero.new(camera: players.primary.camera, x: START_X, y: START_Y))
|
|
246
|
+
# Built here rather than in on_draw: the text renders once per change of the
|
|
247
|
+
# count, and the frames in between read the string it kept.
|
|
248
|
+
@lives_label = RGame::Engine::Text.new('hud.lives', :lives)
|
|
249
|
+
@help_walk = RGame::Engine::Text.new('help.walk')
|
|
250
|
+
@help_slide = RGame::Engine::Text.new('help.slide')
|
|
251
|
+
@help_box = RGame::Engine::Text.new('help.box')
|
|
252
|
+
@help_ball = RGame::Engine::Text.new('help.ball')
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Screen space: outside the WorldView, so it stays put while the map scrolls.
|
|
256
|
+
def on_draw(renderer, _view)
|
|
257
|
+
renderer.text(@help_walk, 12, 12)
|
|
258
|
+
renderer.text(@help_slide, 12, 34)
|
|
259
|
+
renderer.text(@help_box, 12, 56)
|
|
260
|
+
renderer.text(@help_ball, 12, 78)
|
|
261
|
+
renderer.text(@lives_label.with(lives: @hero.lives), 12, 100)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
game = RGame::Game.new(
|
|
266
|
+
root: Scene.new,
|
|
267
|
+
caption: 'Collision tiles',
|
|
268
|
+
width: WIDTH,
|
|
269
|
+
height: HEIGHT,
|
|
270
|
+
media_root: ASSETS,
|
|
271
|
+
locales: LOCALES
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
game.start
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# The English table: every string this example draws.
|
|
2
|
+
en:
|
|
3
|
+
state:
|
|
4
|
+
fullscreen: fullscreen — F returns to a window
|
|
5
|
+
windowed: windowed — F goes fullscreen
|
|
6
|
+
modes:
|
|
7
|
+
disabled: "left/right — scale_mode :disabled, the view is the window"
|
|
8
|
+
stretch: "left/right — scale_mode :stretch, fills and distorts"
|
|
9
|
+
letterbox: "left/right — scale_mode :letterbox, uniform with bars"
|
|
10
|
+
integer: "left/right — scale_mode :integer, whole-number scale only"
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fullscreen — starting fullscreen, and switching while the game runs.
|
|
4
|
+
#
|
|
5
|
+
# Run it windowed or fullscreen, and start in any scale mode:
|
|
6
|
+
#
|
|
7
|
+
# ruby examples/fullscreen/main.rb
|
|
8
|
+
# RGAME_FULLSCREEN=1 ruby examples/fullscreen/main.rb
|
|
9
|
+
# RGAME_SCALE=integer ruby examples/fullscreen/main.rb
|
|
10
|
+
#
|
|
11
|
+
# **F** (or Y on a pad) switches fullscreen. **Left and right** cycle through all
|
|
12
|
+
# four scale modes. Watch the circle: it is round in three of them and an ellipse
|
|
13
|
+
# in `:stretch`, which is the whole difference between uniform and per-axis
|
|
14
|
+
# scaling in one shape. It exercises:
|
|
15
|
+
# - RGame::Game.new(fullscreen:) — the window opens that way;
|
|
16
|
+
# - App#fullscreen? / #fullscreen= — switching at any time;
|
|
17
|
+
# - RGame::Game#scale_mode= — switching that at any time too;
|
|
18
|
+
# - the `view` a node is drawn with — where its size comes from;
|
|
19
|
+
# - InputMap.default.merge — declaring one action of your own.
|
|
20
|
+
#
|
|
21
|
+
# ## Two ways in, because games need both
|
|
22
|
+
#
|
|
23
|
+
# **Opening fullscreen is a constructor argument, not a switch afterwards.**
|
|
24
|
+
# Both end up fullscreen, but a switch made after the window is up shows one
|
|
25
|
+
# windowed frame first — the flash that reads as a broken startup. Passing
|
|
26
|
+
# `fullscreen: true` creates the window fullscreen and there is no first frame
|
|
27
|
+
# to see. A game whose settings file says fullscreen should pass the setting
|
|
28
|
+
# here, not apply it on the first tick.
|
|
29
|
+
#
|
|
30
|
+
# **A game need not offer a way back.** Nothing here is required: leave the
|
|
31
|
+
# toggle out and the game runs fullscreen for its whole life, which is a
|
|
32
|
+
# perfectly ordinary choice.
|
|
33
|
+
#
|
|
34
|
+
# ## Desktop fullscreen, not a mode change
|
|
35
|
+
#
|
|
36
|
+
# The window takes the whole screen at the screen's own resolution. Nothing asks
|
|
37
|
+
# the display to switch mode, so the change is instant, costs no mode list to
|
|
38
|
+
# choose from, and leaves every other window where it was. What a game gets is a
|
|
39
|
+
# bigger view rather than a different one.
|
|
40
|
+
#
|
|
41
|
+
# ## Two answers to a bigger window, and this file shows both
|
|
42
|
+
#
|
|
43
|
+
# Under `:disabled` the view *is* the window: it grows, and the border below
|
|
44
|
+
# grows with it because it is drawn from `view.width`. Extra screen becomes
|
|
45
|
+
# extra room. That is what a tool or an editor wants, and it is why the mode
|
|
46
|
+
# exists — but it is the opt-out rather than the default.
|
|
47
|
+
#
|
|
48
|
+
# Under any other mode `width` and `height` stop describing the window and start
|
|
49
|
+
# describing the game. The view stays 512x320 however big the window is, and the
|
|
50
|
+
# whole frame is scaled onto it — so the border stays exactly where it is and
|
|
51
|
+
# gets bigger. That is what a play area wants, it is what `RGame::Game` does
|
|
52
|
+
# unless told otherwise, and it is the only one of the two that saves a game
|
|
53
|
+
# whose layout is hardcoded.
|
|
54
|
+
#
|
|
55
|
+
# ## Why 512x320, which is nobody's screen
|
|
56
|
+
#
|
|
57
|
+
# The size is picked to make the four modes *differ*, which is the opposite of
|
|
58
|
+
# what a real game wants and exactly what an example about them needs.
|
|
59
|
+
#
|
|
60
|
+
# A logical size that shares its aspect ratio with the display makes `:stretch`
|
|
61
|
+
# and `:letterbox` compute the same numbers, and one that divides the display
|
|
62
|
+
# evenly makes `:integer` agree with both. Choose 640x360 on a 1920x1080 screen
|
|
63
|
+
# and all three land on 3.000x3.000 — four modes, three of them identical, and
|
|
64
|
+
# nothing to look at. **The prettiest resolution is the one that teaches least.**
|
|
65
|
+
#
|
|
66
|
+
# 8:5 matches no common display, so `:stretch` always distorts. And the fit is
|
|
67
|
+
# never a whole number on a 16:9 screen, so `:integer` always gives up some
|
|
68
|
+
# screen that `:letterbox` keeps. Measured, on the sizes people actually own:
|
|
69
|
+
#
|
|
70
|
+
# screen stretch letterbox integer
|
|
71
|
+
# 1920x1080 3.75 x 3.375 3.375 3
|
|
72
|
+
# 2560x1440 5.00 x 4.500 4.500 4
|
|
73
|
+
# 3840x2160 7.50 x 6.750 6.750 6
|
|
74
|
+
#
|
|
75
|
+
# A game would choose the other way round. Pick the logical size so that the
|
|
76
|
+
# scale on your players' screens is a whole number, and `:integer` costs nothing
|
|
77
|
+
# at all.
|
|
78
|
+
#
|
|
79
|
+
# Cycling through the four with left and right is the quickest way to see what
|
|
80
|
+
# each costs:
|
|
81
|
+
#
|
|
82
|
+
# :disabled the border grows to the window; nothing is scaled
|
|
83
|
+
# :stretch fills the window; the circle goes oval
|
|
84
|
+
# :letterbox uniform, centred, bars on two sides
|
|
85
|
+
# :integer the same but whole-number only, so usually wider bars
|
|
86
|
+
#
|
|
87
|
+
# `:integer` is what keeps pixel art crisp: at 1.875x some source pixels cover
|
|
88
|
+
# two screen pixels and some cover one, and the unevenness crawls as things
|
|
89
|
+
# move. It costs screen — see RGame::Engine::Presentation for the measurements
|
|
90
|
+
# against real screen sizes.
|
|
91
|
+
#
|
|
92
|
+
# ## Layout comes from the view, not from a constant
|
|
93
|
+
#
|
|
94
|
+
# `WIDTH` and `HEIGHT` below are what the *window* opens at and returns to. They
|
|
95
|
+
# are not what to draw against: fullscreen makes the view the size of the
|
|
96
|
+
# screen. Every `draw` is handed the `view` it is being drawn into, and
|
|
97
|
+
# `view.width` / `view.height` are the only honest source for "how big is the
|
|
98
|
+
# thing I am drawing in" — which is why the border below follows the switch with
|
|
99
|
+
# nothing listening for it.
|
|
100
|
+
|
|
101
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
|
|
102
|
+
require 'rgame/game'
|
|
103
|
+
|
|
104
|
+
Controls = RGame::Util::Controls
|
|
105
|
+
|
|
106
|
+
# Deliberately not a shape any display has — see the note above on why an
|
|
107
|
+
# example about scale modes wants a resolution that scales *badly*.
|
|
108
|
+
WIDTH = 512
|
|
109
|
+
HEIGHT = 320
|
|
110
|
+
ASSETS = File.expand_path('../assets', __dir__)
|
|
111
|
+
LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
|
|
112
|
+
|
|
113
|
+
# Set in the environment so one file can demonstrate both openings without being
|
|
114
|
+
# edited. A real game reads this from its settings file — see the plan's
|
|
115
|
+
# `examples/save_load`.
|
|
116
|
+
START_FULLSCREEN = ENV.fetch('RGAME_FULLSCREEN', '0') != '0'
|
|
117
|
+
|
|
118
|
+
# :letterbox (what RGame::Game does unless told otherwise), :integer, :stretch
|
|
119
|
+
# or :disabled. A real game reads this from its settings the same way it reads
|
|
120
|
+
# the fullscreen flag. This one starts on `:disabled` so that pressing right
|
|
121
|
+
# walks the list in the order the prose above discusses them.
|
|
122
|
+
SCALE_MODE = ENV.fetch('RGAME_SCALE', 'disabled').to_sym
|
|
123
|
+
|
|
124
|
+
class Scene < RGame::Engine::Node2D
|
|
125
|
+
INSET = 24
|
|
126
|
+
MARK = 40
|
|
127
|
+
THICK = 3
|
|
128
|
+
EDGE = RGame::Util::Color.new(120, 200, 255)
|
|
129
|
+
CORNER = RGame::Util::Color.new(255, 210, 120)
|
|
130
|
+
|
|
131
|
+
DISC = RGame::Util::Color.new(180, 160, 240)
|
|
132
|
+
|
|
133
|
+
# Taken from the engine rather than written out, so this example cannot fall
|
|
134
|
+
# behind the modes that actually exist.
|
|
135
|
+
MODES = RGame::Engine::Presentation::MODES
|
|
136
|
+
|
|
137
|
+
# One Text per state rather than a string built per frame: a label made with
|
|
138
|
+
# interpolation in a draw method allocates a String every frame, which is what
|
|
139
|
+
# Game/NoInterpolationInHotPath refuses. Each mode's line is keyed by the mode.
|
|
140
|
+
STATE = { true => RGame::Engine::Text.new('state.fullscreen'),
|
|
141
|
+
false => RGame::Engine::Text.new('state.windowed') }.freeze
|
|
142
|
+
MODE_LABEL = MODES.to_h { |mode| [mode, RGame::Engine::Text.new(mode, scope: 'modes')] }.freeze
|
|
143
|
+
|
|
144
|
+
def initialize(**)
|
|
145
|
+
super
|
|
146
|
+
@mode_index = MODES.index(SCALE_MODE) || 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def on_control(actions)
|
|
150
|
+
# `context` is the Game, which is an App. A node may not *name* RGame::Core,
|
|
151
|
+
# but it may call methods on an object it is handed — the same duck-typing a
|
|
152
|
+
# node uses on the renderer.
|
|
153
|
+
if actions.pressed?(:fullscreen)
|
|
154
|
+
app = root.context
|
|
155
|
+
app.fullscreen = !app.fullscreen?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ui_left and ui_right come from the default map, so cycling needs no action
|
|
159
|
+
# of its own.
|
|
160
|
+
cycle_mode(-1) if actions.pressed?(:ui_left)
|
|
161
|
+
cycle_mode(1) if actions.pressed?(:ui_right)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `view` is the region being drawn into. Under fullscreen it is the screen;
|
|
165
|
+
# windowed it is the window. Reading it rather than WIDTH/HEIGHT is the whole
|
|
166
|
+
# reason the border below keeps up.
|
|
167
|
+
def on_draw(renderer, view)
|
|
168
|
+
right = view.width - INSET
|
|
169
|
+
bottom = view.height - INSET
|
|
170
|
+
|
|
171
|
+
renderer.line(INSET, INSET, right, INSET, thickness: THICK, color: EDGE)
|
|
172
|
+
renderer.line(INSET, bottom, right, bottom, thickness: THICK, color: EDGE)
|
|
173
|
+
renderer.line(INSET, INSET, INSET, bottom, thickness: THICK, color: EDGE)
|
|
174
|
+
renderer.line(right, INSET, right, bottom, thickness: THICK, color: EDGE)
|
|
175
|
+
|
|
176
|
+
# Corner marks, so the border is not just a rectangle of unknown size: they
|
|
177
|
+
# stay put while the edges between them move.
|
|
178
|
+
renderer.rect(INSET, INSET, MARK, THICK, color: CORNER)
|
|
179
|
+
renderer.rect(INSET, INSET, THICK, MARK, color: CORNER)
|
|
180
|
+
renderer.rect(right - MARK, bottom - THICK, MARK, THICK, color: CORNER)
|
|
181
|
+
renderer.rect(right - THICK, bottom - MARK, THICK, MARK, color: CORNER)
|
|
182
|
+
|
|
183
|
+
# Round under every mode but :stretch, which scales the axes by different
|
|
184
|
+
# factors and turns it into an ellipse. One shape says more about what a
|
|
185
|
+
# mode does than the two lines of text below it.
|
|
186
|
+
renderer.circle(view.width / 2, view.height / 2, view.height / 5, color: DISC)
|
|
187
|
+
|
|
188
|
+
renderer.text(STATE.fetch(root.context.fullscreen?), INSET + 12, INSET + 12)
|
|
189
|
+
renderer.text(MODE_LABEL.fetch(MODES[@mode_index]), INSET + 12, INSET + 34)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def cycle_mode(step)
|
|
195
|
+
@mode_index = (@mode_index + step) % MODES.length
|
|
196
|
+
root.context.scale_mode = MODES[@mode_index]
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
game = RGame::Game.new(
|
|
201
|
+
root: Scene.new,
|
|
202
|
+
caption: 'Fullscreen',
|
|
203
|
+
width: WIDTH,
|
|
204
|
+
height: HEIGHT,
|
|
205
|
+
media_root: ASSETS,
|
|
206
|
+
locales: LOCALES,
|
|
207
|
+
fullscreen: START_FULLSCREEN,
|
|
208
|
+
scale_mode: SCALE_MODE,
|
|
209
|
+
# :fullscreen is this game's own action; everything else comes from the
|
|
210
|
+
# default map. F is a convention players already know.
|
|
211
|
+
input_map: RGame::Engine::InputMap.default.merge(
|
|
212
|
+
fullscreen: { buttons: [Controls::KEY_F, Controls::PAD_Y] }
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
game.start
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Game menu — a menu that opens over a world which keeps running.
|
|
4
|
+
#
|
|
5
|
+
# Run it:
|
|
6
|
+
#
|
|
7
|
+
# ruby examples/game_menu/main.rb
|
|
8
|
+
#
|
|
9
|
+
# Walk with the arrow keys / WASD / a gamepad. Escape (or B) opens the menu;
|
|
10
|
+
# up and down move the focus, Enter or A activates, Escape closes. It exercises:
|
|
11
|
+
# - PlayerLayer — one player's own region of the screen, above the world;
|
|
12
|
+
# - UI::PanelMenu / UI::PanelButton — a focused list on a panel that sizes
|
|
13
|
+
# itself to the buttons, navigated without a pointer;
|
|
14
|
+
# - UI::Menu#open / #close — a menu that is built once and shown when wanted;
|
|
15
|
+
# - Node2D#paused — one node stops while the rest of the tree carries on;
|
|
16
|
+
# - renderer.nine_slice — chrome drawn at any size from one small piece of art.
|
|
17
|
+
#
|
|
18
|
+
# ## The thing to watch
|
|
19
|
+
#
|
|
20
|
+
# **The villagers keep walking while your menu is open.** Only the hero stops,
|
|
21
|
+
# because pausing is a property of a *node*, not of the world: `hero.paused =
|
|
22
|
+
# true` halts that node's `control` and `update` and everything below it, and
|
|
23
|
+
# nothing else in the tree notices. That is what lets two players each open
|
|
24
|
+
# their own menu while the shared world runs on, and this is the single-player
|
|
25
|
+
# shape of it.
|
|
26
|
+
#
|
|
27
|
+
# The hero and the villagers are the same three components with a different
|
|
28
|
+
# controller in the middle: PlayerController reads the player's actions,
|
|
29
|
+
# WanderController rolls its own directions. Nothing else about them differs.
|
|
30
|
+
#
|
|
31
|
+
# ## What this example does not solve
|
|
32
|
+
#
|
|
33
|
+
# Layout. The menu sits at a fixed margin inside the player's region, because
|
|
34
|
+
# centring needs the region's size and that only arrives at draw time. See
|
|
35
|
+
# docs/api/ui.md, "What this is not" — the UI package is focus and activation,
|
|
36
|
+
# and no more than that yet.
|
|
37
|
+
|
|
38
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
|
|
39
|
+
require 'rgame/game'
|
|
40
|
+
|
|
41
|
+
WIDTH = 640
|
|
42
|
+
HEIGHT = 480
|
|
43
|
+
ASSETS = File.expand_path('../assets', __dir__)
|
|
44
|
+
LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
|
|
45
|
+
|
|
46
|
+
SHEET = 'hero.json'
|
|
47
|
+
HERO_SPEED = 90.0
|
|
48
|
+
NPC_SPEED = 55.0
|
|
49
|
+
NPC_SPAWNS = [[140, 120], [430, 170], [220, 330], [480, 360]].freeze
|
|
50
|
+
|
|
51
|
+
# Seeded so the villagers wander the same way every run and two driven runs can
|
|
52
|
+
# be compared. `tools/drive_test_project.rb --seed N` overrides it.
|
|
53
|
+
DEFAULT_SEED = 0x6E11
|
|
54
|
+
|
|
55
|
+
# A walker that stays inside the window. Both the hero and the villagers are
|
|
56
|
+
# this; only the controller hung on them differs.
|
|
57
|
+
class Walker < RGame::Engine::Node2D
|
|
58
|
+
def on_update(_dt)
|
|
59
|
+
self.x = x.clamp(0, WIDTH - width)
|
|
60
|
+
self.y = y.clamp(0, HEIGHT - height)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The pause menu: a PanelMenu, and the hero it stops.
|
|
65
|
+
#
|
|
66
|
+
# It lives inside a PlayerLayer, so three things are already true without this
|
|
67
|
+
# class arranging any of them — it draws in that player's region, its
|
|
68
|
+
# coordinates are relative to that region, and the input it reads is that
|
|
69
|
+
# player's. Nothing here mentions viewports or players.
|
|
70
|
+
class GameMenu < RGame::Engine::Node2D
|
|
71
|
+
PADDING = 16
|
|
72
|
+
ITEM_WIDTH = 180
|
|
73
|
+
ITEM_HEIGHT = 34
|
|
74
|
+
SPACING = 8
|
|
75
|
+
|
|
76
|
+
def initialize(hero:, **)
|
|
77
|
+
super(**)
|
|
78
|
+
@hero = hero
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def on_add
|
|
82
|
+
column = RGame::Engine::UI::Column.new(item_width: ITEM_WIDTH, item_height: ITEM_HEIGHT, spacing: SPACING)
|
|
83
|
+
# The panel reaches PADDING beyond the buttons, so placing the menu PADDING
|
|
84
|
+
# in puts the panel's corner at this node's origin.
|
|
85
|
+
@menu = add_node(RGame::Engine::UI::PanelMenu.new(x: PADDING, y: PADDING, padding: PADDING, layout: column,
|
|
86
|
+
scope: 'menu'))
|
|
87
|
+
@menu.add(RGame::Engine::UI::PanelButton.new(label: 'resume')).on_activated { close }
|
|
88
|
+
# Disabled, so the example shows that state of the art — and because saving
|
|
89
|
+
# is `examples/save_load`'s subject rather than this one's.
|
|
90
|
+
@menu.add(RGame::Engine::UI::PanelButton.new(label: 'save', enabled: false))
|
|
91
|
+
@menu.add(RGame::Engine::UI::PanelButton.new(label: 'quit')).on_activated { root.context.close }
|
|
92
|
+
@menu.close # built once, shown when Escape asks for it
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A closed menu draws nothing and takes no focus or confirm, but its parent
|
|
96
|
+
# still reads Escape, which is what opens it again.
|
|
97
|
+
def on_control(actions)
|
|
98
|
+
toggle if actions.pressed?(:ui_cancel)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def toggle = @menu.open? ? close : open
|
|
104
|
+
|
|
105
|
+
def open
|
|
106
|
+
@menu.open
|
|
107
|
+
@hero.paused = true # only the hero: the villagers walk on
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def close
|
|
111
|
+
@menu.close
|
|
112
|
+
@hero.paused = false
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class Scene < RGame::Engine::Node2D
|
|
117
|
+
MENU_MARGIN = 40
|
|
118
|
+
|
|
119
|
+
def initialize
|
|
120
|
+
super
|
|
121
|
+
@rng = Random.new(ENV.fetch('RGAME_SEED', DEFAULT_SEED).to_i)
|
|
122
|
+
@help = RGame::Engine::Text.new('help.walk')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def on_add
|
|
126
|
+
hero = add_node(walker(RGame::Engine::Components::PlayerController.new,
|
|
127
|
+
HERO_SPEED, WIDTH / 2, HEIGHT / 2))
|
|
128
|
+
NPC_SPAWNS.each do |x, y|
|
|
129
|
+
add_node(walker(RGame::Engine::Components::WanderController.new(rng: @rng), NPC_SPEED, x, y))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The player's own layer, and the menu inside it. With one seat this covers
|
|
133
|
+
# the whole window; with two it would be half of it, and nothing here or in
|
|
134
|
+
# GameMenu would change.
|
|
135
|
+
player = root.system(RGame::Engine::Players).primary
|
|
136
|
+
layer = add_node(RGame::Engine::PlayerLayer.new(player: player))
|
|
137
|
+
layer.add_node(GameMenu.new(hero: hero, x: MENU_MARGIN, y: MENU_MARGIN))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def on_draw(renderer, _view)
|
|
141
|
+
renderer.text(@help, 12, 12)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def walker(controller, speed, x, y)
|
|
147
|
+
node = Walker.new(x: x, y: y)
|
|
148
|
+
node.add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: SHEET))
|
|
149
|
+
node.add_component(RGame::Engine::Components::CharacterBody.new(speed: speed))
|
|
150
|
+
node.add_component(controller)
|
|
151
|
+
node
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
game = RGame::Game.new(
|
|
156
|
+
root: Scene.new,
|
|
157
|
+
caption: 'Game menu',
|
|
158
|
+
width: WIDTH,
|
|
159
|
+
height: HEIGHT,
|
|
160
|
+
media_root: ASSETS,
|
|
161
|
+
locales: LOCALES
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# The one thing that has to be registered by hand: a nine-slice id names an
|
|
165
|
+
# *element of an atlas*, not a file, so there is nothing for the asset manager
|
|
166
|
+
# to resolve `:panel` to on demand. Registering the atlas binds every element in
|
|
167
|
+
# it under its own name.
|
|
168
|
+
game.renderer.register_ui_atlas(game.assets.ui_atlas('ui.json'))
|
|
169
|
+
|
|
170
|
+
game.start
|