Files
elixir-websocket-testing/backend/lib/backend/game_runner.ex
2026-03-02 16:31:44 -07:00

147 lines
3.8 KiB
Elixir

defmodule Backend.GameRunner do
@moduledoc """
GenServer to track all players and their positions in the game.
Uses :global registry for distributed singleton pattern - only one instance
runs across the entire cluster, with automatic failover.
"""
use GenServer
require Logger
@name {:global, __MODULE__}
@pubsub Backend.PubSub
@topic "game_state"
@fps 30
# Client API
def start_link(_opts) do
case GenServer.start_link(__MODULE__, %{}, name: @name) do
{:ok, pid} ->
{:ok, pid}
{:error, {:already_started, pid}} ->
# Another instance is already running globally
:ignore
end
end
def add_player(player_id) do
Logger.info("Player #{player_id} connected")
GenServer.cast(@name, {:add_player, player_id})
end
def remove_player(player_id) do
Logger.info("Player #{player_id} removed")
GenServer.cast(@name, {:remove_player, player_id})
end
def move_player(player_id, directions) do
Logger.info("Player #{player_id} moved #{inspect(directions)}")
GenServer.cast(@name, {:move_player, player_id, directions})
end
def update_player_keys(player_id, keys_pressed) do
GenServer.cast(@name, {:update_player_keys, player_id, keys_pressed})
end
def get_state do
GenServer.call(@name, :get_state)
end
# Server Callbacks
@impl true
def init(_) do
Logger.info("GameState starting on node: #{node()}")
sleep_delay = round(1000 / @fps)
:timer.send_interval(sleep_delay, :tick)
{:ok, %{players: %{}, tick_number: 0}}
end
@impl true
def handle_cast({:add_player, player_id}, _from, state) do
# Start players at random position
x = :rand.uniform(800)
y = :rand.uniform(600)
new_state = %{
state
| players: state.players |> Map.put(player_id, %{x: x, y: y, keys_pressed: []})
}
{:noreply, broadcast_state(new_state)}
end
@impl true
def handle_cast({:update_player_keys, player_id, keys_pressed}, state) do
player =
case Map.get(state.players, player_id) do
nil ->
Logger.warning(
"Key update for non-existent player: #{player_id}, creating at random position"
)
%{x: :rand.uniform(800), y: :rand.uniform(600), keys_pressed: keys_pressed}
existing_player ->
Map.put(existing_player, :keys_pressed, keys_pressed)
end
new_state = %{state | players: Map.put(state.players, player_id, player)}
{:noreply, broadcast_state(new_state)}
end
@impl true
def handle_info(:tick, state) do
if rem(state.tick_number, 100) == 0 do
Logger.info("Game tick #{state.tick_number} on #{node()}, updating player positions")
end
step = 10
new_players =
state.players
|> Enum.map(fn {player_id, player} ->
directions = player.keys_pressed || []
new_player =
Enum.reduce(directions, player, fn direction, acc ->
case direction do
"w" -> %{acc | y: max(0, acc.y - step)}
"a" -> %{acc | x: max(0, acc.x - step)}
"s" -> %{acc | y: min(600, acc.y + step)}
"d" -> %{acc | x: min(800, acc.x + step)}
_ -> acc
end
end)
{player_id, new_player}
end)
|> Enum.into(%{})
new_state = %{state | players: new_players, tick_number: state.tick_number + 1}
{:noreply, broadcast_state(new_state)}
end
@impl true
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
@impl true
def handle_cast({:remove_player, player_id}, state) do
players = Map.delete(state.players, player_id)
new_state = %{state | players: players}
{:noreply, broadcast_state(new_state)}
end
defp broadcast_state(state) do
Phoenix.PubSub.broadcast(@pubsub, @topic, {:game_state_updated, state})
state
end
end