Skip to content

Commit

Permalink
MISC: Add key binding feature
Browse files Browse the repository at this point in the history
  • Loading branch information
catloversg committed Dec 8, 2024
1 parent 933ec96 commit 83dc6fc
Show file tree
Hide file tree
Showing 11 changed files with 867 additions and 86 deletions.
6 changes: 6 additions & 0 deletions src/@types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,9 @@ module "monaco-vim" {
};
};
}

declare interface Navigator {
keyboard?: {
getLayoutMap?: () => Promise<Map<string, string>>;
};
}
15 changes: 13 additions & 2 deletions src/GameOptions/ui/GameOptionsRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Box, Container, Typography } from "@mui/material";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { GameOptionsSidebar } from "./GameOptionsSidebar";
import { GameplayPage } from "./GameplayPage";
import { InterfacePage } from "./InterfacePage";
import { MiscPage } from "./MiscPage";
import { NumericDisplayPage } from "./NumericDisplayOptions";
import { RemoteAPIPage } from "./RemoteAPIPage";
import { SystemPage } from "./SystemPage";
import { KeyBindingPage } from "./KeyBindingPage";

interface IProps {
save: () => void;
Expand All @@ -15,14 +16,24 @@ interface IProps {
softReset: () => void;
reactivateTutorial: () => void;
}
export type OptionsTabName = "System" | "Interface" | "Numeric Display" | "Gameplay" | "Misc" | "Remote API";

export type OptionsTabName =
| "System"
| "Interface"
| "Numeric Display"
| "Gameplay"
| "Misc"
| "Remote API"
| "Key Binding";

const tabs: Record<OptionsTabName, React.ReactNode> = {
System: <SystemPage />,
Interface: <InterfacePage />,
"Numeric Display": <NumericDisplayPage />,
Gameplay: <GameplayPage />,
Misc: <MiscPage />,
"Remote API": <RemoteAPIPage />,
"Key Binding": <KeyBindingPage />,
};

export function GameOptionsRoot(props: IProps): React.ReactElement {
Expand Down
1 change: 1 addition & 0 deletions src/GameOptions/ui/GameOptionsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export const GameOptionsSidebar = (props: IProps): React.ReactElement => {
<SideBarTab sideBarProps={props} tabName="Numeric Display" />
<SideBarTab sideBarProps={props} tabName="Misc" />
<SideBarTab sideBarProps={props} tabName="Remote API" />
<SideBarTab sideBarProps={props} tabName="Key Binding" />
</List>
</Paper>
<Box
Expand Down
308 changes: 308 additions & 0 deletions src/GameOptions/ui/KeyBindingPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
import { Button, Typography } from "@mui/material";
import React, { useCallback, useEffect, useState } from "react";
import { Settings } from "../../Settings/Settings";
import { getRecordKeys } from "../../Types/Record";
import { Modal } from "../../ui/React/Modal";
import { SimplePage } from "../../ui/Router";
import { KEYCODE } from "../../utils/helpers/keyCodes";
import {
convertKeyboardEventToKeyCombination,
defaultKeyBinding,
determineKeyBindingTypes,
getKeyCombination,
isKeyCombinationPressed,
isSpoilerKeyBindingType,
KeyBindingEvents,
KeyBindingEventType,
parseKeyCombinationToString,
SpoilerKeyBindingTypes,
type KeyBindingType,
type KeyCombination,
} from "../../utils/KeyBindingUtils";
import { GameOptionsPage } from "./GameOptionsPage";
import { dialogBoxCreate } from "../../ui/React/DialogBox";
import { knowAboutBitverse } from "../../BitNode/BitNodeUtils";

function determineConflictKeys(
keyBindingType: KeyBindingType,
isPrimary: boolean,
newCombination: KeyCombination,
): Set<string> {
const conflicts: Set<string> = determineKeyBindingTypes(Settings.KeyBindings, newCombination);
// Check if the new combination is the same as the current key binding.
if (conflicts.has(keyBindingType)) {
const currentKeyBinding = getKeyCombination(Settings.KeyBindings, keyBindingType, isPrimary);
if (
currentKeyBinding &&
currentKeyBinding.control === newCombination.control &&
currentKeyBinding.alt === newCombination.alt &&
currentKeyBinding.shift === newCombination.shift &&
currentKeyBinding.meta === newCombination.meta &&
currentKeyBinding.code === newCombination.code
) {
conflicts.delete(keyBindingType);
}
}
// Common single-key hotkeys.
if (
isKeyCombinationPressed(newCombination, { code: KEYCODE.ESC }) ||
isKeyCombinationPressed(newCombination, { code: KEYCODE.ENTER }) ||
isKeyCombinationPressed(newCombination, { code: KEYCODE.NUMPAD_ENTER }) ||
isKeyCombinationPressed(newCombination, { code: KEYCODE.TAB })
) {
conflicts.add("Common hotkeys");
}
// Copy - Paste - Cut
if (window.navigator.userAgent.includes("Mac")) {
if (
isKeyCombinationPressed(newCombination, { meta: true, code: KEYCODE.C }) ||
isKeyCombinationPressed(newCombination, { meta: true, code: KEYCODE.V }) ||
isKeyCombinationPressed(newCombination, { meta: true, code: KEYCODE.X })
) {
conflicts.add("Common hotkeys");
}
} else {
if (
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.C }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.V }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.X })
) {
conflicts.add("Common hotkeys");
}
}
// Terminal-ClearScreen
if (isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.L })) {
conflicts.add("Terminal-ClearScreen");
}
// Bash hotkeys
if (
Settings.EnableBashHotkeys &&
(isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.M }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.P }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.C }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.A }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.E }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.B }) ||
isKeyCombinationPressed(newCombination, { alt: true, code: KEYCODE.B }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.F }) ||
isKeyCombinationPressed(newCombination, { alt: true, code: KEYCODE.F }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.H }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.D }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.W }) ||
isKeyCombinationPressed(newCombination, { alt: true, code: KEYCODE.D }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.U }) ||
isKeyCombinationPressed(newCombination, { control: true, code: KEYCODE.K }))
) {
conflicts.add("Bash hotkeys");
}
// Remove spoilers in the list
if (!knowAboutBitverse()) {
for (const conflict of conflicts) {
if (!isSpoilerKeyBindingType(conflict)) {
continue;
}
conflicts.delete(conflict);
conflicts.add("Endgame content");
}
}
return conflicts;
}

