Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1637,11 +1637,15 @@ <h2 id="receipt-heading" class="sr-only">Session Receipt</h2>

<!-- ── Grim Reaper ──────────────────────────────────────── -->
<div id="grim-reaper" class="grim-reaper" aria-hidden="true">
<div id="reaper-bubble" class="reaper-bubble" aria-live="polite"></div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 155">
<!-- scythe handle -->
<line x1="77" y1="16" x2="62" y2="148" class="reaper-scythe-handle" stroke-width="2.5" stroke-linecap="round"/>
<!-- scythe blade — large crescent sweeping left over the hood -->
<path d="M77,16 C72,6 55,2 38,5 C20,8 6,20 6,32 C6,44 22,48 40,38 C56,28 72,20 77,16 Z" class="reaper-scythe-blade"/>
<!-- scythe (handle + blade wrapped for swing animation) -->
<g class="reaper-scythe-group">
<!-- scythe handle -->
<line x1="77" y1="16" x2="62" y2="148" class="reaper-scythe-handle" stroke-width="2.5" stroke-linecap="round"/>
<!-- scythe blade — large crescent sweeping left over the hood -->
<path d="M77,16 C72,6 55,2 38,5 C20,8 6,20 6,32 C6,44 22,48 40,38 C56,28 72,20 77,16 Z" class="reaper-scythe-blade"/>
</g>
<!-- robe shadow -->
<ellipse cx="50" cy="151" rx="28" ry="5" fill="#000" opacity="0.4"/>
<!-- robe body -->
Expand Down
127 changes: 127 additions & 0 deletions src/js/18-scary-features.js
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,130 @@
});
}

// ── Grim Reaper: Scythe Swing, Speech Bubbles, Proximity ─────

const REAPER_IDLE_QUOTES = [
'Tick tock\u2026',
'Your queries, my harvest.',
'Every token is a breath closer.',
'The servers never sleep.',
'I have been very busy lately.',
'So many tokens\u2026 so little time.',
'CO\u2082 says hello.',
"I'm not waiting for you. I'm waiting \u2018with\u2019 you.",
'Did you really need to ask AI that?',
'One more prompt\u2026',
"Don\u2019t mind me.",
'I go where the tokens flow.',
'Another milestone down.',
'Carbon is forever.',
'Patience is my virtue. Time is not yours.',
];

const REAPER_CLICK_QUOTES = [
'Oh! A visitor. How delightful.',
"Please don\u2019t tap the glass.",
"I\u2019m working. Could you not?",
'Ah, the curious ones. My favourite.',
'Touch me again and I swing the scythe.',
'Yes, yes. I see you.',
'This is not a game. (It is a bit of a game.)',
"You\u2019ve earned 100 doom points.",
"I\u2019m always watching.",
'Boo yourself.',
'Still here? Interesting.',
'We have so much in common, you and I.',
'Every click costs something.',
"You're not the first. You won\u2019t be the last.",
];

const REAPER_HOVER_QUOTES = [
'Getting closer\u2026',
'I can feel your warmth.',
'Are you here to confess?',
'Hello there.',
'You seem\u2026 familiar.',
'Careful now.',
];

let _reaperBubbleTimer = null;
let _reaperHoverActive = false;
let _reaperClickIdx = 0;
let _reaperHoverIdx = 0;

function _showReaperBubble(text, durationMs) {
const bubble = document.getElementById('reaper-bubble');
if (!bubble) return;
bubble.textContent = text;
bubble.classList.add('visible');
clearTimeout(_reaperBubbleTimer);
_reaperBubbleTimer = setTimeout(() => bubble.classList.remove('visible'), durationMs || 3500);
}

function _swingReaperScythe() {
const reaper = document.getElementById('grim-reaper');
if (!reaper) return;
reaper.classList.remove('reaper-swinging');
// Force reflow so re-adding the class retriggers the animation
void reaper.offsetWidth;
reaper.classList.add('reaper-swinging');
setTimeout(() => reaper.classList.remove('reaper-swinging'), 800);
}

function initGrimReaper() {
const reaper = document.getElementById('grim-reaper');
if (!reaper) return;

// Click / tap — show a click quote and swing the scythe
reaper.addEventListener('click', () => {
const quote = REAPER_CLICK_QUOTES[_reaperClickIdx % REAPER_CLICK_QUOTES.length];
_reaperClickIdx++;
_showReaperBubble(quote, 3500);
_swingReaperScythe();
});

// Mouse proximity — react when cursor comes within 140 px of the reaper.
// Throttled via rAF to avoid layout thrash on high-poll-rate mice.
let _reaperMouseFrame = null;
document.addEventListener('mousemove', (e) => {
if (_reaperMouseFrame) return;
_reaperMouseFrame = requestAnimationFrame(() => {
_reaperMouseFrame = null;
const bbox = reaper.getBoundingClientRect();
const nearX = e.clientX > bbox.left - 20 && e.clientX < bbox.right + 140;
const nearY = e.clientY > bbox.top - 100 && e.clientY < bbox.bottom + 10;
const isNear = nearX && nearY;

if (isNear && !_reaperHoverActive) {
_reaperHoverActive = true;
reaper.classList.add('reaper-proximity');
const quote = REAPER_HOVER_QUOTES[_reaperHoverIdx % REAPER_HOVER_QUOTES.length];
_reaperHoverIdx++;
_showReaperBubble(quote, 2500);
_swingReaperScythe();
} else if (!isNear && _reaperHoverActive) {
_reaperHoverActive = false;
reaper.classList.remove('reaper-proximity');
}
});
});

// Periodic idle swing + quote every 25–55 seconds.
// The timeout ID is kept so we can avoid leaking if the element is removed.
let _idleSwingId = null;
function _scheduleIdleSwing() {
const delay = 25000 + Math.random() * 30000;
_idleSwingId = setTimeout(() => {
if (!document.getElementById('grim-reaper')) return; // element removed — stop
if (!_reaperHoverActive) {
const idx = Math.floor(Math.random() * REAPER_IDLE_QUOTES.length);
_showReaperBubble(REAPER_IDLE_QUOTES[idx], 4000);
_swingReaperScythe();
}
_scheduleIdleSwing();
}, delay);
}

_scheduleIdleSwing();
}

