Merge pull request 'feat: animated resource counters — pulse on gain, shake on loss (#57)' (#71) from beacon/polish into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s

This commit was merged in pull request #71.
This commit is contained in:
2026-04-12 05:33:28 +00:00
3 changed files with 80 additions and 4 deletions

View File

@@ -95,7 +95,15 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
.toast-info{background:rgba(74,158,255,0.12);border-color:#4a9eff;color:#80bfff}
@keyframes toast-in{from{transform:translateX(40px);opacity:0}to{transform:translateX(0);opacity:0.95}}
@keyframes toast-out{from{opacity:0.95;transform:translateX(0)}to{opacity:0;transform:translateX(40px)}}
@keyframes res-pulse{0%{transform:scale(1)}50%{transform:scale(1.18);color:#80d0ff}100%{transform:scale(1)}}
@keyframes res-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-3px)}40%{transform:translateX(3px)}60%{transform:translateX(-2px)}80%{transform:translateX(2px)}}
.r-val.pulse{animation:res-pulse 0.35s ease-out}
.r-val.shake{animation:res-shake 0.3s ease-out;color:var(--red)!important}
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
#custom-tooltip{position:fixed;pointer-events:none;z-index:300;max-width:280px;padding:8px 12px;background:#0e1420;border:1px solid #1a3a5a;border-radius:6px;font-size:10px;color:#80bfff;line-height:1.5;opacity:0;transition:opacity 0.15s;box-shadow:0 4px 16px rgba(0,0,0,0.5)}
#custom-tooltip.visible{opacity:1}
#custom-tooltip .tt-label{color:var(--accent);font-weight:600;margin-bottom:3px;font-size:10px}
#custom-tooltip .tt-edu{color:#888;font-style:italic;font-size:9px;border-top:1px solid #1a2a3a;padding-top:4px;margin-top:4px}
</style>
</head>
<body>
@@ -244,5 +252,6 @@ The light is on. The room is empty."
</div>
<div id="toast-container"></div>
<div id="custom-tooltip"></div>
</body>
</html>

View File

@@ -739,16 +739,43 @@ function tickSprint(dt) {
}
// === RENDERING ===
// Track previous resource values for gain/loss animations
const _prevRes = {};
function _animRes(id, val) {
const el = document.getElementById(id);
if (!el) return;
const prev = _prevRes[id];
if (prev !== undefined && val !== prev) {
// Remove any running animation
el.classList.remove('pulse', 'shake');
void el.offsetWidth; // force reflow
if (val > prev) {
el.classList.add('pulse');
} else {
el.classList.add('shake');
}
// Clean up class after animation ends
clearTimeout(el._animTimer);
el._animTimer = setTimeout(() => el.classList.remove('pulse', 'shake'), 400);
}
_prevRes[id] = val;
}
function renderResources() {
const set = (id, val, rate) => {
const el = document.getElementById(id);
if (el) {
_animRes(id, val);
el.textContent = fmt(val);
// Show full spelled-out number on hover for educational value
el.title = val >= 1000 ? spellf(Math.floor(val)) : '';
}
const rEl = document.getElementById(id + '-rate');
if (rEl) rEl.textContent = (rate >= 0 ? '+' : '') + fmt(rate) + '/s';
if (rEl) {
rEl.textContent = (rate >= 0 ? '+' : '') + fmt(rate) + '/s';
rEl.style.color = rate > 0 ? '#4caf50' : rate < 0 ? '#f44336' : '#444';
}
};
set('r-code', G.code, G.codeRate);
@@ -886,7 +913,7 @@ function renderBuildings() {
// Locked preview: show dimmed with unlock hint
if (!isUnlocked) {
html += `<div class="build-btn" style="opacity:0.25;cursor:default" title="${def.edu || ''}">`;
html += `<div class="build-btn" style="opacity:0.25;cursor:default" data-edu="${def.edu || ''}" data-tooltip-label="${def.name} (Locked)">`;
html += `<span class="b-name" style="color:#555">${def.name}</span>`;
html += `<span class="b-count" style="color:#444">\u{1F512}</span>`;
html += `<span class="b-cost" style="color:#444">Phase ${def.phase}: ${PHASES[def.phase]?.name || '?'}</span>`;
@@ -927,7 +954,7 @@ function renderBuildings() {
return boost !== 1 ? `+${fmt(boosted)}/${r}/s` : `+${v}/${r}/s`;
}).join(', ') : '';
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" title="${def.edu}" aria-label="Buy ${def.name}, cost ${costStr}">`;
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" data-edu="${def.edu || ''}" data-tooltip-label="${def.name}" aria-label="Buy ${def.name}, cost ${costStr}">`;
html += `<span class="b-name">${def.name}</span>`;
if (count > 0) html += `<span class="b-count">x${count}</span>`;
html += `<span class="b-cost">Cost: ${costStr}</span>`;
@@ -970,7 +997,7 @@ function renderProjects() {
const afford = canAffordProject(pDef);
const costStr = Object.entries(pDef.cost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" title="${pDef.edu || ''}" aria-label="Research ${pDef.name}, cost ${costStr}">`;
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" data-edu="${pDef.edu || ''}" data-tooltip-label="${pDef.name}" aria-label="Research ${pDef.name}, cost ${costStr}">`;
html += `<span class="p-name">* ${pDef.name}</span>`;
html += `<span class="p-cost">Cost: ${costStr}</span>`;
html += `<span class="p-desc">${pDef.desc}</span></button>`;

View File

@@ -109,3 +109,43 @@ document.addEventListener('visibilitychange', function () {
window.addEventListener('beforeunload', function () {
saveGame();
});
// === CUSTOM TOOLTIP SYSTEM (#57) ===
// Replaces native title= tooltips with styled, instant-appearing tooltips.
// Elements opt in via data-edu="..." and data-tooltip-label="..." attributes.
(function () {
const tip = document.getElementById('custom-tooltip');
if (!tip) return;
document.addEventListener('mouseover', function (e) {
const el = e.target.closest('[data-edu]');
if (!el) return;
const label = el.getAttribute('data-tooltip-label') || '';
const edu = el.getAttribute('data-edu') || '';
let html = '';
if (label) html += '<div class="tt-label">' + label + '</div>';
if (edu) html += '<div class="tt-edu">' + edu + '</div>';
if (!html) return;
tip.innerHTML = html;
tip.classList.add('visible');
});
document.addEventListener('mouseout', function (e) {
const el = e.target.closest('[data-edu]');
if (el) tip.classList.remove('visible');
});
document.addEventListener('mousemove', function (e) {
if (!tip.classList.contains('visible')) return;
const pad = 12;
let x = e.clientX + pad;
let y = e.clientY + pad;
// Keep tooltip on screen
const tw = tip.offsetWidth;
const th = tip.offsetHeight;
if (x + tw > window.innerWidth - 8) x = e.clientX - tw - pad;
if (y + th > window.innerHeight - 8) y = e.clientY - th - pad;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
});
})();