Add touch controls for mobile support

This commit is contained in:
Flashfyre 2023-12-25 15:03:50 -05:00
parent de29ea9c05
commit 41d1a84c76
8 changed files with 332 additions and 24 deletions

157
index.css Normal file
View File

@ -0,0 +1,157 @@
:root {
--color-base: hsl(0, 0%, 55%);
--color-light: hsl(0, 0%, 90%);
--color-dark: hsl(0, 0%, 10%);
--controls-size: 10vh;
--text-shadow-size: 0.65vh;
}
@media (orientation: landscape) {
:root {
--controls-size: 20vh;
--text-shadow-size: 1.3vh;
}
}
@font-face {
font-family: 'emerald';
src: url('fonts/pokemon-emerald-pro.ttf') format('truetype');
}
@font-face {
font-family: 'pkmnems';
src: url('fonts/pkmnems.ttf') format('truetype');
}
html {
touch-action: none;
}
body {
margin: 0;
background: #484050;
}
#app {
display: flex;
justify-content: center;
}
#touchControls:not(.visible) {
display: none;
}
#dpad, #apad {
position: fixed;
bottom: 1rem;
z-index: 3;
}
#dpad {
left: 1rem;
}
#apad {
right: 1rem;
}
#dpad svg {
width: calc(2 * var(--controls-size));
height: calc(2 * var(--controls-size));
fill: var(--color-base);
}
#dpad svg rect {
opacity: 0.4;
}
#apad > * {
width: var(--controls-size);
height: var(--controls-size);
}
#apad .apadBtn {
width: var(--controls-size);
height: var(--controls-size);
background-color: var(--color-base);
border-radius: 50%;
}
#apad .apadLabel {
font-family: 'emerald';
font-size: var(--controls-size);
text-shadow: var(--color-dark) var(--text-shadow-size) var(--text-shadow-size);
color: var(--color-light);
user-select: none;
}
#apad .apadLabelSmall {
font-size: calc(var(--controls-size) / 3);
text-shadow: var(--color-dark) calc(var(--text-shadow-size) / 3) calc(var(--text-shadow-size) / 3);
}
#apad #apadLabelAction, #apad #apadLabelCancel {
margin-left: calc(var(--controls-size) / 3);
line-height: 0.9;
}
#apadLabelMenu {
margin-left: 10%;
line-height: 1.1;
}
#apad > :nth-child(2) {
position: relative;
right: var(--controls-size);
}
#apad .apadRectBtn {
position: relative;
border-radius: 10%;
margin-top: calc(var(--controls-size) * -0.4);
bottom: calc(var(--controls-size) * 0.05);
left: calc(var(--controls-size) * 0.21);
width: calc(var(--controls-size) * 0.6);
height: calc(var(--controls-size) * 0.4);
}
#apad .apadSqBtn {
border-radius: 10%;
width: calc(var(--controls-size) * 0.3);
height: calc(var(--controls-size) * 0.3);
}
#apad .apadBtnContainer {
position: relative;
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
align-items: center;
margin-bottom: calc(var(--controls-size) * -0.8);
top: calc(var(--controls-size) * -0.9);
left: calc(var(--controls-size) * 0.1);
width: calc(var(--controls-size) * 0.8);
height: calc(var(--controls-size) * 0.8);
}
#touchControls:not([data-ui-mode='STARTER_SELECT']) #apad .apadBtnContainer {
display: none;
}
#apad .apadRectBtn + .apadBtnContainer {
top: calc(var(--controls-size) * -1.9);
left: calc(var(--controls-size) * -0.9);
}
#apad .apadBtnContainer .apadLabel {
margin-left: calc(var(--controls-size) / 12);
line-height: 0.8;
}
#dpad path:not(.active), #apad .apadBtn:not(.active) {
opacity: 0.4;
}
#layout:fullscreen #dpad, #layout:fullscreen #apad {
bottom: 6rem;
}

View File

