Add Outer Limits easter egg to oscilloscope knobs
Clicking the Vertical or Horizontal knobs triggers The Outer Limits (1963) opening narration as a typewriter overlay on the CRT display, with Web Speech API narration. Dismiss via click, Escape, or knob re-click. Works on both 465 and 545A skins.
This commit is contained in:
parent
be88ea53b7
commit
ad630ff471
@ -35,6 +35,10 @@
|
|||||||
></canvas>
|
></canvas>
|
||||||
<div class="scope-graticule"></div>
|
<div class="scope-graticule"></div>
|
||||||
<div class="scope-scanlines"></div>
|
<div class="scope-scanlines"></div>
|
||||||
|
<!-- Outer Limits easter egg overlay -->
|
||||||
|
<div class="scope-outer-limits" id="scope-outer-limits" aria-hidden="true">
|
||||||
|
<p class="scope-ol-text" id="scope-ol-text"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -52,14 +56,16 @@
|
|||||||
<!-- VERTICAL section -->
|
<!-- VERTICAL section -->
|
||||||
<div class="scope-section">
|
<div class="scope-section">
|
||||||
<span class="scope-section-label">Vertical</span>
|
<span class="scope-section-label">Vertical</span>
|
||||||
<div class="scope-knob" aria-hidden="true"></div>
|
<div class="scope-knob scope-easter-knob" role="button" tabindex="0"
|
||||||
|
aria-label="Vertical control"></div>
|
||||||
<span class="scope-knob-label">Volts/Div</span>
|
<span class="scope-knob-label">Volts/Div</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- HORIZONTAL section -->
|
<!-- HORIZONTAL section -->
|
||||||
<div class="scope-section">
|
<div class="scope-section">
|
||||||
<span class="scope-section-label">Horizontal</span>
|
<span class="scope-section-label">Horizontal</span>
|
||||||
<div class="scope-knob" aria-hidden="true"></div>
|
<div class="scope-knob scope-easter-knob" role="button" tabindex="0"
|
||||||
|
aria-label="Horizontal control"></div>
|
||||||
<span class="scope-knob-label">Time/Div</span>
|
<span class="scope-knob-label">Time/Div</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -429,9 +435,102 @@
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Outer Limits easter egg ──────────────────────────────
|
||||||
|
function initOuterLimits() {
|
||||||
|
const knobs = document.querySelectorAll('.scope-easter-knob');
|
||||||
|
const overlay = document.getElementById('scope-outer-limits');
|
||||||
|
const textEl = document.getElementById('scope-ol-text');
|
||||||
|
if (!knobs.length || !overlay || !textEl) return;
|
||||||
|
|
||||||
|
// Guard against double-init
|
||||||
|
if (overlay.dataset.olInit) return;
|
||||||
|
overlay.dataset.olInit = 'true';
|
||||||
|
|
||||||
|
const quote = 'There is nothing wrong with your television set. Do not attempt to adjust the picture. We are controlling transmission. We will control the horizontal. We will control the vertical. For the next hour, sit quietly and we will control all that you see and hear.';
|
||||||
|
|
||||||
|
let typeTimer: number | null = null;
|
||||||
|
let utterance: SpeechSynthesisUtterance | null = null;
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
overlay!.dataset.active = 'true';
|
||||||
|
overlay!.setAttribute('aria-hidden', 'false');
|
||||||
|
textEl!.textContent = '';
|
||||||
|
typeText(quote, 0);
|
||||||
|
speak(quote);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
if (typeTimer) { clearTimeout(typeTimer); typeTimer = null; }
|
||||||
|
overlay!.dataset.active = 'false';
|
||||||
|
overlay!.setAttribute('aria-hidden', 'true');
|
||||||
|
if (utterance && window.speechSynthesis.speaking) {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeText(text: string, i: number) {
|
||||||
|
if (i >= text.length) return;
|
||||||
|
textEl!.textContent += text[i];
|
||||||
|
typeTimer = window.setTimeout(() => typeText(text, i + 1), 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
function speak(text: string) {
|
||||||
|
if (!('speechSynthesis' in window)) return;
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
utterance = new SpeechSynthesisUtterance(text);
|
||||||
|
utterance.rate = 0.85;
|
||||||
|
utterance.pitch = 0.7;
|
||||||
|
utterance.volume = 0.8;
|
||||||
|
|
||||||
|
// Prefer a deep English voice if available
|
||||||
|
const voices = window.speechSynthesis.getVoices();
|
||||||
|
const preferred = voices.find(v =>
|
||||||
|
v.lang.startsWith('en') && /male|daniel|david|james|google uk/i.test(v.name)
|
||||||
|
) || voices.find(v => v.lang.startsWith('en'));
|
||||||
|
if (preferred) utterance.voice = preferred;
|
||||||
|
|
||||||
|
window.speechSynthesis.speak(utterance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure voices are loaded (some browsers load async)
|
||||||
|
if ('speechSynthesis' in window) {
|
||||||
|
window.speechSynthesis.getVoices();
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Knob clicks toggle the overlay
|
||||||
|
knobs.forEach(knob => {
|
||||||
|
knob.addEventListener('click', () => {
|
||||||
|
if (overlay!.dataset.active === 'true') {
|
||||||
|
dismiss();
|
||||||
|
} else {
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
knob.addEventListener('keydown', (e: Event) => {
|
||||||
|
const ke = e as KeyboardEvent;
|
||||||
|
if (ke.key === 'Enter' || ke.key === ' ') {
|
||||||
|
ke.preventDefault();
|
||||||
|
if (overlay!.dataset.active === 'true') {
|
||||||
|
dismiss();
|
||||||
|
} else {
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dismiss on overlay click or Escape
|
||||||
|
overlay.addEventListener('click', dismiss);
|
||||||
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && overlay!.dataset.active === 'true') dismiss();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initAll() {
|
function initAll() {
|
||||||
initSkin();
|
initSkin();
|
||||||
initScope();
|
initScope();
|
||||||
|
initOuterLimits();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init when DOM ready (works with Astro's page lifecycle)
|
// Init when DOM ready (works with Astro's page lifecycle)
|
||||||
|
|||||||
@ -359,6 +359,52 @@
|
|||||||
color: #1a1610;
|
color: #1a1610;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Outer Limits easter egg overlay ─────────────────────── */
|
||||||
|
.scope-outer-limits {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 10, 10, 0.92);
|
||||||
|
z-index: 10;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-outer-limits[data-active="true"] {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-ol-text {
|
||||||
|
font-family: 'Georgia', 'Times New Roman', serif;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--scope-teal);
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 0 8px var(--scope-teal-glow);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Easter egg knobs (Vertical / Horizontal) ────────────── */
|
||||||
|
.scope-easter-knob {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-easter-knob:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-easter-knob:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-easter-knob:focus-visible {
|
||||||
|
outline: 2px solid var(--scope-teal);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Idle state ──────────────────────────────────────────── */
|
/* ── Idle state ──────────────────────────────────────────── */
|
||||||
.scope-screen[data-idle="true"] .scope-canvas {
|
.scope-screen[data-idle="true"] .scope-canvas {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user