Adding React frontend.
This commit is contained in:
parent
c3c9e0769c
commit
c90143a501
5
babel.config.json
Normal file
5
babel.config.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-react"
|
||||
]
|
||||
}
|
7
js-src/components/canvas-gba-emulator.jsx
Normal file
7
js-src/components/canvas-gba-emulator.jsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import {MIN_WIDTH, MIN_HEIGHT} from '/constants';
|
||||
|
||||
export default function CanvasGBAEmulator(props) {
|
||||
return (<canvas ref={props.canvasRef} width={MIN_WIDTH} height={MIN_HEIGHT}></canvas>);
|
||||
}
|
13
js-src/components/center-element.jsx
Normal file
13
js-src/components/center-element.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function CenterElement(props) {
|
||||
let hidden = props.hidden;
|
||||
if (hidden == null) {
|
||||
hidden = false;
|
||||
}
|
||||
return (
|
||||
<div style={{
|
||||
display: hidden ? 'none':''
|
||||
}} className="center-content">{props.children}</div>
|
||||
);
|
||||
}
|
21
js-src/components/form-select-files.jsx
Normal file
21
js-src/components/form-select-files.jsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function FormSelectFiles(props) {
|
||||
const inputRom = props.refInputRom ? props.refInputRom : React.useRef(null);
|
||||
const inputSaveState = props.refInputSaveState ? props.refInputSaveState : React.useRef(null);
|
||||
const onStartEmulation = props.onStartEmulation;
|
||||
|
||||
return (
|
||||
<form>
|
||||
<label htmlFor="rom">
|
||||
Rom file
|
||||
<input type="file" ref={inputRom} name="rom"/>
|
||||
</label>
|
||||
<label htmlFor="savestate">
|
||||
Savestate (A ss file from mgba...)
|
||||
<input type="file" ref={inputSaveState} name="savestate"/>
|
||||
</label>
|
||||
<input type="button" value="Start emulation" onClick={onStartEmulation} ref={props.startEmulationButton}/>
|
||||
</form>
|
||||
);
|
||||
}
|
245
js-src/components/page.jsx
Normal file
245
js-src/components/page.jsx
Normal file
@ -0,0 +1,245 @@
|
||||
import React from 'react';
|
||||
|
||||
import CenterElement from '/components/center-element';
|
||||
import FormSelectFiles from '/components/form-select-files';
|
||||
import CanvasGBAEmulator from '/components/canvas-gba-emulator';
|
||||
|
||||
import Endian from '/endian';
|
||||
|
||||
import {MIN_WIDTH, MIN_HEIGHT, PACKET_ID_HELLO, PACKET_ID_SEND_FRAME} from '/constants';
|
||||
|
||||
function handleClickStartEmulationButton({e, inputRom, inputSaveState, setHiddenFormSelectFiles, canvas}) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
e.preventDefault();
|
||||
if (inputRom.files.length == 0) {
|
||||
alert('There is no rom still');
|
||||
return;
|
||||
}
|
||||
if (inputSaveState.files.length == 0) {
|
||||
alert('There is no savestate still');
|
||||
return;
|
||||
}
|
||||
const rom_file = inputRom.files[0];
|
||||
const savestate_file = inputSaveState.files[0];
|
||||
rom_file.arrayBuffer().then((rom_buffer) => {
|
||||
savestate_file.arrayBuffer().then((savestate_buffer) => {
|
||||
const rom_array = new Uint8Array(rom_buffer);
|
||||
const savestate_array = new Uint8Array(savestate_buffer);
|
||||
const websocket = new WebSocket(`ws://localhost:3000/ws`);
|
||||
websocket.binaryType = 'arraybuffer';
|
||||
websocket.onclose = (message) => console.log('CLOSE', message);
|
||||
websocket.onopen = () => {
|
||||
setHiddenFormSelectFiles(c => false);
|
||||
console.log('Opened websocket.');
|
||||
sendHello(websocket, rom_array, savestate_array);
|
||||
};
|
||||
websocket.addEventListener('message', (event) => onWebSocketPacket(event, canvas, ctx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function concatU8Array(array1, array2) {
|
||||
const final_array = new Uint8Array(array1.length + array2.length);
|
||||
final_array.set(array1);
|
||||
final_array.set(array2, array1.length);
|
||||
return final_array;
|
||||
}
|
||||
|
||||
function u64ToByteArrayBigEndian(input_number) {
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const buffer8 = new Uint8Array(buffer);
|
||||
const buffer64 = new BigUint64Array(buffer);
|
||||
buffer64[0] = input_number;
|
||||
if (Endian.isLittleEndian()) {
|
||||
buffer8.reverse();
|
||||
}
|
||||
return buffer8;
|
||||
}
|
||||
|
||||
function sendPacket(websocket, id, raw_data) {
|
||||
const packet_u8 = concatU8Array(
|
||||
concatU8Array(u64ToByteArrayBigEndian(id), u64ToByteArrayBigEndian(BigInt(raw_data.length))),
|
||||
raw_data
|
||||
);
|
||||
const packet_buffer = packet_u8.buffer;
|
||||
console.log('Sending packet');
|
||||
websocket.send(packet_buffer);
|
||||
}
|
||||
|
||||
function sendHello(websocket, rom_array, savestate_array) {
|
||||
console.log('Sending hello.');
|
||||
const length_rom = BigInt(rom_array.length);
|
||||
const length_savestate = BigInt(savestate_array.length);
|
||||
const raw_data =
|
||||
concatU8Array(
|
||||
concatU8Array(
|
||||
concatU8Array(u64ToByteArrayBigEndian(length_rom), rom_array),
|
||||
u64ToByteArrayBigEndian(length_savestate)
|
||||
),
|
||||
savestate_array
|
||||
);
|
||||
sendPacket(websocket, PACKET_ID_HELLO, raw_data);
|
||||
}
|
||||
|
||||
function onWebSocketPacket(event, canvas, ctx) {
|
||||
const buffer = event.data;
|
||||
let packet_u8 = new Uint8Array(buffer);
|
||||
const id = byteArrayToU64BigEndian(packet_u8.slice(0, 8));
|
||||
packet_u8 = packet_u8.slice(8, packet_u8.length);
|
||||
const size = byteArrayToU64BigEndian(packet_u8.slice(0, 8));
|
||||
const raw_data = packet_u8.slice(8, packet_u8.length);
|
||||
packet_u8 = null;
|
||||
switch (id) {
|
||||
case PACKET_ID_SEND_FRAME:
|
||||
handleSendFrame(raw_data, canvas, ctx);
|
||||
break;
|
||||
default:
|
||||
console.log(`Received unknown packet ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
let printing_frame = false;
|
||||
function handleSendFrame(raw_data, canvas, ctx) {
|
||||
if (printing_frame) {
|
||||
return;
|
||||
}
|
||||
printing_frame = true;
|
||||
let data = raw_data;
|
||||
const stride = byteArrayToU32BigEndian(data.slice(0, 4));
|
||||
data = data.slice(4, data.length);
|
||||
const output_buffer_size = byteArrayToU32BigEndian(data.slice(0, 8));
|
||||
data = data.slice(8, data.length);
|
||||
console.log(data.length / 4 / MIN_WIDTH);
|
||||
const img_data = ctx.createImageData(MIN_WIDTH, MIN_HEIGHT);
|
||||
const img_data_u8 = new Uint8Array(img_data.data.buffer);
|
||||
for (let i = 0; i<data.length; i++) {
|
||||
if (i % 4 == 3) {
|
||||
img_data_u8[i] = 255;
|
||||
continue;
|
||||
}
|
||||
img_data_u8[i] = data[i];
|
||||
}
|
||||
data = null;
|
||||
createImageBitmap(img_data).then((bitmap) => drawBitmap(bitmap, canvas, ctx));
|
||||
}
|
||||
|
||||
function byteArrayToU32BigEndian(input_array) {
|
||||
if (Endian.isLittleEndian()) {
|
||||
input_array = input_array.reverse();
|
||||
}
|
||||
const buffer = input_array.buffer;
|
||||
const output_u32_array = new Uint32Array(buffer);
|
||||
return output_u32_array[0];
|
||||
}
|
||||
|
||||
function byteArrayToU64BigEndian(input_array) {
|
||||
if (Endian.isLittleEndian()) {
|
||||
input_array = input_array.reverse();
|
||||
}
|
||||
const buffer = input_array.buffer;
|
||||
const output_u64_array = new BigUint64Array(buffer);
|
||||
return output_u64_array[0];
|
||||
}
|
||||
|
||||
function drawBitmap(bitmap, canvas, ctx) {
|
||||
ctx.drawImage(bitmap, 0, 0, canvas1.width, canvas1.height);
|
||||
printing_frame = false;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const screenDimensions = useScreenDimensions();
|
||||
const emulatorDimensions = calculateSizeEmulator(screenDimensions);
|
||||
const canvasRef = React.useRef(null);
|
||||
function resizeCanvas(node) {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.width = emulatorDimensions.width;
|
||||
canvas.height = emulatorDimensions.height;
|
||||
const ctx = canvas.getContext('2d')
|
||||
fillBlack(canvas, ctx);
|
||||
}
|
||||
};
|
||||
const [hiddenFormSelectFiles, setHiddenFormSelectFiles] = React.useState(false);
|
||||
React.useEffect(resizeCanvas, [emulatorDimensions]);
|
||||
const refInputRom = React.useRef(null);
|
||||
const refInputSaveState = React.useRef(null);
|
||||
const onStartEmulation = (e) => {
|
||||
handleClickStartEmulationButton({
|
||||
e: e,
|
||||
setHiddenFormSelectFiles: setHiddenFormSelectFiles,
|
||||
inputRom: refInputRom.current,
|
||||
inputSaveState: refInputSaveState.current,
|
||||
canvas: canvasRef.current,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<CenterElement>
|
||||
<h2>msGBA Emulator Online for GBA.</h2>
|
||||
</CenterElement>
|
||||
<CenterElement>
|
||||
<CanvasGBAEmulator canvasRef={canvasRef}/>
|
||||
</CenterElement>
|
||||
<CenterElement hidden={hiddenFormSelectFiles}>
|
||||
<FormSelectFiles refInputRom={refInputRom}
|
||||
refInputSaveState={refInputSaveState}
|
||||
onStartEmulation={onStartEmulation}/>
|
||||
</CenterElement>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getScreenDimensions() {
|
||||
return {
|
||||
width: document.body.clientWidth,
|
||||
height: document.body.clientHeight
|
||||
};
|
||||
}
|
||||
|
||||
function useScreenDimensions() {
|
||||
const [screenDimensions, setScreenDimensions] = React.useState(getScreenDimensions());
|
||||
|
||||
React.useEffect(() => {
|
||||
function onResize() {
|
||||
setScreenDimensions(getScreenDimensions());
|
||||
}
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return screenDimensions;
|
||||
}
|
||||
|
||||
function fillBlack(canvas, ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function calculateSizeEmulator(screenDimensions) {
|
||||
const width = screenDimensions.width;
|
||||
const height = screenDimensions.height * 0.75;
|
||||
const emulatorDimensions = {};
|
||||
if (width < MIN_WIDTH || height < MIN_HEIGHT) {
|
||||
return {
|
||||
width: MIN_WIDTH,
|
||||
height: MIN_HEIGHT,
|
||||
};
|
||||
}
|
||||
const ratioWidth = Math.floor(width / MIN_WIDTH);
|
||||
const ratioHeight = Math.floor(height / MIN_HEIGHT);
|
||||
if (ratioWidth < ratioHeight) {
|
||||
emulatorDimensions.width = MIN_WIDTH * ratioWidth;
|
||||
emulatorDimensions.height = MIN_HEIGHT * ratioWidth;
|
||||
} else {
|
||||
emulatorDimensions.height = MIN_HEIGHT * ratioHeight;
|
||||
emulatorDimensions.width = MIN_WIDTH * ratioHeight;
|
||||
}
|
||||
return emulatorDimensions;
|
||||
}
|
10
js-src/constants.js
Normal file
10
js-src/constants.js
Normal file
@ -0,0 +1,10 @@
|
||||
export const MIN_WIDTH = 240;
|
||||
export const MIN_HEIGHT = 160;
|
||||
export const PACKET_ID_HELLO = 0n;
|
||||
export const PACKET_ID_SEND_FRAME = 1n;
|
||||
export default class Constants {
|
||||
};
|
||||
Constants.MIN_WIDTH = MIN_WIDTH;
|
||||
Constants.MIN_HEIGHT = MIN_HEIGHT;
|
||||
Constants.PACKET_ID_HELLO = PACKET_ID_HELLO;
|
||||
Constants.PACKET_ID_SEND_FRAME = PACKET_ID_SEND_FRAME;
|
18
js-src/endian.js
Normal file
18
js-src/endian.js
Normal file
@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
|
||||
let littleEndian = true;
|
||||
(()=>{
|
||||
let buf = new ArrayBuffer(4);
|
||||
let buf8 = new Uint8ClampedArray(buf);
|
||||
let data = new Uint32Array(buf);
|
||||
data[0] = 0xdeadbeef;
|
||||
if(buf8[0] === 0xde){
|
||||
littleEndian = false;
|
||||
}
|
||||
})()
|
||||
|
||||
export default class Endian {
|
||||
static isLittleEndian() {
|
||||
return littleEndian;
|
||||
}
|
||||
}
|
12
js-src/index.jsx
Normal file
12
js-src/index.jsx
Normal file
@ -0,0 +1,12 @@
|
||||
"use strict";
|
||||
import React from 'react';
|
||||
import ReactDOMClient from 'react-dom/client';
|
||||
|
||||
import Endian from '/endian';
|
||||
import Page from '/components/page';
|
||||
|
||||
const body = document.querySelector('body');
|
||||
const app = document.createElement('div');
|
||||
body.appendChild(app);
|
||||
const root = ReactDOMClient.createRoot(app);
|
||||
root.render(<Page/>);
|
@ -14,8 +14,12 @@ sub startup ($self) {
|
||||
my $r = $self->routes;
|
||||
|
||||
# Normal route to controller
|
||||
$r->get('/')->to('Static#index');
|
||||
#$r->get('/')->to('Static#index');
|
||||
$r->websocket('/ws')->to('WS#proxy');
|
||||
$r->get('/', => sub {
|
||||
my $c = shift;
|
||||
$c->reply->static('index.html');
|
||||
});
|
||||
}
|
||||
|
||||
1;
|
||||
|
@ -58,7 +58,7 @@ sub _build_msgba_connection {
|
||||
if (length $tx_buffer->{$ws->tx} < 16 + $size_num) {
|
||||
return;
|
||||
}
|
||||
read $fh, my $raw_data, $size_num;
|
||||
read $fh, my ($raw_data), $size_num;
|
||||
$tx_buffer->{$ws->tx} = substr $tx_buffer->{$ws->tx}, 16 + $size_num, length $tx_buffer->{$ws->tx};
|
||||
handle_packet($ws, {
|
||||
id => $id,
|
||||
|
25
package.json
Normal file
25
package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "MSGBA-Web",
|
||||
"version": "0.1.1",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"babel-loader": "^9.1.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.38.1",
|
||||
"webpack-cli": "^4.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
}
|
17
public/css/styles.css
Normal file
17
public/css/styles.css
Normal file
@ -0,0 +1,17 @@
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body > div, body > div > div {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body
|
||||
.center-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
form label, form input {
|
||||
display: block;
|
||||
}
|
||||
|
@ -1,11 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to the Mojolicious real-time web framework!</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Welcome to the Mojolicious real-time web framework!</h2>
|
||||
This is the static document "public/index.html",
|
||||
<a href="/">click here</a> to get back to the start.
|
||||
</body>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/styles.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="/js/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
240
public/js/bundle.js
Normal file
240
public/js/bundle.js
Normal file
File diff suppressed because one or more lines are too long
@ -204,7 +204,7 @@
|
||||
|
||||
function resizeEmulator(canvas) {
|
||||
const width = document.body.clientWidth;
|
||||
const height = document.body.clientHeight;
|
||||
const height = document.body.clientHeight * 0.75;
|
||||
if (width < MIN_WIDTH || height < MIN_HEIGHT) {
|
||||
alert('Size too small.');
|
||||
return;
|
||||
|
33
webpack.config.js
Normal file
33
webpack.config.js
Normal file
@ -0,0 +1,33 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: './js-src/index.jsx',
|
||||
mode: 'development',
|
||||
output: {
|
||||
filename: 'bundle.js',
|
||||
path: path.resolve(__dirname, 'public/js/'),
|
||||
},
|
||||
resolve: {
|
||||
extensions: [
|
||||
'.js',
|
||||
'.jsx',
|
||||
],
|
||||
roots: [
|
||||
path.resolve(__dirname, 'js-src/')
|
||||
]
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jpe?g|png$/,
|
||||
exclude: /node_modules/,
|
||||
use: ["url-loader", "file-loader"]
|
||||
},
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
exclude: /node_modules/,
|
||||
loader: "babel-loader"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue
Block a user