Enrich program cards and serve local library
This commit is contained in:
parent
8ed968c61b
commit
4fdbae2c8a
9 changed files with 155 additions and 51 deletions
2
.env
2
.env
|
|
@ -1,4 +1,2 @@
|
|||
HOST_PORT=8134
|
||||
PROGRAM_REPOSITORY=https://git.p20.dev/christian/c64-basic
|
||||
PROGRAM_BRANCH=main
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
README.md
20
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`.
|
||||
|
|
|
|||
|
|
@ -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
73
programs.json
Normal 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"
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
56
server.mjs
56
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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue