In this session
- Refactoring
- Making a map
- Adding boundary checks to character movement
- A basic camera
- Centering and tracking the player
- Adding health to characters with damage and removal
- Fixing little bugs!
Refactoring
Changes
- We added a game_state ruleset under
game/rules
. - We created a new action and test harness for game setup
- We transitioned identical logic to the new action
- We called the new action from
app.lua
and removed the old function
Summary
Before we could make more advanced logic for setting up games, we needed to move the logic out of the game/app.lua
file.
This file is meant to connect the various game states and hold the application together,
not have any specific logic.
-- Function that we need to remove
local function set_up_the_game()
local character = require "game.rules.character"
store.dispatch(character.actions.add(character.create { x = 2, y = 1, is_player_controlled = true }))
for _=1,4 do
store.dispatch(
character.actions.add(
character.create { is_enemy = true, x = math.random(10), y = math.random(10) }))
end
end
This function made sure there was a player and added a couple of enemies. Nothing too fancy, but it was in the wrong location and before adding in logic for map generation, it made sense to move.
The structure of our game suggest we should have rule and action for setting up the game. Think of a board game, and usually there is a section of the rulebook with a big heading Set Up. Set up will likely be evolving, could be dependent on what settings a player would like to set, etc… This allows us a clean entry point to initiate the configuration of our game. And, we can test it!
-- New action in game/rules/game_state/actions/setup.lua
return function()
return function(dispatch)
dispatch(character.actions.add(
-- The values we are inputting here are placeholders for the future
character.create {
x = 2,
y = 1,
is_player_controlled = true
}
))
for _=1,4 do
dispatch(character.actions.add(
character.create {
is_enemy = true,
x = math.random(10),
y = math.random(10)
}
))
end
end
end
Map
Changes
- Create new map ruleset and added to setup rules
- Update UI to use the map to render
- Added random generated terrains for dirt, water and grass
- Limit character movement to remain in map boundaries
With the update to how we set up the game, we can start to consider adding a new ruleset around maps. This ruleset would contain any map specific information in a coordinate-based fashion. For example, what is the terrain in a specific square.
Create the map
-- game/rules/map/map.lua
local class = require "moonpie.class"
local map = {}
function map:constructor(props)
self.width = props.width
self.height = props.height
end
return class(map)
-- game/rules/map/actions/set.lua
local types = require "game.rules.map.actions.types"
return function(map)
return {
type = types.set,
payload = map
}
end
-- game/rules/map/reducer.lua
local create_slice = require "moonpie.redux.create_slice"
local types = require "game.rules.map.actions.types"
return create_slice {
[types.set] = function(_, action)
return action.payload
end
}
The first block of code describes the map entity. We consider it a class
that can be instantiated and passed
some properties. Currently, width and height are the only ones supported.
The second block describes the only action we needed to get maps off the ground and that is to set the map. The current implementation plans that we would only have one map at a time. In the future, this could and probably will change. We might have multiple maps in state and swap between them. For now, we start with a single map at the time implementation first.
The final block is a good chance to look at the reducer
framework. We use the create_slice
helper provided
by Moonpie to configure the reducer and define who what actions we want to handle in this state.
A table is provied with various action type values as the key, we can add a handler for what to do at this state time.
This particular action is simple, whenever you set the map, replace the state with the current map.
When using create_slice
, the state is considered scoped to just this slice of the state. Updating these state values should
not impact the state managed by a different part of the state. This provides us with isolation in our state management
and should reduce the potential for unexpected bugs.
Rendering and Validating
The rendering and terrain logic is not terrible complex. We create a list of terrain values to use and randomly input them into our grid. When rendering we ask for the color of the rectangle we’d like to render out.
-- snippet of game/rules/character/actions/set_position.lua
validate = function(self, state)
local dims = map.selectors.get_dimensions(state)
return self.payload.x >= 1 and self.payload.x < dims.width and
self.payload.y >= 1 and self.payload.y < dims.height
end
Simple actions support adding a validate
method to the table. This is called before passing to the reducer. If
an action determines it is invalid, it will be skipped. It makes a handy way of preventing state changes from
occurring that we want to prevent and localizes them to the action. We can just validate that the x and y values
are within the range we expect.
A Basic Camera
Changes
- Created new camera ruleset and it follows player
- Centered camera around the player
With a more interesting map that is larger than our ability to see on the screen at any on time, we need to introduce some mechanism to figure out which part of the map to display.
-- game/rules/camera/actions/set_position.lua
local types = require "game.rules.camera.actions.types"
return function(x, y)
return {
type = types.camera_set_position,
payload = {
x = x,
y = y
}
}
end
-- snippet of game/ui/widgets/combat_map.lua
draw_component = function(self)
for x = 1, self.map.width do
for y = 1, self.map.height do
draw_tile(
x - self.camera.x,
y - self.camera.y,
self.map:get_terrain(x, y).color)
end
end
for _, v in ipairs(self.characters) do
draw_character(
v.x - self.camera.x,
v.y - self.camera.y,
v.is_enemy)
end
end
We provide an action that allows us to move the camera around. The main adjustment was to offset all the drawing by the camera location. Notice that this implementation is very simple. We just want something that will allow us to move what we are rendering.
For centering the character in the camera, we need to figure out what is the width and height of the screen in tiles. That logic is determined by the UI. We created a new action to handle updating the dimensions of the camera.
-- game/rules/camera/actions/set_dimensions.lua
local types = require "game.rules.camera.actions.types"
return function(width, height)
return {
type = types.camera_set_dimensions,
payload = {
width = width,
height = height
}
}
end
Character Health
Changes
- Characters have health and are removed at 0
-- game/rules/character/actions/set_health.lua
local types = require "game.rules.character.actions.types"
return function(character, health)
return {
type = types.character_set_health,
payload = {
character = character,
health = health
}
}
end
-- snippet game/rules/character/actions/attack.lua
dispatch(set_health(target, target.health - 1))
-- game/rules/character/selectors/get_dead.lua
local tables = require "moonpie.tables"
return function(state)
return tables.select(state.characters, function(c)
return c.health <= 0
end)
end
-- Snippet of game/rules/turn/actions/process.lua
-- Check for dead characters
local dead = character.selectors.get_dead(get_state())
if dead then
for _, e in ipairs(dead) do
dispatch(character.actions.remove(e))
end
end
The first basic step for health was just adding a value to game/rules/character/character.lua
to set a property
for health to a default value (10). Where things start getting interesting is adding an action that allows
us to set the health to a different value, combined with a selector that returns all the entities that have
health less than or equal to 0.
Within game/rules/character/actions/attack.lua
we changed a line to dispatch a new health value that is 1 lower
than it was before.
Finally, each time we process the turn, we search for any dead characters and then remove them.
The great thing about this approach is we changed behavior with little impact on the rest of the system and still were able to create the desired effect. From this we are allowing ourselves the opportunity to create new actions that deal damage in various ways to reduce health. In the future we will likely need to create an action for dying that will be different from just removing to allow for more complex steps. Such as, dropping loot on the ground, add experience points or something to the player, these kinds of things. Again, the overall impact to the system adding these changes should be minimal.
Fixing Bugs
- Character would attack themselves
This was a minor bug, but still a funny one. When characters would select their same square to move into, they would see themselves as moving into a character and then issue an attack command. There were lots of ways to solve this, but the most logical was that one should not be able to dispatch the attack actions to themselves.
Because attacks are complex actions, it creates an interesting decision about what to do. Should we error? Should we add logic within the system to handle this case? The approach I went with is that the action will return, essentially an empty action. This would then just pass through the system with no side-effects. It’s less ideal because it adds a tiny bit of work, but it also is low risk.
The key point is that we created a test for this scenario first and proved it was failing and then fixed the error.
-- snippet from game/rules/character/actions/attack_spec.lua
it("returns empty action if source and target", function()
local source = {}
local target = source
assert.same({}, attack(source, target))
end)
Final Stats
- 56 Files Update
- 897 Additions
- 120 Deletions
- (97.59%) Code Coverage
End of stream screenshot shows the magenta “player” character in the middle, the red enemies running around, and the basic health statistics displayed on the side.