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:
2025-12-11 17:12:37 +01:00
parent a5a6b963fa
commit 0aa3fd0c30
2 changed files with 186 additions and 4 deletions

View File

@@ -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
View 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;