diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..25ea755 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.env.example +node_modules +npm-debug.log + diff --git a/.env b/.env new file mode 100644 index 0000000..0423648 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +HOST_PORT=8134 +PROGRAM_REPOSITORY=https://git.p20.dev/christian/c64-basic +PROGRAM_BRANCH=main + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cb691fa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine + +WORKDIR /app +COPY server.mjs ./server.mjs +COPY public ./public + +ENV PORT=8080 +EXPOSE 8080 + +USER node +CMD ["node", "server.mjs"] + diff --git a/README.md b/README.md index ef074b4..cf829c0 100644 --- a/README.md +++ b/README.md @@ -7,26 +7,36 @@ machine-language payload is required. ## Programs -| Program | Type | Controls | Description | Play online | +| Program | Type | Controls | Description | Run locally | | --- | --- | --- | --- | --- | -| [Meteor Dodge](meteor-dodge/meteor-dodge.bas) | Arcade | `A`, `D`, `Q` | Dodge falling meteors and build your score. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#meteor-dodge) | -| [Code Breaker](code-breaker/code-breaker.bas) | Logic | Digits, `Q` | Deduce a four-digit secret in ten guesses. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#code-breaker) | -| [Submarine Hunt](submarine-hunt/submarine-hunt.bas) | Strategy | Coordinates, `Q` | Find three hidden submarines with limited sonar shots. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#submarine-hunt) | -| [Lunar Lander](lunar-lander/lunar-lander.bas) | Simulation | `0`-`9`, `Q` | Choose engine burns and touch down gently. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#lunar-lander) | -| [Memory Matrix](memory-matrix/memory-matrix.bas) | Memory | `1`-`4`, `Q` | Repeat a growing sequence of flashing quadrants. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#memory-matrix) | -| [Robot Maze](robot-maze/robot-maze.bas) | Puzzle | `W`, `A`, `S`, `D`, `Q` | Navigate a freshly generated maze to its exit. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#robot-maze) | -| [Music Box](music-box/music-box.bas) | Music | `A`-`K`, `1`-`3`, `Q` | Play the SID chip as a small computer keyboard. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#music-box) | -| [Pixel Painter](pixel-painter/pixel-painter.bas) | Creative | Cursor keys, Space, `C`, `Q` | Draw character-cell pictures on the screen. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#pixel-painter) | -| [Stock Trader](stock-trader/stock-trader.bas) | Simulation | `B`, `S`, `W`, `Q` | Trade a volatile stock over twenty market days. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#stock-trader) | -| [Starfield](starfield/starfield.bas) | Demo | `1`-`3`, `Q` | Watch and control a layered animated starfield. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#starfield) | +| [Meteor Dodge](meteor-dodge/meteor-dodge.bas) | Arcade | `A`, `D`, `Q` | Dodge falling meteors and build your score. | [Run program](http://localhost:8134/?program=meteor-dodge%2Fmeteor-dodge.prg) | +| [Code Breaker](code-breaker/code-breaker.bas) | Logic | Digits, `Q` | Deduce a four-digit secret in ten guesses. | [Run program](http://localhost:8134/?program=code-breaker%2Fcode-breaker.prg) | +| [Submarine Hunt](submarine-hunt/submarine-hunt.bas) | Strategy | Coordinates, `Q` | Find three hidden submarines with limited sonar shots. | [Run program](http://localhost:8134/?program=submarine-hunt%2Fsubmarine-hunt.prg) | +| [Lunar Lander](lunar-lander/lunar-lander.bas) | Simulation | `0`-`9`, `Q` | Choose engine burns and touch down gently. | [Run program](http://localhost:8134/?program=lunar-lander%2Flunar-lander.prg) | +| [Memory Matrix](memory-matrix/memory-matrix.bas) | Memory | `1`-`4`, `Q` | Repeat a growing sequence of flashing quadrants. | [Run program](http://localhost:8134/?program=memory-matrix%2Fmemory-matrix.prg) | +| [Robot Maze](robot-maze/robot-maze.bas) | Puzzle | `W`, `A`, `S`, `D`, `Q` | Navigate a freshly generated maze to its exit. | [Run program](http://localhost:8134/?program=robot-maze%2Frobot-maze.prg) | +| [Music Box](music-box/music-box.bas) | Music | `A`-`K`, `1`-`3`, `Q` | Play the SID chip as a small computer keyboard. | [Run program](http://localhost:8134/?program=music-box%2Fmusic-box.prg) | +| [Pixel Painter](pixel-painter/pixel-painter.bas) | Creative | Cursor keys, Space, `C`, `Q` | Draw character-cell pictures on the screen. | [Run program](http://localhost:8134/?program=pixel-painter%2Fpixel-painter.prg) | +| [Stock Trader](stock-trader/stock-trader.bas) | Simulation | `B`, `S`, `W`, `Q` | Trade a volatile stock over twenty market days. | [Run program](http://localhost:8134/?program=stock-trader%2Fstock-trader.prg) | +| [Starfield](starfield/starfield.bas) | Demo | `1`-`3`, `Q` | Watch and control a layered animated starfield. | [Run program](http://localhost:8134/?program=starfield%2Fstarfield.prg) | ## Running the programs -Click a **Run program** link in the table to launch that program directly in a -browser-based VICE x64sc emulator powered by WebAssembly. The runner downloads -the emulator from the EmulatorJS CDN; the program itself is embedded in the -repository's runner, so no file picker or manual loading is required. Click the -emulator once to give it keyboard focus and enable audio. +Start the local browser app with Docker Compose: + +```text +docker compose up --build -d +``` + +Open [http://localhost:8134](http://localhost:8134), or click a **Run program** +link in the table. The app runs the VICE x64sc core through WebAssembly and +downloads the selected PRG through its own backend, avoiding browser CORS +restrictions. Click inside the emulator to enable keyboard input and sound. + +The app reads the public repository tree at runtime. New `.prg` files pushed to +the configured branch appear after clicking **Refresh list**; rebuilding the +Docker image is not required. Repository, branch, and host port settings live in +`.env`, where the default host port is `8134`. Each directory contains an editable, line-numbered `.bas` listing and a matching tokenized `.prg` file. The PRGs use the normal C64 BASIC load address, `$0801`. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..f41691b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,10 @@ +services: + c64-basic: + build: . + ports: + - "${HOST_PORT:-8134}:8080" + environment: + PROGRAM_REPOSITORY: "${PROGRAM_REPOSITORY:-https://git.p20.dev/christian/c64-basic}" + PROGRAM_BRANCH: "${PROGRAM_BRANCH:-main}" + restart: unless-stopped + diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..c0afa5b --- /dev/null +++ b/public/app.js @@ -0,0 +1,71 @@ +const library = document.querySelector('#library'); +const player = document.querySelector('#player'); +const status = document.querySelector('#status'); +const programsElement = document.querySelector('#programs'); +const refreshButton = document.querySelector('#refresh'); + +function titleFromPath(path) { + const filename = path.split('/').at(-1).replace(/\.prg$/i, ''); + return filename.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); +} + +async function loadPrograms() { + status.hidden = false; + status.className = 'status'; + status.textContent = 'Loading programs…'; + programsElement.replaceChildren(); + refreshButton.disabled = true; + try { + const response = await fetch('/api/programs', { cache: 'no-store' }); + if (!response.ok) throw new Error('The program list could not be loaded.'); + const { programs } = await response.json(); + for (const program of programs) { + const link = document.createElement('a'); + link.className = 'program-card'; + link.href = `/?program=${encodeURIComponent(program.path)}`; + const title = document.createElement('strong'); + title.textContent = titleFromPath(program.path); + const action = document.createElement('span'); + action.textContent = 'Run program →'; + link.append(title, action); + programsElement.append(link); + } + status.textContent = programs.length ? `${programs.length} programs available` : 'No PRG files found.'; + } catch (error) { + status.className = 'status error'; + status.textContent = error.message; + } finally { + refreshButton.disabled = false; + } +} + +function startEmulator(programPath) { + library.hidden = true; + player.hidden = false; + const title = titleFromPath(programPath); + document.title = `${title} — C64 BASIC Collection`; + document.querySelector('#program-title').textContent = title; + + window.EJS_player = '#game'; + window.EJS_core = 'vice_x64sc'; + window.EJS_gameUrl = `/api/program?path=${encodeURIComponent(programPath)}`; + window.EJS_gameName = programPath.split('/').at(-1); + window.EJS_pathtodata = 'https://cdn.emulatorjs.org/stable/data/'; + window.EJS_startOnLoaded = true; + window.EJS_language = 'en-US'; + + const loader = document.createElement('script'); + loader.src = `${window.EJS_pathtodata}loader.js`; + loader.onerror = () => { + const message = document.querySelector('#emulator-error'); + message.hidden = false; + message.textContent = 'The WASM VICE emulator could not be downloaded. Check your connection and try again.'; + }; + document.body.append(loader); +} + +refreshButton.addEventListener('click', loadPrograms); +const programPath = new URLSearchParams(location.search).get('program'); +if (programPath) startEmulator(programPath); +else loadPrograms(); + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3d46884 --- /dev/null +++ b/public/index.html @@ -0,0 +1,40 @@ + + + + + + + C64 BASIC Collection + + + + +
+
+

