feat: add native cross-platform input actions

This commit is contained in:
Infuseting 2026-10-14 11:59:25 +02:00
parent 8be40e6a10
commit 75744338b8
5 changed files with 233 additions and 2 deletions

43
docs/input.md Normal file
View File

@ -0,0 +1,43 @@
# Contrôles multiplateformes
Le template utilise le [Input Action System Roblox](https://create.roblox.com/docs/input/input-action-system), disponible dans les types installés `@rbxts/types` 1.0.957. GameJoy 1.1.4 reste installé pour compatibilité, mais n'est pas utilisé : les instances natives `InputContext`, `InputAction` et `InputBinding` offrent directement le raccordement clavier, manette et bouton tactile. Ne pas enregistrer la même action dans les deux systèmes.
## Ajouter une action
Créer une `ButtonAction` côté client, dans le contrôleur propriétaire de la fonctionnalité :
```ts
const action = new ButtonAction({
name: "Interact",
label: "Interact",
keyboard: [Enum.KeyCode.E],
gamepad: [Enum.KeyCode.ButtonX],
vr: [Enum.KeyCode.ButtonX],
onPressed: () => this.requestInteraction(),
onReleased: () => this.stopInteraction(),
});
this.trove.add(action);
action.setEnabled(true);
```
Importer `ButtonAction` depuis `client/input/button-action`. Chaque action possède son contexte, désactivé à la création, de priorité 2000 par défaut. Le contexte consomme ses touches avant les contextes de priorité inférieure. Choisir des touches distinctes pour les actions simultanément actives ; éviter les touches Roblox réservées. `priority` permet de donner la priorité à une action de menu.
Les callbacks reçoivent une transition logique, indépendamment du périphérique. `onReleased` est également appelé si une action maintenue est désactivée, détruite ou perd le focus de fenêtre. Les actions sont suspendues pendant la saisie dans un `TextBox`, sauf `allowWhileTyping: true`. Les changements de clavier/manette ne nécessitent pas de réenregistrement. Les bindings VR remplacent ceux de la manette quand `VREnabled` change ; sans option `vr`, les bindings manette sont conservés.
Pour le tactile, rendre un bouton React puis le transmettre par `ref={(button) => action.setTouchButton(button)}`. Le binding `UIButton` déclenche l'action sans callback `Activated` supplémentaire. `ActionTouchButton` fournit un bouton de 112 × 48 dans une zone sûre ; pour plusieurs actions, définir leur disposition dans l'interface de la fonctionnalité. Démonter le bouton efface sa référence. Détruire l'action avec le Trove du contrôleur.
L'instance publique `action.input` permet de consulter `GetState()` et `PreferredBinding`, ou d'écouter les changements de ce dernier pour afficher des indications adaptées au périphérique. Cette abstraction couvre les actions booléennes ; utiliser les types natifs `Direction1D/2D/3D` pour de futures actions analogiques.
## Intégration actuelle
`CmdrController` utilise une action `ToggleConsole` : F2 sur PC, ButtonY sur manette et contrôleurs VR compatibles, bouton « Console » sur les appareils tactiles hors VR. L'ancien raccourci Cmdr est désactivé pour éviter un double déclenchement. L'action et son bouton sont accessibles uniquement lorsque le serveur publie `CmdrEnabled=true`. Les autorisations des commandes restent contrôlées côté serveur.
Ce raccordement ouvre ou ferme Cmdr ; son interface de saisie reste celle de la bibliothèque. Il ne fournit pas un clavier virtuel VR ou une interface d'administration spécialement conçue pour la manette. Les déplacements, le saut, la caméra et le suivi des mains/tête restent les contrôles Roblox standard. Une interaction VR spatiale doit utiliser `VRService` en complément des actions de boutons.
## Vérification
Vérifié via Roblox Studio MCP : ouverture F2, déclenchement du composant tactile par clic simulé, transitions appui/relâchement, suppression des transitions répétées, annulation à la désactivation, blocage d'une action désactivée, raccordement/détachement du bouton, destruction et révocation d'accès serveur. Les transitions ont été exercées avec un binding de test scriptable, retiré après vérification. Pour le test clavier virtuel, le contexte a été réactivé pendant que la fenêtre Studio était sans focus.
Les tests ne remplacent pas les essais sur une manette, un écran tactile et un casque VR physiques. La projection spatiale d'interfaces VR, les gestes et le remappage persistant ne sont pas fournis par cette couche.
Sources consultées : [Input Action System](https://create.roblox.com/docs/input/input-action-system), [InputContext](https://create.roblox.com/docs/reference/engine/classes/InputContext), [GameJoy](https://github.com/Rimuy/GameJoy), README local GameJoy 1.1.4 et déclarations locales des API Roblox.

View File

@ -8,7 +8,8 @@
"crownstudio-roblox-ts-template:out/client/controllers/welcome-chat-controller@WelcomeChatController": "client/controllers/welcome-chat-controller@WelcomeChatController", "crownstudio-roblox-ts-template:out/client/controllers/welcome-chat-controller@WelcomeChatController": "client/controllers/welcome-chat-controller@WelcomeChatController",
"crownstudio-roblox-ts-template:out/server/services/admin-message-service@AdminMessageService": "server/services/admin-message-service@AdminMessageService", "crownstudio-roblox-ts-template:out/server/services/admin-message-service@AdminMessageService": "server/services/admin-message-service@AdminMessageService",
"crownstudio-roblox-ts-template:out/shared/admin-message@AdminMessageEvents": "shared/admin-message@AdminMessageEvents", "crownstudio-roblox-ts-template:out/shared/admin-message@AdminMessageEvents": "shared/admin-message@AdminMessageEvents",
"crownstudio-roblox-ts-template:out/client/controllers/admin-message-controller@AdminMessageController": "client/controllers/admin-message-controller@AdminMessageController" "crownstudio-roblox-ts-template:out/client/controllers/admin-message-controller@AdminMessageController": "client/controllers/admin-message-controller@AdminMessageController",
"crownstudio-roblox-ts-template:out/client/input/button-action@ButtonAction": "client/input/button-action@ButtonAction"
}, },
"metadata": {} "metadata": {}
} }

