can play game

This commit is contained in:
2026-03-02 13:39:18 -07:00
parent 9955a7f90c
commit 0dae393d0d
27 changed files with 856 additions and 474 deletions

View File

@@ -1,28 +1,17 @@
defmodule Backend.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
BackendWeb.Telemetry,
{DNSCluster, query: Application.get_env(:backend, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Backend.PubSub},
# Start the Cluster manager for distributed Erlang
Backend.Cluster,
# Use GlobalSingleton supervisor for instant failover
{Backend.GlobalSingleton, Backend.GameState},
# Start a worker by calling: Backend.Worker.start_link(arg)
# {Backend.Worker, arg},
# Start to serve requests, typically the last entry
BackendWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Backend.Supervisor]
Supervisor.start_link(children, opts)
end

View File

@@ -33,43 +33,30 @@ defmodule Backend.Cluster do
end
defp connect_to_cluster do
# Get all cluster nodes and exclude ourselves
all_nodes =
self_node = node()
cluster_nodes =
System.get_env("CLUSTER_NODES", "")
|> String.split(",", trim: true)
|> Enum.map(&String.to_atom/1)
|> Enum.filter(&(&1 != self_node))
cluster_nodes = Enum.reject(all_nodes, &(&1 == node()))
connection_attempts =
cluster_nodes
|> Enum.map(&%{node: &1, status: Node.connect(&1)})
failures =
Enum.filter(cluster_nodes, fn node_name ->
case Node.connect(node_name) do
true ->
# Logger.debug("Connected to node: #{node_name}")
false
false ->
Logger.warning("Failed to connect to node: #{node_name}")
true
:ignored ->
# Already connected, not a failure
false
end
end)
connection_attempts
|> Enum.filter(fn %{status: status} -> status == false end)
connected_nodes = Node.list()
# Expected count is other nodes (not including ourselves)
expected_count = length(cluster_nodes)
actual_count = length(connected_nodes)
if failures != [] or actual_count < expected_count do
if failures != [] or length(connected_nodes) < length(cluster_nodes) do
Logger.warning(
"Cluster status: #{actual_count}/#{expected_count} nodes connected: #{inspect(connected_nodes)}"
"Cluster status: #{length(connected_nodes)}/#{length(cluster_nodes)} nodes connected: #{inspect(connected_nodes)}"
)
end
end
end
defp schedule_connect do
# Retry connection every 10 seconds

View File

@@ -10,6 +10,7 @@ defmodule Backend.GameState do
@name {:global, __MODULE__}
@pubsub Backend.PubSub
@topic "game_state"
@fps 30
# Client API
@@ -39,6 +40,10 @@ defmodule Backend.GameState do
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
@@ -48,6 +53,9 @@ defmodule Backend.GameState do
@impl true
def init(_) do
Logger.info("GameState starting on node: #{node()}")
sleep_delay = round(1000 / @fps)
:timer.send_interval(sleep_delay, :tick)
{:ok, %{}}
end
@@ -57,43 +65,53 @@ defmodule Backend.GameState do
x = :rand.uniform(800)
y = :rand.uniform(600)
new_state = Map.put(state, player_id, %{x: x, y: y})
new_state = Map.put(state, player_id, %{x: x, y: y, keys_pressed: []})
broadcast_state(new_state)
{:reply, new_state, new_state}
end
@impl true
def handle_cast({:move_player, player_id, directions}, state) do
Logger.info("Processing player move")
def handle_cast({:update_player_keys, player_id, keys_pressed}, state) do
player =
case Map.get(state, player_id) do
nil ->
Logger.warning(
"Move attempted for non-existent player: #{player_id}, creating at random position"
"Key update for non-existent player: #{player_id}, creating at random position"
)
%{x: :rand.uniform(800), y: :rand.uniform(600)}
%{x: :rand.uniform(800), y: :rand.uniform(600), keys_pressed: keys_pressed}
existing_player ->
existing_player
Map.put(existing_player, :keys_pressed, keys_pressed)
end
step = 10
new_state = Map.put(state, player_id, player)
broadcast_state(new_state)
{:noreply, new_state}
end
# Apply all directions to calculate final position
new_position =
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
@impl true
def handle_info(:tick, state) do
# On each tick, move players based on their keys_pressed
new_state =
Enum.reduce(state, state, fn {player_id, player}, acc ->
directions = player.keys_pressed || []
step = 10
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)
Map.put(acc, player_id, new_player)
end)
new_state = Map.put(state, player_id, new_position)
broadcast_state(new_state)
{:noreply, new_state}
end

View File

@@ -0,0 +1,25 @@
defmodule Backend.LobbyTracker do
use GenServer
require Logger
@name {:global, __MODULE__}
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: @name)
end
def get_lobbies do
GenServer.call(@name, :get_lobbies)
end
@impl true
def init(_) do
Logger.info("LobbyTracker started on node: #{node()}")
{:ok, %{}}
end
@impl true
def handle_call(:get_lobbies, _from, state) do
{:reply, state, state}
end
end

View File

