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:
WorldMapWorldMapSo 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!