function SettingUpKeyBindingModal({
open,
onClose,
keyBindingType,
isPrimary,
}: {
open: boolean;
onClose: () => void;
keyBindingType: KeyBindingType;
isPrimary: boolean;
}): React.ReactElement {
const [combination, setCombination] = useState(getKeyCombination(Settings.KeyBindings, keyBindingType, isPrimary));
const [conflicts, setConflicts] = useState(
combination ? determineConflictKeys(keyBindingType, isPrimary, combination) : new Set<string>(),
);
const handler = useCallback(
(event: KeyboardEvent) => {
event.preventDefault();
if (event.getModifierState(event.key)) {
return;
}

const newCombination = convertKeyboardEventToKeyCombination(event);
setCombination(newCombination);
setConflicts(determineConflictKeys(keyBindingType, isPrimary, newCombination));
},
[keyBindingType, isPrimary],
);

useEffect(() => {
const currentKeyCombination = getKeyCombination(Settings.KeyBindings, keyBindingType, isPrimary);
setCombination(currentKeyCombination);
setConflicts(
currentKeyCombination
? determineConflictKeys(keyBindingType, isPrimary, currentKeyCombination)
: new Set<string>(),
);
// Add/remove handler and emit an event that notifies subscribers if the player is setting up key bindings.
if (open) {
document.addEventListener("keydown", handler);
KeyBindingEvents.emit(KeyBindingEventType.StartSettingUp);
} else {
document.removeEventListener("keydown", handler);
KeyBindingEvents.emit(KeyBindingEventType.StopSettingUp);
}
}, [open, keyBindingType, isPrimary, handler]);

const onClickClear = () => {
setCombination(null);
setConflicts(new Set());
};
const onClickDefault = () => {
const defaultKeyCombination = getKeyCombination(defaultKeyBinding, keyBindingType, true);
setCombination(defaultKeyCombination);
setConflicts(
defaultKeyCombination
? determineConflictKeys(keyBindingType, isPrimary, defaultKeyCombination)
: new Set<string>(),
);
};
const onClickOK = () => {
Settings.KeyBindings[keyBindingType][isPrimary ? 0 : 1] = combination;
onClose();
};
const onClickCancel = () => {
onClose();
};

return (
<Modal open={open} onClose={onClose}>
<div style={{ textAlign: "center" }}>
<Typography style={{ padding: "10px 20px" }}>Press the key you would like to use</Typography>
<Typography
minHeight="100px"
display="flex"
alignItems="center"
justifyContent="center"
margin="10px 0"
border="1px solid"
>
{parseKeyCombinationToString(combination)}
</Typography>
<Typography style={{ margin: "15px 0" }}>
{conflicts.size === 0 ? "No conflicts detected" : `Conflicts: ${[...conflicts]}`}
</Typography>
<div style={{ margin: "10px 0" }}>
<Button style={{ minWidth: "100px" }} onClick={onClickClear}>
Clear
</Button>
<Button style={{ marginLeft: "10px", minWidth: "100px" }} onClick={onClickDefault}>
Default
</Button>
</div>
<div>
<Button style={{ minWidth: "100px" }} onClick={onClickOK}>
OK
</Button>
<Button style={{ marginLeft: "10px", minWidth: "100px" }} onClick={onClickCancel}>
Cancel
</Button>
</div>
</div>
</Modal>
);
}

