Enrich program cards and serve local library

This commit is contained in:
Christian Johannesen 2026-10-07 21:10:17 +02:00
parent 8ed968c61b
commit 4fdbae2c8a
9 changed files with 155 additions and 51 deletions

2
.env
View file

@ -1,4 +1,2 @@
HOST_PORT=8134
PROGRAM_REPOSITORY=https://git.p20.dev/christian/c64-basic
PROGRAM_BRANCH=main

View file

@ -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

View file

@ -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`.

View file

@ -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

73
programs.json Normal file
View file

@ -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"
}
]

View file

@ -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.';

View file

@ -16,7 +16,7 @@
<section id="library" class="panel">
<p class="eyebrow">Program library</p>
<h1>Choose a program</h1>
<p class="lede">Programs are read directly from the public repository each time this page loads.</p>
<p class="lede">Programs are read directly from the locally mounted repository each time this page loads.</p>
<p id="status" class="status">Loading programs…</p>
<div id="programs" class="program-grid" aria-live="polite"></div>
<button id="refresh" class="secondary" type="button">Refresh list</button>

View file

@ -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; }

View file

@ -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', () => {