This commit is contained in:
@@ -14,6 +14,7 @@ defmodule CobblemonUi.Application do
|
|||||||
CobblemonUi.CobblemonFS,
|
CobblemonUi.CobblemonFS,
|
||||||
CobblemonUi.TierListScraper,
|
CobblemonUi.TierListScraper,
|
||||||
CobblemonUi.EvolutionApi,
|
CobblemonUi.EvolutionApi,
|
||||||
|
CobblemonUi.PokeApi,
|
||||||
# Start to serve requests, typically the last entry
|
# Start to serve requests, typically the last entry
|
||||||
CobblemonUiWeb.Endpoint
|
CobblemonUiWeb.Endpoint
|
||||||
]
|
]
|
||||||
|
|||||||
89
lib/cobblemon_ui/poke_api.ex
Normal file
89
lib/cobblemon_ui/poke_api.ex
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
defmodule CobblemonUi.PokeApi do
|
||||||
|
@moduledoc """
|
||||||
|
Fetches and caches Pokémon type data from PokeAPI.
|
||||||
|
|
||||||
|
Uses an ETS table for fast lookups. Data is fetched once per species
|
||||||
|
and cached for the lifetime of the process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@table :poke_api_types_cache
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Client API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start_link(_opts) do
|
||||||
|
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a list of type names (lowercase strings) for the given species.
|
||||||
|
|
||||||
|
Example: `get_types("bulbasaur")` → `["grass", "poison"]`
|
||||||
|
"""
|
||||||
|
@spec get_types(String.t()) :: [String.t()]
|
||||||
|
def get_types(species) when is_binary(species) do
|
||||||
|
key = String.downcase(species)
|
||||||
|
|
||||||
|
case :ets.lookup(@table, key) do
|
||||||
|
[{^key, types}] ->
|
||||||
|
types
|
||||||
|
|
||||||
|
[] ->
|
||||||
|
GenServer.call(__MODULE__, {:fetch_types, key}, 15_000)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server callbacks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_opts) do
|
||||||
|
:ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
|
||||||
|
{:ok, %{}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:fetch_types, species}, _from, state) do
|
||||||
|
result =
|
||||||
|
case :ets.lookup(@table, species) do
|
||||||
|
[{^species, types}] ->
|
||||||
|
types
|
||||||
|
|
||||||
|
[] ->
|
||||||
|
types = fetch_types(species)
|
||||||
|
:ets.insert(@table, {species, types})
|
||||||
|
types
|
||||||
|
end
|
||||||
|
|
||||||
|
{:reply, result, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Private helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp fetch_types(species) do
|
||||||
|
url = "https://pokeapi.co/api/v2/pokemon/#{species}"
|
||||||
|
|
||||||
|
case Req.get(url) do
|
||||||
|
{:ok, %Req.Response{status: 200, body: %{"types" => types}}} ->
|
||||||
|
types
|
||||||
|
|> Enum.sort_by(fn t -> t["slot"] end)
|
||||||
|
|> Enum.map(fn t -> get_in(t, ["type", "name"]) end)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
|
{:ok, %Req.Response{status: status}} ->
|
||||||
|
Logger.warning("[PokeApi] Type lookup failed for #{species}: HTTP #{status}")
|
||||||
|
[]
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("[PokeApi] Type lookup failed for #{species}: #{inspect(reason)}")
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
272
lib/cobblemon_ui/type_chart.ex
Normal file
272
lib/cobblemon_ui/type_chart.ex
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
defmodule CobblemonUi.TypeChart do
|
||||||
|
@moduledoc """
|
||||||
|
Pokemon type effectiveness data. Contains strengths (super effective),
|
||||||
|
weaknesses (weak to), resistances (not very effective against), and immunities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@types ~w(normal fire water grass electric ice fighting poison ground flying psychic bug rock ghost dragon dark steel fairy)
|
||||||
|
|
||||||
|
def types, do: @types
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a map of type -> %{weak_to: [...], resists: [...], immune_to: [...]}
|
||||||
|
representing the defensive matchups for each type.
|
||||||
|
"""
|
||||||
|
def defensive_chart do
|
||||||
|
%{
|
||||||
|
"normal" => %{weak_to: ["fighting"], resists: [], immune_to: ["ghost"]},
|
||||||
|
"fire" => %{
|
||||||
|
weak_to: ["water", "ground", "rock"],
|
||||||
|
resists: ["fire", "grass", "ice", "bug", "steel", "fairy"],
|
||||||
|
immune_to: []
|
||||||
|
},
|
||||||
|
"water" => %{
|
||||||
|
weak_to: ["electric", "grass"],
|
||||||
|
resists: ["fire", "water", "ice", "steel"],
|
||||||
|
immune_to: []
|
||||||
|
},
|
||||||
|
"grass" => %{
|
||||||
|
weak_to: ["fire", "ice", "poison", "flying", "bug"],
|
||||||
|
resists: ["water", "electric", "grass", "ground"],
|
||||||
|
immune_to: []
|
||||||
|
},
|
||||||
|
"electric" => %{
|
||||||
|
weak_to: ["ground"],
|
||||||
|
resists: ["electric", "flying", "steel"],
|
||||||
|
immune_to: []
|
||||||
|
},
|
||||||
|
"ice" => %{weak_to: ["fire", "fighting", "rock", "steel"], resists: ["ice"], immune_to: []},
|
||||||
|
"fighting" => %{
|
||||||
|
weak_to: ["flying", "psychic", "fairy"],
|
||||||
|
resists: ["bug", "rock", "dark"],
|
||||||
|
immune_to: []
|
||||||
|
},
|
||||||
|
"poison" => %{
|
||||||
|
weak_to: ["ground", "psychic"],
|
||||||
|
resists: ["grass", "fighting", "poison", "bug", "fairy"],
|
||||||
|
immune_to: []
|
||||||
|
},
|
||||||
|
"ground" => %{
|
||||||
|
weak_to: ["water", "grass", "ice"],
|
||||||
|
resists: ["poison", "rock"],
|
||||||
|
immune_to: ["electric"]
|
||||||
|
},
|
||||||
|
"flying" => %{
|
||||||
|
weak_to: ["electric", "ice", "rock"],
|
||||||
|
resists: ["grass", "fighting", "bug"],
|
||||||
|
immune_to: ["ground"]
|
||||||
|
},
|
||||||
|
"psychic" => %{
|
||||||
|
weak_to: ["bug", "ghost", "dark"],
|
||||||
|
resists: ["fighting", "psychic"],
|
||||||
|
immune_to: []
|
||||||
|
},
|
||||||
|
"bug" => %{
|
||||||
|
weak_to: ["fire", "flying", "rock"],
|
||||||
|
resists: ["grass", "fighting", "ground"],
|
||||||
|
immune_to: []
|
||||||
|
},
|
||||||
|
"rock" => %{
|
||||||
|
weak_to: ["water", "grass", "fighting", "ground", "steel"],
|
||||||
|
resists: ["normal", "fire", "poison", "flying"],
|
||||||
|
immune_to: []
|
||||||
|
},
|
||||||
|
"ghost" => %{
|
||||||
|
weak_to: ["ghost", "dark"],
|
||||||
|
resists: ["poison", "bug"],
|
||||||
|
immune_to: ["normal", "fighting"]
|
||||||
|
},
|
||||||
|
"dragon" => %{
|
||||||
|
weak_to: ["ice", "dragon", "fairy"],
|
||||||
|
resists: ["fire", "water", "electric", "grass"],
|
||||||
|
immune_to: []
|
||||||
|
},
|
||||||
|
"dark" => %{
|
||||||
|
weak_to: ["fighting", "bug", "fairy"],
|
||||||
|
resists: ["ghost", "dark"],
|
||||||
|
immune_to: ["psychic"]
|
||||||
|
},
|
||||||
|
"steel" => %{
|
||||||
|
weak_to: ["fire", "fighting", "ground"],
|
||||||
|
resists: [
|
||||||
|
"normal",
|
||||||
|
"grass",
|
||||||
|
"ice",
|
||||||
|
"flying",
|
||||||
|
"psychic",
|
||||||
|
"bug",
|
||||||
|
"rock",
|
||||||
|
"dragon",
|
||||||
|
"steel",
|
||||||
|
"fairy"
|
||||||
|
],
|
||||||
|
immune_to: ["poison"]
|
||||||
|
},
|
||||||
|
"fairy" => %{
|
||||||
|
weak_to: ["poison", "steel"],
|
||||||
|
resists: ["fighting", "bug", "dark"],
|
||||||
|
immune_to: ["dragon"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a map of type -> %{strong_against: [...], not_effective: [...], no_effect: [...]}
|
||||||
|
representing the offensive matchups for each type.
|
||||||
|
"""
|
||||||
|
def offensive_chart do
|
||||||
|
%{
|
||||||
|
"normal" => %{strong_against: [], not_effective: ["rock", "steel"], no_effect: ["ghost"]},
|
||||||
|
"fire" => %{
|
||||||
|
strong_against: ["grass", "ice", "bug", "steel"],
|
||||||
|
not_effective: ["fire", "water", "rock", "dragon"],
|
||||||
|
no_effect: []
|
||||||
|
},
|
||||||
|
"water" => %{
|
||||||
|
strong_against: ["fire", "ground", "rock"],
|
||||||
|
not_effective: ["water", "grass", "dragon"],
|
||||||
|
no_effect: []
|
||||||
|
},
|
||||||
|
"grass" => %{
|
||||||
|
strong_against: ["water", "ground", "rock"],
|
||||||
|
not_effective: ["fire", "grass", "poison", "flying", "bug", "dragon", "steel"],
|
||||||
|
no_effect: []
|
||||||
|
},
|
||||||
|
"electric" => %{
|
||||||
|
strong_against: ["water", "flying"],
|
||||||
|
not_effective: ["electric", "grass", "dragon"],
|
||||||
|
no_effect: ["ground"]
|
||||||
|
},
|
||||||
|
"ice" => %{
|
||||||
|
strong_against: ["grass", "ground", "flying", "dragon"],
|
||||||
|
not_effective: ["fire", "water", "ice", "steel"],
|
||||||
|
no_effect: []
|
||||||
|
},
|
||||||
|
"fighting" => %{
|
||||||
|
strong_against: ["normal", "ice", "rock", "dark", "steel"],
|
||||||
|
not_effective: ["poison", "flying", "psychic", "bug", "fairy"],
|
||||||
|
no_effect: ["ghost"]
|
||||||
|
},
|
||||||
|
"poison" => %{
|
||||||
|
strong_against: ["grass", "fairy"],
|
||||||
|
not_effective: ["poison", "ground", "rock", "ghost"],
|
||||||
|
no_effect: ["steel"]
|
||||||
|
},
|
||||||
|
"ground" => %{
|
||||||
|
strong_against: ["fire", "electric", "poison", "rock", "steel"],
|
||||||
|
not_effective: ["grass", "bug"],
|
||||||
|
no_effect: ["flying"]
|
||||||
|
},
|
||||||
|
"flying" => %{
|
||||||
|
strong_against: ["grass", "fighting", "bug"],
|
||||||
|
not_effective: ["electric", "rock", "steel"],
|
||||||
|
no_effect: []
|
||||||
|
},
|
||||||
|
"psychic" => %{
|
||||||
|
strong_against: ["fighting", "poison"],
|
||||||
|
not_effective: ["psychic", "steel"],
|
||||||
|
no_effect: ["dark"]
|
||||||
|
},
|
||||||
|
"bug" => %{
|
||||||
|
strong_against: ["grass", "psychic", "dark"],
|
||||||
|
not_effective: ["fire", "fighting", "poison", "flying", "ghost", "steel", "fairy"],
|
||||||
|
no_effect: []
|
||||||
|
},
|
||||||
|
"rock" => %{
|
||||||
|
strong_against: ["fire", "ice", "flying", "bug"],
|
||||||
|
not_effective: ["fighting", "ground", "steel"],
|
||||||
|
no_effect: []
|
||||||
|
},
|
||||||
|
"ghost" => %{
|
||||||
|
strong_against: ["psychic", "ghost"],
|
||||||
|
not_effective: ["dark"],
|
||||||
|
no_effect: ["normal"]
|
||||||
|
},
|
||||||
|
"dragon" => %{strong_against: ["dragon"], not_effective: ["steel"], no_effect: ["fairy"]},
|
||||||
|
"dark" => %{
|
||||||
|
strong_against: ["psychic", "ghost"],
|
||||||
|
not_effective: ["fighting", "dark", "fairy"],
|
||||||
|
no_effect: []
|
||||||
|
},
|
||||||
|
"steel" => %{
|
||||||
|
strong_against: ["ice", "rock", "fairy"],
|
||||||
|
not_effective: ["fire", "water", "electric", "steel"],
|
||||||
|
no_effect: []
|
||||||
|
},
|
||||||
|
"fairy" => %{
|
||||||
|
strong_against: ["fighting", "dragon", "dark"],
|
||||||
|
not_effective: ["fire", "poison", "steel"],
|
||||||
|
no_effect: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the color class for a given type.
|
||||||
|
"""
|
||||||
|
def type_color("normal"), do: "bg-[#A8A77A]"
|
||||||
|
def type_color("fire"), do: "bg-[#EE8130]"
|
||||||
|
def type_color("water"), do: "bg-[#6390F0]"
|
||||||
|
def type_color("grass"), do: "bg-[#7AC74C]"
|
||||||
|
def type_color("electric"), do: "bg-[#F7D02C]"
|
||||||
|
def type_color("ice"), do: "bg-[#96D9D6]"
|
||||||
|
def type_color("fighting"), do: "bg-[#C22E28]"
|
||||||
|
def type_color("poison"), do: "bg-[#A33EA1]"
|
||||||
|
def type_color("ground"), do: "bg-[#E2BF65]"
|
||||||
|
def type_color("flying"), do: "bg-[#A98FF3]"
|
||||||
|
def type_color("psychic"), do: "bg-[#F95587]"
|
||||||
|
def type_color("bug"), do: "bg-[#A6B91A]"
|
||||||
|
def type_color("rock"), do: "bg-[#B6A136]"
|
||||||
|
def type_color("ghost"), do: "bg-[#735797]"
|
||||||
|
def type_color("dragon"), do: "bg-[#6F35FC]"
|
||||||
|
def type_color("dark"), do: "bg-[#705746]"
|
||||||
|
def type_color("steel"), do: "bg-[#B7B7CE]"
|
||||||
|
def type_color("fairy"), do: "bg-[#D685AD]"
|
||||||
|
def type_color(_), do: "bg-base-300"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the text color class for a given type.
|
||||||
|
"""
|
||||||
|
def type_text_color("normal"), do: "text-[#A8A77A]"
|
||||||
|
def type_text_color("fire"), do: "text-[#EE8130]"
|
||||||
|
def type_text_color("water"), do: "text-[#6390F0]"
|
||||||
|
def type_text_color("grass"), do: "text-[#7AC74C]"
|
||||||
|
def type_text_color("electric"), do: "text-[#F7D02C]"
|
||||||
|
def type_text_color("ice"), do: "text-[#96D9D6]"
|
||||||
|
def type_text_color("fighting"), do: "text-[#C22E28]"
|
||||||
|
def type_text_color("poison"), do: "text-[#A33EA1]"
|
||||||
|
def type_text_color("ground"), do: "text-[#E2BF65]"
|
||||||
|
def type_text_color("flying"), do: "text-[#A98FF3]"
|
||||||
|
def type_text_color("psychic"), do: "text-[#F95587]"
|
||||||
|
def type_text_color("bug"), do: "text-[#A6B91A]"
|
||||||
|
def type_text_color("rock"), do: "text-[#B6A136]"
|
||||||
|
def type_text_color("ghost"), do: "text-[#735797]"
|
||||||
|
def type_text_color("dragon"), do: "text-[#6F35FC]"
|
||||||
|
def type_text_color("dark"), do: "text-[#705746]"
|
||||||
|
def type_text_color("steel"), do: "text-[#B7B7CE]"
|
||||||
|
def type_text_color("fairy"), do: "text-[#D685AD]"
|
||||||
|
def type_text_color(_), do: "text-base-content/50"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a heroicon name for each type.
|
||||||
|
"""
|
||||||
|
def type_icon("normal"), do: "hero-minus-circle"
|
||||||
|
def type_icon("fire"), do: "hero-fire"
|
||||||
|
def type_icon("water"), do: "hero-beaker"
|
||||||
|
def type_icon("grass"), do: "hero-puzzle-piece"
|
||||||
|
def type_icon("electric"), do: "hero-bolt"
|
||||||
|
def type_icon("ice"), do: "hero-cloud"
|
||||||
|
def type_icon("fighting"), do: "hero-hand-raised"
|
||||||
|
def type_icon("poison"), do: "hero-beaker"
|
||||||
|
def type_icon("ground"), do: "hero-globe-americas"
|
||||||
|
def type_icon("flying"), do: "hero-paper-airplane"
|
||||||
|
def type_icon("psychic"), do: "hero-eye"
|
||||||
|
def type_icon("bug"), do: "hero-bug-ant"
|
||||||
|
def type_icon("rock"), do: "hero-cube"
|
||||||
|
def type_icon("ghost"), do: "hero-moon"
|
||||||
|
def type_icon("dragon"), do: "hero-star"
|
||||||
|
def type_icon("dark"), do: "hero-moon"
|
||||||
|
def type_icon("steel"), do: "hero-shield-check"
|
||||||
|
def type_icon("fairy"), do: "hero-sparkles"
|
||||||
|
def type_icon(_), do: "hero-question-mark-circle"
|
||||||
|
end
|
||||||
@@ -24,9 +24,11 @@ defmodule CobblemonUiWeb.DashboardLive do
|
|||||||
battle: nil,
|
battle: nil,
|
||||||
selected_pokemon: nil,
|
selected_pokemon: nil,
|
||||||
tier_list: CobblemonUi.TierListScraper.get_tier_list(),
|
tier_list: CobblemonUi.TierListScraper.get_tier_list(),
|
||||||
|
species_info: %{},
|
||||||
view_mode: :party,
|
view_mode: :party,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: nil
|
error: nil,
|
||||||
|
selected_type: nil
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -36,14 +38,19 @@ defmodule CobblemonUiWeb.DashboardLive do
|
|||||||
{:ok, data} ->
|
{:ok, data} ->
|
||||||
battle = find_player_battle(uuid)
|
battle = find_player_battle(uuid)
|
||||||
|
|
||||||
{:noreply,
|
socket =
|
||||||
assign(socket,
|
assign(socket,
|
||||||
selected_player: uuid,
|
selected_player: uuid,
|
||||||
player_data: data,
|
player_data: data,
|
||||||
battle: battle,
|
battle: battle,
|
||||||
selected_pokemon: nil,
|
selected_pokemon: nil,
|
||||||
error: nil
|
species_info: %{},
|
||||||
)}
|
error: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
if connected?(socket), do: send(self(), {:load_species_info, data})
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
{:error, :not_found} ->
|
{:error, :not_found} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
@@ -94,11 +101,24 @@ defmodule CobblemonUiWeb.DashboardLive do
|
|||||||
{:noreply, assign(socket, view_mode: String.to_existing_atom(mode), selected_pokemon: nil)}
|
{:noreply, assign(socket, view_mode: String.to_existing_atom(mode), selected_pokemon: nil)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("select_type", %{"type" => ""}, socket) do
|
||||||
|
{:noreply, assign(socket, selected_type: nil)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("select_type", %{"type" => type}, socket) do
|
||||||
|
{:noreply, assign(socket, selected_type: type)}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:tick, socket) do
|
def handle_info(:tick, socket) do
|
||||||
{:noreply, do_refresh(socket)}
|
{:noreply, do_refresh(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:load_species_info, player_data}, socket) do
|
||||||
|
species_info = compute_species_info(player_data, socket.assigns.tier_list)
|
||||||
|
{:noreply, assign(socket, species_info: species_info)}
|
||||||
|
end
|
||||||
|
|
||||||
defp do_refresh(socket) do
|
defp do_refresh(socket) do
|
||||||
players =
|
players =
|
||||||
case CobblemonUi.CobblemonFS.list_players() do
|
case CobblemonUi.CobblemonFS.list_players() do
|
||||||
@@ -276,13 +296,16 @@ defmodule CobblemonUiWeb.DashboardLive do
|
|||||||
<h3 class="text-sm font-semibold text-base-content/50 uppercase tracking-wider mb-3">
|
<h3 class="text-sm font-semibold text-base-content/50 uppercase tracking-wider mb-3">
|
||||||
Box {box.box + 1}
|
Box {box.box + 1}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-6 gap-2">
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||||
<%= for {pokemon, idx} <- Enum.with_index(box.pokemon), not is_nil(pokemon) do %>
|
<%= for {pokemon, idx} <- Enum.with_index(box.pokemon), not is_nil(pokemon) do %>
|
||||||
<.pokemon_card
|
<% skey = String.downcase(pokemon.species || "") %>
|
||||||
|
<% info = Map.get(@species_info, skey, %{}) %>
|
||||||
|
<.pc_pokemon_card
|
||||||
pokemon={pokemon}
|
pokemon={pokemon}
|
||||||
index={pc_global_index(@player_data.pc, box.box, idx)}
|
index={pc_global_index(@player_data.pc, box.box, idx)}
|
||||||
tier={Map.get(@tier_list, String.downcase(pokemon.species || ""), nil)}
|
tier={Map.get(@tier_list, skey, nil)}
|
||||||
compact
|
types={Map.get(info, :types, [])}
|
||||||
|
evo_tiers={Map.get(info, :evo_tiers, [])}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,6 +324,9 @@ defmodule CobblemonUiWeb.DashboardLive do
|
|||||||
pokemon={@selected_pokemon}
|
pokemon={@selected_pokemon}
|
||||||
tier={Map.get(@tier_list, String.downcase(@selected_pokemon.species || ""), nil)}
|
tier={Map.get(@tier_list, String.downcase(@selected_pokemon.species || ""), nil)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<%!-- Type Chart --%>
|
||||||
|
<.type_chart selected_type={@selected_type} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,6 +359,38 @@ defmodule CobblemonUiWeb.DashboardLive do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp compute_species_info(player_data, tier_list) do
|
||||||
|
all_pokemon =
|
||||||
|
(player_data.party ++ Enum.flat_map(player_data.pc, & &1.pokemon))
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
|
unique_species =
|
||||||
|
all_pokemon
|
||||||
|
|> Enum.map(&String.downcase(&1.species || ""))
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|> Enum.uniq()
|
||||||
|
|
||||||
|
unique_species
|
||||||
|
|> Task.async_stream(
|
||||||
|
fn species ->
|
||||||
|
types = CobblemonUi.PokeApi.get_types(species)
|
||||||
|
evolutions = CobblemonUi.EvolutionApi.get_evolutions(species)
|
||||||
|
|
||||||
|
evo_tiers =
|
||||||
|
Enum.map(evolutions, fn evo ->
|
||||||
|
%{species: evo, tier: Map.get(tier_list, evo)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
{species, %{types: types, evo_tiers: evo_tiers}}
|
||||||
|
end,
|
||||||
|
max_concurrency: 10,
|
||||||
|
timeout: :infinity
|
||||||
|
)
|
||||||
|
|> Enum.reduce(%{}, fn {:ok, {species, info}}, acc ->
|
||||||
|
Map.put(acc, species, info)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp opponent_evolutions(nil, _player_id), do: %{}
|
defp opponent_evolutions(nil, _player_id), do: %{}
|
||||||
|
|
||||||
defp opponent_evolutions(battle, player_id) do
|
defp opponent_evolutions(battle, player_id) do
|
||||||
|
|||||||
@@ -89,6 +89,76 @@ defmodule CobblemonUiWeb.PokemonComponents do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attr :pokemon, :map, required: true
|
||||||
|
attr :index, :integer, required: true
|
||||||
|
attr :tier, :string, default: nil
|
||||||
|
attr :types, :list, default: []
|
||||||
|
attr :evo_tiers, :list, default: []
|
||||||
|
|
||||||
|
def pc_pokemon_card(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
phx-click="select_pokemon"
|
||||||
|
phx-value-index={@index}
|
||||||
|
class="group rounded-lg border border-base-300/30 bg-base-200/20 hover:bg-base-200/40 hover:border-primary/30 transition-all duration-150 cursor-pointer hover:shadow-md hover:shadow-primary/5 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2.5">
|
||||||
|
<%!-- Sprite --%>
|
||||||
|
<div class="w-12 h-12 rounded-lg bg-base-300/15 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={"https://img.rankedboost.com/wp-content/plugins/k-Pokemon/assets/sprites-official/#{String.downcase(@pokemon.species || "")}.png"}
|
||||||
|
alt={@pokemon.species}
|
||||||
|
class="w-10 h-10 object-contain drop-shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<%!-- Info --%>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-xs font-semibold text-base-content/90 capitalize truncate group-hover:text-primary transition-colors">
|
||||||
|
{@pokemon.species || "Unknown"}
|
||||||
|
</span>
|
||||||
|
<.tier_badge :if={@tier} tier={@tier} species={@pokemon.species} compact={true} />
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-base-content/40 mt-0.5">
|
||||||
|
Lv. {@pokemon.level || "?"}
|
||||||
|
</p>
|
||||||
|
<%!-- Types --%>
|
||||||
|
<div :if={@types != []} class="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
<span
|
||||||
|
:for={type <- @types}
|
||||||
|
class={[
|
||||||
|
"inline-block text-[9px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded leading-none text-white/90",
|
||||||
|
CobblemonUi.TypeChart.type_color(type)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%!-- Evolution tiers --%>
|
||||||
|
<div :if={@evo_tiers != []} class="mt-2 pt-2 border-t border-base-content/5">
|
||||||
|
<div class="flex items-center gap-1 flex-wrap">
|
||||||
|
<span class="text-[9px] text-base-content/25 uppercase tracking-wider font-medium mr-0.5">
|
||||||
|
Evo
|
||||||
|
</span>
|
||||||
|
<%= for evo <- @evo_tiers do %>
|
||||||
|
<div class="flex items-center gap-1 rounded bg-base-300/15 px-1.5 py-0.5">
|
||||||
|
<img
|
||||||
|
src={"https://img.rankedboost.com/wp-content/plugins/k-Pokemon/assets/sprites-official/#{evo.species}.png"}
|
||||||
|
alt={evo.species}
|
||||||
|
class="w-4 h-4 object-contain opacity-70"
|
||||||
|
/>
|
||||||
|
<span class="text-[9px] text-base-content/40 capitalize">{evo.species}</span>
|
||||||
|
<.tier_badge :if={evo.tier} tier={evo.tier} species={evo.species} compact={true} />
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
attr :tier, :string, required: true
|
attr :tier, :string, required: true
|
||||||
attr :species, :string, required: true
|
attr :species, :string, required: true
|
||||||
attr :compact, :boolean, default: false
|
attr :compact, :boolean, default: false
|
||||||
@@ -281,6 +351,232 @@ defmodule CobblemonUiWeb.PokemonComponents do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# --- Type Chart Component ---
|
||||||
|
|
||||||
|
alias CobblemonUi.TypeChart
|
||||||
|
|
||||||
|
attr :selected_type, :string, default: nil
|
||||||
|
|
||||||
|
def type_chart(assigns) do
|
||||||
|
chart = TypeChart.defensive_chart()
|
||||||
|
offense = TypeChart.offensive_chart()
|
||||||
|
types = TypeChart.types()
|
||||||
|
|
||||||
|
assigns = assign(assigns, chart: chart, offense: offense, types: types)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="mt-8 rounded-xl border border-base-300/40 bg-base-200/30 overflow-hidden">
|
||||||
|
<div class="px-5 py-4 border-b border-base-300/30 flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-primary/15 flex items-center justify-center shrink-0">
|
||||||
|
<.icon name="hero-table-cells" class="size-5 text-primary/80" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-base-content text-lg">Type Chart</h3>
|
||||||
|
<p class="text-xs text-base-content/40">
|
||||||
|
Tap a type to see its matchups
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Type picker grid --%>
|
||||||
|
<div class="px-5 pt-4 pb-2">
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
:for={t <- @types}
|
||||||
|
phx-click="select_type"
|
||||||
|
phx-value-type={t}
|
||||||
|
class={[
|
||||||
|
"inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold capitalize transition-all duration-150 border cursor-pointer",
|
||||||
|
if(@selected_type == t,
|
||||||
|
do: "ring-2 ring-primary/60 border-primary/40 bg-base-100 shadow-md scale-105",
|
||||||
|
else:
|
||||||
|
"border-base-300/30 bg-base-200/40 hover:bg-base-200/70 hover:border-base-300/50 hover:shadow-sm"
|
||||||
|
)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<span class={[
|
||||||
|
"inline-block w-2.5 h-2.5 rounded-full shrink-0",
|
||||||
|
type_bg(@selected_type == t, t)
|
||||||
|
]} />
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Selected type detail --%>
|
||||||
|
<%= if @selected_type do %>
|
||||||
|
<div class="px-5 py-4 animate-in fade-in duration-200">
|
||||||
|
<div class="flex items-center gap-3 mb-5">
|
||||||
|
<div class={[
|
||||||
|
"w-12 h-12 rounded-xl flex items-center justify-center shrink-0 shadow-sm",
|
||||||
|
TypeChart.type_color(@selected_type)
|
||||||
|
]}>
|
||||||
|
<.icon name={TypeChart.type_icon(@selected_type)} class="size-6 text-white drop-shadow" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-base-content capitalize text-xl">{@selected_type}</h4>
|
||||||
|
<p class="text-xs text-base-content/40">Defensive & Offensive matchups</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
phx-click="select_type"
|
||||||
|
phx-value-type=""
|
||||||
|
class="ml-auto btn btn-ghost btn-sm btn-circle hover:bg-base-300/50"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<%!-- Defensive --%>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<.icon name="hero-shield-check" class="size-3.5" /> Defensive
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<%!-- Weak to --%>
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] text-error/80 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-3" /> Weak to (2×)
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<.type_pill
|
||||||
|
:for={t <- @chart[@selected_type].weak_to}
|
||||||
|
type_name={t}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
:if={@chart[@selected_type].weak_to == []}
|
||||||
|
class="text-xs text-base-content/25 italic"
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Resists --%>
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] text-success/80 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
|
||||||
|
<.icon name="hero-shield-check" class="size-3" /> Resists (½×)
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<.type_pill
|
||||||
|
:for={t <- @chart[@selected_type].resists}
|
||||||
|
type_name={t}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
:if={@chart[@selected_type].resists == []}
|
||||||
|
class="text-xs text-base-content/25 italic"
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Immune to --%>
|
||||||
|
<div :if={@chart[@selected_type].immune_to != []}>
|
||||||
|
<p class="text-[11px] text-info/80 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
|
||||||
|
<.icon name="hero-no-symbol" class="size-3" /> Immune to (0×)
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<.type_pill
|
||||||
|
:for={t <- @chart[@selected_type].immune_to}
|
||||||
|
type_name={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Offensive --%>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<.icon name="hero-bolt" class="size-3.5" /> Offensive
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<%!-- Super effective --%>
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] text-error/80 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
|
||||||
|
<.icon name="hero-bolt" class="size-3" /> Super effective (2×)
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<.type_pill
|
||||||
|
:for={t <- @offense[@selected_type].strong_against}
|
||||||
|
type_name={t}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
:if={@offense[@selected_type].strong_against == []}
|
||||||
|
class="text-xs text-base-content/25 italic"
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Not very effective --%>
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] text-warning/80 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
|
||||||
|
<.icon name="hero-shield-exclamation" class="size-3" /> Not very effective (½×)
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<.type_pill
|
||||||
|
:for={t <- @offense[@selected_type].not_effective}
|
||||||
|
type_name={t}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
:if={@offense[@selected_type].not_effective == []}
|
||||||
|
class="text-xs text-base-content/25 italic"
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- No effect --%>
|
||||||
|
<div :if={@offense[@selected_type].no_effect != []}>
|
||||||
|
<p class="text-[11px] text-base-content/40 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
|
||||||
|
<.icon name="hero-no-symbol" class="size-3" /> No effect (0×)
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<.type_pill
|
||||||
|
:for={t <- @offense[@selected_type].no_effect}
|
||||||
|
type_name={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%!-- Compact overview when no type selected --%>
|
||||||
|
<div class="px-5 pb-4">
|
||||||
|
<p class="text-xs text-base-content/30 italic">Select a type above to view matchups</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :type_name, :string, required: true
|
||||||
|
|
||||||
|
def type_pill(assigns) do
|
||||||
|
~H"""
|
||||||
|
<button
|
||||||
|
phx-click="select_type"
|
||||||
|
phx-value-type={@type_name}
|
||||||
|
class={[
|
||||||
|
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-semibold capitalize",
|
||||||
|
"border transition-all duration-150 cursor-pointer hover:scale-105 hover:shadow-sm",
|
||||||
|
TypeChart.type_color(@type_name),
|
||||||
|
"text-white border-white/10"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<.icon name={TypeChart.type_icon(@type_name)} class="size-3 drop-shadow" />
|
||||||
|
{@type_name}
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp type_bg(true, type), do: TypeChart.type_color(type)
|
||||||
|
defp type_bg(false, type), do: TypeChart.type_color(type) <> "/60"
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
|
||||||
defp gender_symbol("male"), do: "♂"
|
defp gender_symbol("male"), do: "♂"
|
||||||
|
|||||||
Reference in New Issue
Block a user