Player Attack Events (Part 2)
Adding attack events to UI log
With PCAttackedPCEvent
events being created on the backend, I can now include them in the events list which is pushed to the frontend LiveView client.
Events are filtered in the LiveView controller to only send events that have a matching format
function which converts the struct to be received by the frontend.
I add a new definition for the PCAttackedPCEvent
struct which is all it takes to start seeing these events in the payload for the LiveView event handlers on the frontend.
defmodule MinotaurWeb.GameFormatter do
# …
def format(%PCAttackedPCEvent{} = event, game) do
%{
id: event.id,
event_type: "PC_ATTACKED_PC",
player_id: event.player_id,
round: event.round,
pc_name: game.players[event.player_id].display_name,
target_id: event.target_id,
target_name: game.players[event.target_id].display_name
}
end
end
In the frontend code, I update the event handlers to convert the incoming payload to an event message so it can be rendered in the events log UI component.
// …
function convertPCAttackedPC(event) {
return {
eventId: event.id,
type: GAME_EVENT_TYPE,
round: event.round,
text: `${event.pcName} attacked ${event.targetName}.`,
}
}
function convertGameEventToMessage(event, playerId) {
const { eventType: type } = event
if (type === 'PC_ATTACKED_PC') {
return convertPCAttackedPC(event)
}
if (type === 'PC_ENTERED_HEX') {
return convertPCEnteredHex(event, playerId)
}
if (type === 'PC_LEFT_HEX') {
return convertPCLeftHex(event)
}
return null
}
// …
Looking at the feature test I wrote the other day which is still failing, I see the player names need to be changed to “you” if the current player was the attacker or the target.
defmodule MinotaurWeb.Game.PlayerAttackedTest do
# …
describe "round ends and player attacks another player" do
setup [:register_user, :create_game, :register_attacks, :join_session_before_next_round]
test "should update event log", ctx do
ctx.session
|> find(@events_log, fn element ->
assert_text(element, "You attacked Player2.")
assert_text(element, "Player2 attacked Player3.")
assert_text(element, "Player3 attacked you.")
end)
end
end
# …
end
I update convertPCAttackedPC
to take the playerId
as an argument which is used to determine if either name should be replaced in the message.
function convertPCAttackedPC(event, playerId) {
const attacker = playerId === event.playerId ? 'You' : event.pcName
const target = playerId === event.targetId ? 'you' : event.targetName
return {
eventId: event.id,
type: GAME_EVENT_TYPE,
round: event.round,
text: `${attacker} attacked ${target}.`,
}
}
Attack event messages are rendering in the events log and the tests are all passing!
Refactoring player name resolution
The data being sent to the frontend for movement and attack events include both the player ids and names which can be redundant since these paired values are static for the entire game session. I want to store a lookup map on the frontend to convert a player id to a name which will remove the need to send the player name with every event record.
I update the function that builds the LiveView event payload for joining the game session to include a map of player ids and their associated display names.
defmodule MinotaurWeb.GameLive do
# …
defp get_game_players(%{players: players}) do
Map.new(players, fn {id, player} ->
{id, Map.take(player, [:display_name])}
end)
end
defp push_game_joined_event(socket, game, player_state) do
state = %{
players: get_game_players(game),
player_state: GameFormatter.format(player_state),
round: get_round_data(game),
events: get_events(game, player_state.player_id)
}
push_event(socket, "game-joined", state)
end
# …
end
On the frontend, there is a store object that is passed as an argument to all LiveView event handlers which seems like a good place to save this player data. The store is a wrapper object around Alpine’s store feature which Alpine UI components can use as a data source for rendering content. I update the store object to provide a function for setting the initial values for the game session and simply track the data in-memory within a closure.
export default function initializeStore(Alpine) {
let gamePlayers= {}
return {
// …
players: {
setForGame(players) {
gamePlayers = players
},
getById(id) {
return gamePlayers[id]
},
},
}
}
In the LiveView game joined event handler, I extract the player data from the event payload and pass it to the store.
function GamePlayers(attrs) {
const playerEntries = Object.entries(attrs).map(
([id, { display_name: name }]) => [id, { name }],
)
return Object.fromEntries(playerEntries)
}
export default function createHandleGameJoined({ store }) {
return (serverEvent) => {
const gamePlayers = GamePlayers(serverEvent.players)
store.players.setForGame(gamePlayers)
// …
}
}
I update the chain of functions that convert game events to messages by passing in an anonymous function which resolves a player name from a player id using a reference to the players store.
function convertPCEnteredHex(event, playerId, getName) {
const mover = playerId === event.playerId ? 'You' : getName(event.playerId)
const direction = convertCoordToDirection(event.from)
if (!direction) {
return null
}
return {
eventId: event.id,
type: GAME_EVENT_TYPE,
round: event.round,
text: `${mover} arrived from the ${direction}.`,
}
}
function convertPCLeftHex(event, getName) {
const direction = convertCoordToDirection(event.to)
if (!direction) {
return null
}
return {
eventId: event.id,
type: GAME_EVENT_TYPE,
round: event.round,
text: `${getName(event.playerId)} moved to the ${direction}.`,
}
}
function convertPCAttackedPC(event, playerId, getName) {
const attacker = playerId === event.playerId ? 'You' : getName(event.playerId)
const target = playerId === event.targetId ? 'you' : getName(event.targetId)
return {
eventId: event.id,
type: GAME_EVENT_TYPE,
round: event.round,
text: `${attacker} attacked ${target}.`,
}
}
function convertGameEventToMessage(event, playerId, getName) {
const { eventType: type } = event
if (type === 'PC_ATTACKED_PC') {
return convertPCAttackedPC(event, playerId, getName)
}
if (type === 'PC_ENTERED_HEX') {
return convertPCEnteredHex(event, playerId, getName)
}
if (type === 'PC_LEFT_HEX') {
return convertPCLeftHex(event, getName)
}
return null
}
export default function createAddGameEventsToLog({ store }) {
const getPCName = (playerId) => store.players.getById(playerId).name
return function addGameEventsToLog(events, playerId) {
const eventMessages = events
.sort((a, b) => a.id - b.id)
.map((event) => convertGameEventToMessage(event, playerId, getPCName))
.reduce(insertRoundMarkers, [])
if (eventMessages.length) {
store.eventsLog.appendMessages(eventMessages)
}
}
}
I notice that the argument list is getting a bit busy in the event message conversion functions.
One of the arguments is the id of the current player which is used to check if the player name should be used or the second-person pronoun in the event message string.
The getName
function is already being passed into these conversion functions and it makes sense that this function could also encapsulate the logic for determining if a name or pronoun should be used.
I update the code with this change which trims down the conversion function bodies a bit.
function convertPCEnteredHex(event, getName) {
const mover = getName(event.playerId)
const direction = convertCoordToDirection(event.from)
// …
return {
// …
text: `${mover} arrived from the ${direction}.`,
}
}
function convertPCLeftHex(event, getName) {
const mover = getName(event.playerId)
const direction = convertCoordToDirection(event.to)
// …
return {
// …
text: `${mover} moved to the ${direction}.`,
}
}
function convertPCAttackedPC(event, getName) {
const attacker = getName(event.playerId)
const target = getName(event.targetId, false)
return {
// …
text: `${attacker} attacked ${target}.`,
}
}
function convertGameEventToMessage(event, getName) {
const { eventType: type } = event
if (type === 'PC_ATTACKED_PC') {
return convertPCAttackedPC(event, getName)
}
if (type === 'PC_ENTERED_HEX') {
return convertPCEnteredHex(event, getName)
}
if (type === 'PC_LEFT_HEX') {
return convertPCLeftHex(event, getName)
}
return null
}
export default function createAddGameEventsToLog({ store }) {
return function addGameEventsToLog(events, currentPlayerId) {
function getName(playerId, capitalize = true) {
if (playerId === currentPlayerId) {
return capitalize ? 'You' : 'you'
}
const player = store.players.getById(playerId)
if (!player) {
return 'ERR'
}
return player.name
}
const eventMessages = events
.sort((a, b) => a.id - b.id)
.map((event) => convertGameEventToMessage(event, getName))
.reduce(insertRoundMarkers, [])
// …
}
The pcName
and targetName
properties in the event type specifications are now redundant and no longer used in the domain code after the latest refactor.
I strip these from the frontend and backend event records and all the tests are still passing.
Event messages are now being created for all types of actions that can be resolved from a player. It is time to find the next feature to build to make this game more interesting. I can’t think of what would be more fun than having some monsters to fight so I’ll start brainstorming how to implement that as the next feature.