diff --git a/.env b/.env index 0423648..b593bf6 100644 --- a/.env +++ b/.env @@ -1,4 +1,2 @@ HOST_PORT=8134 -PROGRAM_REPOSITORY=https://git.p20.dev/christian/c64-basic -PROGRAM_BRANCH=main diff --git a/Dockerfile b/Dockerfile index cb691fa..3d6414e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,10 @@ FROM node:22-alpine WORKDIR /app COPY server.mjs ./server.mjs COPY public ./public +COPY . /programs ENV PORT=8080 +ENV PROGRAM_DIRECTORY=/programs EXPOSE 8080 USER node diff --git a/README.md b/README.md index 35555b3..89effec 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,15 @@ machine-language payload is required. | 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](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) | +| [Code Breaker](code-breaker/code-breaker.bas) | Logic | `0`-`9`, `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 | Row, Column, `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) | +| [Memory Matrix](memory-matrix/memory-matrix.bas) | Memory | `1`, `2`, `3`, `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) | +| [Music Box](music-box/music-box.bas) | Music | `A`-`K`, `1`, `2`, `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) | +| [Starfield](starfield/starfield.bas) | Demo | `1`, `2`, `3`, `Q` | Watch and control a layered animated starfield. | [Run program](http://localhost:8134/?program=starfield%2Fstarfield.prg) | ## Running the programs @@ -35,10 +35,12 @@ restrictions. Click inside the emulator to enable keyboard input and sound. Direct keyboard input is enabled by default, mouse locking is disabled, and moving the pointer anywhere over the emulator reveals its control bar. -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`. +Docker Compose mounts this repository read-only at `/programs`, and the app +serves PRG files from that local mount. There is no runtime dependency on the +Git server and no browser CORS boundary. Add a program to `programs.json` and +place its PRG in the referenced path; it appears after clicking **Refresh list** +without rebuilding the image. The default host port is configured as `8134` in +`.env`. 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 index f41691b..6ddaf58 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,7 +4,8 @@ services: ports: - "${HOST_PORT:-8134}:8080" environment: - PROGRAM_REPOSITORY: "${PROGRAM_REPOSITORY:-https://git.p20.dev/christian/c64-basic}" - PROGRAM_BRANCH: "${PROGRAM_BRANCH:-main}" + PROGRAM_DIRECTORY: /programs + volumes: + - .:/programs:ro restart: unless-stopped diff --git a/programs.json b/programs.json new file mode 100644 index 0000000..143861e --- /dev/null +++ b/programs.json @@ -0,0 +1,73 @@ +[ + { + "title": "Meteor Dodge", + "type": "Arcade", + "description": "Dodge falling meteors and build your score.", + "controls": ["A", "D", "Q"], + "path": "meteor-dodge/meteor-dodge.prg" + }, + { + "title": "Code Breaker", + "type": "Logic", + "description": "Deduce a four-digit secret in ten guesses.", + "controls": ["0-9", "Q"], + "path": "code-breaker/code-breaker.prg" + }, + { + "title": "Submarine Hunt", + "type": "Strategy", + "description": "Find three hidden submarines with limited sonar shots.", + "controls": ["ROW", "COLUMN", "Q"], + "path": "submarine-hunt/submarine-hunt.prg" + }, + { + "title": "Lunar Lander", + "type": "Simulation", + "description": "Choose engine burns and touch down gently.", + "controls": ["0-9", "Q"], + "path": "lunar-lander/lunar-lander.prg" + }, + { + "title": "Memory Matrix", + "type": "Memory", + "description": "Repeat a growing sequence of flashing quadrants.", + "controls": ["1", "2", "3", "4", "Q"], + "path": "memory-matrix/memory-matrix.prg" + }, + { + "title": "Robot Maze", + "type": "Puzzle", + "description": "Navigate a freshly generated maze to its exit.", + "controls": ["W", "A", "S", "D", "Q"], + "path": "robot-maze/robot-maze.prg" + }, + { + "title": "Music Box", + "type": "Music", + "description": "Play the SID chip as a small computer keyboard.", + "controls": ["A-K", "1", "2", "3", "Q"], + "path": "music-box/music-box.prg" + }, + { + "title": "Pixel Painter", + "type": "Creative", + "description": "Draw character-cell pictures on the screen.", + "controls": ["CURSOR KEYS", "SPACE", "C", "Q"], + "path": "pixel-painter/pixel-painter.prg" + }, + { + "title": "Stock Trader", + "type": "Simulation", + "description": "Trade a volatile stock over twenty market days.", + "controls": ["B", "S", "W", "Q"], + "path": "stock-trader/stock-trader.prg" + }, + { + "title": "Starfield", + "type": "Demo", + "description": "Watch and control a layered animated starfield.", + "controls": ["1", "2", "3", "Q"], + "path": "starfield/starfield.prg" + } +] + diff --git a/public/app.js b/public/app.js index c27aee2..7358a26 100644 --- a/public/app.js +++ b/public/app.js @@ -9,6 +9,18 @@ function titleFromPath(path) { return filename.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); } +const typeIcons = { + Arcade: '🎮', + Logic: '◆', + Strategy: '♟', + Simulation: '⚙', + Memory: '◫', + Puzzle: '⌘', + Music: '♪', + Creative: '✎', + Demo: '✦' +}; + async function loadPrograms() { status.hidden = false; status.className = 'status'; @@ -23,11 +35,32 @@ async function loadPrograms() { const link = document.createElement('a'); link.className = 'program-card'; link.href = `/?program=${encodeURIComponent(program.path)}`; + + const pill = document.createElement('span'); + pill.className = 'type-pill'; + pill.textContent = `${typeIcons[program.type] || '●'} ${program.type}`; + const title = document.createElement('strong'); - title.textContent = titleFromPath(program.path); + title.className = 'program-title'; + title.textContent = program.title; + + const description = document.createElement('span'); + description.className = 'program-description'; + description.textContent = program.description; + + const controls = document.createElement('span'); + controls.className = 'controls'; + controls.setAttribute('aria-label', `Controls: ${program.controls.join(', ')}`); + for (const control of program.controls) { + const key = document.createElement('kbd'); + key.textContent = control; + controls.append(key); + } + const action = document.createElement('span'); + action.className = 'program-action'; action.textContent = 'Run program →'; - link.append(title, action); + link.append(pill, title, description, controls, action); programsElement.append(link); } status.textContent = programs.length ? `${programs.length} programs available` : 'No PRG files found.'; diff --git a/public/index.html b/public/index.html index 3d46884..34df03d 100644 --- a/public/index.html +++ b/public/index.html @@ -16,7 +16,7 @@

