added epic counters
This commit is contained in:
parent
10c51f6825
commit
09a6cd4946
4 changed files with 963 additions and 8 deletions
114
main.py
114
main.py
|
|
@ -56,7 +56,9 @@ INVENTORY_SERVICE_URL = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-ser
|
|||
# In-memory caches for REST endpoints
|
||||
_cached_live: dict = {"players": []}
|
||||
_cached_trails: dict = {"trails": []}
|
||||
_cached_total_rares: dict = {"all_time": 0, "today": 0, "last_updated": None}
|
||||
_cache_task: asyncio.Task | None = None
|
||||
_rares_cache_task: asyncio.Task | None = None
|
||||
|
||||
async def _refresh_cache_loop() -> None:
|
||||
"""Background task: refresh `/live` and `/trails` caches every 5 seconds."""
|
||||
|
|
@ -131,10 +133,61 @@ async def _refresh_cache_loop() -> None:
|
|||
|
||||
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()
|
||||
# In-memory store mapping character_name to the most recent telemetry snapshot
|
||||
live_snapshots: Dict[str, dict] = {}
|
||||
live_vitals: Dict[str, dict] = {}
|
||||
|
||||
# Shared secret used to authenticate plugin WebSocket connections (override for production)
|
||||
SHARED_SECRET = "your_shared_secret"
|
||||
|
|
@ -212,6 +265,25 @@ class FullInventoryMessage(BaseModel):
|
|||
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")
|
||||
async def on_startup():
|
||||
"""Event handler triggered when application starts up.
|
||||
|
|
@ -238,17 +310,18 @@ async def on_startup():
|
|||
else:
|
||||
raise RuntimeError(f"Could not connect to database after {max_attempts} attempts")
|
||||
# Start background cache refresh (live & trails)
|
||||
global _cache_task
|
||||
global _cache_task, _rares_cache_task
|
||||
_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")
|
||||
async def on_shutdown():
|
||||
"""Event handler triggered when application is shutting down.
|
||||
|
||||
Ensures the database connection is closed cleanly.
|
||||
"""
|
||||
# Stop cache refresh task
|
||||
global _cache_task
|
||||
# Stop cache refresh tasks
|
||||
global _cache_task, _rares_cache_task
|
||||
if _cache_task:
|
||||
logger.info("Stopping background cache refresh task")
|
||||
_cache_task.cancel()
|
||||
|
|
@ -256,6 +329,14 @@ async def on_shutdown():
|
|||
await _cache_task
|
||||
except asyncio.CancelledError:
|
||||
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")
|
||||
await database.disconnect()
|
||||
|
||||
|
|
@ -294,6 +375,17 @@ async def get_trails(
|
|||
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 ---------------------------------
|
||||
@app.get("/inventory/{character_name}")
|
||||
async def get_character_inventory(character_name: str):
|
||||
|
|
@ -669,6 +761,8 @@ async def ws_receive_snapshots(
|
|||
rare_events.insert().values(**rare_ev.dict())
|
||||
)
|
||||
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:
|
||||
logger.error(f"Failed to persist rare event: {e}")
|
||||
except Exception as e:
|
||||
|
|
@ -690,6 +784,18 @@ async def ws_receive_snapshots(
|
|||
except Exception as e:
|
||||
logger.error(f"Failed to process inventory for {data.get('character_name', 'unknown')}: {e}", exc_info=True)
|
||||
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
|
||||
if msg_type:
|
||||
logger.warning(f"Unknown message type '{msg_type}' from {websocket.client}")
|
||||
|
|
|
|||
|
|
@ -17,12 +17,33 @@
|
|||
<!-- Container for sort and filter controls -->
|
||||
<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 -->
|
||||
<input type="text" id="playerFilter" class="player-filter" placeholder="Filter players..." />
|
||||
|
||||
|
||||
<ul id="playerList"></ul>
|
||||
</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 -->
|
||||
<div id="mapContainer">
|
||||
<div id="mapGroup">
|
||||
|
|
|
|||
373
static/script.js
373
static/script.js
|
|
@ -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() {
|
||||
if (pollID !== null) return;
|
||||
pollLive();
|
||||
pollTotalRares(); // Initial fetch
|
||||
pollID = setInterval(pollLive, POLL_MS);
|
||||
// Poll total rares every 5 minutes (300,000 ms)
|
||||
setInterval(pollTotalRares, 300000);
|
||||
}
|
||||
|
||||
img.onload = () => {
|
||||
|
|
@ -968,6 +990,43 @@ function render(players) {
|
|||
dots.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 => {
|
||||
const { x, y } = worldToPx(p.ew, p.ns);
|
||||
|
||||
|
|
@ -1002,7 +1061,8 @@ function render(players) {
|
|||
const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞';
|
||||
|
||||
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 total-kills">${p.total_kills || 0}</span>
|
||||
<span class="stat kph">${p.kills_per_hour}</span>
|
||||
|
|
@ -1110,6 +1170,10 @@ function initWebSocket() {
|
|||
try { msg = JSON.parse(evt.data); } catch { return; }
|
||||
if (msg.type === 'chat') {
|
||||
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));
|
||||
|
|
@ -1276,3 +1340,310 @@ wrap.addEventListener('mousemove', e => {
|
|||
wrap.addEventListener('mouseleave', () => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
461
static/style.css
461
static/style.css
|
|
@ -122,6 +122,104 @@ body {
|
|||
font-size: 1.25rem;
|
||||
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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
|
@ -243,9 +341,10 @@ body {
|
|||
#playerList li {
|
||||
display: grid;
|
||||
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:
|
||||
"name name name name name name"
|
||||
"vitals vitals vitals vitals vitals vitals"
|
||||
"kills totalkills kph kph kph kph"
|
||||
"rares kpr meta meta meta meta"
|
||||
"onlinetime deaths tapers tapers tapers tapers";
|
||||
|
|
@ -254,7 +353,7 @@ body {
|
|||
padding: 8px 10px;
|
||||
background: var(--card);
|
||||
border-left: 4px solid transparent;
|
||||
transition: background 0.15s;
|
||||
transition: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +371,8 @@ body {
|
|||
.stat.deaths { grid-area: deaths; }
|
||||
.stat.tapers { grid-area: tapers; }
|
||||
|
||||
.player-vitals { grid-area: vitals; }
|
||||
|
||||
/* pill styling */
|
||||
#playerList li .stat {
|
||||
background: rgba(255,255,255,0.1);
|
||||
|
|
@ -719,3 +820,359 @@ body.noselect, body.noselect * {
|
|||
margin-top: 4px;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue