Buckle up, this one is long and heavy on architecture.
We want to be able to interact with the world map in many ways. Think of a paint program: you have a tool to draw straight lines, or a pencil, or color filling tool. You click on the canvas all the same, but what happens depends on the active tool. The tools are mediators between user interaction and the canvas.
For this app, I’ve come to think of these “tools” as modes. Instead of letting the WorldMap
to process events, we want the active mode to do so, and let it decide how it will update the WorldMap
.
For now, we will be thinking about introducing first 2 modes:
MoveMode
, allowing to move the map around and basic introspectionPOIMode
, allowing management of points of interestFortunetely, we already have the entire behavior of MoveMode
implemented. We “just” need to encapsulate it in a class and insert it into the workflow.
The classes will be called TheEmpire::Mode::MoveMode
and TheEmpire::Mode::POIMode
. We’ll begin by thinking how they should be used in the app:
WorldMap
WorldMap
So let’s see how we can extend TheEmpire
to get all of these requirements done. As I often do, I’ll start by establishing how I want the new classess to be used, and then I’ll implement them to fit these expectations.
We initialize the modes in TheEmpire#initialize
:
# src/the_empire.cr, class TheEmpire#initialize
# below initializing of @world_map
@move_mode = TheEmpire::Mode::MoveMode.new(@world_map)
@poi_mode = TheEmpire::Mode::POIMode.new(@world_map)
@modes = [
@move_mode,
@poi_mode
]
@active_mode = @move_mode
Notice they receive the @world_map
in an argument. That’s how they will be able to update it. We’ll pass the modes data to the BottomMenu
. That’s how it will be able to render buttons for each mode:
# src/the_empire.cr, class TheEmpire#initialize
@bottom_menu = TheEmpire::BottomMenu.new(
position: {0, @window_height - BOTTOM_MENU_HEIGHT},
size: {@window_width - RIGHT_MENU_WIDTH, BOTTOM_MENU_HEIGHT},
modes: @modes,
active_mode: @active_mode
)
And finally, we’ll replace @world_map
with @active_mode
in TheEmpire#handle_event
and render the contents of @active_mode
in addition to other renders:
# src/the_empire.cr, class TheEmpire#initialize
def handle_event(event)
@active_mode.handle_event(event) # <- this was @world_map
@bottom_menu.handle_event(event)
@right_menu.handle_event(event)
end
def render
@window.clear(SF::Color::White)
@window.draw(@world_map)
@window.draw(@active_mode) # <- this is new
@window.draw(@bottom_menu)
@window.draw(@right_menu)
@window.display
end
Perfect. The initial implementation of modes will be fairly simple:
# src/the_empire/modes/move_mode.cr
class TheEmpire
module Mode
class MoveMode
include SF::Drawable
def initialize(@world_map : TheEmpire::WorldMap)
end
def handle_event(event)
end
def draw(target : SF::RenderTarget, states : SF::RenderStates)
end
end
end
end
Moving on, compiler gives us this error about BottomMenu
:
In src/the_empire.cr:38:42
38 | @bottom_menu = TheEmpire::BottomMenu.new(
^--
Error: no overload matches 'TheEmpire::BottomMenu.new', position: Tuple(Int32, Int32), size: Tuple(Int32, Int32), modes: Array(TheEmpire::Mode::MoveMode | TheEmpire::Mode::POIMode), active_mode: TheEmpire::Mode::MoveMode
Overloads are:
- TheEmpire::BottomMenu.new(position, size)
Of course, we passed additional arguments to TheEmpire::BottomMenu
, and it doesn’t know how to handle them. Look at the new arguments:
modes: Array(TheEmpire::Mode::MoveMode | TheEmpire::Mode::POIMode), active_mode: TheEmpire::Mode::MoveMode
It tells us that modes
is an array of either MoveMode
or POIMode
, and active_mode
is an MoveMode
. While this is true, it’s way too specific. We need to work with an idea of a mode. All of the modes should be recognized as specialized instances of the same tool.
We’ll introduce a parent class and inherit:
# src/the_empire/mode/base_mode.cr
class TheEmpire
module Mode
abstract class BaseMode
include SF::Drawable
def initialize(@world_map : TheEmpire::WorldMap)
end
end
end
end
Make sure that parent class is used:
# src/the_empire/mode/move_mode.cr
class TheEmpire
module Mode
class MoveMode < TheEmpire::Mode::BaseMode
def initialize(world_map : TheEmpire::WorldMap)
super(world_map)
end
def handle_event(event)
end
def draw(target : SF::RenderTarget, states : SF::RenderStates)
end
end
end
end
Now we can extend the BottomMenu
based on it. For now, we’ll accept the new arguments and assign them to instance variables:
# src/the_empire/bottom_menu.cr, class TheEmpire::BottomMenu
@modes : Array(TheEmpire::Mode::BaseMode)
@active_mode : TheEmpire::Mode::BaseMode
def initialize(position, size, @modes, @active_mode)
...
end
And that compiles correctly! Now, before we get to the exciting part of implementing the actual mode behavior, we need to think a bit about…
We need to be able to freely pick an active mode. That presents a little bit of a challange. BottomMenu
can render the buttons, because it got modes in initializer. That’s easy.
When one of these buttons is clicked, BottomMenu
must somehow be able to tell TheEmpire
, that we want to use a different mode.
We’ll solve this with the best react.js
taught us: data down, actions up.
That’s the mentioned easy part. We pass @modes
to the initializer of BottomMenu
, so we can use them to render the corresponding buttons. I’m going to start this with what is called a pro-coder move:
# src/the_empire/mode/base_mode.cr
class TheEmpire
module Mode
abstract class BaseMode
include SF::Drawable
getter button_text : String
def initialize(@world_map : TheEmpire::WorldMap)
@button_text = self.class.to_s.split("::").last.gsub("Mode", "")
end
end
end
end
This little trick implements a #button_text
method for all modes, based on the class name.
It will return Move
for MoveMode
and POI
for POIMode
, easy.
In BottomMenu
we can now create the buttons based on passed @modes
and @active_mode
:
# src/the_empire/bottom_menu.cr, class TheEmpire::BottomMenu
@buttons : Array(UI::Button)
def initialize(position, size, @modes, @active_mode)
@bounding_rectangle = SF::IntRect.new(position[0], position[1], size[0], size[1])
@buttons = @modes.map_with_index do |mode, index|
UI::Button.new(
# each button will be 220 pixels to the right from where the previous started
position: { @bounding_rectangle.position[0] + 20 + index * 220, @bounding_rectangle.position[1] + 20},
size: {200, 80},
# text on the button is taken from the mode
text: mode.button_text,
# button is active if the mode is currently active
active: mode == @active_mode,
on_click: ->(button : UI::Button) {
deactivate_all!
activate(button)
}
)
end
end
The rest of the class is easily updated to perform all actions on @buttons.each
. Also I extended the UI::Button
to accept active
as param too.
That covers the “data down” part:
We have 2 buttons based on available modes, and the Move
mode is currently active. Awesome. Moving to…
The concept of Actions up
means, that child component (like BottomMenu
) should be able to tell parent component (TheEmpire
) that something happened and let it (the parent component) handle the outcome.
In this case, if a button in BottomMenu
is clicked, TheEmpire
must update @active_mode
.
We’re gonna get that done with events. Not CrSFML
events, we will add new ones, specifically for that purpose:
# src/the_empire/event.cr
class TheEmpire
abstract struct Event
struct ChangeModeEvent < Event
getter mode
def initialize(@mode : TheEmpire::Mode::BaseMode)
end
end
end
end
We have a TheEmpire::Event::ChangeModeEvent
struct, which represents the information, that active mode must change. TheEmpire
must be ready to handle it:
# src/the_empire.cr, class TheEmpire
def handle_page_event(event : TheEmpire::Event)
case event
when TheEmpire::Event::ChangeModeEvent
@active_mode = event.mode
end
nil # this helps to keep the `Proc` types below simple
end
#handle_page_event
is now how TheEmpire
is allowed to process TheEmpire::Event
events coming from any child components.
Next step, we pass that new method to BottomMenu
:
# src/the_empire.cr, class TheEmpire#initialize
@bottom_menu = TheEmpire::BottomMenu.new(
position: {0, @window_height - BOTTOM_MENU_HEIGHT},
size: {@window_width - RIGHT_MENU_WIDTH, BOTTOM_MENU_HEIGHT},
modes: @modes,
active_mode: @active_mode,
omit_event: ->handle_page_event(TheEmpire::Event) # <- this is new
)
With this wonderful piece of coding trickery, we can turn #handle_page_event
method into a Proc
and pass it into BottomMenu
under the name of omit_event
.
We can now do this:
# src/the_empire/bottom_menu.cr, class TheEmpire::BottomMenu
@omit_event : Proc(TheEmpire::Event, Nil)
def initialize(position, size, @modes, @active_mode, @omit_event)
@bounding_rectangle = SF::IntRect.new(position[0], position[1], size[0], size[1])
@buttons = @modes.map_with_index do |mode, index|
UI::Button.new(
position: { @bounding_rectangle.position[0] + 20 + index * 220, @bounding_rectangle.position[1] + 20},
size: {200, 80},
text: mode.button_text,
active: mode == @active_mode,
on_click: -> {
event = TheEmpire::Event::ChangeModeEvent.new(mode)
@omit_event.call(event)
}
)
end
end
When we click on any of these buttons, we create a TheEmpire::Event::ChangeModeEvent
and call the @omit_event
Proc
, which then processess the event inside TheEmpire
.
Finally, we must update the @handle_page_event
with this:
# src/the_empire.cr, class TheEmpire
def handle_page_event(event : TheEmpire::Event)
case event
when TheEmpire::Event::ChangeModeEvent
@active_mode = event.mode
# this part is new
@bottom_menu = TheEmpire::BottomMenu.new(
position: {0, @window_height - BOTTOM_MENU_HEIGHT},
size: {@window_width - RIGHT_MENU_WIDTH, BOTTOM_MENU_HEIGHT},
modes: @modes,
active_mode: @active_mode,
omit_event: ->handle_page_event(TheEmpire::Event)
)
end
nil
end
When processing the event, we change @active_mode
, but that doesn’t automatically propagate to BottomMenu
. If we want the BottomMenu
to render based on the new @active_mode
, we need to initialize it again with updated data.
Long and difficult road led here, but alas, victory:
While that looks exactly like what we had at the beginning, we have developed important architecture! Actually, even less is working than before, since we can’t move things. But that’s easy, a cherry on top. Let’s implement the…
I want all modes to move around with AWSD
buttons, so we’ll handle that in the parent class:
# src/the_empire/mode/base_mode.cr
class TheEmpire
module Mode
abstract class BaseMode
include SF::Drawable
MOVING_SPEED = 10
getter button_text : String
def initialize(@world_map : TheEmpire::WorldMap)
@button_text = self.class.to_s.split("::").last.gsub("Mode", "")
end
def handle_event(event)
case event
when SF::Event::KeyPressed
case event.code
when SF::Keyboard::Key::W then @world_map.moving_up_speed = MOVING_SPEED
when SF::Keyboard::Key::S then @world_map.moving_up_speed = -MOVING_SPEED
when SF::Keyboard::Key::A then @world_map.moving_left_speed = MOVING_SPEED
when SF::Keyboard::Key::D then @world_map.moving_left_speed = -MOVING_SPEED
end
when SF::Event::KeyReleased
case event.code
when SF::Keyboard::Key::W then @world_map.moving_up_speed = 0
when SF::Keyboard::Key::S then @world_map.moving_up_speed = 0
when SF::Keyboard::Key::A then @world_map.moving_left_speed = 0
when SF::Keyboard::Key::D then @world_map.moving_left_speed = 0
end
end
end
end
end
end
MoveMode
will call the parent, plus get the ability to drag-and-drop and scroll:
# src/the_empire/mode/move_mode.cr
class TheEmpire
module Mode
class MoveMode < TheEmpire::Mode::BaseMode
def initialize(world_map : TheEmpire::WorldMap)
super(world_map)
@moving_around = false
@mouse_button_initial_x = 0
@mouse_button_initial_y = 0
end
def handle_event(event)
super(event) # <- This calls the `#handle_event` from parent class
# this below handles drag-and-drop and scroll
# it was just moved here from `WorldMap`
case event
when SF::Event::MouseButtonPressed
if @world_map.bounding_rectangle.contains?(event.x, event.y)
@moving_around = true
@mouse_button_initial_x = event.x
@mouse_button_initial_y = event.y
end
when SF::Event::MouseButtonReleased
@moving_around = false
when SF::Event::MouseMoved
if @moving_around
x_delta = @mouse_button_initial_x - event.x
y_delta = @mouse_button_initial_y - event.y
@world_map.move({x_delta, y_delta})
@mouse_button_initial_x = event.x
@mouse_button_initial_y = event.y
end
when SF::Event::MouseWheelMoved
if event.delta > 0
@world_map.scale(1.25)
else
@world_map.scale(0.8)
end
end
end
def draw(target : SF::RenderTarget, states : SF::RenderStates)
end
end
end
end
All of the event handling was removed from the WorldMap
, as it was moved to MoveMode
. POIMode
will just call the parent action.
Grand finale, we end up with this:
MoveMode
supports AWSD
and mouse actions, and POIMode
supports AWSD
, but doesn’t react to mouse actions (trust me, I guess).
Big win! We will add actual drawing behavior to POIMode
soon enough.
For now, we need to extend our UI capabilities a little bit, starting with a basic ui framework!