defmodule Odinsea.Game.Map do @moduledoc """ Represents a game map instance. Each map is a GenServer that manages all objects on the map: - Players - Monsters (mobs) - NPCs - Items (drops) - Reactors - Portals Maps are registered by map_id and belong to a specific channel. """ use GenServer require Logger alias Odinsea.Game.Character alias Odinsea.Channel.Packets, as: ChannelPackets # ============================================================================ # Data Structures # ============================================================================ defmodule State do @moduledoc "Map instance state" defstruct [ # Map identity :map_id, :channel_id, # Objects on map (by type) :players, # Map stores character_id => %{oid: integer(), character: Character.State} :monsters, # Map stores oid => Monster :npcs, # Map stores oid => NPC :items, # Map stores oid => Item :reactors, # Map stores oid => Reactor # Object ID counter :next_oid, # Map properties (TODO: load from WZ data) :return_map, :forced_return, :time_limit, :field_limit, :mob_rate, :drop_rate, # Timestamps :created_at ] @type t :: %__MODULE__{ map_id: non_neg_integer(), channel_id: byte(), players: %{pos_integer() => map()}, monsters: %{pos_integer() => any()}, npcs: %{pos_integer() => any()}, items: %{pos_integer() => any()}, reactors: %{pos_integer() => any()}, next_oid: pos_integer(), return_map: non_neg_integer() | nil, forced_return: non_neg_integer() | nil, time_limit: non_neg_integer() | nil, field_limit: non_neg_integer() | nil, mob_rate: float(), drop_rate: float(), created_at: DateTime.t() } end # ============================================================================ # Client API # ============================================================================ @doc """ Starts a map GenServer. """ def start_link(opts) do map_id = Keyword.fetch!(opts, :map_id) channel_id = Keyword.fetch!(opts, :channel_id) GenServer.start_link(__MODULE__, opts, name: via_tuple(map_id, channel_id)) end @doc """ Ensures a map is loaded for the given channel. """ def ensure_map(map_id, channel_id) do case Registry.lookup(Odinsea.MapRegistry, {map_id, channel_id}) do [{pid, _}] -> {:ok, pid} [] -> # Start map via DynamicSupervisor spec = {__MODULE__, map_id: map_id, channel_id: channel_id} case DynamicSupervisor.start_child(Odinsea.MapSupervisor, spec) do {:ok, pid} -> {:ok, pid} {:error, {:already_started, pid}} -> {:ok, pid} error -> error end end end @doc """ Adds a player to the map. """ def add_player(map_id, character_id) do # TODO: Get channel_id from somewhere channel_id = 1 {:ok, _pid} = ensure_map(map_id, channel_id) GenServer.call(via_tuple(map_id, channel_id), {:add_player, character_id}) end @doc """ Removes a player from the map. """ def remove_player(map_id, character_id) do # TODO: Get channel_id from somewhere channel_id = 1 case Registry.lookup(Odinsea.MapRegistry, {map_id, channel_id}) do [{_pid, _}] -> GenServer.call(via_tuple(map_id, channel_id), {:remove_player, character_id}) [] -> :ok end end @doc """ Broadcasts a packet to all players on the map. """ def broadcast(map_id, channel_id, packet) do GenServer.cast(via_tuple(map_id, channel_id), {:broadcast, packet}) end @doc """ Broadcasts a packet to all players except the specified character. """ def broadcast_except(map_id, channel_id, except_character_id, packet) do GenServer.cast(via_tuple(map_id, channel_id), {:broadcast_except, except_character_id, packet}) end @doc """ Gets all players on the map. """ def get_players(map_id, channel_id) do GenServer.call(via_tuple(map_id, channel_id), :get_players) end # ============================================================================ # GenServer Callbacks # ============================================================================ @impl true def init(opts) do map_id = Keyword.fetch!(opts, :map_id) channel_id = Keyword.fetch!(opts, :channel_id) state = %State{ map_id: map_id, channel_id: channel_id, players: %{}, monsters: %{}, npcs: %{}, items: %{}, reactors: %{}, next_oid: 500_000, return_map: nil, forced_return: nil, time_limit: nil, field_limit: 0, mob_rate: 1.0, drop_rate: 1.0, created_at: DateTime.utc_now() } Logger.debug("Map loaded: #{map_id} (channel #{channel_id})") {:ok, state} end @impl true def handle_call({:add_player, character_id}, _from, state) do # Allocate OID for this player oid = state.next_oid # Get character state case Character.get_state(character_id) do %Character.State{} = char_state -> # Add player to map player_entry = %{ oid: oid, character: char_state } new_players = Map.put(state.players, character_id, player_entry) # Broadcast spawn packet to other players spawn_packet = ChannelPackets.spawn_player(oid, char_state) broadcast_to_players(new_players, spawn_packet, except: character_id) # Send existing players to new player client_pid = char_state.client_pid if client_pid do send_existing_players(client_pid, new_players, except: character_id) end new_state = %{ state | players: new_players, next_oid: oid + 1 } {:reply, {:ok, oid}, new_state} nil -> {:reply, {:error, :character_not_found}, state} end end @impl true def handle_call({:remove_player, character_id}, _from, state) do case Map.get(state.players, character_id) do nil -> {:reply, :ok, state} player_entry -> # Broadcast despawn packet despawn_packet = ChannelPackets.remove_player(player_entry.oid) broadcast_to_players(state.players, despawn_packet, except: character_id) # Remove from map new_players = Map.delete(state.players, character_id) new_state = %{state | players: new_players} {:reply, :ok, new_state} end end @impl true def handle_call(:get_players, _from, state) do {:reply, state.players, state} end @impl true def handle_cast({:broadcast, packet}, state) do broadcast_to_players(state.players, packet) {:noreply, state} end @impl true def handle_cast({:broadcast_except, except_character_id, packet}, state) do broadcast_to_players(state.players, packet, except: except_character_id) {:noreply, state} end # ============================================================================ # Helper Functions # ============================================================================ defp via_tuple(map_id, channel_id) do {:via, Registry, {Odinsea.MapRegistry, {map_id, channel_id}}} end defp broadcast_to_players(players, packet, opts \\ []) do except_char_id = Keyword.get(opts, :except) Enum.each(players, fn {char_id, %{character: char_state}} when char_id != except_char_id -> if char_state.client_pid do send_packet(char_state.client_pid, packet) end _ -> :ok end) end defp send_existing_players(client_pid, players, opts) do except_char_id = Keyword.get(opts, :except) Enum.each(players, fn {char_id, %{oid: oid, character: char_state}} when char_id != except_char_id -> spawn_packet = ChannelPackets.spawn_player(oid, char_state) send_packet(client_pid, spawn_packet) _ -> :ok end) end defp send_packet(client_pid, packet) do send(client_pid, {:send_packet, packet}) end end