Program library

+

Choose a program

+

Programs are read directly from the public repository each time this page loads.

+

Loading programs…

+
+ +
+ +
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..aae8199 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,32 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + background: #121024; + color: #f7f3ff; +} + +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #33265d, #121024 42rem); } +.site-header { display: flex; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 5vw, 4rem); border-bottom: 1px solid #ffffff1c; color: #bdb5dd; } +.brand { color: #fff; font-weight: 800; text-decoration: none; } +main { width: min(1100px, calc(100% - 2rem)); margin: 0 auto; padding: clamp(2rem, 6vw, 5rem) 0; } +.panel { max-width: 900px; margin: auto; } +.eyebrow { margin: 0 0 .5rem; color: #c9a7ff; font-size: .75rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } +h1 { margin: 0; font-size: clamp(2rem, 7vw, 4rem); line-height: 1; } +.lede, .hint { color: #c8c1df; } +.status { margin: 2rem 0 1rem; color: #9ce6c2; } +.program-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; } +.program-card { display: flex; min-height: 125px; flex-direction: column; justify-content: space-between; padding: 1.25rem; border: 1px solid #ffffff24; border-radius: 14px; background: #211c3bcc; color: #fff; text-decoration: none; transition: transform .15s, border-color .15s; } +.program-card:hover { transform: translateY(-3px); border-color: #b58cff; } +.program-card span { color: #bca8f1; font-size: .9rem; } +.button, button { display: inline-block; margin-top: 1.5rem; padding: .7rem 1rem; border: 1px solid #ffffff2b; border-radius: 8px; background: #302653; color: #fff; font: inherit; text-decoration: none; cursor: pointer; } +button:disabled { opacity: .5; cursor: wait; } +.player-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; } +.player-heading h1 { font-size: clamp(1.8rem, 5vw, 3rem); } +#game { width: min(100%, 960px); height: min(75vw, 720px); min-height: 480px; margin: 1.5rem auto 0; background: #000; } +.error { color: #ffb4ab; } +@media (max-width: 600px) { + .site-header span { display: none; } + .player-heading { align-items: start; flex-direction: column; } + #game { min-height: 60vh; } +} diff --git a/runner.html b/runner.html deleted file mode 100644 index 520dc53..0000000 --- a/runner.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - C64 BASIC Collection - - - -
-

