feat(ui): add visual sync countdown bar, quick stash overview strip, and live button state reactive updates

This commit is contained in:
Christian 2026-10-15 16:34:01 +02:00
parent 0a7fb7322c
commit 2fdc030e9d
3 changed files with 209 additions and 18 deletions

View file

@ -201,6 +201,111 @@ html, body {
transition: width 0.3s ease;
}
/* Server Sync Countdown Bar */
#sync-container {
background: #0b0e12;
padding: 3px 14px 6px;
display: flex;
flex-direction: column;
gap: 3px;
border-top: 1px solid rgba(36, 45, 54, 0.4);
}
.sync-label-row {
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 0.62rem;
color: var(--text-secondary);
}
.sync-status-text {
display: inline-flex;
align-items: center;
gap: 5px;
letter-spacing: 0.5px;
}
.sync-pulse-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--neon-cyan);
box-shadow: 0 0 6px var(--neon-cyan);
animation: pulse 1.5s infinite;
}
.sync-bar-track {
width: 100%;
height: 4px;
background: #151b22;
border-radius: 2px;
overflow: hidden;
}
#sync-bar-fill {
width: 100%;
height: 100%;
background: linear-gradient(90deg, var(--neon-cyan) 0%, #3b82f6 100%);
transition: width 0.1s linear;
}
/* Quick Stash Overview Strip (Sticky Under Sync Bar) */
#quick-stash-strip {
background: #080b0e;
border-top: 1px solid rgba(36, 45, 54, 0.4);
padding: 6px 14px;
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
#quick-stash-strip::-webkit-scrollbar {
display: none;
}
.stash-strip-title {
font-family: var(--font-mono);
font-size: 0.62rem;
color: var(--text-secondary);
white-space: nowrap;
letter-spacing: 0.5px;
font-weight: 700;
}
.stash-strip-items {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.stash-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: #111720;
border: 1px solid #232e3a;
border-radius: 6px;
padding: 2px 7px;
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--text-primary);
}
.stash-chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.stash-chip-val {
font-weight: 700;
}
/* Main Viewport Content */
#main-viewport {
flex: 1;

View file

@ -61,6 +61,29 @@
<div id="heat-bar-fill"></div>
</div>
</div>
<!-- Server Sync Countdown Bar -->
<div id="sync-container">
<div class="sync-label-row">
<span class="sync-status-text">
<span class="sync-pulse-dot"></span> NEXT SERVER TICK
</span>
<span id="sync-timer-text">6.0s</span>
</div>
<div class="sync-bar-track">
<div id="sync-bar-fill"></div>
</div>
</div>
<!-- Quick Inventory Overview Strip (Always visible) -->
<div id="quick-stash-strip">
<div class="stash-strip-title">
<span>STASH OVERVIEW:</span>
</div>
<div id="quick-stash-items" class="stash-strip-items">
<!-- Dynamically populated chips of all owned products -->
</div>
</div>
</header>
<!-- Toast Notifications Container -->

View file

