-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Converting Game Commands to Game Actions
This tutorial will cover how to convert basic Game Commands into the Game Actions framework.
For this tutorial we will convert GAME_COMMAND_CHANGE_SURFACE_STYLE
to SurfaceSetStyleAction
.
Start by working out how the game command is called. This can be done for looking for all references to GAME_COMMAND_CHANGE_SURFACE_STYLE
. From this search we find there are 3 places where it is called from. They are all from tools. All of the calls are the last call of a function and no state from the output of the action are used. This makes things very easy as there is no requirement to use callbacks or complex return values.
gGameCommandErrorTitle = STR_CANT_CHANGE_LAND_TYPE;
game_do_command(
gMapSelectPositionA.x, 1, gMapSelectPositionA.y, gLandToolTerrainSurface | (gLandToolTerrainEdge << 8),
GAME_COMMAND_CHANGE_SURFACE_STYLE, gMapSelectPositionB.x, gMapSelectPositionB.y);
gCurrentToolId = TOOL_UP_DOWN_ARROW;
Notice that the error title is STR_CANT_CHANGE_LAND_TYPE
this will be used for failure returning within the game action function. Setting the toolId at the end does not depend on the output of this function and should be safe to do even with network delay. If it isn't it may need wrapped in a callback.
Refer to Game Commands for notes on the function.
For ever game action there is a header created in the src/openrct2/actions/ folder. The header should have the same name as the class. We will call it SurfaceSetStyleAction.hpp
to be consistent with the naming convention. Create a stub class for SurfaceSetStyleAction.
#pragma once
#include "GameAction.h"
DEFINE_GAME_ACTION(SurfaceSetStyleAction, GAME_COMMAND_CHANGE_SURFACE_STYLE, GameActionResult)
{
private:
public:
SurfaceSetStyleAction()
{
}
GameActionResult::Ptr Query() const override
{
}
GameActionResult::Ptr Execute() const override
{
}
};
After creating the game action you can add it to GameActionRegistration.cpp
by including the file and adding an entry in the Register()
function.
...
Register<SurfaceSetStyleAction>();
...
Have a look at the game command and make a list of all of the parameters.
- int32_t x0,
- int32_t y0,
- int32_t x1,
- int32_t y1,
- uint8_t surfaceStyle,
- uint8_t edgeStyle,
- uint8_t flags
Parameter 1-4 is a map range and should use struct MapRange
. Parameter 5-6 are the styles that are set, both are required in our action. Parameter 7 is the flags, this is no longer passed directly into the function and is therefore not required.
From the previous knowledge we can now create the member variables.
private:
MapRange _range;
uint8_t _surfaceStyle;
uint8_t _edgeStyle;
And create an appropriate constructor.
SurfaceSetStyleAction(MapRange range, uint8_t surfaceStyle, uint8_t edgeStyle)
: _range(range)
, _surfaceStyle(surfaceStyle)
, _edgeStyle(edgeStyle)
{
}
Game Actions need a serialise function to send the parameters over the network. Every member variable must be accounted for. This same function is used for serialising and deserialising. All member variables must be accessed by reference so don't try use any intermediate variables in this function.
void Serialise(DataSerialiser & stream) override
{
GameAction::Serialise(stream);
stream << DS_TAG(_range) << DS_TAG(_surfaceStyle) << DS_TAG(_edgeStyle);
}
I like to create the query function by copying and pasting the game command directly into the game action and massaging until it fits.
gCommandExpenditureType = RCT_EXPENDITURE_TYPE_LANDSCAPING; // This will become the result expenditure type
x0 = std::max(x0, 32); // This section checks range parameters are valid
y0 = std::max(y0, 32);
x1 = std::min(x1, (int32_t)gMapSizeMaxXY);
y1 = std::min(y1, (int32_t)gMapSizeMaxXY);
// There has been no check to confirm that these values are normalised! What would happen if x0 was > x1?
int32_t xMid, yMid;
xMid = (x0 + x1) / 2 + 16;
yMid = (y0 + y1) / 2 + 16;
int32_t heightMid = tile_element_height(xMid, yMid);
gCommandPosition.x = xMid;
gCommandPosition.y = yMid;
gCommandPosition.z = heightMid;
// This section defines where command took place it will be placed in the result.
New code.
auto res = MakeResult();
res->ErrorTitle = STR_CANT_CHANGE_LAND_TYPE; // Always set the ErrorTitle even in a non error.
// If you do not set the ErrorTitle then insufficient funds messages display (undefined string).
res->ExpenditureType = RCT_EXPENDITURE_TYPE_LANDSCAPING;
auto normRange = _range.Normalise();
auto x0 = std::max(normRange.GetLeft(), 32);
auto y0 = std::max(normRange.GetTop(), 32);
auto x1 = std::min(normRange.GetRight(), (int32_t)gMapSizeMaxXY);
auto y1 = std::min(normRange.GetBottom(), (int32_t)gMapSizeMaxXY);
MapRange validRange{ x0, y0, x1, y1 };
auto xMid = (validRange.GetLeft() + validRange.GetRight()) / 2 + 16;
auto yMid = (validRange.GetTop() + validRange.GetBottom()) / 2 + 16;
auto heightMid = tile_element_height(xMid, yMid) & 0xFFFF;
res->Position.x = xMid;
res->Position.y = yMid;
res->Position.z = heightMid;
At this point I realised that the surfaceStyle
and edgeStyle
have not been checked for validity. From looking at its use surfaceStyle
is invalid if its value is >0x1F
and it must also be a loaded object. We should modify the function to check for this to prevent crashes and abuse. edgeStyle
is invalid if >0xF
it should also verify that there is an object tied to this entry. For both of these styles 0xFF
is used to indicate that the style should not be changed.
Additional checking code.
auto& objManager = GetContext()->GetObjectManager();
if (_surfaceStyle != 0xFF)
{
if (_surfaceStyle > 0x1F)
{
log_error("Invalid surface style.");
return MakeResult(GA_ERROR::INVALID_PARAMETERS, STR_CANT_CHANGE_LAND_TYPE);
}
const auto surfaceObj = static_cast<TerrainSurfaceObject*>(
objManager.GetLoadedObject(OBJECT_TYPE_TERRAIN_SURFACE, _surfaceStyle));
if (surfaceObj == nullptr)
{
log_error("Invalid surface style.");
return MakeResult(GA_ERROR::INVALID_PARAMETERS, STR_CANT_CHANGE_LAND_TYPE);
}
}
if (_edgeStyle != 0xFF)
{
if (_edgeStyle > 0xF)
{
log_error("Invalid edge style.");
return MakeResult(GA_ERROR::INVALID_PARAMETERS, STR_CANT_CHANGE_LAND_TYPE);
}
const auto edgeObj = static_cast<TerrainEdgeObject*>(
objManager.GetLoadedObject(OBJECT_TYPE_TERRAIN_SURFACE, _edgeStyle));
if (edgeObj == nullptr)
{
log_error("Invalid edge style.");
return MakeResult(GA_ERROR::INVALID_PARAMETERS, STR_CANT_CHANGE_LAND_TYPE);
}
}
We can now confirm that all of the parameters are set up.
Original code:
// Do nothing during pause
if (game_is_paused() && !gCheatsBuildInPauseMode) // This is no longer required
{
return 0;
}
// Do nothing if not in editor, sandbox mode or landscaping is forbidden
if (!(gScreenFlags & SCREEN_FLAGS_SCENARIO_EDITOR) && !gCheatsSandboxMode
&& (gParkFlags & PARK_FLAGS_FORBID_LANDSCAPE_CHANGES))
{
return 0; // This is not correct it should return an error message
}
We can straight away delete the pause check that is handled differently in GameActions. The second part can be fixed and return a valid error message.
// Do nothing if not in editor, sandbox mode or landscaping is forbidden
if (!(gScreenFlags & SCREEN_FLAGS_SCENARIO_EDITOR) && !gCheatsSandboxMode
&& (gParkFlags & PARK_FLAGS_FORBID_LANDSCAPE_CHANGES))
{
return MakeResult(GA_ERROR::DISALLOWED, STR_CANT_CHANGE_LAND_TYPE, STR_FORBIDDEN_BY_THE_LOCAL_AUTHORITY);
}
The rest of the function is hard to follow if broken up so consider it as a whole. In an ideal world this would be split up into multiple functions.
money32 surfaceCost = 0;
money32 edgeCost = 0;
for (int32_t x = x0; x <= x1; x += 32)
{
for (int32_t y = y0; y <= y1; y += 32)
{
if (x > 0x1FFF) // This should never happen as range is already verified and normalised
continue;
if (y > 0x1FFF)
continue;
if (!(gScreenFlags & SCREEN_FLAGS_SCENARIO_EDITOR) && !gCheatsSandboxMode)
{
if (!map_is_location_in_park({ x, y }))
continue; // This function actually checks the range again just to be really sure
}
auto surfaceElement = map_get_surface_element_at({ x, y })->AsSurface();
// This is dangerous potential null deref. Except we have triple checked range is valid.
if (surfaceElement == nullptr)
{
continue;
}
if (surfaceStyle != 0xFF)
{
uint8_t cur_terrain = surfaceElement->GetSurfaceStyle(); // names could be improved
if (surfaceStyle != cur_terrain)
{
// Prevent network-originated value of surfaceStyle from causing
// invalid access. **now better protected**
uint8_t style = surfaceStyle & 0x1F;
auto& objManager = GetContext()->GetObjectManager();
const auto surfaceObj = static_cast<TerrainSurfaceObject*>(
objManager.GetLoadedObject(OBJECT_TYPE_TERRAIN_SURFACE, style));
if (surfaceObj != nullptr)
{
surfaceCost += surfaceObj->Price;
}
if (flags & GAME_COMMAND_FLAG_APPLY) // In the query this can all be removed
{
surfaceElement->SetSurfaceStyle(surfaceStyle);
map_invalidate_tile_full(x, y);
footpath_remove_litter(x, y, tile_element_height(x, y));
}
}
}
if (edgeStyle != 0xFF)
{
uint8_t currentEdge = surfaceElement->GetEdgeStyle();
if (edgeStyle != currentEdge)
{
edgeCost += 100;
if (flags & GAME_COMMAND_FLAG_APPLY) // In the query this can all be removed
{
surfaceElement->SetEdgeStyle(edgeStyle);
map_invalidate_tile_full(x, y);
}
}
}
if (flags & GAME_COMMAND_FLAG_APPLY) // In the query this can all be removed
{
if (surfaceElement->CanGrassGrow() && (surfaceElement->GetGrassLength() & 7) != GRASS_LENGTH_CLEAR_0)
{
surfaceElement->SetGrassLength(GRASS_LENGTH_CLEAR_0);
map_invalidate_tile_full(x, y);
}
}
}
}
// This is no longer performed this way in GameActions and can be deleted.
if (flags & GAME_COMMAND_FLAG_APPLY && gGameCommandNestLevel == 1)
{
LocationXYZ16 coord;
coord.x = ((x0 + x1) / 2) + 16;
coord.y = ((y0 + y1) / 2) + 16;
coord.z = tile_element_height(coord.x, coord.y);
network_set_player_last_action_coord(network_get_player_index(game_command_playerid), coord);
}
// No requirement for NO_MONEY check in GameActions
return (gParkFlags & PARK_FLAGS_NO_MONEY) ? 0 : surfaceCost + edgeCost;
New code
money32 surfaceCost = 0;
money32 edgeCost = 0;
for (int32_t x = validRange.GetLeft(); x <= validRange.GetRight(); x += 32)
{
for (int32_t y = validRange.GetTop(); y <= validRange.GetBottom(); y += 32)
{
if (!(gScreenFlags & SCREEN_FLAGS_SCENARIO_EDITOR) && !gCheatsSandboxMode)
{
if (!map_is_location_in_park({ x, y }))
continue;
}
auto tileElement = map_get_surface_element_at({ x, y });
if (tileElement == nullptr)
{
continue;
}
auto surfaceElement = tileElement->AsSurface();
if (_surfaceStyle != 0xFF)
{
uint8_t curSurfaceStyle = surfaceElement->GetSurfaceStyle();
if (_surfaceStyle != curSurfaceStyle)
{
auto& objManager = GetContext()->GetObjectManager();
const auto surfaceObj = static_cast<TerrainSurfaceObject*>(
objManager.GetLoadedObject(OBJECT_TYPE_TERRAIN_SURFACE, _surfaceStyle));
if (surfaceObj != nullptr)
{
surfaceCost += surfaceObj->Price;
}
}
}
if (_edgeStyle != 0xFF)
{
uint8_t curEdgeStyle = surfaceElement->GetEdgeStyle();
if (_edgeStyle != curEdgeStyle)
{
edgeCost += 100;
}
}
}
}
res->Cost = surfaceCost + edgeCost;
return res;
At this point the Query is now complete. Further refactoring could look at splitting it up into separate functions.
Within the execute function we do not need to check for validity of parameters. But we do need to do the parts that were guarded by GAME_COMMAND_FLAG_APPLY.
The start of the function is pretty similar:
auto res = MakeResult();
res->ErrorTitle = STR_CANT_CHANGE_LAND_TYPE;
res->ExpenditureType = RCT_EXPENDITURE_TYPE_LANDSCAPING;
auto normRange = _range.Normalise();
auto x0 = std::max(normRange.GetLeft(), 32);
auto y0 = std::max(normRange.GetTop(), 32);
auto x1 = std::min(normRange.GetRight(), (int32_t)gMapSizeMaxXY);
auto y1 = std::min(normRange.GetBottom(), (int32_t)gMapSizeMaxXY);
MapRange validRange{ x0, y0, x1, y1 };
auto xMid = (validRange.GetLeft() + validRange.GetRight()) / 2 + 16;
auto yMid = (validRange.GetTop() + validRange.GetBottom()) / 2 + 16;
auto heightMid = tile_element_height(xMid, yMid) & 0xFFFF;
res->Position.x = xMid;
res->Position.y = yMid;
res->Position.z = heightMid;
We can then go into the main for loop.
money32 surfaceCost = 0;
money32 edgeCost = 0;
for (int32_t x = validRange.GetLeft(); x <= validRange.GetRight(); x += 32)
{
for (int32_t y = validRange.GetTop(); y <= validRange.GetBottom(); y += 32)
{
auto tileElement = map_get_surface_element_at({ x, y });
if (tileElement == nullptr) // Nullptr checks should always be kept
{
continue;
}
auto surfaceElement = tileElement->AsSurface();
if (_surfaceStyle != 0xFF)
{
uint8_t curSurfaceStyle = surfaceElement->GetSurfaceStyle();
if (_surfaceStyle != curSurfaceStyle)
{
auto& objManager = GetContext()->GetObjectManager();
const auto surfaceObj = static_cast<TerrainSurfaceObject*>(
objManager.GetLoadedObject(OBJECT_TYPE_TERRAIN_SURFACE, _surfaceStyle));
if (surfaceObj != nullptr)
{
surfaceCost += surfaceObj->Price;
surfaceElement->SetSurfaceStyle(_surfaceStyle);
map_invalidate_tile_full(x, y);
footpath_remove_litter(x, y, tile_element_height(x, y));
}
}
}
if (_edgeStyle != 0xFF)
{
uint8_t curEdgeStyle = surfaceElement->GetEdgeStyle();
if (_edgeStyle != curEdgeStyle)
{
edgeCost += 100;
surfaceElement->SetEdgeStyle(_edgeStyle);
map_invalidate_tile_full(x, y);
}
}
if (surfaceElement->CanGrassGrow() && (surfaceElement->GetGrassLength() & 7) != GRASS_LENGTH_CLEAR_0)
{
surfaceElement->SetGrassLength(GRASS_LENGTH_CLEAR_0);
map_invalidate_tile_full(x, y);
}
}
}
res->Cost = surfaceCost + edgeCost;
return res;
The execute function has now been completed. There is a lot of overlap between the two functions and it may make more sense to combine the function into one again. We are now ready to start moving the three calls to the game command into three calls to the GameAction.
Now that the game action is setup we can modify the callers to use the new class. Start with just one and test it before rolling it out to all of the others.
gGameCommandErrorTitle = STR_CANT_CHANGE_LAND_TYPE; // This is no longer used.
game_do_command(
gMapSelectPositionA.x, 1, gMapSelectPositionA.y, gLandToolTerrainSurface | (gLandToolTerrainEdge << 8),
GAME_COMMAND_CHANGE_SURFACE_STYLE, gMapSelectPositionB.x, gMapSelectPositionB.y);
gCurrentToolId = TOOL_UP_DOWN_ARROW; // This should maybe be wrapped in a callback
New code
auto surfaceSetStyleAction = SurfaceSetStyleAction(
{ gMapSelectPositionA.x, gMapSelectPositionA.y, gMapSelectPositionB.x, gMapSelectPositionB.y },
gLandToolTerrainSurface, gLandToolTerrainEdge);
GameActions::Execute(&surfaceSetStyleAction);
gCurrentToolId = TOOL_UP_DOWN_ARROW;
Test the function by first locally using it. If that is successful try it with a networked local server. When I first wrote this tutorial I forgot to implement the serialise function. This prevented networked play. The console helpfully told me that I was trying to use an invalid surface style which straight away informed me the parameter was not being sent correctly. During the testing I found there was no need for a callback on the ToolId.
Now the fun part of deleting all references to the old game command and its function. Remember to leave a note in Game.h that the GAME_COMMAND has been turned into a GameAction by leaving a "GA" comment.
- Home
- FAQ & Common Issues
- Roadmap
- Installation
- Building
- Features
- Development
- Benchmarking & stress testing OpenRCT2
- Coding Style
- Commit Messages
- Overall program structure
- Data Structures
- CSS1.DAT
- Custom Music and Ride Music Objects
- Game Actions
- G1 Elements Layout
- game.cfg structure
- Maps
- Music Cleanup
- Objects
- Official extended scenery set
- Peep AI
- Peep Sprite Type
- RCT1 ride and vehicle types and their RCT2 equivalents
- RCT12_MAX_SOMETHING versus MAX_SOMETHING
- Ride rating calculation
- SV6 Ride Structure
- Settings in config.ini
- Sizes and angles in the game world
- Sprite List csg1.dat
- Sprite List g1.dat
- Strings used in RCT1
- Strings used in the game
- TD6 format
- Terminology
- Track Data
- Track Designs
- Track drawers, RTDs and vehicle types
- Track types
- Vehicle Sprite Layout
- Widget colours
- Debugging OpenRCT2 on macOS
- OpenGL renderer
- Rebase and Sync fork with OpenRCT2
- Release Checklist
- Replay System
- Using minidumps from crash reports
- Using Track Block Get Previous
- History
- Testing