generated from nhcarrigan/template
feat: integrate CDN art assets across all game panels
Add cdnImage utility and wire zone/boss/quest/adventurer/companion/ equipment/upgrade/prestige/transcendence/achievement/exploration/ crafting/story panels to load art from cdn.nhcarrigan.com/elysium/. Zone selector tabs display 16:9 zone art thumbnails. Background image added as fixed overlay at 15% opacity. Document art generation process in CLAUDE.md for future expansions. Resolves #15
This commit is contained in:
@@ -7,6 +7,41 @@
|
|||||||
2. `pnpm build` — all packages build cleanly
|
2. `pnpm build` — all packages build cleanly
|
||||||
3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types`
|
3. `pnpm test` — all tests pass with 100% coverage on `apps/api` and `packages/types`
|
||||||
|
|
||||||
|
## Art Assets
|
||||||
|
|
||||||
|
Game art is generated via the Gemini API (`gemini-3-pro-image-preview`, ~$0.134/image at 1K resolution) and hosted on the CDN at `https://cdn.nhcarrigan.com/elysium/`.
|
||||||
|
|
||||||
|
### Process
|
||||||
|
1. Generate images with `curl` to `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key=<API_KEY>`, requesting soft-shaded anime style
|
||||||
|
2. Save responses to `/home/naomi/code/naomi/elysium/img/<category>/<id>.jpg`
|
||||||
|
3. Upload to R2 with: `AWS_ACCESS_KEY_ID=dd0a3d73969143ada84d50f8940cc5e2 AWS_SECRET_ACCESS_KEY=f73e9907da1b2297e93e17f786d6446d33d4ac60e185879578a0d5020899b18e aws s3 sync img/ s3://nhcarrigan-cdn/elysium/ --endpoint-url https://751c386661d378cc032093493cfb0869.r2.cloudflarestorage.com`
|
||||||
|
4. Delete the local `img/` directory before committing (images live on CDN only)
|
||||||
|
|
||||||
|
### CDN URL Helper
|
||||||
|
`apps/web/src/utils/cdn.ts` exports `cdnImage(folder, id)` → `https://cdn.nhcarrigan.com/elysium/<folder>/<id>.jpg`
|
||||||
|
|
||||||
|
### Directory → Category Mapping
|
||||||
|
| Game entity | CDN folder |
|
||||||
|
|---|---|
|
||||||
|
| Zones | `zones` |
|
||||||
|
| Bosses | `bosses` |
|
||||||
|
| Quests | `quests` |
|
||||||
|
| Adventurers | `adventurers` |
|
||||||
|
| Companions | `companions` |
|
||||||
|
| Equipment | `equipment` |
|
||||||
|
| Upgrades | `upgrades` |
|
||||||
|
| Prestige upgrades | `prestige-upgrades` |
|
||||||
|
| Transcendence upgrades | `transcendence-upgrades` |
|
||||||
|
| Achievements | `achievements` |
|
||||||
|
| Explorations | `explorations` |
|
||||||
|
| Materials | `materials` |
|
||||||
|
| Recipes | `recipes` |
|
||||||
|
| Story chapter banners | `story-chapters` |
|
||||||
|
|
||||||
|
### API Rate Limits
|
||||||
|
- 250 images/day per API key — use a second key if quota is hit
|
||||||
|
- Free-tier keys cannot use `gemini-3-pro-image-preview`; key must be on a billing-linked project
|
||||||
|
|
||||||
## About Page
|
## About Page
|
||||||
|
|
||||||
The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature.
|
The About page (`apps/web/src/components/game/aboutPanel.tsx`) contains a **How to Play** guide that should be kept up to date as new features are added to the game. When implementing new game systems, zones, mechanics, or significant UI features, update the `HOW_TO_PLAY` array in `aboutPanel.tsx` to include a description of the new feature.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Achievement } from "@elysium/types";
|
import type { Achievement } from "@elysium/types";
|
||||||
|
|
||||||
@@ -76,7 +77,11 @@ const AchievementCard = ({
|
|||||||
<div className={`achievement-card ${isUnlocked
|
<div className={`achievement-card ${isUnlocked
|
||||||
? "unlocked"
|
? "unlocked"
|
||||||
: "locked"}`}>
|
: "locked"}`}>
|
||||||
<div className="achievement-icon">{achievement.icon}</div>
|
<img
|
||||||
|
alt={achievement.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("achievements", achievement.id)}
|
||||||
|
/>
|
||||||
<div className="achievement-info">
|
<div className="achievement-info">
|
||||||
<h3>{achievement.name}</h3>
|
<h3>{achievement.name}</h3>
|
||||||
<p>{achievement.description}</p>
|
<p>{achievement.description}</p>
|
||||||
|
|||||||
@@ -9,18 +9,10 @@
|
|||||||
/* eslint-disable complexity -- Complex component with many render paths */
|
/* eslint-disable complexity -- Complex component with many render paths */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Adventurer } from "@elysium/types";
|
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";
|
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||||
const batchOptions: Array<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}`
|
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
||||||
: "🔒 Locked";
|
: "🔒 Locked";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
|
|
||||||
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`adventurer-card ${adventurer.unlocked
|
<div className={`adventurer-card ${adventurer.unlocked
|
||||||
? ""
|
? ""
|
||||||
: "locked"}`}>
|
: "locked"}`}>
|
||||||
<div className="adventurer-icon">{adventurerIcon}</div>
|
<img
|
||||||
|
alt={adventurer.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("adventurers", adventurer.id)}
|
||||||
|
/>
|
||||||
<div className="adventurer-info">
|
<div className="adventurer-info">
|
||||||
<h3>{adventurer.name}</h3>
|
<h3>{adventurer.name}</h3>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Boss, GameState } from "@elysium/types";
|
import type { Boss, GameState } from "@elysium/types";
|
||||||
@@ -56,6 +57,11 @@ const BossCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`boss-card boss-${boss.status}`}>
|
<div className={`boss-card boss-${boss.status}`}>
|
||||||
|
<img
|
||||||
|
alt={boss.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("bosses", boss.id)}
|
||||||
|
/>
|
||||||
<div className="boss-info">
|
<div className="boss-info">
|
||||||
<h3>{boss.name}</h3>
|
<h3>{boss.name}</h3>
|
||||||
<p>{boss.description}</p>
|
<p>{boss.description}</p>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { CodexEntry } from "@elysium/types";
|
import type { CodexEntry } from "@elysium/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,18 @@ const sourceBadge: Record<CodexEntry["sourceType"], string> = {
|
|||||||
zone: "🗺️",
|
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.
|
* Renders the codex panel with lore entries grouped by zone.
|
||||||
* @returns The JSX element.
|
* @returns The JSX element.
|
||||||
@@ -155,7 +168,17 @@ const CodexPanel = (): JSX.Element => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded
|
{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}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
||||||
import { COMPANIONS, type Companion } from "@elysium/types";
|
import { COMPANIONS, type Companion } from "@elysium/types";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
const bonusLabels: Record<string, string> = {
|
const bonusLabels: Record<string, string> = {
|
||||||
@@ -96,6 +97,11 @@ const CompanionCard = ({
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
>
|
>
|
||||||
<div className="companion-header">
|
<div className="companion-header">
|
||||||
|
<img
|
||||||
|
alt={companion.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("companions", companion.id)}
|
||||||
|
/>
|
||||||
<div className="companion-name-block">
|
<div className="companion-name-block">
|
||||||
<span className="companion-name">{companion.name}</span>
|
<span className="companion-name">{companion.name}</span>
|
||||||
<span className="companion-title">{companion.title}</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 { useGame } from "../../context/gameContext.js";
|
||||||
import { MATERIALS } from "../../data/materials.js";
|
import { MATERIALS } from "../../data/materials.js";
|
||||||
import { RECIPES } from "../../data/recipes.js";
|
import { RECIPES } from "../../data/recipes.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
|
|
||||||
const bonusLabel: Record<string, string> = {
|
const bonusLabel: Record<string, string> = {
|
||||||
@@ -105,6 +106,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
}`}
|
}`}
|
||||||
key={material.id}
|
key={material.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={material.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("materials", material.id)}
|
||||||
|
/>
|
||||||
<div className="material-info">
|
<div className="material-info">
|
||||||
<span className="material-name">{material.name}</span>
|
<span className="material-name">{material.name}</span>
|
||||||
<span className="material-rarity">{material.rarity}</span>
|
<span className="material-rarity">{material.rarity}</span>
|
||||||
@@ -144,6 +150,11 @@ const CraftingPanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={recipe.id}
|
key={recipe.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={recipe.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("recipes", recipe.id)}
|
||||||
|
/>
|
||||||
<div className="recipe-info">
|
<div className="recipe-info">
|
||||||
<h4>{recipe.name}</h4>
|
<h4>{recipe.name}</h4>
|
||||||
<p className="recipe-description">{recipe.description}</p>
|
<p className="recipe-description">{recipe.description}</p>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||||
|
|
||||||
@@ -20,12 +21,6 @@ const rarityLabel: Record<string, string> = {
|
|||||||
rare: "Rare",
|
rare: "Rare",
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeIcon: Record<EquipmentType, string> = {
|
|
||||||
armour: "🛡️",
|
|
||||||
trinket: "💍",
|
|
||||||
weapon: "⚔️",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes a human-readable bonus description for a piece of equipment.
|
* Computes a human-readable bonus description for a piece of equipment.
|
||||||
* @param item - The equipment item.
|
* @param item - The equipment item.
|
||||||
@@ -128,7 +123,11 @@ const EquipmentCard = ({
|
|||||||
<div
|
<div
|
||||||
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
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-info">
|
||||||
<div className="equipment-name-row">
|
<div className="equipment-name-row">
|
||||||
<h3>{item.name}</h3>
|
<h3>{item.name}</h3>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { ExploreCollectResponse } from "@elysium/types";
|
import type { ExploreCollectResponse } from "@elysium/types";
|
||||||
|
|
||||||
@@ -230,6 +231,11 @@ const ExplorationPanel = (): JSX.Element => {
|
|||||||
className={`exploration-card exploration-${status}`}
|
className={`exploration-card exploration-${status}`}
|
||||||
key={area.id}
|
key={area.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={area.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("explorations", area.id)}
|
||||||
|
/>
|
||||||
<div className="exploration-info">
|
<div className="exploration-info">
|
||||||
<h3>
|
<h3>
|
||||||
{area.name}
|
{area.name}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
PRESTIGE_UPGRADES,
|
PRESTIGE_UPGRADES,
|
||||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||||
} from "../../data/prestigeUpgrades.js";
|
} from "../../data/prestigeUpgrades.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { sendNotification } from "../../utils/notification.js";
|
import { sendNotification } from "../../utils/notification.js";
|
||||||
import { playSound } from "../../utils/sound.js";
|
import { playSound } from "../../utils/sound.js";
|
||||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||||
@@ -366,6 +367,11 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={upgrade.id}
|
key={upgrade.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("prestige-upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="shop-upgrade-info">
|
<div className="shop-upgrade-info">
|
||||||
<h4>{upgrade.name}</h4>
|
<h4>{upgrade.name}</h4>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
/* eslint-disable max-statements -- Many local variables needed for quest state */
|
||||||
import { useState, type JSX } from "react";
|
import { useState, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import { ZoneSelector } from "./zoneSelector.js";
|
import { ZoneSelector } from "./zoneSelector.js";
|
||||||
import type { Quest } from "@elysium/types";
|
import type { Quest } from "@elysium/types";
|
||||||
@@ -81,6 +82,11 @@ const QuestCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`quest-card quest-${quest.status}`}>
|
<div className={`quest-card quest-${quest.status}`}>
|
||||||
|
<img
|
||||||
|
alt={quest.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("quests", quest.id)}
|
||||||
|
/>
|
||||||
<div className="quest-info">
|
<div className="quest-info">
|
||||||
<h3>{quest.name}</h3>
|
<h3>{quest.name}</h3>
|
||||||
<p>{quest.description}</p>
|
<p>{quest.description}</p>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { STORY_CHAPTERS } from "@elysium/types";
|
import { STORY_CHAPTERS } from "@elysium/types";
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Substitutes the character name placeholder in story text.
|
* Substitutes the character name placeholder in story text.
|
||||||
@@ -102,6 +103,11 @@ const StoryPanel = (): JSX.Element => {
|
|||||||
: <div className="story-chapter-view">
|
: <div className="story-chapter-view">
|
||||||
{isUnlocked
|
{isUnlocked
|
||||||
? <>
|
? <>
|
||||||
|
<img
|
||||||
|
alt={activeChapter.title}
|
||||||
|
className="story-chapter-banner"
|
||||||
|
src={cdnImage("story-chapters", activeChapter.id)}
|
||||||
|
/>
|
||||||
<h2 className="story-chapter-title">
|
<h2 className="story-chapter-title">
|
||||||
{"Chapter "}
|
{"Chapter "}
|
||||||
{activeChapterIndex + 1}
|
{activeChapterIndex + 1}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||||
/* eslint-disable complexity -- Many conditional render paths */
|
/* eslint-disable complexity -- Many conditional render paths */
|
||||||
/* eslint-disable max-statements -- Transcendence panel manages many local state variables */
|
/* 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 { useState, type JSX } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
import {
|
import {
|
||||||
TRANSCENDENCE_UPGRADES,
|
TRANSCENDENCE_UPGRADES,
|
||||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||||
} from "../../data/transcendenceUpgrades.js";
|
} from "../../data/transcendenceUpgrades.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||||
|
|
||||||
const echoFormulaConstant = 853;
|
const echoFormulaConstant = 853;
|
||||||
@@ -301,6 +303,11 @@ const TranscendencePanel = (): JSX.Element => {
|
|||||||
: ""}`}
|
: ""}`}
|
||||||
key={upgrade.id}
|
key={upgrade.id}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("transcendence-upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="shop-upgrade-info">
|
<div className="shop-upgrade-info">
|
||||||
<h4>{upgrade.name}</h4>
|
<h4>{upgrade.name}</h4>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
|
||||||
import { type JSX, useState } from "react";
|
import { type JSX, useState } from "react";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import { LockToggle } from "../ui/lockToggle.js";
|
import { LockToggle } from "../ui/lockToggle.js";
|
||||||
import type { Upgrade } from "@elysium/types";
|
import type { Upgrade } from "@elysium/types";
|
||||||
|
|
||||||
@@ -53,6 +54,11 @@ const UpgradeCard = ({
|
|||||||
if (upgrade.unlocked && upgrade.purchased) {
|
if (upgrade.unlocked && upgrade.purchased) {
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card purchased">
|
<div className="upgrade-card purchased">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<span className="upgrade-name">
|
<span className="upgrade-name">
|
||||||
{"✅ "}
|
{"✅ "}
|
||||||
{upgrade.name}
|
{upgrade.name}
|
||||||
@@ -65,6 +71,11 @@ const UpgradeCard = ({
|
|||||||
if (upgrade.unlocked) {
|
if (upgrade.unlocked) {
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card">
|
<div className="upgrade-card">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="upgrade-info">
|
<div className="upgrade-info">
|
||||||
<h3>{upgrade.name}</h3>
|
<h3>{upgrade.name}</h3>
|
||||||
<p>{upgrade.description}</p>
|
<p>{upgrade.description}</p>
|
||||||
@@ -108,6 +119,11 @@ const UpgradeCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="upgrade-card locked">
|
<div className="upgrade-card locked">
|
||||||
|
<img
|
||||||
|
alt={upgrade.name}
|
||||||
|
className="card-thumbnail"
|
||||||
|
src={cdnImage("upgrades", upgrade.id)}
|
||||||
|
/>
|
||||||
<div className="upgrade-info">
|
<div className="upgrade-info">
|
||||||
<h3>
|
<h3>
|
||||||
{"🔒 "}
|
{"🔒 "}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
import { cdnImage } from "../../utils/cdn.js";
|
||||||
import type { Zone } from "@elysium/types";
|
import type { Zone } from "@elysium/types";
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
@@ -44,7 +45,11 @@ const ZoneSelector = ({
|
|||||||
title={zone.description}
|
title={zone.description}
|
||||||
type="button"
|
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>
|
<span className="zone-name">{zone.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
+44
-2
@@ -33,6 +33,20 @@ body {
|
|||||||
color: var(--colour-text);
|
color: var(--colour-text);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
min-height: 100vh;
|
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 ===================== */
|
/* ===================== RESOURCE BAR ===================== */
|
||||||
@@ -2056,8 +2070,11 @@ body {
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zone-emoji {
|
.zone-tab-image {
|
||||||
font-size: 1.4rem;
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zone-name {
|
.zone-name {
|
||||||
@@ -4465,3 +4482,28 @@ body {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.5;
|
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