Files
odinsea-elixir/lib/odinsea/game/map_factory.ex
2026-02-14 23:12:33 -07:00

560 lines
14 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 SpawnPoint do
@moduledoc "Represents a monster spawn point on a map"
@type t :: %__MODULE__{
mob_id: integer(),
x: integer(),
y: integer(),
fh: integer(),
cy: integer(),
f: integer(),
mob_time: integer()
}
defstruct [
:mob_id,
:x,
:y,
:fh,
:cy,
:f,
:mob_time
]
end
defmodule ReactorSpawn do
@moduledoc "Represents a reactor spawn point on a map"
@type t :: %__MODULE__{
reactor_id: integer(),
x: integer(),
y: integer(),
facing_direction: integer(),
name: String.t(),
delay: integer()
}
defstruct [
:reactor_id,
:x,
:y,
facing_direction: 0,
name: "",
delay: 0
]
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: [SpawnPoint.t()],
reactor_spawns: [ReactorSpawn.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,
:reactor_spawns,
: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(%{})
# Parse spawn points
spawn_points =
(map_data[:spawns] || [])
|> Enum.map(&build_spawn_point/1)
# Parse reactor spawns
reactor_spawns =
(map_data[:reactors] || [])
|> Enum.map(&build_reactor_spawn/1)
# 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,
reactor_spawns: reactor_spawns,
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
defp build_spawn_point(spawn_data) do
%SpawnPoint{
mob_id: spawn_data[:mob_id] || 0,
x: spawn_data[:x] || 0,
y: spawn_data[:y] || 0,
fh: spawn_data[:fh] || 0,
cy: spawn_data[:cy] || 0,
f: spawn_data[:f] || 0,
mob_time: spawn_data[:mob_time] || 10_000
}
end
defp build_reactor_spawn(reactor_data) do
%ReactorSpawn{
reactor_id: reactor_data[:reactor_id] || reactor_data[:id] || 0,
x: reactor_data[:x] || 0,
y: reactor_data[:y] || 0,
facing_direction: reactor_data[:f] || reactor_data[:facing_direction] || 0,
name: reactor_data[:name] || "",
delay: reactor_data[:reactor_time] || reactor_data[:delay] || 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 - with monsters!
%{
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: ""}
],
spawns: [
# Blue Snails (mob_id: 100001)
%{mob_id: 100001, x: -500, y: 100, fh: 0, cy: 0, f: 0, mob_time: 8000},
%{mob_id: 100001, x: -200, y: 100, fh: 0, cy: 0, f: 1, mob_time: 8000},
%{mob_id: 100001, x: 200, y: 100, fh: 0, cy: 0, f: 0, mob_time: 8000},
# Orange Mushrooms (mob_id: 1210102)
%{mob_id: 1210102, x: 500, y: 100, fh: 0, cy: 0, f: 1, mob_time: 10000},
%{mob_id: 1210102, x: 800, y: 100, fh: 0, cy: 0, f: 0, mob_time: 10000}
]
},
# 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