Fix controls (#610)

* Controls were broken on mobile because dpad movement was interpreted
as a/b button presses.
* Fixes it to:
  * Handle multiple fingers using the dpad / a / b buttons
* A dpad movement must start on the dpad, and a/b presses must start on
a/b.
This commit is contained in:
Corwin 2024-04-06 03:33:42 +01:00 committed by GitHub
commit b47dc28f6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 155 additions and 78 deletions

View file

@ -51,6 +51,8 @@ function App() {
"agbrswebplayer", "agbrswebplayer",
); );
const [mgbaId, setMgbaId] = useState(0);
const setVolume = (newVolume: number) => const setVolume = (newVolume: number) =>
setState({ volume: newVolume, bindings }); setState({ volume: newVolume, bindings });
const setBindings = (newBindings: Bindings) => const setBindings = (newBindings: Bindings) =>
@ -79,7 +81,7 @@ function App() {
} }
if (reset) { if (reset) {
mgbaRef.current?.restart(); setMgbaId((id) => id + 1);
} }
}; };
@ -111,6 +113,7 @@ function App() {
)} )}
{isPlaying ? ( {isPlaying ? (
<Mgba <Mgba
key={mgbaId}
ref={mgbaRef} ref={mgbaRef}
gameUrl={gameUrl} gameUrl={gameUrl}
volume={volume} volume={volume}

View file

@ -8,6 +8,7 @@ import {
import mGBA from "./vendor/mgba"; import mGBA from "./vendor/mgba";
import { GbaKey, KeyBindings } from "./bindings"; import { GbaKey, KeyBindings } from "./bindings";
import { styled } from "styled-components"; import { styled } from "styled-components";
import { useSmoothedFramerate } from "./useSmoothedFramerate.hook";
type Module = any; type Module = any;
@ -40,6 +41,14 @@ export interface MgbaHandle {
buttonRelease: (key: GbaKey) => void; buttonRelease: (key: GbaKey) => void;
} }
const whichFrameSkip = (frameRate: number): number | undefined => {
if ((frameRate + 5) % 60 <= 10) {
// framerate close to multiple of 60
// use frameskip
return Math.round(frameRate / 60);
}
};
export const Mgba = forwardRef<MgbaHandle, MgbaProps>( export const Mgba = forwardRef<MgbaHandle, MgbaProps>(
({ gameUrl, volume, controls, paused }, ref) => { ({ gameUrl, volume, controls, paused }, ref) => {
const canvas = useRef(null); const canvas = useRef(null);
@ -92,6 +101,24 @@ export const Mgba = forwardRef<MgbaHandle, MgbaProps>(
}; };
}, [state]); }, [state]);
const frameRate = useSmoothedFramerate();
const frameSkipToUse = whichFrameSkip(frameRate);
useEffect(() => {
if (!gameLoaded) return;
if (frameSkipToUse) {
// framerate close to multiple of 60
// use frameskip
console.log("Using frameskip");
mgbaModule.current.setMainLoopTiming(1, frameSkipToUse);
} else {
// frame rate not close to multiple of 60, use timeout
console.log("Using timeout");
mgbaModule.current.setMainLoopTiming(0, 1000 / 59.727500569606);
}
}, [frameSkipToUse, gameLoaded]);
useEffect(() => { useEffect(() => {
if (!gameLoaded) return; if (!gameLoaded) return;

View file

@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
export const useSmoothedFramerate = (): number => {
const [smoothedFrameTime, setSmoothedFrameTime] = useState(60);
useEffect(() => {
let previous: number | undefined = undefined;
let stopped = false;
const raf = (time: DOMHighResTimeStamp) => {
if (previous) {
let delta = time - previous;
setSmoothedFrameTime((time) => (time * 3 + delta) / 4);
}
previous = time;
if (!stopped) {
window.requestAnimationFrame(raf);
}
}
window.requestAnimationFrame(raf);
return () => { stopped = true; }
}, []);
return Math.round(1 / (smoothedFrameTime / 1000));
}

View file

@ -62,7 +62,7 @@
max-width: 60%; max-width: 60%;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
margin-block-end: 40px; margin-block-end: 20px;
} }
.red { .red {
@ -110,8 +110,12 @@
.mobileControlsRow { .mobileControlsRow {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
width: 100%;
}
.mobileControlsReset {
justify-content: center; justify-content: center;
gap: 40px;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
@ -120,7 +124,7 @@
} }
header, header,
.desktopHelp { .desktopHelp {
width: 90%; max-width: 90%;
} }
} }
@ -169,7 +173,7 @@
<img id="mobileSelect" src="assets/SELECT.png" /> <img id="mobileSelect" src="assets/SELECT.png" />
<img id="mobileStart" src="assets/START.png" /> <img id="mobileStart" src="assets/START.png" />
</div> </div>
<div class="mobileControlsRow"> <div class="mobileControlsRow mobileControlsReset">
<button id="mobileRestart">Restart</button> <button id="mobileRestart">Restart</button>
</div> </div>
</div> </div>
@ -222,119 +226,127 @@
addSimpleButton(mobileStart, "Start"); addSimpleButton(mobileStart, "Start");
addSimpleButton(mobileSelect, "Select"); addSimpleButton(mobileSelect, "Select");
let previouslyPressedButtons = []; let previouslyPressedButtons = new Set();
const dpadMovement = (touch) => { const dpadMovement = (touches) => {
const target = touch.target.getBoundingClientRect(); const currentlyPressed = new Set();
for (let touch of touches) {
const target = touch.target.getBoundingClientRect();
const touchPoint = { x: touch.clientX, y: touch.clientY }; const touchPoint = { x: touch.clientX, y: touch.clientY };
const targetArea = { const targetArea = {
x: target.left, x: target.left,
y: target.top, y: target.top,
width: target.width, width: target.width,
height: target.height, height: target.height,
}; };
const relativePosition = { const relativePosition = {
x: touchPoint.x - targetArea.x, x: touchPoint.x - targetArea.x,
y: touchPoint.y - targetArea.y, y: touchPoint.y - targetArea.y,
}; };
const touchedBox = { const touchedBox = {
x: Math.floor(relativePosition.x / (targetArea.width / 3)), x: Math.floor(relativePosition.x / (targetArea.width / 3)),
y: Math.floor(relativePosition.y / (targetArea.height / 3)), y: Math.floor(relativePosition.y / (targetArea.height / 3)),
}; };
const buttonBoxMapping = [ const buttonBoxMapping = [
[["Up", "Left"], ["Up"], ["Up", "Right"]], [["Up", "Left"], ["Up"], ["Up", "Right"]],
[["Left"], [], ["Right"]], [["Left"], [], ["Right"]],
[["Down", "Left"], ["Down"], ["Down", "Right"]], [["Down", "Left"], ["Down"], ["Down", "Right"]],
]; ];
const buttonsToPress = const buttonsToPress =
(buttonBoxMapping[touchedBox.y] ?? [])[touchedBox.x] ?? []; (buttonBoxMapping[touchedBox.y] ?? [])[touchedBox.x] ?? [];
for (let button of buttonsToPress) {
currentlyPressed.add(button);
}
}
for (let oldButton of previouslyPressedButtons) { for (let oldButton of previouslyPressedButtons) {
if (!buttonsToPress.includes(oldButton)) { if (!currentlyPressed.has(oldButton)) {
releaseButton(oldButton); releaseButton(oldButton);
} }
} }
for (let newButton of buttonsToPress) { for (let newButton of currentlyPressed) {
if (!previouslyPressedButtons.includes(newButton)) { if (!previouslyPressedButtons.has(newButton)) {
pressButton(newButton); pressButton(newButton);
} }
} }
previouslyPressedButtons = buttonsToPress; previouslyPressedButtons = currentlyPressed;
}; };
mobileDpad.addEventListener("touchstart", (evt) => mobileDpad.addEventListener("touchstart", (evt) =>
dpadMovement(evt.touches[0]) dpadMovement(evt.targetTouches)
); );
mobileDpad.addEventListener("touchmove", (evt) => mobileDpad.addEventListener("touchmove", (evt) =>
dpadMovement(evt.touches[0]) dpadMovement(evt.targetTouches)
); );
mobileDpad.addEventListener("touchend", (evt) => { mobileDpad.addEventListener("touchend", (evt) => {
for (let oldButton of previouslyPressedButtons) { dpadMovement(evt.targetTouches);
releaseButton(oldButton);
}
previouslyPressedButtons = [];
}); });
let mobileAbAPress = undefined; mobileDpad.addEventListener("touchcancel", (evt) => {
const mobileAbMovement = (touch) => { dpadMovement(evt.targetTouches);
const target = touch.target.getBoundingClientRect(); });
const touchPoint = { x: touch.clientX, y: touch.clientY }; let mobileAbAPress = new Set();
const targetArea = { const mobileAbMovement = (touches) => {
x: target.left, const currentTouches = new Set();
y: target.top, for (let touch of touches) {
width: target.width, const target = touch.target.getBoundingClientRect();
height: target.height,
};
const relativePosition = { const touchPoint = { x: touch.clientX, y: touch.clientY };
x: touchPoint.x - targetArea.x, const targetArea = {
y: touchPoint.y - targetArea.y, x: target.left,
}; y: target.top,
width: target.width,
height: target.height,
};
const aPress = relativePosition.x > relativePosition.y; const relativePosition = {
x: touchPoint.x - targetArea.x,
y: touchPoint.y - targetArea.y,
};
if (aPress !== mobileAbAPress) { const aPress = relativePosition.x > relativePosition.y;
if (mobileAbAPress === true) { currentTouches.add(aPress ? "A" : "B");
releaseButton("A"); }
} else if (mobileAbAPress === false) {
releaseButton("B"); for (let oldTouch of mobileAbAPress) {
if (!currentTouches.has(oldTouch)) {
releaseButton(oldTouch);
} }
} }
if (aPress) {
pressButton("A");
} else {
pressButton("B");
}
mobileAbAPress = aPress; for (let newTouch of currentTouches) {
if (!mobileAbAPress.has(newTouch)) {
pressButton(newTouch);
}
}
mobileAbAPress = currentTouches;
}; };
mobileAb.addEventListener("touchstart", (evt) => mobileAb.addEventListener("touchstart", (evt) => {
mobileAbMovement(evt.touches[0]) mobileAbMovement(evt.targetTouches);
); });
mobileAb.addEventListener("touchmove", (evt) => mobileAb.addEventListener("touchmove", (evt) => {
mobileAbMovement(evt.touches[0]) mobileAbMovement(evt.targetTouches);
); });
mobileAb.addEventListener("touchend", (evt) => { mobileAb.addEventListener("touchend", (evt) => {
if (mobileAbAPress === true) { mobileAbMovement(evt.targetTouches);
releaseButton("A"); });
} else if (mobileAbAPress === false) {
releaseButton("B");
}
mobileAbAPress = undefined; mobileAb.addEventListener("touchcancel", (evt) => {
mobileAbMovement(evt.targetTouches);
}); });
</script> </script>
</body> </body>