make website with nextjs (#619)
3
.github/workflows/build-site.yml
vendored
|
@ -22,7 +22,8 @@ jobs:
|
|||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
website/app/node_modules
|
||||
website/agb/node_modules
|
||||
website/agb/.next/cache
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/target
|
||||
|
|
3
.gitignore
vendored
|
@ -2,4 +2,5 @@ target
|
|||
/out
|
||||
template/Cargo.lock
|
||||
agb*/Cargo.lock
|
||||
*.tiled-session
|
||||
*.tiled-session
|
||||
website/build
|
21
justfile
|
@ -93,24 +93,23 @@ miri:
|
|||
(cd agb-hashmap && cargo miri test)
|
||||
|
||||
build-mgba-wasm:
|
||||
rm -rf website/app/src/vendor
|
||||
mkdir website/app/src/vendor
|
||||
podman build --file website/mgba-wasm/BuildMgbaWasm --output=website/app/src/vendor .
|
||||
rm -rf website/agb/src/app/mgba/vendor
|
||||
mkdir website/agb/src/app/mgba/vendor
|
||||
podman build --file website/mgba-wasm/BuildMgbaWasm --output=website/agb/src/app/mgba/vendor .
|
||||
|
||||
build-combo-rom-site:
|
||||
just _build-rom "examples/combo" "AGBGAMES"
|
||||
|
||||
build-site-mgba-wrapper: build-mgba-wasm
|
||||
(cd website/app && npm install --no-save --prefer-offline --no-audit)
|
||||
(cd website/app && npm run build)
|
||||
build-site-app: build-mgba-wasm build-combo-rom-site
|
||||
mkdir -p website/agb/public
|
||||
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
|
||||
cp website/site website/build -r
|
||||
cp website/agb/out website/build -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:
|
||||
(cd tools && cargo build)
|
||||
|
|
1
website/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
build
|
3
website/agb/.eslintrc.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
25
website/app/.gitignore → website/agb/.gitignore
vendored
|
@ -4,23 +4,38 @@
|
|||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.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
|
@ -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.
|
10
website/agb/next.config.mjs
Normal 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
27
website/agb/package.json
Normal 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"
|
||||
}
|
||||
}
|
43
website/agb/src/app/contentBlock.tsx
Normal 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>
|
||||
);
|
31
website/agb/src/app/crash/backtrace.tsx
Normal 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>
|
||||
);
|
||||
};
|
21
website/agb/src/app/crash/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
Before Width: | Height: | Size: 163 B After Width: | Height: | Size: 163 B |
Before Width: | Height: | Size: 173 B After Width: | Height: | Size: 173 B |
Before Width: | Height: | Size: 190 B After Width: | Height: | Size: 190 B |
Before Width: | Height: | Size: 189 B After Width: | Height: | Size: 189 B |
Before Width: | Height: | Size: 241 B After Width: | Height: | Size: 241 B |
Before Width: | Height: | Size: 156 B After Width: | Height: | Size: 156 B |
Before Width: | Height: | Size: 181 B After Width: | Height: | Size: 181 B |
Before Width: | Height: | Size: 206 B After Width: | Height: | Size: 206 B |
22
website/agb/src/app/globalStyles.css
Normal 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;
|
||||
}
|
21
website/agb/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -145,5 +145,6 @@ export const Mgba = forwardRef<MgbaHandle, MgbaProps>(
|
|||
});
|
||||
|
||||
return <MgbaCanvas ref={canvas} />;
|
||||
},
|
||||
}
|
||||
);
|
||||
Mgba.displayName = "Mgba";
|
186
website/agb/src/app/mgba/mgbaWrapper.tsx
Normal 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;
|
225
website/agb/src/app/mobileController.tsx
Normal 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>
|
||||
);
|
||||
};
|
130
website/agb/src/app/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
29
website/agb/src/app/registry.tsx
Normal 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>
|
||||
);
|
||||
}
|
10
website/agb/src/app/useClientValue.hook.ts
Normal 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
|
@ -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"]
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
{}
|
|
@ -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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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/).
|
33325
website/app/package-lock.json
generated
|
@ -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"
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 3.8 KiB |
|
@ -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>
|
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 9.4 KiB |
|
@ -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"
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
|
@ -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"]
|
||||
}
|
|
@ -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>
|
|
@ -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>
|