added epic counters

This commit is contained in:
erik 2025-06-11 08:20:57 +00:00
parent 10c51f6825
commit 09a6cd4946
4 changed files with 963 additions and 8 deletions

114
main.py
View file

@ -56,7 +56,9 @@ INVENTORY_SERVICE_URL = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-ser
# In-memory caches for REST endpoints # In-memory caches for REST endpoints
_cached_live: dict = {"players": []} _cached_live: dict = {"players": []}
_cached_trails: dict = {"trails": []} _cached_trails: dict = {"trails": []}
_cached_total_rares: dict = {"all_time": 0, "today": 0, "last_updated": None}
_cache_task: asyncio.Task | None = None _cache_task: asyncio.Task | None = None
_rares_cache_task: asyncio.Task | None = None
async def _refresh_cache_loop() -> None: async def _refresh_cache_loop() -> None:
"""Background task: refresh `/live` and `/trails` caches every 5 seconds.""" """Background task: refresh `/live` and `/trails` caches every 5 seconds."""
@ -131,10 +133,61 @@ async def _refresh_cache_loop() -> None:
await asyncio.sleep(5) await asyncio.sleep(5)
async def _refresh_total_rares_cache() -> None:
"""Background task: refresh total rares cache every 5 minutes."""
consecutive_failures = 0
max_consecutive_failures = 3
while True:
try:
async with database.connection() as conn:
# Get all-time total rares (sum of all characters) - gracefully handle missing table
try:
all_time_query = "SELECT COALESCE(SUM(total_rares), 0) as total FROM rare_stats"
all_time_result = await conn.fetch_one(all_time_query)
all_time_total = all_time_result["total"] if all_time_result else 0
except Exception as e:
logger.debug(f"rare_stats table not available: {e}")
all_time_total = 0
# Get today's rares from rare_events table - gracefully handle missing table
try:
today_query = """
SELECT COUNT(*) as today_count
FROM rare_events
WHERE timestamp >= CURRENT_DATE
"""
today_result = await conn.fetch_one(today_query)
today_total = today_result["today_count"] if today_result else 0
except Exception as e:
logger.debug(f"rare_events table not available or empty: {e}")
today_total = 0
# Update cache
_cached_total_rares["all_time"] = all_time_total
_cached_total_rares["today"] = today_total
_cached_total_rares["last_updated"] = datetime.now(timezone.utc)
consecutive_failures = 0
logger.debug(f"Total rares cache updated: All-time: {all_time_total}, Today: {today_total}")
except Exception as e:
consecutive_failures += 1
logger.error(f"Total rares cache refresh failed ({consecutive_failures}/{max_consecutive_failures}): {e}", exc_info=True)
if consecutive_failures >= max_consecutive_failures:
logger.warning("Too many consecutive total rares cache failures, waiting longer...")
await asyncio.sleep(60) # Wait longer on repeated failures
continue
# Sleep for 5 minutes (300 seconds)
await asyncio.sleep(300)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
app = FastAPI() app = FastAPI()
# In-memory store mapping character_name to the most recent telemetry snapshot # In-memory store mapping character_name to the most recent telemetry snapshot
live_snapshots: Dict[str, dict] = {} live_snapshots: Dict[str, dict] = {}
live_vitals: Dict[str, dict] = {}
# Shared secret used to authenticate plugin WebSocket connections (override for production) # Shared secret used to authenticate plugin WebSocket connections (override for production)
SHARED_SECRET = "your_shared_secret" SHARED_SECRET = "your_shared_secret"
@ -212,6 +265,25 @@ class FullInventoryMessage(BaseModel):
items: List[Dict[str, Any]] items: List[Dict[str, Any]]
class VitalsMessage(BaseModel):
"""
Model for the vitals WebSocket message type.
Contains character health, stamina, mana, and vitae information.
"""
character_name: str
timestamp: datetime
health_current: int
health_max: int
health_percentage: float
stamina_current: int
stamina_max: int
stamina_percentage: float
mana_current: int
mana_max: int
mana_percentage: float
vitae: int
@app.on_event("startup") @app.on_event("startup")
async def on_startup(): async def on_startup():
"""Event handler triggered when application starts up. """Event handler triggered when application starts up.
@ -238,17 +310,18 @@ async def on_startup():
else: else:
raise RuntimeError(f"Could not connect to database after {max_attempts} attempts") raise RuntimeError(f"Could not connect to database after {max_attempts} attempts")
# Start background cache refresh (live & trails) # Start background cache refresh (live & trails)
global _cache_task global _cache_task, _rares_cache_task
_cache_task = asyncio.create_task(_refresh_cache_loop()) _cache_task = asyncio.create_task(_refresh_cache_loop())
logger.info("Background cache refresh task started") _rares_cache_task = asyncio.create_task(_refresh_total_rares_cache())
logger.info("Background cache refresh tasks started")
@app.on_event("shutdown") @app.on_event("shutdown")
async def on_shutdown(): async def on_shutdown():
"""Event handler triggered when application is shutting down. """Event handler triggered when application is shutting down.
Ensures the database connection is closed cleanly. Ensures the database connection is closed cleanly.
""" """
# Stop cache refresh task # Stop cache refresh tasks
global _cache_task global _cache_task, _rares_cache_task
if _cache_task: if _cache_task:
logger.info("Stopping background cache refresh task") logger.info("Stopping background cache refresh task")
_cache_task.cancel() _cache_task.cancel()
@ -256,6 +329,14 @@ async def on_shutdown():
await _cache_task await _cache_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
if _rares_cache_task:
logger.info("Stopping total rares cache refresh task")
_rares_cache_task.cancel()
try:
await _rares_cache_task
except asyncio.CancelledError:
pass
logger.info("Disconnecting from database") logger.info("Disconnecting from database")
await database.disconnect() await database.disconnect()
@ -294,6 +375,17 @@ async def get_trails(
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/total-rares")
@app.get("/total-rares/")
async def get_total_rares():
"""Return cached total rares statistics (updated every 5 minutes)."""
try:
return JSONResponse(content=jsonable_encoder(_cached_total_rares))
except Exception as e:
logger.error(f"Failed to get total rares: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# --- GET Inventory Endpoints --------------------------------- # --- GET Inventory Endpoints ---------------------------------
@app.get("/inventory/{character_name}") @app.get("/inventory/{character_name}")
async def get_character_inventory(character_name: str): async def get_character_inventory(character_name: str):
@ -669,6 +761,8 @@ async def ws_receive_snapshots(
rare_events.insert().values(**rare_ev.dict()) rare_events.insert().values(**rare_ev.dict())
) )
logger.info(f"Recorded rare event: {rare_ev.name} by {name}") logger.info(f"Recorded rare event: {rare_ev.name} by {name}")
# Broadcast rare event to browser clients for epic notifications
await _broadcast_to_browser_clients(data)
except Exception as e: except Exception as e:
logger.error(f"Failed to persist rare event: {e}") logger.error(f"Failed to persist rare event: {e}")
except Exception as e: except Exception as e:
@ -690,6 +784,18 @@ async def ws_receive_snapshots(
except Exception as e: except Exception as e:
logger.error(f"Failed to process inventory for {data.get('character_name', 'unknown')}: {e}", exc_info=True) logger.error(f"Failed to process inventory for {data.get('character_name', 'unknown')}: {e}", exc_info=True)
continue continue
# --- Vitals message: store character health/stamina/mana and broadcast ---
if msg_type == "vitals":
payload = data.copy()
payload.pop("type", None)
try:
vitals_msg = VitalsMessage.parse_obj(payload)
live_vitals[vitals_msg.character_name] = vitals_msg.dict()
await _broadcast_to_browser_clients(data)
logger.debug(f"Updated vitals for {vitals_msg.character_name}: {vitals_msg.health_percentage}% HP, {vitals_msg.stamina_percentage}% Stam, {vitals_msg.mana_percentage}% Mana")
except Exception as e:
logger.error(f"Failed to process vitals for {data.get('character_name', 'unknown')}: {e}", exc_info=True)
continue
# Unknown message types are ignored # Unknown message types are ignored
if msg_type: if msg_type:
logger.warning(f"Unknown message type '{msg_type}' from {websocket.client}") logger.warning(f"Unknown message type '{msg_type}' from {websocket.client}")

View file

@ -17,12 +17,33 @@
<!-- Container for sort and filter controls --> <!-- Container for sort and filter controls -->
<div id="sortButtons" class="sort-buttons"></div> <div id="sortButtons" class="sort-buttons"></div>
<h2>Active Players</h2> <h2 id="activePlayersHeader">Active Mosswart Enjoyers</h2>
<!-- Total rares counter -->
<div id="totalRaresCounter" class="total-rares-counter">
🔥 Total Rares: <span id="totalRaresCount">Loading...</span>
</div>
<!-- Server KPH counter -->
<div id="serverKphCounter" class="server-kph-counter">
⚡ Server KPH: <span id="serverKphCount">Loading...</span>
</div>
<!-- Total kills counter -->
<div id="totalKillsCounter" class="total-kills-counter">
⚔️ Total Kills: <span id="totalKillsCount">Loading...</span>
</div>
<!-- Text input to filter active players by name --> <!-- Text input to filter active players by name -->
<input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." /> <input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." />
<ul id="playerList"></ul> <ul id="playerList"></ul>
</aside> </aside>
<!-- Epic rare notifications container -->
<div id="rareNotifications" class="rare-notifications"></div>
<!-- Fireworks container -->
<div id="fireworksContainer" class="fireworks-container"></div>
<!-- Main map container showing terrain and player data --> <!-- Main map container showing terrain and player data -->
<div id="mapContainer"> <div id="mapContainer">
<div id="mapGroup"> <div id="mapGroup">

View file

@ -930,10 +930,32 @@ async function pollLive() {
} }
} }
async function pollTotalRares() {
try {
const response = await fetch(`${API_BASE}/total-rares/`);
const data = await response.json();
updateTotalRaresDisplay(data);
} catch (e) {
console.error('Total rares fetch failed:', e);
}
}
function updateTotalRaresDisplay(data) {
const countElement = document.getElementById('totalRaresCount');
if (countElement && data.all_time !== undefined && data.today !== undefined) {
const allTimeFormatted = data.all_time.toLocaleString();
const todayFormatted = data.today.toLocaleString();
countElement.textContent = `${allTimeFormatted} (Today: ${todayFormatted})`;
}
}
function startPolling() { function startPolling() {
if (pollID !== null) return; if (pollID !== null) return;
pollLive(); pollLive();
pollTotalRares(); // Initial fetch
pollID = setInterval(pollLive, POLL_MS); pollID = setInterval(pollLive, POLL_MS);
// Poll total rares every 5 minutes (300,000 ms)
setInterval(pollTotalRares, 300000);
} }
img.onload = () => { img.onload = () => {
@ -968,6 +990,43 @@ function render(players) {
dots.innerHTML = ''; dots.innerHTML = '';
list.innerHTML = ''; list.innerHTML = '';
// Update header with active player count
const header = document.getElementById('activePlayersHeader');
if (header) {
header.textContent = `Active Mosswart Enjoyers (${players.length})`;
}
// Calculate and update server KPH
const totalKPH = players.reduce((sum, p) => sum + (p.kills_per_hour || 0), 0);
const kphElement = document.getElementById('serverKphCount');
if (kphElement) {
// Format with commas and one decimal place for EPIC display
const formattedKPH = totalKPH.toLocaleString('en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1
});
kphElement.textContent = formattedKPH;
// Add extra epic effect for high KPH
const container = document.getElementById('serverKphCounter');
if (container) {
if (totalKPH > 5000) {
container.classList.add('ultra-epic');
} else {
container.classList.remove('ultra-epic');
}
}
}
// Calculate and update total kills
const totalKills = players.reduce((sum, p) => sum + (p.total_kills || 0), 0);
const killsElement = document.getElementById('totalKillsCount');
if (killsElement) {
// Format with commas for readability
const formattedKills = totalKills.toLocaleString();
killsElement.textContent = formattedKills;
}
players.forEach(p => { players.forEach(p => {
const { x, y } = worldToPx(p.ew, p.ns); const { x, y } = worldToPx(p.ew, p.ns);
@ -1002,7 +1061,8 @@ function render(players) {
const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞'; const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞';
li.innerHTML = ` li.innerHTML = `
<span class="player-name">${p.character_name} <span class="coordinates-inline">${loc(p.ns, p.ew)}</span></span> <span class="player-name">${p.character_name}${createVitaeIndicator(p.character_name)} <span class="coordinates-inline">${loc(p.ns, p.ew)}</span></span>
${createVitalsHTML(p.character_name)}
<span class="stat kills">${p.kills}</span> <span class="stat kills">${p.kills}</span>
<span class="stat total-kills">${p.total_kills || 0}</span> <span class="stat total-kills">${p.total_kills || 0}</span>
<span class="stat kph">${p.kills_per_hour}</span> <span class="stat kph">${p.kills_per_hour}</span>
@ -1110,6 +1170,10 @@ function initWebSocket() {
try { msg = JSON.parse(evt.data); } catch { return; } try { msg = JSON.parse(evt.data); } catch { return; }
if (msg.type === 'chat') { if (msg.type === 'chat') {
appendChatMessage(msg); appendChatMessage(msg);
} else if (msg.type === 'vitals') {
updateVitalsDisplay(msg);
} else if (msg.type === 'rare') {
triggerEpicRareNotification(msg.character_name, msg.name);
} }
}); });
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000)); socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
@ -1276,3 +1340,310 @@ wrap.addEventListener('mousemove', e => {
wrap.addEventListener('mouseleave', () => { wrap.addEventListener('mouseleave', () => {
coordinates.style.display = 'none'; coordinates.style.display = 'none';
}); });
/* ---------- vitals display functions ----------------------------- */
// Store vitals data per character
const characterVitals = {};
function updateVitalsDisplay(vitalsMsg) {
// Store the vitals data for this character
characterVitals[vitalsMsg.character_name] = {
health_percentage: vitalsMsg.health_percentage,
stamina_percentage: vitalsMsg.stamina_percentage,
mana_percentage: vitalsMsg.mana_percentage,
vitae: vitalsMsg.vitae
};
// Re-render the player list to update vitals in the UI
renderList();
}
function createVitalsHTML(characterName) {
const vitals = characterVitals[characterName];
if (!vitals) {
return ''; // No vitals data available
}
return `
<div class="player-vitals">
<div class="vital-bar-inline ${getVitalClass(vitals.health_percentage)}">
<div class="vital-fill health" style="width: ${vitals.health_percentage}%"></div>
</div>
<div class="vital-bar-inline ${getVitalClass(vitals.stamina_percentage)}">
<div class="vital-fill stamina" style="width: ${vitals.stamina_percentage}%"></div>
</div>
<div class="vital-bar-inline ${getVitalClass(vitals.mana_percentage)}">
<div class="vital-fill mana" style="width: ${vitals.mana_percentage}%"></div>
</div>
</div>
`;
}
function createVitaeIndicator(characterName) {
const vitals = characterVitals[characterName];
if (!vitals || !vitals.vitae || vitals.vitae >= 100) {
return ''; // No vitae penalty
}
return `<span class="vitae-indicator">⚰️ ${vitals.vitae}%</span>`;
}
function getVitalClass(percentage) {
if (percentage <= 25) {
return 'critical-vital';
} else if (percentage <= 50) {
return 'low-vital';
}
return '';
}
/* ---------- epic rare notification system ------------------------ */
// Track previous rare count to detect increases
let lastRareCount = 0;
let notificationQueue = [];
let isShowingNotification = false;
function triggerEpicRareNotification(characterName, rareName) {
// Add to queue
notificationQueue.push({ characterName, rareName });
// Process queue if not already showing a notification
if (!isShowingNotification) {
processNotificationQueue();
}
// Trigger fireworks immediately
createFireworks();
// Highlight the player in the list
highlightRareFinder(characterName);
}
function processNotificationQueue() {
if (notificationQueue.length === 0) {
isShowingNotification = false;
return;
}
isShowingNotification = true;
const notification = notificationQueue.shift();
// Create notification element
const container = document.getElementById('rareNotifications');
const notifEl = document.createElement('div');
notifEl.className = 'rare-notification';
notifEl.innerHTML = `
<div class="rare-notification-title">🎆 LEGENDARY RARE! 🎆</div>
<div class="rare-notification-mob">${notification.rareName}</div>
<div class="rare-notification-finder">found by</div>
<div class="rare-notification-character"> ${notification.characterName} </div>
`;
container.appendChild(notifEl);
// Remove notification after 6 seconds and process next
setTimeout(() => {
notifEl.style.animation = 'notification-slide-out 0.5s ease-in forwards';
setTimeout(() => {
notifEl.remove();
processNotificationQueue();
}, 500);
}, 6000);
}
// Add slide out animation
const style = document.createElement('style');
style.textContent = `
@keyframes notification-slide-out {
to {
transform: translateY(-100px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
function createFireworks() {
const container = document.getElementById('fireworksContainer');
const rareCounter = document.getElementById('totalRaresCounter');
const rect = rareCounter.getBoundingClientRect();
// Create 30 particles
const particleCount = 30;
const colors = ['particle-gold', 'particle-red', 'particle-orange', 'particle-purple', 'particle-blue'];
for (let i = 0; i < particleCount; i++) {
const particle = document.createElement('div');
particle.className = `firework-particle ${colors[Math.floor(Math.random() * colors.length)]}`;
// Start position at rare counter
particle.style.left = `${rect.left + rect.width / 2}px`;
particle.style.top = `${rect.top + rect.height / 2}px`;
// Random explosion direction
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
const velocity = 100 + Math.random() * 200;
const dx = Math.cos(angle) * velocity;
const dy = Math.sin(angle) * velocity - 50; // Slight upward bias
// Create custom animation for this particle
const animName = `particle-${Date.now()}-${i}`;
const keyframes = `
@keyframes ${animName} {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
100% {
transform: translate(${dx}px, ${dy + 200}px) scale(0);
opacity: 0;
}
}
`;
const styleEl = document.createElement('style');
styleEl.textContent = keyframes;
document.head.appendChild(styleEl);
particle.style.animation = `${animName} 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`;
container.appendChild(particle);
// Clean up particle and animation after completion
setTimeout(() => {
particle.remove();
styleEl.remove();
}, 2000);
}
}
function highlightRareFinder(characterName) {
// Find the player in the list
const playerItems = document.querySelectorAll('#playerList li');
playerItems.forEach(item => {
const nameSpan = item.querySelector('.player-name');
if (nameSpan && nameSpan.textContent.includes(characterName)) {
item.classList.add('rare-finder-glow');
// Remove glow after 5 seconds
setTimeout(() => {
item.classList.remove('rare-finder-glow');
}, 5000);
}
});
}
// Update total rares display to trigger fireworks on increase
const originalUpdateTotalRaresDisplay = updateTotalRaresDisplay;
updateTotalRaresDisplay = function(data) {
originalUpdateTotalRaresDisplay(data);
// Check if total increased
const newTotal = data.all_time || 0;
if (newTotal > lastRareCount && lastRareCount > 0) {
// Don't trigger on initial load
createFireworks();
// Check for milestones when count increases
if (newTotal > 0 && newTotal % 100 === 0) {
triggerMilestoneCelebration(newTotal);
}
}
lastRareCount = newTotal;
}
function triggerMilestoneCelebration(rareNumber) {
console.log(`🏆 MILESTONE: Rare #${rareNumber}! 🏆`);
// Create full-screen milestone overlay
const overlay = document.createElement('div');
overlay.className = 'milestone-overlay';
overlay.innerHTML = `
<div class="milestone-content">
<div class="milestone-number">#${rareNumber}</div>
<div class="milestone-text">🏆 EPIC MILESTONE! 🏆</div>
<div class="milestone-subtitle">Server Achievement Unlocked</div>
</div>
`;
document.body.appendChild(overlay);
// Add screen shake effect
document.body.classList.add('screen-shake');
// Create massive firework explosion
createMilestoneFireworks();
// Remove milestone overlay after 5 seconds
setTimeout(() => {
overlay.style.animation = 'milestone-fade-in 0.5s ease-out reverse';
document.body.classList.remove('screen-shake');
setTimeout(() => {
overlay.remove();
}, 500);
}, 5000);
}
function createMilestoneFireworks() {
const container = document.getElementById('fireworksContainer');
// Create multiple bursts across the screen
const burstCount = 5;
const particlesPerBurst = 50;
const colors = ['#ffd700', '#ff6600', '#ff0044', '#cc00ff', '#00ccff', '#00ff00'];
for (let burst = 0; burst < burstCount; burst++) {
setTimeout(() => {
// Random position for each burst
const x = Math.random() * window.innerWidth;
const y = Math.random() * (window.innerHeight * 0.7) + (window.innerHeight * 0.15);
for (let i = 0; i < particlesPerBurst; i++) {
const particle = document.createElement('div');
particle.className = 'milestone-particle';
particle.style.background = colors[Math.floor(Math.random() * colors.length)];
particle.style.boxShadow = `0 0 12px ${particle.style.background}`;
// Start position
particle.style.left = `${x}px`;
particle.style.top = `${y}px`;
// Random explosion direction
const angle = (Math.PI * 2 * i) / particlesPerBurst + (Math.random() - 0.5) * 0.8;
const velocity = 200 + Math.random() * 300;
const dx = Math.cos(angle) * velocity;
const dy = Math.sin(angle) * velocity - 100; // Upward bias
// Create custom animation
const animName = `milestone-particle-${Date.now()}-${burst}-${i}`;
const keyframes = `
@keyframes ${animName} {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
100% {
transform: translate(${dx}px, ${dy + 400}px) scale(0);
opacity: 0;
}
}
`;
const styleEl = document.createElement('style');
styleEl.textContent = keyframes;
document.head.appendChild(styleEl);
particle.style.animation = `${animName} 3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`;
container.appendChild(particle);
// Clean up
setTimeout(() => {
particle.remove();
styleEl.remove();
}, 3000);
}
}, burst * 200); // Stagger bursts
}
}

View file

@ -122,6 +122,104 @@ body {
font-size: 1.25rem; font-size: 1.25rem;
color: var(--accent); color: var(--accent);
} }
.total-rares-counter {
margin: 0 0 12px 0;
padding: 8px 12px;
background: linear-gradient(135deg, #2a2a2a, #1a1a1a);
border: 1px solid #444;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 600;
color: #ffd700;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.total-rares-counter #totalRaresCount {
color: #fff;
margin-left: 4px;
}
.server-kph-counter {
margin: 0 0 12px 0;
padding: 9px 12px;
background: linear-gradient(135deg, #2a2a44, #1a1a33);
border: 2px solid #4466aa;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
color: #aaccff;
text-align: center;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4);
position: relative;
animation: kph-border-glow 4s ease-in-out infinite;
}
@keyframes kph-border-glow {
0%, 100% { border-color: #4466aa; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4); }
50% { border-color: #6688cc; box-shadow: 0 3px 12px rgba(102, 136, 204, 0.3); }
}
.server-kph-counter #serverKphCount {
color: #fff;
margin-left: 4px;
font-size: 1.1rem;
font-weight: 700;
text-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
animation: kph-pulse 3s ease-in-out infinite;
}
@keyframes kph-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
/* ULTRA MODE for KPH > 5000 */
.server-kph-counter.ultra-epic {
background: linear-gradient(135deg, #6644ff, #4422cc, #6644ff);
background-size: 200% 200%;
animation: kph-border-glow 4s ease-in-out infinite, ultra-background 3s ease-in-out infinite;
border-color: #8866ff;
color: #eeeeff;
box-shadow: 0 4px 12px rgba(102, 68, 255, 0.5);
}
@keyframes ultra-background {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.server-kph-counter.ultra-epic #serverKphCount {
font-size: 1.3rem;
color: #ffffff;
text-shadow: 0 0 12px rgba(255, 255, 255, 0.7);
animation: kph-pulse 3s ease-in-out infinite, ultra-glow 2s ease-in-out infinite alternate;
}
@keyframes ultra-glow {
from { text-shadow: 0 0 12px rgba(255, 255, 255, 0.7); }
to { text-shadow: 0 0 18px rgba(255, 255, 255, 0.9), 0 0 25px rgba(136, 102, 255, 0.5); }
}
.total-kills-counter {
margin: 0 0 12px 0;
padding: 8px 12px;
background: linear-gradient(135deg, #2a2a2a, #1a1a1a);
border: 1px solid #555;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 600;
color: #ff6666;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.total-kills-counter #totalKillsCount {
color: #fff;
margin-left: 4px;
}
#playerList { #playerList {
list-style: none; list-style: none;
margin: 0; margin: 0;
@ -243,9 +341,10 @@ body {
#playerList li { #playerList li {
display: grid; display: grid;
grid-template-columns: 1fr auto auto auto auto auto; grid-template-columns: 1fr auto auto auto auto auto;
grid-template-rows: auto auto auto auto; grid-template-rows: auto auto auto auto auto;
grid-template-areas: grid-template-areas:
"name name name name name name" "name name name name name name"
"vitals vitals vitals vitals vitals vitals"
"kills totalkills kph kph kph kph" "kills totalkills kph kph kph kph"
"rares kpr meta meta meta meta" "rares kpr meta meta meta meta"
"onlinetime deaths tapers tapers tapers tapers"; "onlinetime deaths tapers tapers tapers tapers";
@ -254,7 +353,7 @@ body {
padding: 8px 10px; padding: 8px 10px;
background: var(--card); background: var(--card);
border-left: 4px solid transparent; border-left: 4px solid transparent;
transition: background 0.15s; transition: none;
font-size: 0.85rem; font-size: 0.85rem;
} }
@ -272,6 +371,8 @@ body {
.stat.deaths { grid-area: deaths; } .stat.deaths { grid-area: deaths; }
.stat.tapers { grid-area: tapers; } .stat.tapers { grid-area: tapers; }
.player-vitals { grid-area: vitals; }
/* pill styling */ /* pill styling */
#playerList li .stat { #playerList li .stat {
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
@ -719,3 +820,359 @@ body.noselect, body.noselect * {
margin-top: 4px; margin-top: 4px;
text-align: center; text-align: center;
} }
/* ---------- inline vitals bars ---------------------------------- */
.player-vitals {
grid-column: 1 / -1;
margin: 2px 0 4px 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.vital-bar-inline {
height: 5px;
background: #222;
border-radius: 3px;
overflow: hidden;
position: relative;
}
.vitae-indicator {
font-size: 0.75rem;
color: #ff6666;
margin-left: 8px;
font-weight: 500;
}
.vital-fill {
height: 100%;
transition: width 0.3s ease-out;
border-radius: 2px;
}
.vital-fill.health {
background: linear-gradient(90deg, #ff4444, #ff6666);
}
.vital-fill.stamina {
background: linear-gradient(90deg, #ffaa00, #ffcc44);
}
.vital-fill.mana {
background: linear-gradient(90deg, #4488ff, #66aaff);
}
/* Pulsing effects for low vitals */
.vital-bar-inline.low-vital {
animation: pulse-bar-low 2s ease-in-out infinite;
}
.vital-bar-inline.critical-vital {
animation: pulse-bar-critical 1s ease-in-out infinite;
}
@keyframes pulse-bar-low {
0%, 100% { background: #222; }
50% { background: #332200; }
}
@keyframes pulse-bar-critical {
0%, 100% { background: #222; }
50% { background: #440000; }
}
/* ---------- epic rare notifications ------------------------------ */
.rare-notifications {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 10001;
pointer-events: none;
}
.rare-notification {
background: linear-gradient(135deg, #ffd700, #ffed4e, #ffd700);
border: 3px solid #ff6600;
border-radius: 12px;
padding: 20px 30px;
margin-bottom: 10px;
text-align: center;
box-shadow: 0 8px 32px rgba(255, 215, 0, 0.5);
animation: notification-slide-in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55),
epic-glow 2s ease-in-out infinite;
position: relative;
overflow: hidden;
}
@keyframes notification-slide-in {
from {
transform: translateY(-100px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes epic-glow {
0%, 100% {
box-shadow: 0 8px 32px rgba(255, 215, 0, 0.5);
}
50% {
box-shadow: 0 8px 48px rgba(255, 215, 0, 0.8);
}
}
.rare-notification-title {
font-size: 1.2rem;
font-weight: 800;
color: #ff0044;
text-transform: uppercase;
margin-bottom: 8px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
animation: epic-text-pulse 1s ease-in-out infinite;
}
@keyframes epic-text-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.rare-notification-mob {
font-size: 1.5rem;
font-weight: 700;
color: #1a0033;
margin-bottom: 4px;
text-shadow: 2px 2px 4px rgba(255, 255, 255, 0.5);
}
.rare-notification-finder {
font-size: 1rem;
color: #333;
font-style: italic;
margin-bottom: 4px;
}
.rare-notification-character {
font-size: 1.3rem;
font-weight: 700;
color: #ff0044;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
/* Shine effect overlay */
.rare-notification::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg,
transparent 30%,
rgba(255, 255, 255, 0.5) 50%,
transparent 70%
);
transform: rotate(45deg);
animation: notification-shine 3s infinite;
}
@keyframes notification-shine {
0% { transform: translateX(-100%) translateY(-100%) rotate(45deg); }
100% { transform: translateX(100%) translateY(100%) rotate(45deg); }
}
/* ---------- fireworks particles ---------------------------------- */
.fireworks-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
}
.firework-particle {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
pointer-events: none;
animation: firework-fly 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
@keyframes firework-fly {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
100% {
opacity: 0;
}
}
/* Different particle colors */
.particle-gold { background: #ffd700; box-shadow: 0 0 6px #ffd700; }
.particle-red { background: #ff4444; box-shadow: 0 0 6px #ff4444; }
.particle-orange { background: #ff8800; box-shadow: 0 0 6px #ff8800; }
.particle-purple { background: #cc00ff; box-shadow: 0 0 6px #cc00ff; }
.particle-blue { background: #00ccff; box-shadow: 0 0 6px #00ccff; }
/* Character glow effect in player list */
.player-item.rare-finder-glow {
animation: rare-finder-highlight 5s ease-in-out;
border-left-color: #ffd700 !important;
border-left-width: 6px !important;
}
@keyframes rare-finder-highlight {
0%, 100% {
background: var(--card);
box-shadow: none;
}
50% {
background: rgba(255, 215, 0, 0.2);
box-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
}
}
/* ---------- milestone celebration overlay ------------------------ */
.milestone-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(ellipse at center, rgba(255, 215, 0, 0.3), rgba(0, 0, 0, 0.8));
z-index: 20000;
display: flex;
align-items: center;
justify-content: center;
animation: milestone-fade-in 0.5s ease-out;
}
@keyframes milestone-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.milestone-content {
text-align: center;
animation: milestone-zoom 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
@keyframes milestone-zoom {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.milestone-number {
font-size: 8rem;
font-weight: 900;
color: #ffd700;
text-shadow:
0 0 30px #ffd700,
0 0 60px #ff6600,
0 0 90px #ff0044,
0 0 120px #ff0044;
margin-bottom: 20px;
animation: milestone-pulse 1s ease-in-out infinite alternate;
}
@keyframes milestone-pulse {
from {
transform: scale(1);
text-shadow:
0 0 30px #ffd700,
0 0 60px #ff6600,
0 0 90px #ff0044,
0 0 120px #ff0044;
}
to {
transform: scale(1.1);
text-shadow:
0 0 40px #ffd700,
0 0 80px #ff6600,
0 0 120px #ff0044,
0 0 160px #ff0044;
}
}
.milestone-text {
font-size: 3rem;
font-weight: 700;
color: #fff;
text-transform: uppercase;
letter-spacing: 0.2em;
text-shadow: 0 0 20px rgba(255, 255, 255, 0.8);
animation: milestone-text-glow 2s ease-in-out infinite;
}
@keyframes milestone-text-glow {
0%, 100% {
opacity: 0.8;
}
50% {
opacity: 1;
}
}
.milestone-subtitle {
font-size: 1.5rem;
color: #ffcc00;
margin-top: 20px;
font-style: italic;
animation: milestone-subtitle-slide 1s ease-out;
}
@keyframes milestone-subtitle-slide {
from {
transform: translateY(50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Milestone firework burst - larger particles */
.milestone-particle {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
pointer-events: none;
background: #ffd700;
box-shadow: 0 0 12px #ffd700;
}
/* Screen shake effect */
@keyframes screen-shake {
0%, 100% { transform: translate(0, 0); }
10% { transform: translate(-5px, -5px); }
20% { transform: translate(5px, -5px); }
30% { transform: translate(-5px, 5px); }
40% { transform: translate(5px, 5px); }
50% { transform: translate(-3px, -3px); }
60% { transform: translate(3px, -3px); }
70% { transform: translate(-3px, 3px); }
80% { transform: translate(3px, 3px); }
90% { transform: translate(-1px, -1px); }
}
.screen-shake {
animation: screen-shake 0.5s ease-in-out;
}