make website with nextjs (#619)

This commit is contained in:
Corwin 2024-04-09 01:01:40 +01:00 committed by GitHub
commit 362e0d864f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 5776 additions and 34130 deletions

View file

@ -22,7 +22,8 @@ jobs:
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
website/app/node_modules website/agb/node_modules
website/agb/.next/cache
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
~/target ~/target

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ target
template/Cargo.lock template/Cargo.lock
agb*/Cargo.lock agb*/Cargo.lock
*.tiled-session *.tiled-session
website/build

View file

@ -93,24 +93,23 @@ miri:
(cd agb-hashmap && cargo miri test) (cd agb-hashmap && cargo miri test)
build-mgba-wasm: build-mgba-wasm:
rm -rf website/app/src/vendor rm -rf website/agb/src/app/mgba/vendor
mkdir website/app/src/vendor mkdir website/agb/src/app/mgba/vendor
podman build --file website/mgba-wasm/BuildMgbaWasm --output=website/app/src/vendor . podman build --file website/mgba-wasm/BuildMgbaWasm --output=website/agb/src/app/mgba/vendor .
build-combo-rom-site: build-combo-rom-site:
just _build-rom "examples/combo" "AGBGAMES" just _build-rom "examples/combo" "AGBGAMES"
build-site-mgba-wrapper: build-mgba-wasm build-site-app: build-mgba-wasm build-combo-rom-site
(cd website/app && npm install --no-save --prefer-offline --no-audit) mkdir -p website/agb/public
(cd website/app && npm run build) gzip -9 -c examples/target/examples/combo.gba > website/agb/public/combo.gba.gz
(cd website/agb && npm install --no-save --prefer-offline --no-audit)
(cd website/agb && npm run build)
build-site: build-combo-rom-site build-site-mgba-wrapper build-book build-site: build-site-app build-book
rm -rf website/build rm -rf website/build
cp website/site website/build -r cp website/agb/out website/build -r
cp book/book website/build/book -r cp book/book website/build/book -r
cp website/app/build website/build/mgba -r
cp examples/target/examples/combo.gba website/build/assets/combo.gba
gzip -9 -c website/build/assets/combo.gba > website/build/assets/combo.gba.gz
_run-tool +tool: _run-tool +tool:
(cd tools && cargo build) (cd tools && cargo build)

1
website/.gitignore vendored
View file

@ -1 +0,0 @@
build

View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

View file

@ -4,23 +4,38 @@
/node_modules /node_modules
/.pnp /.pnp
.pnp.js .pnp.js
.yarn/install-state.gz
# testing # testing
/coverage /coverage
# next.js
/.next/
/out/
# production # production
/build /build
# misc # misc
.DS_Store .DS_Store
.env.local *.pem
.env.development.local
.env.test.local
.env.production.local
# debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files
.env*.local
src/vendor # vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
vendor
*.gba
*.gba.gz

36
website/agb/README.md Normal file
View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View file

@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
compiler: {
styledComponents: true,
},
output: "export",
images: { unoptimized: true },
};
export default nextConfig;

4919
website/agb/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
website/agb/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "agb",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.1.4",
"react": "^18",
"react-dom": "^18",
"sharp": "^0.33.3",
"styled-components": "^6.1.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/styled-components": "^5.1.34",
"eslint": "^8",
"eslint-config-next": "14.1.4",
"typescript": "^5"
}
}

View file

@ -0,0 +1,43 @@
"use client";
import { FC, ReactNode } from "react";
import styled from "styled-components";
const Section = styled.section<{ $color: string }>`
background-color: ${(props) => props.$color};
&:last-of-type {
flex-grow: 1;
}
`;
const CENTERED_CSS = `
margin-left: auto;
margin-right: auto;
max-width: 60%;
@media (max-width: 40rem) {
max-width: 90%;
}
`;
export const CenteredBlock = styled.div`
${CENTERED_CSS}
`;
const InnerBlock = styled.div<{ $centered?: boolean }>`
${(props) => props.$centered && CENTERED_CSS}
margin-top: 40px;
margin-bottom: 40px;
`;
export const ContentBlock: FC<{
color?: string;
uncentered?: boolean;
children: ReactNode;
}> = ({ color = "", children, uncentered = false }) => (
<Section $color={color}>
<InnerBlock $centered={!uncentered}>{children}</InnerBlock>
</Section>
);

View file

