Compare commits
9
Commits
a2d899be6f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84ba7f6de | ||
|
|
d45aa8b603 | ||
|
|
d54a44da1d | ||
|
|
bf175b64a2 | ||
|
|
245f499f04 | ||
|
|
c1350387b1 | ||
|
|
5357622c2d | ||
|
|
89194dd94a | ||
|
|
97168ea0e5 |
@@ -10,7 +10,6 @@ name: Images
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*.*.*']
|
||||
workflow_dispatch:
|
||||
|
||||
# Jobs that run docker builds use dejima-privileged; anything else should use
|
||||
@@ -59,6 +58,12 @@ jobs:
|
||||
echo "host=$host" >> "$GITHUB_OUTPUT"
|
||||
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Shown in Settings, since images carry no version number
|
||||
- name: Name the version
|
||||
id: version
|
||||
shell: bash
|
||||
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
@@ -71,13 +76,13 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.registry.outputs.host }}/${{ steps.registry.outputs.owner }}/${{ matrix.image }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=ref,event=branch
|
||||
tags: type=raw,value=latest
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
# GHCR sometimes answers the streamer's base image pull with 429 Too Many
|
||||
# Requests; it clears within minutes, so retry once.
|
||||
- id: build
|
||||
uses: docker/build-push-action@v6
|
||||
continue-on-error: true
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
push: true
|
||||
@@ -85,5 +90,22 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
VERSION=${{ steps.version.outputs.sha }}
|
||||
provenance: false
|
||||
|
||||
- name: Wait before retrying
|
||||
if: steps.build.outcome == 'failure'
|
||||
run: sleep 120
|
||||
|
||||
- name: Retry the build
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.sha }}
|
||||
provenance: false
|
||||
|
||||
@@ -7,7 +7,6 @@ name: Images
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*.*.*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -39,6 +38,12 @@ jobs:
|
||||
shell: bash
|
||||
run: echo "name=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Shown in Settings, since images carry no version number
|
||||
- name: Name the version
|
||||
id: version
|
||||
shell: bash
|
||||
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
@@ -51,13 +56,13 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ steps.owner.outputs.name }}/${{ matrix.image }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=ref,event=branch
|
||||
tags: type=raw,value=latest
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
# GHCR sometimes answers the streamer's base image pull with 429 Too Many
|
||||
# Requests; it clears within minutes, so retry once.
|
||||
- id: build
|
||||
uses: docker/build-push-action@v6
|
||||
continue-on-error: true
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
push: true
|
||||
@@ -65,5 +70,22 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
VERSION=${{ steps.version.outputs.sha }}
|
||||
provenance: false
|
||||
|
||||
- name: Wait before retrying
|
||||
if: steps.build.outcome == 'failure'
|
||||
run: sleep 120
|
||||
|
||||
- name: Retry the build
|
||||
if: steps.build.outcome == 'failure'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.sha }}
|
||||
provenance: false
|
||||
|
||||
@@ -17,6 +17,7 @@ the Dotcom Arcade application.
|
||||
7. **[Common Pitfalls](./common-pitfalls.md)** - Known issues and solutions
|
||||
8. **[Upstream Sync](./upstream-sync.md)** - Merging changes from flashpoint-web
|
||||
9. **[GitHub Mirror](./github-mirror.md)** - Publishing to GitHub and GHCR
|
||||
10. **[Demo Data](./demo-data.md)** - Made-up games for development and screenshots
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Demo data
|
||||
|
||||
`scripts/demo/build-demo-data.mjs` builds a small Flashpoint install of made-up
|
||||
games, for developing without a 2.3 TB download and for taking the README's
|
||||
screenshots. Everything in it is invented:
|
||||
|
||||
- about 50 games with fake titles, studios and descriptions;
|
||||
- generated tile art and screenshots;
|
||||
- four curated playlists;
|
||||
- **Pixel Penguin Panic**, a tiny HTML5 game written for the demo, which is
|
||||
the only one that plays.
|
||||
|
||||
The database uses Flashpoint 14's schema (`scripts/demo/flashpoint-schema.sql`,
|
||||
from [FPA-Rust](https://github.com/FlashpointProject/FPA-Rust), MIT).
|
||||
|
||||
## Using it
|
||||
|
||||
```sh
|
||||
npm run install:all
|
||||
node scripts/demo/build-demo-data.mjs /tmp/demo-flashpoint
|
||||
```
|
||||
|
||||
Then point the app at it, with `FLASHPOINT_PATH=/tmp/demo-flashpoint` in
|
||||
`backend/.env` for development, or `FLASHPOINT_HOST_PATH` in `.env` for Docker.
|
||||
|
||||
## Screenshots
|
||||
|
||||
The README's screenshots in `docs/images` come from the demo data, so they show
|
||||
no real games. To retake them after a UI change:
|
||||
|
||||
1. Build the frontend (`npm run build`), then run the app on the demo data with
|
||||
a fresh user database, serving the built frontend (`SERVE_FRONTEND=true`).
|
||||
2. Install Playwright: `npm install --no-save playwright && npx playwright install chromium`.
|
||||
3. Run `node scripts/demo/take-screenshots.mjs http://localhost:3100`. Restart
|
||||
the app with a fresh user database before running it again: repeated runs
|
||||
trip the login rate limit.
|
||||
|
||||
The script creates the first account, plays a few games for "Jump back in",
|
||||
fixes the browser's date so the Game of the Day is always the same, moves the
|
||||
Time Machine to the week the demo fills, and writes `home.png`, `game.png` and
|
||||
`playing.png`.
|
||||
|
||||
Each game's tile and screenshot is a small scene drawn for its genre (a road
|
||||
for driving games, a block grid for puzzles, and so on), with layouts varied by
|
||||
the game's tags and seeded by its title, so every build draws the same art.
|
||||
@@ -29,13 +29,11 @@ All three are built from this repository by `docker compose up -d --build`.
|
||||
### Prebuilt images
|
||||
|
||||
Images are published to GitHub's container registry. To pull them instead of
|
||||
building, set `IMAGE_PREFIX` in `.env`, ending in `/`, and optionally
|
||||
`IMAGE_TAG`. `latest` is the newest release, `1.2` the newest of a minor
|
||||
version, and `main` follows the `main` branch.
|
||||
building, set `IMAGE_PREFIX` in `.env`, ending in `/`. There's one tag,
|
||||
`latest`, built from the tip of `main`.
|
||||
|
||||
```sh
|
||||
IMAGE_PREFIX=ghcr.io/branderson/
|
||||
IMAGE_TAG=latest
|
||||
```
|
||||
|
||||
Then run `docker compose pull && docker compose up -d`. Images from another
|
||||
@@ -53,8 +51,7 @@ Two workflows build the same images with the same tags:
|
||||
|
||||
`.gitea/workflows/images.yml` builds all three images on Gitea Actions and
|
||||
pushes them to the Gitea instance's container registry, under the repository's
|
||||
owner. Pushes to `main` publish a `main` tag; `v1.2.3` tags publish `1.2.3`,
|
||||
`1.2` and `latest`. Its jobs run docker builds, so they need a runner labelled
|
||||
owner. Each push to `main` publishes `latest`. Its jobs run docker builds, so they need a runner labelled
|
||||
`dejima-privileged`; jobs that don't build images should use `dejima-confined`.
|
||||
It also needs a repository secret, `REGISTRY_TOKEN`: an access token with
|
||||
package write access, belonging to the repository's owner, whom the workflow
|
||||
|
||||
@@ -127,6 +127,7 @@ for Flashpoint Ultimate.
|
||||
- [Common Pitfalls](08-development/common-pitfalls.md) - Known issues
|
||||
- [Upstream Sync](08-development/upstream-sync.md) - Merging changes from flashpoint-web
|
||||
- [GitHub Mirror](08-development/github-mirror.md) - Publishing to GitHub and GHCR
|
||||
- [Demo Data](08-development/demo-data.md) - Made-up games for development and screenshots
|
||||
|
||||
### 09. Deployment
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 294 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 800 KiB After Width: | Height: | Size: 472 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 248 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
-- Flashpoint 14's database schema, the result of the migrations in
|
||||
-- https://github.com/FlashpointProject/FPA-Rust (crates/flashpoint-archive),
|
||||
-- Copyright (c) 2024 Flashpoint Archive, MIT License.
|
||||
|
||||
CREATE TABLE "additional_app" (
|
||||
"id" varchar NOT NULL,
|
||||
"applicationPath" varchar NOT NULL,
|
||||
"autoRunBefore" boolean NOT NULL,
|
||||
"launchCommand" varchar NOT NULL,
|
||||
"name" varchar NOT NULL COLLATE NOCASE,
|
||||
"waitForExit" boolean NOT NULL,
|
||||
"parentGameId" varchar,
|
||||
PRIMARY KEY("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "custom_id_order" (
|
||||
"id" VARCHAR NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ext_data (
|
||||
"extId" varchar NOT NULL,
|
||||
"gameId" varchar NOT NULL,
|
||||
"data" jsonb,
|
||||
PRIMARY KEY(extId, gameId)
|
||||
);
|
||||
|
||||
CREATE TABLE "game" (
|
||||
"id" varchar NOT NULL,
|
||||
"parentGameId" varchar,
|
||||
"title" varchar NOT NULL,
|
||||
"alternateTitles" varchar NOT NULL,
|
||||
"series" varchar NOT NULL,
|
||||
"developer" varchar NOT NULL,
|
||||
"publisher" varchar NOT NULL,
|
||||
"dateAdded" datetime NOT NULL,
|
||||
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
|
||||
"broken" boolean NOT NULL,
|
||||
"extreme" boolean NOT NULL,
|
||||
"playMode" varchar NOT NULL,
|
||||
"status" varchar NOT NULL,
|
||||
"notes" varchar NOT NULL,
|
||||
"source" varchar NOT NULL,
|
||||
"applicationPath" varchar NOT NULL,
|
||||
"launchCommand" varchar NOT NULL,
|
||||
"releaseDate" varchar NOT NULL,
|
||||
"version" varchar NOT NULL,
|
||||
"originalDescription" varchar NOT NULL,
|
||||
"language" varchar NOT NULL,
|
||||
"library" varchar NOT NULL,
|
||||
"orderTitle" varchar NOT NULL,
|
||||
"activeDataId" integer,
|
||||
"activeDataOnDisk" boolean NOT NULL DEFAULT (0),
|
||||
"tagsStr" varchar NOT NULL DEFAULT ('') COLLATE NOCASE,
|
||||
"platformsStr" varchar,
|
||||
"platformId" integer,
|
||||
"lastPlayed" datetime,
|
||||
"playtime" integer DEFAULT 0,
|
||||
"playCounter" integer DEFAULT 0,
|
||||
"archiveState" integer DEFAULT 2,
|
||||
"activeGameConfigId" integer,
|
||||
"activeGameConfigOwner" varchar COLLATE NOCASE, "platformName" varchar COLLATE NOCASE, "ruffleSupport" varchar NOT NULL DEFAULT '', "logoPath" varchar, "screenshotPath" varchar, "owner" varchar,
|
||||
PRIMARY KEY("id"),
|
||||
CONSTRAINT "FK_45a9180069d42ac8231ff11acd0" FOREIGN KEY("parentGameId") REFERENCES "game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
|
||||
);
|
||||
|
||||
CREATE TABLE "game_config" (
|
||||
"id" integer NOT NULL,
|
||||
"gameId" varchar NOT NULL COLLATE NOCASE,
|
||||
"name" varchar NOT NULL COLLATE NOCASE,
|
||||
"owner" varchar NOT NULL COLLATE NOCASE,
|
||||
"middleware" varchar,
|
||||
PRIMARY KEY("id" AUTOINCREMENT)
|
||||
);
|
||||
|
||||
CREATE TABLE "game_data" (
|
||||
"id" integer NOT NULL,
|
||||
"gameId" varchar,
|
||||
"title" varchar NOT NULL,
|
||||
"dateAdded" datetime NOT NULL,
|
||||
"sha256" varchar NOT NULL,
|
||||
"crc32" integer NOT NULL,
|
||||
"presentOnDisk" boolean NOT NULL DEFAULT (0),
|
||||
"path" varchar,
|
||||
"size" integer NOT NULL,
|
||||
"parameters" varchar,
|
||||
"applicationPath" varchar,
|
||||
"launchCommand" varchar,
|
||||
PRIMARY KEY("id" AUTOINCREMENT),
|
||||
CONSTRAINT "UQ_gameid_dateadded" UNIQUE("gameId", "dateAdded"),
|
||||
CONSTRAINT "FK_gamedata_gameid" FOREIGN KEY("gameId") REFERENCES "game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
|
||||
);
|
||||
|
||||
CREATE TABLE "game_platforms_platform" (
|
||||
"gameId" varchar NOT NULL,
|
||||
"platformId" integer NOT NULL,
|
||||
PRIMARY KEY("gameId","platformId")
|
||||
);
|
||||
|
||||
CREATE TABLE "game_redirect" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"sourceId" VARCHAR NOT NULL,
|
||||
"dateAdded" datetime,
|
||||
PRIMARY KEY("id", "sourceId")
|
||||
);
|
||||
|
||||
CREATE TABLE "game_tags_tag" (
|
||||
"gameId" varchar NOT NULL,
|
||||
"tagId" integer NOT NULL,
|
||||
PRIMARY KEY("gameId","tagId")
|
||||
);
|
||||
|
||||
CREATE TABLE "platform" (
|
||||
"id" integer NOT NULL,
|
||||
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
|
||||
"primaryAliasId" integer,
|
||||
"description" varchar NOT NULL DEFAULT '',
|
||||
CONSTRAINT "REL_platform_primary_alias_unique" UNIQUE("primaryAliasId"),
|
||||
PRIMARY KEY("id" AUTOINCREMENT)
|
||||
);
|
||||
|
||||
CREATE TABLE "platform_alias" (
|
||||
"id" integer NOT NULL,
|
||||
"platformId" integer,
|
||||
"name" varchar NOT NULL COLLATE NOCASE,
|
||||
CONSTRAINT "UQ_platform_alias_name_unique" UNIQUE("name"),
|
||||
PRIMARY KEY("id" AUTOINCREMENT)
|
||||
);
|
||||
|
||||
CREATE TABLE "tag" (
|
||||
"id" integer NOT NULL,
|
||||
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
|
||||
"primaryAliasId" integer,
|
||||
"categoryId" integer,
|
||||
"description" varchar NOT NULL DEFAULT '',
|
||||
CONSTRAINT "REL_3c002904ab97fb1b4e61e8493c" UNIQUE("primaryAliasId"),
|
||||
PRIMARY KEY("id" AUTOINCREMENT)
|
||||
);
|
||||
|
||||
CREATE TABLE "tag_alias" (
|
||||
"id" integer NOT NULL,
|
||||
"tagId" integer,
|
||||
"name" varchar NOT NULL COLLATE NOCASE,
|
||||
CONSTRAINT "UQ_34d6ff6807129b3b193aea26789" UNIQUE("name"),
|
||||
PRIMARY KEY("id" AUTOINCREMENT)
|
||||
);
|
||||
|
||||
CREATE TABLE "tag_category" (
|
||||
"id" integer NOT NULL,
|
||||
"name" varchar NOT NULL COLLATE NOCASE,
|
||||
"color" varchar NOT NULL,
|
||||
"description" varchar,
|
||||
CONSTRAINT "UQ_tag_category_name" UNIQUE("name"),
|
||||
PRIMARY KEY("id" AUTOINCREMENT)
|
||||
);
|
||||
|
||||
CREATE TABLE "tag_filter_index" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
PRIMARY KEY("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "tag_filter_index_info" (
|
||||
"key" VARCHAR NOT NULL, dirty INTEGER DEFAULT 1,
|
||||
PRIMARY KEY("key")
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_34d6ff6807129b3b193aea2678" ON "tag_alias" (
|
||||
"name"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_6366e7093c3571f85f1b5ffd4f" ON "game_tags_tag" (
|
||||
"gameId"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_d12253f0cbce01f030a9ced11d" ON "game_tags_tag" (
|
||||
"tagId"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_ext_data_extId" ON "ext_data" (
|
||||
"extId"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_ext_data_gameId" ON "ext_data" (
|
||||
"gameId"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_gameTitle" ON "game" (
|
||||
"title"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_game_activeDataId" ON "game" (
|
||||
"activeDataId"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_game_config_game_id" ON "game_config" (
|
||||
"gameId"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_lookup_dateAdded" ON "game" (
|
||||
"library",
|
||||
"dateAdded"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_lookup_dateModified" ON "game" (
|
||||
"library",
|
||||
"dateModified"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_lookup_developer" ON "game" (
|
||||
"library",
|
||||
"developer"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_lookup_lastPlayed" ON "game" (
|
||||
"library",
|
||||
"lastPlayed"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_lookup_playtime" ON "game" (
|
||||
"library",
|
||||
"playtime"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_lookup_publisher" ON "game" (
|
||||
"library",
|
||||
"publisher"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_lookup_series" ON "game" (
|
||||
"library",
|
||||
"series"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_lookup_title" ON "game" (
|
||||
"library",
|
||||
"title"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_redirect_sourceId" ON "game_redirect" (
|
||||
"sourceId"
|
||||
);
|
||||
|
||||
CREATE INDEX "IDX_total" ON "game" (
|
||||
"library",
|
||||
"broken",
|
||||
"extreme"
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
<!doctype html>
|
||||
<!-- Pixel Penguin Panic: a tiny game made for Dotcom Arcade's demo data. Catch
|
||||
the fish, dodge the rocks. Plays itself until a key is pressed. -->
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Pixel Penguin Panic</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #0b1730; }
|
||||
canvas { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="640" height="480"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('game');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = 640, H = 480, GROUND = 420;
|
||||
let penguin = { x: W / 2, dir: 0 }, things = [], score = 0, best = 0, lives = 3;
|
||||
let demo = true, tick = 0, flash = 0;
|
||||
const keys = {};
|
||||
|
||||
addEventListener('keydown', (e) => { keys[e.key] = true; if (demo) { demo = false; reset(); } });
|
||||
addEventListener('keyup', (e) => { keys[e.key] = false; });
|
||||
canvas.addEventListener('pointerdown', () => { if (demo) { demo = false; reset(); } });
|
||||
canvas.addEventListener('pointermove', (e) => {
|
||||
if (demo) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
penguin.x = ((e.clientX - r.left) / r.width) * W;
|
||||
});
|
||||
|
||||
function reset() { things = []; score = 0; lives = 3; penguin.x = W / 2; }
|
||||
|
||||
function spawn() {
|
||||
const rock = Math.random() < 0.3;
|
||||
things.push({ x: 30 + Math.random() * (W - 60), y: -20, vy: 2 + Math.random() * 2 + score / 400, rock });
|
||||
}
|
||||
|
||||
function update() {
|
||||
tick++;
|
||||
if (tick % 28 === 0) spawn();
|
||||
if (demo) {
|
||||
const fish = things.filter((t) => !t.rock).sort((a, b) => b.y - a.y)[0];
|
||||
const target = fish ? fish.x : W / 2;
|
||||
penguin.x += Math.max(-5, Math.min(5, target - penguin.x));
|
||||
} else {
|
||||
if (keys.ArrowLeft || keys.a) penguin.x -= 6;
|
||||
if (keys.ArrowRight || keys.d) penguin.x += 6;
|
||||
}
|
||||
penguin.x = Math.max(24, Math.min(W - 24, penguin.x));
|
||||
for (const t of things) {
|
||||
t.y += t.vy;
|
||||
if (t.y > GROUND - 40 && t.y < GROUND && Math.abs(t.x - penguin.x) < 30) {
|
||||
t.done = true;
|
||||
if (t.rock) { lives--; flash = 12; if (lives <= 0) { best = Math.max(best, score); reset(); } }
|
||||
else score += 10;
|
||||
}
|
||||
if (t.y > H) t.done = true;
|
||||
}
|
||||
things = things.filter((t) => !t.done);
|
||||
if (flash > 0) flash--;
|
||||
}
|
||||
|
||||
function drawPenguin(x) {
|
||||
const y = GROUND;
|
||||
ctx.fillStyle = '#111827'; ctx.beginPath(); ctx.ellipse(x, y - 26, 22, 28, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#f8fafc'; ctx.beginPath(); ctx.ellipse(x, y - 22, 14, 20, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#fff'; ctx.fillRect(x - 9, y - 46, 7, 7); ctx.fillRect(x + 3, y - 46, 7, 7);
|
||||
ctx.fillStyle = '#000'; ctx.fillRect(x - 6, y - 44, 3, 4); ctx.fillRect(x + 6, y - 44, 3, 4);
|
||||
ctx.fillStyle = '#f59e0b'; ctx.fillRect(x - 5, y - 36, 10, 5); ctx.fillRect(x - 16, y - 2, 12, 4); ctx.fillRect(x + 4, y - 2, 12, 4);
|
||||
}
|
||||
|
||||
function drawFish(t) {
|
||||
ctx.fillStyle = '#38bdf8';
|
||||
ctx.beginPath(); ctx.ellipse(t.x, t.y, 14, 8, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(t.x + 12, t.y); ctx.lineTo(t.x + 22, t.y - 8); ctx.lineTo(t.x + 22, t.y + 8); ctx.fill();
|
||||
ctx.fillStyle = '#000'; ctx.fillRect(t.x - 8, t.y - 3, 3, 3);
|
||||
}
|
||||
|
||||
function drawRock(t) {
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.beginPath(); ctx.arc(t.x, t.y, 13, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#94a3b8'; ctx.fillRect(t.x - 6, t.y - 7, 5, 4);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, H);
|
||||
sky.addColorStop(0, '#1e3a8a'); sky.addColorStop(1, '#7dd3fc');
|
||||
ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
for (let i = 0; i < 40; i++) ctx.fillRect((i * 97 + tick * 0.3) % W, (i * 53) % 260, 2, 2);
|
||||
ctx.fillStyle = '#e0f2fe'; ctx.fillRect(0, GROUND, W, H - GROUND);
|
||||
ctx.fillStyle = '#bae6fd'; for (let x = 0; x < W; x += 40) ctx.fillRect(x, GROUND, 20, 6);
|
||||
things.forEach((t) => (t.rock ? drawRock(t) : drawFish(t)));
|
||||
drawPenguin(penguin.x);
|
||||
if (flash % 4 > 1) { ctx.fillStyle = 'rgba(239,68,68,0.3)'; ctx.fillRect(0, 0, W, H); }
|
||||
ctx.fillStyle = '#fff'; ctx.font = 'bold 20px monospace';
|
||||
ctx.fillText(`SCORE ${String(score).padStart(5, '0')}`, 16, 32);
|
||||
ctx.fillText('♥'.repeat(lives), W - 80, 32);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = 'bold 34px monospace'; ctx.fillText('PIXEL PENGUIN PANIC', W / 2, 90);
|
||||
if (demo && Math.floor(tick / 30) % 2) { ctx.font = 'bold 18px monospace'; ctx.fillText('PRESS ANY KEY', W / 2, 124); }
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
(function loop() { update(); draw(); requestAnimationFrame(loop); })();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
// Takes the README's screenshots (docs/images) from an app running on the demo
|
||||
// data, with a fresh user database. See docs/08-development/demo-data.md.
|
||||
//
|
||||
// node scripts/demo/take-screenshots.mjs [base URL, default http://localhost:3100]
|
||||
//
|
||||
// Needs Playwright: npm install --no-save playwright && npx playwright install chromium
|
||||
import { dirname, join } from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const base = process.argv[2] ?? 'http://localhost:3100';
|
||||
const images = join(dirname(new URL(import.meta.url).pathname), '../../docs/images');
|
||||
const user = { username: 'demo', email: 'demo@example.com', password: 'demo-password' };
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({
|
||||
baseURL: base,
|
||||
viewport: { width: 1280, height: 1060 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const api = context.request;
|
||||
|
||||
async function call(method, url, data) {
|
||||
const response = await api.fetch(url, { method, data });
|
||||
if (!response.ok())
|
||||
throw new Error(`${method} ${url}: ${response.status()} ${await response.text()}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const { needsSetup } = await call('GET', '/api/auth/setup-status');
|
||||
if (needsSetup) await call('POST', '/api/auth/register', user);
|
||||
const { tokens } = await call('POST', '/api/auth/login', {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
});
|
||||
const auth = { Authorization: `Bearer ${tokens.accessToken}` };
|
||||
|
||||
async function findGame(title) {
|
||||
const response = await api.post('/api/games', {
|
||||
data: { search: title, limit: 1 },
|
||||
headers: auth,
|
||||
});
|
||||
const [game] = (await response.json()).data;
|
||||
if (game?.title !== title) throw new Error(`demo game not found: ${title}`);
|
||||
return game;
|
||||
}
|
||||
|
||||
// Recently played, oldest first, for the homepage's "Jump back in"
|
||||
for (const title of [
|
||||
'Moon Mower',
|
||||
'Jelly Jump',
|
||||
'Laser Llama',
|
||||
'Lemonade Empire',
|
||||
'Crayon Kart',
|
||||
'Drum Doodle',
|
||||
'Pocket Pirates',
|
||||
]) {
|
||||
const game = await findGame(title);
|
||||
const start = await api.post('/api/play/start', {
|
||||
data: { gameId: game.id, gameTitle: game.title },
|
||||
headers: auth,
|
||||
});
|
||||
const { sessionId } = await start.json();
|
||||
await api.post('/api/play/end', { data: { sessionId }, headers: auth });
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForFunction(() => [...document.images].every((image) => image.complete));
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// The Game of the Day follows the browser's date; this one picks The Lost Floppy
|
||||
await page.clock.setFixedTime(new Date('2026-06-08T12:00:00'));
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByPlaceholder('Enter your username').fill(user.username);
|
||||
await page.getByPlaceholder('Enter your password').fill(user.password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForURL((url) => url.pathname !== '/login');
|
||||
await page.goto('/');
|
||||
await settle();
|
||||
|
||||
// The Time Machine opens on a random week; travel to the one the demo fills
|
||||
await page.getByTitle('Pick a date').click();
|
||||
await page.getByRole('combobox', { name: /month/i }).selectOption({ label: 'Jun' });
|
||||
await page.getByRole('combobox', { name: /year/i }).selectOption('2008');
|
||||
await page.getByRole('button', { name: /June 16(th)?, 2008/ }).click();
|
||||
await settle();
|
||||
await page.screenshot({ path: join(images, 'home.png') });
|
||||
|
||||
const penguin = await findGame('Pixel Penguin Panic');
|
||||
await page.goto(`/browse/${penguin.id}`);
|
||||
await settle();
|
||||
await page.screenshot({ path: join(images, 'game.png') });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /^play$/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(6000);
|
||||
await page.screenshot({ path: join(images, 'playing.png') });
|
||||
|
||||
await browser.close();
|
||||
console.log(`Screenshots written to ${images}`);
|
||||
Reference in New Issue
Block a user