From 1b476ad816c38fd731644edc6031856a12a41611 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Wed, 25 Mar 2026 21:32:10 -0600 Subject: [PATCH] updates --- lib/cobblemon_ui/application.ex | 1 + lib/cobblemon_ui/sprite_cache.ex | 106 +++++++ lib/cobblemon_ui_web/endpoint.ex | 3 + .../live/battle_components.ex | 4 +- lib/cobblemon_ui_web/live/dashboard_live.ex | 4 + .../live/pokemon_components.ex | 12 +- lib/cobblemon_ui_web/live/type_icons.ex | 283 ++++++++++++++++++ lib/cobblemon_ui_web/sprite_static.ex | 37 +++ 8 files changed, 443 insertions(+), 7 deletions(-) create mode 100644 lib/cobblemon_ui/sprite_cache.ex create mode 100644 lib/cobblemon_ui_web/live/type_icons.ex create mode 100644 lib/cobblemon_ui_web/sprite_static.ex diff --git a/lib/cobblemon_ui/application.ex b/lib/cobblemon_ui/application.ex index cffcc36..ef2927a 100644 --- a/lib/cobblemon_ui/application.ex +++ b/lib/cobblemon_ui/application.ex @@ -15,6 +15,7 @@ defmodule CobblemonUi.Application do CobblemonUi.TierListScraper, CobblemonUi.EvolutionApi, CobblemonUi.PokeApi, + CobblemonUi.SpriteCache, # Start to serve requests, typically the last entry CobblemonUiWeb.Endpoint ] diff --git a/lib/cobblemon_ui/sprite_cache.ex b/lib/cobblemon_ui/sprite_cache.ex new file mode 100644 index 0000000..5327188 --- /dev/null +++ b/lib/cobblemon_ui/sprite_cache.ex @@ -0,0 +1,106 @@ +defmodule CobblemonUi.SpriteCache do + @moduledoc """ + Downloads and caches Pokémon sprite images locally. + + Sprites are stored under `$CACHE_DIR/sprites/.png`. + Use `ensure_sprite/1` to lazily download a sprite on first access, + or `sprite_path/1` to get the expected local filesystem path. + """ + + use GenServer + require Logger + + @source_url "https://img.rankedboost.com/wp-content/plugins/k-Pokemon/assets/sprites-official" + + # --------------------------------------------------------------------------- + # Client API + # --------------------------------------------------------------------------- + + def start_link(_opts) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @doc """ + Ensures the sprite for the given species is downloaded locally. + Returns `:ok` if the file exists (or was just fetched), or `{:error, reason}`. + """ + @spec ensure_sprite(String.t()) :: :ok | {:error, term()} + def ensure_sprite(species) when is_binary(species) do + key = String.downcase(species) + path = sprite_path(key) + + if File.exists?(path) do + :ok + else + GenServer.call(__MODULE__, {:download, key}, 30_000) + end + end + + @doc """ + Returns the local URL path for a species sprite (for use in templates). + Always returns the path even if the file hasn't been downloaded yet. + """ + @spec sprite_url(String.t()) :: String.t() + def sprite_url(species) do + "/sprites/#{String.downcase(species)}.png" + end + + @doc "Returns the filesystem path where a sprite would be stored." + @spec sprite_path(String.t()) :: String.t() + def sprite_path(species) do + Path.join(sprites_dir(), "#{String.downcase(species)}.png") + end + + @doc "Returns the base sprites directory." + @spec sprites_dir() :: String.t() + def sprites_dir do + dir = System.get_env("CACHE_DIR", ".") + Path.join(dir, "sprites") + end + + # --------------------------------------------------------------------------- + # Server callbacks + # --------------------------------------------------------------------------- + + @impl true + def init(_opts) do + File.mkdir_p!(sprites_dir()) + {:ok, MapSet.new()} + end + + @impl true + def handle_call({:download, species}, _from, in_progress) do + path = sprite_path(species) + + if File.exists?(path) do + {:reply, :ok, in_progress} + else + result = download_sprite(species, path) + {:reply, result, in_progress} + end + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp download_sprite(species, dest) do + url = "#{@source_url}/#{species}.png" + + case Req.get(url, decode_body: false) do + {:ok, %Req.Response{status: 200, body: body}} when is_binary(body) -> + File.mkdir_p!(Path.dirname(dest)) + File.write!(dest, body) + Logger.debug("[SpriteCache] Downloaded sprite for #{species}") + :ok + + {:ok, %Req.Response{status: status}} -> + Logger.warning("[SpriteCache] Failed to download #{species}: HTTP #{status}") + {:error, {:http_error, status}} + + {:error, reason} -> + Logger.warning("[SpriteCache] Failed to download #{species}: #{inspect(reason)}") + {:error, reason} + end + end +end diff --git a/lib/cobblemon_ui_web/endpoint.ex b/lib/cobblemon_ui_web/endpoint.ex index 37934f1..952abed 100644 --- a/lib/cobblemon_ui_web/endpoint.ex +++ b/lib/cobblemon_ui_web/endpoint.ex @@ -17,6 +17,9 @@ defmodule CobblemonUiWeb.Endpoint do websocket: [connect_info: [session: @session_options]], longpoll: [connect_info: [session: @session_options]] + # Serve cached sprites from the CACHE_DIR/sprites directory. + plug CobblemonUiWeb.Plugs.SpriteStatic + # Serve at "/" the static files from "priv/static" directory. # # When code reloading is disabled (e.g., in production), diff --git a/lib/cobblemon_ui_web/live/battle_components.ex b/lib/cobblemon_ui_web/live/battle_components.ex index 2067dec..0ec909b 100644 --- a/lib/cobblemon_ui_web/live/battle_components.ex +++ b/lib/cobblemon_ui_web/live/battle_components.ex @@ -60,7 +60,7 @@ defmodule CobblemonUiWeb.BattleComponents do <%!-- Sprite --%>
{poke.species} @@ -111,7 +111,7 @@ defmodule CobblemonUiWeb.BattleComponents do <% evo_tier = Map.get(@tier_list, evo, nil) %>
{evo} diff --git a/lib/cobblemon_ui_web/live/dashboard_live.ex b/lib/cobblemon_ui_web/live/dashboard_live.ex index ca932b6..f9c4552 100644 --- a/lib/cobblemon_ui_web/live/dashboard_live.ex +++ b/lib/cobblemon_ui_web/live/dashboard_live.ex @@ -367,6 +367,10 @@ defmodule CobblemonUiWeb.DashboardLive do types = CobblemonUi.PokeApi.get_types(species) evolutions = CobblemonUi.EvolutionApi.get_evolutions(species) + # Pre-download sprites for this species and its evolutions + CobblemonUi.SpriteCache.ensure_sprite(species) + Enum.each(evolutions, &CobblemonUi.SpriteCache.ensure_sprite/1) + evo_tiers = Enum.map(evolutions, fn evo -> %{species: evo, tier: Map.get(tier_list, evo)} diff --git a/lib/cobblemon_ui_web/live/pokemon_components.ex b/lib/cobblemon_ui_web/live/pokemon_components.ex index 73116f6..ee272ca 100644 --- a/lib/cobblemon_ui_web/live/pokemon_components.ex +++ b/lib/cobblemon_ui_web/live/pokemon_components.ex @@ -1,6 +1,8 @@ defmodule CobblemonUiWeb.PokemonComponents do use CobblemonUiWeb, :html + alias CobblemonUiWeb.TypeIcons + attr :pokemon, :map, required: true attr :index, :integer, required: true attr :compact, :boolean, default: false @@ -106,7 +108,7 @@ defmodule CobblemonUiWeb.PokemonComponents do <%!-- Sprite --%>
{@pokemon.species} @@ -145,7 +147,7 @@ defmodule CobblemonUiWeb.PokemonComponents do <%= for evo <- @evo_tiers do %>
{evo.species} @@ -198,7 +200,7 @@ defmodule CobblemonUiWeb.PokemonComponents do
{@pokemon.species} @@ -391,7 +393,7 @@ defmodule CobblemonUiWeb.PokemonComponents do "w-11 h-11 rounded-full flex items-center justify-center shrink-0 shadow-sm mt-0.5", TypeChart.type_color(@type_name) ]}> - {@type_name} +
<%!-- Weakness / Resistance rows --%>
@@ -445,7 +447,7 @@ defmodule CobblemonUiWeb.PokemonComponents do ]} title={@type_name} > - {@type_name} + """ end diff --git a/lib/cobblemon_ui_web/live/type_icons.ex b/lib/cobblemon_ui_web/live/type_icons.ex new file mode 100644 index 0000000..3086e0c --- /dev/null +++ b/lib/cobblemon_ui_web/live/type_icons.ex @@ -0,0 +1,283 @@ +defmodule CobblemonUiWeb.TypeIcons do + @moduledoc """ + Inline SVG components for each Pokemon type icon. + Source: https://github.com/duiker101/pokemon-type-svg-icons + """ + use Phoenix.Component + + attr :type, :string, required: true + attr :class, :string, default: "w-6 h-6" + + def type_icon(%{type: "bug"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "dark"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "dragon"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "electric"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "fairy"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "fighting"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "fire"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "flying"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "ghost"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "grass"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "ground"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "ice"} = assigns) do + ~H""" + + + + + + + + + """ + end + + def type_icon(%{type: "normal"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "poison"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "psychic"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "rock"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "steel"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(%{type: "water"} = assigns) do + ~H""" + + + + """ + end + + def type_icon(assigns) do + ~H""" + + + ? + + """ + end +end diff --git a/lib/cobblemon_ui_web/sprite_static.ex b/lib/cobblemon_ui_web/sprite_static.ex new file mode 100644 index 0000000..7005e03 --- /dev/null +++ b/lib/cobblemon_ui_web/sprite_static.ex @@ -0,0 +1,37 @@ +defmodule CobblemonUiWeb.Plugs.SpriteStatic do + @moduledoc """ + Serves cached Pokémon sprite images from the sprites cache directory. + + Matches requests to `/sprites/.png` and serves the corresponding + file from `CobblemonUi.SpriteCache.sprites_dir()`. If the file doesn't + exist locally, triggers an on-demand download before responding. + """ + + import Plug.Conn + + def init(opts), do: opts + + def call(%Plug.Conn{request_path: "/sprites/" <> filename} = conn, _opts) do + if String.ends_with?(filename, ".png") do + species = String.trim_trailing(filename, ".png") + CobblemonUi.SpriteCache.ensure_sprite(species) + path = CobblemonUi.SpriteCache.sprite_path(species) + + if File.exists?(path) do + conn + |> put_resp_content_type("image/png") + |> put_resp_header("cache-control", "public, max-age=604800, immutable") + |> send_file(200, path) + |> halt() + else + conn + |> send_resp(404, "Not found") + |> halt() + end + else + conn + end + end + + def call(conn, _opts), do: conn +end