@ -68,7 +68,10 @@ const dom = {
authModal: document.getElementById('auth-modal'),
raidModal: document.getElementById('raid-modal'),
raidBribeCost: document.getElementById('raid-bribe-cost'),
toastContainer: document.getElementById('toast-container')
toastContainer: document.getElementById('toast-container'),
syncTimer: document.getElementById('sync-timer-text'),
syncBar: document.getElementById('sync-bar-fill'),
quickStash: document.getElementById('quick-stash-items')
};
// --- MULTIPLIERS & BONUSES ---
@ -219,22 +222,22 @@ function renderLabView() {
</div>
<div class="btn-grid-actions">
<button class="btn-tactical" onclick="buyRaw('${d.id}')" ${G.cash < rawCost ? 'disabled' : ''}>
<button id="btn-buy-raw-${d.id}" class="btn-tactical" onclick="buyRaw('${d.id}')" ${G.cash < rawCost ? 'disabled' : ''}>
Buy Raw
<span class="btn-subtext">$${rawCost.toLocaleString()}</span>
</button>
<button class="btn-tactical btn-primary-action" onclick="manufacture('${d.id}')" ${data.raw < 1 ? 'disabled' : ''}>
<button id="btn-make-${d.id}" class="btn-tactical btn-primary-action" onclick="manufacture('${d.id}')" ${data.raw < 1 ? 'disabled' : ''}>
Synthesize
<span class="btn-subtext">1 Raw -> 1g</span>
</button>
<button class="btn-tactical btn-sell-action" onclick="sellManual('${d.id}')" ${data.product < 1 ? 'disabled' : ''}>
<button id="btn-sell-${d.id}" class="btn-tactical btn-sell-action" onclick="sellManual('${d.id}')" ${data.product < 1 ? 'disabled' : ''}>
Quick Deal
<span class="btn-subtext">+$${Math.floor(d.sellPrice * getSellPriceMultiplier()).toLocaleString()}</span>
</button>
</div>
<div class="btn-grid-auto">
<button class="btn-tactical" onclick="hireWorker('${d.id}')" ${G.cash < workerCost ? 'disabled' : ''}>
<button id="btn-hire-worker-${d.id}" class="btn-tactical" onclick="hireWorker('${d.id}')" ${G.cash < workerCost ? 'disabled' : ''}>
Hire ${d.workerName}
<span class="btn-subtext">$${workerCost.toLocaleString()}</span>
</button>
@ -291,11 +294,11 @@ function renderStreetsView() {
</div>
<div class="btn-grid-auto">
<button class="btn-tactical btn-sell-action" onclick="hireDealer('${d.id}')" ${G.cash < dealerCost ? 'disabled' : ''}>
<button id="btn-hire-dealer-${d.id}" class="btn-tactical btn-sell-action" onclick="hireDealer('${d.id}')" ${G.cash < dealerCost ? 'disabled' : ''}>
Hire ${d.dealerName}
<span class="btn-subtext">$${dealerCost.toLocaleString()}</span>
</button>
<button class="btn-tactical" onclick="sellManual('${d.id}', 10)" ${data.product < 10 ? 'disabled' : ''}>
<button id="btn-sell-10x-${d.id}" class="btn-tactical" onclick="sellManual('${d.id}', 10)" ${data.product < 10 ? 'disabled' : ''}>
Sell 10x Stash
<span class="btn-subtext">+$${(unitPrice * Math.min(10, Math.floor(data.product))).toLocaleString()}</span>
</button>
@ -720,6 +723,24 @@ function clientTick(deltaSec) {
// Direct, surgical DOM text updates every tick (no lag, no re-rendering entire card HTML)
updateLiveInventory();
updateSyncCountdown();
}
const TICK_INTERVAL_MS = 6000;
let lastSyncTimestamp = Date.now();
function updateSyncCountdown() {
const elapsed = Date.now() - lastSyncTimestamp;
const remainingMs = Math.max(0, TICK_INTERVAL_MS - elapsed);
const remainingSec = (remainingMs / 1000).toFixed(1);
const pct = Math.min(100, Math.max(0, (remainingMs / TICK_INTERVAL_MS) * 100));
if (dom.syncTimer) {
dom.syncTimer.textContent = `${remainingSec}s`;
}
if (dom.syncBar) {
dom.syncBar.style.width = `${pct}%`;
}
}
function updateLiveInventory() {
@ -730,27 +751,69 @@ function updateLiveInventory() {
dom.heatPct.textContent = `${Math.round(G.heat)}%`;
dom.heatBar.style.width = `${Math.min(100, Math.round(G.heat))}%`;
// Surgically update raw and product text fields if they exist in DOM
const rawCostMulti = getRawCostMultiplier();
let stashChipsHtml = '';
// Surgically update raw, product, buttons, and stash chips
DRUG_CATALOG.forEach(d => {
const drugData = G.drugs[d.id];
if (!drugData) return;
// Populate quick stash overview for owned/unlocked products
if (drugData.unlocked || drugData.product > 0 || drugData.raw > 0) {
stashChipsHtml += `
<div class="stash-chip" title="${d.name}">
<span class="stash-chip-dot" style="background: ${d.color};"></span>
<span>${d.name.split(' ')[0]}:</span>
<span class="stash-chip-val" style="color: ${drugData.product > 0 ? '#fff' : '#6b7280'}">${fmtNum(drugData.product)}g</span>
</div>
`;
}
// Live text update for raw & product
const rawEl = document.getElementById(`val-raw-${d.id}`);
if (rawEl && G.drugs[d.id]) {
rawEl.textContent = fmtNum(G.drugs[d.id].raw);
}
if (rawEl) rawEl.textContent = fmtNum(drugData.raw);
const prodEl = document.getElementById(`val-prod-${d.id}`);
if (prodEl && G.drugs[d.id]) {
prodEl.textContent = `${fmtNum(G.drugs[d.id].product)}g`;
}
if (prodEl) prodEl.textContent = `${fmtNum(drugData.product)}g`;
const streetProdEl = document.getElementById(`val-prod-street-${d.id}`);
if (streetProdEl && G.drugs[d.id]) {
streetProdEl.textContent = `${fmtNum(G.drugs[d.id].product)}g`;
}
if (streetProdEl) streetProdEl.textContent = `${fmtNum(drugData.product)}g`;
// Live button state updates (enable/disable as resources change)
const rawCost = Math.floor(d.rawCost * rawCostMulti);
const workerCost = getWorkerCost(d.id);
const dealerCost = getDealerCost(d.id);
const btnBuyRaw = document.getElementById(`btn-buy-raw-${d.id}`);
if (btnBuyRaw) btnBuyRaw.disabled = (G.cash < rawCost);
const btnMake = document.getElementById(`btn-make-${d.id}`);
if (btnMake) btnMake.disabled = (drugData.raw < 1);
const btnSell = document.getElementById(`btn-sell-${d.id}`);
if (btnSell) btnSell.disabled = (drugData.product < 1);
const btnHireWorker = document.getElementById(`btn-hire-worker-${d.id}`);
if (btnHireWorker) btnHireWorker.disabled = (G.cash < workerCost);
const btnHireDealer = document.getElementById(`btn-hire-dealer-${d.id}`);
if (btnHireDealer) btnHireDealer.disabled = (G.cash < dealerCost);
const btnSell10x = document.getElementById(`btn-sell-10x-${d.id}`);
if (btnSell10x) btnSell10x.disabled = (drugData.product < 10);
});
if (dom.quickStash) {
dom.quickStash.innerHTML = stashChipsHtml || '<span style="font-size: 0.65rem; color: #6b7280;">No active stash</span>';
}
}
// --- AUTHORITATIVE SERVER TICK (Every 6 seconds) ---
async function authoritativeServerTick() {
if (isSyncing) return;
isSyncing = true;
lastSyncTimestamp = Date.now();
// Grab queued actions and reset local queue
const actionsToSend = [...pendingActions];
@ -788,7 +851,7 @@ function gameLoop(now) {
}
// Server tick: exactly every 6 seconds to prevent flooding server
setInterval(authoritativeServerTick, 6000);
setInterval(authoritativeServerTick, TICK_INTERVAL_MS);
// Clock updates
setInterval(() => {