export function KeyBindingPage(): React.ReactElement {
const [popupOpen, setPopupOpen] = useState(false);
const [keyBindingType, setKeyBindingType] = useState<KeyBindingType>(SimplePage.Options);
const [isPrimary, setIsPrimary] = useState(true);

const showModal = (keyBindingType: KeyBindingType, isPrimary: boolean) => {
setPopupOpen(true);
setKeyBindingType(keyBindingType);
setIsPrimary(isPrimary);
};

const onClickHowToUse = () => {
dialogBoxCreate(
<>
<Typography>
You can assign up to 2 key combinations per "action". If a key combination is assigned to many actions,
pressing that key combination will perform all those actions.
</Typography>
<br />
<Typography>
Some key combinations cannot be used. Your OS and browsers usually have some built-in key bindings that cannot
be overridden. For example, on Windows, Windows+R always opens the "Run" dialog.
</Typography>
<br />
<Typography>
When you set up key bindings, the list of conflicts may contain "Endgame content". It means that the key
combination is currently used for features that you have not unlocked.
</Typography>
<br />
<Typography>
On non-Apple keyboards, the "Windows" key (other names: win, start, super, meta, etc.) is shown as ⊞. On Apple
keyboards, the command key is shown as ⌘.
</Typography>
<br />
<Typography>
Do NOT use the right Alt key and the AltGr key, especially if you don't use the US keyboard layout. On many
keyboard layouts, those keys cause problems with key bindings.
</Typography>
</>,
);
};
knowAboutBitverse();

return (
<GameOptionsPage title="Key Binding">
<Button onClick={onClickHowToUse}>How to use</Button>
<br />
<table>
<tbody>
{getRecordKeys(Settings.KeyBindings)
.filter(
(keyBindingType) =>
knowAboutBitverse() || !(SpoilerKeyBindingTypes as unknown as string[]).includes(keyBindingType),
)
.map((keyBindingType) => (
<tr key={keyBindingType}>
<td>
<Typography minWidth="250px">{keyBindingType}</Typography>
</td>
<td>
<Button sx={{ minWidth: "250px" }} onClick={() => showModal(keyBindingType, true)}>
{Settings.KeyBindings[keyBindingType][0] ? (
parseKeyCombinationToString(Settings.KeyBindings[keyBindingType][0])
) : (
// Use a non-breaking space to make the button fit to the parent td element.
<>&nbsp;</>
)}
</Button>
</td>
<td>
<Button sx={{ minWidth: "250px" }} onClick={() => showModal(keyBindingType, false)}>
{Settings.KeyBindings[keyBindingType][1] ? (
parseKeyCombinationToString(Settings.KeyBindings[keyBindingType][1])
) : (
// Use a non-breaking space to make the button fit to the parent td element.
<>&nbsp;</>
)}
</Button>
</td>
</tr>
))}
</tbody>
</table>
<SettingUpKeyBindingModal
open={popupOpen}
onClose={() => setPopupOpen(false)}
keyBindingType={keyBindingType}
isPrimary={isPrimary}
/>
</GameOptionsPage>
);
}
Loading

0 comments on commit 83dc6fc

Please sign in to comment.