kimi gone wild
This commit is contained in:
@@ -16,12 +16,55 @@ defmodule Odinsea.Game.Map do
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.Game.{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 [
|
||||
@@ -32,22 +75,26 @@ defmodule Odinsea.Game.Map do
|
||||
:players,
|
||||
# Map stores character_id => %{oid: integer(), character: Character.State}
|
||||
:monsters,
|
||||
# Map stores oid => Monster
|
||||
# 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 (TODO: load from WZ data)
|
||||
# Map properties (loaded from MapFactory)
|
||||
:return_map,
|
||||
:forced_return,
|
||||
:time_limit,
|
||||
:field_limit,
|
||||
:mob_rate,
|
||||
:drop_rate,
|
||||
:map_name,
|
||||
:street_name,
|
||||
# Timestamps
|
||||
:created_at
|
||||
]
|
||||
@@ -56,10 +103,11 @@ defmodule Odinsea.Game.Map do
|
||||
map_id: non_neg_integer(),
|
||||
channel_id: byte(),
|
||||
players: %{pos_integer() => map()},
|
||||
monsters: %{pos_integer() => any()},
|
||||
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,
|
||||
@@ -67,6 +115,8 @@ defmodule Odinsea.Game.Map do
|
||||
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
|
||||
@@ -152,6 +202,69 @@ defmodule Odinsea.Game.Map 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
|
||||
# ============================================================================
|
||||
@@ -161,6 +274,26 @@ defmodule Odinsea.Game.Map 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,
|
||||
@@ -168,18 +301,27 @@ defmodule Odinsea.Game.Map do
|
||||
monsters: %{},
|
||||
npcs: %{},
|
||||
items: %{},
|
||||
reactors: %{},
|
||||
next_oid: 500_000,
|
||||
return_map: nil,
|
||||
forced_return: nil,
|
||||
time_limit: nil,
|
||||
field_limit: 0,
|
||||
mob_rate: 1.0,
|
||||
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})")
|
||||
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
|
||||
|
||||
@@ -208,6 +350,8 @@ defmodule Odinsea.Game.Map do
|
||||
|
||||
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 = %{
|
||||
@@ -247,6 +391,79 @@ defmodule Odinsea.Game.Map 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)
|
||||
@@ -259,6 +476,254 @@ defmodule Odinsea.Game.Map do
|
||||
{: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
|
||||
# ============================================================================
|
||||
@@ -294,7 +759,390 @@ defmodule Odinsea.Game.Map do
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
@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)
|
||||
|
||||
case DropSystem.pickup_drop(drop, character_id, now) do
|
||||
{:ok, updated_drop} ->
|
||||
# Broadcast pickup animation
|
||||
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
|
||||
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user