Files
odinsea-elixir/lib/odinsea/game/map.ex

301 lines
8.1 KiB
Elixir

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