@@ -0,0 +1,118 @@
defmodule BackendWeb.ConnectedUserChannel do
@moduledoc """
Manages requests that come in from browser
"""
use BackendWeb, :channel
require Logger
@impl true
def join("user:" <> user_name, _params, %{assigns: %{user_name: socket_user}} = socket)
when user_name == socket_user do
Logger.info("WebSocket connected to #{node()}, user: #{user_name}")
Phoenix.PubSub.subscribe(Backend.PubSub, "game_state")
Phoenix.PubSub.subscribe(Backend.PubSub, "user_sessions:#{user_name}")
# Notify other sessions for this user
Phoenix.PubSub.broadcast_from(
Backend.PubSub,
self(),
"user_sessions:#{user_name}",
:new_browser_connection
)
current_state = Backend.GameState.get_state()
{:ok, %{game_state: current_state}, socket}
end
def join("user:" <> user_name, _params, %{assigns: %{user_name: socket_user}}) do
Logger.warning("User #{socket_user} attempted to join channel for #{user_name}")
{:error, %{reason: "unauthorized"}}
end
def join(_topic, _params, _socket) do
{:error, %{reason: "authentication required"}}
end
@impl true
def handle_info({:game_state_updated, state}, socket) do
push(socket, "game_state", %{game_state: state})
{:noreply, socket}
end
@impl true
def handle_info(:new_browser_connection, socket) do
Logger.warning("New browser connection detected for user: #{socket.assigns.user_name}")
push(socket, "new_browser_connection", %{})
{:noreply, socket}
end
@impl true
def handle_in("join_game", %{"name" => name}, socket) do
Logger.info("Player '#{name}' joining game on #{node()}")
socket =
socket
|> assign(:player_name, name)
|> assign(:keys_pressed, MapSet.new())
Backend.GameState.add_player(name)
{:reply, :ok, socket}
end
@impl true
def handle_in("key_down", %{"key" => key}, socket) do
case socket.assigns[:player_name] do
nil ->
Logger.warning("Key down attempted without joining game")
{:noreply, socket}
player_name ->
keys_pressed = MapSet.put(socket.assigns[:keys_pressed] || MapSet.new(), key)
socket = assign(socket, :keys_pressed, keys_pressed)
Logger.debug(
"Player '#{player_name}' key down: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}"
)
Backend.GameState.update_player_keys(player_name, MapSet.to_list(keys_pressed))
{:noreply, socket}
end
end
@impl true
def handle_in("key_up", %{"key" => key}, socket) do
case socket.assigns[:player_name] do
nil ->
Logger.warning("Key up attempted without joining game")
{:noreply, socket}
player_name ->
keys_pressed = MapSet.delete(socket.assigns[:keys_pressed] || MapSet.new(), key)
socket = assign(socket, :keys_pressed, keys_pressed)
Logger.debug(
"Player '#{player_name}' key up: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}"
)
Backend.GameState.update_player_keys(player_name, MapSet.to_list(keys_pressed))
{:noreply, socket}
end
end
@impl true
def terminate(_reason, socket) do
case socket.assigns[:player_name] do
nil ->
Logger.info("WebSocket disconnected from #{node()}")
player_name ->
Logger.info("Player '#{player_name}' disconnected from #{node()}")
Backend.GameState.remove_player(player_name)
end
:ok
end
end

View File

@@ -1,60 +0,0 @@
defmodule BackendWeb.GameChannel do
@moduledoc """
Channel for handling game events and player movements.
"""
use BackendWeb, :channel
require Logger
@impl true
def join("game:lobby", _payload, socket) do
Logger.info("WebSocket connected to #{node()}, waiting for name")
Phoenix.PubSub.subscribe(Backend.PubSub, "game_state")
current_state = Backend.GameState.get_state()
{:ok, %{players: current_state}, socket}
end
@impl true
def handle_info({:game_state_updated, state}, socket) do
push(socket, "game_state", %{players: state})
{:noreply, socket}
end
@impl true
def handle_in("join_game", %{"name" => name}, socket) do
Logger.info("Player '#{name}' joining game on #{node()}")
socket = assign(socket, :player_name, name)
Backend.GameState.add_player(name)
{:reply, :ok, socket}
end
@impl true
def handle_in("move", %{"directions" => directions}, socket) do
case socket.assigns[:player_name] do
nil ->
Logger.warning("Move attempted without joining game")
{:noreply, socket}
player_name ->
Logger.debug("Player '#{player_name}' moved #{inspect(directions)} on #{node()}")
Backend.GameState.move_player(player_name, directions)
{:noreply, socket}
end
end
@impl true
def terminate(_reason, socket) do
case socket.assigns[:player_name] do
nil ->
Logger.info("WebSocket disconnected from #{node()}")
player_name ->
Logger.info("Player '#{player_name}' disconnected from #{node()}")
Backend.GameState.remove_player(player_name)
end
:ok
end
end

View File

@@ -1,36 +1,27 @@
defmodule BackendWeb.UserSocket do
use Phoenix.Socket
require Logger
# A Socket handler
#
# It's possible to control the websocket connection and
# assign values that can be accessed by your channel topics.
## Channels
channel("game:*", BackendWeb.GameChannel)
channel("user:*", BackendWeb.ConnectedUserChannel)
# Socket params are passed from the client and can
# be used to verify and authenticate a user. After
# verification, you can put default assigns into
# the socket that will be set for all channels, ie
#
# {:ok, assign(socket, :user_id, verified_user_id)}
#
# To deny connection, return `:error` or `{:error, term}`.
@impl true
def connect(_params, socket, _connect_info) do
{:ok, socket}
def connect(%{"user_name" => user_name}, socket, _connect_info) do
{:ok, assign(socket, :user_name, user_name)}
end
def connect(_params, _socket, _connect_info) do
Logger.warning("WebSocket connection rejected: user_name required")
{:error, %{reason: "user_name required"}}
end
# Socket id's are topics that allow you to identify all sockets for a given user:
#
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
#
# Would allow you to broadcast a "disconnect" event and terminate
# all active sockets and channels for a given user:
#
#
# Returning `nil` makes this socket anonymous.
@impl true
def id(%{assigns: %{user_name: user_name}}) do
# assign websocket to user name
# allows other parts of app to manipulate all sockets for a given user
"user_socket:#{user_name}"
end
def id(_socket), do: nil
end