]> git.localhorst.tv Git - alttp.git/commitdiff
guessing game auto-tracking
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 14 Mar 2024 17:17:01 +0000 (18:17 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 14 Mar 2024 17:17:01 +0000 (18:17 +0100)
resources/js/app/index.js
resources/js/components/common/Icon.js
resources/js/components/common/ToggleSwitch.js
resources/js/components/twitch-bot/GuessingGameAutoTracking.js [new file with mode: 0644]
resources/js/components/twitch-bot/GuessingGameControls.js
resources/js/helpers/SNESSocket.js [new file with mode: 0644]
resources/js/helpers/alttp-ram.js [new file with mode: 0644]
resources/js/hooks/snes.js [new file with mode: 0644]
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/sass/form.scss

index 2c80c798bccb397862cb29e576345ef59da594ff..385ed7dff8a88734891fb32f5d5733178195fb35 100644 (file)
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
 
 import Routes from './Routes';
 import AlttpBaseRomProvider from '../helpers/AlttpBaseRomContext';
+import { SNESProvider } from '../hooks/snes';
 import { UserProvider } from '../hooks/user';
 import i18n from '../i18n';
 
@@ -21,14 +22,16 @@ const App = () => {
        }, []);
 
        return <AlttpBaseRomProvider>
-               <UserProvider>
-                       <Helmet>
-                               <html lang={i18n.language} />
-                               <title>{t('general.appName')}</title>
-                               <meta name="description" content={t('general.appDescription')} />
-                       </Helmet>
-                       <Routes />
-               </UserProvider>
+               <SNESProvider>
+                       <UserProvider>
+                               <Helmet>
+                                       <html lang={i18n.language} />
+                                       <title>{t('general.appName')}</title>
+                                       <meta name="description" content={t('general.appDescription')} />
+                               </Helmet>
+                               <Routes />
+                       </UserProvider>
+               </SNESProvider>
        </AlttpBaseRomProvider>;
 };
 
index 4d9559c867cb119af5e3ae5af6996858753ceabd..f3caeb3eaf8f80a7b23c28288abc53fe9cc64335 100644 (file)
@@ -74,6 +74,7 @@ Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy');
 Icon.FORBIDDEN = makePreset('ForbiddenIcon', 'square-xmark');
 Icon.FORFEIT = makePreset('ForfeitIcon', 'square-xmark');
 Icon.HASH = makePreset('HashIcon', 'hashtag');
+Icon.INFO = makePreset('Info', 'circle-info');
 Icon.INVERT = makePreset('InvertIcon', 'circle-half-stroke');
 Icon.LANGUAGE = makePreset('LanguageIcon', 'language');
 Icon.LOCKED = makePreset('LockedIcon', 'lock');
