UNPKG

zx-generation

Version:

A high-fidelity ZX Spectrum emulator in JavaScript — fully generated by a large language model (LLM) to explore the boundaries of AI in systems programming.

391 lines (335 loc) 16 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <meta name="mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black"> <title>ZX Spectrum Basic</title> <link rel="icon" href="data:,"> <link rel="stylesheet" href="retro.css"> </head> <body class="page-layout"> <div class="top-menu"> <div class="menu-container"> <div class="brand">ZX Spectrum</div> </div> </div> <div class="emulator-container"> <div class="rainbow-strip"></div> <canvas id="screen" width="320" height="240"></canvas> <div class="controls"> <button id="reset-btn" class="btn">Reset</button> <button id="pause-btn" class="btn">Pause</button> <button id="load-btn" class="btn">Load File</button> <button id="audio-btn" class="btn">Audio Off</button> <button id="fast-load-btn" class="btn">Fast</button> <button id="fullscreen-btn" class="btn">Fullscreen</button> </div> <div id="tape-status"></div> <input type="file" id="tape-file" accept=".z80,.tap,.tzx"> </div> <script type="module"> import { ZXSpectrum } from 'https://cdn.jsdelivr.net/npm/zx-generation@latest/dist/zxgeneration.esm.js'; // Initialize emulator with responsive scale const isMobile = window.innerWidth <= 768; const initialScale = isMobile ? 1 : 2; const spectrum = new ZXSpectrum('#screen', { scale: initialScale }); // Function to resize canvas for mobile function resizeCanvas() { const canvas = document.getElementById('screen'); const displaySize = spectrum.display.getDisplaySize(); const aspectRatio = displaySize.width / displaySize.height; const isLandscape = window.orientation === 90 || window.orientation === -90 || (window.innerWidth > window.innerHeight && window.innerHeight < 600); if (window.innerWidth <= 768 || isLandscape) { if (isLandscape) { // Landscape mode - prioritize height const topMenuHeight = 36; const padding = 16; const availableHeight = window.innerHeight - topMenuHeight - padding; let height = availableHeight; let width = height * aspectRatio; // Check if width is too large const maxWidth = window.innerWidth - 150; if (width > maxWidth) { width = maxWidth; height = width / aspectRatio; } canvas.style.width = Math.floor(width) + 'px'; canvas.style.height = Math.floor(height) + 'px'; } else { // Portrait mode - prioritize width const containerPadding = 16; const availableWidth = window.innerWidth - containerPadding; // Calculate height needed for UI elements const topMenuHeight = 44; const controlsHeight = 120; const tapeStatusHeight = 44; const gaps = 48; const totalUIHeight = topMenuHeight + controlsHeight + tapeStatusHeight + gaps; const availableHeight = window.innerHeight - totalUIHeight; // Calculate optimal size let width = availableWidth; let height = width / aspectRatio; // If height exceeds available space, scale down if (height > availableHeight && availableHeight > 100) { height = availableHeight; width = height * aspectRatio; } // Ensure minimum size const minWidth = 280; if (width < minWidth) { width = minWidth; height = width / aspectRatio; } canvas.style.width = Math.floor(width) + 'px'; canvas.style.height = Math.floor(height) + 'px'; } } else { // Desktop size canvas.style.width = (displaySize.width * 2) + 'px'; canvas.style.height = (displaySize.height * 2) + 'px'; } } // Initial resize resizeCanvas(); // State management const state = { isPaused: false, audioEnabled: false, tapeLoaded: false, turboInterval: null, wasPlaying: false, lastBlockIndex: 0 }; // UI elements const ui = { resetBtn: document.getElementById('reset-btn'), pauseBtn: document.getElementById('pause-btn'), loadBtn: document.getElementById('load-btn'), audioBtn: document.getElementById('audio-btn'), fastLoadBtn: document.getElementById('fast-load-btn'), fullscreenBtn: document.getElementById('fullscreen-btn'), tapeFile: document.getElementById('tape-file'), tapeStatus: document.getElementById('tape-status') }; // Helper functions const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); async function typeCommand(command) { const KEY_DELAY = 100; const KEY_HOLD = 50; for (const char of command) { let keys = []; switch(char) { case '"': keys = ['SYMBOL_SHIFT', 'P']; break; case ' ': keys = ['SPACE']; break; case '\n': keys = ['ENTER']; break; default: keys = [char.toUpperCase()]; } keys.forEach(key => spectrum.keyDown(key)); await delay(KEY_HOLD); keys.reverse().forEach(key => spectrum.keyUp(key)); await delay(KEY_DELAY); } } function updateFullscreenMode() { const canvas = document.getElementById('screen'); const displaySize = spectrum.display.getDisplaySize(); const isFullscreen = document.fullscreenElement || document.webkitFullscreenElement || window.innerHeight === screen.height; document.body.classList.toggle('fullscreen-mode', isFullscreen); if (isFullscreen) { const screenWidth = window.innerWidth; const screenHeight = window.innerHeight; const canvasAspect = displaySize.width / displaySize.height; const screenAspect = screenWidth / screenHeight; let newWidth, newHeight; if (screenAspect > canvasAspect) { newHeight = screenHeight * 0.95; newWidth = newHeight * canvasAspect; } else { newWidth = screenWidth * 0.95; newHeight = newWidth / canvasAspect; } canvas.style.width = Math.floor(newWidth) + 'px'; canvas.style.height = Math.floor(newHeight) + 'px'; ui.fullscreenBtn.textContent = 'Exit Fullscreen'; } else { resizeCanvas(); ui.fullscreenBtn.textContent = 'Fullscreen'; } } // Button handlers ui.resetBtn.addEventListener('click', () => { spectrum.reset(); state.isPaused = false; ui.pauseBtn.textContent = 'Pause'; ui.pauseBtn.classList.remove('active'); ui.tapeStatus.style.visibility = 'hidden'; ui.fastLoadBtn.style.display = 'none'; }); ui.pauseBtn.addEventListener('click', () => { state.isPaused = !state.isPaused; if (state.isPaused) { spectrum.stop(); ui.pauseBtn.textContent = 'Resume'; ui.pauseBtn.classList.add('active'); } else { spectrum.start(); ui.pauseBtn.textContent = 'Pause'; ui.pauseBtn.classList.remove('active'); } }); ui.loadBtn.addEventListener('click', () => { ui.tapeFile.click(); }); ui.audioBtn.addEventListener('click', async () => { state.audioEnabled = !state.audioEnabled; if (state.audioEnabled) { spectrum.setMuted(false); // Resume audio context if needed const audioContext = spectrum.sound?.audioContext || spectrum.audioWorklet?.audioContext; if (audioContext?.state === 'suspended') { try { await audioContext.resume(); } catch (err) { console.warn('Failed to resume audio:', err); } } ui.audioBtn.textContent = 'Audio On'; ui.audioBtn.classList.add('active'); } else { spectrum.setMuted(true); ui.audioBtn.textContent = 'Audio Off'; ui.audioBtn.classList.remove('active'); } }); ui.fastLoadBtn.addEventListener('click', () => { if (state.turboInterval) { clearInterval(state.turboInterval); state.turboInterval = null; ui.fastLoadBtn.textContent = 'Fast'; ui.fastLoadBtn.classList.remove('active'); } else { ui.fastLoadBtn.textContent = 'Normal'; ui.fastLoadBtn.classList.add('active'); state.turboInterval = setInterval(() => { for (let i = 0; i < 10; i++) { spectrum.runFrame(); } }, 10); } }); ui.fullscreenBtn.addEventListener('click', () => { if (!document.fullscreenElement && !document.webkitFullscreenElement) { const requestFS = document.body.requestFullscreen || document.body.webkitRequestFullscreen || document.body.msRequestFullscreen; requestFS.call(document.body); } else { const exitFS = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen; exitFS.call(document); } }); // File handling ui.tapeFile.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const fileExt = file.name.toLowerCase().split('.').pop(); const buffer = await file.arrayBuffer(); try { if (fileExt === 'z80') { spectrum.loadZ80Snapshot(buffer); ui.tapeStatus.textContent = `Loaded snapshot: ${file.name}`; ui.tapeStatus.style.visibility = 'visible'; ui.fastLoadBtn.style.display = 'none'; } else if (fileExt === 'tap' || fileExt === 'tzx') { spectrum.loadTape(buffer, file.name); state.tapeLoaded = true; await delay(500); await typeCommand('J""'); // LOAD "" await typeCommand('\n'); // Enter await delay(1000); spectrum.playTape(); ui.tapeStatus.textContent = `Loading tape: ${file.name}`; ui.tapeStatus.style.visibility = 'visible'; ui.fastLoadBtn.style.display = 'inline-block'; } } catch (error) { ui.tapeStatus.textContent = `Error: ${error.message}`; ui.tapeStatus.style.visibility = 'visible'; console.error('File loading error:', error); } e.target.value = ''; }); // Tape status monitoring setInterval(() => { if (state.tapeLoaded && spectrum.tape?.data) { const status = spectrum.getTapeStatus(); if (status.playing) { const currentBlock = spectrum.tape.blockIndex; const totalBlocks = spectrum.tape.blocks.length; state.lastBlockIndex = currentBlock; ui.tapeStatus.textContent = `${status.status} (Block ${currentBlock}/${totalBlocks})`; state.wasPlaying = true; } else if (state.wasPlaying) { state.wasPlaying = false; if (state.lastBlockIndex >= spectrum.tape.blocks.length) { ui.tapeStatus.textContent = 'Tape loading complete! Press any key to start...'; setTimeout(async () => { spectrum.keyDown('SPACE'); await delay(100); spectrum.keyUp('SPACE'); ui.tapeStatus.textContent = 'Ready!'; setTimeout(() => { ui.tapeStatus.style.visibility = 'hidden'; }, 2000); }, 2000); } else { ui.tapeStatus.textContent = 'Tape stopped'; } // Disable turbo mode if active if (state.turboInterval) { clearInterval(state.turboInterval); state.turboInterval = null; ui.fastLoadBtn.textContent = 'Fast'; ui.fastLoadBtn.classList.remove('active'); } } } }, 100); // Fullscreen event listeners document.addEventListener('fullscreenchange', updateFullscreenMode); document.addEventListener('webkitfullscreenchange', updateFullscreenMode); // Handle orientation change and window resize window.addEventListener('orientationchange', () => { setTimeout(resizeCanvas, 100); }); window.addEventListener('resize', () => { resizeCanvas(); updateFullscreenMode(); }); // Prevent zoom on double tap for mobile let lastTouchEnd = 0; document.addEventListener('touchend', (e) => { const now = Date.now(); if (now - lastTouchEnd <= 300) { e.preventDefault(); } lastTouchEnd = now; }, false); </script> </body> </html>