feat: Add Minecraft-style XP progress bar to character sheets
- Create new XPBar component with green progress bar visualization - Add XPSummary card showing total XP, available XP, and current level - Integrate XP bar into PlayerTab character sheet display - Add reference guide showing Deathwatch advancement costs - Add GM controls for quick XP adjustment (+100, +200, -50 XP buttons) - Set default XP threshold to 500 (customizable per characteristic) - Supports tracking both total XP and spent XP separately This enables faster XP progression visualization for monthly play sessions.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import logger, { debug, info, warn, logApiCall, logApiError, logUserAction } from '../utils/logger';
|
||||
import { Tooltip } from './DeathwatchRoller';
|
||||
import { XPBar, XPSummary } from './XPBar';
|
||||
|
||||
const STORAGE_SHOP_AUTHED = 'dw:shop:authedPlayer';
|
||||
const STORAGE_SHOP_PLAYERS = 'dw:shop:players:v1';
|
||||
@@ -936,6 +937,82 @@ function PlayerTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Experience Points Display - Minecraft-style XP Bar */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3 space-y-2">
|
||||
<div className="text-lg font-medium">Experience Points (XP)</div>
|
||||
|
||||
{/* XP Bar */}
|
||||
<div className="space-y-1">
|
||||
<XPBar
|
||||
currentXP={currentPlayer?.tabInfo?.xp || 0}
|
||||
xpSpent={currentPlayer?.tabInfo?.xpSpent || 0}
|
||||
thresholdXP={500}
|
||||
showLabel={true}
|
||||
compact={false}
|
||||
/>
|
||||
<div className="text-xs text-slate-400">
|
||||
1 Characteristic = 500 XP | 1 Skill Rank = 100 XP | 1 Talent = 50 XP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* XP Summary */}
|
||||
<div className="mt-3">
|
||||
<XPSummary
|
||||
currentXP={currentPlayer?.tabInfo?.xp || 0}
|
||||
xpSpent={currentPlayer?.tabInfo?.xpSpent || 0}
|
||||
thresholdXP={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* GM Controls */}
|
||||
{isGMLoggedIn() && (
|
||||
<div className="mt-4 space-y-2 border-t border-white/10 pt-3">
|
||||
<div className="text-sm text-slate-300 font-medium">GM Controls</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs min-w-fit">Set Total XP:</span>
|
||||
<GmSetXP name={currentPlayer?.name} onSet={gmSetXP} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs min-w-fit">Set Spent XP:</span>
|
||||
<GmSetXPSpent name={currentPlayer?.name} onSet={gmSetXPSpent} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentXP = currentPlayer?.tabInfo?.xp || 0;
|
||||
gmSetXP(currentPlayer.name, currentXP + 100);
|
||||
}}
|
||||
className="px-3 py-1 rounded bg-emerald-600 hover:bg-emerald-500 text-xs"
|
||||
>
|
||||
+100 XP
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentXP = currentPlayer?.tabInfo?.xp || 0;
|
||||
gmSetXP(currentPlayer.name, currentXP + 200);
|
||||
}}
|
||||
className="px-3 py-1 rounded bg-emerald-600 hover:bg-emerald-500 text-xs"
|
||||
>
|
||||
+200 XP
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentXP = currentPlayer?.tabInfo?.xp || 0;
|
||||
if (currentXP > 0) {
|
||||
gmSetXP(currentPlayer.name, Math.max(0, currentXP - 50));
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 rounded bg-rose-600 hover:bg-rose-500 text-xs"
|
||||
>
|
||||
-50 XP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Characteristics */}
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<div className="font-semibold mb-2">Characteristics</div>
|
||||
@@ -1168,10 +1245,20 @@ function PlayerTab({
|
||||
<div>
|
||||
<label className="text-xs uppercase opacity-70">Renown</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" value={renown} onChange={e=>setRenown(e.target.value)} />
|
||||
<label className="text-xs uppercase opacity-70">XP</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 mb-1" type="number" value={xp} onChange={e=>setXp(parseInt(e.target.value||'0'))} />
|
||||
<label className="text-xs uppercase opacity-70">XP Spent</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1" type="number" value={xpSpent} onChange={e=>setXpSpent(parseInt(e.target.value||'0'))} />
|
||||
<label className="text-xs uppercase opacity-70 mt-3 block">Experience Points</label>
|
||||
<div className="mb-3">
|
||||
<XPBar currentXP={xp} xpSpent={xpSpent} thresholdXP={500} showLabel={true} compact={false} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
||||
<div>
|
||||
<label className="uppercase opacity-70 block">XP Total</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1" type="number" value={xp} onChange={e=>setXp(parseInt(e.target.value||'0'))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="uppercase opacity-70 block">XP Spent</label>
|
||||
<input className="w-full rounded border border-white/10 bg-white/10 px-2 py-1" type="number" value={xpSpent} onChange={e=>setXpSpent(parseInt(e.target.value||'0'))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
95
src/components/XPBar.jsx
Normal file
95
src/components/XPBar.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* XP Bar Component - Minecraft-style progress bar for experience points
|
||||
* Shows progress toward next "level" with customizable thresholds
|
||||
*/
|
||||
|
||||
export function XPBar({
|
||||
currentXP = 0,
|
||||
xpSpent = 0,
|
||||
thresholdXP = 500,
|
||||
showLabel = true,
|
||||
compact = false
|
||||
}) {
|
||||
// Available XP is current minus spent
|
||||
const availableXP = Math.max(0, currentXP - xpSpent);
|
||||
|
||||
// Calculate progress toward next threshold
|
||||
// Each "level" is thresholdXP points
|
||||
const currentLevel = Math.floor(currentXP / thresholdXP);
|
||||
const nextLevelThreshold = (currentLevel + 1) * thresholdXP;
|
||||
const xpIntoLevel = currentXP % thresholdXP;
|
||||
const progress = (xpIntoLevel / thresholdXP) * 100;
|
||||
|
||||
const barHeight = compact ? 'h-2' : 'h-4';
|
||||
const textSize = compact ? 'text-xs' : 'text-sm';
|
||||
|
||||
return (
|
||||
<div className={`w-full ${compact ? 'space-y-1' : 'space-y-2'}`}>
|
||||
{/* XP Bar */}
|
||||
<div className="relative w-full bg-slate-800 rounded overflow-hidden border border-slate-700">
|
||||
{/* Green fill */}
|
||||
<div
|
||||
className={`${barHeight} bg-green-500 transition-all duration-300 ease-out flex items-center justify-center`}
|
||||
style={{ width: `${progress}%` }}
|
||||
>
|
||||
{/* Optional inner glow effect */}
|
||||
{progress > 10 && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-green-300 to-transparent opacity-40"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label overlay */}
|
||||
{showLabel && (
|
||||
<div className={`absolute inset-0 flex items-center justify-center text-white font-semibold ${textSize} pointer-events-none`}>
|
||||
<span className="drop-shadow-lg">
|
||||
{currentXP}/{nextLevelThreshold} XP
|
||||
{availableXP > 0 && <span className="text-green-300 ml-1">({availableXP} avail)</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Debug info - only show if spent XP is tracked */}
|
||||
{xpSpent > 0 && !compact && (
|
||||
<div className="text-xs text-slate-400 flex justify-between">
|
||||
<span>Lvl {currentLevel}</span>
|
||||
<span>Spent: {xpSpent}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* XP Summary Card - Shows overall player XP status
|
||||
*/
|
||||
export function XPSummary({ currentXP = 0, xpSpent = 0, thresholdXP = 500 }) {
|
||||
const availableXP = Math.max(0, currentXP - xpSpent);
|
||||
const currentLevel = Math.floor(currentXP / thresholdXP);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="bg-slate-700/50 rounded p-2">
|
||||
<div className="text-xs text-slate-400">Total XP</div>
|
||||
<div className="text-lg font-bold text-green-400">{currentXP}</div>
|
||||
</div>
|
||||
<div className="bg-slate-700/50 rounded p-2">
|
||||
<div className="text-xs text-slate-400">Available XP</div>
|
||||
<div className="text-lg font-bold text-green-300">{availableXP}</div>
|
||||
</div>
|
||||
<div className="bg-slate-700/50 rounded p-2 col-span-2">
|
||||
<div className="text-xs text-slate-400">Current Level</div>
|
||||
<div className="text-lg font-bold text-blue-400">Level {currentLevel}</div>
|
||||
</div>
|
||||
</div>
|
||||
{xpSpent > 0 && (
|
||||
<div className="text-xs text-slate-400">
|
||||
Spent: {xpSpent} XP | Remaining: {availableXP} XP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default XPBar;
|
||||
Reference in New Issue
Block a user