View File

@ -2,17 +2,51 @@ import { Controller, OnStart } from "@flamework/core";
import { CmdrClient } from "@rbxts/cmdr"; import { CmdrClient } from "@rbxts/cmdr";
import { Players } from "@rbxts/services"; import { Players } from "@rbxts/services";
import { Trove } from "@rbxts/trove"; import { Trove } from "@rbxts/trove";
import React from "@rbxts/react";
import { createRoot } from "@rbxts/react-roblox";
import { UserInputService, VRService } from "@rbxts/services";
import { ButtonAction } from "client/input/button-action";
import { ActionTouchButton } from "client/ui/action-touch-button";
import { CMDR_ACCESS_ATTRIBUTE } from "shared/cmdr"; import { CMDR_ACCESS_ATTRIBUTE } from "shared/cmdr";
import { TEMPLATE_NAME } from "shared/template-name"; import { TEMPLATE_NAME } from "shared/template-name";
@Controller() @Controller()
export class CmdrController implements OnStart { export class CmdrController implements OnStart {
private readonly trove = new Trove(); private readonly trove = new Trove();
private action?: ButtonAction;
private renderTouch?: () => void;
public onStart(): void { public onStart(): void {
CmdrClient.SetActivationKeys([Enum.KeyCode.F2]); CmdrClient.SetActivationKeys([]);
CmdrClient.SetPlaceName(TEMPLATE_NAME); CmdrClient.SetPlaceName(TEMPLATE_NAME);
CmdrClient.SetActivationUnlocksMouse(true); CmdrClient.SetActivationUnlocksMouse(true);
this.action = new ButtonAction({
name: "ToggleConsole",
label: "Console",
keyboard: [Enum.KeyCode.F2],
gamepad: [Enum.KeyCode.ButtonY],
vr: [Enum.KeyCode.ButtonY],
allowWhileTyping: true,
onPressed: () => CmdrClient.Toggle(),
});
this.trove.add(this.action);
const mount = new Instance("Folder");
mount.Name = "ConsoleInputRoot";
mount.Parent = Players.LocalPlayer.WaitForChild("PlayerGui");
const root = createRoot(mount);
this.trove.add(mount);
this.trove.add(() => root.unmount());
this.renderTouch = () => {
const visible =
Players.LocalPlayer.GetAttribute(CMDR_ACCESS_ATTRIBUTE) === true &&
UserInputService.TouchEnabled &&
!VRService.VREnabled;
root.render(
visible ? React.createElement(ActionTouchButton, { action: this.action! }) : undefined,
);
};
this.trove.connect(UserInputService.GetPropertyChangedSignal("TouchEnabled"), this.renderTouch);
this.trove.connect(VRService.GetPropertyChangedSignal("VREnabled"), this.renderTouch);
this.trove.connect(Players.LocalPlayer.GetAttributeChangedSignal(CMDR_ACCESS_ATTRIBUTE), () => this.trove.connect(Players.LocalPlayer.GetAttributeChangedSignal(CMDR_ACCESS_ATTRIBUTE), () =>
this.updateAccess(), this.updateAccess(),
); );
@ -23,6 +57,8 @@ export class CmdrController implements OnStart {
private updateAccess(): void { private updateAccess(): void {
const enabled = Players.LocalPlayer.GetAttribute(CMDR_ACCESS_ATTRIBUTE) === true; const enabled = Players.LocalPlayer.GetAttribute(CMDR_ACCESS_ATTRIBUTE) === true;
CmdrClient.SetEnabled(enabled); CmdrClient.SetEnabled(enabled);
this.action?.setEnabled(enabled);
this.renderTouch?.();
if (!enabled) { if (!enabled) {
CmdrClient.Hide(); CmdrClient.Hide();
} }

View File

@ -0,0 +1,127 @@
import { Players, UserInputService, VRService } from "@rbxts/services";
import { Trove } from "@rbxts/trove";
export interface ButtonActionOptions {
readonly name: string;
readonly label: string;
readonly keyboard: readonly Enum.KeyCode[];
readonly gamepad: readonly Enum.KeyCode[];
/** VR controllers expose their buttons as gamepad KeyCodes. */
readonly vr?: readonly Enum.KeyCode[];
readonly priority?: number;
readonly allowWhileTyping?: boolean;
readonly onPressed: () => void;
readonly onReleased?: () => void;
}
/** One logical button action, independently enabled and owned by its feature. */
export class ButtonAction {
public readonly input = new Instance("InputAction");
private readonly context = new Instance("InputContext");
private readonly touch = new Instance("InputBinding");
private readonly trove = new Trove();
private readonly controllerBindings = new Trove();
private enabled = false;
private focused = true;
private pressed = false;
public constructor(private readonly options: ButtonActionOptions) {
this.context.Name = options.name;
this.context.Priority = options.priority ?? 2000;
this.context.Sink = true;
this.context.Enabled = false;
this.input.Name = options.name;
this.input.DisplayName = options.label;
this.input.Type = Enum.InputActionType.Bool;
this.input.Parent = this.context;
this.touch.Name = "Touch";
this.touch.Parent = this.input;
for (const key of options.keyboard) this.bindKey(key, "Keyboard");
this.updateControllerBindings();
this.trove.add(this.context);
this.trove.add(this.controllerBindings);
this.trove.connect(VRService.GetPropertyChangedSignal("VREnabled"), () =>
this.updateControllerBindings(),
);
this.trove.connect(this.input.Pressed, () => {
if (!this.context.Enabled || this.pressed) return;
this.pressed = true;
options.onPressed();
});
this.trove.connect(this.input.Released, () => this.release());
// Native keyboard bindings are filtered while a TextBox owns keyboard focus.
// Opt-in shortcuts such as the console toggle must still work during typing.
if (options.allowWhileTyping === true) {
this.trove.connect(UserInputService.InputBegan, (input) => {
if (
this.context.Enabled &&
!this.pressed &&
UserInputService.GetFocusedTextBox() !== undefined &&
options.keyboard.includes(input.KeyCode)
) {
this.pressed = true;
options.onPressed();
}
});
this.trove.connect(UserInputService.InputEnded, (input) => {
if (options.keyboard.includes(input.KeyCode)) this.release();
});
}
this.trove.connect(UserInputService.TextBoxFocused, () => this.updateEnabled());
this.trove.connect(UserInputService.TextBoxFocusReleased, () => this.updateEnabled());
this.trove.connect(UserInputService.WindowFocusReleased, () => {
this.focused = false;
this.updateEnabled();
});
this.trove.connect(UserInputService.WindowFocused, () => {
this.focused = true;
this.updateEnabled();
});
this.context.Parent = Players.LocalPlayer.WaitForChild("PlayerScripts");
}
public setEnabled(enabled: boolean): void {
this.enabled = enabled;
this.updateEnabled();
}
/** Connect a React-owned touch button without a second input callback. */
public setTouchButton(button: GuiButton | undefined): void {
this.touch.UIButton = button;
}
public destroy(): void {
this.setEnabled(false);
this.trove.destroy();
}
private bindKey(key: Enum.KeyCode, device: string): InputBinding {
const binding = new Instance("InputBinding");
binding.Name = `${device}_${key.Name}`;
binding.KeyCode = key;
binding.Parent = this.input;
return binding;
}
private updateControllerBindings(): void {
this.release();
this.controllerBindings.clean();
const keys = VRService.VREnabled
? (this.options.vr ?? this.options.gamepad)
: this.options.gamepad;
for (const key of keys) this.controllerBindings.add(this.bindKey(key, "Controller"));
}
private updateEnabled(): void {
const typing = UserInputService.GetFocusedTextBox() !== undefined;
this.context.Enabled =
this.enabled && this.focused && (this.options.allowWhileTyping === true || !typing);
if (!this.context.Enabled) this.release();
}
private release(): void {
if (!this.pressed) return;
this.pressed = false;
this.options.onReleased?.();
}
}

View File

@ -0,0 +1,24 @@
import React from "@rbxts/react";
import { ButtonAction } from "client/input/button-action";
export function ActionTouchButton({ action }: { readonly action: ButtonAction }): React.Element {
return (
<screengui ResetOnSpawn={false} ScreenInsets={Enum.ScreenInsets.CoreUISafeInsets}>
<textbutton
key="ActionButton"
ref={(button) => action.setTouchButton(button)}
AnchorPoint={new Vector2(1, 0)}
Position={UDim2.fromScale(0.97, 0.15)}
Size={UDim2.fromOffset(112, 48)}
BackgroundColor3={Color3.fromRGB(30, 35, 46)}
TextColor3={Color3.fromRGB(255, 255, 255)}
Text={action.input.DisplayName}
TextSize={18}
Font={Enum.Font.GothamMedium}
Selectable={false}
>
<uicorner CornerRadius={new UDim(0, 12)} />
</textbutton>
</screengui>
);
}