diff --git a/CLAUDE.md b/CLAUDE.md index 9df208f..a877cf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,41 @@ 2. `pnpm build` — all packages build cleanly 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=`, requesting soft-shaded anime style +2. Save responses to `/home/naomi/code/naomi/elysium/img//.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//.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 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. diff --git a/apps/web/src/components/game/achievementPanel.tsx b/apps/web/src/components/game/achievementPanel.tsx index 29205be..6bf9cab 100644 --- a/apps/web/src/components/game/achievementPanel.tsx +++ b/apps/web/src/components/game/achievementPanel.tsx @@ -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 = ({
-
{achievement.icon}
+ {achievement.name}

{achievement.name}

{achievement.description}

diff --git a/apps/web/src/components/game/adventurerPanel.tsx b/apps/web/src/components/game/adventurerPanel.tsx index 262f48f..efa7790 100644 --- a/apps/web/src/components/game/adventurerPanel.tsx +++ b/apps/web/src/components/game/adventurerPanel.tsx @@ -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 = { - cleric: "✝️", - mage: "🔮", - paladin: "🛡️", - ranger: "🏹", - rogue: "🗝️", - warrior: "🗡️", -}; - type BatchSize = 1 | 5 | 10 | 25 | 100 | "max"; const batchOptions: Array = [ 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 (
-
{adventurerIcon}
+ {adventurer.name}

{adventurer.name}

diff --git a/apps/web/src/components/game/bossPanel.tsx b/apps/web/src/components/game/bossPanel.tsx index 1490257..d3619cc 100644 --- a/apps/web/src/components/game/bossPanel.tsx +++ b/apps/web/src/components/game/bossPanel.tsx @@ -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 (

+ {boss.name}

{boss.name}

{boss.description}

diff --git a/apps/web/src/components/game/codexPanel.tsx b/apps/web/src/components/game/codexPanel.tsx index 32ddb75..74a1e9a 100644 --- a/apps/web/src/components/game/codexPanel.tsx +++ b/apps/web/src/components/game/codexPanel.tsx @@ -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 = { zone: "🗺️", }; +const sourceTypeFolder: Record = { + 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 => {
{isExpanded - ?

{entry.content}

+ ? <> + {entry.title} +

{entry.content}

+ : null}
); diff --git a/apps/web/src/components/game/companionPanel.tsx b/apps/web/src/components/game/companionPanel.tsx index 92d6432..f4dd476 100644 --- a/apps/web/src/components/game/companionPanel.tsx +++ b/apps/web/src/components/game/companionPanel.tsx @@ -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 = { @@ -96,6 +97,11 @@ const CompanionCard = ({ : ""}`} >
+ {companion.name}
{companion.name} {companion.title} diff --git a/apps/web/src/components/game/craftingPanel.tsx b/apps/web/src/components/game/craftingPanel.tsx index 584c710..86ddcb4 100644 --- a/apps/web/src/components/game/craftingPanel.tsx +++ b/apps/web/src/components/game/craftingPanel.tsx @@ -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 = { @@ -105,6 +106,11 @@ const CraftingPanel = (): JSX.Element => { }`} key={material.id} > + {material.name}
{material.name} {material.rarity} @@ -144,6 +150,11 @@ const CraftingPanel = (): JSX.Element => { : ""}`} key={recipe.id} > + {recipe.name}

{recipe.name}

{recipe.description}

diff --git a/apps/web/src/components/game/equipmentPanel.tsx b/apps/web/src/components/game/equipmentPanel.tsx index dc6c4e9..376df27 100644 --- a/apps/web/src/components/game/equipmentPanel.tsx +++ b/apps/web/src/components/game/equipmentPanel.tsx @@ -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 = { rare: "Rare", }; -const typeIcon: Record = { - 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 = ({
-
{typeIcon[item.type]}
+ {item.name}

{item.name}

diff --git a/apps/web/src/components/game/explorationPanel.tsx b/apps/web/src/components/game/explorationPanel.tsx index eb6744d..03ce4c0 100644 --- a/apps/web/src/components/game/explorationPanel.tsx +++ b/apps/web/src/components/game/explorationPanel.tsx @@ -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} > + {area.name}

{area.name} diff --git a/apps/web/src/components/game/prestigePanel.tsx b/apps/web/src/components/game/prestigePanel.tsx index e834518..8bfdf0f 100644 --- a/apps/web/src/components/game/prestigePanel.tsx +++ b/apps/web/src/components/game/prestigePanel.tsx @@ -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} > + {upgrade.name}

{upgrade.name}

{upgrade.description}

diff --git a/apps/web/src/components/game/questPanel.tsx b/apps/web/src/components/game/questPanel.tsx index 442122e..3aafb03 100644 --- a/apps/web/src/components/game/questPanel.tsx +++ b/apps/web/src/components/game/questPanel.tsx @@ -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 (
+ {quest.name}

{quest.name}

{quest.description}

diff --git a/apps/web/src/components/game/storyPanel.tsx b/apps/web/src/components/game/storyPanel.tsx index e872f9b..9bdecf2 100644 --- a/apps/web/src/components/game/storyPanel.tsx +++ b/apps/web/src/components/game/storyPanel.tsx @@ -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 => { :
{isUnlocked ? <> + {activeChapter.title}

{"Chapter "} {activeChapterIndex + 1} diff --git a/apps/web/src/components/game/transcendencePanel.tsx b/apps/web/src/components/game/transcendencePanel.tsx index aaa362e..8ff0645 100644 --- a/apps/web/src/components/game/transcendencePanel.tsx +++ b/apps/web/src/components/game/transcendencePanel.tsx @@ -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} > + {upgrade.name}

{upgrade.name}

{upgrade.description}

diff --git a/apps/web/src/components/game/upgradePanel.tsx b/apps/web/src/components/game/upgradePanel.tsx index d004bc7..f5d6ee8 100644 --- a/apps/web/src/components/game/upgradePanel.tsx +++ b/apps/web/src/components/game/upgradePanel.tsx @@ -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 (
+ {upgrade.name} {"✅ "} {upgrade.name} @@ -65,6 +71,11 @@ const UpgradeCard = ({ if (upgrade.unlocked) { return (
+ {upgrade.name}

{upgrade.name}

{upgrade.description}

@@ -108,6 +119,11 @@ const UpgradeCard = ({ return (
+ {upgrade.name}

{"🔒 "} diff --git a/apps/web/src/components/game/zoneSelector.tsx b/apps/web/src/components/game/zoneSelector.tsx index da2a439..254963c 100644 --- a/apps/web/src/components/game/zoneSelector.tsx +++ b/apps/web/src/components/game/zoneSelector.tsx @@ -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" > - {zone.emoji} + {zone.name} {zone.name} ); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 5c43aaa..4045209 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; +} diff --git a/apps/web/src/utils/cdn.ts b/apps/web/src/utils/cdn.ts new file mode 100644 index 0000000..56d3026 --- /dev/null +++ b/apps/web/src/utils/cdn.ts @@ -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 };