@ -0,0 +1,31 @@
"use client";
import { FC } from "react";
import { useClientValue } from "../useClientValue.hook";
import { styled } from "styled-components";
const BacktraceWrapper = styled.section`
display: flex;
gap: 10px;
justify-content: center;
`;
const getBacktrace = () => window.location.hash.slice(1);
export const BacktraceDisplay: FC = () => {
const backtrace = useClientValue(getBacktrace) ?? "";
return (
<BacktraceWrapper>
<label>Backtrace:</label>
<input type="text" value={backtrace} />
<button
onClick={() => {
navigator.clipboard.writeText(backtrace);
}}
>
Copy
</button>
</BacktraceWrapper>
);
};

View file

@ -0,0 +1,21 @@
import { Metadata } from "next";
import { BacktraceDisplay } from "./backtrace";
import { ContentBlock } from "../contentBlock";
export const metadata: Metadata = {
title: "agbrs crash backtrace",
};
export default function Crash() {
return (
<ContentBlock>
<h1>agbrs crash backtrace viewer</h1>
<p>This page will eventually let you view backtraces in the browser.</p>
<p>
For now you can copy the backtrace code here and use it with{" "}
<code>agb-addr2line</code>
</p>
<BacktraceDisplay />
</ContentBlock>
);
}

View file

Before

Width:  |  Height:  |  Size: 163 B

After

Width:  |  Height:  |  Size: 163 B

View file

Before

Width:  |  Height:  |  Size: 173 B

After

Width:  |  Height:  |  Size: 173 B

View file

Before

Width:  |  Height:  |  Size: 190 B

After

Width:  |  Height:  |  Size: 190 B

View file

Before

Width:  |  Height:  |  Size: 189 B

After

Width:  |  Height:  |  Size: 189 B

View file

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 241 B

View file

Before

Width:  |  Height:  |  Size: 156 B

After

Width:  |  Height:  |  Size: 156 B

View file

Before

Width:  |  Height:  |  Size: 181 B

After

Width:  |  Height:  |  Size: 181 B

View file

Before

Width:  |  Height:  |  Size: 206 B

After

Width:  |  Height:  |  Size: 206 B

View file

@ -0,0 +1,22 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
font: 1.2em/1.62 sans-serif;
background-color: white;
font-size: 1.5rem;
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
margin: 0;
}
h1,
h2,
h3 {
line-height: 1.2;
}

View file

@ -0,0 +1,21 @@
import type { Metadata } from "next";
import "./globalStyles.css";
import StyledComponentsRegistry from "./registry";
export const metadata: Metadata = {
title: "agb - a rust framework for making Game Boy Advance games",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<StyledComponentsRegistry>{children}</StyledComponentsRegistry>
</body>
</html>
);
}

View file

@ -145,5 +145,6 @@ export const Mgba = forwardRef<MgbaHandle, MgbaProps>(
}); });
return <MgbaCanvas ref={canvas} />; return <MgbaCanvas ref={canvas} />;
}, }
); );
Mgba.displayName = "Mgba";

View file

