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

435 lines
11 KiB
Elixir

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