Start repo, claude & kimi still vibing tho
This commit is contained in:
434
lib/odinsea/game/character.ex
Normal file
434
lib/odinsea/game/character.ex
Normal file
@@ -0,0 +1,434 @@
|
||||
defmodule Odinsea.Game.Character do
|
||||
@moduledoc """
|
||||
Represents an in-game character (player) with stats, position, inventory, skills, etc.
|
||||
This is a GenServer that manages character state while the player is logged into a channel.
|
||||
|
||||
Unlike the database schema (Odinsea.Database.Schema.Character), this module represents
|
||||
the live, mutable game state including position, buffs, equipment effects, etc.
|
||||
"""
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Database.Schema.Character, as: CharacterDB
|
||||
alias Odinsea.Game.Map, as: GameMap
|
||||
alias Odinsea.Net.Packet.Out
|
||||
|
||||
# ============================================================================
|
||||
# Data Structures
|
||||
# ============================================================================
|
||||
|
||||
defmodule Stats do
|
||||
@moduledoc "Character stats (base + equipped + buffed)"
|
||||
defstruct [
|
||||
# Base stats
|
||||
:str,
|
||||
:dex,
|
||||
:int,
|
||||
:luk,
|
||||
:hp,
|
||||
:max_hp,
|
||||
:mp,
|
||||
:max_mp,
|
||||
# Computed stats (from equipment + buffs)
|
||||
:weapon_attack,
|
||||
:magic_attack,
|
||||
:weapon_defense,
|
||||
:magic_defense,
|
||||
:accuracy,
|
||||
:avoidability,
|
||||
:speed,
|
||||
:jump
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
str: non_neg_integer(),
|
||||
dex: non_neg_integer(),
|
||||
int: non_neg_integer(),
|
||||
luk: non_neg_integer(),
|
||||
hp: non_neg_integer(),
|
||||
max_hp: non_neg_integer(),
|
||||
mp: non_neg_integer(),
|
||||
max_mp: non_neg_integer(),
|
||||
weapon_attack: non_neg_integer(),
|
||||
magic_attack: non_neg_integer(),
|
||||
weapon_defense: non_neg_integer(),
|
||||
magic_defense: non_neg_integer(),
|
||||
accuracy: non_neg_integer(),
|
||||
avoidability: non_neg_integer(),
|
||||
speed: non_neg_integer(),
|
||||
jump: non_neg_integer()
|
||||
}
|
||||
end
|
||||
|
||||
defmodule Position do
|
||||
@moduledoc "Character position and stance"
|
||||
defstruct [:x, :y, :foothold, :stance]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
foothold: non_neg_integer(),
|
||||
stance: byte()
|
||||
}
|
||||
end
|
||||
|
||||
defmodule State do
|
||||
@moduledoc "In-game character state"
|
||||
defstruct [
|
||||
# Identity
|
||||
:character_id,
|
||||
:account_id,
|
||||
:name,
|
||||
:world_id,
|
||||
:channel_id,
|
||||
# Character data
|
||||
:level,
|
||||
:job,
|
||||
:exp,
|
||||
:meso,
|
||||
:fame,
|
||||
:gender,
|
||||
:skin_color,
|
||||
:hair,
|
||||
:face,
|
||||
# Stats
|
||||
:stats,
|
||||
# Position & Map
|
||||
:map_id,
|
||||
:position,
|
||||
:spawn_point,
|
||||
# AP/SP
|
||||
:remaining_ap,
|
||||
:remaining_sp,
|
||||
# Client connection
|
||||
:client_pid,
|
||||
# Inventory (TODO)
|
||||
:inventories,
|
||||
# Skills (TODO)
|
||||
:skills,
|
||||
# Buffs (TODO)
|
||||
:buffs,
|
||||
# Pets (TODO)
|
||||
:pets,
|
||||
# Timestamps
|
||||
:created_at,
|
||||
:updated_at
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
character_id: pos_integer(),
|
||||
account_id: pos_integer(),
|
||||
name: String.t(),
|
||||
world_id: byte(),
|
||||
channel_id: byte(),
|
||||
level: non_neg_integer(),
|
||||
job: non_neg_integer(),
|
||||
exp: non_neg_integer(),
|
||||
meso: non_neg_integer(),
|
||||
fame: integer(),
|
||||
gender: byte(),
|
||||
skin_color: byte(),
|
||||
hair: non_neg_integer(),
|
||||
face: non_neg_integer(),
|
||||
stats: Stats.t(),
|
||||
map_id: non_neg_integer(),
|
||||
position: Position.t(),
|
||||
spawn_point: byte(),
|
||||
remaining_ap: non_neg_integer(),
|
||||
remaining_sp: list(non_neg_integer()),
|
||||
client_pid: pid() | nil,
|
||||
inventories: map(),
|
||||
skills: map(),
|
||||
buffs: list(),
|
||||
pets: list(),
|
||||
created_at: DateTime.t(),
|
||||
updated_at: DateTime.t()
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts a character GenServer for an in-game player.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
character_id = Keyword.fetch!(opts, :character_id)
|
||||
GenServer.start_link(__MODULE__, opts, name: via_tuple(character_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads a character from the database and starts their GenServer.
|
||||
"""
|
||||
def load(character_id, account_id, world_id, channel_id, client_pid) do
|
||||
case Odinsea.Database.Context.get_character(character_id) do
|
||||
nil ->
|
||||
{:error, :character_not_found}
|
||||
|
||||
db_char ->
|
||||
# Verify ownership
|
||||
if db_char.account_id != account_id do
|
||||
{:error, :unauthorized}
|
||||
else
|
||||
state = from_database(db_char, world_id, channel_id, client_pid)
|
||||
|
||||
case start_link(character_id: character_id, state: state) do
|
||||
{:ok, pid} -> {:ok, pid, state}
|
||||
{:error, {:already_started, pid}} -> {:ok, pid, state}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current character state.
|
||||
"""
|
||||
def get_state(character_id) do
|
||||
GenServer.call(via_tuple(character_id), :get_state)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates character position (from movement packet).
|
||||
"""
|
||||
def update_position(character_id, position) do
|
||||
GenServer.cast(via_tuple(character_id), {:update_position, position})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changes the character's map.
|
||||
"""
|
||||
def change_map(character_id, new_map_id, spawn_point \\ 0) do
|
||||
GenServer.call(via_tuple(character_id), {:change_map, new_map_id, spawn_point})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the character's current map ID.
|
||||
"""
|
||||
def get_map_id(character_id) do
|
||||
GenServer.call(via_tuple(character_id), :get_map_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the character's client PID.
|
||||
"""
|
||||
def get_client_pid(character_id) do
|
||||
GenServer.call(via_tuple(character_id), :get_client_pid)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Saves character to database.
|
||||
"""
|
||||
def save(character_id) do
|
||||
GenServer.call(via_tuple(character_id), :save)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the character GenServer (logout).
|
||||
"""
|
||||
def logout(character_id) do
|
||||
GenServer.stop(via_tuple(character_id), :normal)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
state = Keyword.fetch!(opts, :state)
|
||||
Logger.debug("Character loaded: #{state.name} (ID: #{state.character_id})")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_state, _from, state) do
|
||||
{:reply, state, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_map_id, _from, state) do
|
||||
{:reply, state.map_id, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_client_pid, _from, state) do
|
||||
{:reply, state.client_pid, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:change_map, new_map_id, spawn_point}, _from, state) do
|
||||
old_map_id = state.map_id
|
||||
|
||||
# Remove from old map
|
||||
if old_map_id do
|
||||
GameMap.remove_player(old_map_id, state.character_id)
|
||||
end
|
||||
|
||||
# Update state
|
||||
new_state = %{
|
||||
state
|
||||
| map_id: new_map_id,
|
||||
spawn_point: spawn_point,
|
||||
updated_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
# Add to new map
|
||||
GameMap.add_player(new_map_id, state.character_id)
|
||||
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:save, _from, state) do
|
||||
result = save_to_database(state)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:update_position, position}, state) do
|
||||
new_state = %{
|
||||
state
|
||||
| position: position,
|
||||
updated_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(reason, state) do
|
||||
Logger.debug(
|
||||
"Character logout: #{state.name} (ID: #{state.character_id}), reason: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
# Remove from map
|
||||
if state.map_id do
|
||||
GameMap.remove_player(state.map_id, state.character_id)
|
||||
end
|
||||
|
||||
# Save to database
|
||||
save_to_database(state)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp via_tuple(character_id) do
|
||||
{:via, Registry, {Odinsea.CharacterRegistry, character_id}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts database character to in-game state.
|
||||
"""
|
||||
def from_database(%CharacterDB{} = db_char, world_id, channel_id, client_pid) do
|
||||
stats = %Stats{
|
||||
str: db_char.str,
|
||||
dex: db_char.dex,
|
||||
int: db_char.int,
|
||||
luk: db_char.luk,
|
||||
hp: db_char.hp,
|
||||
max_hp: db_char.max_hp,
|
||||
mp: db_char.mp,
|
||||
max_mp: db_char.max_mp,
|
||||
# Computed stats - TODO: calculate from equipment
|
||||
weapon_attack: 0,
|
||||
magic_attack: 0,
|
||||
weapon_defense: 0,
|
||||
magic_defense: 0,
|
||||
accuracy: 0,
|
||||
avoidability: 0,
|
||||
speed: 100,
|
||||
jump: 100
|
||||
}
|
||||
|
||||
position = %Position{
|
||||
x: 0,
|
||||
y: 0,
|
||||
foothold: 0,
|
||||
stance: 0
|
||||
}
|
||||
|
||||
# Parse remaining_sp (stored as comma-separated string in Java version)
|
||||
remaining_sp =
|
||||
case db_char.remaining_sp do
|
||||
nil -> [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
sp_str when is_binary(sp_str) -> parse_sp_string(sp_str)
|
||||
sp_list when is_list(sp_list) -> sp_list
|
||||
end
|
||||
|
||||
%State{
|
||||
character_id: db_char.id,
|
||||
account_id: db_char.account_id,
|
||||
name: db_char.name,
|
||||
world_id: world_id,
|
||||
channel_id: channel_id,
|
||||
level: db_char.level,
|
||||
job: db_char.job,
|
||||
exp: db_char.exp,
|
||||
meso: db_char.meso,
|
||||
fame: db_char.fame,
|
||||
gender: db_char.gender,
|
||||
skin_color: db_char.skin_color,
|
||||
hair: db_char.hair,
|
||||
face: db_char.face,
|
||||
stats: stats,
|
||||
map_id: db_char.map_id,
|
||||
position: position,
|
||||
spawn_point: db_char.spawn_point,
|
||||
remaining_ap: db_char.remaining_ap,
|
||||
remaining_sp: remaining_sp,
|
||||
client_pid: client_pid,
|
||||
inventories: %{},
|
||||
skills: %{},
|
||||
buffs: [],
|
||||
pets: [],
|
||||
created_at: db_char.inserted_at,
|
||||
updated_at: DateTime.utc_now()
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_sp_string(sp_str) do
|
||||
sp_str
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.to_integer/1)
|
||||
|> Kernel.++(List.duplicate(0, 10))
|
||||
|> Enum.take(10)
|
||||
rescue
|
||||
_ -> [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Saves character state to database.
|
||||
"""
|
||||
def save_to_database(%State{} = state) do
|
||||
# Convert remaining_sp list to comma-separated string for database
|
||||
sp_string = Enum.join(state.remaining_sp, ",")
|
||||
|
||||
attrs = %{
|
||||
level: state.level,
|
||||
job: state.job,
|
||||
exp: state.exp,
|
||||
str: state.stats.str,
|
||||
dex: state.stats.dex,
|
||||
int: state.stats.int,
|
||||
luk: state.stats.luk,
|
||||
hp: state.stats.hp,
|
||||
max_hp: state.stats.max_hp,
|
||||
mp: state.stats.mp,
|
||||
max_mp: state.stats.max_mp,
|
||||
meso: state.meso,
|
||||
fame: state.fame,
|
||||
map_id: state.map_id,
|
||||
spawn_point: state.spawn_point,
|
||||
remaining_ap: state.remaining_ap,
|
||||
remaining_sp: sp_string
|
||||
}
|
||||
|
||||
Odinsea.Database.Context.update_character(state.character_id, attrs)
|
||||
end
|
||||
end
|
||||
300
lib/odinsea/game/map.ex
Normal file
300
lib/odinsea/game/map.ex
Normal file
@@ -0,0 +1,300 @@
|
||||
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
|
||||
146
lib/odinsea/game/movement.ex
Normal file
146
lib/odinsea/game/movement.ex
Normal file
@@ -0,0 +1,146 @@
|
||||
defmodule Odinsea.Game.Movement do
|
||||
@moduledoc """
|
||||
Movement parsing and validation for players, mobs, pets, summons, and dragons.
|
||||
Ported from Java MovementParse.java.
|
||||
|
||||
Movement types (kind):
|
||||
- 1: Player
|
||||
- 2: Mob
|
||||
- 3: Pet
|
||||
- 4: Summon
|
||||
- 5: Dragon
|
||||
|
||||
This is a SIMPLIFIED implementation for now. The full Java version has complex
|
||||
parsing for different movement command types. We'll expand this as needed.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.Character.Position
|
||||
|
||||
@doc """
|
||||
Parses movement data from a packet.
|
||||
Returns {:ok, movements} or {:error, reason}.
|
||||
|
||||
For now, this returns a simplified structure. The full implementation
|
||||
would parse all movement fragment types.
|
||||
"""
|
||||
def parse_movement(packet, _kind) do
|
||||
num_commands = In.decode_byte(packet)
|
||||
|
||||
# For now, just skip through the movement data and extract final position
|
||||
# TODO: Implement full movement parsing with all command types
|
||||
case extract_final_position(packet, num_commands) do
|
||||
{:ok, position} ->
|
||||
{:ok, %{num_commands: num_commands, final_position: position}}
|
||||
|
||||
:error ->
|
||||
{:error, :invalid_movement}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an entity's position from movement data.
|
||||
"""
|
||||
def update_position(_movements, character_id) do
|
||||
# TODO: Implement position update logic
|
||||
# For now, just return ok
|
||||
Logger.debug("Update position for character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
# Extract the final position from movement data
|
||||
# This is a TEMPORARY simplification - we just read through the movement
|
||||
# commands and try to extract the last absolute position
|
||||
defp extract_final_position(packet, num_commands) do
|
||||
try do
|
||||
final_pos = parse_commands(packet, num_commands, nil)
|
||||
{:ok, final_pos || %{x: 0, y: 0, stance: 0, foothold: 0}}
|
||||
rescue
|
||||
_ ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_commands(_packet, 0, last_position) do
|
||||
last_position
|
||||
end
|
||||
|
||||
defp parse_commands(packet, remaining, last_position) do
|
||||
command = In.decode_byte(packet)
|
||||
|
||||
new_position =
|
||||
case command do
|
||||
# Absolute movement commands - extract position
|
||||
cmd when cmd in [0, 37, 38, 39, 40, 41, 42] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
_xoffset = In.decode_short(packet)
|
||||
_yoffset = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Relative movement - skip for now
|
||||
cmd when cmd in [1, 2, 33, 34, 36] ->
|
||||
_xmod = In.decode_short(packet)
|
||||
_ymod = In.decode_short(packet)
|
||||
_stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
last_position
|
||||
|
||||
# Teleport movement
|
||||
cmd when cmd in [3, 4, 8, 100, 101] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Chair movement
|
||||
cmd when cmd in [9, 12] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Aran combat step
|
||||
cmd when cmd in [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 35] ->
|
||||
_stance = In.decode_byte(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
last_position
|
||||
|
||||
# Jump down
|
||||
cmd when cmd in [13, 14] ->
|
||||
# Simplified - just skip the data
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
_fh = In.decode_short(packet)
|
||||
_xoffset = In.decode_short(packet)
|
||||
_yoffset = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Unknown/unhandled - log and skip
|
||||
_ ->
|
||||
Logger.warning("Unhandled movement command: #{command}")
|
||||
last_position
|
||||
end
|
||||
|
||||
parse_commands(packet, remaining - 1, new_position || last_position)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user