@ -0,0 +1,186 @@
import {
FC,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { Mgba, MgbaHandle } from "./mgba";
import {
BindingsControl,
DefaultBindingsSet,
Bindings,
GbaKey,
} from "./bindings";
import { styled } from "styled-components";
import { useOnKeyUp } from "./useOnKeyUp.hook";
import { useLocalStorage } from "./useLocalStorage.hook";
import { useAvoidItchIoScrolling } from "./useAvoidItchIoScrolling";
import { Slider } from "./Slider";
const BindingsDialog = styled.dialog`
border-radius: 5px;
margin-top: 20px;
overflow-y: auto;
max-height: calc(100vh - 100px);
`;
const VolumeLabel = styled.label`
display: flex;
gap: 10px;
`;
const ActionButton = styled.button`
width: 100%;
margin-top: 20px;
`;
const AppContainer = styled.main`
height: 100vh;
display: flex;
`;
const StartButtonWrapper = styled.button`
margin: auto;
font-size: 3em;
padding: 1em;
text-transform: uppercase;
background-color: black;
color: white;
border: none;
border-radius: 0.5em;
aspect-ratio: 240 / 160;
width: 100%;
height: 100%;
&:hover {
background-color: #222;
cursor: pointer;
}
`;
interface MgbaWrapperProps {
gameUrl: string;
startNotPlaying?: boolean;
}
export const MgbaStandalone: FC<MgbaWrapperProps> = (props) => (
<AppContainer>
<MgbaWrapper {...props} />
</AppContainer>
);
export interface MgbaWrapperHandle extends MgbaHandle {
hardReset: () => void;
}
export const MgbaWrapper = forwardRef<MgbaWrapperHandle, MgbaWrapperProps>(
({ gameUrl, startNotPlaying = false }, ref) => {
const [{ volume, bindings }, setState] = useLocalStorage(
{ volume: 1.0, bindings: DefaultBindingsSet() },
"agbrswebplayer"
);
const [mgbaId, setMgbaId] = useState(0);
const setVolume = (newVolume: number) =>
setState({ volume: newVolume, bindings });
const setBindings = (newBindings: Bindings) =>
setState({ volume, bindings: newBindings });
const [paused, setPaused] = useState(false);
const [showBindings, setShowBindings] = useState(false);
const mgbaRef = useRef<MgbaHandle>(null);
useOnKeyUp("Escape", () => {
setShowBindings(!showBindings);
});
useImperativeHandle(ref, () => ({
restart: () => mgbaRef.current?.restart(),
buttonPress: (key: GbaKey) => mgbaRef.current?.buttonPress(key),
buttonRelease: (key: GbaKey) => mgbaRef.current?.buttonRelease(key),
hardReset: () => setMgbaId((id) => id + 1),
}));
useAvoidItchIoScrolling();
const [isPlaying, setIsPlaying] = useState(!startNotPlaying);
return (
<>
{showBindings && (
<BindingsWindow
bindings={bindings}
setBindings={setBindings}
setPaused={setPaused}
volume={volume}
setVolume={setVolume}
hide={() => setShowBindings(false)}
restart={() => mgbaRef.current?.restart()}
/>
)}
{isPlaying ? (
<Mgba
key={mgbaId}
ref={mgbaRef}
gameUrl={gameUrl}
volume={volume}
controls={bindings.Actual}
paused={paused}
/>
) : (
<StartButton onClick={() => setIsPlaying(true)} />
)}
</>
);
}
);
MgbaWrapper.displayName = "MgbaWrapper";
function BindingsWindow({
bindings,
setBindings,
setPaused,
volume,
setVolume,
hide,
restart,
}: {
bindings: Bindings;
setBindings: (b: Bindings) => void;
setPaused: (paused: boolean) => void;
volume: number;
setVolume: (v: number) => void;
hide: () => void;
restart: () => void;
}) {
return (
<BindingsDialog open onClose={hide}>
<VolumeLabel>
Volume:
<Slider value={volume} onChange={(e) => setVolume(e)} />
</VolumeLabel>
<ActionButton onClick={() => setVolume(0)}>Mute</ActionButton>
<BindingsControl
bindings={bindings}
setBindings={setBindings}
setPaused={setPaused}
/>
<ActionButton onClick={restart}>Restart</ActionButton>
<ActionButton onClick={hide}>Close</ActionButton>
</BindingsDialog>
);
}
function StartButton({ onClick }: { onClick: () => void }) {
return (
<StartButtonWrapper onClick={onClick}>Press to start</StartButtonWrapper>
);
}
export default MgbaWrapper;

View file

@ -0,0 +1,225 @@
import { FC, useMemo, useState } from "react";
import styled from "styled-components";
import Image from "next/image";
import TriggerL from "./gba-parts/L.png";
import TriggerR from "./gba-parts/R.png";
import DPad from "./gba-parts/dpad.png";
import ABButtons from "./gba-parts/ab.png";
import Select from "./gba-parts/SELECT.png";
import Start from "./gba-parts/START.png";
import { MgbaWrapperHandle } from "./mgba/mgbaWrapper";
import { GbaKey } from "./mgba/bindings";
const MobileControls = styled.div`
display: flex;
gap: 10px;
justify-content: center;
align-items: center;
flex-direction: column;
margin-bottom: 40px;
touch-action: none;
img {
image-rendering: pixelated;
height: 100%;
width: unset;
}
`;
enum MobileControlsSize {
Big,
Small,
}
const MobileControlsRow = styled.div<{
$size: MobileControlsSize;
$centered?: boolean;
}>`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: calc(
32px * ${(props) => (props.$size === MobileControlsSize.Big ? 6 : 3)}
);
${(props) => props.$centered && `justify-content: center;`}
`;
const useSimpleButton = (mgba: MgbaWrapperHandle, button: GbaKey) => {
return useMemo(() => {
return {
onTouchStart: () => {
mgba?.buttonPress(button);
},
onTouchEnd: () => {
mgba?.buttonRelease(button);
},
};
}, [button, mgba]);
};
const relativeTouch = (touch: Touch) => {
const target = (touch.target as Element).getBoundingClientRect();
const touchPoint = { x: touch.clientX, y: touch.clientY };
const targetArea = {
x: target.left,
y: target.top,
width: target.width,
height: target.height,
};
const relativePosition = {
x: (touchPoint.x - targetArea.x) / targetArea.width,
y: (touchPoint.y - targetArea.y) / targetArea.height,
};
return relativePosition;
};
const useDpadTouch = (mgba: MgbaWrapperHandle) => {
const [previouslyPressedButtons, setTouchedButtons] = useState<Set<GbaKey>>(
new Set()
);
return useMemo(() => {
const updateDpad = (touches: TouchList) => {
const currentlyPressed = new Set<GbaKey>();
for (let touch of touches) {
const relative = relativeTouch(touch);
const touchedBox = {
x: relative.x * 3,
y: relative.y * 3,
};
if (touchedBox.y <= 1) {
currentlyPressed.add(GbaKey.Up);
}
if (touchedBox.y >= 2) {
currentlyPressed.add(GbaKey.Down);
}
if (touchedBox.x <= 1) {
currentlyPressed.add(GbaKey.Left);
}
if (touchedBox.x >= 2) {
currentlyPressed.add(GbaKey.Right);
}
}
for (let oldButton of previouslyPressedButtons) {
if (!currentlyPressed.has(oldButton)) {
mgba.buttonRelease(oldButton);
}
}
for (let newButton of currentlyPressed) {
if (!previouslyPressedButtons.has(newButton)) {
mgba.buttonPress(newButton);
}
}
setTouchedButtons(currentlyPressed);
};
return {
onTouchStart: (event: React.TouchEvent) =>
updateDpad(event.nativeEvent.targetTouches),
onTouchEnd: (event: React.TouchEvent) =>
updateDpad(event.nativeEvent.targetTouches),
onTouchMove: (event: React.TouchEvent) =>
updateDpad(event.nativeEvent.targetTouches),
};
}, [mgba, previouslyPressedButtons]);
};
const useAbTouch = (mgba: MgbaWrapperHandle) => {
const [previouslyPressedButtons, setTouchedButtons] = useState<Set<GbaKey>>(
new Set()
);
return useMemo(() => {
const updateAbButtons = (touches: TouchList) => {
const currentlyPressed = new Set<GbaKey>();
for (let touch of touches) {
const relative = relativeTouch(touch);
const aIsPressed = relative.x > relative.y;
currentlyPressed.add(aIsPressed ? GbaKey.A : GbaKey.B);
}
for (let oldButton of previouslyPressedButtons) {
if (!currentlyPressed.has(oldButton)) {
mgba.buttonRelease(oldButton);
}
}
for (let newButton of currentlyPressed) {
if (!previouslyPressedButtons.has(newButton)) {
mgba.buttonPress(newButton);
}
}
setTouchedButtons(currentlyPressed);
};
return {
onTouchStart: (event: React.TouchEvent) =>
updateAbButtons(event.nativeEvent.targetTouches),
onTouchEnd: (event: React.TouchEvent) =>
updateAbButtons(event.nativeEvent.targetTouches),
onTouchMove: (event: React.TouchEvent) =>
updateAbButtons(event.nativeEvent.targetTouches),
};
}, [mgba, previouslyPressedButtons]);
};
export const MobileController: FC<{ mgba: MgbaWrapperHandle }> = ({ mgba }) => {
return (
<MobileControls onContextMenu={(evt) => evt.preventDefault()}>
<MobileControlsRow $size={MobileControlsSize.Small}>
<Image
{...useSimpleButton(mgba, GbaKey.L)}
src={TriggerL}
alt="L trigger"
/>
<Image
{...useSimpleButton(mgba, GbaKey.R)}
src={TriggerR}
alt="R trigger"
/>
</MobileControlsRow>
<MobileControlsRow $size={MobileControlsSize.Big}>
<Image
{...useDpadTouch(mgba)}
src={DPad}
alt="Directional pad (Dpad)"
/>
<Image {...useAbTouch(mgba)} src={ABButtons} alt="A / B buttons" />
</MobileControlsRow>
<MobileControlsRow $size={MobileControlsSize.Small}>
<Image
{...useSimpleButton(mgba, GbaKey.Select)}
src={Select}
alt="Select button"
/>
<Image
{...useSimpleButton(mgba, GbaKey.Start)}
src={Start}
alt="Start button"
/>
</MobileControlsRow>
<MobileControlsRow $size={MobileControlsSize.Small} $centered>
<button onClick={() => mgba.hardReset()}>Restart</button>
</MobileControlsRow>
</MobileControls>
);
};

View file

@ -0,0 +1,130 @@
"use client";
import styled from "styled-components";
import { CenteredBlock, ContentBlock } from "./contentBlock";
import MgbaWrapper, { MgbaWrapperHandle } from "./mgba/mgbaWrapper";
import Image from "next/image";
import left from "./gba-parts/left.png";
import right from "./gba-parts/right.png";
import { MobileController } from "./mobileController";
import { useMemo, useRef } from "react";
import { GbaKey } from "./mgba/bindings";
import { useClientValue } from "./useClientValue.hook";
const ExternalLink = styled.a`
text-decoration: none;
color: black;
background-color: #fad288;
border: solid #fad288 2px;
border-radius: 5px;
padding: 5px 10px;
`;
const HelpLinks = styled.div`
display: flex;
justify-content: space-around;
`;
const GameDisplay = styled.div`
height: min(calc(100vw / 1.5), 40vh);
max-width: 100vw;
margin-top: 20px;
overflow: hidden;
`;
const GamePanelWrapper = styled.div`
display: flex;
justify-content: center;
align-items: baseline;
height: 100%;
`;
const GameDisplayWindow = styled.div`
border: 0;
height: 100%;
max-width: 100vw;
aspect-ratio: 240 / 160;
`;
const GameSide = styled.div`
aspect-ratio: 15 / 31;
height: 100%;
img {
height: 100%;
width: 100%;
image-rendering: pixelated;
}
`;
const isTouchScreen = () => navigator.maxTouchPoints > 1;
const MgbaWithControllerSides = () => {
const mgba = useRef<MgbaWrapperHandle>(null);
const mgbaHandle = useMemo(
() => ({
hardReset: () => mgba.current?.hardReset(),
restart: () => mgba.current?.restart(),
buttonPress: (key: GbaKey) => mgba.current?.buttonPress(key),
buttonRelease: (key: GbaKey) => mgba.current?.buttonRelease(key),
}),
[]
);
const shouldUseTouchScreenInput = useClientValue(isTouchScreen);
return (
<>
<GameDisplay>
<GamePanelWrapper>
<GameSide>
<Image src={left} alt="" />
</GameSide>
<GameDisplayWindow>
<MgbaWrapper gameUrl="combo.gba.gz" ref={mgba} />
</GameDisplayWindow>
<GameSide>
<Image src={right} alt="" />
</GameSide>
</GamePanelWrapper>
</GameDisplay>
{shouldUseTouchScreenInput ? (
<MobileController mgba={mgbaHandle} />
) : (
<CenteredBlock>
<p>
Press escape to open the menu where you can view or change controls
and restart the game. The game provided is a combination of multiple
Game Boy Advance games made using agb, you can press left or right
on the main menu to switch game.
</p>
</CenteredBlock>
)}
</>
);
};
export default function Home() {
return (
<>
<ContentBlock>
<h1>agb - a rust framework for making Game Boy Advance games</h1>
</ContentBlock>
<ContentBlock uncentered>
<MgbaWithControllerSides />
</ContentBlock>
<ContentBlock color="#f5755e">
<HelpLinks>
<ExternalLink href="https://github.com/agbrs/agb">
GitHub
</ExternalLink>
<ExternalLink href="book/">Book</ExternalLink>
<ExternalLink href="https://docs.rs/agb/latest/agb/">
Docs
</ExternalLink>
</HelpLinks>
</ContentBlock>
</>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import React, { useState } from "react";
import { useServerInsertedHTML } from "next/navigation";
import { ServerStyleSheet, StyleSheetManager } from "styled-components";
export default function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode;
}) {
// Only create stylesheet once with lazy initial state
// x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement();
styledComponentsStyleSheet.instance.clearTag();
return <>{styles}</>;
});
if (typeof window !== "undefined") return <>{children}</>;
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
);
}

