generated from nhcarrigan/template
feat: initial prototype — core game systems (#30)
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* @file Character sheet panel for viewing and editing the player's character.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many fields */
|
||||
/* eslint-disable complexity -- Many conditional render paths for optional fields */
|
||||
/* eslint-disable max-statements -- Component requires many state declarations */
|
||||
/* eslint-disable max-lines -- Large component with editing and view modes */
|
||||
import {
|
||||
DEFAULT_PROFILE_SETTINGS,
|
||||
STORY_CHAPTERS,
|
||||
type EquipmentBonus,
|
||||
type EquipmentRarity,
|
||||
type EquipmentType,
|
||||
type ProfileSettings,
|
||||
} from "@elysium/types";
|
||||
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
||||
import { updateProfile } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
|
||||
interface EquippedItem {
|
||||
name: string;
|
||||
type: EquipmentType;
|
||||
rarity: EquipmentRarity;
|
||||
bonus: EquipmentBonus;
|
||||
}
|
||||
|
||||
interface CharacterSheetData {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
characterRace: string;
|
||||
characterClass: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
activeTitle: string;
|
||||
unlockedTitles: Array<{ id: string; name: string }>;
|
||||
equippedItems: Array<EquippedItem>;
|
||||
}
|
||||
|
||||
const emptySheet: CharacterSheetData = {
|
||||
activeTitle: "",
|
||||
bio: "",
|
||||
characterClass: "",
|
||||
characterName: "",
|
||||
characterRace: "",
|
||||
equippedItems: [],
|
||||
guildDescription: "",
|
||||
guildName: "",
|
||||
pronouns: "",
|
||||
unlockedTitles: [],
|
||||
};
|
||||
|
||||
const slotIcons: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an equipment bonus as a human-readable string.
|
||||
* @param bonus - The equipment bonus to format.
|
||||
* @returns The formatted bonus string.
|
||||
*/
|
||||
const formatBonus = (bonus: EquipmentBonus): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold Income`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat Power`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click Power`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the character sheet panel for viewing and editing player profile.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const CharacterSheetPanel = (): JSX.Element => {
|
||||
const { state, loginStreak } = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
|
||||
const [ draft, setDraft ] = useState<CharacterSheetData>(emptySheet);
|
||||
const [ editing, setEditing ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ saving, setSaving ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ saved, setSaved ] = useState(false);
|
||||
const [ copied, setCopied ] = useState(false);
|
||||
const savedSettingsReference = useRef<ProfileSettings>({
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (player?.discordId === undefined || player.discordId === "") {
|
||||
return;
|
||||
}
|
||||
fetch(`/api/profile/${player.discordId}`).
|
||||
then(async(response) => {
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||
const data = (await response.json()) as {
|
||||
characterName: string;
|
||||
pronouns: string;
|
||||
characterRace: string;
|
||||
characterClass: string;
|
||||
bio: string;
|
||||
guildName: string;
|
||||
guildDescription: string;
|
||||
profileSettings: ProfileSettings;
|
||||
activeTitle: string;
|
||||
unlockedTitles: Array<{ id: string; name: string }>;
|
||||
equippedItems: Array<EquippedItem>;
|
||||
};
|
||||
const loaded: CharacterSheetData = {
|
||||
activeTitle: data.activeTitle,
|
||||
bio: data.bio,
|
||||
characterClass: data.characterClass,
|
||||
characterName: data.characterName,
|
||||
characterRace: data.characterRace,
|
||||
equippedItems: data.equippedItems,
|
||||
guildDescription: data.guildDescription,
|
||||
guildName: data.guildName,
|
||||
pronouns: data.pronouns,
|
||||
unlockedTitles: data.unlockedTitles,
|
||||
};
|
||||
setSheet(loaded);
|
||||
setDraft(loaded);
|
||||
savedSettingsReference.current = {
|
||||
...DEFAULT_PROFILE_SETTINGS,
|
||||
...data.profileSettings,
|
||||
};
|
||||
}).
|
||||
catch(() => {
|
||||
|
||||
/* Fall back to empty */
|
||||
}).
|
||||
finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [ player?.discordId ]);
|
||||
|
||||
function handleEdit(): void {
|
||||
setDraft({ ...sheet });
|
||||
setEditing(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const characterName
|
||||
= draft.characterName === ""
|
||||
? player?.characterName ?? ""
|
||||
: draft.characterName;
|
||||
await updateProfile({
|
||||
activeTitle: draft.activeTitle,
|
||||
bio: draft.bio,
|
||||
characterClass: draft.characterClass,
|
||||
characterName: characterName,
|
||||
characterRace: draft.characterRace,
|
||||
guildDescription: draft.guildDescription,
|
||||
guildName: draft.guildName,
|
||||
profileSettings: savedSettingsReference.current,
|
||||
pronouns: draft.pronouns,
|
||||
});
|
||||
setSheet({ ...draft });
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setEditing(false);
|
||||
setSaved(false);
|
||||
}, 900);
|
||||
} catch (error_) {
|
||||
setError(error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveClick(): void {
|
||||
void handleSave();
|
||||
}
|
||||
|
||||
function handleShareClick(): void {
|
||||
const discordId = player?.discordId ?? "";
|
||||
const url = `${window.location.origin}/character/${discordId}`;
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterName: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handlePronounsChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, pronouns: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleRaceChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterRace: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleClassChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, characterClass: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, bio: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleTitleChange(event: ChangeEvent<HTMLSelectElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, activeTitle: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleGuildNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, guildName: value };
|
||||
});
|
||||
}
|
||||
|
||||
function handleGuildDescChange(
|
||||
event: ChangeEvent<HTMLTextAreaElement>,
|
||||
): void {
|
||||
const { value } = event.target;
|
||||
setDraft((current) => {
|
||||
return { ...current, guildDescription: value };
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading character sheet…"}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
const isSaveDisabled = saving || draft.characterName.trim() === "";
|
||||
let saveLabel = "Save";
|
||||
if (saving) {
|
||||
saveLabel = "Saving…";
|
||||
}
|
||||
if (saved) {
|
||||
saveLabel = "✓ Saved!";
|
||||
}
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"📋 Character Sheet"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-form">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"⚔️ Character"}</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-name">
|
||||
{"Character Name"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-name"
|
||||
maxLength={32}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Your character's name"
|
||||
type="text"
|
||||
value={draft.characterName}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterName.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-pronouns">
|
||||
{"Pronouns"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-pronouns"
|
||||
maxLength={20}
|
||||
onChange={handlePronounsChange}
|
||||
placeholder="e.g. she/her, he/him, they/them"
|
||||
type="text"
|
||||
value={draft.pronouns}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.pronouns.length}
|
||||
{" / 20"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-race">
|
||||
{"Race"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-race"
|
||||
maxLength={32}
|
||||
onChange={handleRaceChange}
|
||||
placeholder="e.g. Elf, Dwarf, Human, Tiefling…"
|
||||
type="text"
|
||||
value={draft.characterRace}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterRace.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-class">
|
||||
{"Class"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-class"
|
||||
maxLength={32}
|
||||
onChange={handleClassChange}
|
||||
placeholder="e.g. Paladin, Archmage, Shadow Rogue…"
|
||||
type="text"
|
||||
value={draft.characterClass}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.characterClass.length}
|
||||
{" / 32"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-bio">
|
||||
{"About Your Character"}
|
||||
</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-bio"
|
||||
maxLength={200}
|
||||
onChange={handleBioChange}
|
||||
placeholder={
|
||||
"Describe your character's story, personality, or appearance…"
|
||||
}
|
||||
rows={4}
|
||||
value={draft.bio}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.bio.length}
|
||||
{" / 200"}
|
||||
</span>
|
||||
|
||||
{draft.unlockedTitles.length > 0
|
||||
&& <>
|
||||
<label className="character-sheet-label" htmlFor="cs-title">
|
||||
{"Active Title"}
|
||||
</label>
|
||||
<select
|
||||
className="character-sheet-input"
|
||||
id="cs-title"
|
||||
onChange={handleTitleChange}
|
||||
value={draft.activeTitle}
|
||||
>
|
||||
<option value="">{"— None —"}</option>
|
||||
{draft.unlockedTitles.map((title) => {
|
||||
return (
|
||||
<option key={title.id} value={title.id}>
|
||||
{title.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"🏰 Guild"}</h3>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-name">
|
||||
{"Guild Name"}
|
||||
</label>
|
||||
<input
|
||||
className="character-sheet-input"
|
||||
id="cs-guild-name"
|
||||
maxLength={64}
|
||||
onChange={handleGuildNameChange}
|
||||
placeholder="Name your guild"
|
||||
type="text"
|
||||
value={draft.guildName}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.guildName.length}
|
||||
{" / 64"}
|
||||
</span>
|
||||
|
||||
<label className="character-sheet-label" htmlFor="cs-guild-desc">
|
||||
{"Guild Description"}
|
||||
</label>
|
||||
<textarea
|
||||
className="character-sheet-textarea"
|
||||
id="cs-guild-desc"
|
||||
maxLength={500}
|
||||
onChange={handleGuildDescChange}
|
||||
placeholder="Describe your guild's history, goals, or lore…"
|
||||
rows={6}
|
||||
value={draft.guildDescription}
|
||||
/>
|
||||
<span className="character-sheet-hint">
|
||||
{draft.guildDescription.length}
|
||||
{" / 500"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error === null
|
||||
? null
|
||||
: <p className="character-sheet-error">{error}</p>
|
||||
}
|
||||
|
||||
<div className="character-sheet-actions">
|
||||
<button
|
||||
className="character-sheet-cancel"
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
className="character-sheet-save"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSaveClick}
|
||||
type="button"
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const subtitleParts = [ sheet.characterRace, sheet.characterClass ].filter(
|
||||
(part) => {
|
||||
return part !== "";
|
||||
},
|
||||
);
|
||||
const subtitle = subtitleParts.join(" · ");
|
||||
|
||||
const completedChapters = state?.story?.completedChapters ?? [];
|
||||
|
||||
return (
|
||||
<section className="panel character-sheet-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"📋 Character Sheet"}</h2>
|
||||
<div className="character-sheet-header-actions">
|
||||
<button
|
||||
className="character-sheet-edit-btn"
|
||||
onClick={handleShareClick}
|
||||
type="button"
|
||||
>
|
||||
{copied
|
||||
? "✓ Copied!"
|
||||
: "🔗 Share"}
|
||||
</button>
|
||||
<a className="character-sheet-edit-btn" href="/leaderboards">
|
||||
{"🏆 Boards"}
|
||||
</a>
|
||||
<button
|
||||
className="character-sheet-edit-btn"
|
||||
onClick={handleEdit}
|
||||
type="button"
|
||||
>
|
||||
{"✏️ Edit"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-view">
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"⚔️ Character"}</h3>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Name"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.characterName === ""
|
||||
? <em className="character-sheet-empty">{"Not set"}</em>
|
||||
: sheet.characterName
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Streak"}</span>
|
||||
<span className="character-sheet-streak">
|
||||
{"🔥 "}
|
||||
{loginStreak}
|
||||
{"-day login streak"}
|
||||
</span>
|
||||
</div>
|
||||
{sheet.activeTitle === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Title"}</span>
|
||||
<span
|
||||
className={"character-sheet-field-value character-sheet-title"}
|
||||
>
|
||||
{sheet.unlockedTitles.find((title) => {
|
||||
return title.id === sheet.activeTitle;
|
||||
})?.name ?? sheet.activeTitle}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{sheet.pronouns === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Pronouns"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.pronouns}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
{subtitle === ""
|
||||
? null
|
||||
: <div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Identity"}</span>
|
||||
<span className="character-sheet-field-value">{subtitle}</span>
|
||||
</div>
|
||||
}
|
||||
{sheet.bio === ""
|
||||
? null
|
||||
: <div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">{"About"}</span>
|
||||
<p className="character-sheet-bio-text">{sheet.bio}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3>
|
||||
{sheet.equippedItems.length > 0
|
||||
? <div className="character-sheet-equipment-list">
|
||||
{sheet.equippedItems.map((item) => {
|
||||
return (
|
||||
<div
|
||||
className="character-sheet-equipment-item"
|
||||
key={item.type}
|
||||
>
|
||||
<div className="character-sheet-equipment-header">
|
||||
<span className="character-sheet-equipment-slot">
|
||||
{slotIcons[item.type]}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"character-sheet-equipment-name"
|
||||
+ ` character-sheet-rarity--${item.rarity}`
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"character-sheet-equipment-rarity"
|
||||
+ ` character-sheet-rarity--${item.rarity}`
|
||||
}
|
||||
>
|
||||
{item.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="character-sheet-equipment-bonus">
|
||||
{formatBonus(item.bonus)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: <p className="character-sheet-empty">
|
||||
{"No equipment found. Defeat bosses to earn gear!"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">{"🏰 Guild"}</h3>
|
||||
{sheet.guildName === ""
|
||||
? <p className="character-sheet-empty">
|
||||
{"No guild registered yet. Click ✏️ Edit to add one!"}
|
||||
</p>
|
||||
: <>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">{"Name"}</span>
|
||||
<span className="character-sheet-field-value">
|
||||
{sheet.guildName}
|
||||
</span>
|
||||
</div>
|
||||
{sheet.guildDescription === ""
|
||||
? null
|
||||
: <div className="character-sheet-bio">
|
||||
<span className="character-sheet-field-label">{"Lore"}</span>
|
||||
<p className="character-sheet-bio-text">
|
||||
{sheet.guildDescription}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
|
||||
{completedChapters.length === 0
|
||||
? null
|
||||
: <div className="character-sheet-section">
|
||||
<h3 className="character-sheet-section-title">
|
||||
{"📖 Story Choices"}
|
||||
</h3>
|
||||
{completedChapters.map((completion) => {
|
||||
const chapter = STORY_CHAPTERS.find((candidate) => {
|
||||
return candidate.id === completion.chapterId;
|
||||
});
|
||||
if (chapter === undefined) {
|
||||
return null;
|
||||
}
|
||||
const choice = chapter.choices.find((candidate) => {
|
||||
return candidate.id === completion.choiceId;
|
||||
});
|
||||
if (choice === undefined) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="character-sheet-story-entry"
|
||||
key={completion.chapterId}
|
||||
>
|
||||
<span className="character-sheet-story-chapter">
|
||||
{chapter.title}
|
||||
</span>
|
||||
<span className="character-sheet-story-choice">
|
||||
{choice.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { CharacterSheetPanel };
|
||||
Reference in New Issue
Block a user