C64 BASIC Collection

-

Click the emulator to enable keyboard input and sound.

-
-
- - - - diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..dfb72f2 --- /dev/null +++ b/server.mjs @@ -0,0 +1,97 @@ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const port = Number(process.env.PORT || 8080); +const repository = (process.env.PROGRAM_REPOSITORY || 'https://git.p20.dev/christian/c64-basic').replace(/\/$/, ''); +const branch = process.env.PROGRAM_BRANCH || 'main'; +const publicDirectory = fileURLToPath(new URL('./public/', import.meta.url)); +const repositoryMatch = repository.match(/^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); + +if (!repositoryMatch) { + throw new Error('PROGRAM_REPOSITORY must look like https://host/owner/repository'); +} + +const [, repositoryHost, repositoryOwner, repositoryName] = repositoryMatch; +const apiBase = `https://${repositoryHost}/api/v1/repos/${encodeURIComponent(repositoryOwner)}/${encodeURIComponent(repositoryName)}`; + +function sendJson(response, status, value) { + const body = JSON.stringify(value); + response.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + 'Cache-Control': 'no-store' + }); + response.end(body); +} + +async function fetchPrograms() { + const treeUrl = `${apiBase}/git/trees/${encodeURIComponent(branch)}?recursive=true`; + const upstream = await fetch(treeUrl, { signal: AbortSignal.timeout(10000) }); + if (!upstream.ok) throw new Error(`Repository returned HTTP ${upstream.status}`); + const tree = await upstream.json(); + return tree.tree + .filter(entry => entry.type === 'blob' && entry.path.toLowerCase().endsWith('.prg')) + .map(entry => ({ path: entry.path, size: entry.size })) + .sort((left, right) => left.path.localeCompare(right.path)); +} + +async function proxyProgram(response, programPath) { + const programs = await fetchPrograms(); + if (!programs.some(program => program.path === programPath)) { + sendJson(response, 404, { error: 'Program not found' }); + return; + } + + const encodedPath = programPath.split('/').map(encodeURIComponent).join('/'); + const rawUrl = `${repository}/raw/branch/${encodeURIComponent(branch)}/${encodedPath}`; + const upstream = await fetch(rawUrl, { signal: AbortSignal.timeout(10000) }); + if (!upstream.ok) throw new Error(`Program download returned HTTP ${upstream.status}`); + const bytes = Buffer.from(await upstream.arrayBuffer()); + response.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': bytes.length, + 'Content-Disposition': `inline; filename="${programPath.split('/').at(-1)}"`, + 'Cache-Control': 'no-store' + }); + response.end(bytes); +} + +function serveStatic(response, pathname) { + const requested = pathname === '/' ? 'index.html' : pathname.slice(1); + const filePath = normalize(join(publicDirectory, requested)); + if (!filePath.startsWith(publicDirectory) || !existsSync(filePath) || !statSync(filePath).isFile()) { + return false; + } + const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; + response.writeHead(200, { + 'Content-Type': types[extname(filePath)] || 'application/octet-stream', + 'Content-Length': statSync(filePath).size, + 'Cache-Control': 'no-cache' + }); + createReadStream(filePath).pipe(response); + return true; +} + +createServer(async (request, response) => { + try { + const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`); + if (request.method !== 'GET') { + sendJson(response, 405, { error: 'Method not allowed' }); + } else if (url.pathname === '/api/programs') { + sendJson(response, 200, { repository, branch, programs: await fetchPrograms() }); + } else if (url.pathname === '/api/program') { + await proxyProgram(response, url.searchParams.get('path') || ''); + } else if (!serveStatic(response, url.pathname)) { + sendJson(response, 404, { error: 'Not found' }); + } + } catch (error) { + console.error(error); + if (!response.headersSent) sendJson(response, 502, { error: 'Unable to reach the program repository' }); + else response.end(); + } +}).listen(port, '0.0.0.0', () => { + console.log(`C64 BASIC runner listening on port ${port}`); +}); +