Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d085b08dd |
@@ -1,2 +1 @@
|
|||||||
@firebox:registry = "https://nodepack.playbox.link"
|
@firebox:registry = "https://nodepack.playbox.link"
|
||||||
@dylanpizzo:registry = "https://dylanpizzo.dev"
|
|
||||||
+6
-6
@@ -1,15 +1,15 @@
|
|||||||
# Installs Node image
|
# Installs Node image
|
||||||
FROM node:20-alpine as base
|
FROM node:18-alpine as base
|
||||||
|
|
||||||
# sets the working directory for any RUN, CMD, COPY command
|
# sets the working directory for any RUN, CMD, COPY command
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# # Install Python and pip
|
# Install Python and pip
|
||||||
# RUN apk add --update python3 py3-pip
|
RUN apk add --update python3 py3-pip
|
||||||
# RUN python3 -m ensurepip
|
RUN python3 -m ensurepip
|
||||||
# RUN pip3 install --no-cache --upgrade pip setuptools
|
RUN pip3 install --no-cache --upgrade pip setuptools
|
||||||
|
|
||||||
# COPY ./data ./data
|
COPY ./data ./data
|
||||||
|
|
||||||
# Copies stuff to cache for install
|
# Copies stuff to cache for install
|
||||||
COPY ./package.json ./package-lock.json tsconfig.json ./
|
COPY ./package.json ./package-lock.json tsconfig.json ./
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# Picobook
|
# Firstack
|
||||||
|
|
||||||
A website for hosting pico8 projects.
|
Firstack is a template repo for a tech stack. This stack includes
|
||||||
|
|
||||||
|
- [react](https://react.dev/)
|
||||||
|
- [emotion](https://emotion.sh/)
|
||||||
|
- [fastify](https://fastify.dev/)
|
||||||
|
- [postgres](https://www.postgresql.org/)
|
||||||
|
- [typescript](https://www.typescriptlang.org/)
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+30
-13
@@ -1,16 +1,33 @@
|
|||||||
version: "3.9"
|
version: '3.9'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
container_name: picobook-app
|
container_name: picobook-app
|
||||||
image: node
|
image: node
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: base
|
target: base
|
||||||
env_file: .env
|
env_file:
|
||||||
ports:
|
.env
|
||||||
- ${PORT}:${PORT}
|
ports:
|
||||||
profiles: ["prod"]
|
- ${PORT}:${PORT}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
profiles: ["prod"]
|
||||||
|
|
||||||
|
db:
|
||||||
|
container_name: picobook-postgres
|
||||||
|
image: postgres
|
||||||
|
env_file:
|
||||||
|
.env
|
||||||
|
ports:
|
||||||
|
- '5432:${DB_PORT}'
|
||||||
|
volumes:
|
||||||
|
- data:/data/db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=${DB_NAME}
|
||||||
|
profiles: ["dev", "prod"]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data: {}
|
data: {}
|
||||||
Generated
+8217
-9268
File diff suppressed because it is too large
Load Diff
+60
-60
@@ -1,62 +1,62 @@
|
|||||||
{
|
{
|
||||||
"name": "picobook",
|
"name": "firstack",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Picobook.",
|
"description": "Firstack is a template repo for a tech stack.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"makeuser": "npm run withenv ./scripts/makeuser.ts",
|
"makeuser": "npm run withenv ./scripts/makeuser.ts",
|
||||||
"dev-docker": "docker compose --profile dev up -d",
|
"dev-docker": "docker compose --profile dev up -d",
|
||||||
"dev-server": "echo \"starting server\" && npm run withenv ./src/server/index.ts",
|
"dev-server": "echo \"starting server\" && npm run withenv ./src/server/index.ts",
|
||||||
"dev-watch-client": "npm run withenv ./scripts/watch.ts",
|
"dev-watch-client": "npm run withenv ./scripts/watch.ts",
|
||||||
"dev-migrate": "source ./.env && pg-migrations apply --directory ./src/database/migrations",
|
"dev-migrate": "source ./.env && pg-migrations apply --directory ./src/database/migrations",
|
||||||
"prod-migrate": "pg-migrations apply --directory ./src/database/migrations",
|
"prod-migrate": "pg-migrations apply --directory ./src/database/migrations",
|
||||||
"prod-build-client": "npm run withenv ./scripts/build.ts",
|
"prod-build-client": "npm run withenv ./scripts/build.ts",
|
||||||
"prod-docker": "docker compose --profile prod up -d",
|
"prod-docker": "docker compose --profile prod up -d",
|
||||||
"prod-start": "echo \"building frontend\" && npm run prod-build-client && echo \"starting server\" && npm run withenv ./src/server/index.ts",
|
"prod-start": "echo \"building frontend\" && npm run prod-build-client && echo \"${DATABASE_URL}\" && echo \"running migrations\" && npm run prod-migrate && echo \"starting server\" && npm run withenv ./src/server/index.ts",
|
||||||
"withenv": "tsx ./scripts/run-with-env.ts",
|
"withenv": "tsx ./scripts/run-with-env.ts",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"add-pico": "npm run withenv ./scripts/do-release.ts"
|
"build-p8client": "bun build src/client/pico8-client/veryRawRenderCart.js --outdir src/client/pico8-client/build",
|
||||||
},
|
"add-pico": "npm run withenv ./scripts/do-release.ts"
|
||||||
"repository": {
|
},
|
||||||
"type": "git",
|
"repository": {
|
||||||
"url": "https://git.playbox.link/dylan/firstack.git"
|
"type": "git",
|
||||||
},
|
"url": "https://git.playbox.link/dylan/firstack.git"
|
||||||
"author": "",
|
},
|
||||||
"license": "UNLICENSED",
|
"author": "",
|
||||||
"dependencies": {
|
"license": "UNLICENSED",
|
||||||
"@athingperday/react-pico-player": "^0.1.1",
|
"dependencies": {
|
||||||
"@databases/pg": "^5.4.1",
|
"@databases/pg": "^5.4.1",
|
||||||
"@fastify/cookie": "^9.0.4",
|
"@fastify/cookie": "^9.0.4",
|
||||||
"@fastify/secure-session": "^7.1.0",
|
"@fastify/secure-session": "^7.1.0",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^6.10.2",
|
||||||
"@fastify/websocket": "^10.0.1",
|
"@fastify/websocket": "^10.0.1",
|
||||||
"@octokit/core": "^7.0.6",
|
"@firebox/components": "^0.1.5",
|
||||||
"@sinclair/typebox": "^0.31.5",
|
"@firebox/tsutil": "^0.1.2",
|
||||||
"dotenv": "^16.4.5",
|
"@sinclair/typebox": "^0.31.5",
|
||||||
"execa": "^8.0.1",
|
"dotenv": "^16.4.5",
|
||||||
"fastify": "^4.26.2",
|
"execa": "^8.0.1",
|
||||||
"isomorphic-git": "^1.25.6",
|
"fastify": "^4.26.2",
|
||||||
"octokit": "^5.0.5",
|
"isomorphic-git": "^1.25.6",
|
||||||
"react-pico-8": "^4.1.0",
|
"react-pico-8": "^4.1.0",
|
||||||
"react-router-dom": "^6.18.0",
|
"react-router-dom": "^6.18.0",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@databases/pg-migrations": "^5.0.2",
|
"@databases/pg-migrations": "^5.0.2",
|
||||||
"@emotion/css": "^11.11.2",
|
"@emotion/css": "^11.11.2",
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.1",
|
||||||
"@types/node": "^20.11.30",
|
"@types/node": "^20.11.30",
|
||||||
"@types/react": "^18.2.21",
|
"@types/react": "^18.2.21",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
"esbuild": "^0.19.2",
|
"esbuild": "^0.19.2",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^4.10.1",
|
"react-icons": "^4.10.1",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "^5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-28
@@ -1,15 +1,15 @@
|
|||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom"
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { DbRelease } from "../server/dbal/dbal";
|
||||||
import { css } from "@emotion/css";
|
import { css } from "@emotion/css";
|
||||||
// import { type Pico8Player } from "@athingperday/react-pico-player";
|
|
||||||
|
|
||||||
type Info = {
|
type Info = {
|
||||||
author: string | null;
|
author: string | null;
|
||||||
games: string[];
|
games: {slug: string; releases: DbRelease[]}[];
|
||||||
};
|
}
|
||||||
|
|
||||||
export const AuthorPage = () => {
|
export const AuthorPage = () => {
|
||||||
const { author } = useParams();
|
const {author} = useParams();
|
||||||
const [info, setInfo] = useState<Info | null>(null);
|
const [info, setInfo] = useState<Info | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -17,38 +17,46 @@ export const AuthorPage = () => {
|
|||||||
let url = `/api/author?author=${author}`;
|
let url = `/api/author?author=${author}`;
|
||||||
const information = await fetch(url);
|
const information = await fetch(url);
|
||||||
const json = await information.json();
|
const json = await information.json();
|
||||||
console.log("json", json);
|
console.log('json', json);
|
||||||
setInfo(json);
|
setInfo(json);
|
||||||
};
|
}
|
||||||
fetchInfo();
|
fetchInfo();
|
||||||
}, [setInfo, author]);
|
}, [setInfo, author]);
|
||||||
|
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return <div>LOADING...</div>;
|
return (
|
||||||
|
<div>
|
||||||
|
LOADING...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!info.author) {
|
if (!info.author) {
|
||||||
return <div>NOT FOUND</div>;
|
return (
|
||||||
|
<div>
|
||||||
|
NOT FOUND
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={css`
|
||||||
className={css`
|
margin: auto;
|
||||||
margin: auto;
|
width: max-content;
|
||||||
width: max-content;
|
max-inline-size: 66ch;
|
||||||
max-inline-size: 66ch;
|
padding: 1.5em;
|
||||||
padding: 1.5em;
|
display: flex;
|
||||||
display: flex;
|
flex-direction: column;
|
||||||
flex-direction: column;
|
gap: 1em;
|
||||||
gap: 1em;
|
`}>
|
||||||
`}
|
|
||||||
>
|
|
||||||
<h1>{author}</h1>
|
<h1>{author}</h1>
|
||||||
{info.games.map((game) => (
|
{
|
||||||
<Link key={game} to={`/u/${author}/${game}`}>
|
info.games.map(game => (
|
||||||
<h3>{game}</h3>
|
<Link key={game.slug} to={`/u/${author}/${game.slug}`}>
|
||||||
</Link>
|
<h3>{game.releases[0].manifest.title ?? game.slug}</h3>
|
||||||
))}
|
</Link>
|
||||||
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
+106
-115
@@ -1,145 +1,136 @@
|
|||||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
import { Link, useParams, useSearchParams } from "react-router-dom"
|
||||||
import { MutableRefObject, useEffect, useRef, useState } from "react";
|
import { Pico8Console, Pico8ConsoleImperatives } from "./pico8-client/Pico8Console";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { DbRelease } from "../server/dbal/dbal";
|
||||||
import { css } from "@emotion/css";
|
import { css } from "@emotion/css";
|
||||||
import { useWebsocket } from "./hooks/useWebsocket";
|
import { useWebsocket } from "./hooks/useWebsocket";
|
||||||
import { Pico8Player } from "@athingperday/react-pico-player";
|
import { PicoPortal } from "./components/PicoPortal";
|
||||||
|
|
||||||
type PicoPlayerHandle =
|
type Info = {
|
||||||
NonNullable<
|
release: DbRelease | null;
|
||||||
Parameters<typeof Pico8Player>["0"]["consoleRef"]
|
versions: string[];
|
||||||
> extends MutableRefObject<infer T>
|
}
|
||||||
? NonNullable<T>
|
|
||||||
: never;
|
|
||||||
|
|
||||||
type Game = {
|
|
||||||
carts: Parameters<typeof Pico8Player>["0"]["carts"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GamePage = () => {
|
export const GamePage = () => {
|
||||||
const { author, slug } = useParams();
|
const {author, slug} = useParams();
|
||||||
// const [text, setText] = useState("");
|
|
||||||
const [prevGpio, setPrevGpio] = useState<number[] | null>(null);
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const room = searchParams.get("room");
|
const room = searchParams.get('room');
|
||||||
const picoRef = useRef<PicoPlayerHandle>(null);
|
const picoRef = useRef<Pico8ConsoleImperatives>(null);
|
||||||
const socket = useWebsocket({
|
const socket = useWebsocket({
|
||||||
url: `/api/ws/room?room=${room}`,
|
url: `/api/ws/room?room=${room}&`,
|
||||||
onMessage({ message }) {
|
// url: "wss://echo.websocket.org",
|
||||||
// console.log("message", message);
|
onMessage({message}) {
|
||||||
const msg = message as any;
|
if (picoRef.current) {
|
||||||
if (msg.getGpio) {
|
const handle = picoRef.current.getPicoConsoleHandle();
|
||||||
if (picoRef.current) {
|
if (handle) {
|
||||||
const handle = picoRef.current;
|
handle.buttons;
|
||||||
if (handle) {
|
|
||||||
socket.sendMessage({ gpio: handle.gpio });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (msg.gpio) {
|
// const msg = message as any;
|
||||||
if (picoRef.current) {
|
// if (msg.type === "gpio") {
|
||||||
const handle = picoRef.current;
|
// if (picoRef.current) {
|
||||||
if (handle) {
|
// const handle = picoRef.current.getPicoConsoleHandle();
|
||||||
// console.log("updating pico gpio");
|
// if (handle) {
|
||||||
handle.gpio.length = 0;
|
// console.log("updating pico gpio");
|
||||||
handle.gpio.push(...msg.gpio);
|
// (handle.gpio as any).dontSend = true;
|
||||||
setPrevGpio([...handle.gpio]);
|
// handle.gpio.length = 0;
|
||||||
}
|
// handle.gpio.push(...msg.gpio);
|
||||||
}
|
// (handle.gpio as any).dontSend = false;
|
||||||
}
|
// }
|
||||||
},
|
// }
|
||||||
});
|
// }
|
||||||
const [game, setGame] = useState<Game | null>(null);
|
console.log('message', message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// const version = searchParams.get('v');
|
||||||
|
const [v, setVersion] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<Info | null>(null);
|
||||||
|
|
||||||
|
const version = v ?? info?.release?.version ?? info?.versions[0];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchInfo = async () => {
|
const fetchInfo = async () => {
|
||||||
let url = `/api/game?author=${author}&name=${slug ?? ""}`;
|
let url = `/api/release?author=${author}&slug=${slug}`;
|
||||||
|
if (version) {
|
||||||
|
url += `&version=${version}`;
|
||||||
|
}
|
||||||
const information = await fetch(url);
|
const information = await fetch(url);
|
||||||
const json = await information.json();
|
const json = await information.json();
|
||||||
console.log(json);
|
setInfo(json);
|
||||||
setGame(json);
|
}
|
||||||
};
|
|
||||||
fetchInfo();
|
fetchInfo();
|
||||||
}, [setGame, slug]);
|
}, [setInfo, author, slug, version]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!info) {
|
||||||
const interval = setInterval(() => {
|
return (
|
||||||
const handle = picoRef.current;
|
<div>
|
||||||
if (!handle) {
|
LOADING...
|
||||||
return;
|
</div>
|
||||||
}
|
)
|
||||||
if (JSON.stringify(handle.gpio) !== JSON.stringify(prevGpio)) {
|
|
||||||
if (prevGpio) {
|
|
||||||
setPrevGpio([...handle.gpio]);
|
|
||||||
socket.sendMessage({ gpio: handle.gpio });
|
|
||||||
} else {
|
|
||||||
socket.sendMessage({ getGpio: true });
|
|
||||||
setPrevGpio([...handle.gpio]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1000 / 60);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!game) {
|
|
||||||
return <div>LOADING...</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!game.carts) {
|
if (!info.release) {
|
||||||
return <div>NOT FOUND</div>;
|
return (
|
||||||
|
<div>
|
||||||
|
NOT FOUND
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={css`
|
||||||
className={css`
|
margin: auto;
|
||||||
margin: auto;
|
width: max-content;
|
||||||
width: max-content;
|
max-inline-size: 66ch;
|
||||||
max-inline-size: 66ch;
|
padding: 1.5em;
|
||||||
padding: 1.5em;
|
display: flex;
|
||||||
display: flex;
|
flex-direction: column;
|
||||||
flex-direction: column;
|
gap: 1em;
|
||||||
gap: 1em;
|
`}>
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<h1>{slug}</h1>
|
<h1>{info.release.manifest.title ?? slug!.split("-").map(word => word[0].toUpperCase()+word.slice(1)).join(" ")}</h1>
|
||||||
<h2>
|
<h2>by <Link to={`/u/${info.release.author}`}>{info.release.author}</Link></h2>
|
||||||
by <Link to={`/u/${author}`}>{author}</Link>
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className={css`
|
||||||
className={css`
|
width: 512px;
|
||||||
width: 512px;
|
max-width: 100%;
|
||||||
max-width: 100%;
|
margin: auto;
|
||||||
margin: auto;
|
`}>
|
||||||
`}
|
<div className={css`
|
||||||
>
|
border: 2px solid transparent;
|
||||||
<div
|
&:focus-within {
|
||||||
className={css`
|
border: 2px solid limegreen;
|
||||||
border: 2px solid transparent;
|
}
|
||||||
&:focus-within {
|
`}>
|
||||||
border: 2px solid limegreen;
|
<Pico8Console
|
||||||
|
ref={picoRef}
|
||||||
|
carts={info.release.carts}
|
||||||
|
// onGpioChange={(gpio: number[]) => {
|
||||||
|
// console.log("sending gpio");
|
||||||
|
// socket.sendMessage({
|
||||||
|
// type: "gpio",
|
||||||
|
// gpio,
|
||||||
|
// });
|
||||||
|
// }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={css`
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
`}>
|
||||||
|
Version: <select defaultValue={info.release.version} onChange={(ev) => setVersion(ev.target.value)}>
|
||||||
|
{
|
||||||
|
[...info.versions].reverse().map(v => (
|
||||||
|
<option key={v} value={v}>{v}</option>
|
||||||
|
))
|
||||||
}
|
}
|
||||||
`}
|
</select>
|
||||||
>
|
|
||||||
<Pico8Player consoleRef={picoRef} carts={game.carts} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* <div>
|
<PicoPortal />
|
||||||
<input onChange={(x) => setText(x.target.value)} />
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
socket.sendMessage({ text });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div> */}
|
|
||||||
{/* <div>
|
{/* <div>
|
||||||
<p>This is a paragraph about this game. It is a cool game. And a cool website to play it on. It automagically connects from GitHub.</p>
|
<p>This is a paragraph about this game. It is a cool game. And a cool website to play it on. It automagically connects from GitHub.</p>
|
||||||
</div> */}
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
+2
-14
@@ -1,15 +1,3 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
import { authors } from "../data/authors";
|
|
||||||
|
|
||||||
export const HomePage = () => {
|
export const HomePage = () => {
|
||||||
return (
|
return <div>Welcome to Picobook!</div>
|
||||||
<div>
|
}
|
||||||
<h2>Welcome to Picobook!</h2>
|
|
||||||
{authors.map((author) => (
|
|
||||||
<Link key={author.username} to={`/u/${author.username}`}>
|
|
||||||
<h3>{author.username}</h3>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
// import { css } from "@emotion/css";
|
import { css } from "@emotion/css";
|
||||||
// import { Pico8Console } from "./pico8-client/Pico8Console";
|
import { Pico8Console } from "./pico8-client/Pico8Console";
|
||||||
// import testcarts from "./testcarts";
|
import testcarts from "./testcarts";
|
||||||
import { Routing } from "./routing";
|
import { Routing } from "./routing";
|
||||||
|
|
||||||
const App = (props: {}) => {
|
const App = (props: {}) => {
|
||||||
return (
|
return (
|
||||||
<Routing />
|
<Routing/>
|
||||||
// <div className={css`
|
// <div className={css`
|
||||||
// min-height: 100vh;
|
// min-height: 100vh;
|
||||||
// `}>
|
// `}>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { Pico8Console, Pico8ConsoleImperatives } from "../pico8-client/Pico8Console"
|
||||||
|
|
||||||
|
export const PicoPortal = () => {
|
||||||
|
const emptyCartData: number[] = new Array(32786).fill(0);
|
||||||
|
const cart = {name: "empty", rom: emptyCartData};
|
||||||
|
// const picoRef = useRef<Pico8ConsoleImperatives>(null);
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
<Pico8Console
|
||||||
|
ref={(ref) => {
|
||||||
|
if (!ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handle = ref.getPicoConsoleHandle();
|
||||||
|
if (!handle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle.buttons.subscribe((buttons) => {
|
||||||
|
console.log(buttons);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
carts={[cart]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,52 +1,46 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export const useWebsocket = (props: {
|
export const useWebsocket = (props: {url: string; onMessage: (stuff: {socket: WebSocket; message: unknown}) => void}) => {
|
||||||
url: string;
|
const {url, onMessage} = props;
|
||||||
onMessage: (stuff: { socket: WebSocket; message: unknown }) => void;
|
const onMessageRef = useRef(onMessage);
|
||||||
}) => {
|
const ws = useRef<WebSocket | null>(null);
|
||||||
const { url, onMessage } = props;
|
onMessageRef.current = onMessage;
|
||||||
const onMessageRef = useRef(onMessage);
|
|
||||||
const ws = useRef<WebSocket | null>(null);
|
|
||||||
onMessageRef.current = onMessage;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const webSocket = new WebSocket(url);
|
const webSocket = new WebSocket(url);
|
||||||
|
|
||||||
webSocket.addEventListener("open", () => {
|
webSocket.addEventListener("open", () => {
|
||||||
console.log("WebSocket is open now.");
|
console.log("WebSocket is open now.");
|
||||||
});
|
});
|
||||||
|
|
||||||
webSocket.addEventListener("message", (event: any) => {
|
webSocket.addEventListener("message", (event: any) => {
|
||||||
onMessageRef.current({
|
onMessageRef.current({socket: webSocket, message: JSON.parse(event.data)});
|
||||||
socket: webSocket,
|
});
|
||||||
message: JSON.parse(event.data),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
webSocket.addEventListener("error", (event) => {
|
webSocket.addEventListener("error", (event) => {
|
||||||
console.log("WebSocket error: ", event);
|
console.log("WebSocket error: ", event);
|
||||||
});
|
});
|
||||||
|
|
||||||
webSocket.addEventListener("close", () => {
|
webSocket.addEventListener("close", () => {
|
||||||
console.log("WebSocket is closed now.");
|
console.log("WebSocket is closed now.");
|
||||||
ws.current = null;
|
ws.current = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.current = webSocket;
|
ws.current = webSocket;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
webSocket.close();
|
webSocket.close();
|
||||||
};
|
};
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
const sendMessage = useCallback((message: unknown) => {
|
}, [url]);
|
||||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
|
||||||
ws.current.send(JSON.stringify(message));
|
|
||||||
// console.log("sending", message);
|
|
||||||
} else {
|
|
||||||
console.error("WebSocket is not open. Message not sent.");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { sendMessage };
|
const sendMessage = useCallback((message: unknown) => {
|
||||||
|
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||||
|
ws.current.send(JSON.stringify(message));
|
||||||
|
} else {
|
||||||
|
console.error("WebSocket is not open. Message not sent.");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {sendMessage};
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { css } from "@emotion/css";
|
||||||
|
import { PicoCart, PicoPlayerHandle, makePicoConsole } from "./renderCart";
|
||||||
|
import { ForwardedRef, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type Pico8ConsoleImperatives = {
|
||||||
|
getPicoConsoleHandle(): PicoPlayerHandle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Pico8ConsoleProps = {
|
||||||
|
carts: PicoCart[],
|
||||||
|
// onGpioChange?(gpio: number[]): void,
|
||||||
|
}
|
||||||
|
|
||||||
|
// const noop = () => {};
|
||||||
|
|
||||||
|
export const Pico8Console = forwardRef((props: Pico8ConsoleProps, forwardedRef: ForwardedRef<Pico8ConsoleImperatives>) => {
|
||||||
|
const {
|
||||||
|
carts,
|
||||||
|
// onGpioChange = noop
|
||||||
|
} = props;
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [handle, setHandle] = useState<PicoPlayerHandle | null>(null);
|
||||||
|
const attachConsole = useCallback(async () => {
|
||||||
|
const picoConsole = await makePicoConsole({
|
||||||
|
carts,
|
||||||
|
});
|
||||||
|
picoConsole.canvas.tabIndex=0;
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.appendChild(picoConsole.canvas);
|
||||||
|
|
||||||
|
// Set the width and height because pico8 adds them as properties on chrome-based browsers
|
||||||
|
picoConsole.canvas.style.width = "";
|
||||||
|
picoConsole.canvas.style.height = "";
|
||||||
|
|
||||||
|
picoConsole.canvas.focus();
|
||||||
|
}
|
||||||
|
setHandle(picoConsole);
|
||||||
|
// picoConsole.gpio.subscribe(onGpioChange);
|
||||||
|
picoConsole.canvas.addEventListener('keydown',(event) => {
|
||||||
|
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}, {passive: false});
|
||||||
|
picoConsole.canvas.addEventListener('click', () => {
|
||||||
|
picoConsole.canvas.focus();
|
||||||
|
})
|
||||||
|
}, [carts]);
|
||||||
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
|
getPicoConsoleHandle() {
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}), [handle]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (playing) {
|
||||||
|
attachConsole();
|
||||||
|
return () => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [playing, attachConsole]);
|
||||||
|
if (!playing) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={css`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background-color: black;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
`}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {setPlaying(true)}}
|
||||||
|
>
|
||||||
|
Play!
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={css`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
& > canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
`}></div>
|
||||||
|
);
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
export {makePicoConsole} from "./renderCart";
|
||||||
|
export {pngToRom} from "./pngToRom";
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// TODO: something is broken for the new mygame.p8.png
|
||||||
|
const imageDataToRom = (imageData: ImageData) => {
|
||||||
|
const width = imageData.width;
|
||||||
|
const height = imageData.height;
|
||||||
|
const data = []; // For raw cart data
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const index = (y * width + x) * 4;
|
||||||
|
const r = imageData.data[index];
|
||||||
|
const g = imageData.data[index + 1];
|
||||||
|
const b = imageData.data[index + 2];
|
||||||
|
const a = imageData.data[index + 3];
|
||||||
|
|
||||||
|
// Extracting and encoding the data from pixel components
|
||||||
|
const byte = ((b & 3) << 0) | ((g & 3) << 2) | ((r & 3) << 4) | ((a & 3) << 6);
|
||||||
|
data.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point, `data` contains the raw bytes extracted from the image
|
||||||
|
// Here, you would decode this data into a format representing the cart
|
||||||
|
// This could involve uncompressing code, reading sprite data, etc.
|
||||||
|
// The specifics depend on your cart format and how data was originally encoded
|
||||||
|
|
||||||
|
// Returning raw data for demonstration; you'll need to adapt this part
|
||||||
|
return data.slice(0, 32768);
|
||||||
|
// return data.slice(0, 32768);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngGetImageData = (src: string) => {
|
||||||
|
return new Promise<ImageData>((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||||
|
resolve(imageData);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(Error("BAD IMAGE"));
|
||||||
|
}
|
||||||
|
img.src = src;
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pngToRom = async (src: string) => {
|
||||||
|
return imageDataToRom(await pngGetImageData(src));
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
import "./build/veryRawRenderCart.js";
|
||||||
|
|
||||||
|
export type PicoBool = 0 | 1;
|
||||||
|
|
||||||
|
export type RenderCart = (Module: {canvas: HTMLCanvasElement, codo_textarea?: HTMLTextAreaElement}, cartNames: string[], cartDatas: number[][], audioContext: AudioContext) => {
|
||||||
|
p8_touch_detected?: PicoBool;
|
||||||
|
p8_dropped_cart?: string;
|
||||||
|
p8_dropped_cart_name?: string;
|
||||||
|
pico8_state?: Partial<{
|
||||||
|
frame_number: number;
|
||||||
|
has_focus: PicoBool;
|
||||||
|
is_paused: PicoBool;
|
||||||
|
request_pointer_lock: PicoBool;
|
||||||
|
require_page_navigate_confirmation: PicoBool;
|
||||||
|
show_dpad: PicoBool;
|
||||||
|
shutdown_requested: PicoBool;
|
||||||
|
sound_volume: number;
|
||||||
|
}>;
|
||||||
|
pico8_buttons?: [number, number, number, number, number, number, number, number];
|
||||||
|
pico8_gamepads?: {count: number};
|
||||||
|
pico8_gpio?: number[]; // should be 128 length
|
||||||
|
pico8_audio_context?: AudioContext;
|
||||||
|
pico8_mouse?: [number, number, number];
|
||||||
|
codo_command?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedRenderCart = (window as any).P8 as RenderCart;
|
||||||
|
|
||||||
|
export {typedRenderCart as renderCart}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { assertNever } from "@firebox/tsutil";
|
||||||
|
import { pngToRom } from "./pngToRom";
|
||||||
|
import { RenderCart, renderCart as rawRenderCart } from "./rawRenderCart";
|
||||||
|
import { Watched, watch } from "../util/watch";
|
||||||
|
|
||||||
|
export type PicoCart = {
|
||||||
|
name: string;
|
||||||
|
src: string;
|
||||||
|
} | {
|
||||||
|
name: string;
|
||||||
|
rom: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerButtons = {
|
||||||
|
left: boolean;
|
||||||
|
right: boolean;
|
||||||
|
up: boolean;
|
||||||
|
down: boolean;
|
||||||
|
o: boolean;
|
||||||
|
x: boolean;
|
||||||
|
menu: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawHandle = ReturnType<RenderCart>;
|
||||||
|
|
||||||
|
export type PicoPlayerHandle = {
|
||||||
|
raw: RawHandle;
|
||||||
|
rawModule: unknown;
|
||||||
|
// external things
|
||||||
|
readonly canvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
// i/o
|
||||||
|
buttons: Watched<NonNullable<RawHandle["pico8_buttons"]>>;
|
||||||
|
setButtons: (buttons: PlayerButtons[]) => void;
|
||||||
|
setMouse: (mouse: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
leftClick: boolean;
|
||||||
|
rightClick: boolean;
|
||||||
|
}) => void;
|
||||||
|
setGamepadCount: (count: number) => void;
|
||||||
|
gpio: (
|
||||||
|
number[]
|
||||||
|
// & {subscribe: (f: (gpio: number[]) => void) => void}
|
||||||
|
); // read + write (should be 256-tuple)
|
||||||
|
|
||||||
|
// state
|
||||||
|
readonly state: {
|
||||||
|
frameNumber: number;
|
||||||
|
isPaused: boolean;
|
||||||
|
hasFocus: boolean;
|
||||||
|
requestPointerLock: boolean;
|
||||||
|
requirePageNavigateConfirmation: boolean;
|
||||||
|
showDpad: boolean;
|
||||||
|
shutdownRequested: boolean;
|
||||||
|
soundVolume: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// misc?
|
||||||
|
setTouchDetected: (touchDetected: boolean) => void;
|
||||||
|
dropCart: (cart: PicoCart) => void;
|
||||||
|
|
||||||
|
// Module
|
||||||
|
toggleSound: () => void;
|
||||||
|
toggleControlMenu: () => void;
|
||||||
|
togglePaused: () => void;
|
||||||
|
// TODO: rename these two better (what do they do??)
|
||||||
|
modDragOver: () => void;
|
||||||
|
modDragStop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitfield = (...args: boolean[]): number => {
|
||||||
|
if (!args.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (args[0]?1:0)+2*bitfield(...args.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRom = async (cart: PicoCart) => {
|
||||||
|
if ("src" in cart) {
|
||||||
|
return await pngToRom(cart.src);
|
||||||
|
} else if ("rom" in cart) {
|
||||||
|
return cart.rom;
|
||||||
|
}
|
||||||
|
assertNever(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makePicoConsole = async (props: {
|
||||||
|
canvas?: HTMLCanvasElement;
|
||||||
|
codoTextarea?: HTMLTextAreaElement;
|
||||||
|
audioContext?: AudioContext;
|
||||||
|
carts: PicoCart[];
|
||||||
|
}): Promise<PicoPlayerHandle> => {
|
||||||
|
const {carts, canvas = document.createElement("canvas"), codoTextarea = document.createElement("textarea"), audioContext = new AudioContext()} = props;
|
||||||
|
canvas.style.imageRendering = "pixelated";
|
||||||
|
codoTextarea.style.display="none";
|
||||||
|
codoTextarea.style.position="fixed";
|
||||||
|
codoTextarea.style.left="-9999px";
|
||||||
|
codoTextarea.style.height="0px";
|
||||||
|
codoTextarea.style.overflow="hidden";
|
||||||
|
const Module = {canvas, keyboardListeningElement: canvas};
|
||||||
|
const cartsDatas = await Promise.all(carts.map(cart => getRom(cart)));
|
||||||
|
const handle = rawRenderCart(Module, carts.map(cart => cart.name), cartsDatas, audioContext);
|
||||||
|
handle.pico8_state = {};
|
||||||
|
handle.pico8_buttons = [0,0,0,0,0,0,0,0];
|
||||||
|
handle.pico8_mouse = [0,0,0];
|
||||||
|
handle.pico8_gpio = [
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
];
|
||||||
|
// let gpioChanged = (gpio: number[]) => {};
|
||||||
|
// const gpioInner = [
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
||||||
|
// ];
|
||||||
|
// handle.pico8_gpio = new Proxy(gpioInner, {
|
||||||
|
// get(target, prop) {
|
||||||
|
// return target[prop as any];
|
||||||
|
// },
|
||||||
|
// set(target, prop, newValue) {
|
||||||
|
// const t = target as any;
|
||||||
|
// if (t.setting) {
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
// const prev = [...target];
|
||||||
|
// target[prop as any] = newValue;
|
||||||
|
// const next = [...target];
|
||||||
|
// if (!t.dontSend && prev.some((p, i) => p !== next[i])) {
|
||||||
|
// gpioChanged(target);
|
||||||
|
// }
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// (handle as any).pico8_gpio.subscribe = (f: (gpio: number[]) => void) => {
|
||||||
|
// gpioChanged = f;
|
||||||
|
// }
|
||||||
|
handle.pico8_gamepads = {count: 0};
|
||||||
|
return {
|
||||||
|
raw: handle,
|
||||||
|
rawModule: Module,
|
||||||
|
canvas,
|
||||||
|
state: {
|
||||||
|
frameNumber: handle.pico8_state.frame_number!,
|
||||||
|
isPaused: !!handle.pico8_state.is_paused!,
|
||||||
|
hasFocus: !!handle.pico8_state.has_focus!,
|
||||||
|
requestPointerLock: !!handle.pico8_state.request_pointer_lock!,
|
||||||
|
requirePageNavigateConfirmation: !!handle.pico8_state.require_page_navigate_confirmation!,
|
||||||
|
showDpad: !!handle.pico8_state.show_dpad!,
|
||||||
|
shutdownRequested: !!handle.pico8_state.shutdown_requested!,
|
||||||
|
soundVolume: handle.pico8_state.sound_volume!,
|
||||||
|
},
|
||||||
|
gpio: handle.pico8_gpio as PicoPlayerHandle["gpio"],
|
||||||
|
setMouse({x, y, leftClick, rightClick}) {
|
||||||
|
handle.pico8_mouse = [x, y, bitfield(leftClick, rightClick)];
|
||||||
|
},
|
||||||
|
buttons: watch(handle.pico8_buttons!),
|
||||||
|
setButtons(buttons) {
|
||||||
|
// TODO: pad this properly here instead of casting
|
||||||
|
handle.pico8_buttons = buttons.map(({left, right, up, down, o, x, menu}) => bitfield(left, right, up, down, o, x, menu)) as any;
|
||||||
|
},
|
||||||
|
setGamepadCount(count) {
|
||||||
|
handle.pico8_gamepads = {count};
|
||||||
|
},
|
||||||
|
setTouchDetected(touchDetected) {
|
||||||
|
handle.p8_touch_detected = touchDetected ? 1 : 0;
|
||||||
|
},
|
||||||
|
dropCart(cart) {
|
||||||
|
handle.p8_dropped_cart_name = cart.name;
|
||||||
|
// TODO: make sure this is a dataURL first, and if not, load it and then pass it in
|
||||||
|
// handle.p8_dropped_cart = cart.src;
|
||||||
|
// handle.codo_command = 9;
|
||||||
|
},
|
||||||
|
modDragOver: (Module as any).pico8DragOver,
|
||||||
|
modDragStop: (Module as any).pico8DragStop,
|
||||||
|
togglePaused: (Module as any).pico8TogglePaused,
|
||||||
|
toggleSound: (Module as any).pico8ToggleSound,
|
||||||
|
toggleControlMenu: (Module as any).pico8ToggleControlMenu,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
|||||||
|
const deepEqual = (a: any, b: any) => {
|
||||||
|
if (a === b) return true;
|
||||||
|
|
||||||
|
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
||||||
|
|
||||||
|
let keysA = Object.keys(a), keysB = Object.keys(b);
|
||||||
|
|
||||||
|
if (keysA.length !== keysB.length) return false;
|
||||||
|
|
||||||
|
for (let key of keysA) {
|
||||||
|
if (!keysB.includes(key)) return false;
|
||||||
|
|
||||||
|
if (typeof a[key] === 'function' || typeof b[key] === 'function') {
|
||||||
|
if (a[key].toString() !== b[key].toString()) return false;
|
||||||
|
} else {
|
||||||
|
if (!deepEqual(a[key], b[key])) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Watched<T> = {
|
||||||
|
value: T;
|
||||||
|
subscribe: (f: (newVal: T, oldVal: T) => void) => void;
|
||||||
|
unsubscribe: (f: (newVal: T, oldVal: T) => void) => void;
|
||||||
|
locked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const watch = <T extends Record<any,any> | any[]>(target: T): Watched<T> => {
|
||||||
|
let listeners: Array<(newVal: T, oldVal: T) => void> = [];
|
||||||
|
let locked = false;
|
||||||
|
const proxy = new Proxy(target, {
|
||||||
|
get(t: any, prop) {
|
||||||
|
return t[prop as any];
|
||||||
|
},
|
||||||
|
set(t: any, prop, newValue) {
|
||||||
|
if (locked) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const prev = structuredClone(t);
|
||||||
|
t[prop as any] = newValue;
|
||||||
|
if (deepEqual(prev, t)) {
|
||||||
|
listeners.forEach(listener => listener(t, prev));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
get value() {
|
||||||
|
return target;
|
||||||
|
},
|
||||||
|
subscribe(f) {
|
||||||
|
listeners.push(f)
|
||||||
|
},
|
||||||
|
unsubscribe(f) {
|
||||||
|
listeners = listeners.filter(l => l !== f);
|
||||||
|
},
|
||||||
|
get locked() {
|
||||||
|
return locked;
|
||||||
|
},
|
||||||
|
set locked(newVal: boolean) {
|
||||||
|
locked = newVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export const authors = [
|
|
||||||
{
|
|
||||||
username: "dylanpizzo",
|
|
||||||
repo: "pico-carts",
|
|
||||||
auth: {
|
|
||||||
pat: "f4a1ab7c30ecb6b68da44f5b3f231ab6+719a6bf4edff6d809d1eb6d3ba3c9eb5:a12f955e441c684193053075690f2aba161894c26ad62d6ef8e5fb71b1969cbe755333e1dc7fc90e339ccba2fae27bd82e2ebd1bb233956e09c1e0bf1b951d69a279b4731ca26f8315514524ba93c2228b02456081624db71c14fd4ba0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
username: "loupizzo",
|
|
||||||
repo: "pico-carts",
|
|
||||||
auth: {
|
|
||||||
pat: "4827f847fdc737e5480748b4d5269bae+be1b031d650bfad15bbfef146d736c5e:3ac3c214ae4236bf89a317ba6b731b781e6e8dfa22c304adfe1ba6328cc497bc24a97f3389ce1a439c6956f2dfea900f4fba559ebe8ecb91b573bc897656c08d7f60372b69f3ee7310ae70eb75ee7bff9ce35e7c3c1b088e86e3e458a7",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import createConnectionPool, {ConnectionPool, ConnectionPoolConfig, sql} from '@databases/pg';
|
||||||
|
|
||||||
|
export {sql};
|
||||||
|
|
||||||
|
const portString = process.env["DB_PORT"];
|
||||||
|
const portNumber = portString ? parseInt(portString) : undefined;
|
||||||
|
|
||||||
|
const clientConfig: ConnectionPoolConfig = {
|
||||||
|
host: process.env["DB_HOST"],
|
||||||
|
user: process.env["DB_USER"],
|
||||||
|
database: process.env["DB_NAME"],
|
||||||
|
password: process.env["DB_PASSWORD"],
|
||||||
|
port: portNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const db: ConnectionPool = createConnectionPool({
|
||||||
|
connectionString: false,
|
||||||
|
...clientConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
process.once('SIGTERM', () => {
|
||||||
|
db.dispose().catch((ex) => {
|
||||||
|
console.error(ex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export {db};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id text,
|
||||||
|
name text,
|
||||||
|
password text
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE repos (
|
||||||
|
id text,
|
||||||
|
repo_fullname text, -- e.g. "username/reponame"
|
||||||
|
repo_hosttype text, -- "github", "gitea", "gitlab", ...
|
||||||
|
user_id text
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE releases (
|
||||||
|
id text,
|
||||||
|
repo_id text,
|
||||||
|
release_number integer,
|
||||||
|
cart_png_base64 text,
|
||||||
|
created_at time
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
DROP TABLE repos;
|
||||||
|
|
||||||
|
DROP TABLE releases;
|
||||||
|
|
||||||
|
CREATE TABLE releases (
|
||||||
|
id text,
|
||||||
|
repo text,
|
||||||
|
author text,
|
||||||
|
slug text,
|
||||||
|
version text,
|
||||||
|
carts json,
|
||||||
|
manifest json,
|
||||||
|
created_at time
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE releases DROP COLUMN created_at;
|
||||||
|
ALTER TABLE releases ADD created_at timestamp;
|
||||||
+6
-13
@@ -1,18 +1,11 @@
|
|||||||
// import type { RouteList } from "./routelist.ts";
|
import type { RouteList } from "./routelist.ts"
|
||||||
|
|
||||||
// type RouteUrl = RouteList[number]["url"];
|
type RouteUrl = RouteList[number]["url"];
|
||||||
|
|
||||||
// type HttpMethod = RouteList[number]["method"];
|
type HttpMethod = RouteList[number]["method"];
|
||||||
|
|
||||||
// type Route<M extends HttpMethod, U extends RouteUrl> = Extract<
|
type Route<M extends HttpMethod, U extends RouteUrl> = Extract<RouteList[number], {url: U, method: M}>;
|
||||||
// RouteList[number],
|
|
||||||
// { url: U; method: M }
|
|
||||||
// >;
|
|
||||||
|
|
||||||
// export type RoutePayload<M extends HttpMethod, U extends RouteUrl> = Parameters<
|
export type RoutePayload<M extends HttpMethod, U extends RouteUrl> = Parameters<Route<M, U>["handler"]>[0]["payload"];
|
||||||
// Route<M, U>["handler"]
|
|
||||||
// >[0]["payload"];
|
|
||||||
|
|
||||||
// export type RouteResponse<M extends HttpMethod, U extends RouteUrl> = Awaited<
|
export type RouteResponse<M extends HttpMethod, U extends RouteUrl> = Awaited<ReturnType<Route<M, U>["handler"]>>;
|
||||||
// ReturnType<Route<M, U>["handler"]>
|
|
||||||
// >;
|
|
||||||
@@ -1,59 +1,29 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { FirRouteInput, FirRouteOptions } from "../util/routewrap.ts";
|
import { FirRouteInput, FirRouteOptions } from "../util/routewrap.ts";
|
||||||
import { authors } from "../../data/authors.ts";
|
import { getAuthorGames, getReleases } from "../dbal/dbal.ts";
|
||||||
import { Octokit } from "octokit";
|
|
||||||
import { decrypt } from "../util/crypt.ts";
|
|
||||||
|
|
||||||
const method = "GET";
|
const method = "GET";
|
||||||
const url = "/api/author";
|
const url = "/api/author";
|
||||||
|
|
||||||
const payloadT = Type.Any();
|
const payloadT = Type.Any();
|
||||||
|
|
||||||
const handler = async ({ payload }: FirRouteInput<typeof payloadT>) => {
|
const handler = async ({payload}: FirRouteInput<typeof payloadT>) => {
|
||||||
const { author } = payload;
|
const {author} = payload;
|
||||||
|
|
||||||
const authorData = authors.find((x) => x.username === author);
|
if (typeof author !== "string") {
|
||||||
|
|
||||||
if (!authorData) {
|
|
||||||
return {
|
return {
|
||||||
author: null,
|
author: null,
|
||||||
games: [],
|
releases: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
console.log("author", authorData);
|
console.log("author", author);
|
||||||
|
|
||||||
const pat = decrypt({
|
const games = await getAuthorGames({author});
|
||||||
password: process.env.ENCRYPTION_PASSWORD!,
|
|
||||||
cyphertext: authorData.auth.pat,
|
|
||||||
});
|
|
||||||
|
|
||||||
const octokit = new Octokit({
|
|
||||||
auth: pat,
|
|
||||||
});
|
|
||||||
|
|
||||||
const gameStuff = await octokit.rest.repos.getContent({
|
|
||||||
owner: authorData.username,
|
|
||||||
repo: authorData.repo,
|
|
||||||
path: ".picobook",
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: get the games.
|
|
||||||
const games: string[] = [];
|
|
||||||
|
|
||||||
if (Array.isArray(gameStuff.data)) {
|
|
||||||
games.push(
|
|
||||||
...gameStuff.data
|
|
||||||
.map((x) => x.name)
|
|
||||||
.filter((x) => x.endsWith(".p8.png"))
|
|
||||||
.map((x) => x.slice(0, -".p8.png".length)),
|
|
||||||
);
|
|
||||||
console.log("hi");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
author,
|
author,
|
||||||
games,
|
games,
|
||||||
};
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
import { FirRouteInput, FirRouteOptions } from "../util/routewrap.ts";
|
|
||||||
import { authors } from "../../data/authors.ts";
|
|
||||||
import { Octokit } from "octokit";
|
|
||||||
import { decrypt } from "../util/crypt.ts";
|
|
||||||
|
|
||||||
const method = "GET";
|
|
||||||
const url = "/api/game";
|
|
||||||
|
|
||||||
const payloadT = Type.Any();
|
|
||||||
|
|
||||||
const handler = async ({ payload }: FirRouteInput<typeof payloadT>) => {
|
|
||||||
const { author, name } = payload;
|
|
||||||
|
|
||||||
const authorData = authors.find((x) => x.username === author);
|
|
||||||
|
|
||||||
if (!authorData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
console.log("author", authorData);
|
|
||||||
|
|
||||||
const pat = decrypt({
|
|
||||||
password: process.env.ENCRYPTION_PASSWORD!,
|
|
||||||
cyphertext: authorData.auth.pat,
|
|
||||||
});
|
|
||||||
|
|
||||||
const octokit = new Octokit({
|
|
||||||
auth: pat,
|
|
||||||
});
|
|
||||||
|
|
||||||
const gameData = (
|
|
||||||
await octokit.rest.repos.getContent({
|
|
||||||
owner: authorData.username,
|
|
||||||
repo: authorData.repo,
|
|
||||||
path: `.picobook/${name}.p8.png`,
|
|
||||||
})
|
|
||||||
).data;
|
|
||||||
|
|
||||||
console.log(gameData);
|
|
||||||
|
|
||||||
if (Array.isArray(gameData)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (gameData.type !== "file") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
carts: [{ name, src: `data:image/png;base64,${gameData.content}` }],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
payloadT,
|
|
||||||
handler,
|
|
||||||
} as const satisfies FirRouteOptions<typeof payloadT>;
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { FirRouteInput, FirRouteOptions } from "../util/routewrap.ts";
|
||||||
|
import { getRelease, getReleases } from "../dbal/dbal.ts";
|
||||||
|
|
||||||
|
const method = "GET";
|
||||||
|
const url = "/api/release";
|
||||||
|
|
||||||
|
const payloadT = Type.Any();
|
||||||
|
|
||||||
|
const handler = async ({payload}: FirRouteInput<typeof payloadT>) => {
|
||||||
|
const {author, slug, version} = payload;
|
||||||
|
|
||||||
|
if (typeof author !== "string") {
|
||||||
|
return {
|
||||||
|
release: null,
|
||||||
|
versions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof slug !== "string") {
|
||||||
|
return {
|
||||||
|
release: null,
|
||||||
|
versions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const release = await getRelease({author, slug, version});
|
||||||
|
const releases = await getReleases({author, slug});
|
||||||
|
|
||||||
|
const versions = releases.map(r => r.version);
|
||||||
|
|
||||||
|
return {
|
||||||
|
release,
|
||||||
|
versions,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
payloadT,
|
||||||
|
handler,
|
||||||
|
} as const satisfies FirRouteOptions<typeof payloadT>;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { FirRouteInput, FirRouteOptions } from "../util/routewrap";
|
||||||
|
import {git} from "../util/git.ts";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import path from "path";
|
||||||
|
import {fileURLToPath} from 'url';
|
||||||
|
import { getCarts } from "../util/carts.ts";
|
||||||
|
import { getRelease, insertRelease } from "../dbal/dbal.ts";
|
||||||
|
import { ManifestType } from "../types.ts";
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const reposPath = path.resolve(__dirname, "..", "..", "..", "repos");
|
||||||
|
|
||||||
|
const method = "POST";
|
||||||
|
const url = "/api/release";
|
||||||
|
|
||||||
|
const payloadT = Type.Any();
|
||||||
|
|
||||||
|
const handler = async ({payload}: FirRouteInput<typeof payloadT>) => {
|
||||||
|
const {manifest, token} = payload;
|
||||||
|
|
||||||
|
if (!ManifestType.Check(manifest)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const release = await getRelease({author: manifest.author, slug: manifest.id, version: manifest.version});
|
||||||
|
if (release) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = randomUUID();
|
||||||
|
const repoPath = path.join(reposPath, uuid);
|
||||||
|
|
||||||
|
await git.clone({
|
||||||
|
from: manifest.repo,
|
||||||
|
to: repoPath,
|
||||||
|
auth: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const carts = await getCarts(repoPath, manifest.carts);
|
||||||
|
|
||||||
|
await insertRelease({
|
||||||
|
manifest,
|
||||||
|
carts,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
manifest,
|
||||||
|
carts,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
payloadT,
|
||||||
|
handler,
|
||||||
|
} as const satisfies FirRouteOptions<typeof payloadT>;
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Database Access Layer stuff goes here
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { db, sql } from "../../database/db";
|
||||||
|
import { PicobookManifest } from '../types';
|
||||||
|
|
||||||
|
export type DbRelease = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
repo: string;
|
||||||
|
version: string;
|
||||||
|
carts: {name: string; rom: number[]}[];
|
||||||
|
author: string;
|
||||||
|
manifest: PicobookManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DbReleaseInternal = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
repo: string;
|
||||||
|
version: string;
|
||||||
|
carts: {carts: {name: string; rom: number[]}[]};
|
||||||
|
author: string;
|
||||||
|
manifest: PicobookManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareVersions = (a: string, b: string) => {
|
||||||
|
const [a1, a2] = a.split(".").map(x => Number(x));
|
||||||
|
const [b1, b2] = b.split(".").map(x => Number(x));
|
||||||
|
if (a1 !== b1) {
|
||||||
|
return a1 - b1;
|
||||||
|
} else {
|
||||||
|
return a2 - b2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareByVersion = (a: DbRelease, b: DbRelease) => compareVersions(a.version, b.version);
|
||||||
|
|
||||||
|
export const getReleases = async (where: {
|
||||||
|
author: string;
|
||||||
|
slug?: string;
|
||||||
|
version?: string;
|
||||||
|
}): Promise<DbRelease[]> => {
|
||||||
|
const {author, slug, version} = where;
|
||||||
|
let rows: DbReleaseInternal[];
|
||||||
|
if (!slug) {
|
||||||
|
rows = await db.query(sql`
|
||||||
|
SELECT * from releases
|
||||||
|
WHERE
|
||||||
|
author = ${author}
|
||||||
|
`);
|
||||||
|
} else if (!version) {
|
||||||
|
rows = await db.query(sql`
|
||||||
|
SELECT * from releases
|
||||||
|
WHERE
|
||||||
|
slug = ${slug} AND
|
||||||
|
author = ${author}
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
rows = await db.query(sql`
|
||||||
|
SELECT * from releases
|
||||||
|
WHERE
|
||||||
|
slug = ${slug} AND
|
||||||
|
author = ${author} AND
|
||||||
|
version = ${version}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
return rows.map(row => ({...row, carts: row.carts.carts}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRelease = async (where: {
|
||||||
|
author: string;
|
||||||
|
slug: string;
|
||||||
|
version?: string;
|
||||||
|
}) => {
|
||||||
|
const {version} = where;
|
||||||
|
const releases = await getReleases(where);
|
||||||
|
if (version) {
|
||||||
|
if (releases.length === 1) {
|
||||||
|
return releases[0];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (releases.length < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
releases.sort(compareByVersion);
|
||||||
|
return releases[releases.length-1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAuthorGames = async (where: {
|
||||||
|
author: string;
|
||||||
|
}) => {
|
||||||
|
const releases = await getReleases(where);
|
||||||
|
const games = releases.reduce((accum, curr) => {
|
||||||
|
const found = accum.find(r => r.slug === curr.slug);
|
||||||
|
if (found) {
|
||||||
|
found.releases.push(curr);
|
||||||
|
} else {
|
||||||
|
accum.push({slug: curr.slug, releases: [curr]});
|
||||||
|
}
|
||||||
|
return accum;
|
||||||
|
}, [] as {slug: string; releases: DbRelease[]}[]);
|
||||||
|
games.forEach(game => {
|
||||||
|
game.releases.sort(compareByVersion).reverse();
|
||||||
|
});
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const insertRelease = async (props: {manifest: PicobookManifest, carts: {name: string; rom: number[]}[]}) => {
|
||||||
|
const {manifest, carts} = props;
|
||||||
|
const {id: slug, author, repo, version} = manifest;
|
||||||
|
const id = uuidv4();
|
||||||
|
const now = new Date();
|
||||||
|
await db.query(sql`
|
||||||
|
INSERT INTO releases (id, slug, repo, version, author, carts, manifest, created_at)
|
||||||
|
VALUES (${id}, ${slug}, ${repo}, ${version}, ${author}, ${{carts}}, ${manifest}, ${now})
|
||||||
|
`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
+20
-14
@@ -1,28 +1,37 @@
|
|||||||
// Import the framework and instantiate it
|
// Import the framework and instantiate it
|
||||||
import Fastify from "fastify";
|
import Fastify from 'fastify'
|
||||||
import fastifyStatic from "@fastify/static";
|
import fastifyStatic from '@fastify/static'
|
||||||
import { fastifyWebsocket } from "@fastify/websocket";
|
import {fastifyWebsocket} from '@fastify/websocket';
|
||||||
import { routeList } from "./routelist.ts";
|
import { routeList } from "./routelist.ts";
|
||||||
import { attachRoute } from "./util/routewrap.ts";
|
import { attachRoute } from "./util/routewrap.ts";
|
||||||
|
import { git } from './util/git.ts';
|
||||||
|
import path from "path";
|
||||||
|
import {fileURLToPath} from 'url';
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
|
await git.clone({
|
||||||
|
from: "https://github.com/thisismypassport/shrinko8",
|
||||||
|
to: path.resolve(__dirname, "shrinko8"),
|
||||||
|
});
|
||||||
|
|
||||||
const server = Fastify({
|
const server = Fastify({
|
||||||
logger: true,
|
logger: true
|
||||||
});
|
});
|
||||||
|
|
||||||
server.register(fastifyWebsocket);
|
server.register(fastifyWebsocket);
|
||||||
|
|
||||||
server.register(fastifyStatic, {
|
server.register(fastifyStatic, {
|
||||||
root: new URL("public", import.meta.url).toString().slice("file://".length),
|
root: new URL('public', import.meta.url).toString().slice("file://".length),
|
||||||
prefix: "/",
|
prefix: '/',
|
||||||
});
|
});
|
||||||
|
|
||||||
routeList.forEach((firRoute) => {
|
routeList.forEach(firRoute => {
|
||||||
attachRoute(server, firRoute);
|
attachRoute(server, firRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.setNotFoundHandler((req, res) => {
|
server.setNotFoundHandler((req, res) => {
|
||||||
if (!req.url.startsWith("/api")) {
|
if (!req.url.startsWith("/api")) {
|
||||||
res.sendFile("index.html");
|
res.sendFile('index.html');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,11 +40,8 @@ try {
|
|||||||
// Note: host needs to be 0.0.0.0 rather than omitted or localhost, otherwise
|
// Note: host needs to be 0.0.0.0 rather than omitted or localhost, otherwise
|
||||||
// it always returns an empty reply when used inside docker...
|
// it always returns an empty reply when used inside docker...
|
||||||
// See: https://github.com/fastify/fastify/issues/935
|
// See: https://github.com/fastify/fastify/issues/935
|
||||||
await server.listen({
|
await server.listen({ port: parseInt(process.env["PORT"] ?? "3000"), host: "0.0.0.0" })
|
||||||
port: parseInt(process.env["PORT"] ?? "3000"),
|
|
||||||
host: "0.0.0.0",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
server.log.error(err);
|
server.log.error(err)
|
||||||
process.exit(1);
|
process.exit(1)
|
||||||
}
|
}
|
||||||
@@ -40,14 +40,6 @@
|
|||||||
footer {
|
footer {
|
||||||
max-inline-size: none;
|
max-inline-size: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
|
||||||
a:hover,
|
|
||||||
a:focus,
|
|
||||||
a:active,
|
|
||||||
a:visited {
|
|
||||||
color: lime;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
File diff suppressed because one or more lines are too long
+1074
-1303
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>PICO-8 Cartridge</title>
|
||||||
|
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||||
|
<style>
|
||||||
|
.cart-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cart-container > canvas {
|
||||||
|
width: 512px;
|
||||||
|
max-width: calc(100% - 2px);
|
||||||
|
border: 1px solid white;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="padding:0px; margin:0px; background-color:#222; color:#ccc">
|
||||||
|
<div id="container" class="cart-container"></div>
|
||||||
|
<button id="start-button">Click</button>
|
||||||
|
<script type="module">
|
||||||
|
import {makePicoConsole, pngToRom} from "./dist/index.js";
|
||||||
|
import mygame from "./mygamefull.js";
|
||||||
|
// const arrayToHex = (a) => {
|
||||||
|
// const h = ["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f"];
|
||||||
|
// return a.map(n => h[Math.floor(n/16)]+h[n%16]).join("");
|
||||||
|
// };
|
||||||
|
const hexToArray = (hex) => {
|
||||||
|
const h = ["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f"];
|
||||||
|
const a = [];
|
||||||
|
for (let i = 0; i < hex.length; i+=2) {
|
||||||
|
a.push(16*h.indexOf(hex[i])+h.indexOf(hex[i+1]));
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
mygame.carts[0] = ({name: mygame.carts[0].name, rom: hexToArray(mygame.carts[0].hex)});
|
||||||
|
// const mainRom = mygame.carts[0].rom;
|
||||||
|
// const pngRom = await pngToRom("mygame.p8.png");
|
||||||
|
// pngRom.forEach((v,i) => {
|
||||||
|
// if (v !== mainRom[i]) {
|
||||||
|
// console.log(i, Math.floor(i/160), i%160, v, mainRom[i]);
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// console.log(arrayToHex(await pngToRom("secondcart.p8.png")));
|
||||||
|
async function start() {
|
||||||
|
const console1 = await makePicoConsole({carts: mygame.carts});
|
||||||
|
console.log(console1);
|
||||||
|
document.getElementById("container").appendChild(console1.canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("start-button").addEventListener("click", start);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
+10
-2
@@ -1,9 +1,17 @@
|
|||||||
import echo from "./api/echo.ts";
|
import echo from "./api/echo.ts";
|
||||||
import getAuthor from "./api/getAuthor.ts";
|
import getAuthor from "./api/getAuthor.ts";
|
||||||
import getGame from "./api/getGame.ts";
|
import getRelease from "./api/getRelease.ts";
|
||||||
|
import release from "./api/release.ts";
|
||||||
import room from "./api/room.ts";
|
import room from "./api/room.ts";
|
||||||
import webhook from "./api/webhook.ts";
|
import webhook from "./api/webhook.ts";
|
||||||
|
|
||||||
export const routeList = [echo, webhook, getAuthor, room, getGame];
|
export const routeList = [
|
||||||
|
echo,
|
||||||
|
webhook,
|
||||||
|
release,
|
||||||
|
getRelease,
|
||||||
|
getAuthor,
|
||||||
|
room,
|
||||||
|
];
|
||||||
|
|
||||||
export type RouteList = typeof routeList;
|
export type RouteList = typeof routeList;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { shrinko8 } from "./shrinko8";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const outputDirPath = path.resolve(__dirname, "..", "..", "..", "output");
|
||||||
|
|
||||||
|
const getRom = async (inputFile: string) => {
|
||||||
|
const uuid = randomUUID();
|
||||||
|
const dir = path.join(outputDirPath, uuid);
|
||||||
|
fs.mkdirSync(dir, {recursive: true});
|
||||||
|
const outputFile = path.join(dir, "output.js");
|
||||||
|
|
||||||
|
await shrinko8({
|
||||||
|
inputFile,
|
||||||
|
outputFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
const js = await fs.promises.readFile(outputFile, "utf8");
|
||||||
|
|
||||||
|
await fs.promises.rm(dir, {recursive: true, force: true});
|
||||||
|
|
||||||
|
const match = js.match(/\b_cartdat\s*=\s*(\[.*?\])/s);
|
||||||
|
if (!match) {
|
||||||
|
console.log("BEGIN js contents --------------------------------------------");
|
||||||
|
console.log(js);
|
||||||
|
console.log("END js contents ----------------------------------------------");
|
||||||
|
throw Error("Could not properly parse js file to find _cartdat");
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(match[1]) as number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCart = async (baseDir: string, inputFile: string) => {
|
||||||
|
return {
|
||||||
|
name: inputFile,
|
||||||
|
rom: await getRom(path.join(baseDir, inputFile)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCarts = async (baseDir: string, inputFiles: string[]) => {
|
||||||
|
return await Promise.all(inputFiles.map(inputFile => getCart(baseDir, inputFile)));
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
|
|
||||||
// Key derivation from password
|
|
||||||
const algorithm = "aes-256-gcm";
|
|
||||||
// TODO: make salt vary by use case;
|
|
||||||
const salt = "saltysalt";
|
|
||||||
|
|
||||||
export const encrypt = (props: { password: string; text: string }) => {
|
|
||||||
const { password, text } = props;
|
|
||||||
const key = new Uint8Array(crypto.scryptSync(password, salt, 32)); // 32 bytes key
|
|
||||||
const iv = new Uint8Array(crypto.randomBytes(16)); // Initialization Vector
|
|
||||||
// Encryption
|
|
||||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
|
||||||
let encrypted = cipher.update(text, "utf8", "hex");
|
|
||||||
encrypted += cipher.final("hex");
|
|
||||||
const authTag = cipher.getAuthTag().toString("hex");
|
|
||||||
const fullEncrypted =
|
|
||||||
authTag + "+" + Buffer.from(iv).toString("hex") + ":" + encrypted; // Store IV with encrypted data
|
|
||||||
// console.log({ iv, authTag });
|
|
||||||
return fullEncrypted;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decrypt = (props: { password: string; cyphertext: string }) => {
|
|
||||||
const { password, cyphertext } = props;
|
|
||||||
const key = new Uint8Array(crypto.scryptSync(password, salt, 32)); // 32 bytes key
|
|
||||||
// Decryption
|
|
||||||
const [pre, encryptedHex] = cyphertext.split(":");
|
|
||||||
const [authTag, ivHex] = pre.split("+");
|
|
||||||
const ivFromStorage = new Uint8Array(Buffer.from(ivHex, "hex"));
|
|
||||||
// console.log({ ivFromStorage, authTag });
|
|
||||||
const decipher = crypto.createDecipheriv(algorithm, key, ivFromStorage);
|
|
||||||
decipher.setAuthTag(new Uint8Array(Buffer.from(authTag, "hex")));
|
|
||||||
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
|
||||||
decrypted += decipher.final("utf8");
|
|
||||||
return decrypted;
|
|
||||||
};
|
|
||||||
|
|
||||||
// console.log(process.env.ENCRYPTION_PASSWORD);
|
|
||||||
|
|
||||||
// const x = encrypt({
|
|
||||||
// password: process.env.ENCRYPTION_PASSWORD!,
|
|
||||||
// text: "",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const y = decrypt({
|
|
||||||
// password: process.env.ENCRYPTION_PASSWORD!,
|
|
||||||
// cyphertext: "",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// console.log(x);
|
|
||||||
// console.log(y);
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import isogit from "isomorphic-git";
|
||||||
|
import http from "isomorphic-git/http/node";
|
||||||
|
|
||||||
|
const clone = async (options: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
auth?: string;
|
||||||
|
}) => {
|
||||||
|
fs.mkdirSync(options.to, {recursive: true});
|
||||||
|
await isogit.clone({
|
||||||
|
fs,
|
||||||
|
http,
|
||||||
|
onAuth() {
|
||||||
|
return {
|
||||||
|
username: 'x-access-token',
|
||||||
|
password: options.auth,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dir: options.to,
|
||||||
|
url: options.from,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const git = {
|
||||||
|
clone,
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import {fileURLToPath} from 'url';
|
||||||
|
import {execFile} from "child_process";
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
|
const picoDirPath = path.resolve(__dirname, "..", "..", "..", "pico8");
|
||||||
|
const picoBinPath = path.resolve(picoDirPath, "pico8_dyn");
|
||||||
|
|
||||||
|
const cmd = (cmd: string, args: string[], options = {}) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(cmd, args, options, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject({error, stderr});
|
||||||
|
} else {
|
||||||
|
resolve({stdout});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const execPico = async (args: string[]) => {
|
||||||
|
return await cmd(picoBinPath, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pico8 = {
|
||||||
|
async export(filesIn: string[], fileOut: string) {
|
||||||
|
try {
|
||||||
|
// console.log((await cmd("ls", ["-la", "/app/pico8"]) as any).stdout)
|
||||||
|
return await execPico([...filesIn, "-export", fileOut]);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("CAUGHT ERROR", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const result = await pico8.export(["/home/dylan/.lexaloffle/pico-8/carts/my-pico-project/mygame.p8","/home/dylan/.lexaloffle/pico-8/carts/my-pico-project/secondcart.p8"], "/home/dylan/repos/picobook/sample2.js");
|
||||||
|
// console.log(result);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { execa } from "execa";
|
||||||
|
import path from "path";
|
||||||
|
import {fileURLToPath} from 'url';
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
|
const shrinko8DirPath = path.resolve(__dirname, "../shrinko8");
|
||||||
|
const shrinko8Path = path.resolve(shrinko8DirPath, "shrinko8.py");
|
||||||
|
const pico8DatPath = path.resolve(__dirname, "../../../data/pico8.dat");
|
||||||
|
|
||||||
|
export const shrinko8 = async (props: {
|
||||||
|
inputFile: string;
|
||||||
|
outputFile: string;
|
||||||
|
options?: string[];
|
||||||
|
}) => {
|
||||||
|
const {inputFile, outputFile, options = []} = props;
|
||||||
|
return await execa("python3", [shrinko8Path, inputFile, outputFile, "--pico8-dat", pico8DatPath, ...options])
|
||||||
|
}
|
||||||
|
|
||||||
+3
-3
@@ -3,16 +3,16 @@
|
|||||||
"target": "es2017",
|
"target": "es2017",
|
||||||
"module": "es2022",
|
"module": "es2022",
|
||||||
"lib": ["DOM"],
|
"lib": ["DOM"],
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "node",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"strict": true
|
"strict": true,
|
||||||
},
|
},
|
||||||
"ts-node": {
|
"ts-node": {
|
||||||
"esm": true,
|
"esm": true,
|
||||||
"experimentalSpecifierResolution": "node"
|
"experimentalSpecifierResolution": "node",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user