Files
elysium/apps/web/src/components/game/storyPanel.tsx
T
hikari 11e97325cb
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Lint, Build & Test (push) Successful in 1m6s
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>
2026-03-09 16:21:44 -07:00

186 lines
6.2 KiB
TypeScript

/**
* @file Story panel component displaying the main questline narrative.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Complex component with many conditional render paths */
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.
* @param text - The story text with placeholders.
* @param characterName - The player's character name.
* @returns The text with placeholders replaced.
*/
const substituteCharacterName = (
text: string,
characterName: string,
): string => {
const fallback = characterName === ""
? "the guild leader"
: characterName;
return text.replaceAll("{characterName}", fallback);
};
/**
* Renders the story panel with chapter navigation and content.
* @returns The JSX element.
*/
const StoryPanel = (): JSX.Element => {
const { state, completeChapter } = useGame();
const [ activeChapterIndex, setActiveChapterIndex ] = useState(0);
if (state === null) {
return (
<div className="story-panel">
<p>{"Loading…"}</p>
</div>
);
}
const unlockedIds = state.story?.unlockedChapterIds ?? [];
const completedChapters = state.story?.completedChapters ?? [];
const { characterName } = state.player;
const activeChapter = STORY_CHAPTERS[activeChapterIndex];
const isUnlocked = unlockedIds.includes(activeChapter?.id ?? "");
const completion
= activeChapter === undefined
? null
: completedChapters.find((completedChapter) => {
return completedChapter.chapterId === activeChapter.id;
}) ?? null;
const isUnread = isUnlocked && completion === null;
return (
<div className="story-panel">
<div className="story-chapter-tabs">
{STORY_CHAPTERS.map((chapter, index) => {
const unlocked = unlockedIds.includes(chapter.id);
const completed = completedChapters.some((completedChapter) => {
return completedChapter.chapterId === chapter.id;
});
const unread = unlocked && !completed;
function handleChapterSelect(): void {
setActiveChapterIndex(index);
}
return (
<button
aria-label={
unlocked
? chapter.title
: `Chapter ${String(index + 1)} (locked)`
}
className={[
"story-tab-btn",
activeChapterIndex === index
? "active"
: "",
unlocked
? ""
: "locked",
].join(" ")}
key={chapter.id}
onClick={handleChapterSelect}
type="button"
>
{index + 1}
{unread
? <span className="story-unread-dot" />
: null}
</button>
);
})}
</div>
{activeChapter === undefined
? null
: <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}
{": "}
{activeChapter.title}
</h2>
<div className="story-chapter-content">
{substituteCharacterName(activeChapter.content, characterName).
split("\n\n").
map((paragraph, paraIndex) => {
// eslint-disable-next-line react/no-array-index-key -- Static content paragraphs have no stable id
return <p key={paraIndex}>{paragraph}</p>;
})}
</div>
{completion === null && isUnread
? <div className="story-choices">
<p className="story-choices-prompt">{"What do you do?"}</p>
{activeChapter.choices.map((storyChoice) => {
const chapterForClosure = activeChapter;
function handleChoice(): void {
completeChapter(chapterForClosure.id, storyChoice.id);
}
return (
<button
className="story-choice-btn"
key={storyChoice.id}
onClick={handleChoice}
type="button"
>
{storyChoice.label}
</button>
);
})}
</div>
: null}
{completion === null
? null
: <div className="story-choice-result">
<p className="story-choice-label">
<strong>{"Your choice:"}</strong>{" "}
{
activeChapter.choices.find((storyChoice) => {
return storyChoice.id === completion.choiceId;
})?.label
}
</p>
<p className="story-choice-outcome">
{substituteCharacterName(
activeChapter.choices.find((storyChoice) => {
return storyChoice.id === completion.choiceId;
})?.outcome ?? "",
characterName,
)}
</p>
</div>
}
</>
: <div className="story-locked">
<p className="story-locked-title">
{"Chapter "}
{activeChapterIndex + 1}
</p>
<p className="story-locked-hint">
{"🔒 This chapter has not yet been unlocked."}
</p>
</div>
}
</div>
}
</div>
);
};
export { StoryPanel };