474 lines
12 KiB
Elixir
474 lines
12 KiB
Elixir
defmodule Odinsea.Game.MapFactory do
|
|
@moduledoc """
|
|
Map Factory - loads and caches map templates and data.
|
|
|
|
This module loads map metadata (portals, footholds, spawns, properties) from cached JSON files.
|
|
The JSON files should be exported from the Java server's WZ data providers.
|
|
|
|
Map templates are cached in ETS for fast lookups.
|
|
"""
|
|
|
|
use GenServer
|
|
require Logger
|
|
|
|
# ETS table names
|
|
@map_templates :odinsea_map_templates
|
|
@portal_cache :odinsea_portal_cache
|
|
@foothold_cache :odinsea_foothold_cache
|
|
|
|
# Data file paths
|
|
@map_data_file "data/maps.json"
|
|
@portal_data_file "data/portals.json"
|
|
@foothold_data_file "data/footholds.json"
|
|
|
|
defmodule Portal do
|
|
@moduledoc "Represents a portal on a map"
|
|
|
|
@type portal_type ::
|
|
:spawn
|
|
| :invisible
|
|
| :visible
|
|
| :collision
|
|
| :changeable
|
|
| :changeable_invisible
|
|
| :town_portal_point
|
|
| :script
|
|
| :sp
|
|
| :pi
|
|
| :pv
|
|
| :tp
|
|
| :ps
|
|
| :psi
|
|
| :hidden
|
|
|
|
@type t :: %__MODULE__{
|
|
id: integer(),
|
|
name: String.t(),
|
|
type: portal_type(),
|
|
x: integer(),
|
|
y: integer(),
|
|
target_map: integer(),
|
|
target_portal: String.t(),
|
|
script: String.t() | nil
|
|
}
|
|
|
|
defstruct [
|
|
:id,
|
|
:name,
|
|
:type,
|
|
:x,
|
|
:y,
|
|
:target_map,
|
|
:target_portal,
|
|
:script
|
|
]
|
|
|
|
@doc "Converts portal type integer to atom"
|
|
def type_from_int(type_int) do
|
|
case type_int do
|
|
0 -> :spawn
|
|
1 -> :invisible
|
|
2 -> :visible
|
|
3 -> :collision
|
|
4 -> :changeable
|
|
5 -> :changeable_invisible
|
|
6 -> :town_portal_point
|
|
7 -> :script
|
|
8 -> :sp
|
|
9 -> :pi
|
|
10 -> :pv
|
|
11 -> :tp
|
|
12 -> :ps
|
|
13 -> :psi
|
|
14 -> :hidden
|
|
_ -> :invisible
|
|
end
|
|
end
|
|
|
|
@doc "Converts portal type string to atom"
|
|
def type_from_string(type_str) do
|
|
case type_str do
|
|
"sp" -> :spawn
|
|
"pi" -> :invisible
|
|
"pv" -> :visible
|
|
"pc" -> :collision
|
|
"pg" -> :changeable
|
|
"pgi" -> :changeable_invisible
|
|
"tp" -> :town_portal_point
|
|
"ps" -> :script
|
|
"psi" -> :script
|
|
"hidden" -> :hidden
|
|
_ -> :invisible
|
|
end
|
|
end
|
|
end
|
|
|
|
defmodule Foothold do
|
|
@moduledoc "Represents a foothold (platform) on a map"
|
|
|
|
@type t :: %__MODULE__{
|
|
id: integer(),
|
|
x1: integer(),
|
|
y1: integer(),
|
|
x2: integer(),
|
|
y2: integer(),
|
|
prev: integer(),
|
|
next: integer()
|
|
}
|
|
|
|
defstruct [
|
|
:id,
|
|
:x1,
|
|
:y1,
|
|
:x2,
|
|
:y2,
|
|
:prev,
|
|
:next
|
|
]
|
|
end
|
|
|
|
defmodule FieldTemplate do
|
|
@moduledoc "Map field template containing all map data"
|
|
|
|
@type t :: %__MODULE__{
|
|
map_id: integer(),
|
|
map_name: String.t(),
|
|
street_name: String.t(),
|
|
return_map: integer(),
|
|
forced_return: integer(),
|
|
mob_rate: float(),
|
|
field_limit: integer(),
|
|
time_limit: integer(),
|
|
dec_hp: integer(),
|
|
dec_hp_interval: integer(),
|
|
portal_map: %{String.t() => Portal.t()},
|
|
portals: [Portal.t()],
|
|
spawn_points: [Portal.t()],
|
|
footholds: [Foothold.t()],
|
|
top: integer(),
|
|
bottom: integer(),
|
|
left: integer(),
|
|
right: integer(),
|
|
bgm: String.t(),
|
|
first_user_enter: String.t(),
|
|
user_enter: String.t(),
|
|
clock: boolean(),
|
|
everlast: boolean(),
|
|
town: boolean(),
|
|
mount_allowed: boolean(),
|
|
recovery_rate: float(),
|
|
create_mob_interval: integer(),
|
|
fixed_mob_capacity: integer()
|
|
}
|
|
|
|
defstruct [
|
|
:map_id,
|
|
:map_name,
|
|
:street_name,
|
|
:return_map,
|
|
:forced_return,
|
|
:mob_rate,
|
|
:field_limit,
|
|
:time_limit,
|
|
:dec_hp,
|
|
:dec_hp_interval,
|
|
:portal_map,
|
|
:portals,
|
|
:spawn_points,
|
|
:footholds,
|
|
:top,
|
|
:bottom,
|
|
:left,
|
|
:right,
|
|
:bgm,
|
|
:first_user_enter,
|
|
:user_enter,
|
|
:clock,
|
|
:everlast,
|
|
:town,
|
|
:mount_allowed,
|
|
:recovery_rate,
|
|
:create_mob_interval,
|
|
:fixed_mob_capacity
|
|
]
|
|
end
|
|
|
|
## Public API
|
|
|
|
@doc "Starts the MapFactory GenServer"
|
|
def start_link(opts \\ []) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
@doc "Gets map template by map ID"
|
|
@spec get_map_template(integer()) :: FieldTemplate.t() | nil
|
|
def get_map_template(map_id) do
|
|
case :ets.lookup(@map_templates, map_id) do
|
|
[{^map_id, template}] -> template
|
|
[] -> nil
|
|
end
|
|
end
|
|
|
|
@doc "Gets portal by name on a map"
|
|
@spec get_portal(integer(), String.t()) :: Portal.t() | nil
|
|
def get_portal(map_id, portal_name) do
|
|
case get_map_template(map_id) do
|
|
nil -> nil
|
|
template -> Map.get(template.portal_map, portal_name)
|
|
end
|
|
end
|
|
|
|
@doc "Gets random spawn portal on a map"
|
|
@spec get_random_spawn_portal(integer()) :: Portal.t() | nil
|
|
def get_random_spawn_portal(map_id) do
|
|
case get_map_template(map_id) do
|
|
nil ->
|
|
nil
|
|
|
|
template ->
|
|
spawn_points = template.spawn_points
|
|
|
|
if Enum.empty?(spawn_points) do
|
|
nil
|
|
else
|
|
Enum.random(spawn_points)
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc "Gets map name"
|
|
@spec get_map_name(integer()) :: String.t()
|
|
def get_map_name(map_id) do
|
|
case get_map_template(map_id) do
|
|
nil -> "UNKNOWN"
|
|
template -> template.map_name || "UNKNOWN"
|
|
end
|
|
end
|
|
|
|
@doc "Gets return map ID"
|
|
@spec get_return_map(integer()) :: integer()
|
|
def get_return_map(map_id) do
|
|
case get_map_template(map_id) do
|
|
nil -> 999_999_999
|
|
template -> template.return_map || 999_999_999
|
|
end
|
|
end
|
|
|
|
@doc "Checks if map exists"
|
|
@spec map_exists?(integer()) :: boolean()
|
|
def map_exists?(map_id) do
|
|
:ets.member(@map_templates, map_id)
|
|
end
|
|
|
|
@doc "Gets all loaded map IDs"
|
|
@spec get_all_map_ids() :: [integer()]
|
|
def get_all_map_ids do
|
|
:ets.select(@map_templates, [{{:"$1", :_}, [], [:"$1"]}])
|
|
end
|
|
|
|
@doc "Reloads map data from files"
|
|
def reload do
|
|
GenServer.call(__MODULE__, :reload, :infinity)
|
|
end
|
|
|
|
## GenServer Callbacks
|
|
|
|
@impl true
|
|
def init(_opts) do
|
|
# Create ETS tables
|
|
:ets.new(@map_templates, [:set, :public, :named_table, read_concurrency: true])
|
|
:ets.new(@portal_cache, [:set, :public, :named_table, read_concurrency: true])
|
|
:ets.new(@foothold_cache, [:set, :public, :named_table, read_concurrency: true])
|
|
|
|
# Load data
|
|
load_map_data()
|
|
|
|
{:ok, %{}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:reload, _from, state) do
|
|
Logger.info("Reloading map data...")
|
|
load_map_data()
|
|
{:reply, :ok, state}
|
|
end
|
|
|
|
## Private Functions
|
|
|
|
defp load_map_data do
|
|
priv_dir = :code.priv_dir(:odinsea) |> to_string()
|
|
|
|
# Load maps
|
|
load_maps(Path.join(priv_dir, @map_data_file))
|
|
|
|
map_count = :ets.info(@map_templates, :size)
|
|
Logger.info("Loaded #{map_count} map templates")
|
|
end
|
|
|
|
defp load_maps(file_path) do
|
|
case File.read(file_path) do
|
|
{:ok, content} ->
|
|
case Jason.decode(content, keys: :atoms) do
|
|
{:ok, maps} when is_list(maps) ->
|
|
Enum.each(maps, fn map_data ->
|
|
template = build_field_template(map_data)
|
|
:ets.insert(@map_templates, {template.map_id, template})
|
|
end)
|
|
|
|
{:error, reason} ->
|
|
Logger.warn("Failed to parse maps JSON: #{inspect(reason)}")
|
|
create_fallback_maps()
|
|
end
|
|
|
|
{:error, :enoent} ->
|
|
Logger.warn("Maps file not found: #{file_path}, using fallback data")
|
|
create_fallback_maps()
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Failed to read maps: #{inspect(reason)}")
|
|
create_fallback_maps()
|
|
end
|
|
end
|
|
|
|
defp build_field_template(map_data) do
|
|
# Parse portals
|
|
portals =
|
|
(map_data[:portals] || [])
|
|
|> Enum.map(&build_portal/1)
|
|
|
|
portal_map =
|
|
portals
|
|
|> Enum.map(fn portal -> {portal.name, portal} end)
|
|
|> Enum.into(%{})
|
|
|
|
spawn_points =
|
|
Enum.filter(portals, fn portal ->
|
|
portal.type == :spawn || portal.name == "sp"
|
|
end)
|
|
|
|
# Parse footholds
|
|
footholds =
|
|
(map_data[:footholds] || [])
|
|
|> Enum.map(&build_foothold/1)
|
|
|
|
%FieldTemplate{
|
|
map_id: map_data[:map_id],
|
|
map_name: map_data[:map_name] || "",
|
|
street_name: map_data[:street_name] || "",
|
|
return_map: map_data[:return_map] || 999_999_999,
|
|
forced_return: map_data[:forced_return] || 999_999_999,
|
|
mob_rate: map_data[:mob_rate] || 1.0,
|
|
field_limit: map_data[:field_limit] || 0,
|
|
time_limit: map_data[:time_limit] || -1,
|
|
dec_hp: map_data[:dec_hp] || 0,
|
|
dec_hp_interval: map_data[:dec_hp_interval] || 10000,
|
|
portal_map: portal_map,
|
|
portals: portals,
|
|
spawn_points: spawn_points,
|
|
footholds: footholds,
|
|
top: map_data[:top] || 0,
|
|
bottom: map_data[:bottom] || 0,
|
|
left: map_data[:left] || 0,
|
|
right: map_data[:right] || 0,
|
|
bgm: map_data[:bgm] || "",
|
|
first_user_enter: map_data[:first_user_enter] || "",
|
|
user_enter: map_data[:user_enter] || "",
|
|
clock: map_data[:clock] || false,
|
|
everlast: map_data[:everlast] || false,
|
|
town: map_data[:town] || false,
|
|
mount_allowed: map_data[:mount_allowed] || true,
|
|
recovery_rate: map_data[:recovery_rate] || 1.0,
|
|
create_mob_interval: map_data[:create_mob_interval] || 4000,
|
|
fixed_mob_capacity: map_data[:fixed_mob_capacity] || 0
|
|
}
|
|
end
|
|
|
|
defp build_portal(portal_data) do
|
|
type =
|
|
cond do
|
|
is_integer(portal_data[:type]) -> Portal.type_from_int(portal_data[:type])
|
|
is_binary(portal_data[:type]) -> Portal.type_from_string(portal_data[:type])
|
|
true -> :invisible
|
|
end
|
|
|
|
%Portal{
|
|
id: portal_data[:id] || 0,
|
|
name: portal_data[:name] || "sp",
|
|
type: type,
|
|
x: portal_data[:x] || 0,
|
|
y: portal_data[:y] || 0,
|
|
target_map: portal_data[:target_map] || 999_999_999,
|
|
target_portal: portal_data[:target_portal] || "",
|
|
script: portal_data[:script]
|
|
}
|
|
end
|
|
|
|
defp build_foothold(foothold_data) do
|
|
%Foothold{
|
|
id: foothold_data[:id] || 0,
|
|
x1: foothold_data[:x1] || 0,
|
|
y1: foothold_data[:y1] || 0,
|
|
x2: foothold_data[:x2] || 0,
|
|
y2: foothold_data[:y2] || 0,
|
|
prev: foothold_data[:prev] || 0,
|
|
next: foothold_data[:next] || 0
|
|
}
|
|
end
|
|
|
|
# Fallback data for basic testing
|
|
defp create_fallback_maps do
|
|
# Common beginner maps
|
|
fallback_maps = [
|
|
# Maple Island - Southperry
|
|
%{
|
|
map_id: 60000,
|
|
map_name: "Southperry",
|
|
street_name: "Maple Island",
|
|
return_map: 60000,
|
|
forced_return: 60000,
|
|
portals: [
|
|
%{id: 0, name: "sp", type: "sp", x: 0, y: 0, target_map: 60000, target_portal: ""}
|
|
]
|
|
},
|
|
# Victoria Island - Henesys
|
|
%{
|
|
map_id: 100000000,
|
|
map_name: "Henesys",
|
|
street_name: "Victoria Island",
|
|
return_map: 100000000,
|
|
forced_return: 100000000,
|
|
portals: [
|
|
%{id: 0, name: "sp", type: "sp", x: -1283, y: 86, target_map: 100000000, target_portal: ""}
|
|
]
|
|
},
|
|
# Henesys Hunting Ground I
|
|
%{
|
|
map_id: 100010000,
|
|
map_name: "Henesys Hunting Ground I",
|
|
street_name: "Victoria Island",
|
|
return_map: 100000000,
|
|
forced_return: 100000000,
|
|
portals: [
|
|
%{id: 0, name: "sp", type: "sp", x: 0, y: 0, target_map: 100010000, target_portal: ""}
|
|
]
|
|
},
|
|
# Hidden Street - FM Entrance
|
|
%{
|
|
map_id: 910000000,
|
|
map_name: "Free Market Entrance",
|
|
street_name: "Free Market",
|
|
return_map: 100000000,
|
|
forced_return: 100000000,
|
|
portals: [
|
|
%{id: 0, name: "sp", type: "sp", x: 0, y: 0, target_map: 910000000, target_portal: ""}
|
|
]
|
|
}
|
|
]
|
|
|
|
Enum.each(fallback_maps, fn map_data ->
|
|
template = build_field_template(map_data)
|
|
:ets.insert(@map_templates, {template.map_id, template})
|
|
end)
|
|
end
|
|
end
|