View file

@ -0,0 +1,10 @@
import { useEffect, useState } from "react";
export const useClientValue = <T,>(fn: () => T) => {
const [value, setValue] = useState<T>();
useEffect(() => {
setValue(fn());
}, [fn]);
return value;
}

27
website/agb/tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"target": "ES2020",
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -1 +0,0 @@
{}

View file

@ -1,46 +0,0 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

File diff suppressed because it is too large Load diff

View file

@ -1,52 +0,0 @@
{
"name": "website",
"version": "0.1.0",
"homepage": "./",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.3",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"styled-components": "^6.0.2",
"typescript": "^5.1.6",
"web-vitals": "^3.3.2"
},
"overrides": {
"typescript": "^5.1.6"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/styled-components": "^5.1.26",
"prettier": "3.2.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -1,172 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { Mgba, MgbaHandle } from "./mgba";
import { BindingsControl, DefaultBindingsSet, Bindings } from "./bindings";
import { styled } from "styled-components";
import { useOnKeyUp } from "./useOnKeyUp.hook";
import { useLocalStorage } from "./useLocalStorage.hook";
import { useAvoidItchIoScrolling } from "./useAvoidItchIoScrolling";
import { Slider } from "./Slider";
const BindingsDialog = styled.dialog`
border-radius: 5px;
margin-top: 20px;
overflow-y: auto;
max-height: calc(100vh - 100px);
`;
const VolumeLabel = styled.label`
display: flex;
gap: 10px;
`;
const ActionButton = styled.button`
width: 100%;
margin-top: 20px;
`;
const AppContainer = styled.main`
height: 100vh;
display: flex;
`;
const StartButtonWrapper = styled.button`
margin: auto;
font-size: 5em;
padding: 1em;
text-transform: uppercase;
background-color: black;
color: white;
border: none;
border-radius: 0.5em;
&:hover {
background-color: #222;
cursor: pointer;
}
`;
function App() {
const [{ volume, bindings }, setState] = useLocalStorage(
{ volume: 1.0, bindings: DefaultBindingsSet() },
"agbrswebplayer",
);
const [mgbaId, setMgbaId] = useState(0);
const setVolume = (newVolume: number) =>
setState({ volume: newVolume, bindings });
const setBindings = (newBindings: Bindings) =>
setState({ volume, bindings: newBindings });
const [paused, setPaused] = useState(false);
const [showBindings, setShowBindings] = useState(false);
const mgbaRef = useRef<MgbaHandle>(null);
useOnKeyUp("Escape", () => {
setShowBindings(!showBindings);
});
useEffect(() => {
const buttonPress = (event: MessageEvent) => {
const data = event.data;
const { isPressed, button, reset } = data;
if (isPressed === true) {
mgbaRef.current?.buttonPress(button);
} else if (isPressed === false) {
mgbaRef.current?.buttonRelease(button);
}
if (reset) {
setMgbaId((id) => id + 1);
}
};
window.addEventListener("message", buttonPress);
return () => {
window.removeEventListener("message", buttonPress);
};
}, []);
useAvoidItchIoScrolling();
const gameUrl = window.location.hash.slice(1);
const [isPlaying, setIsPlaying] = useState(true);
return (
<AppContainer>
{showBindings && (
<BindingsWindow
bindings={bindings}
setBindings={setBindings}
setPaused={setPaused}
volume={volume}
setVolume={setVolume}
hide={() => setShowBindings(false)}
restart={() => mgbaRef.current?.restart()}
/>
)}
{isPlaying ? (
<Mgba
key={mgbaId}
ref={mgbaRef}
gameUrl={gameUrl}
volume={volume}
controls={bindings.Actual}
paused={paused}
/>
) : (
<StartButton onClick={() => setIsPlaying(true)} />
)}
</AppContainer>
);
}
function BindingsWindow({
bindings,
setBindings,
setPaused,
volume,
setVolume,
hide,
restart,
}: {
bindings: Bindings;
setBindings: (b: Bindings) => void;
setPaused: (paused: boolean) => void;
volume: number;
setVolume: (v: number) => void;
hide: () => void;
restart: () => void;
}) {
return (
<BindingsDialog open onClose={hide}>
<VolumeLabel>
Volume:
<Slider value={volume} onChange={(e) => setVolume(e)} />
</VolumeLabel>
<ActionButton onClick={() => setVolume(0)}>Mute</ActionButton>
<BindingsControl
bindings={bindings}
setBindings={setBindings}
setPaused={setPaused}
/>
<ActionButton onClick={restart}>Restart</ActionButton>
<ActionButton onClick={hide}>Close</ActionButton>
</BindingsDialog>
);
}
function StartButton({ onClick }: { onClick: () => void }) {
return (
<StartButtonWrapper onClick={onClick}>Press to start</StartButtonWrapper>
);
}
export default App;

View file

@ -1,15 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { GlobalStyle } from "./globalStyles";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<GlobalStyle />
<App />
</React.StrictMode>
);