index da9653c57d451c53527a1b79d1af9d56d54911ea..db07619ffd70a68ce7b8810c050975436289f1b8 100644 (file)
@@ -12,6 +12,7 @@ const ToggleSwitch = ({
        onChange,
        onLabel,
        readonly,
+       title,
        value,
 }) => {
        const toggle = () => {
@@ -43,6 +44,7 @@ const ToggleSwitch = ({
                        role="button"
                        aria-pressed={value}
                        tabIndex="0"
+                       title={title}
                        onBlur={onBlur ? () => onBlur({ target: { name, value } }) : null}
                        onClick={handleClick}
                        onKeyDown={handleKey}
@@ -68,6 +70,7 @@ ToggleSwitch.propTypes = {
        onChange: PropTypes.func,
        onLabel: PropTypes.string,
        readonly: PropTypes.bool,
+       title: PropTypes.string,
        value: PropTypes.bool,
 };
 
@@ -81,6 +84,7 @@ ToggleSwitch.defaultProps = {
        onChange: null,
        onLabel: '',
        readonly: false,
+       title: null,
        value: false,
 };
 
diff --git a/resources/js/components/twitch-bot/GuessingGameAutoTracking.js b/resources/js/components/twitch-bot/GuessingGameAutoTracking.js
new file mode 100644 (file)
index 0000000..fae142c
--- /dev/null
@@ -0,0 +1,346 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import ToggleSwitch from '../common/ToggleSwitch';
+import {
+       compareGTBasementState,
+       countGTBasementState,
+       getGTBasementState,
+} from '../../helpers/alttp-ram';
+import { useSNES } from '../../hooks/snes';
+
+const IN_GAME_MODES = [
+       0x05, // loading game
+       0x06, // entering dungeon
+       0x07, // dungeon
+       0x08, // entering overworld
+       0x09, // overworld
+       0x0A, // entering special overworld
+       0x0B, // special overworld
+       0x0E, // text/menu/map
+       0x0F, // closing spot
+       0x10, // opening spot
+       0x11, // falling
+       0x12, // dying
+       0x13, // fanfare
+       0x15, // mirror
+       0x16, // refill
+       0x17, // S&Q
+       0x18, // aga 2 cutscene
+       0x19, // triforce room
+       0x1A, // credits
+       0x1B, // spawn select
+];
+
+const GT_TYPES = [
+       0x02, // all dungeons
+       0x03, // defeat ganon
+       0x04, // fast ganon (the default and used for defeat ganon for some reason)
+       0x07, // crystals & bosses
+       0x08, // bosses
+       0x09, // all dungeons, no aga 1
+       0x0B, // completionist
+];
+
+const FREE_ITEM_MENU = 0x180045;
+const GT_CRYSTALS = 0x18019A;
+const GANON_TYPE = 0x1801A8;
+const SEED_TYPE = 0x180210;
+const INIT_SRAM = 0x183000;
+
+const GAME_MODE = 0x10;
+const CURRENT_DUNGEON = 0x10E;
+const SAVE_WRAM = 0xF000;
+const ROOM_DATA_START = 0x000;
+const ROOM_DATA_END = 0x140;
+const PYRAMID_SCREEN = 0x2DB;
+const BIG_KEYS_1 = 0x366;
+const OWNED_CRYSTALS = 0x37A;
+
+const GT_ENTRANCE_ID = 55;
+
+const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
+       const [enabled, setEnabled] = React.useState(false);
+       const controls = React.useRef({
+               onSolve,
+               onStart,
+               onStop,
+       });
+
+       const [inGame, setInGame] = React.useState(false);
+       const [seedType, setSeedType] = React.useState(0);
+       const [gtCrystals, setGTCrystals] = React.useState(0);
+       const [ganonType, setGanonType] = React.useState(0);
+       const [freeItemMenu, setFreeItemMenu] = React.useState(0);
+       const [pyramidOpen, setPyramidOpen] = React.useState(false);
+
+       const [ownedCrystals, setOwnedCrystals] = React.useState(0);
+       const [lastEntrance, setLastEntrance] = React.useState(0);
+       const [hasEntered, setHasEntered] = React.useState(false);
+       const [basement, setBasement] = React.useState({
+               state: getGTBasementState(),
+               last: '',
+               count: 0,
+               torch: 0,
+       });
+       const [hasBigKey, setHasBigKey] = React.useState(false);
+
+       const {
+               disable: disableSNES,
+               enable: enableSNES,
+               sock,
+               status,
+       } = useSNES();
+       const { t } = useTranslation();
+
+       React.useEffect(() => {
+               controls.current = {
+                       onSolve,
+                       onStart,
+                       onStop,
+               };
+       }, [onSolve, onStart, onStop]);
+
+       const resetState = React.useCallback(() => {
+               setInGame(false);
+               setSeedType(0);
+               setGTCrystals(0);
+               setGanonType(0);
+               setFreeItemMenu(0);
+
+               setOwnedCrystals(0);
+               setLastEntrance(0);
+               setHasEntered(false);
+               setBasement({
+                       state: getGTBasementState(),
+                       last: '',
+                       count: 0,
+                       torch: 0,
+               });
+               setHasBigKey(false);
+       }, []);
+
+       const enable = React.useCallback(() => {
+               enableSNES();
+               setEnabled(true);
+       }, []);
+
+       const disable = React.useCallback(() => {
+               disableSNES();
+               setEnabled(false);
+               resetState();
+       }, []);
+
+       React.useEffect(() => {
+               const savedSettings = localStorage.getItem('guessingGame.settings');
+               if (savedSettings) {
+                       const settings = JSON.parse(savedSettings);
+                       if (settings.autoTrack) {
+                               enable();
+                       }
+               }
+       }, []);
+
+       const saveSettings = React.useCallback((newSettings) => {
+               const savedSettings = localStorage.getItem('guessingGame.settings');
+               const settings = savedSettings
+                       ? { ...JSON.parse(savedSettings), ...newSettings }
+                       : newSettings;
+               localStorage.setItem('guessingGame.settings', JSON.stringify(settings));
+       }, []);
+
+       const toggle = React.useCallback(() => {
+               if (enabled) {
+                       disable();
+                       saveSettings({ autoTrack: false });
+               } else {
+                       enable();
+                       saveSettings({ autoTrack: true });
+               }
+       }, [enabled]);
+
+       // game mode timer
+       React.useEffect(() => {
+               if (enabled && !status.error && status.connected && status.device) {
+                       const checkInGame = () => {
+                               sock.current.readWRAM(GAME_MODE, 1, (data) => {
+                                       setInGame(IN_GAME_MODES.includes(data[0]));
+                               });
+                       };
+                       checkInGame();
+                       const timer = setInterval(checkInGame, 5000);
+                       return () => {
+                               clearInterval(timer);
+                       };
+               } else {
+                       setInGame(false);
+               }
+       }, [enabled && !status.error && status.connected && status.device]);
+
+       // refresh static game information
+       React.useEffect(() => {
+               if (!inGame) return;
+               sock.current.readBytes(SEED_TYPE, 1, (data) => {
+                       setSeedType(data[0]);
+               });
+               sock.current.readBytes(GT_CRYSTALS, 1, (data) => {
+                       setGTCrystals(data[0]);
+               });
+               sock.current.readBytes(GANON_TYPE, 1, (data) => {
+                       setGanonType(data[0]);
+               });
+               sock.current.readBytes(FREE_ITEM_MENU, 1, (data) => {
+                       setFreeItemMenu(data[0]);
+               });
+               sock.current.readBytes(INIT_SRAM + PYRAMID_SCREEN, 1, (data) => {
+                       setPyramidOpen(!!(data[0] & 0x20));
+               });
+       }, [inGame, sock]);
+
+       const applicable = React.useMemo(() => {
+               return !seedType &&
+                       gtCrystals &&
+                       GT_TYPES.includes(ganonType) &&
+                       !pyramidOpen &&
+                       !(freeItemMenu & 0x02);
+       }, [freeItemMenu, ganonType, gtCrystals, pyramidOpen, seedType]);
+
+       // update crystals information
+       React.useEffect(() => {
+               if (!applicable || !inGame || hasBigKey) return;
+               const updateCrystals = () => {
+                       sock.current.readWRAM(SAVE_WRAM + OWNED_CRYSTALS, 1, (data) => {
+                               let owned = 0;
+                               for (let i = 0; i < 7; ++i) {
+                                       if (data[0] & Math.pow(2, i)) {
+                                               ++owned;
+                                       }
+                               }
+                               setOwnedCrystals(owned);
+                       });
+               };
+               // increase frequency for the last
+               const timer = setInterval(updateCrystals, ownedCrystals === gtCrystals - 1 ? 1000 : 15000);
+               return () => {
+                       clearInterval(timer);
+               };
+       }, [applicable, gtCrystals, hasBigKey, inGame, ownedCrystals, sock]);
+
+       // start game once all required crystals have been acquired
+       React.useEffect(() => {
+               if (!applicable || hasBigKey || ownedCrystals !== gtCrystals || hasEntered) return;
+               controls.current.onStart();
+               const updateDungeon = () => {
+                       sock.current.readWRAM(CURRENT_DUNGEON, 2, (data) => {
+                               setLastEntrance(data[0] + (data[1] * 256));
+                       });
+               };
+               const timer = setInterval(updateDungeon, 1000);
+               return () => {
+                       clearInterval(timer);
+               };
+       }, [applicable, controls, gtCrystals, hasBigKey, hasEntered, ownedCrystals]);
+
+       // stop game when GT has been entered
+       React.useEffect(() => {
+               if (!applicable || hasBigKey || ownedCrystals !== gtCrystals) return;
+               if (lastEntrance === GT_ENTRANCE_ID) {
+                       controls.current.onStop();
+                       setHasEntered(true);
+               }
+       }, [applicable, controls, gtCrystals, hasBigKey, lastEntrance, ownedCrystals]);
+
+       // watch GT state
+       React.useEffect(() => {
+               if (!applicable || !hasEntered || hasBigKey) return;
+               const updateGTState = () => {
+                       const roomDataSize = ROOM_DATA_END - ROOM_DATA_START;
+                       sock.current.readWRAM(SAVE_WRAM + ROOM_DATA_START, roomDataSize, (data) => {
+                               const gtState = getGTBasementState(data);
+                               const gtCount = countGTBasementState(gtState);
+                               setBasement(old => {
+                                       const cmp = compareGTBasementState(old.state, gtState);
+                                       if (cmp) {
+                                               return {
+                                                       state: gtState,
+                                                       last: cmp,
+                                                       count: gtCount,
+                                                       torch: cmp === 'torchSeen' ? gtCount : old.torch,
+                                               };
+                                       }
+                                       return old;
+                               });
+                       });
+               };
+               const timer = setInterval(updateGTState, 500);
+               return () => {
+                       clearInterval(timer);
+               };
+       }, [applicable, hasBigKey, hasEntered]);
+
+       React.useEffect(() => {
+               if (!applicable) return;
+               if (hasBigKey) {
+                       const solution = basement.last === 'torch' ? basement.torch : basement.count;
+                       controls.current.onSolve(solution);
+               } else {
+                       sock.current.readWRAM(SAVE_WRAM + BIG_KEYS_1, 1, (data) => {
+                               setHasBigKey(!!(data[0] & 0x04));
+                       });
+               }
+       }, [applicable, basement, controls, hasBigKey]);
+
+       const statusMsg = React.useMemo(() => {
+               if (!enabled) {
+                       return 'disabled';
+               }
+               if (status.error) {
+                       return 'error';
+               }
+               if (!status.connected) {
+                       return 'disconnected';
+               }
+               if (!status.device) {
+                       return 'no-device';
+               }
+               if (!inGame) {
+                       return 'not-in-game';
+               }
+               if (!applicable) {
+                       return 'not-applicable';
+               }
+               return 'tracking';
+       }, [applicable, enabled, inGame, status]);
+
+       return <div>
+               {['disconnected', 'error', 'no-device'].includes(statusMsg) ?
+                       <Icon.WARNING
+                               className="me-2 text-warning"
+                               size="lg"
+                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
+                       />
+               : null}
+               {['not-applicable', 'not-in-game'].includes(statusMsg) ?
+                       <Icon.INFO
+                               className="me-2 text-info"
+                               size="lg"
+                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
+                       />
+               : null}
+               <ToggleSwitch
+                       onChange={toggle}
+                       title={t('autoTracking.heading')}
+                       value={enabled}
+               />
+       </div>;
+};
+
+GuessingGameAutoTracking.propTypes = {
+       onSolve: PropTypes.func,
+       onStart: PropTypes.func,
+       onStop: PropTypes.func,
+};
+
+export default GuessingGameAutoTracking;
index c3846c574e4fe3724155a81f7cd76779b5d5f972..50a3c3a4b0bde6d0bf138dfa0e54fed07ba00399 100644 (file)
@@ -3,6 +3,7 @@ import React from 'react';
 import { Button } from 'react-bootstrap';
 import { useTranslation } from 'react-i18next';
 
+import GuessingGameAutoTracking from './GuessingGameAutoTracking';
 import {
        hasActiveGuessing,
        isAcceptingGuesses,
@@ -23,29 +24,36 @@ const GuessingGameControls = ({
        ];
 
        return <div>
-               <div className="button-bar mt-3">
-                       <Button
-                               onClick={onStart}
-                               variant={hasActiveGuessing(channel) ? 'success' : 'outline-success'}
-                       >
-                               {t('button.start')}
-                       </Button>
-                       <Button
-                               onClick={onStop}
-                               variant={
-                                       hasActiveGuessing(channel) && isAcceptingGuesses(channel)
-                                       ? 'danger' : 'outline-danger'
-                               }
-                       >
-                               {t('button.stop')}
-                       </Button>
-                       <Button
-                               className="ms-3"
-                               onClick={onCancel}
-                               variant={hasActiveGuessing(channel) ? 'secondary' : 'outline-secondary'}
-                       >
-                               {t('button.cancel')}
-                       </Button>
+               <div className="d-flex align-items-center justify-content-between mt-3">
+                       <div className="button-bar">
+                               <Button
+                                       onClick={onStart}
+                                       variant={hasActiveGuessing(channel) ? 'success' : 'outline-success'}
+                               >
+                                       {t('button.start')}
+                               </Button>
+                               <Button
+                                       onClick={onStop}
+                                       variant={
+                                               hasActiveGuessing(channel) && isAcceptingGuesses(channel)
+                                               ? 'danger' : 'outline-danger'
+                                       }
+                               >
+                                       {t('button.stop')}
+                               </Button>
+                               <Button
+                                       className="ms-3"
+                                       onClick={onCancel}
+                                       variant={hasActiveGuessing(channel) ? 'secondary' : 'outline-secondary'}
+                               >
+                                       {t('button.cancel')}
+                               </Button>
+                       </div>
+                       <GuessingGameAutoTracking
+                               onSolve={onSolve}
+                               onStart={onStart}
+                               onStop={onStop}
+                       />
                </div>
                {hasActiveGuessing(channel) ?
                        <div className="bkgg-buttons d-grid gap-3 my-3">
diff --git a/resources/js/helpers/SNESSocket.js b/resources/js/helpers/SNESSocket.js
new file mode 100644 (file)
index 0000000..27441e8
--- /dev/null
@@ -0,0 +1,107 @@
+export default class SNESSocket {
+
+       constructor(uri) {
+               this.uri = uri;
+               this.queue = [];
+               this.device = '';
+               this.deviceList = [];
+               this.open();
+       }
+
+       open() {
+               if (this.sock) {
+                       this.sock.close();
+                       this.sock = null;
+               }
+               this.sock = new WebSocket(this.uri);
+               this.sock.binaryType = 'arraybuffer';
+               this.sock.onclose = () => {
+                       if (this.onclose) {
+                               this.onclose();
+                       }
+               };
+               this.sock.onerror = (e) => {
+                       if (this.onerror) {
+                               this.onerror(e);
+                       }
+               };
+               this.sock.onopen = () => {
+                       if (this.onopen) {
+                               this.onopen();
+                       }
+               };
+               this.sock.onmessage = (e) => {
+                       if (this.queue.length) {
+                               const handler = this.queue.shift();
+                               handler(e.data);
+                       }
+               };
+       }
+
+       close() {
+               this.sock.close();
+       }
+
+       isOpen() {
+               return this.sock.readyState === 1;
+       }
+
+       send(opcode, flags, operands, callback) {
+               const payload = {
+                       Opcode: opcode,
+                       Space: 'SNES',
+               };
+               if (flags) {
+                       payload.Flags = flags;
+               }
+               if (operands) {
+                       payload.Operands = operands;
+               }
+               this.sock.send(JSON.stringify(payload));
+               if (callback) {
+                       this.queue.push(callback);
+               }
+       }
+
+       attachDevice(device) {
+               this.device = device;
+               this.send('Attach', null, [device]);
+       }
+
+       readBytes(start, size, callback) {
+               const handler = (rsp) => {
+                       if (callback) callback(new Uint8Array(rsp));
+               };
+               this.send('GetAddress', null, [start.toString(16), size.toString(16)], handler);
+       }
+
+       readSRAM(start, size, callback) {
+               this.readBytes(start + 0xE00000, size, callback);
+       }
+
+       readVRAM(start, size, callback) {
+               this.readBytes(start + 0xF70000, size, callback);
+       }
+
+       readWRAM(start, size, callback) {
+               this.readBytes(start + 0xF50000, size, callback);
+       }
+
+       requestDeviceInfo(callback) {
+               const handler = (rsp) => {
+                       const data = JSON.parse(rsp);
+                       if (callback) callback(data);
+               };
+               this.send('Info', null, null, handler);
+       }
+
+       requestDeviceList(callback) {
+               const handler = (rsp) => {
+                       const data = JSON.parse(rsp);
+                       this.deviceList = data.Results || [];
+                       if (callback) callback(data);
+               };
+               this.send('DeviceList', null, null, handler);
+       }
+
+}
diff --git a/resources/js/helpers/alttp-ram.js b/resources/js/helpers/alttp-ram.js
new file mode 100644 (file)
index 0000000..2ae71cf
--- /dev/null
@@ -0,0 +1,100 @@
+export const isChestOpen = (data, room, chest) => {
+       if (chest < 4) {
+               return !!(data && (data[2 * room] & Math.pow(2, chest + 4)));
+       } else {
+               return !!(data && (data[(2 * room) + 1] & Math.pow(2, chest - 4)));
+       }
+};
+
+export const hasVisitedQuadrant = (data, room, quadrant) => {
+       return !!(data && (data[2 * room] & Math.pow(2, quadrant - 1)));
+};
+
+export const GT_BASEMENT_CHECKS = [
+       'iceM',
+       'iceL',
+       'iceR',
+       'dmUL',
+       'dmUR',
+       'dmBL',
+       'dmBR',
+       'randoUL',
+       'randoUR',
+       'randoBL',
+       'randoBR',
+       'fireSnake',
+       'mapChest',
+       'hopeL',
+       'hopeR',
+       'bobsChest',
+       'torchSeen',
+       'tileRoom',
+       'compassUL',
+       'compassUR',
+       'compassBL',
+       'compassBR',
+];
+
+export const GT_BASEMENT_ALL = [
+       'iceM',
+       'iceL',
+       'iceR',
+       'dmUL',
+       'dmUR',
+       'dmBL',
+       'dmBR',
+       'randoUL',
+       'randoUR',
+       'randoBL',
+       'randoBR',
+       'fireSnake',
+       'mapChest',
+       'hopeL',
+       'hopeR',
+       'bobsChest',
+       'torch',
+       'torchSeen',
+       'tileRoom',
+       'compassUL',
+       'compassUR',
+       'compassBL',
+       'compassBR',
+];
+
+export const getGTBasementState = (data) => ({
+       iceM: isChestOpen(data, 0x1C, 0),
+       iceL: isChestOpen(data, 0x1C, 1),
+       iceR: isChestOpen(data, 0x1C, 2),
+       dmUL: isChestOpen(data, 0x7B, 0),
+       dmUR: isChestOpen(data, 0x7B, 1),
+       dmBL: isChestOpen(data, 0x7B, 2),
+       dmBR: isChestOpen(data, 0x7B, 3),
+       randoUL: isChestOpen(data, 0x7C, 0),
+       randoUR: isChestOpen(data, 0x7C, 1),
+       randoBL: isChestOpen(data, 0x7C, 2),
+       randoBR: isChestOpen(data, 0x7C, 3),
+       fireSnake: isChestOpen(data, 0x7D, 0),
+       mapChest: isChestOpen(data, 0x8B, 0),
+       hopeL: isChestOpen(data, 0x8C, 1),
+       hopeR: isChestOpen(data, 0x8C, 2),
+       bobsChest: isChestOpen(data, 0x8C, 3),
+       torchSeen: hasVisitedQuadrant(data, 0x8C, 4),
+       torch: isChestOpen(data, 0x8C, 6),
+       tileRoom: isChestOpen(data, 0x8D, 0),
+       compassUL: isChestOpen(data, 0x9D, 0),
+       compassUR: isChestOpen(data, 0x9D, 1),
+       compassBL: isChestOpen(data, 0x9D, 2),
+       compassBR: isChestOpen(data, 0x9D, 3),
+});
+
+export const countGTBasementState = (state) =>
+       GT_BASEMENT_CHECKS.reduce((acc, cur) => state[cur] ? acc + 1 : acc, 0);
+
+export const compareGTBasementState = (prev, cur) => {
+       for (let i = 0; i < GT_BASEMENT_ALL.length; ++i) {
+               if (prev[GT_BASEMENT_ALL[i]] !== cur[GT_BASEMENT_ALL[i]]) {
+                       return GT_BASEMENT_ALL[i];
+               }
+       }
+       return '';
+};
diff --git a/resources/js/hooks/snes.js b/resources/js/hooks/snes.js
new file mode 100644 (file)
index 0000000..05e84b2
--- /dev/null
@@ -0,0 +1,125 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import SNESSocket from '../helpers/SNESSocket';
+
+const context = React.createContext({});
+
+export const useSNES = () => React.useContext(context);
+
+export const SNESProvider = ({ children }) => {
+       const [enabled, setEnabled] = React.useState(false);
+
+       const sock = React.useRef(null);
+
+       const [settings, setSettings] = React.useState({
+               proto: 'ws',
+               host: 'localhost',
+               port: 8080,
+               device: '',
+       });
+
+       const [status, setStatus] = React.useState({
+               connected: false,
+               device: '',
+               deviceList: [],
+               error: false,
+       });
+
+       React.useEffect(() => {
+               if (sock.current) {
+                       sock.current.close();
+                       sock.current = null;
+               }
+               if (enabled) {
+                       const tryAttach = () => {
+                               const { deviceList } = sock.current;
+                               let device = '';
+                               if (deviceList.includes(settings.device)) {
+                                       device = settings.device;
+                               } else if (deviceList.length > 0) {
+                                       device = deviceList[0];
+                               }
+                               setStatus(s => ({ ...s, device, deviceList }));
+                               if (device) {
+                                       sock.current.attachDevice(device);
+                               }
+                       };
+                       sock.current = new SNESSocket(`${settings.proto}://${settings.host}:${settings.port}`);
+                       sock.current.onclose = () => {
+                               setStatus({
+                                       connected: false,
+                                       device: '',
+                                       deviceList: [],
+                                       error: false,
+                               });
+                       };
+                       sock.current.onerror = (e) => {
+                               setStatus({
+                                       connected: false,
+                                       device: '',
+                                       deviceList: [],
+                                       error: e,
+                               });
+                       };
+                       sock.current.onopen = () => {
+                               setStatus({
+                                       connected: true,
+                                       device: '',
+                                       deviceList: [],
+                                       error: false,
+                               });
+                               sock.current.requestDeviceList(() => {
+                                       tryAttach();
+                               });
+                       };
+                       const watchdog = setInterval(() => {
+                               if (!sock.current.isOpen()) {
+                                       sock.current.open();
+                                       return;
+                               }
+                               if (!sock.current.device) {
+                                       sock.current.requestDeviceList(() => {
+                                               tryAttach();
+                                       });
+                               }
+                       }, 5000);
+                       return () => {
+                               clearInterval(watchdog);
+                       };
+               }
+       }, [enabled, settings]);
+
+       const enable = React.useCallback(() => {
+               setEnabled(prevEnabled => {
+                       if (prevEnabled) return true;
+                       return true;
+               });
+       }, []);
+
+       const disable = React.useCallback(() => {
+               setEnabled(prevEnabled => {
+                       if (!prevEnabled) return false;
+                       return false;
+               });
+       }, []);
+
+       React.useEffect(() => {
+               const savedSettings = localStorage.getItem('snes.settings');
+               if (savedSettings) {
+                       setSettings(JSON.parse(savedSettings));
+               }
+       }, []);
+
+       const value = React.useMemo(() => {
+               return { disable, enable, enabled, settings, sock, status };
+       }, [disable, enable, enabled, settings, sock, status]);
+
+       return <context.Provider value={value}>
+               {children}
+       </context.Provider>;
+};
+
+SNESProvider.propTypes = {
+       children: PropTypes.node,
+};
index a2ac1d15688bfefec0ca81a14c3ba051508c51af..3cac15db0256f8381a8a5432b121ca5286d47b7b 100644 (file)
@@ -52,6 +52,18 @@ export default {
                        rejectSuccess: 'Abgelehnt',
                        rejectError: 'Fehler beim Ablehnen',
                },
+               autoTracking: {
+                       heading: 'Auto-Tracking',
+                       statusMsg: {
+                               disabled: 'Deaktiviert',
+                               disconnected: 'Verbindung getrennt',
+                               error: 'Verbindungsfehler',
+                               'no-device': 'Kein SNES',
+                               'not-applicable': 'Verbunden mit {{ device }}, ungeeigneter Modus',
+                               'not-in-game': 'Verbunden mit {{ device }}, nicht im Spiel',
+                               tracking: 'Verbunden mit {{ device }}',
+                       },
+               },
                button: {
                        add: 'Hinzufügen',
                        back: 'Zurück',
index 5ad5c352c19c59d9845f58d76c5c9b8cc5be9cc0..d9cd50ff574849bf311be4ad658b977a764ae770 100644 (file)
@@ -52,6 +52,18 @@ export default {
                        rejectSuccess: 'Rejected',
                        rejectError: 'Error rejecting',
                },
+               autoTracking: {
+                       heading: 'Auto tracking',
+                       statusMsg: {
+                               disabled: 'Disabled',
+                               disconnected: 'Disconnected',
+                               error: 'Connection error',
+                               'no-device': 'No device',
+                               'not-applicable': 'Connected to {{ device }}, mode not applicable',
+                               'not-in-game': 'Connected to {{ device }}, not in game',
+                               tracking: 'Connected to {{ device }}',
+                       },
+               },
                button: {
                        add: 'Add',
                        back: 'Back',
index 2202be65cf76c474c57085e5590b50ee525b3640..411188ff2eba90c3252cdc7bf45d030ace06a1fb 100644 (file)
@@ -67,7 +67,6 @@ label {
 
                .handle-label {
                        display: inline-block;
-                       margin-top: 2px;
                        font-size: 18px;
                        font-weight: 600;
                        white-space: nowrap;