1174 lines
35 KiB
Elixir
1174 lines
35 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.Game.{Drop, MapFactory, LifeFactory, Monster, Reactor, ReactorFactory}
|
|
alias Odinsea.Channel.Packets, as: ChannelPackets
|
|
|
|
# ============================================================================
|
|
# Data Structures
|
|
# ============================================================================
|
|
|
|
defmodule SpawnPoint do
|
|
@moduledoc "Represents a monster spawn point on the map"
|
|
defstruct [
|
|
:id,
|
|
# Unique spawn point ID
|
|
:mob_id,
|
|
# Monster ID to spawn
|
|
:x,
|
|
# Spawn position X
|
|
:y,
|
|
# Spawn position Y
|
|
:fh,
|
|
# Foothold
|
|
:cy,
|
|
# CY value
|
|
:f,
|
|
# Facing direction (0 = left, 1 = right)
|
|
:mob_time,
|
|
# Respawn time in milliseconds
|
|
:spawned_oid,
|
|
# OID of currently spawned monster (nil if not spawned)
|
|
:last_spawn_time,
|
|
# Last time monster was spawned
|
|
:respawn_timer_ref
|
|
# Timer reference for respawn
|
|
]
|
|
|
|
@type t :: %__MODULE__{
|
|
id: integer(),
|
|
mob_id: integer(),
|
|
x: integer(),
|
|
y: integer(),
|
|
fh: integer(),
|
|
cy: integer(),
|
|
f: integer(),
|
|
mob_time: integer(),
|
|
spawned_oid: integer() | nil,
|
|
last_spawn_time: DateTime.t() | nil,
|
|
respawn_timer_ref: reference() | nil
|
|
}
|
|
end
|
|
|
|
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.t()
|
|
:npcs,
|
|
# Map stores oid => NPC
|
|
:items,
|
|
# Map stores oid => Item
|
|
:reactors,
|
|
# Map stores oid => Reactor
|
|
:spawn_points,
|
|
# Map stores spawn_id => SpawnPoint.t()
|
|
# Object ID counter
|
|
:next_oid,
|
|
# Map properties (loaded from MapFactory)
|
|
:return_map,
|
|
:forced_return,
|
|
:time_limit,
|
|
:field_limit,
|
|
:mob_rate,
|
|
:drop_rate,
|
|
:map_name,
|
|
:street_name,
|
|
# Timestamps
|
|
:created_at
|
|
]
|
|
|
|
@type t :: %__MODULE__{
|
|
map_id: non_neg_integer(),
|
|
channel_id: byte(),
|
|
players: %{pos_integer() => map()},
|
|
monsters: %{pos_integer() => Monster.t()},
|
|
npcs: %{pos_integer() => any()},
|
|
items: %{pos_integer() => any()},
|
|
reactors: %{pos_integer() => any()},
|
|
spawn_points: %{integer() => SpawnPoint.t()},
|
|
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(),
|
|
map_name: String.t() | nil,
|
|
street_name: String.t() | nil,
|
|
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
|
|
|
|
@doc """
|
|
Gets all monsters on the map.
|
|
"""
|
|
def get_monsters(map_id, channel_id) do
|
|
GenServer.call(via_tuple(map_id, channel_id), :get_monsters)
|
|
end
|
|
|
|
@doc """
|
|
Spawns a monster at the specified spawn point.
|
|
"""
|
|
def spawn_monster(map_id, channel_id, spawn_id) do
|
|
GenServer.cast(via_tuple(map_id, channel_id), {:spawn_monster, spawn_id})
|
|
end
|
|
|
|
@doc """
|
|
Handles monster death and initiates respawn.
|
|
"""
|
|
def monster_killed(map_id, channel_id, oid, killer_id \\ nil) do
|
|
GenServer.cast(via_tuple(map_id, channel_id), {:monster_killed, oid, killer_id})
|
|
end
|
|
|
|
@doc """
|
|
Damages a monster.
|
|
"""
|
|
def damage_monster(map_id, channel_id, oid, damage, character_id) do
|
|
GenServer.call(via_tuple(map_id, channel_id), {:damage_monster, oid, damage, character_id})
|
|
end
|
|
|
|
@doc """
|
|
Hits a reactor, advancing its state and triggering effects.
|
|
"""
|
|
def hit_reactor(map_id, channel_id, oid, character_id, stance \\ 0) do
|
|
GenServer.call(via_tuple(map_id, channel_id), {:hit_reactor, oid, character_id, stance})
|
|
end
|
|
|
|
@doc """
|
|
Destroys a reactor (e.g., after final state).
|
|
"""
|
|
def destroy_reactor(map_id, channel_id, oid) do
|
|
GenServer.call(via_tuple(map_id, channel_id), {:destroy_reactor, oid})
|
|
end
|
|
|
|
@doc """
|
|
Gets a reactor by OID.
|
|
"""
|
|
def get_reactor(map_id, channel_id, oid) do
|
|
GenServer.call(via_tuple(map_id, channel_id), {:get_reactor, oid})
|
|
end
|
|
|
|
@doc """
|
|
Gets all reactors on the map.
|
|
"""
|
|
def get_reactors(map_id, channel_id) do
|
|
GenServer.call(via_tuple(map_id, channel_id), :get_reactors)
|
|
end
|
|
|
|
@doc """
|
|
Respawns a destroyed reactor after its delay.
|
|
"""
|
|
def respawn_reactor(map_id, channel_id, original_reactor) do
|
|
GenServer.cast(via_tuple(map_id, channel_id), {:respawn_reactor, original_reactor})
|
|
end
|
|
|
|
# ============================================================================
|
|
# GenServer Callbacks
|
|
# ============================================================================
|
|
|
|
@impl true
|
|
def init(opts) do
|
|
map_id = Keyword.fetch!(opts, :map_id)
|
|
channel_id = Keyword.fetch!(opts, :channel_id)
|
|
|
|
# Load map template from MapFactory
|
|
template = MapFactory.get_template(map_id)
|
|
|
|
spawn_points =
|
|
if template do
|
|
load_spawn_points(template)
|
|
else
|
|
%{}
|
|
end
|
|
|
|
# Load reactor spawns from template and create reactors
|
|
reactors =
|
|
if template do
|
|
load_reactors(template, 500_000)
|
|
else
|
|
{%{}, 500_000}
|
|
end
|
|
|
|
{reactor_map, next_oid} = reactors
|
|
|
|
state = %State{
|
|
map_id: map_id,
|
|
channel_id: channel_id,
|
|
players: %{},
|
|
monsters: %{},
|
|
npcs: %{},
|
|
items: %{},
|
|
reactors: reactor_map,
|
|
spawn_points: spawn_points,
|
|
next_oid: next_oid,
|
|
return_map: if(template, do: template.return_map, else: nil),
|
|
forced_return: if(template, do: template.forced_return, else: nil),
|
|
time_limit: if(template, do: template.time_limit, else: nil),
|
|
field_limit: if(template, do: template.field_limit, else: 0),
|
|
mob_rate: if(template, do: template.mob_rate, else: 1.0),
|
|
drop_rate: 1.0,
|
|
map_name: if(template, do: template.map_name, else: "Unknown"),
|
|
street_name: if(template, do: template.street_name, else: ""),
|
|
created_at: DateTime.utc_now()
|
|
}
|
|
|
|
Logger.debug("Map loaded: #{map_id} (channel #{channel_id}) - #{map_size(spawn_points)} spawn points")
|
|
|
|
# Schedule initial monster spawning
|
|
if map_size(spawn_points) > 0 do
|
|
Process.send_after(self(), :spawn_initial_monsters, 100)
|
|
end
|
|
|
|
{: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)
|
|
send_existing_monsters(client_pid, state.monsters)
|
|
send_existing_reactors(client_pid, state.reactors)
|
|
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_call(:get_monsters, _from, state) do
|
|
{:reply, state.monsters, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:damage_monster, oid, damage_amount, character_id}, _from, state) do
|
|
case Map.get(state.monsters, oid) do
|
|
nil ->
|
|
{:reply, {:error, :monster_not_found}, state}
|
|
|
|
monster ->
|
|
# Apply damage to monster
|
|
case Monster.damage(monster, damage_amount, character_id) do
|
|
{:dead, updated_monster, actual_damage} ->
|
|
# Monster died
|
|
Logger.debug("Monster #{oid} killed on map #{state.map_id}")
|
|
|
|
# Remove monster from map
|
|
new_monsters = Map.delete(state.monsters, oid)
|
|
|
|
# Find spawn point
|
|
spawn_point_id = find_spawn_point_by_oid(state.spawn_points, oid)
|
|
|
|
# Update spawn point to clear spawned monster
|
|
new_spawn_points =
|
|
if spawn_point_id do
|
|
update_spawn_point(state.spawn_points, spawn_point_id, fn sp ->
|
|
%{sp | spawned_oid: nil, last_spawn_time: DateTime.utc_now()}
|
|
end)
|
|
else
|
|
state.spawn_points
|
|
end
|
|
|
|
# Schedule respawn
|
|
if spawn_point_id do
|
|
spawn_point = Map.get(new_spawn_points, spawn_point_id)
|
|
schedule_respawn(spawn_point_id, spawn_point.mob_time)
|
|
end
|
|
|
|
# Broadcast monster death packet
|
|
kill_packet = ChannelPackets.kill_monster(updated_monster, 1)
|
|
broadcast_to_players(state.players, kill_packet)
|
|
|
|
Logger.debug("Monster killed: OID #{oid} on map #{state.map_id}")
|
|
|
|
# Calculate and distribute EXP
|
|
distribute_exp(updated_monster, state.players, character_id)
|
|
|
|
# Create drops
|
|
new_state =
|
|
if not Monster.drops_disabled?(updated_monster) do
|
|
create_monster_drops(updated_monster, character_id, state)
|
|
else
|
|
%{state | monsters: new_monsters, spawn_points: new_spawn_points}
|
|
end
|
|
|
|
{:reply, {:ok, :killed}, new_state}
|
|
|
|
{:ok, updated_monster, actual_damage} ->
|
|
# Monster still alive
|
|
new_monsters = Map.put(state.monsters, oid, updated_monster)
|
|
new_state = %{state | monsters: new_monsters}
|
|
|
|
# Broadcast damage packet
|
|
damage_packet = ChannelPackets.damage_monster(oid, actual_damage)
|
|
broadcast_to_players(state.players, damage_packet)
|
|
|
|
{:reply, {:ok, :damaged}, new_state}
|
|
end
|
|
end
|
|
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
|
|
|
|
# ============================================================================
|
|
# Reactor Callbacks
|
|
# ============================================================================
|
|
|
|
@impl true
|
|
def handle_call({:hit_reactor, oid, _character_id, stance}, _from, state) do
|
|
case Map.get(state.reactors, oid) do
|
|
nil ->
|
|
{:reply, {:error, :reactor_not_found}, state}
|
|
|
|
reactor ->
|
|
if not reactor.alive do
|
|
{:reply, {:error, :reactor_not_alive}, state}
|
|
else
|
|
# Advance reactor state
|
|
old_state = reactor.state
|
|
new_reactor = Reactor.advance_state(reactor)
|
|
|
|
# Check if reactor should be destroyed
|
|
if Reactor.should_destroy?(new_reactor) do
|
|
# Destroy reactor
|
|
destroy_packet = ChannelPackets.destroy_reactor(new_reactor)
|
|
broadcast_to_players(state.players, destroy_packet)
|
|
|
|
new_reactor = Reactor.set_alive(new_reactor, false)
|
|
new_reactors = Map.put(state.reactors, oid, new_reactor)
|
|
|
|
# Schedule respawn if delay is set
|
|
if new_reactor.delay > 0 do
|
|
schedule_reactor_respawn(oid, new_reactor.delay)
|
|
end
|
|
|
|
{:reply, {:ok, :destroyed}, %{state | reactors: new_reactors}}
|
|
else
|
|
# Trigger state change
|
|
trigger_packet = ChannelPackets.trigger_reactor(new_reactor, stance)
|
|
broadcast_to_players(state.players, trigger_packet)
|
|
|
|
# Check for timeout and schedule if needed
|
|
timeout = Reactor.get_timeout(new_reactor)
|
|
new_reactor =
|
|
if timeout > 0 do
|
|
Reactor.set_timer_active(new_reactor, true)
|
|
else
|
|
new_reactor
|
|
end
|
|
|
|
new_reactors = Map.put(state.reactors, oid, new_reactor)
|
|
|
|
# If state changed, this might trigger a script
|
|
script_trigger = old_state != new_reactor.state
|
|
|
|
{:reply, {:ok, %{state_changed: true, script_trigger: script_trigger}}, %{state | reactors: new_reactors}}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:destroy_reactor, oid}, _from, state) do
|
|
case Map.get(state.reactors, oid) do
|
|
nil ->
|
|
{:reply, {:error, :reactor_not_found}, state}
|
|
|
|
reactor ->
|
|
# Broadcast destroy
|
|
destroy_packet = ChannelPackets.destroy_reactor(reactor)
|
|
broadcast_to_players(state.players, destroy_packet)
|
|
|
|
new_reactor =
|
|
reactor
|
|
|> Reactor.set_alive(false)
|
|
|> Reactor.set_timer_active(false)
|
|
|
|
new_reactors = Map.put(state.reactors, oid, new_reactor)
|
|
|
|
# Schedule respawn if delay is set
|
|
if reactor.delay > 0 do
|
|
schedule_reactor_respawn(oid, reactor.delay)
|
|
end
|
|
|
|
{:reply, :ok, %{state | reactors: new_reactors}}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:get_reactor, oid}, _from, state) do
|
|
{:reply, Map.get(state.reactors, oid), state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:get_reactors, _from, state) do
|
|
{:reply, state.reactors, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:respawn_reactor, original_reactor}, state) do
|
|
# Create a fresh copy of the reactor
|
|
respawned =
|
|
original_reactor
|
|
|> Reactor.copy()
|
|
|> Reactor.set_oid(state.next_oid)
|
|
|
|
new_reactors = Map.put(state.reactors, state.next_oid, respawned)
|
|
|
|
# Broadcast spawn
|
|
spawn_packet = ChannelPackets.spawn_reactor(respawned)
|
|
broadcast_to_players(state.players, spawn_packet)
|
|
|
|
Logger.debug("Reactor #{respawned.reactor_id} respawned on map #{state.map_id} with OID #{state.next_oid}")
|
|
|
|
{:noreply, %{state | reactors: new_reactors, next_oid: state.next_oid + 1}}
|
|
end
|
|
|
|
# ============================================================================
|
|
# Monster Callbacks
|
|
# ============================================================================
|
|
|
|
@impl true
|
|
def handle_cast({:spawn_monster, spawn_id}, state) do
|
|
case Map.get(state.spawn_points, spawn_id) do
|
|
nil ->
|
|
Logger.warn("Spawn point #{spawn_id} not found on map #{state.map_id}")
|
|
{:noreply, state}
|
|
|
|
spawn_point ->
|
|
if spawn_point.spawned_oid do
|
|
# Already spawned
|
|
{:noreply, state}
|
|
else
|
|
# Spawn new monster
|
|
{new_state, _oid} = do_spawn_monster(state, spawn_point, spawn_id)
|
|
{:noreply, new_state}
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:monster_killed, oid, killer_id}, state) do
|
|
# Handle monster death (called externally)
|
|
case Map.get(state.monsters, oid) do
|
|
nil ->
|
|
{:noreply, state}
|
|
|
|
monster ->
|
|
# Remove monster
|
|
new_monsters = Map.delete(state.monsters, oid)
|
|
|
|
# Find and update spawn point
|
|
spawn_point_id = find_spawn_point_by_oid(state.spawn_points, oid)
|
|
|
|
new_spawn_points =
|
|
if spawn_point_id do
|
|
update_spawn_point(state.spawn_points, spawn_point_id, fn sp ->
|
|
%{sp | spawned_oid: nil, last_spawn_time: DateTime.utc_now()}
|
|
end)
|
|
else
|
|
state.spawn_points
|
|
end
|
|
|
|
# Schedule respawn
|
|
if spawn_point_id do
|
|
spawn_point = Map.get(new_spawn_points, spawn_point_id)
|
|
schedule_respawn(spawn_point_id, spawn_point.mob_time)
|
|
end
|
|
|
|
# Create drops if killer_id is provided
|
|
new_state =
|
|
if killer_id && not Monster.drops_disabled?(monster) do
|
|
monster_with_stats = %{monster | attackers: %{}} # Reset attackers since this is external
|
|
create_monster_drops(monster, killer_id, %{state |
|
|
monsters: new_monsters,
|
|
spawn_points: new_spawn_points
|
|
})
|
|
else
|
|
%{state | monsters: new_monsters, spawn_points: new_spawn_points}
|
|
end
|
|
|
|
{:noreply, new_state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:spawn_initial_monsters, state) do
|
|
Logger.debug("Spawning initial monsters on map #{state.map_id}")
|
|
|
|
# Spawn all monsters at their spawn points
|
|
new_state =
|
|
Enum.reduce(state.spawn_points, state, fn {spawn_id, spawn_point}, acc_state ->
|
|
{updated_state, _oid} = do_spawn_monster(acc_state, spawn_point, spawn_id)
|
|
updated_state
|
|
end)
|
|
|
|
{:noreply, new_state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:respawn_monster, spawn_id}, state) do
|
|
# Respawn monster at spawn point
|
|
case Map.get(state.spawn_points, spawn_id) do
|
|
nil ->
|
|
{:noreply, state}
|
|
|
|
spawn_point ->
|
|
if spawn_point.spawned_oid do
|
|
# Already spawned (shouldn't happen)
|
|
{:noreply, state}
|
|
else
|
|
{new_state, _oid} = do_spawn_monster(state, spawn_point, spawn_id)
|
|
{:noreply, new_state}
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:respawn_reactor, oid}, state) do
|
|
# Respawn a destroyed reactor
|
|
case Map.get(state.reactors, oid) do
|
|
nil ->
|
|
{:noreply, state}
|
|
|
|
original_reactor ->
|
|
if original_reactor.alive do
|
|
# Already alive (shouldn't happen)
|
|
{:noreply, state}
|
|
else
|
|
# Create a fresh copy
|
|
respawned =
|
|
original_reactor
|
|
|> Reactor.copy()
|
|
|> Reactor.set_oid(state.next_oid)
|
|
|
|
new_reactors =
|
|
state.reactors
|
|
|> Map.delete(oid) # Remove old destroyed reactor
|
|
|> Map.put(state.next_oid, respawned)
|
|
|
|
# Broadcast spawn to players
|
|
spawn_packet = ChannelPackets.spawn_reactor(respawned)
|
|
broadcast_to_players(state.players, spawn_packet)
|
|
|
|
Logger.debug("Reactor #{respawned.reactor_id} respawned on map #{state.map_id} with OID #{state.next_oid}")
|
|
|
|
{:noreply, %{state | reactors: new_reactors, next_oid: state.next_oid + 1}}
|
|
end
|
|
end
|
|
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_existing_monsters(client_pid, monsters) do
|
|
Enum.each(monsters, fn {_oid, monster} ->
|
|
spawn_packet = ChannelPackets.spawn_monster(monster, -1, 0)
|
|
send_packet(client_pid, spawn_packet)
|
|
end)
|
|
end
|
|
|
|
defp send_existing_reactors(client_pid, reactors) do
|
|
Enum.each(reactors, fn {_oid, reactor} ->
|
|
spawn_packet = ChannelPackets.spawn_reactor(reactor)
|
|
send_packet(client_pid, spawn_packet)
|
|
end)
|
|
end
|
|
|
|
defp send_packet(client_pid, packet) do
|
|
send(client_pid, {:send_packet, packet})
|
|
end
|
|
|
|
# ============================================================================
|
|
# Monster Spawning Helpers
|
|
# ============================================================================
|
|
|
|
defp load_spawn_points(template) do
|
|
# Load spawn points from template
|
|
template.spawn_points
|
|
|> Enum.with_index()
|
|
|> Enum.map(fn {sp, idx} ->
|
|
spawn_point = %SpawnPoint{
|
|
id: idx,
|
|
mob_id: sp.mob_id,
|
|
x: sp.x,
|
|
y: sp.y,
|
|
fh: sp.fh,
|
|
cy: sp.cy,
|
|
f: sp.f || 0,
|
|
mob_time: sp.mob_time || 10_000,
|
|
# Default 10 seconds
|
|
spawned_oid: nil,
|
|
last_spawn_time: nil,
|
|
respawn_timer_ref: nil
|
|
}
|
|
|
|
{idx, spawn_point}
|
|
end)
|
|
|> Map.new()
|
|
rescue
|
|
_e ->
|
|
Logger.warn("Failed to load spawn points for map, using empty spawn list")
|
|
%{}
|
|
end
|
|
|
|
defp load_reactors(template, starting_oid) do
|
|
# Load reactors from template reactor spawns
|
|
{reactor_map, next_oid} =
|
|
template.reactor_spawns
|
|
|> Enum.with_index(starting_oid)
|
|
|> Enum.reduce({%{}, starting_oid}, fn {rs, oid}, {acc_map, _acc_oid} ->
|
|
case ReactorFactory.create_reactor(
|
|
rs.reactor_id,
|
|
rs.x,
|
|
rs.y,
|
|
rs.facing_direction,
|
|
rs.name,
|
|
rs.delay
|
|
) do
|
|
nil ->
|
|
# Reactor stats not found, skip
|
|
{acc_map, oid}
|
|
|
|
reactor ->
|
|
# Assign OID to reactor
|
|
reactor = Reactor.set_oid(reactor, oid)
|
|
{Map.put(acc_map, oid, reactor), oid + 1}
|
|
end
|
|
end)
|
|
|
|
count = map_size(reactor_map)
|
|
if count > 0 do
|
|
Logger.debug("Loaded #{count} reactors on map #{template.map_id}")
|
|
end
|
|
|
|
{reactor_map, next_oid}
|
|
rescue
|
|
_e ->
|
|
Logger.warn("Failed to load reactors for map, using empty reactor list")
|
|
{%{}, starting_oid}
|
|
end
|
|
|
|
defp do_spawn_monster(state, spawn_point, spawn_id) do
|
|
# Get monster stats from LifeFactory
|
|
case LifeFactory.get_monster_stats(spawn_point.mob_id) do
|
|
nil ->
|
|
Logger.warn("Monster stats not found for mob_id #{spawn_point.mob_id}")
|
|
{state, nil}
|
|
|
|
stats ->
|
|
# Allocate OID
|
|
oid = state.next_oid
|
|
|
|
# Create monster instance
|
|
position = %{x: spawn_point.x, y: spawn_point.y, fh: spawn_point.fh}
|
|
|
|
monster = %Monster{
|
|
oid: oid,
|
|
mob_id: spawn_point.mob_id,
|
|
stats: stats,
|
|
hp: stats.hp,
|
|
mp: stats.mp,
|
|
max_hp: stats.hp,
|
|
max_mp: stats.mp,
|
|
position: position,
|
|
stance: 5,
|
|
# Default stance
|
|
controller_id: nil,
|
|
controller_has_aggro: false,
|
|
spawn_effect: 0,
|
|
team: -1,
|
|
fake: false,
|
|
link_oid: 0,
|
|
status_effects: %{},
|
|
poisons: [],
|
|
attackers: %{},
|
|
last_attack: System.system_time(:millisecond),
|
|
last_move: System.system_time(:millisecond),
|
|
last_skill_use: 0,
|
|
killed: false,
|
|
drops_disabled: false,
|
|
create_time: System.system_time(:millisecond)
|
|
}
|
|
|
|
# Add to monsters map
|
|
new_monsters = Map.put(state.monsters, oid, monster)
|
|
|
|
# Update spawn point
|
|
new_spawn_points =
|
|
update_spawn_point(state.spawn_points, spawn_id, fn sp ->
|
|
%{sp | spawned_oid: oid, last_spawn_time: DateTime.utc_now()}
|
|
end)
|
|
|
|
# Broadcast monster spawn packet to all players
|
|
spawn_packet = ChannelPackets.spawn_monster(monster, -1, 0)
|
|
broadcast_to_players(state.players, spawn_packet)
|
|
|
|
Logger.debug("Spawned monster #{monster.mob_id} (OID: #{oid}) on map #{state.map_id}")
|
|
|
|
new_state = %{
|
|
state
|
|
| monsters: new_monsters,
|
|
spawn_points: new_spawn_points,
|
|
next_oid: oid + 1
|
|
}
|
|
|
|
Logger.debug("Spawned monster #{spawn_point.mob_id} (OID: #{oid}) on map #{state.map_id}")
|
|
|
|
{new_state, oid}
|
|
end
|
|
end
|
|
|
|
defp find_spawn_point_by_oid(spawn_points, oid) do
|
|
Enum.find_value(spawn_points, fn {spawn_id, sp} ->
|
|
if sp.spawned_oid == oid, do: spawn_id, else: nil
|
|
end)
|
|
end
|
|
|
|
defp update_spawn_point(spawn_points, spawn_id, update_fn) do
|
|
case Map.get(spawn_points, spawn_id) do
|
|
nil -> spawn_points
|
|
sp -> Map.put(spawn_points, spawn_id, update_fn.(sp))
|
|
end
|
|
end
|
|
|
|
defp schedule_respawn(spawn_id, mob_time) do
|
|
# Schedule respawn message
|
|
Process.send_after(self(), {:respawn_monster, spawn_id}, mob_time)
|
|
end
|
|
|
|
defp schedule_reactor_respawn(oid, delay) do
|
|
# Schedule reactor respawn message
|
|
Process.send_after(self(), {:respawn_reactor, oid}, delay)
|
|
end
|
|
|
|
# ============================================================================
|
|
# EXP Distribution
|
|
# ============================================================================
|
|
|
|
defp distribute_exp(monster, players, _killer_id) do
|
|
# Calculate base EXP from monster
|
|
base_exp = calculate_monster_exp(monster)
|
|
|
|
if base_exp > 0 do
|
|
# Find highest damage dealer
|
|
{highest_attacker_id, _highest_damage} =
|
|
Enum.max_by(
|
|
monster.attackers,
|
|
fn {_id, entry} -> entry.damage end,
|
|
fn -> {nil, 0} end
|
|
)
|
|
|
|
# Distribute EXP to all attackers
|
|
Enum.each(monster.attackers, fn {attacker_id, attacker_data} ->
|
|
# Calculate EXP share based on damage dealt
|
|
damage_ratio = attacker_data.damage / max(1, monster.max_hp)
|
|
attacker_exp = trunc(base_exp * min(1.0, damage_ratio))
|
|
|
|
is_highest = attacker_id == highest_attacker_id
|
|
|
|
# Find character and give EXP
|
|
case find_character_pid(attacker_id) do
|
|
{:ok, character_pid} ->
|
|
give_exp_to_character(character_pid, attacker_exp, is_highest, monster)
|
|
|
|
{:error, _} ->
|
|
Logger.debug("Character #{attacker_id} not found for EXP distribution")
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
defp calculate_monster_exp(monster) do
|
|
# Base EXP from monster stats
|
|
base = monster.stats.exp
|
|
|
|
# Apply any multipliers
|
|
# TODO: Add event multipliers, premium account bonuses, etc.
|
|
base
|
|
end
|
|
|
|
defp give_exp_to_character(character_pid, exp_amount, is_highest, monster) do
|
|
# TODO: Apply EXP buffs (Holy Symbol, exp cards, etc.)
|
|
# TODO: Apply level difference penalties
|
|
# TODO: Apply server rates
|
|
|
|
final_exp = exp_amount
|
|
|
|
# Give EXP to character
|
|
case Character.gain_exp(character_pid, final_exp, is_highest) do
|
|
:ok ->
|
|
Logger.debug("Gave #{final_exp} EXP to character (highest: #{is_highest})")
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("Failed to give EXP to character: #{inspect(reason)}")
|
|
end
|
|
end
|
|
|
|
defp find_character_pid(character_id) do
|
|
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
|
[{pid, _}] -> {:ok, pid}
|
|
[] -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
# ============================================================================
|
|
# Drop System
|
|
# ============================================================================
|
|
|
|
defp create_monster_drops(monster, killer_id, state) do
|
|
# Get monster position
|
|
position = monster.position
|
|
|
|
# Calculate drop rate multiplier (from map/server rates)
|
|
drop_rate_multiplier = state.drop_rate
|
|
|
|
# Create drops
|
|
drops = DropSystem.create_monster_drops(
|
|
monster.mob_id,
|
|
killer_id,
|
|
position,
|
|
state.next_oid,
|
|
drop_rate_multiplier
|
|
)
|
|
|
|
# Also create global drops
|
|
global_drops = DropSystem.create_global_drops(
|
|
killer_id,
|
|
position,
|
|
state.next_oid + length(drops),
|
|
drop_rate_multiplier
|
|
)
|
|
|
|
all_drops = drops ++ global_drops
|
|
|
|
if length(all_drops) > 0 do
|
|
# Add drops to map state
|
|
new_items =
|
|
Enum.reduce(all_drops, state.items, fn drop, items ->
|
|
Map.put(items, drop.oid, drop)
|
|
end)
|
|
|
|
# Broadcast drop spawn packets
|
|
Enum.each(all_drops, fn drop ->
|
|
spawn_packet = ChannelPackets.spawn_drop(drop, position, 1)
|
|
broadcast_to_players(state.players, spawn_packet)
|
|
end)
|
|
|
|
# Update next OID
|
|
next_oid = state.next_oid + length(all_drops)
|
|
|
|
Logger.debug("Created #{length(all_drops)} drops on map #{state.map_id}")
|
|
|
|
%{state | items: new_items, next_oid: next_oid}
|
|
else
|
|
state
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets all drops on the map.
|
|
"""
|
|
def get_drops(map_id, channel_id) do
|
|
GenServer.call(via_tuple(map_id, channel_id), :get_drops)
|
|
end
|
|
|
|
@doc """
|
|
Attempts to pick up a drop.
|
|
Returns {:ok, drop} if successful, {:error, reason} if not.
|
|
"""
|
|
def pickup_drop(map_id, channel_id, drop_oid, character_id) do
|
|
GenServer.call(via_tuple(map_id, channel_id), {:pickup_drop, drop_oid, character_id})
|
|
end
|
|
|
|
@doc """
|
|
Checks if a drop is visible to a character (for quest items, individual rewards).
|
|
"""
|
|
def drop_visible_to?(map_id, channel_id, drop_oid, character_id, quest_status \\ %{}) do
|
|
GenServer.call(via_tuple(map_id, channel_id), {:drop_visible_to, drop_oid, character_id, quest_status})
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:get_drops, _from, state) do
|
|
{:reply, state.items, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:pickup_drop, drop_oid, character_id}, _from, state) do
|
|
case Map.get(state.items, drop_oid) do
|
|
nil ->
|
|
{:reply, {:error, :drop_not_found}, state}
|
|
|
|
drop ->
|
|
now = System.system_time(:millisecond)
|
|
|
|
# Validate ownership using Drop.can_loot?
|
|
if not Drop.can_loot?(drop, character_id, now) do
|
|
{:reply, {:error, :not_owner}, state}
|
|
else
|
|
case DropSystem.pickup_drop(drop, character_id, now) do
|
|
{:ok, updated_drop} ->
|
|
# Broadcast pickup animation to all players
|
|
remove_packet = ChannelPackets.remove_drop(drop_oid, 2, character_id)
|
|
broadcast_to_players(state.players, remove_packet)
|
|
|
|
# Remove from map
|
|
new_items = Map.delete(state.items, drop_oid)
|
|
|
|
# Return drop info for inventory addition
|
|
{:reply, {:ok, updated_drop}, %{state | items: new_items}}
|
|
|
|
{:error, reason} ->
|
|
{:reply, {:error, reason}, state}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:drop_visible_to, drop_oid, character_id, quest_status}, _from, state) do
|
|
case Map.get(state.items, drop_oid) do
|
|
nil ->
|
|
{:reply, false, state}
|
|
|
|
drop ->
|
|
visible = Drop.visible_to?(drop, character_id, quest_status)
|
|
{:reply, visible, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:check_drop_expiration, state) do
|
|
now = System.system_time(:millisecond)
|
|
|
|
# Check for expired drops
|
|
{expired_drops, valid_drops} =
|
|
Enum.split_with(state.items, fn {_oid, drop} ->
|
|
Drop.should_expire?(drop, now)
|
|
end)
|
|
|
|
# Broadcast expiration for expired drops
|
|
Enum.each(expired_drops, fn {oid, _drop} ->
|
|
expire_packet = ChannelPackets.remove_drop(oid, 0, 0)
|
|
broadcast_to_players(state.players, expire_packet)
|
|
end)
|
|
|
|
# Convert valid drops back to map
|
|
new_items = Map.new(valid_drops)
|
|
|
|
# Schedule next check if there are drops remaining
|
|
if map_size(new_items) > 0 do
|
|
Process.send_after(self(), :check_drop_expiration, 10_000)
|
|
end
|
|
|
|
{:noreply, %{state | items: new_items}}
|
|
end
|
|
|
|
defp send_existing_items(client_pid, items) do
|
|
Enum.each(items, fn {_oid, drop} ->
|
|
if not drop.picked_up do
|
|
packet = ChannelPackets.spawn_drop(drop, nil, 2)
|
|
send_packet(client_pid, packet)
|
|
end
|
|
end)
|
|
end
|
|
end
|