View file

@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

View file

@ -1,55 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>agbrs crash backtrace</title>
<style>
body {
margin: 1em auto;
max-width: 40em;
padding: 0 0.62em 3.24em;
font: 1.2em/1.62 sans-serif;
}
h1,
h2,
h3 {
line-height: 1.2;
}
.backtrace {
display: flex;
gap: 10px;
justify-content: center;
}
</style>
</head>
<body>
<h1>agbrs crash backtrace viewer</h1>
<p>This page will eventually let you view backtraces in the browser.</p>
<p>
For now you can copy the backtrace code here and use it with
<code>agb-addr2line</code>
</p>
<p class="backtrace">
<label for="backtrace">Backtrace:</label>
<input id="backtrace" type="text" />
<button id="backtraceCopy">Copy</button>
</p>
<script>
const updateBacktrace = () => {
if (window.location.hash.length > 1) {
backtrace.value = window.location.hash.slice(1);
}
};
updateBacktrace();
window.addEventListener("hashchange", updateBacktrace);
backtraceCopy.addEventListener("click", (evt) => {
evt.preventDefault();
navigator.clipboard.writeText(backtrace.value);
});
</script>
</body>
</html>

View file

@ -1,353 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>agb - a rust framework for making Game Boy Advance games</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
font: 1.2em/1.62 sans-serif;
background-color: white;
font-size: 1.5rem;
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
margin: 0;
}
h1,
h2,
h3 {
line-height: 1.2;
}
.gameDisplay > div {
display: flex;
justify-content: center;
height: 100%;
}
.gameDisplay {
height: clamp(480px, 40vh, calc(100vw / 3));
max-width: 100vw;
margin-top: 20px;
overflow: hidden;
}
.gameDisplay .imageWrapper {
aspect-ratio: 15 / 31;
height: 100%;
}
.gameDisplay .imageWrapper > img {
height: 100%;
image-rendering: pixelated;
}
.gameDisplay iframe {
border: 0;
height: 100%;
max-width: 100vw;
aspect-ratio: 240 / 160;
}
header,
.desktopHelp {
max-width: 60%;
margin-left: auto;
margin-right: auto;
margin-block-end: 20px;
}
.red {
background-color: #f5755e;
flex-grow: 1;
}
.links {
margin-left: auto;
margin-right: auto;
display: flex;
justify-content: space-around;
margin-top: 40px;
margin-bottom: 40px;
max-width: 40rem;
}
.links > a {
text-decoration: none;
color: black;
background-color: #fad288;
border: solid #fad288 2px;
border-radius: 5px;
padding: 5px 10px;
}
.links > a:hover {
border: solid black 2px;
}
.mobileControls {
display: flex;
gap: 10px;
justify-content: center;
align-items: center;
flex-direction: column;
margin-bottom: 40px;
}
.mobileControls img {
image-rendering: pixelated;
height: 100%;
}
.mobileControlsRow {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.mobileControlsReset {
justify-content: center;
}
@media (max-width: 800px) {
.desktopHelp {
display: none;
}
header,
.desktopHelp {
max-width: 90%;
}
}
@media (min-width: 800px) {
.mobileControls {
display: none;
}
}
.mobileControlsBig {
height: calc(32px * 6);
}
.mobileControlsSmall {
height: calc(32px * 3);
}
</style>
</head>
<body>
<header>
<h1>agb - a rust framework for making Game Boy Advance games</h1>
</header>
<section>
<div class="gameDisplay">
<div>
<div class="imageWrapper"><img src="assets/left.png" /></div>
<iframe
id="gameFrame"
onload="this.contentWindow.focus()"
src="mgba/index.html#/assets/combo.gba.gz"
></iframe>
<div class="imageWrapper"><img src="assets/right.png" /></div>
</div>
</div>
<div id="mobileControls" class="mobileControls">
<div class="mobileControlsRow mobileControlsSmall">
<img id="mobileL" src="assets/L.png" />
<img id="mobileR" src="assets/R.png" />
</div>
<div class="mobileControlsRow mobileControlsBig">
<img id="mobileDpad" src="assets/dpad.png" />
<img id="mobileAb" src="assets/ab.png" />
</div>
<div class="mobileControlsRow mobileControlsSmall">
<img id="mobileSelect" src="assets/SELECT.png" />
<img id="mobileStart" src="assets/START.png" />
</div>
<div class="mobileControlsRow mobileControlsReset">
<button id="mobileRestart">Restart</button>
</div>
</div>
<div class="desktopHelp">
<p>
Press escape to open the menu where you can view or change controls
and restart the game. The game provided is a combination of multiple
Game Boy Advance games made using agb, you can press left or right on
the main menu to switch game.
</p>
</div>
</section>
<section class="red">
<div class="links">
<a href="https://github.com/agbrs/agb">GitHub</a>
<a href="book/">Book</a>
<a href="https://docs.rs/agb/latest/agb/">Docs</a>
</div>
</section>
<script>
const addSimpleButton = (ele, key) => {
ele.addEventListener("touchstart", (evt) => pressButton(key));
ele.addEventListener("touchend", (evt) => releaseButton(key));
};
mobileRestart.addEventListener("click", () => {
gameFrame.contentWindow.postMessage({ reset: true });
});
const pressButton = (key) => {
gameFrame.contentWindow.postMessage({ isPressed: true, button: key });
};
const releaseButton = (key) => {
gameFrame.contentWindow.postMessage({ isPressed: false, button: key });
};
mobileControls.addEventListener("touchmove", (evt) =>
evt.preventDefault()
);
mobileControls.addEventListener("contextmenu", (evt) =>
evt.preventDefault()
);
addSimpleButton(mobileL, "L");
addSimpleButton(mobileR, "L");
addSimpleButton(mobileStart, "Start");
addSimpleButton(mobileSelect, "Select");
let previouslyPressedButtons = new Set();
const dpadMovement = (touches) => {
const currentlyPressed = new Set();
for (let touch of touches) {
const target = touch.target.getBoundingClientRect();
const touchPoint = { x: touch.clientX, y: touch.clientY };
const targetArea = {
x: target.left,
y: target.top,
width: target.width,
height: target.height,
};
const relativePosition = {
x: touchPoint.x - targetArea.x,
y: touchPoint.y - targetArea.y,
};
const touchedBox = {
x: Math.floor(relativePosition.x / (targetArea.width / 3)),
y: Math.floor(relativePosition.y / (targetArea.height / 3)),
};
const buttonBoxMapping = [
[["Up", "Left"], ["Up"], ["Up", "Right"]],
[["Left"], [], ["Right"]],
[["Down", "Left"], ["Down"], ["Down", "Right"]],
];
const buttonsToPress =
(buttonBoxMapping[touchedBox.y] ?? [])[touchedBox.x] ?? [];
for (let button of buttonsToPress) {
currentlyPressed.add(button);
}
}
for (let oldButton of previouslyPressedButtons) {
if (!currentlyPressed.has(oldButton)) {
releaseButton(oldButton);
}
}
for (let newButton of currentlyPressed) {
if (!previouslyPressedButtons.has(newButton)) {
pressButton(newButton);
}
}
previouslyPressedButtons = currentlyPressed;
};
mobileDpad.addEventListener("touchstart", (evt) =>
dpadMovement(evt.targetTouches)
);
mobileDpad.addEventListener("touchmove", (evt) =>
dpadMovement(evt.targetTouches)
);
mobileDpad.addEventListener("touchend", (evt) => {
dpadMovement(evt.targetTouches);
});
mobileDpad.addEventListener("touchcancel", (evt) => {
dpadMovement(evt.targetTouches);
});
let mobileAbAPress = new Set();
const mobileAbMovement = (touches) => {
const currentTouches = new Set();
for (let touch of touches) {
const target = touch.target.getBoundingClientRect();
const touchPoint = { x: touch.clientX, y: touch.clientY };
const targetArea = {
x: target.left,
y: target.top,
width: target.width,
height: target.height,
};
const relativePosition = {
x: touchPoint.x - targetArea.x,
y: touchPoint.y - targetArea.y,
};
const aPress = relativePosition.x > relativePosition.y;
currentTouches.add(aPress ? "A" : "B");
}
for (let oldTouch of mobileAbAPress) {
if (!currentTouches.has(oldTouch)) {
releaseButton(oldTouch);
}
}
for (let newTouch of currentTouches) {
if (!mobileAbAPress.has(newTouch)) {
pressButton(newTouch);
}
}
mobileAbAPress = currentTouches;
};
mobileAb.addEventListener("touchstart", (evt) => {
mobileAbMovement(evt.targetTouches);
});
mobileAb.addEventListener("touchmove", (evt) => {
mobileAbMovement(evt.targetTouches);
});
mobileAb.addEventListener("touchend", (evt) => {
mobileAbMovement(evt.targetTouches);
});
mobileAb.addEventListener("touchcancel", (evt) => {
mobileAbMovement(evt.targetTouches);
});
</script>
</body>
</html>