1 change: 1 addition & 0 deletions src/js/21-boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
initShame();
initVillainLeaderboard();
initIntervention();
initGrimReaper();

// Persist accelerator game state every 30 seconds and on page hide
setInterval(saveAcceleratorState, 30000);
Expand Down
69 changes: 69 additions & 0 deletions styles/scary-features.css
Original file line number Diff line number Diff line change
Expand Up @@ -881,9 +881,78 @@
.grim-reaper { transform: translateX(0); }
}

/* Scythe swing — pivot near the grip point (≈84% across, 68% down the bounding box) */
.reaper-scythe-group {
transform-box: fill-box;
transform-origin: 84% 68%;
will-change: transform;
}

@keyframes reaper-scythe-swing {
0% { transform: rotate(0deg); }
15% { transform: rotate(-26deg); }
45% { transform: rotate(20deg); }
70% { transform: rotate(-8deg); }
85% { transform: rotate(4deg); }
100% { transform: rotate(0deg); }
}

.reaper-swinging .reaper-scythe-group {
animation: reaper-scythe-swing 0.75s ease-in-out forwards;
}

/* Chat / speech bubble */
.reaper-bubble {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
background: #0d0d0d;
border: 1.5px solid var(--accent);
color: #eee;
border-radius: 10px 10px 10px 2px;
padding: 0.45rem 0.65rem;
font-size: 0.68rem;
line-height: 1.45;
width: 170px;
white-space: normal;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.6);
z-index: 11;
}

.reaper-bubble.visible { opacity: 1; }

/* Tail pointing down-left toward the reaper */
.reaper-bubble::after {
content: '';
position: absolute;
bottom: -8px;
left: 14px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid var(--accent);
}

:root[data-theme="light"] .reaper-bubble {
background: #1a0000;
color: #ffe0e0;
}

/* Proximity highlight — reaper eyes blaze brighter when cursor is near */
.reaper-proximity .reaper-eye-inner {
animation: reaper-eye-pulse 0.8s ease-in-out infinite;
filter: drop-shadow(0 0 8px #ff3333);
}

@media (prefers-reduced-motion: reduce) {
.grim-reaper svg,
.reaper-eye-inner { animation: none; }
/* transition: none keeps the hover snap instant on reduced-motion devices. */
.grim-reaper { transition: none; }
.reaper-swinging .reaper-scythe-group { animation: none; }
.reaper-bubble { transition: none; }
}
34 changes: 34 additions & 0 deletions tests/e2e/death-clock.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,40 @@ test.describe('mobile layout — fixed elements within viewport', () => {
expect(cutOff).toBeLessThan(bbox.width / 2);
});

test('grim reaper has a speech bubble element', async ({ page }) => {
const bubble = page.locator('#reaper-bubble');
await expect(bubble).toBeAttached();
// Bubble should start hidden (no "visible" class)
await expect(bubble).not.toHaveClass(/visible/);
});

test('grim reaper shows speech bubble and swings scythe when clicked', async ({ page }) => {
const reaper = page.locator('#grim-reaper');
const bubble = page.locator('#reaper-bubble');

await reaper.click();

// Bubble should become visible after click
await expect(bubble).toHaveClass(/visible/, { timeout: 1000 });
// Bubble text should be non-empty
const text = await bubble.textContent();
expect(text.trim().length).toBeGreaterThan(0);

// The scythe-group wrapper must exist in the SVG
await expect(reaper.locator('.reaper-scythe-group')).toBeAttached();
});

test('grim reaper speech bubble auto-hides after a few seconds', async ({ page }) => {
const reaper = page.locator('#grim-reaper');
const bubble = page.locator('#reaper-bubble');

await reaper.click();
await expect(bubble).toHaveClass(/visible/, { timeout: 1000 });

// After ~4 s the bubble should disappear (default duration is 3.5 s)
await expect(bubble).not.toHaveClass(/visible/, { timeout: 5000 });
});

test('Share Your Doom button is fully within the viewport on mobile', async ({ page }) => {
// Reveal the panel immediately via the ?share=true query param
await page.goto('/?share=true');
Expand Down
Loading