generated from nhcarrigan/template
feat: integrate art assets across all game panels (#43)
## Summary - Adds `apps/web/src/utils/cdn.ts` with a `cdnImage(folder, id)` helper that builds URLs from `https://cdn.nhcarrigan.com/elysium/` - Wires CDN art into all 13 game panels (bosses, quests, adventurers, companions, equipment, upgrades, prestige, transcendence, achievements, explorations, crafting, story, codex) - Zone selector tabs now display 16:9 zone art thumbnails in place of emoji icons - Adds a fixed background image at 15% opacity via `body::before` - Documents the art generation and CDN upload process in `CLAUDE.md` for future expansions Resolves #15 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #43 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #43.
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Achievement } from "@elysium/types";
|
||||
|
||||
@@ -76,7 +77,11 @@ const AchievementCard = ({
|
||||
<div className={`achievement-card ${isUnlocked
|
||||
? "unlocked"
|
||||
: "locked"}`}>
|
||||
<div className="achievement-icon">{achievement.icon}</div>
|
||||
<img
|
||||
alt={achievement.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("achievements", achievement.id)}
|
||||
/>
|
||||
<div className="achievement-info">
|
||||
<h3>{achievement.name}</h3>
|
||||
<p>{achievement.description}</p>
|
||||
|
||||
@@ -9,18 +9,10 @@
|
||||
/* eslint-disable complexity -- Complex component with many render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
|
||||
const iconByClass: Record<string, string> = {
|
||||
cleric: "✝️",
|
||||
mage: "🔮",
|
||||
paladin: "🛡️",
|
||||
ranger: "🏹",
|
||||
rogue: "🗝️",
|
||||
warrior: "🗡️",
|
||||
};
|
||||
|
||||
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
||||
|
||||
@@ -105,14 +97,15 @@ const AdventurerCard = ({
|
||||
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
||||
: "🔒 Locked";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
|
||||
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
|
||||
|
||||
return (
|
||||
<div className={`adventurer-card ${adventurer.unlocked
|
||||
? ""
|
||||
: "locked"}`}>
|
||||
<div className="adventurer-icon">{adventurerIcon}</div>
|
||||
<img
|
||||
alt={adventurer.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("adventurers", adventurer.id)}
|
||||
/>
|
||||
<div className="adventurer-info">
|
||||
<h3>{adventurer.name}</h3>
|
||||
<p>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { Boss, GameState } from "@elysium/types";
|
||||
@@ -56,6 +57,11 @@ const BossCard = ({
|
||||
|
||||
return (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
<img
|
||||
alt={boss.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("bosses", boss.id)}
|
||||
/>
|
||||
<div className="boss-info">
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import type { CodexEntry } from "@elysium/types";
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,18 @@ const sourceBadge: Record<CodexEntry["sourceType"], string> = {
|
||||
zone: "🗺️",
|
||||
};
|
||||
|
||||
const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
|
||||
adventurer: "adventurers",
|
||||
boss: "bosses",
|
||||
equipment: "equipment",
|
||||
exploration: "explorations",
|
||||
prestige: "prestige-upgrades",
|
||||
quest: "quests",
|
||||
recipe: "recipes",
|
||||
upgrade: "upgrades",
|
||||
zone: "zones",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the codex panel with lore entries grouped by zone.
|
||||
* @returns The JSX element.
|
||||
@@ -155,7 +168,17 @@ const CodexPanel = (): JSX.Element => {
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded
|
||||
? <p className="codex-entry-content">{entry.content}</p>
|
||||
? <>
|
||||
<img
|
||||
alt={entry.title}
|
||||
className="codex-entry-image"
|
||||
src={cdnImage(
|
||||
sourceTypeFolder[entry.sourceType],
|
||||
entry.sourceId,
|
||||
)}
|
||||
/>
|
||||
<p className="codex-entry-content">{entry.content}</p>
|
||||
</>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
||||
import { COMPANIONS, type Companion } from "@elysium/types";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import type { JSX } from "react";
|
||||
|
||||
const bonusLabels: Record<string, string> = {
|
||||
@@ -96,6 +97,11 @@ const CompanionCard = ({
|
||||
: ""}`}
|
||||
>
|
||||
<div className="companion-header">
|
||||
<img
|
||||
alt={companion.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("companions", companion.id)}
|
||||
/>
|
||||
<div className="companion-name-block">
|
||||
<span className="companion-name">{companion.name}</span>
|
||||
<span className="companion-title">{companion.title}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { MATERIALS } from "../../data/materials.js";
|
||||
import { RECIPES } from "../../data/recipes.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
|
||||
const bonusLabel: Record<string, string> = {
|
||||
@@ -105,6 +106,11 @@ const CraftingPanel = (): JSX.Element => {
|
||||
}`}
|
||||
key={material.id}
|
||||
>
|
||||
<img
|
||||
alt={material.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("materials", material.id)}
|
||||
/>
|
||||
<div className="material-info">
|
||||
<span className="material-name">{material.name}</span>
|
||||
<span className="material-rarity">{material.rarity}</span>
|
||||
@@ -144,6 +150,11 @@ const CraftingPanel = (): JSX.Element => {
|
||||
: ""}`}
|
||||
key={recipe.id}
|
||||
>
|
||||
<img
|
||||
alt={recipe.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("recipes", recipe.id)}
|
||||
/>
|
||||
<div className="recipe-info">
|
||||
<h4>{recipe.name}</h4>
|
||||
<p className="recipe-description">{recipe.description}</p>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||
|
||||
@@ -20,12 +21,6 @@ const rarityLabel: Record<string, string> = {
|
||||
rare: "Rare",
|
||||
};
|
||||
|
||||
const typeIcon: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a human-readable bonus description for a piece of equipment.
|
||||
* @param item - The equipment item.
|
||||
@@ -128,7 +123,11 @@ const EquipmentCard = ({
|
||||
<div
|
||||
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
||||
>
|
||||
<div className="equipment-icon">{typeIcon[item.type]}</div>
|
||||
<img
|
||||
alt={item.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("equipment", item.id)}
|
||||
/>
|
||||
<div className="equipment-info">
|
||||
<div className="equipment-name-row">
|
||||
<h3>{item.name}</h3>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { ExploreCollectResponse } from "@elysium/types";
|
||||
|
||||
@@ -230,6 +231,11 @@ const ExplorationPanel = (): JSX.Element => {
|
||||
className={`exploration-card exploration-${status}`}
|
||||
key={area.id}
|
||||
>
|
||||
<img
|
||||
alt={area.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("explorations", area.id)}
|
||||
/>
|
||||
<div className="exploration-info">
|
||||
<h3>
|
||||
{area.name}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PRESTIGE_UPGRADES,
|
||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/prestigeUpgrades.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { sendNotification } from "../../utils/notification.js";
|
||||
import { playSound } from "../../utils/sound.js";
|
||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||
@@ -366,6 +367,11 @@ const PrestigePanel = (): JSX.Element => {
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<img
|
||||
alt={upgrade.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("prestige-upgrades", upgrade.id)}
|
||||
/>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { Quest } from "@elysium/types";
|
||||
@@ -81,6 +82,11 @@ const QuestCard = ({
|
||||
|
||||
return (
|
||||
<div className={`quest-card quest-${quest.status}`}>
|
||||
<img
|
||||
alt={quest.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("quests", quest.id)}
|
||||
/>
|
||||
<div className="quest-info">
|
||||
<h3>{quest.name}</h3>
|
||||
<p>{quest.description}</p>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { STORY_CHAPTERS } from "@elysium/types";
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
|
||||
/**
|
||||
* Substitutes the character name placeholder in story text.
|
||||
@@ -102,6 +103,11 @@ const StoryPanel = (): JSX.Element => {
|
||||
: <div className="story-chapter-view">
|
||||
{isUnlocked
|
||||
? <>
|
||||
<img
|
||||
alt={activeChapter.title}
|
||||
className="story-chapter-banner"
|
||||
src={cdnImage("story-chapters", activeChapter.id)}
|
||||
/>
|
||||
<h2 className="story-chapter-title">
|
||||
{"Chapter "}
|
||||
{activeChapterIndex + 1}
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Many conditional render paths */
|
||||
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
||||
/* eslint-disable max-lines -- Transcendence panel with CDN images exceeds line limit */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
TRANSCENDENCE_UPGRADES,
|
||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/transcendenceUpgrades.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const echoFormulaConstant = 853;
|
||||
@@ -301,6 +303,11 @@ const TranscendencePanel = (): JSX.Element => {
|
||||
: ""}`}
|
||||
key={upgrade.id}
|
||||
>
|
||||
<img
|
||||
alt={upgrade.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("transcendence-upgrades", upgrade.id)}
|
||||
/>
|
||||
<div className="shop-upgrade-info">
|
||||
<h4>{upgrade.name}</h4>
|
||||
<p>{upgrade.description}</p>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Upgrade } from "@elysium/types";
|
||||
|
||||
@@ -53,6 +54,11 @@ const UpgradeCard = ({
|
||||
if (upgrade.unlocked && upgrade.purchased) {
|
||||
return (
|
||||
<div className="upgrade-card purchased">
|
||||
<img
|
||||
alt={upgrade.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("upgrades", upgrade.id)}
|
||||
/>
|
||||
<span className="upgrade-name">
|
||||
{"✅ "}
|
||||
{upgrade.name}
|
||||
@@ -65,6 +71,11 @@ const UpgradeCard = ({
|
||||
if (upgrade.unlocked) {
|
||||
return (
|
||||
<div className="upgrade-card">
|
||||
<img
|
||||
alt={upgrade.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("upgrades", upgrade.id)}
|
||||
/>
|
||||
<div className="upgrade-info">
|
||||
<h3>{upgrade.name}</h3>
|
||||
<p>{upgrade.description}</p>
|
||||
@@ -108,6 +119,11 @@ const UpgradeCard = ({
|
||||
|
||||
return (
|
||||
<div className="upgrade-card locked">
|
||||
<img
|
||||
alt={upgrade.name}
|
||||
className="card-thumbnail"
|
||||
src={cdnImage("upgrades", upgrade.id)}
|
||||
/>
|
||||
<div className="upgrade-info">
|
||||
<h3>
|
||||
{"🔒 "}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import type { Zone } from "@elysium/types";
|
||||
import type { JSX } from "react";
|
||||
|
||||
@@ -44,7 +45,11 @@ const ZoneSelector = ({
|
||||
title={zone.description}
|
||||
type="button"
|
||||
>
|
||||
<span className="zone-emoji">{zone.emoji}</span>
|
||||
<img
|
||||
alt={zone.name}
|
||||
className="zone-tab-image"
|
||||
src={cdnImage("zones", zone.id)}
|
||||
/>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
+44
-2
@@ -33,6 +33,20 @@ body {
|
||||
color: var(--colour-text);
|
||||
font-family: var(--font);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
background-attachment: fixed;
|
||||
background-image: url("https://cdn.nhcarrigan.com/elysium/background.jpg");
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
content: "";
|
||||
inset: 0;
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* ===================== RESOURCE BAR ===================== */
|
||||
@@ -2056,8 +2070,11 @@ body {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.zone-emoji {
|
||||
font-size: 1.4rem;
|
||||
.zone-tab-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 0.35rem;
|
||||
object-fit: cover;
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.zone-name {
|
||||
@@ -4465,3 +4482,28 @@ body {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===================== CDN ASSET IMAGES ===================== */
|
||||
.card-thumbnail {
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.story-chapter-banner {
|
||||
border-radius: var(--radius);
|
||||
height: 220px;
|
||||
margin-bottom: 1rem;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.codex-entry-image {
|
||||
border-radius: var(--radius);
|
||||
height: 80px;
|
||||
margin-bottom: 0.5rem;
|
||||
object-fit: cover;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file CDN URL utility for Elysium game assets.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
const cdnBase = "https://cdn.nhcarrigan.com/elysium";
|
||||
|
||||
/**
|
||||
* Returns the CDN URL for a game asset image.
|
||||
* @param folder - The asset category folder (e.g. "bosses", "companions").
|
||||
* @param id - The asset identifier (file name without extension).
|
||||
* @returns The full CDN URL for the asset.
|
||||
*/
|
||||
const cdnImage = (folder: string, id: string): string => {
|
||||
return `${cdnBase}/${folder}/${id}.jpg`;
|
||||
};
|
||||
|
||||
export { cdnImage };
|
||||
Reference in New Issue
Block a user