feat: v1 prototype — core game systems #30

Merged
naomi merged 84 commits from feat/prototype into main 2026-03-08 15:53:39 -07:00
6 changed files with 208 additions and 2 deletions
Showing only changes of commit bbdacf272c - Show all commits
+5
View File
@@ -75,6 +75,10 @@ profileRouter.get("/:discordId", async (context) => {
return { id, name: title?.name ?? id };
});
const equippedItems = (state?.equipment ?? [])
.filter((e) => e.owned && e.equipped)
.map(({ name, type, rarity, bonus }) => ({ name, type, rarity, bonus }));
return context.json({
characterName: player.characterName,
pronouns: player.pronouns ?? "",
@@ -106,6 +110,7 @@ profileRouter.get("/:discordId", async (context) => {
achievementsUnlocked,
unlockedTitles,
activeTitle: player.activeTitle ?? "",
equippedItems,
});
});
@@ -71,6 +71,10 @@ const HOW_TO_PLAY = [
title: "🏅 Titles",
body: "Earn Titles by reaching milestones — defeating bosses, completing quests, prestiging, and more. Once unlocked, titles are yours forever and are never lost on prestige or transcendence resets. Set your active title from the Character tab to display it on your character sheet and public profile.",
},
{
title: "🗡️ Equipment",
body: "Defeat bosses to earn equipment drops: weapons, armour, and trinkets. Each item provides bonuses to gold income, combat power, or click power. Only one item per slot can be equipped at a time — visit the Equipment panel to manage your loadout. Your currently equipped items are displayed on your character sheet and public profile.",
},
{
title: "☁️ Cloud Saves",
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
+43 -1
View File
@@ -1,4 +1,4 @@
import type { PublicProfileResponse } from "@elysium/types";
import type { EquipmentBonus, EquipmentType, PublicProfileResponse } from "@elysium/types";
import { useEffect, useState } from "react";
interface CharacterPageProps {
@@ -48,6 +48,26 @@ export const CharacterPage = ({ discordId }: CharacterPageProps): React.JSX.Elem
);
}
const SLOT_ICONS: Record<EquipmentType, string> = {
weapon: "⚔️",
armour: "🛡️",
trinket: "💍",
};
const formatBonus = (bonus: EquipmentBonus): string => {
const parts: string[] = [];
if (bonus.goldMultiplier !== undefined) {
parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold Income`);
}
if (bonus.combatMultiplier !== undefined) {
parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat Power`);
}
if (bonus.clickMultiplier !== undefined) {
parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click Power`);
}
return parts.join(" · ");
};
const avatarUrl = profile.avatar
? `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`
: `https://cdn.discordapp.com/embed/avatars/${parseInt(discordId, 10) % 5}.png`;
@@ -119,6 +139,28 @@ export const CharacterPage = ({ discordId }: CharacterPageProps): React.JSX.Elem
</div>
)}
{profile.equippedItems.length > 0 && (
<div className="character-page-section">
<h2 className="character-page-section-title">🗡 Equipment</h2>
<div className="character-page-equipment-list">
{profile.equippedItems.map((item) => (
<div className="character-page-equipment-item" key={item.type}>
<div className="character-page-equipment-header">
<span className="character-page-equipment-slot">{SLOT_ICONS[item.type]}</span>
<span className={`character-page-equipment-name character-sheet-rarity--${item.rarity}`}>
{item.name}
</span>
<span className={`character-page-equipment-rarity character-sheet-rarity--${item.rarity}`}>
{item.rarity}
</span>
</div>
<p className="character-page-equipment-bonus">{formatBonus(item.bonus)}</p>
</div>
))}
</div>
</div>
)}
<div className="character-page-divider" />
<p className="character-page-player-line">
@@ -1,9 +1,16 @@
import type { ProfileSettings } from "@elysium/types";
import type { EquipmentBonus, EquipmentRarity, EquipmentType, ProfileSettings } from "@elysium/types";
import { DEFAULT_PROFILE_SETTINGS } from "@elysium/types";
import { 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;
@@ -14,6 +21,7 @@ interface CharacterSheetData {
guildDescription: string;
activeTitle: string;
unlockedTitles: Array<{ id: string; name: string }>;
equippedItems: EquippedItem[];
}
const EMPTY_SHEET: CharacterSheetData = {
@@ -26,6 +34,27 @@ const EMPTY_SHEET: CharacterSheetData = {
guildDescription: "",
activeTitle: "",
unlockedTitles: [],
equippedItems: [],
};
const SLOT_ICONS: Record<EquipmentType, string> = {
weapon: "⚔️",
armour: "🛡️",
trinket: "💍",
};
const formatBonus = (bonus: EquipmentBonus): string => {
const parts: string[] = [];
if (bonus.goldMultiplier !== undefined) {
parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold Income`);
}
if (bonus.combatMultiplier !== undefined) {
parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat Power`);
}
if (bonus.clickMultiplier !== undefined) {
parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click Power`);
}
return parts.join(" · ");
};
export const CharacterSheetPanel = (): React.JSX.Element => {
@@ -58,6 +87,7 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
profileSettings: ProfileSettings;
activeTitle: string;
unlockedTitles: Array<{ id: string; name: string }>;
equippedItems: EquippedItem[];
};
const loaded: CharacterSheetData = {
characterName: data.characterName ?? "",
@@ -69,6 +99,7 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
guildDescription: data.guildDescription ?? "",
activeTitle: data.activeTitle ?? "",
unlockedTitles: data.unlockedTitles ?? [],
equippedItems: data.equippedItems ?? [],
};
setSheet(loaded);
setDraft(loaded);
@@ -322,6 +353,30 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
)}
</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) => (
<div className="character-sheet-equipment-item" key={item.type}>
<div className="character-sheet-equipment-header">
<span className="character-sheet-equipment-slot">{SLOT_ICONS[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 ? (
+97
View File
@@ -3467,3 +3467,100 @@ body {
.character-page-link:hover {
opacity: 0.85;
}
/* ── Equipment rarity colours (shared by CharacterSheet + CharacterPage) ── */
.character-sheet-rarity--common {
color: var(--colour-text-muted);
}
.character-sheet-rarity--rare {
color: #4a9eff;
}
.character-sheet-rarity--epic {
color: #c084fc;
}
.character-sheet-rarity--legendary {
color: #f59e0b;
}
/* ── Character Sheet equipment section ── */
.character-sheet-equipment-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.character-sheet-equipment-item {
background: var(--colour-bg-alt);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
}
.character-sheet-equipment-header {
align-items: center;
display: flex;
gap: 0.4rem;
}
.character-sheet-equipment-slot {
font-size: 1rem;
}
.character-sheet-equipment-name {
flex: 1;
font-weight: 600;
}
.character-sheet-equipment-rarity {
font-size: 0.7rem;
font-style: italic;
text-transform: capitalize;
}
.character-sheet-equipment-bonus {
color: var(--colour-text-muted);
font-size: 0.78rem;
margin: 0.2rem 0 0;
}
/* ── Character Page equipment section ── */
.character-page-equipment-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.character-page-equipment-item {
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.character-page-equipment-header {
align-items: center;
display: flex;
gap: 0.4rem;
}
.character-page-equipment-slot {
font-size: 1rem;
}
.character-page-equipment-name {
flex: 1;
font-weight: 600;
}
.character-page-equipment-rarity {
font-size: 0.7rem;
font-style: italic;
text-transform: capitalize;
}
.character-page-equipment-bonus {
color: rgba(255, 255, 255, 0.5);
font-size: 0.78rem;
margin: 0.2rem 0 0;
}
+3
View File
@@ -1,3 +1,4 @@
import type { EquipmentBonus, EquipmentRarity, EquipmentType } from "./Equipment.js";
import type { GameState } from "./GameState.js";
import type { Player } from "./Player.js";
import type { ProfileSettings } from "./ProfileSettings.js";
@@ -122,6 +123,8 @@ export interface PublicProfileResponse {
unlockedTitles: Array<{ id: string; name: string }>;
/** The player's active title display name (empty string if none set) */
activeTitle: string;
/** Items the player currently has equipped */
equippedItems: Array<{ name: string; type: EquipmentType; rarity: EquipmentRarity; bonus: EquipmentBonus }>;
}
export interface UpdateProfileRequest {