feat: add equipment, achievements, and visual polish

- Equipment system: 12 items across weapon/armour/trinket slots with
  common/rare/epic/legendary rarities; starter commons auto-equipped,
  higher tiers drop from boss victories
- Achievement system: 15 milestones with typed conditions; checked
  each tick and crystal rewards applied automatically
- Achievement toast: slide-in notification, auto-dismisses after 4s
- Floating click text: +X gold floats on each manual click
- Expanded quests (9 total) and upgrades (12 total)
- Upgrade panel now shows locked upgrades so players can see their
  progression path
- formatNumber utility (K/M/B/T) used consistently across all panels
- Backfill logic for existing saves to add new content gracefully
- types package now emits .d.ts declarations
This commit is contained in:
2026-03-06 13:27:48 -08:00
committed by Naomi Carrigan
parent a3daed1683
commit e9e0df31fd
33 changed files with 2066 additions and 133 deletions
+6 -6
View File
@@ -1,7 +1,7 @@
import type {
AuthResponse,
BossDamageRequest,
BossDamageResponse,
BossChallengeRequest,
BossChallengeResponse,
LoadResponse,
PrestigeRequest,
PrestigeResponse,
@@ -61,10 +61,10 @@ export const saveGame = async (body: SaveRequest): Promise<SaveResponse> =>
body: JSON.stringify(body),
});
export const dealBossDamage = async (
body: BossDamageRequest,
): Promise<BossDamageResponse> =>
request<BossDamageResponse>("/boss/damage", {
export const challengeBoss = async (
body: BossChallengeRequest,
): Promise<BossChallengeResponse> =>
request<BossChallengeResponse>("/boss/challenge", {
method: "POST",
body: JSON.stringify(body),
});
@@ -0,0 +1,75 @@
import type { Achievement } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
import { formatNumber } from "../../utils/format.js";
const conditionDescription = (achievement: Achievement): string => {
const { condition } = achievement;
switch (condition.type) {
case "totalGoldEarned":
return `Earn ${formatNumber(condition.amount)} total gold`;
case "totalClicks":
return `Click ${formatNumber(condition.amount)} times`;
case "bossesDefeated":
return `Defeat ${condition.amount} boss${condition.amount > 1 ? "es" : ""}`;
case "questsCompleted":
return `Complete ${condition.amount} quest${condition.amount > 1 ? "s" : ""}`;
case "adventurerTotal":
return `Recruit ${formatNumber(condition.amount)} total adventurers`;
case "prestigeCount":
return `Prestige ${condition.amount} time${condition.amount > 1 ? "s" : ""}`;
case "equipmentOwned":
return `Own ${condition.amount} equipment item${condition.amount > 1 ? "s" : ""}`;
}
};
interface AchievementCardProps {
achievement: Achievement;
}
const AchievementCard = ({ achievement }: AchievementCardProps): React.JSX.Element => {
const isUnlocked = achievement.unlockedAt !== null;
return (
<div className={`achievement-card ${isUnlocked ? "unlocked" : "locked"}`}>
<div className="achievement-icon">{achievement.icon}</div>
<div className="achievement-info">
<h3>{achievement.name}</h3>
<p>{achievement.description}</p>
<p className="achievement-condition">{conditionDescription(achievement)}</p>
{achievement.reward?.crystals != null && (
<p className="achievement-reward">💎 +{achievement.reward.crystals} Crystals</p>
)}
</div>
<div className="achievement-status">
{isUnlocked ? (
<span className="achievement-unlocked-badge"> Unlocked</span>
) : (
<span className="achievement-locked-badge">🔒</span>
)}
</div>
</div>
);
};
export const AchievementPanel = (): React.JSX.Element => {
const { state } = useGame();
if (!state) return <section className="panel"><p>Loading...</p></section>;
const achievements = state.achievements ?? [];
const unlocked = achievements.filter((a) => a.unlockedAt !== null).length;
return (
<section className="panel achievement-panel">
<h2>Achievements</h2>
<p className="achievement-progress">
{unlocked} / {achievements.length} unlocked
</p>
<div className="achievement-list">
{achievements.map((achievement) => (
<AchievementCard key={achievement.id} achievement={achievement} />
))}
</div>
</section>
);
};
@@ -0,0 +1,48 @@
import { useEffect } from "react";
import type { Achievement } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
interface ToastItemProps {
achievement: Achievement;
onDismiss: (id: string) => void;
}
const ToastItem = ({ achievement, onDismiss }: ToastItemProps): React.JSX.Element => {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss(achievement.id);
}, 4000);
return () => { clearTimeout(timer); };
}, [achievement.id, onDismiss]);
return (
<div className="achievement-toast" onClick={() => { onDismiss(achievement.id); }}>
<span className="toast-icon">{achievement.icon}</span>
<div className="toast-content">
<span className="toast-label">Achievement Unlocked!</span>
<span className="toast-name">{achievement.name}</span>
{achievement.reward?.crystals != null && (
<span className="toast-reward">💎 +{achievement.reward.crystals}</span>
)}
</div>
</div>
);
};
export const AchievementToast = (): React.JSX.Element | null => {
const { newAchievements, dismissAchievement } = useGame();
if (newAchievements.length === 0) return null;
return (
<div className="achievement-toast-container">
{newAchievements.map((achievement) => (
<ToastItem
key={achievement.id}
achievement={achievement}
onDismiss={dismissAchievement}
/>
))}
</div>
);
};
@@ -0,0 +1,171 @@
import type { BattleResult } from "../../context/GameContext.js";
import { useEffect, useState } from "react";
interface BattleModalProps {
battle: BattleResult;
onDismiss: () => void;
}
const formatNumber = (n: number): string => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return Math.floor(n).toLocaleString();
};
export const BattleModal = ({
battle,
onDismiss,
}: BattleModalProps): React.JSX.Element => {
const { result, bossName } = battle;
const [phase, setPhase] = useState<"animating" | "result">("animating");
// Starting HP percentages
const bossStartPercent = (result.bossHpBefore / result.bossMaxHp) * 100;
const partyStartPercent = 100;
// Target HP percentages (after battle)
const bossEndPercent = (result.bossHpAtBattleEnd / result.bossMaxHp) * 100;
const partyEndPercent = result.partyMaxHp > 0
? (result.partyHpRemaining / result.partyMaxHp) * 100
: 0;
const [bossHpPercent, setBossHpPercent] = useState(bossStartPercent);
const [partyHpPercent, setPartyHpPercent] = useState(partyStartPercent);
useEffect(() => {
// Brief delay so CSS transition has a starting point to animate from
const startAnimation = setTimeout(() => {
setBossHpPercent(bossEndPercent);
setPartyHpPercent(partyEndPercent);
}, 200);
// Reveal result after animation completes
const revealResult = setTimeout(() => {
setPhase("result");
}, 5_200);
return () => {
clearTimeout(startAnimation);
clearTimeout(revealResult);
};
}, [bossEndPercent, partyEndPercent]);
const bossHpBarColour = bossHpPercent > 50
? "#e74c3c"
: bossHpPercent > 25
? "#e67e22"
: "#c0392b";
const partyHpBarColour = partyHpPercent > 50
? "#27ae60"
: partyHpPercent > 25
? "#f39c12"
: "#e74c3c";
return (
<div className="modal-overlay">
<div className="modal battle-modal">
<h2> Battle: {bossName}</h2>
<div className="battle-stats">
<div className="battle-stat">
<span className="stat-label">Your Party DPS</span>
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
</div>
<div className="battle-stat-divider">vs</div>
<div className="battle-stat">
<span className="stat-label">Boss DPS</span>
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
</div>
</div>
<div className="battle-bars">
<div className="battle-bar-row">
<span className="bar-label">👹 {bossName}</span>
<div className="hp-bar-container">
<div
className="hp-bar-fill"
style={{
width: `${bossHpPercent.toFixed(1)}%`,
backgroundColor: bossHpBarColour,
transition: "width 5s ease-in-out",
}}
/>
</div>
<span className="bar-hp">
{formatNumber(result.bossHpAtBattleEnd)} / {formatNumber(result.bossMaxHp)}
</span>
</div>
<div className="vs-divider"> VS </div>
<div className="battle-bar-row">
<span className="bar-label">🛡 Your Party</span>
<div className="hp-bar-container">
<div
className="hp-bar-fill party-hp"
style={{
width: `${partyHpPercent.toFixed(1)}%`,
backgroundColor: partyHpBarColour,
transition: "width 5s ease-in-out",
}}
/>
</div>
<span className="bar-hp">
{formatNumber(result.partyHpRemaining)} / {formatNumber(result.partyMaxHp)}
</span>
</div>
</div>
{phase === "animating" && (
<p className="battle-in-progress">Battling</p>
)}
{phase === "result" && (
<div className={`battle-outcome ${result.won ? "victory" : "defeat"}`}>
{result.won ? (
<>
<h3>🏆 Victory!</h3>
{result.rewards && (
<div className="battle-rewards">
<p>Rewards:</p>
<span>🪙 {formatNumber(result.rewards.gold)} gold</span>
{result.rewards.essence > 0 && (
<span> {formatNumber(result.rewards.essence)} essence</span>
)}
{result.rewards.crystals > 0 && (
<span>💎 {formatNumber(result.rewards.crystals)} crystals</span>
)}
</div>
)}
</>
) : (
<>
<h3>💀 Defeat</h3>
<p>Your party was defeated. The boss has reset.</p>
{result.casualties && result.casualties.length > 0 && (
<div className="battle-casualties">
<p>Casualties:</p>
{result.casualties.map((c) => (
<span key={c.adventurerId}>
{c.killed} {c.adventurerId} lost
</span>
))}
</div>
)}
</>
)}
<button
className="dismiss-button"
onClick={onDismiss}
type="button"
>
Continue
</button>
</div>
)}
</div>
</div>
);
};
+101 -13
View File
@@ -1,15 +1,25 @@
import type { Boss } from "@elysium/types";
import { useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { formatNumber } from "../../utils/format.js";
interface BossCardProps {
boss: Boss;
prestigeCount: number;
onChallenge: (bossId: string) => void;
isChallenging: boolean;
}
const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element => {
const { attackBoss } = useGame();
const BossCard = ({
boss,
prestigeCount,
onChallenge,
isChallenging,
}: BossCardProps): React.JSX.Element => {
const hpPercent = (boss.currentHp / boss.maxHp) * 100;
const isLocked = boss.prestigeRequirement > prestigeCount;
const canChallenge =
(boss.status === "available" || boss.status === "in_progress") && !isChallenging;
return (
<div className={`boss-card boss-${boss.status}`}>
@@ -17,7 +27,9 @@ const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element =>
<h3>{boss.name}</h3>
<p>{boss.description}</p>
{isLocked && boss.status === "locked" && (
<p className="prestige-lock">🔒 Requires Prestige {boss.prestigeRequirement}</p>
<p className="prestige-lock">
🔒 Requires Prestige {boss.prestigeRequirement}
</p>
)}
</div>
@@ -30,26 +42,40 @@ const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element =>
/>
</div>
<span className="hp-text">
{boss.currentHp.toLocaleString()} / {boss.maxHp.toLocaleString()} HP
{formatNumber(boss.currentHp)} / {formatNumber(boss.maxHp)} HP
</span>
</div>
)}
<div className="boss-rewards">
<span>🪙 {boss.goldReward.toLocaleString()}</span>
{boss.essenceReward > 0 && <span> {boss.essenceReward.toLocaleString()}</span>}
{boss.crystalReward > 0 && <span>💎 {boss.crystalReward.toLocaleString()}</span>}
<div className="boss-meta">
<span className="boss-dps">💢 Boss DPS: {formatNumber(boss.damagePerSecond)}</span>
</div>
{boss.status === "available" || boss.status === "in_progress" ? (
<div className="boss-rewards">
<span>🪙 {formatNumber(boss.goldReward)}</span>
{boss.essenceReward > 0 && (
<span> {formatNumber(boss.essenceReward)}</span>
)}
{boss.crystalReward > 0 && (
<span>💎 {formatNumber(boss.crystalReward)}</span>
)}
{(boss.equipmentRewards ?? []).length > 0 && (
<span>🗡 {boss.equipmentRewards.length} Equipment</span>
)}
</div>
{(boss.status === "available" || boss.status === "in_progress") && (
<button
className="attack-button"
onClick={() => { void attackBoss(boss.id); }}
disabled={!canChallenge}
onClick={() => {
onChallenge(boss.id);
}}
type="button"
>
Attack
{isChallenging ? "⚔️ Battling…" : "⚔️ Challenge"}
</button>
) : null}
)}
{boss.status === "defeated" && (
<span className="boss-badge defeated"> Defeated</span>
@@ -59,19 +85,81 @@ const BossCard = ({ boss, prestigeCount }: BossCardProps): React.JSX.Element =>
};
export const BossPanel = (): React.JSX.Element => {
const { state } = useGame();
const { state, challengeBoss } = useGame();
const [challengingBossId, setChallengingBossId] = useState<string | null>(null);
if (!state) return <section className="panel"><p>Loading...</p></section>;
// Calculate party combat stats including equipment multiplier
let globalMultiplier = 1;
for (const upgrade of state.upgrades) {
if (upgrade.purchased && upgrade.target === "global") {
globalMultiplier *= upgrade.multiplier;
}
}
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
const equipmentCombatMultiplier = (state.equipment ?? [])
.filter((e) => e.equipped && e.bonus.combatMultiplier != null)
.reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1);
let partyDPS = 0;
let partyHP = 0;
for (const adventurer of state.adventurers) {
if (adventurer.count === 0) continue;
let adventurerMultiplier = 1;
for (const upgrade of state.upgrades) {
if (
upgrade.purchased &&
upgrade.target === "adventurer" &&
upgrade.adventurerId === adventurer.id
) {
adventurerMultiplier *= upgrade.multiplier;
}
}
partyDPS +=
adventurer.combatPower *
adventurer.count *
adventurerMultiplier *
globalMultiplier *
prestigeMultiplier;
partyHP += adventurer.level * 50 * adventurer.count;
}
partyDPS *= equipmentCombatMultiplier;
const handleChallenge = async (bossId: string): Promise<void> => {
setChallengingBossId(bossId);
try {
await challengeBoss(bossId);
} finally {
setChallengingBossId(null);
}
};
return (
<section className="panel boss-panel">
<h2>Boss Encounters</h2>
<div className="party-combat-stats">
<div className="combat-stat">
<span className="stat-label"> Party DPS</span>
<span className="stat-value">{formatNumber(partyDPS)}</span>
</div>
<div className="combat-stat">
<span className="stat-label"> Party HP</span>
<span className="stat-value">{formatNumber(partyHP)}</span>
</div>
</div>
<div className="boss-list">
{state.bosses.map((boss) => (
<BossCard
key={boss.id}
boss={boss}
isChallenging={challengingBossId === boss.id}
prestigeCount={state.prestige.count}
onChallenge={(id) => {
void handleChallenge(id);
}}
/>
))}
</div>
+50 -9
View File
@@ -1,8 +1,38 @@
import { useCallback, useRef, useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { calculateClickPower } from "../../engine/tick.js";
import { formatNumber } from "../../utils/format.js";
interface FloatText {
id: number;
x: number;
y: number;
text: string;
}
export const ClickArea = (): React.JSX.Element => {
const { state, handleClick } = useGame();
const [floats, setFloats] = useState<FloatText[]>([]);
const nextIdRef = useRef(0);
const handleClickWithFloat = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
if (!state) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const id = nextIdRef.current++;
const clickPower = calculateClickPower(state);
setFloats((prev) => [...prev, { id, x, y, text: `+${formatNumber(clickPower)}` }]);
handleClick();
setTimeout(() => {
setFloats((prev) => prev.filter((f) => f.id !== id));
}, 900);
},
[state, handleClick],
);
if (!state) return <div className="click-area-placeholder" />;
@@ -11,15 +41,26 @@ export const ClickArea = (): React.JSX.Element => {
return (
<section className="click-area">
<h2>Guild Hall</h2>
<button
className="click-button"
onClick={handleClick}
type="button"
aria-label={`Click to earn ${clickPower.toFixed(1)} gold`}
>
</button>
<p className="click-power">+{clickPower.toFixed(1)} gold per click</p>
<div className="click-button-wrapper">
<button
className="click-button"
onClick={handleClickWithFloat}
type="button"
aria-label={`Click to earn ${formatNumber(clickPower)} gold`}
>
</button>
{floats.map((float) => (
<span
key={float.id}
className="click-float"
style={{ left: float.x, top: float.y }}
>
{float.text}
</span>
))}
</div>
<p className="click-power">+{formatNumber(clickPower)} gold/click</p>
</section>
);
};
@@ -0,0 +1,102 @@
import type { Equipment, EquipmentType } from "@elysium/types";
import { useGame } from "../../context/GameContext.js";
const RARITY_LABEL: Record<string, string> = {
common: "Common",
rare: "Rare",
epic: "Epic",
legendary: "Legendary",
};
const TYPE_ICON: Record<EquipmentType, string> = {
weapon: "⚔️",
armour: "🛡️",
trinket: "💍",
};
const bonusDescription = (item: Equipment): string => {
const parts: string[] = [];
if (item.bonus.combatMultiplier != null) {
parts.push(`+${Math.round((item.bonus.combatMultiplier - 1) * 100)}% Combat`);
}
if (item.bonus.goldMultiplier != null) {
parts.push(`+${Math.round((item.bonus.goldMultiplier - 1) * 100)}% Gold/s`);
}
if (item.bonus.clickMultiplier != null) {
parts.push(`+${Math.round((item.bonus.clickMultiplier - 1) * 100)}% Click`);
}
return parts.join(", ");
};
interface EquipmentCardProps {
item: Equipment;
}
const EquipmentCard = ({ item }: EquipmentCardProps): React.JSX.Element => {
const { equipItem } = useGame();
return (
<div className={`equipment-card rarity-${item.rarity} ${item.equipped ? "equipped" : ""} ${!item.owned ? "not-owned" : ""}`}>
<div className="equipment-icon">{TYPE_ICON[item.type]}</div>
<div className="equipment-info">
<div className="equipment-name-row">
<h3>{item.name}</h3>
<span className={`rarity-badge rarity-${item.rarity}`}>{RARITY_LABEL[item.rarity]}</span>
</div>
<p className="equipment-description">{item.description}</p>
<p className="equipment-bonus">{bonusDescription(item)}</p>
</div>
<div className="equipment-action">
{!item.owned && <span className="equipment-locked">🔒 Not yet obtained</span>}
{item.owned && item.equipped && <span className="equipment-equipped-badge"> Equipped</span>}
{item.owned && !item.equipped && (
<button
className="equip-button"
onClick={() => { equipItem(item.id); }}
type="button"
>
Equip
</button>
)}
</div>
</div>
);
};
const SLOT_ORDER: EquipmentType[] = ["weapon", "armour", "trinket"];
const SLOT_LABEL: Record<EquipmentType, string> = {
weapon: "⚔️ Weapons",
armour: "🛡️ Armour",
trinket: "💍 Trinkets",
};
export const EquipmentPanel = (): React.JSX.Element => {
const { state } = useGame();
if (!state) return <section className="panel"><p>Loading...</p></section>;
const equipment = state.equipment ?? [];
return (
<section className="panel equipment-panel">
<h2>Equipment</h2>
<p className="equipment-intro">
Equipment drops from bosses and grants passive bonuses. Only one item per slot can be equipped at a time.
</p>
{SLOT_ORDER.map((slotType) => {
const items = equipment.filter((e) => e.type === slotType);
return (
<div key={slotType} className="equipment-slot-section">
<h3 className="slot-heading">{SLOT_LABEL[slotType]}</h3>
<div className="equipment-list">
{items.map((item) => (
<EquipmentCard key={item.id} item={item} />
))}
</div>
</div>
);
})}
</section>
);
};
+14 -2
View File
@@ -1,26 +1,32 @@
import { useState } from "react";
import { useGame } from "../../context/GameContext.js";
import { ResourceBar } from "../ui/ResourceBar.js";
import { AchievementPanel } from "./AchievementPanel.js";
import { AchievementToast } from "./AchievementToast.js";
import { AdventurerPanel } from "./AdventurerPanel.js";
import { BattleModal } from "./BattleModal.js";
import { BossPanel } from "./BossPanel.js";
import { ClickArea } from "./ClickArea.js";
import { EquipmentPanel } from "./EquipmentPanel.js";
import { OfflineModal } from "./OfflineModal.js";
import { PrestigePanel } from "./PrestigePanel.js";
import { QuestPanel } from "./QuestPanel.js";
import { UpgradePanel } from "./UpgradePanel.js";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "prestige";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige";
const TABS: { id: Tab; label: string }[] = [
{ id: "adventurers", label: "⚔️ Adventurers" },
{ id: "upgrades", label: "🔧 Upgrades" },
{ id: "quests", label: "📜 Quests" },
{ id: "bosses", label: "👹 Bosses" },
{ id: "equipment", label: "🗡️ Equipment" },
{ id: "achievements", label: "🏆 Achievements" },
{ id: "prestige", label: "⭐ Prestige" },
];
export const GameLayout = (): React.JSX.Element => {
const { state, isLoading, error } = useGame();
const { state, isLoading, error, battleResult, dismissBattle } = useGame();
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
if (isLoading) {
@@ -48,6 +54,10 @@ export const GameLayout = (): React.JSX.Element => {
prestigeCount={state.prestige.count}
/>
<OfflineModal />
<AchievementToast />
{battleResult && (
<BattleModal battle={battleResult} onDismiss={dismissBattle} />
)}
<div className="game-main">
<aside className="game-sidebar">
@@ -73,6 +83,8 @@ export const GameLayout = (): React.JSX.Element => {
{activeTab === "upgrades" && <UpgradePanel />}
{activeTab === "quests" && <QuestPanel />}
{activeTab === "bosses" && <BossPanel />}
{activeTab === "equipment" && <EquipmentPanel />}
{activeTab === "achievements" && <AchievementPanel />}
{activeTab === "prestige" && <PrestigePanel />}
</div>
</main>
+39 -3
View File
@@ -12,6 +12,23 @@ const UpgradeCard = ({ upgrade, currentGold, currentEssence }: UpgradeCardProps)
const canAfford =
currentGold >= upgrade.costGold && currentEssence >= upgrade.costEssence;
if (!upgrade.unlocked) {
return (
<div className="upgrade-card locked">
<div className="upgrade-info">
<h3>🔒 {upgrade.name}</h3>
<p>{upgrade.description}</p>
<p className="upgrade-multiplier">×{upgrade.multiplier} multiplier</p>
</div>
<div className="upgrade-cost">
{upgrade.costGold > 0 && <span>🪙 {upgrade.costGold.toLocaleString()}</span>}
{upgrade.costEssence > 0 && <span> {upgrade.costEssence.toLocaleString()}</span>}
</div>
<span className="upgrade-locked-label">Locked</span>
</div>
);
}
if (upgrade.purchased) {
return (
<div className="upgrade-card purchased">
@@ -49,16 +66,35 @@ export const UpgradePanel = (): React.JSX.Element => {
if (!state) return <section className="panel"><p>Loading...</p></section>;
const availableUpgrades = state.upgrades.filter((u) => u.unlocked);
const purchased = state.upgrades.filter((u) => u.purchased);
const available = state.upgrades.filter((u) => u.unlocked && !u.purchased);
const locked = state.upgrades.filter((u) => !u.unlocked);
return (
<section className="panel upgrade-panel">
<h2>Upgrades</h2>
{availableUpgrades.length === 0 ? (
<p className="upgrade-progress">{purchased.length} / {state.upgrades.length} purchased</p>
{state.upgrades.length === 0 ? (
<p className="empty-state">No upgrades available yet keep adventuring!</p>
) : (
<div className="upgrade-list">
{availableUpgrades.map((upgrade) => (
{available.map((upgrade) => (
<UpgradeCard
key={upgrade.id}
upgrade={upgrade}
currentGold={state.resources.gold}
currentEssence={state.resources.essence}
/>
))}
{purchased.map((upgrade) => (
<UpgradeCard
key={upgrade.id}
upgrade={upgrade}
currentGold={state.resources.gold}
currentEssence={state.resources.essence}
/>
))}
{locked.map((upgrade) => (
<UpgradeCard
key={upgrade.id}
upgrade={upgrade}
+1 -13
View File
@@ -1,23 +1,11 @@
import type { Resource } from "@elysium/types";
import { formatNumber } from "../../utils/format.js";
interface ResourceBarProps {
resources: Resource;
prestigeCount: number;
}
const formatNumber = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`;
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`;
}
return value.toFixed(1);
};
export const ResourceBar = ({ resources, prestigeCount }: ResourceBarProps): React.JSX.Element => (
<header className="resource-bar">
<div className="resource">
+131 -30
View File
@@ -1,4 +1,4 @@
import type { GameState } from "@elysium/types";
import type { Achievement, BossChallengeResponse, GameState } from "@elysium/types";
import {
createContext,
useCallback,
@@ -7,9 +7,14 @@ import {
useRef,
useState,
} from "react";
import { dealBossDamage, loadGame, saveGame } from "../api/client.js";
import { challengeBoss as challengeBossApi, loadGame, saveGame } from "../api/client.js";
import { applyTick, calculateClickPower } from "../engine/tick.js";
export interface BattleResult {
bossName: string;
result: BossChallengeResponse;
}
interface GameContextValue {
state: GameState | null;
isLoading: boolean;
@@ -22,14 +27,24 @@ interface GameContextValue {
buyUpgrade: (upgradeId: string) => void;
/** Start a quest */
startQuest: (questId: string) => void;
/** Attack the active boss */
attackBoss: (bossId: string) => void;
/** Challenge a boss — runs full server-side simulation */
challengeBoss: (bossId: string) => Promise<void>;
/** Equip an owned equipment item (auto-unequips the same slot) */
equipItem: (equipmentId: string) => void;
/** Reload state from the server */
reload: () => Promise<void>;
/** Offline gold earned on login */
offlineGold: number;
/** Dismiss the offline gold notification */
dismissOfflineGold: () => void;
/** Battle result to display in the modal (null when no battle pending) */
battleResult: BattleResult | null;
/** Dismiss the battle result modal */
dismissBattle: () => void;
/** Queue of newly unlocked achievements (for toasts) */
newAchievements: Achievement[];
/** Remove an achievement from the toast queue */
dismissAchievement: (id: string) => void;
}
const GameContext = createContext<GameContextValue | null>(null);
@@ -41,9 +56,12 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [offlineGold, setOfflineGold] = useState(0);
const [battleResult, setBattleResult] = useState<BattleResult | null>(null);
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
const stateRef = useRef<GameState | null>(null);
const lastSaveRef = useRef<number>(Date.now());
const rafRef = useRef<number | null>(null);
const newlyUnlockedRef = useRef<Achievement[]>([]);
stateRef.current = state;
@@ -79,9 +97,22 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
setState((prev) => {
if (!prev) return prev;
return applyTick(prev, deltaSeconds);
const next = applyTick(prev, deltaSeconds);
// Detect newly unlocked achievements
newlyUnlockedRef.current = next.achievements.filter((a, i) => {
const wasLocked = (prev.achievements ?? [])[i]?.unlockedAt === null;
return wasLocked && a.unlockedAt !== null;
});
return next;
});
if (newlyUnlockedRef.current.length > 0) {
setNewAchievements((prev) => [...prev, ...newlyUnlockedRef.current]);
newlyUnlockedRef.current = [];
}
// Auto-save every 30 seconds
if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) {
lastSaveRef.current = Date.now();
@@ -176,50 +207,107 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
});
}, []);
const attackBoss = useCallback(async (bossId: string) => {
const equipItem = useCallback((equipmentId: string) => {
setState((prev) => {
if (!prev) return prev;
const item = (prev.equipment ?? []).find((e) => e.id === equipmentId);
if (!item || !item.owned) return prev;
return {
...prev,
equipment: (prev.equipment ?? []).map((e) => {
if (e.id === equipmentId) return { ...e, equipped: true };
// Unequip the previously-equipped item in the same slot
if (e.type === item.type && e.equipped) return { ...e, equipped: false };
return e;
}),
};
});
}, []);
const challengeBoss = useCallback(async (bossId: string) => {
if (!stateRef.current) return;
const clickPower = calculateClickPower(stateRef.current);
const boss = stateRef.current.bosses.find((b) => b.id === bossId);
if (!boss) return;
try {
const result = await dealBossDamage({ bossId, damage: clickPower });
const result = await challengeBossApi({ bossId });
// Update local state to match server result
setState((prev) => {
if (!prev) return prev;
return {
...prev,
bosses: prev.bosses.map((b) =>
b.id === bossId
if (result.won) {
const bossIndex = prev.bosses.findIndex((b) => b.id === bossId);
return {
...prev,
bosses: prev.bosses.map((b, idx) => {
if (b.id === bossId) {
return { ...b, status: "defeated" as const, currentHp: 0 };
}
if (
idx === bossIndex + 1 &&
b.prestigeRequirement <= prev.prestige.count
) {
return { ...b, status: "available" as const };
}
return b;
}),
resources: result.rewards
? {
...b,
status: result.defeated ? ("defeated" as const) : ("in_progress" as const),
currentHp: result.currentHp,
}
: b,
),
...(result.defeated && result.rewards
? {
resources: {
...prev.resources,
gold: prev.resources.gold + result.rewards.gold,
essence: prev.resources.essence + result.rewards.essence,
crystals: prev.resources.crystals + result.rewards.crystals,
},
player: {
}
: prev.resources,
player: result.rewards
? {
...prev.player,
totalGoldEarned:
prev.player.totalGoldEarned + result.rewards.gold,
},
upgrades: prev.upgrades.map((u) =>
}
: prev.player,
upgrades: result.rewards
? prev.upgrades.map((u) =>
result.rewards!.upgradeIds.includes(u.id)
? { ...u, unlocked: true }
: u,
),
}
: {}),
)
: prev.upgrades,
equipment: result.rewards
? (prev.equipment ?? []).map((e) => {
if (!result.rewards!.equipmentIds.includes(e.id)) return e;
const slotEmpty = !(prev.equipment ?? []).some(
(other) => other.type === e.type && other.equipped,
);
return { ...e, owned: true, equipped: slotEmpty || e.equipped };
})
: prev.equipment ?? [],
};
}
// Loss: reset boss HP and apply casualties
return {
...prev,
bosses: prev.bosses.map((b) =>
b.id === bossId
? { ...b, status: "available" as const, currentHp: b.maxHp }
: b,
),
adventurers: prev.adventurers.map((a) => {
const casualty = result.casualties?.find(
(c) => c.adventurerId === a.id,
);
if (!casualty) return a;
return { ...a, count: Math.max(0, a.count - casualty.killed) };
}),
};
});
setBattleResult({ bossName: boss.name, result });
} catch {
// Rate limited or other error — silently ignore
// Silently ignore — server errors shouldn't crash the UI
}
}, []);
@@ -227,6 +315,14 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
setOfflineGold(0);
}, []);
const dismissBattle = useCallback(() => {
setBattleResult(null);
}, []);
const dismissAchievement = useCallback((id: string) => {
setNewAchievements((prev) => prev.filter((a) => a.id !== id));
}, []);
return (
<GameContext.Provider
value={{
@@ -237,10 +333,15 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
buyAdventurer,
buyUpgrade,
startQuest,
attackBoss,
challengeBoss,
equipItem,
reload,
offlineGold,
dismissOfflineGold,
battleResult,
dismissBattle,
newAchievements,
dismissAchievement,
}}
>
{children}
+122 -10
View File
@@ -1,4 +1,44 @@
import type { GameState } from "@elysium/types";
import type { Achievement, Equipment, GameState } from "@elysium/types";
/**
* Checks all achievements against the current game state and returns an updated
* achievements array, marking newly-met conditions with the current timestamp.
*/
const checkAchievements = (state: GameState): Achievement[] => {
const now = Date.now();
return (state.achievements ?? []).map((achievement) => {
if (achievement.unlockedAt !== null) return achievement;
const { condition } = achievement;
let met = false;
switch (condition.type) {
case "totalGoldEarned":
met = state.player.totalGoldEarned >= condition.amount;
break;
case "totalClicks":
met = state.player.totalClicks >= condition.amount;
break;
case "bossesDefeated":
met = state.bosses.filter((b) => b.status === "defeated").length >= condition.amount;
break;
case "questsCompleted":
met = state.quests.filter((q) => q.status === "completed").length >= condition.amount;
break;
case "adventurerTotal":
met = state.adventurers.reduce((sum, a) => sum + a.count, 0) >= condition.amount;
break;
case "prestigeCount":
met = state.prestige.count >= condition.amount;
break;
case "equipmentOwned":
met = (state.equipment ?? []).filter((e) => e.owned).length >= condition.amount;
break;
}
return met ? { ...achievement, unlockedAt: now } : achievement;
});
};
/**
* Pure function — applies one game tick to the state.
@@ -6,6 +46,12 @@ import type { GameState } from "@elysium/types";
* Returns a new GameState (does not mutate the original).
*/
export const applyTick = (state: GameState, deltaSeconds: number): GameState => {
const equippedItems: Equipment[] = (state.equipment ?? []).filter((e) => e.equipped);
const equipmentGoldMultiplier = equippedItems.reduce(
(mult, e) => mult * (e.bonus.goldMultiplier ?? 1),
1,
);
let goldGained = 0;
let essenceGained = 0;
@@ -26,7 +72,12 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
const prestige = state.prestige.productionMultiplier;
goldGained +=
adventurer.goldPerSecond * adventurer.count * upgradeMultiplier * prestige * deltaSeconds;
adventurer.goldPerSecond *
adventurer.count *
upgradeMultiplier *
prestige *
equipmentGoldMultiplier *
deltaSeconds;
essenceGained +=
adventurer.essencePerSecond *
@@ -36,12 +87,16 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
deltaSeconds;
}
// Complete active quests
// Complete active quests and apply their rewards
const now = Date.now();
let questGold = 0;
let questEssence = 0;
let questCrystals = 0;
let updatedUpgrades = state.upgrades;
let updatedAdventurers = state.adventurers;
let updatedEquipment = state.equipment ?? [];
const updatedQuests = state.quests.map((quest) => {
if (
quest.status !== "active" ||
@@ -51,7 +106,6 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
return quest;
}
const completed = { ...quest, status: "completed" as const };
for (const reward of quest.rewards) {
if (reward.type === "gold" && reward.amount != null) {
questGold += reward.amount;
@@ -59,15 +113,46 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
questEssence += reward.amount;
} else if (reward.type === "crystals" && reward.amount != null) {
questCrystals += reward.amount;
} else if (reward.type === "upgrade" && reward.targetId != null) {
updatedUpgrades = updatedUpgrades.map((u) =>
u.id === reward.targetId ? { ...u, unlocked: true } : u,
);
} else if (reward.type === "adventurer" && reward.targetId != null) {
updatedAdventurers = updatedAdventurers.map((a) =>
a.id === reward.targetId ? { ...a, unlocked: true } : a,
);
} else if (reward.type === "equipment" && reward.targetId != null) {
const targetId = reward.targetId;
updatedEquipment = updatedEquipment.map((e) => {
if (e.id !== targetId) return e;
const slotEmpty = !updatedEquipment.some(
(other) => other.type === e.type && other.equipped,
);
return { ...e, owned: true, equipped: slotEmpty || e.equipped };
});
}
}
return completed;
return { ...quest, status: "completed" as const };
});
// Unlock quests whose prerequisites are now all completed
const completedIds = new Set(
updatedQuests.filter((q) => q.status === "completed").map((q) => q.id),
);
const fullyUpdatedQuests = updatedQuests.map((quest) => {
if (quest.status !== "locked") return quest;
if (quest.prerequisiteIds.every((id) => completedIds.has(id))) {
return { ...quest, status: "available" as const };
}
return quest;
});
const newGold = state.resources.gold + goldGained + questGold;
const newEssence = state.resources.essence + essenceGained + questEssence;
const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold;
return {
const partialState: GameState = {
...state,
resources: {
...state.resources,
@@ -77,20 +162,47 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
},
player: {
...state.player,
totalGoldEarned: state.player.totalGoldEarned + goldGained + questGold,
totalGoldEarned: newTotalGoldEarned,
},
quests: updatedQuests,
quests: fullyUpdatedQuests,
upgrades: updatedUpgrades,
adventurers: updatedAdventurers,
equipment: updatedEquipment,
lastTickAt: now,
};
// Check achievements and apply crystal rewards for newly unlocked ones
const updatedAchievements = checkAchievements(partialState);
const crystalsFromAchievements = updatedAchievements.reduce((sum, a, i) => {
const wasLocked = (state.achievements ?? [])[i]?.unlockedAt === null;
const isNowUnlocked = a.unlockedAt !== null;
if (wasLocked && isNowUnlocked) {
return sum + (a.reward?.crystals ?? 0);
}
return sum;
}, 0);
return {
...partialState,
achievements: updatedAchievements,
resources: {
...partialState.resources,
crystals: partialState.resources.crystals + crystalsFromAchievements,
},
};
};
/**
* Calculates the effective click power, including upgrades.
* Calculates the effective click power, including upgrades and equipped trinkets.
*/
export const calculateClickPower = (state: GameState): number => {
const clickMultiplier = state.upgrades
.filter((u) => u.purchased && u.target === "click")
.reduce((mult, upgrade) => mult * upgrade.multiplier, 1);
return state.baseClickPower * clickMultiplier * state.prestige.productionMultiplier;
const equipmentClickMultiplier = (state.equipment ?? [])
.filter((e) => e.equipped && e.bonus.clickMultiplier != null)
.reduce((mult, e) => mult * (e.bonus.clickMultiplier ?? 1), 1);
return state.baseClickPower * clickMultiplier * state.prestige.productionMultiplier * equipmentClickMultiplier;
};
+485
View File
@@ -291,6 +291,22 @@ body {
opacity: 0.7;
}
.upgrade-card.locked {
opacity: 0.45;
}
.upgrade-locked-label {
color: var(--colour-text-muted);
font-size: 0.75rem;
white-space: nowrap;
}
.upgrade-progress {
color: var(--colour-text-muted);
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.upgrade-info {
flex: 1;
}
@@ -611,6 +627,475 @@ body {
background: var(--colour-accent-light);
}
/* ===================== BATTLE MODAL ===================== */
.battle-modal {
max-width: 520px;
}
.battle-stats {
align-items: center;
display: flex;
gap: 1rem;
justify-content: center;
margin-bottom: 1.5rem;
}
.battle-stat {
background: var(--colour-bg);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 120px;
padding: 0.5rem 0.75rem;
text-align: center;
}
.battle-stat .stat-label {
color: var(--colour-text-muted);
font-size: 0.75rem;
text-transform: uppercase;
}
.battle-stat .stat-value {
color: var(--colour-accent-light);
font-size: 1.1rem;
font-weight: 700;
}
.battle-stat-divider {
color: var(--colour-text-muted);
font-size: 0.85rem;
}
.battle-bars {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.battle-bar-row {
align-items: center;
display: flex;
gap: 0.5rem;
}
.battle-bar-row .bar-label {
font-size: 0.85rem;
min-width: 100px;
text-align: left;
}
.hp-bar-container {
background: var(--colour-bg);
border: 1px solid var(--colour-border);
border-radius: 4px;
flex: 1;
height: 14px;
overflow: hidden;
}
.hp-bar-fill {
border-radius: 4px;
height: 100%;
}
.battle-bar-row .bar-hp {
color: var(--colour-text-muted);
font-size: 0.75rem;
min-width: 80px;
text-align: right;
}
.vs-divider {
color: var(--colour-text-muted);
font-size: 0.85rem;
text-align: center;
}
.battle-in-progress {
color: var(--colour-text-muted);
font-style: italic;
}
.battle-outcome {
border-radius: var(--radius);
margin-top: 1rem;
padding: 1rem;
}
.battle-outcome.victory {
background: rgba(39, 174, 96, 0.1);
border: 1px solid #27ae60;
}
.battle-outcome.defeat {
background: rgba(231, 76, 60, 0.1);
border: 1px solid #e74c3c;
}
.battle-outcome h3 {
font-size: 1.2rem;
margin-bottom: 0.5rem;
}
.battle-rewards,
.battle-casualties {
display: flex;
flex-direction: column;
font-size: 0.9rem;
gap: 0.2rem;
margin: 0.5rem 0;
}
.dismiss-button {
background: var(--colour-accent);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 1rem;
font-weight: 700;
margin-top: 0.75rem;
padding: 0.5rem 2rem;
transition: background 0.15s;
}
.dismiss-button:hover {
background: var(--colour-accent-light);
}
/* Party combat stat bar in BossPanel */
.party-combat-stats {
background: var(--colour-bg);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
gap: 2rem;
justify-content: center;
margin-bottom: 1.25rem;
padding: 0.75rem 1rem;
}
.combat-stat {
display: flex;
flex-direction: column;
gap: 0.2rem;
text-align: center;
}
.combat-stat .stat-label {
color: var(--colour-text-muted);
font-size: 0.75rem;
text-transform: uppercase;
}
.combat-stat .stat-value {
color: var(--colour-accent-light);
font-size: 1rem;
font-weight: 700;
}
.boss-meta {
color: var(--colour-text-muted);
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
/* ===================== CLICK FLOAT ===================== */
@keyframes float-up {
0% { opacity: 1; transform: translate(-50%, 0); }
100% { opacity: 0; transform: translate(-50%, -70px); }
}
.click-button-wrapper {
position: relative;
display: inline-block;
}
.click-float {
animation: float-up 0.9s ease-out forwards;
color: var(--colour-gold);
font-size: 1rem;
font-weight: 700;
pointer-events: none;
position: absolute;
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
user-select: none;
}
/* ===================== EQUIPMENT ===================== */
.equipment-intro {
color: var(--colour-text-muted);
font-size: 0.85rem;
margin-bottom: 1.25rem;
}
.equipment-slot-section {
margin-bottom: 1.5rem;
}
.slot-heading {
color: var(--colour-text-muted);
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.equipment-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.equipment-card {
align-items: center;
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-left: 3px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
gap: 0.75rem;
padding: 0.75rem;
transition: border-color 0.15s;
}
.equipment-card.equipped {
border-color: var(--colour-success);
border-left-color: var(--colour-success);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.15);
}
.equipment-card.not-owned {
opacity: 0.45;
}
/* Rarity border-left colours */
.equipment-card.rarity-common { border-left-color: #9ca3af; }
.equipment-card.rarity-rare { border-left-color: #3b82f6; }
.equipment-card.rarity-epic { border-left-color: #a855f7; }
.equipment-card.rarity-legendary { border-left-color: #f59e0b; }
.equipment-icon {
font-size: 1.5rem;
min-width: 2rem;
text-align: center;
}
.equipment-info {
flex: 1;
min-width: 0;
}
.equipment-name-row {
align-items: center;
display: flex;
gap: 0.5rem;
margin-bottom: 0.15rem;
}
.equipment-name-row h3 {
font-size: 0.95rem;
margin: 0;
}
.rarity-badge {
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
}
.rarity-badge.rarity-common { background: rgba(156, 163, 175, 0.2); color: #9ca3af; }
.rarity-badge.rarity-rare { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
.rarity-badge.rarity-epic { background: rgba(168, 85, 247, 0.2); color: #c084fc; }
.rarity-badge.rarity-legendary { background: rgba(245, 158, 11, 0.2); color: #fbbf24; }
.equipment-description {
color: var(--colour-text-muted);
font-size: 0.8rem;
margin-bottom: 0.2rem;
}
.equipment-bonus {
color: var(--colour-gold);
font-size: 0.8rem;
font-weight: 600;
}
.equipment-action {
flex-shrink: 0;
text-align: right;
}
.equipment-locked {
color: var(--colour-text-muted);
font-size: 0.8rem;
}
.equipment-equipped-badge {
color: var(--colour-success);
font-size: 0.85rem;
font-weight: 600;
}
.equip-button {
background: var(--colour-accent);
border: none;
border-radius: var(--radius);
color: #fff;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
padding: 0.3rem 0.8rem;
transition: background 0.15s;
}
.equip-button:hover {
background: var(--colour-accent-light);
}
/* ===================== ACHIEVEMENTS ===================== */
.achievement-progress {
color: var(--colour-text-muted);
font-size: 0.85rem;
margin-bottom: 1rem;
}
.achievement-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.achievement-card {
align-items: center;
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
display: flex;
gap: 0.75rem;
padding: 0.75rem;
transition: border-color 0.15s;
}
.achievement-card.unlocked {
border-color: var(--colour-gold);
box-shadow: 0 0 8px rgba(245, 158, 11, 0.15);
}
.achievement-card.locked {
opacity: 0.5;
}
.achievement-icon {
font-size: 1.5rem;
min-width: 2rem;
text-align: center;
}
.achievement-info {
flex: 1;
}
.achievement-info h3 {
font-size: 0.95rem;
margin-bottom: 0.1rem;
}
.achievement-info p {
color: var(--colour-text-muted);
font-size: 0.8rem;
}
.achievement-condition {
font-style: italic;
}
.achievement-reward {
color: var(--colour-crystal) !important;
font-weight: 600;
}
.achievement-status {
flex-shrink: 0;
}
.achievement-unlocked-badge {
color: var(--colour-gold);
font-size: 0.85rem;
font-weight: 700;
}
.achievement-locked-badge {
color: var(--colour-text-muted);
font-size: 1rem;
}
/* ===================== ACHIEVEMENT TOAST ===================== */
@keyframes slide-in-right {
from { opacity: 0; transform: translateX(120%); }
to { opacity: 1; transform: translateX(0); }
}
.achievement-toast-container {
bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
position: fixed;
right: 1.5rem;
z-index: 200;
}
.achievement-toast {
align-items: center;
animation: slide-in-right 0.35s ease-out;
background: var(--colour-surface);
border: 1px solid var(--colour-gold);
border-radius: var(--radius);
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
cursor: pointer;
display: flex;
gap: 0.75rem;
max-width: 280px;
padding: 0.75rem 1rem;
}
.toast-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.toast-content {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.toast-label {
color: var(--colour-gold);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.toast-name {
color: var(--colour-text);
font-size: 0.9rem;
font-weight: 600;
}
.toast-reward {
color: var(--colour-crystal);
font-size: 0.8rem;
}
/* ===================== UTILITY ===================== */
.error {
color: var(--colour-error);
+20
View File
@@ -0,0 +1,20 @@
/**
* Formats a number with K/M/B/T suffixes for display.
* Numbers below 1000 show one decimal place.
*/
export const formatNumber = (value: number): string => {
if (!isFinite(value) || isNaN(value)) return "0";
if (value >= 1_000_000_000_000) {
return `${(value / 1_000_000_000_000).toFixed(2)}T`;
}
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`;
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`;
}
return value.toFixed(1);
};