← back to Games Agentabrams
index.html
401 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>games.agentabrams.com</title>
<!-- file:// fallback: inlined mirror of games.json (a <script src> works over file://, fetch() does not). Regenerated by sync.sh. -->
<script src="games-data.js"></script>
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: flex; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: #12141a; color: #e8e9ef; overflow: hidden;
}
/* ---- left panel ---- */
#panel {
width: 260px; min-width: 260px; height: 100%; display: flex; flex-direction: column;
background: linear-gradient(180deg, #1a1e28, #14171f);
border-right: 1px solid #2b3140;
}
#brand {
padding: 18px 16px 14px; border-bottom: 1px solid #2b3140;
}
#brand .dot { color: #7c5cff; }
#brand h1 {
margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 1.5px;
}
#brand .sub { font-size: 10px; opacity: .45; letter-spacing: 1px; margin-top: 4px; }
#list { flex: 1; overflow-y: auto; padding: 10px 8px; }
#list .dir { font-size: 10px; opacity: .4; letter-spacing: 1px; padding: 4px 8px 8px; }
.game {
display: flex; align-items: flex-start; gap: 8px; width: 100%; text-align: left; cursor: pointer;
background: none; border: 1px solid transparent; border-radius: 8px;
color: #e8e9ef; font: inherit; padding: 9px 10px; margin-bottom: 2px;
}
.game:hover { background: #222839; }
.game.active { background: #2a3147; border-color: #3d4763; }
.game .ficon { font-size: 16px; line-height: 1.25; flex: 0 0 auto; width: 20px; text-align: center; }
.game .fbody { flex: 1; min-width: 0; }
.game .fname { font-size: 13px; font-weight: 600; }
.game.active .fname::before { content: "▾ "; color: #7c5cff; }
.game .fdesc { font-size: 10.5px; opacity: .5; margin-top: 3px; line-height: 1.4;
display: none; }
.game.active .fdesc { display: block; }
/* open-in-own-window pop-out button */
.game .popout {
flex: 0 0 auto; align-self: center; cursor: pointer; text-decoration: none;
font-size: 13px; line-height: 1; color: #8a93a8; opacity: 0;
background: none; border: 1px solid transparent; border-radius: 6px; padding: 4px 6px;
transition: opacity .15s, background .15s, color .15s;
}
/* reveal on hover, on the active row, AND on keyboard focus so tab users can
see + reach the pop-out (a11y: it was invisible to keyboard nav before). */
.game:hover .popout, .game.active .popout, .game:focus-within .popout { opacity: .8; }
.game .popout:hover, .game .popout:focus-visible { opacity: 1; color: #ffd23e; background: #1b2030; border-color: #3d4763; }
.game .popout:focus-visible { outline: 2px solid #ffd23e; outline-offset: 1px; }
/* stage pop-out for the current game */
#popCurrent {
position: absolute; top: 12px; right: 12px; z-index: 5; cursor: pointer; text-decoration: none;
font-family: inherit; font-size: 11px; letter-spacing: .5px; color: #cdd3e0;
background: rgba(20,23,31,.72); border: 1px solid #2b3140; border-radius: 8px;
padding: 7px 11px; backdrop-filter: blur(6px); display: none;
}
#popCurrent:hover { color: #ffd23e; border-color: #3d4763; }
#panel footer {
padding: 12px 16px; border-top: 1px solid #2b3140; font-size: 10px;
letter-spacing: .5px; line-height: 1.6;
}
#panel footer .fnote { opacity: .4; margin-top: 4px; }
#panel footer .xlink {
display: inline-block; color: #e9c46a; text-decoration: none;
font-weight: 700; letter-spacing: .6px;
}
#panel footer .xlink:hover { color: #f6de9b; text-decoration: underline; }
/* ---- right panel ---- */
#stage { flex: 1; height: 100%; position: relative; background: #0e1016; }
#frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
#empty {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 10px; opacity: .35; font-size: 13px; letter-spacing: 1px;
}
#empty .big { font-size: 40px; }
/* ---- mobile ---- */
@media (max-width: 700px) {
body { flex-direction: column; }
#panel { width: 100%; min-width: 0; height: auto; max-height: 45%; }
#panel footer { display: none; }
}
/* ---- onload hero: fireworks motion-graphics splash ---- */
#hero {
position: fixed; inset: 0; z-index: 9999; background: #06070c;
display: flex; flex-direction: column; align-items: center; justify-content: center;
overflow: hidden; cursor: pointer; transition: opacity .55s ease;
}
#hero.hide { opacity: 0; pointer-events: none; }
#herofx { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
#hero .wrap {
position: relative; z-index: 2; text-align: center; pointer-events: none; padding: 20px;
animation: heroin 1s cubic-bezier(.2,.9,.2,1) both;
}
@keyframes heroin {
0% { transform: scale(.55); opacity: 0; filter: blur(10px); }
60% { opacity: 1; }
100% { transform: scale(1); opacity: 1; filter: blur(0); }
}
#hero h1 {
margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 800; letter-spacing: 6px; color: #fff; line-height: 1.04;
font-size: clamp(30px, 7.5vw, 76px);
text-shadow: 0 0 18px rgba(124,92,255,.9), 0 0 44px rgba(124,92,255,.5);
}
#hero h1 .a { color: #7c5cff; }
#hero .tag {
margin-top: 16px; font-family: ui-monospace, Menlo, monospace;
font-size: clamp(11px, 2vw, 15px); letter-spacing: 4px; color: #aeb7c7; opacity: .85;
}
#hero .enter {
margin-top: 34px; pointer-events: auto; cursor: pointer;
font-family: ui-monospace, Menlo, monospace; font-weight: 700; letter-spacing: 2px;
font-size: 15px; color: #06070c; background: #ffd23e; border: 0; border-radius: 999px;
padding: 13px 32px; opacity: 0; transition: opacity .5s ease;
animation: heropulse 1.15s ease-in-out infinite;
}
#hero .enter.show { opacity: 1; } /* reveal driven by JS, not animation-fill */
@keyframes heropulse {
0%,100% { transform: scale(1); box-shadow: 0 0 24px rgba(255,210,62,.55); }
50% { transform: scale(1.06); box-shadow: 0 0 42px rgba(255,210,62,.9); }
}
#hero .skip {
position: absolute; bottom: 16px; right: 18px; z-index: 3; pointer-events: auto;
cursor: pointer; font-family: ui-monospace, monospace; font-size: 11px; letter-spacing: 1px;
color: #8ea; opacity: .45; background: none; border: 0;
}
#hero .skip:hover { opacity: .9; }
@media (prefers-reduced-motion: reduce) {
#hero .wrap { animation: none; }
#hero .enter { animation: none; opacity: 1; }
}
</style>
</head>
<body>
<div id="hero">
<canvas id="herofx"></canvas>
<div class="wrap">
<h1>AGENT <span class="a">ABRAMS</span><br>ARCADE</h1>
<div class="tag">▶ PRESS START</div>
<button class="enter" id="heroEnter">ENTER ARCADE</button>
</div>
<button class="skip" id="heroSkip">skip ✕</button>
</div>
<aside id="panel">
<div id="brand">
<h1><span class="dot">●</span> AGENT ABRAMS ARCADE</h1>
<div class="sub">games.agentabrams.com</div>
</div>
<nav id="list"><div class="dir">~/games/</div></nav>
<footer><a class="xlink" href="https://90210.agentabrams.com">◆ Beverly Hills 90210 collection →</a><div class="fnote">zero dependencies · pure browser</div></footer>
</aside>
<main id="stage">
<a id="popCurrent" href="#" target="_blank" rel="noopener" title="Open this game in its own window">⧉ own window</a>
<iframe id="frame" title="game" hidden></iframe>
<div id="empty"><div class="big">🕹</div><div>select a game</div></div>
</main>
<script>
(function () {
var list = document.getElementById('list');
var frame = document.getElementById('frame');
var empty = document.getElementById('empty');
var popCurrent = document.getElementById('popCurrent');
var games = [], buttons = {}, DEFAULT_ICON = '🎮';
// Standalone, shareable URL for a game's own page. nginx serves index.html
// for the directory; the explicit index.html also resolves over file://.
function gameUrl(g) { return g.path + 'index.html'; }
// Open a game in its OWN window/tab. The controls are real anchors, so the
// href carries the destination: Cmd/Ctrl/Shift/middle-click and right-click
// "Open in New Window" are handled natively by the browser. We only intercept
// a plain left-click to pop a nicely-sized window — and if the popup blocker
// kills that, we DON'T preventDefault, so the anchor's target=_blank still
// opens a new tab. "Must open in a new window" holds for every game.
function openWindow(g, e) {
if (!g) return;
if (e && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1)) return; // native
var w = Math.min(1200, Math.round(screen.availWidth * 0.8));
var h = Math.min(820, Math.round(screen.availHeight * 0.85));
var feat = 'width=' + w + ',height=' + h + ',menubar=no,toolbar=no,location=no,status=no';
var win = window.open(gameUrl(g), 'aa_' + g.id, feat);
if (win) { if (e) e.preventDefault(); try { win.focus(); } catch (_) {} }
// else popup blocked -> let the anchor navigate to a new tab
}
function select(id, push) {
var g = games.find(function (x) { return x.id === id; });
if (!g) return;
Object.keys(buttons).forEach(function (k) { buttons[k].classList.toggle('active', k === id); });
frame.hidden = false; empty.style.display = 'none';
popCurrent.style.display = 'block';
popCurrent.href = gameUrl(g);
popCurrent.onclick = function (e) { openWindow(g, e); };
// keyboard-primary games (e.g. Skyfire) need the iframe focused or key events
// never reach them — setting src alone does NOT focus an iframe.
frame.onload = function () { try { frame.focus(); if (frame.contentWindow) frame.contentWindow.focus(); } catch (e) {} };
frame.src = g.path + 'index.html';
document.title = g.file + ' — games.agentabrams.com';
try { localStorage.setItem('aa-last-game', id); } catch (e) {}
if (push !== false) history.replaceState(null, '', '#' + id);
}
// Build the gallery from a data object ({ games: [...] }).
function render(data) {
games = data.games || [];
games.forEach(function (g) {
// row is a div[role=button] (not <button>) so it can legally host a
// nested <a> pop-out — a <button> may not contain interactive content.
var b = document.createElement('div');
b.className = 'game';
b.setAttribute('role', 'button');
b.tabIndex = 0;
b.innerHTML = '<span class="ficon"></span>' +
'<span class="fbody"><div class="fname"></div><div class="fdesc"></div></span>' +
'<a class="popout" target="_blank" rel="noopener" title="Open in its own window">⧉</a>';
b.querySelector('.ficon').textContent = g.icon || DEFAULT_ICON;
b.querySelector('.fname').textContent = g.file;
b.querySelector('.fdesc').textContent = g.desc || '';
b.onclick = function () { select(g.id); };
b.onkeydown = function (e) {
if (e.target === b && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(g.id); }
};
var pop = b.querySelector('.popout');
pop.href = gameUrl(g); // real href -> native new-tab gestures work
pop.onclick = function (e) { e.stopPropagation(); openWindow(g, e); };
list.appendChild(b);
buttons[g.id] = b;
});
var want = location.hash.replace('#', '');
if (!want) { try { want = localStorage.getItem('aa-last-game'); } catch (e) {} }
if (!want || !games.some(function (g) { return g.id === want; })) {
want = games.length ? games[0].id : null;
}
if (want) select(want, false);
}
// Prefer a live fetch (freshest, cache-busted) so a newly-added game shows
// up without a hard refresh. Fall back to the inlined games-data.js when
// running from file:// — browsers block fetch() of a sibling file there.
fetch('games.json?cb=' + Date.now(), { cache: 'no-store' })
.then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json(); })
.then(render)
.catch(function () {
if (window.__GAMES_DATA__) { render(window.__GAMES_DATA__); }
else { list.innerHTML = '<div style="padding:16px;color:#8b93a7;font:13px ui-monospace,monospace">Could not load games. Serve over http:// or ensure games-data.js is present.</div>'; }
});
addEventListener('hashchange', function () {
var id = location.hash.replace('#', '');
if (id) select(id, false);
});
})();
</script>
<script>
/* ---- Onload hero: canvas fireworks / explosion motion graphics ---- */
(function () {
var hero = document.getElementById('hero');
if (!hero) return;
var cv = document.getElementById('herofx');
var ctx = cv.getContext('2d');
var W = 0, H = 0, dpr = 1;
var COLORS = ['#7c5cff', '#ff4d9d', '#ffd23e', '#3ddc97', '#37c7ff', '#ff7847', '#ffffff'];
var rockets = [], parts = [], flash = 0;
var running = true, dismissed = false, t0 = performance.now(), spawnAt = 0;
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = cv.clientWidth; H = cv.clientHeight;
cv.width = Math.floor(W * dpr); cv.height = Math.floor(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
function rand(a, b) { return a + Math.random() * (b - a); }
function pick(a) { return a[(Math.random() * a.length) | 0]; }
function launchRocket(tx, ty) {
var sx = tx != null ? tx : rand(W * 0.15, W * 0.85);
var targetY = ty != null ? ty : rand(H * 0.15, H * 0.5);
var sy = H + 8;
var dur = rand(38, 58); // frames to apex
rockets.push({
x: sx, y: sy, vy: (targetY - sy) / dur, targetY: targetY,
col: pick(COLORS), trail: []
});
}
function explode(x, y, col, big) {
var n = big ? 90 : (34 + (Math.random() * 40 | 0));
var base = rand(2.2, 4.6) * (big ? 1.5 : 1);
var hueSpread = Math.random() < 0.4; // some bursts are multi-color
for (var i = 0; i < n; i++) {
var a = (Math.PI * 2 * i) / n + rand(-0.12, 0.12);
var sp = base * rand(0.35, 1);
parts.push({
x: x, y: y,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
col: hueSpread ? pick(COLORS) : col,
life: 1, decay: rand(0.008, 0.02) * (big ? 0.7 : 1),
r: rand(1.4, 2.8), tw: rand(0, Math.PI * 2)
});
}
flash = Math.min(1, flash + (big ? 0.5 : 0.22));
}
function step() {
// motion-blur trail: translucent fill instead of clear
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(6,7,12,0.28)';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
// rockets
for (var i = rockets.length - 1; i >= 0; i--) {
var r = rockets[i];
r.x += 0; r.y += r.vy; r.vy += 0.02; // slight decel
r.trail.push({ x: r.x, y: r.y }); if (r.trail.length > 6) r.trail.shift();
ctx.strokeStyle = r.col; ctx.lineWidth = 2; ctx.beginPath();
for (var k = 0; k < r.trail.length; k++) {
var p = r.trail[k]; if (k === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(r.x, r.y, 2.2, 0, 7); ctx.fill();
if (r.y <= r.targetY || r.vy >= 0) { explode(r.x, r.y, r.col, Math.random() < 0.18); rockets.splice(i, 1); }
}
// particles
for (var j = parts.length - 1; j >= 0; j--) {
var q = parts[j];
q.vx *= 0.985; q.vy = q.vy * 0.985 + 0.045; // drag + gravity
q.x += q.vx; q.y += q.vy; q.life -= q.decay; q.tw += 0.3;
if (q.life <= 0) { parts.splice(j, 1); continue; }
var tw = 0.6 + 0.4 * Math.sin(q.tw);
ctx.globalAlpha = Math.max(0, q.life) * tw;
ctx.fillStyle = q.col;
ctx.beginPath(); ctx.arc(q.x, q.y, q.r, 0, 7); ctx.fill();
}
ctx.globalAlpha = 1;
// screen flash
if (flash > 0) {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(255,255,255,' + (flash * 0.10) + ')';
ctx.fillRect(0, 0, W, H);
flash *= 0.9; if (flash < 0.01) flash = 0;
}
}
function loop(now) {
if (!running) return;
var t = now - t0;
// cadence of launches, faster during the opening beat
if (now > spawnAt) { launchRocket(); spawnAt = now + (t < 2500 ? rand(220, 420) : rand(420, 780)); }
step();
requestAnimationFrame(loop);
}
function dismiss() {
if (dismissed) return; dismissed = true;
// celebratory finale
for (var i = 0; i < 7; i++) explode(rand(W * 0.15, W * 0.85), rand(H * 0.2, H * 0.6), pick(COLORS), i % 2 === 0);
hero.classList.add('hide');
setTimeout(function () {
running = false;
hero.style.display = 'none';
// hand keyboard focus back to the game iframe (keyboard-primary games need it)
var f = document.getElementById('frame');
if (f) { try { f.focus(); if (f.contentWindow) f.contentWindow.focus(); } catch (e) {} }
}, 620);
}
hero.addEventListener('click', dismiss);
hero.addEventListener('touchstart', function (e) { e.preventDefault(); dismiss(); }, { passive: false });
window.addEventListener('keydown', function (e) {
if (dismissed) return;
if (e.key === 'Enter' || e.key === ' ' || e.code === 'Space' || e.key === 'Escape') { e.preventDefault(); dismiss(); }
});
resize();
// reveal the ENTER button after the title lands (JS, so it works even when
// CSS animations are suppressed by prefers-reduced-motion / headless)
var enterBtn = document.getElementById('heroEnter');
if (enterBtn) setTimeout(function () { enterBtn.classList.add('show'); }, 1600);
// opening barrage so the splash lands with a bang
for (var b = 0; b < 3; b++) setTimeout(launchRocket, b * 180);
requestAnimationFrame(loop);
// auto-enter after a beat so the splash is never a wall
setTimeout(dismiss, 9000);
})();
</script>
</body>
</html>