Saving, technically done

This commit is contained in:
2025-03-17 21:10:33 -05:00
parent 3b6850f20a
commit aa42df4a0e
5 changed files with 143 additions and 21 deletions

View File

@ -63,7 +63,6 @@ class HomeplayEmulator {
try { try {
this.saveUpdates = await this.hasSaveUpdates(); this.saveUpdates = await this.hasSaveUpdates();
if(this.saveUpdates) { if(this.saveUpdates) {
console.log('Saving');
await this.save(); await this.save();
} }
} catch(e) { } catch(e) {
@ -111,7 +110,9 @@ class HomeplayEmulator {
setTimeout(() => { setTimeout(() => {
window.EJS_emulator.gameManager.saveSaveFiles(); window.EJS_emulator.gameManager.saveSaveFiles();
this.gameStarted = true; setTimeout(() => {
this.gameStarted = true;
}, 1000);
}, 1000) }, 1000)
} }
@ -145,12 +146,8 @@ class HomeplayEmulator {
window.EJS_volume = data.volume; window.EJS_volume = data.volume;
break; break;
case 'save':
window.EJS_emulator.saveSaveFiles();
break;
default: default:
console.error('Unknown message', message); console.error('Website sent invalid message', data);
} }
} }
@ -184,19 +181,21 @@ class HomeplayEmulator {
} }
getSaveFiles = () => { getSaveFiles = () => {
return [ 'rom.srm' ]; return [ 'rom.srm', 'rom.rtc' ];
} }
hasSaveUpdates = async () => { hasSaveUpdates = async () => {
if(!window.EJS_emulator) return false; if(!window.EJS_emulator) return false;
if(!window.EJS_emulator.gameManager) return false; if(!window.EJS_emulator.gameManager) return false;
if(!this.gameStarted) return false; if(!this.gameStarted) return false;
// At least 20 seconds must've passed.
if(window.EJS_emulator.gameManager.getFrameNum() < 20 * 60) return false;
const saveFiles = this.getSaveFiles(); const saveFiles = this.getSaveFiles();
const hashes = await Promise.all(saveFiles.map(f => this.getFileMD5(`/homeplay/saves/${f}`))); const hashes = await Promise.all(saveFiles.map(f => this.getFileMD5(`/homeplay/saves/${f}`)));
window.EJS_emulator.gameManager.saveSaveFiles(); window.EJS_emulator.gameManager.saveSaveFiles();
const newHashes = await Promise.all(saveFiles.map(f => this.getFileMD5(`/homeplay/saves/${f}`))); const newHashes = await Promise.all(saveFiles.map(f => this.getFileMD5(`/homeplay/saves/${f}`)));
const changedFiles = saveFiles.filter((f, i) => hashes[i] !== newHashes[i]); const changedFiles = saveFiles.filter((f, i) => hashes[i] !== newHashes[i]);

View File

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import styles from './Emulator.module.scss'; import styles from './Emulator.module.scss';
import { GAME_SYSTEM_CORES, GameSystem, GameSystemCore } from '@/lib/game'; import { GAME_SYSTEM_CORES, GameSystem, GameSystemCore } from '@/lib/game';
import { useLanguage } from '@/providers/LanguageProvider'; import { useLanguage } from '@/providers/LanguageProvider';
import { useAPI } from '@/providers/APIProvider';
type SendEmulatorMessageInit = { type SendEmulatorMessageInit = {
message:'init'; message:'init';
@ -18,8 +19,7 @@ type SendEmulatorMessageVolume = {
type SendEmulatorMessage = ( type SendEmulatorMessage = (
SendEmulatorMessageInit | SendEmulatorMessageInit |
SendEmulatorMessageVolume | SendEmulatorMessageVolume
{ message: 'save' }
); );
type ReceiveEmulatorMessage = ( type ReceiveEmulatorMessage = (
@ -41,11 +41,32 @@ export const Emulator:React.FC<EmulatorProps> = props => {
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const [ hasInit, setHasInit ] = useState(false); const [ hasInit, setHasInit ] = useState(false);
const { t } = useLanguage(); const { t } = useLanguage();
const [ saving, setSaving ] = useState<boolean>(false);
const [ savingError, setSavingError ] = useState<string|null>(null);
const { saveUpdate } = useAPI();
const send = (msg:SendEmulatorMessage) => { const send = (msg:SendEmulatorMessage) => {
iframeRef.current?.contentWindow?.postMessage(msg, '*'); iframeRef.current?.contentWindow?.postMessage(msg, '*');
}; };
const save = async (data:string) => {
if(saving) return;
if(!data) return;
setSaving(true);
try {
const res = await saveUpdate({
gameId: props.gameId,
data
});
console.log('res', res);
} catch(e) {
console.error(e);
} finally {
setSaving(false);
}
}
useEffect(() => { useEffect(() => {
if(hasInit) return; if(hasInit) return;
setHasInit(true); setHasInit(true);
@ -70,11 +91,7 @@ export const Emulator:React.FC<EmulatorProps> = props => {
break; break;
case 'save': case 'save':
// Download save data save(msg.data).catch(console.error);
const a = document.createElement('a');
a.href = `data:application/zip;base64,${msg.data}`;
a.download = `${props.gameId}.zip`;
a.click();
break; break;
case 'start': case 'start':
@ -82,6 +99,7 @@ export const Emulator:React.FC<EmulatorProps> = props => {
case 'load_state': case 'load_state':
case 'save_state': case 'save_state':
break; break;
default: default:
console.error('Invalid message received:', msg); console.error('Invalid message received:', msg);
break break

View File

@ -2,12 +2,15 @@ import React from 'react';
import { AppProps } from 'next/app'; import { AppProps } from 'next/app';
import './globals.scss'; import './globals.scss';
import { LanguageProvider } from '@/providers/LanguageProvider'; import { LanguageProvider } from '@/providers/LanguageProvider';
import { APIProvider } from '@/providers/APIProvider';
const RootLayout:React.FC<AppProps> = ({ Component, pageProps }) => { const RootLayout:React.FC<AppProps> = ({ Component, pageProps }) => {
return ( return (
<> <>
<LanguageProvider> <LanguageProvider>
<Component {...pageProps} /> <APIProvider>
<Component {...pageProps} />
</APIProvider>
</LanguageProvider> </LanguageProvider>
</> </>
); );

View File

@ -4,13 +4,41 @@ import * as path from 'path';
const PATH_SAVES = path.resolve('.', 'data', 'saves'); const PATH_SAVES = path.resolve('.', 'data', 'saves');
const handler:NextApiHandler = async (req, res) => { const handlerPut:NextApiHandler = async (req, res) => {
if (req.method !== 'GET') { if(!req.body || typeof req.body !== 'object') {
res.setHeader('Allow', ['GET']); res.status(400).end(`Bad Request`);
res.status(405).end(`Method Not Allowed`);
return; return;
} }
if(!req.body.gameId || typeof req.body.gameId !== 'string') {
res.status(400).end(`Bad Request`);
}
if(!req.body.data || typeof req.body.data !== 'string') {
res.status(400).end(`Bad Request`);
}
// Check the length of the data.
if(req.body.data.length > 1024 * 1024) {// 1MB
res.status(413).end(`Payload Too Large`);
return;
}
// Data is a base64 string, convert it to a binary zip file.
const pathSave = path.resolve(PATH_SAVES, `${req.body.gameId}.zip`);
try {
const data = Buffer.from(req.body.data, 'base64');
fs.promises.writeFile(pathSave, data);
} catch (error) {
console.error(error);
res.status(500).end(`Internal Server Error`);
return;
}
res.status(200).end(`true`);
}
const handlerGet:NextApiHandler = async (req, res) => {
// ID query param // ID query param
if(!req.query.id || typeof req.query.id !== 'string') { if(!req.query.id || typeof req.query.id !== 'string') {
res.status(404).end(`Not Found`); res.status(404).end(`Not Found`);
@ -39,6 +67,20 @@ const handler:NextApiHandler = async (req, res) => {
} catch (error) { } catch (error) {
res.status(500).end(`Internal Server Error`); res.status(500).end(`Internal Server Error`);
} }
}
const handler:NextApiHandler = async (req, res) => {
switch(req.method) {
case 'POST':
case 'PUT':
return handlerPut(req, res);
case 'GET':
return handlerGet(req, res);
default:
res.status(405).end(`Method Not Allowed`);
}
}; };
export default handler; export default handler;

View File

@ -0,0 +1,60 @@
import React, { createContext, useContext, ReactNode } from 'react';
type APIContextType = {
saveUpdate:(p:{ gameId:string, data:string }) => Promise<void>;
};
const APIContext = createContext<APIContextType | undefined>(undefined);
export const APIProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const apiSend = async <T,>(p:{
path:string;
method:'POST'|'GET'|'PUT'|'DELETE';
body?:any;
query?:Record<string, string>;
}) => {
const query = p.query ? '?' + Object.entries(p.query).map(([k, v]) => {
return `${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
}).join('&') : '';
const res = await fetch(`/api/v1/${p.path}${query}`, {
method: p.method,
body: p.body ? JSON.stringify(p.body) : undefined,
headers: {
'Content-Type': 'application/json',
},
});
if(!res.ok) {
throw new Error(`API Error: ${res.status}`);
}
return res.json();
}
// Methods
const saveUpdate:APIContextType['saveUpdate'] = async p => {
return await apiSend({
path: 'save',
method: 'PUT',
body: p
});
};
// Provider
return (
<APIContext.Provider value={{
saveUpdate,
}}>
{children}
</APIContext.Provider>
);
};
export const useAPI = (): APIContextType => {
const context = useContext(APIContext);
if (!context) {
throw new Error('useAPI must be used within an APIProvider');
}
return context;
};