@ -7,32 +7,50 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pokemon Rogue Battle</title> <title>Pokemon Rogue Battle</title>
<style type="text/css"> <link rel="stylesheet" type="text/css" href="index.css" />
@font-face {
font-family: 'emerald';
src: url('fonts/pokemon-emerald-pro.ttf') format('truetype');
}
@font-face {
font-family: 'pkmnems';
src: url('fonts/pkmnems.ttf') format('truetype');
}
body {
margin: 0;
background: #484050;
}
#app {
display: flex;
justify-content: center;
}
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<div id="touchControls">
<div id="dpad" class="unselectable">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72">
<path id="dpadUp" data-key="UP" d="M48,5.8C48,2.5,45.4,0,42,0H29.9C26.6,0,24,2.4,24,5.8V24h24V5.8z" />
<path id="dpadRight" data-key="RIGHT" d="M66.2,24H48v24h18.2c3.3,0,5.8-2.7,5.8-6V29.9C72,26.5,69.5,24,66.2,24z" />
<path id="dpadDown" data-key="DOWN" d="M24,66.3c0,3.3,2.6,5.7,5.9,5.7H42c3.3,0,6-2.4,6-5.7V48H24V66.3z" />
<path id="dpadLeft" data-key="LEFT" d="M5.7,24C2.4,24,0,26.5,0,29.9V42c0,3.3,2.3,6,5.7,6H24V24H5.7z" />
<rect id="dpadCenter" x="24" y="24" width="24" height="24" />
</svg>
</div>
<div id="apad" class="unselectable">
<div id="apadAction" class="apadCircBtn apadBtn" data-key="ACTION">
<text id="apadLabelAction" class="apadLabel">A</text>
</div>
<div id="apadCancel" class="apadCircBtn apadBtn" data-key="CANCEL">
<text id="apadLabelCancel" class="apadLabel">B</text>
</div>
<div id="apadMenu" class="apadRectBtn apadBtn" data-key="MENU">
<text id="apadLabelMenu" class="apadLabel apadLabelSmall">Menu</text>
</div>
<div class="apadBtnContainer">
<div id="apadCycleShiny" class="apadSqBtn apadBtn" data-key="CYCLE_SHINY">
<text class="apadLabel apadLabelSmall">R</text>
</div>
<div id="apadCycleForm" class="apadSqBtn apadBtn" data-key="CYCLE_FORM">
<text class="apadLabel apadLabelSmall">F</text>
</div>
<div id="apadCycleGender" class="apadSqBtn apadBtn" data-key="CYCLE_GENDER">
<text class="apadLabel apadLabelSmall">G</text>
</div>
<div id="apadCycleAbility" class="apadSqBtn apadBtn" data-key="CYCLE_ABILITY">
<text class="apadLabel apadLabelSmall">E</text>
</div>
</div>
</div>
</div>
<script type="module" src="src/main.ts"></script> <script type="module" src="src/main.ts"></script>
<script src="src/touch-controls.js"></script>
<script src="src/debug.js"></script> <script src="src/debug.js"></script>
</body> </body>

View File