Program library

Choose a program

-

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

+

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

Loading programs…

diff --git a/public/styles.css b/public/styles.css index aae8199..8cbb65c 100644 --- a/public/styles.css +++ b/public/styles.css @@ -16,9 +16,14 @@ 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 { display: flex; min-height: 250px; flex-direction: column; align-items: flex-start; 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; } +.type-pill { display: inline-flex; gap: .4rem; align-items: center; padding: .3rem .65rem; border: 1px solid #b58cff55; border-radius: 999px; background: #8d5cff24; color: #d9c7ff; font-size: .75rem; font-weight: 750; letter-spacing: .04em; text-transform: uppercase; } +.program-title { margin-top: 1rem; font-size: 1.2rem; } +.program-description { margin-top: .55rem; color: #c8c1df; font-size: .92rem; line-height: 1.45; } +.controls { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: 1rem; } +kbd { min-width: 1.8rem; padding: .25rem .45rem; border: 1px solid #aaa1c9; border-bottom-width: 3px; border-radius: 5px; background: #f2eff8; color: #241f38; font: 700 .72rem ui-monospace, monospace; text-align: center; } +.program-action { margin-top: auto; padding-top: 1.25rem; 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; } diff --git a/server.mjs b/server.mjs index dfb72f2..3ac0f74 100644 --- a/server.mjs +++ b/server.mjs @@ -1,20 +1,12 @@ -import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'; import { createServer } from 'node:http'; -import { extname, join, normalize } from 'node:path'; +import { extname, join, normalize, resolve, sep } 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 programDirectory = resolve(process.env.PROGRAM_DIRECTORY || fileURLToPath(new URL('./', import.meta.url))); +const manifestPath = join(programDirectory, 'programs.json'); 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); @@ -26,36 +18,34 @@ function sendJson(response, status, value) { 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)); +function fetchPrograms() { + const programs = JSON.parse(readFileSync(manifestPath, 'utf8')); + if (!Array.isArray(programs)) throw new Error('programs.json must contain an array'); + return programs.map(program => { + if (!program.path?.toLowerCase().endsWith('.prg')) throw new Error('Every program must reference a PRG file'); + const filePath = resolve(programDirectory, program.path); + if (!filePath.startsWith(programDirectory + sep) || !existsSync(filePath) || !statSync(filePath).isFile()) { + throw new Error(`Program file is missing: ${program.path}`); + } + return { ...program, size: statSync(filePath).size }; + }); } -async function proxyProgram(response, programPath) { - const programs = await fetchPrograms(); +function sendProgram(response, programPath) { + const programs = 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()); + const filePath = resolve(programDirectory, programPath); response.writeHead(200, { 'Content-Type': 'application/octet-stream', - 'Content-Length': bytes.length, + 'Content-Length': statSync(filePath).size, 'Content-Disposition': `inline; filename="${programPath.split('/').at(-1)}"`, 'Cache-Control': 'no-store' }); - response.end(bytes); + createReadStream(filePath).pipe(response); } function serveStatic(response, pathname) { @@ -80,15 +70,15 @@ createServer(async (request, response) => { 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() }); + sendJson(response, 200, { programs: fetchPrograms() }); } else if (url.pathname === '/api/program') { - await proxyProgram(response, url.searchParams.get('path') || ''); + sendProgram(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' }); + if (!response.headersSent) sendJson(response, 500, { error: 'Unable to read the program library' }); else response.end(); } }).listen(port, '0.0.0.0', () => {