Files
Escapepage/assets/game1.js
T
FrankandClaude Sonnet 5 a290b75238 Stop auto-scrolling the game terminal when a new message arrives
Every message append (mainframe broadcasts, lock reveals, the boot
sequence, and responses to your own commands) forced the page to jump
to the bottom. Removed all four window.scrollTo calls so the view
stays put.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:16:48 +02:00

499 lines
16 KiB
JavaScript

/* Game1 entry point built with Webpack Encore */
import './styles/game1.css';
let sequenceFinished = false;
let stillPlayingSound = true;
let navigatingAway = false;
function goTo(url) {
navigatingAway = true;
window.location.href = url;
}
function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl) {
try {
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
const es = new EventSource(url);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('[Mercure][game1] Update:', data);
if (data && !Array.isArray(data) && data.type === 'game_finished') {
const destination = data.status === 'won' ? wonUrl : lostUrl;
if (destination) {
goTo(destination);
}
return;
}
// data is [sendTo, message, messageType?] - messageType defaults to 'mainframe' (green)
if (Array.isArray(data) && data.length >= 2) {
const sendTo = parseInt(data[0]);
// Filter: 0 means everyone, otherwise must match myScreen
if (sendTo !== 0 && sendTo !== parseInt(myScreen)) {
console.log('[Mercure][game1] Message not for this player, skipping.');
return;
}
const messageContainer = document.getElementById('message-container');
if (messageContainer) {
appendResultMessage(messageContainer, data[1], data[2] || 'mainframe');
if(stillPlayingSound)
playSound();
console.log('[Mercure][game1] sequenceFinished status:', sequenceFinished);
if (sequenceFinished) {
flashRed();
}
}
}
} catch (e) {
console.log('[Mercure][game1] Raw event:', event.data);
}
};
es.onerror = (err) => {
console.warn('[Mercure][game1] EventSource error:', err);
};
console.log('[Mercure][game1] Subscribed to', url);
} catch (e) {
console.error('[Mercure][game1] Failed to subscribe:', e);
}
}
function playSound() {
const sound = document.getElementById('message-sound');
if (sound) {
sound.currentTime = 0;
sound.play().catch(e => console.warn('[Audio] Playback failed:', e));
}
}
function flashRed() {
console.log('[Game1] Triggering flashRed');
const body = document.body;
body.classList.remove('flash-red');
void body.offsetWidth; // Trigger reflow to restart animation
body.classList.add('flash-red');
// Also remove it after animation finishes so it's clean for inspection
setTimeout(() => {
body.classList.remove('flash-red');
console.log('[Game1] Removed flash-red class');
}, 150);
}
let lockRevealTimer = null;
let lockExpireTimer = null;
let lockCountdownTimer = null;
let currentLockedAt = null;
function lockMessageClass(messageType) {
if (messageType === 'virus') return 'message-virus';
if (messageType === 'mainframe') return 'message-mainframe';
if (messageType === 'hint') return 'message-hint';
return '';
}
function appendResultMessage(container, text, messageType) {
const msgEl = document.createElement('div');
msgEl.className = ('message ' + lockMessageClass(messageType)).trim();
msgEl.textContent = text;
container.appendChild(msgEl);
}
function setInputDisabled(disabled) {
const inputField = document.getElementById('input-message');
if (inputField) {
inputField.disabled = disabled;
}
}
function clearLockTimers() {
if (lockRevealTimer) { clearTimeout(lockRevealTimer); lockRevealTimer = null; }
if (lockExpireTimer) { clearTimeout(lockExpireTimer); lockExpireTimer = null; }
if (lockCountdownTimer) { clearInterval(lockCountdownTimer); lockCountdownTimer = null; }
}
function clearLock() {
clearLockTimers();
currentLockedAt = null;
document.body.classList.remove('locked');
const banner = document.getElementById('lock-banner');
if (banner) banner.style.display = 'none';
setInputDisabled(false);
}
function updateLockCountdown(unlockAtMs) {
const countdownEl = document.getElementById('lock-countdown');
if (!countdownEl) return;
const remaining = Math.max(0, Math.ceil((unlockAtMs - Date.now()) / 1000));
countdownEl.textContent = remaining + 's';
}
async function fetchLockReveal(apiEchoUrl, messageContainer) {
if (!apiEchoUrl) return;
try {
const response = await fetchJson(apiEchoUrl, {
method: 'POST',
body: { message: '', ts: new Date().toISOString() },
});
const result = response && response.result;
if (result && Array.isArray(result.result)) {
result.result.forEach(text => appendResultMessage(messageContainer, text, result.messageType));
}
if (result && result.locked === false) {
clearLock();
return;
}
// Code has been revealed (or already was), let the player try /unlock
setInputDisabled(false);
} catch (e) {
console.error('[Game1] Failed to fetch lock reveal:', e);
}
}
function applyLock(lockData, apiEchoUrl, messageContainer) {
if (currentLockedAt === lockData.lockedAt) {
return; // already tracking this lock, avoid re-fetching/duplicating messages
}
currentLockedAt = lockData.lockedAt;
clearLockTimers();
const banner = document.getElementById('lock-banner');
if (banner) banner.style.display = 'flex';
document.body.classList.add('locked');
const revealAtMs = lockData.revealAt * 1000;
const unlockAtMs = lockData.unlockAt * 1000;
const now = Date.now();
if (now < revealAtMs) {
setInputDisabled(true);
lockRevealTimer = setTimeout(() => fetchLockReveal(apiEchoUrl, messageContainer), revealAtMs - now);
} else {
fetchLockReveal(apiEchoUrl, messageContainer);
}
lockExpireTimer = setTimeout(() => clearLock(), Math.max(0, unlockAtMs - now));
updateLockCountdown(unlockAtMs);
lockCountdownTimer = setInterval(() => {
updateLockCountdown(unlockAtMs);
if (Date.now() >= unlockAtMs) {
clearInterval(lockCountdownTimer);
lockCountdownTimer = null;
}
}, 1000);
}
let filesRemovalTimer = null;
let scheduledFilesRemovalDeadline = null;
async function pingFilesRemovalDeadline(apiEchoUrl) {
if (!apiEchoUrl) return;
try {
// A no-op message is enough to make the server evaluate the deadline server-side;
// the actual restore notice (if any) arrives for everyone via the Mercure broadcast.
await fetchJson(apiEchoUrl, {
method: 'POST',
body: { message: '', ts: new Date().toISOString() },
});
} catch (e) {
console.error('[Game1] Failed to ping files-removal deadline:', e);
}
}
function scheduleFilesRemovalCheck(deadline, apiEchoUrl) {
if (!deadline || scheduledFilesRemovalDeadline === deadline) {
return; // nothing to (re)schedule
}
scheduledFilesRemovalDeadline = deadline;
if (filesRemovalTimer) {
clearTimeout(filesRemovalTimer);
filesRemovalTimer = null;
}
const delay = Math.max(0, deadline * 1000 - Date.now());
filesRemovalTimer = setTimeout(() => {
filesRemovalTimer = null;
scheduledFilesRemovalDeadline = null;
pingFilesRemovalDeadline(apiEchoUrl);
}, delay);
}
async function fetchJson(url, options = {}) {
const opts = { ...options };
const headers = new Headers(opts.headers || {});
headers.set('Accept', 'application/json');
if (opts.body !== undefined && typeof opts.body !== 'string') {
headers.set('Content-Type', 'application/json');
opts.body = JSON.stringify(opts.body);
}
// Useful convention for server-side checks
if (!headers.has('X-Requested-With')) {
headers.set('X-Requested-With', 'XMLHttpRequest');
}
opts.headers = headers;
const res = await fetch(url, opts);
const text = await res.text();
let data;
try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
if (!res.ok) {
const err = new Error('HTTP ' + res.status + ' ' + res.statusText);
console.error('[API][game1]', err, data);
throw err;
}
return data;
}
document.addEventListener('DOMContentLoaded', async () => {
// Simple boot log so you can verify it in the browser console
// and confirm this specific bundle is loaded on the Game Hub page.
console.log('Game1 bundle loaded');
// Example: add a CSS class to <body> so page-specific styles can apply
document.body.classList.add('game1-page');
// Look for config injected by Twig in the page
const cfgEl = document.getElementById('mercure-config');
// Prevent/warn on page reload, except for our own win/lose redirects
window.addEventListener('beforeunload', (event) => {
if (navigatingAway) {
return;
}
// Standard way to trigger the browser's confirmation dialog
event.preventDefault();
// Included for compatibility with older browsers
event.returnValue = '';
});
if (!cfgEl) {
console.warn('[Mercure][game1] #mercure-config element not found on page');
return;
}
const mercurePublicUrl = cfgEl.dataset.mercurePublicUrl;
const topic = cfgEl.dataset.topic;
const screen = cfgEl.dataset.screen;
const apiPingUrl = cfgEl.dataset.apiPingUrl;
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
const lostUrl = cfgEl.dataset.lostUrl;
const wonUrl = cfgEl.dataset.wonUrl;
const lockLockedAt = cfgEl.dataset.lockLockedAt;
const lockRevealAt = cfgEl.dataset.lockRevealAt;
const lockUnlockAt = cfgEl.dataset.lockUnlockAt;
const filesRemovalDeadline = cfgEl.dataset.filesRemovalDeadline;
// Resume the auto-restore timer after a page refresh, if a window is already running
if (filesRemovalDeadline) {
scheduleFilesRemovalCheck(parseInt(filesRemovalDeadline, 10), apiEchoUrl);
}
if (mercurePublicUrl && topic) {
subscribeToMercure(mercurePublicUrl, topic, screen, wonUrl, lostUrl);
} else {
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
}
// Timer logic
const timerEl = document.getElementById('game-timer');
if (timerEl && timerEl.dataset.endTime) {
const endTime = parseInt(timerEl.dataset.endTime) * 1000;
const updateTimer = async () => {
const now = Date.now();
const diff = endTime - now;
if (diff <= 0) {
timerEl.textContent = '00:00:00';
// Timer reached zero, check with server
if (apiCheckFinishedUrl && lostUrl) {
try {
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
if (response && response.finished) {
goTo(response.status === 'won' && wonUrl ? wonUrl : lostUrl);
return; // Stop the timer loop
}
} catch (e) {
console.error('[API][game1] Failed to check finished status:', e);
}
}
// Even if check failed or not finished, stop the loop if diff <= 0
// (though technically if the server says not finished, we might want to keep checking,
// but 00:00:00 usually means it's over).
return;
}
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
const hDisplay = hours.toString().padStart(2, '0');
const mDisplay = minutes.toString().padStart(2, '0');
const sDisplay = seconds.toString().padStart(2, '0');
timerEl.textContent = `${hDisplay}:${mDisplay}:${sDisplay}`;
setTimeout(updateTimer, 1000);
};
updateTimer();
}
// Demo API calls
try {
if (apiPingUrl) {
const ping = await fetchJson(apiPingUrl);
console.log('[API][game1] ping →', ping);
} else {
console.warn('[API][game1] data-api-ping-url missing');
}
if (apiEchoUrl) {
const echo = await fetchJson(apiEchoUrl, {
method: 'POST',
body: { message: 'from game1.js', ts: new Date().toISOString() },
});
console.log('[API][game1] echo →', echo);
} else {
console.warn('[API][game1] data-api-echo-url missing');
}
} catch (e) {
console.error('[API][game1] Request failed:', e);
}
// Add messages to message-container
const messageContainer = document.getElementById('message-container');
if (messageContainer) {
let messages = [
['System initializing...', 500],
['Connection established.', 200],
['Welcome agent ' + screen + ' to the mainframe.', 1000],
['Scanning...', 3000],
['Virus detected.', 500],
['Starting Mainframe help modus...', 2000],
['Help modus activated.', 500],
['Blocking virus activated', 0]
];
let currentMessageIndex = 0;
const printNextMessage = () => {
if (currentMessageIndex < messages.length) {
const msg = messages[currentMessageIndex];
const msgEl = document.createElement('div');
let extraClass = '';
if(msg[2])
extraClass = msg[2];
msgEl.className = 'message ' + extraClass;
msgEl.textContent = msg[0];
messageContainer.appendChild(msgEl);
playSound();
currentMessageIndex++;
setTimeout(printNextMessage, msg[1]);
if (sequenceFinished) {
flashRed();
}
} else {
// After it has printed a set of messages, it has to start a timer of 2 seconds
console.log('[Game1] All messages printed. Starting 2s timer to expand message-container height...');
setTimeout(() => {
messageContainer.style.height = '400vh';
const inputField = document.getElementById('input-message');
inputField.disabled = false;
let lastCommand = '';
// Recall the last submitted command on ArrowUp, cursor at the end
inputField.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
inputField.value = lastCommand;
inputField.setSelectionRange(inputField.value.length, inputField.value.length);
}
});
// Add event listener for Enter key
inputField.addEventListener('keypress', async (e) => {
if (e.key === 'Enter') {
stillPlayingSound = false;
sequenceFinished = false;
const message = inputField.value.trim();
const msgEl = document.createElement('div');
msgEl.className = 'message';
msgEl.textContent = message;
messageContainer.appendChild(msgEl);
if (message && apiEchoUrl) {
lastCommand = message;
inputField.value = '';
try {
const response = await fetchJson(apiEchoUrl, {
method: 'POST',
body: { message, ts: new Date().toISOString() },
});
console.log('[API][game1] message sent →', response);
if (response && response.result && Array.isArray(response.result.result)) {
response.result.result.forEach(text => appendResultMessage(messageContainer, text, response.result.messageType));
}
if (response && response.result) {
if (response.result.gameWon === true && wonUrl) {
goTo(wonUrl);
return;
}
if (response.result.locked === true) {
applyLock({
lockedAt: response.result.lockedAt,
revealAt: response.result.revealAt,
unlockAt: response.result.unlockAt,
}, apiEchoUrl, messageContainer);
} else if (response.result.locked === false) {
clearLock();
}
if (response.result.filesRemovalDeadline) {
scheduleFilesRemovalCheck(response.result.filesRemovalDeadline, apiEchoUrl);
}
}
} catch (err) {
console.error('[API][game1] Failed to send message:', err);
}
}
}
});
console.log('[Game1] message-container height changed to 400vh and input enabled');
sequenceFinished = true;
console.log('[Game1] sequenceFinished is now TRUE');
// Restore an in-progress lock after a page refresh
if (lockUnlockAt && parseInt(lockUnlockAt, 10) * 1000 > Date.now()) {
applyLock({
lockedAt: parseInt(lockLockedAt, 10),
revealAt: parseInt(lockRevealAt, 10),
unlockAt: parseInt(lockUnlockAt, 10),
}, apiEchoUrl, messageContainer);
}
}, 2000);
}
};
printNextMessage();
}
});