@ -552,15 +552,19 @@ export default class BattleScene extends Phaser.Scene {
[Button.SPEED_UP]: [keyCodes.PLUS], [Button.SPEED_UP]: [keyCodes.PLUS],
[Button.SLOW_DOWN]: [keyCodes.MINUS] [Button.SLOW_DOWN]: [keyCodes.MINUS]
}; };
const mobileKeyConfig = {};
this.buttonKeys = []; this.buttonKeys = [];
for (let b of Utils.getEnumValues(Button)) { for (let b of Utils.getEnumValues(Button)) {
const keys: Phaser.Input.Keyboard.Key[] = []; const keys: Phaser.Input.Keyboard.Key[] = [];
if (keyConfig.hasOwnProperty(b)) { if (keyConfig.hasOwnProperty(b)) {
for (let k of keyConfig[b]) for (let k of keyConfig[b])
keys.push(this.input.keyboard.addKey(k)); keys.push(this.input.keyboard.addKey(k));
mobileKeyConfig[Button[b]] = keys[0];
} }
this.buttonKeys[b] = keys; this.buttonKeys[b] = keys;
} }
initTouchControls(mobileKeyConfig);
} }
getParty(): PlayerPokemon[] { getParty(): PlayerPokemon[] {

View File

@ -243,6 +243,8 @@ export class GameData {
} }
private loadSettings(): boolean { private loadSettings(): boolean {
Object.values(Setting).map(setting => setting as Setting).forEach(setting => setSetting(this.scene, setting, settingDefaults[setting]));
if (!localStorage.hasOwnProperty('settings')) if (!localStorage.hasOwnProperty('settings'))
return false; return false;

View File

@ -7,7 +7,8 @@ export enum Setting {
BGM_Volume = "BGM_VOLUME", BGM_Volume = "BGM_VOLUME",
SE_Volume = "SE_VOLUME", SE_Volume = "SE_VOLUME",
Show_Stats_on_Level_Up = "SHOW_LEVEL_UP_STATS", Show_Stats_on_Level_Up = "SHOW_LEVEL_UP_STATS",
Window_Type = "WINDOW_TYPE" Window_Type = "WINDOW_TYPE",
Touch_Controls = "TOUCH_CONTROLS"
} }
export interface SettingOptions { export interface SettingOptions {
@ -24,7 +25,8 @@ export const settingOptions: SettingOptions = {
[Setting.BGM_Volume]: new Array(11).fill(null).map((_, i) => i ? (i * 10).toString() : 'Mute'), [Setting.BGM_Volume]: new Array(11).fill(null).map((_, i) => i ? (i * 10).toString() : 'Mute'),
[Setting.SE_Volume]: new Array(11).fill(null).map((_, i) => i ? (i * 10).toString() : 'Mute'), [Setting.SE_Volume]: new Array(11).fill(null).map((_, i) => i ? (i * 10).toString() : 'Mute'),
[Setting.Show_Stats_on_Level_Up]: [ 'Off', 'On' ], [Setting.Show_Stats_on_Level_Up]: [ 'Off', 'On' ],
[Setting.Window_Type]: new Array(4).fill(null).map((_, i) => (i + 1).toString()) [Setting.Window_Type]: new Array(4).fill(null).map((_, i) => (i + 1).toString()),
[Setting.Touch_Controls]: [ 'Auto', 'Disabled' ]
}; };
export const settingDefaults: SettingDefaults = { export const settingDefaults: SettingDefaults = {
@ -33,7 +35,8 @@ export const settingDefaults: SettingDefaults = {
[Setting.BGM_Volume]: 10, [Setting.BGM_Volume]: 10,
[Setting.SE_Volume]: 10, [Setting.SE_Volume]: 10,
[Setting.Show_Stats_on_Level_Up]: 1, [Setting.Show_Stats_on_Level_Up]: 1,
[Setting.Window_Type]: 1 [Setting.Window_Type]: 1,
[Setting.Touch_Controls]: 0
}; };
export function setSetting(scene: BattleScene, setting: Setting, value: integer): boolean { export function setSetting(scene: BattleScene, setting: Setting, value: integer): boolean {
@ -59,6 +62,11 @@ export function setSetting(scene: BattleScene, setting: Setting, value: integer)
case Setting.Window_Type: case Setting.Window_Type:
updateWindowType(scene, parseInt(settingOptions[setting][value])); updateWindowType(scene, parseInt(settingOptions[setting][value]));
break; break;
case Setting.Touch_Controls:
const touchControls = document.getElementById('touchControls');
if (touchControls)
touchControls.classList.toggle('visible', settingOptions[setting][value] !== 'Disabled' && hasTouchscreen());
break;
} }
return true; return true;

106
src/touch-controls.js Normal file
View File

@ -0,0 +1,106 @@
const keys = new Map();
const keysDown = new Map();
let lastTouchedId;
function initTouchControls(buttonMap) {
for (const button of document.querySelectorAll('[data-key]')) {
// @ts-ignore
bindKey(button, button.dataset.key, buttonMap);
}
}
function hasTouchscreen() {
return window.matchMedia('(hover: none), (pointer: coarse)').matches;
}
/**
* Simulate a keyboard event on the canvas
*
* @param {string} eventType Type of the keyboard event
* @param {string} button Button to simulate
* @param {object} buttonMap Map of buttons to key objects
*/
function simulateKeyboardEvent(eventType, button, buttonMap) {
const key = buttonMap[button];
switch (eventType) {
case 'keydown':
key.onDown({});
break;
case 'keyup':
key.onUp({});
break;
}
}
/**
* Simulate a keyboard input from 'keydown' to 'keyup'
*
* @param {string} key Key to simulate
* @param {object} buttonMap Map of buttons to key objects
*/
function simulateKeyboardInput(key, buttonMap) {
simulateKeyboardEvent('keydown', key, buttonMap);
window.setTimeout(() => {
simulateKeyboardEvent('keyup', key, buttonMap);
}, 100);
}
/**
* Bind a node by a specific key to simulate on touch
*
* @param {*} node The node to bind a key to
* @param {string} key Key to simulate
* @param {object} buttonMap Map of buttons to key objects
*/
function bindKey(node, key, buttonMap) {
keys.set(node.id, key);
node.addEventListener('touchstart', event => {
event.preventDefault();
simulateKeyboardEvent('keydown', key, buttonMap);
keysDown.set(event.target.id, node.id);
node.classList.add('active');
});
node.addEventListener('touchend', event => {
event.preventDefault();
const pressedKey = keysDown.get(event.target.id);
if (pressedKey && keys.has(pressedKey)) {
const key = keys.get(pressedKey);
simulateKeyboardEvent('keyup', key, buttonMap);
}
keysDown.delete(event.target.id);
node.classList.remove('active');
if (lastTouchedId) {
document.getElementById(lastTouchedId).classList.remove('active');
}
});
// Inspired by https://github.com/pulsejet/mkxp-web/blob/262a2254b684567311c9f0e135ee29f6e8c3613e/extra/js/dpad.js
node.addEventListener('touchmove', event => {
const { target, clientX, clientY } = event.changedTouches[0];
const origTargetId = keysDown.get(target.id);
const nextTargetId = document.elementFromPoint(clientX, clientY).id;
if (origTargetId === nextTargetId)
return;
if (origTargetId) {
const key = keys.get(origTargetId);
simulateKeyboardEvent('keyup', key, buttonMap);
keysDown.delete(target.id);
document.getElementById(origTargetId).classList.remove('active');
}
if (keys.has(nextTargetId)) {
const key = keys.get(nextTargetId);
simulateKeyboardEvent('keydown', key, buttonMap);
keysDown.set(target.id, nextTargetId);
lastTouchedId = nextTargetId;
document.getElementById(nextTargetId).classList.add('active');
}
});
}

View File

@ -280,6 +280,9 @@ export default class UI extends Phaser.GameObjects.Container {
if (chainMode && this.mode && !clear) if (chainMode && this.mode && !clear)
this.modeChain.push(this.mode); this.modeChain.push(this.mode);
this.mode = mode; this.mode = mode;
const touchControls = document.getElementById('touchControls');
if (touchControls)
touchControls.dataset.uiMode = Mode[mode];
this.getHandler().show(args); this.getHandler().show(args);
} }
resolve(); resolve();

View File

@ -1,5 +1,12 @@
import BattleScene from "../battle-scene"; import BattleScene from "../battle-scene";
const windowTypeControlColors = {
0: [ '#706880', '#8888c8', '#484868' ],
1: [ '#d04028', '#e0a028', '#902008' ],
2: [ '#48b840', '#88d880', '#089040' ],
3: [ '#2068d0', '#80b0e0', '#104888' ]
};
export function addWindow(scene: BattleScene, x: number, y: number, width: number, height: number, mergeMaskTop?: boolean, mergeMaskLeft?: boolean, maskOffsetX?: number, maskOffsetY?: number): Phaser.GameObjects.NineSlice { export function addWindow(scene: BattleScene, x: number, y: number, width: number, height: number, mergeMaskTop?: boolean, mergeMaskLeft?: boolean, maskOffsetX?: number, maskOffsetY?: number): Phaser.GameObjects.NineSlice {
const window = scene.add.nineslice(x, y, `window_${scene.windowType}`, null, width, height, 8, 8, 8, 8); const window = scene.add.nineslice(x, y, `window_${scene.windowType}`, null, width, height, 8, 8, 8, 8);
window.setOrigin(0, 0); window.setOrigin(0, 0);
@ -36,6 +43,9 @@ export function updateWindowType(scene: BattleScene, windowTypeIndex: integer):
scene.windowType = windowTypeIndex; scene.windowType = windowTypeIndex;
const rootStyle = document.documentElement.style;
[ 'base', 'light', 'dark' ].map((k, i) => rootStyle.setProperty(`--color-${k}`, windowTypeControlColors[windowTypeIndex - 1][i]));
const windowKey = `window_${windowTypeIndex}`; const windowKey = `window_${windowTypeIndex}`;
for (let window of windowObjects) for (let window of windowObjects)