diff --git a/.agents/skills/roblox-keybinds/SKILL.md b/.agents/skills/roblox-keybinds/SKILL.md new file mode 100644 index 0000000..f61977a --- /dev/null +++ b/.agents/skills/roblox-keybinds/SKILL.md @@ -0,0 +1,76 @@ +--- +name: roblox-keybinds +description: Ajouter ou modifier les keybinds et actions de boutons de ce projet Roblox, avec raccordement clavier/souris, manette, tactile et VR via ButtonAction. Utiliser pour les raccourcis, interactions, actions maintenues et conflits de touches ; pas pour les commandes Cmdr seules ni le suivi spatial VR. +--- + +# Ajouter des keybinds au projet + +## Partir de la couche existante + +Lire les fichiers depuis la racine du dépôt : + +- `src/client/input/button-action.ts` : API réellement disponible et gestion du cycle de vie. +- `docs/input.md` : conventions et limites de la couche multiplateforme. +- Le contrôleur propriétaire de l'action ; `src/client/controllers/cmdr-controller.ts` fournit un exemple de raccordement complet. +- `src/client/ui/action-touch-button.tsx` si un bouton tactile est nécessaire. + +Réutiliser `ButtonAction` pour les actions booléennes. GameJoy peut être installé sans être utilisé : ne pas enregistrer une seconde fois l'action dans GameJoy, ContextActionService, Cmdr ou un listener global. Un nouveau raccourci doit déclencher la logique existante de la fonctionnalité, pas une copie de cette logique. + +Pour une action analogique, une combinaison de touches ou un remappage persistant, vérifier les besoins et les API natives actuelles avant d'étendre l'abstraction. `ButtonAction` ne fournit pas ces fonctionnalités ; les éléments d'un tableau de touches sont des alternatives, pas une combinaison. + +## Définir puis raccorder l'action + +Déterminer le comportement (appui unique ou maintien), les conditions d'activation et les bindings par périphérique. Rechercher les bindings existants avant de choisir les touches ; éviter les entrées Roblox réservées et les conflits entre contextes actifs. Conserver les déplacements et la caméra Roblox, sauf demande explicite. + +Créer l'action dans le contrôleur client propriétaire : + +```ts +import { ButtonAction } from "client/input/button-action"; + +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); +``` + +Adapter les callbacks au comportement demandé ; omettre `onReleased` pour une action ponctuelle. Les noms et textes de jeu restent en anglais. Les tableaux `keyboard` et `gamepad` sont requis. Une entrée souris utilise un `Enum.KeyCode` souris pris en charge par les types et le moteur installés. + +- L'action démarre désactivée. Appeler `setEnabled` selon l'état réel du jeu, des menus et des permissions, puis la détruire via le Trove propriétaire. +- Chaque action possède un `InputContext` avec `Sink=true` et une priorité par défaut de 2000. Une priorité différente doit correspondre à un besoin de précédence réel ; elle ne règle pas un conflit entre deux actions de même priorité. +- `onReleased` sert aussi à annuler un maintien lors d'une désactivation, d'une perte de focus ou d'une destruction. Prévoir un arrêt sans effet indésirable, même si l'action n'atteint pas sa durée normale. +- Garder le blocage pendant la saisie par défaut. Réserver `allowWhileTyping: true` aux raccourcis qui doivent fonctionner dans un `TextBox`, comme fermer la console. Le contournement clavier est déjà implémenté dans `ButtonAction` : ne pas ajouter un second listener dans le contrôleur. +- Les boutons VR utilisent des KeyCodes de contrôleur. `vr` remplace les bindings manette en VR ; sans cette option, la manette sert de repli. Ne pas présenter cela comme une prise en charge des gestes, du suivi des mains ou d'une interface spatiale. +- Une action ayant des conséquences de gameplay envoie une intention au serveur, qui conserve les validations et autorisations. Un binding client n'est pas une permission. + +## Interface tactile et indications + +Raccorder le bouton React avec `ref={(button) => action.setTouchButton(button)}`. Laisser le démontage transmettre `undefined`. Ne pas ajouter un `Activated` déclenchant aussi l'action : `InputBinding.UIButton` est déjà responsable de l'entrée. + +Réutiliser `ActionTouchButton` pour un bouton isolé si sa position convient. Pour plusieurs actions, organiser leurs boutons dans l'interface de la fonctionnalité afin d'éviter leur superposition et les contrôles Roblox. Préserver les zones sûres et des cibles tactiles d'au moins 44 × 44. Afficher les boutons selon les capacités tactiles et l'état d'activation ; la configuration actuelle de Cmdr les masque en VR et sans permission. + +Utiliser `action.input.PreferredBinding` et son signal de changement pour des indications adaptatives si la fonctionnalité en affiche. Ne pas figer toutes les indications au périphérique détecté au démarrage. Ne pas exposer une indication clavier comme seule manière de découvrir une action mobile. + +## Vérifier le comportement + +Pour une modification de code, exécuter les vérifications du projet (`pnpm check`, `pnpm format:check`, `pnpm build`) et distinguer les échecs préexistants de ceux introduits. Si Rojo manque au PATH alors qu'il est installé, ajouter son dossier au PATH de la session plutôt que modifier la configuration du projet. + +Dans Roblox Studio via MCP, exercer les chemins affectés : + +- Appui unique, relâchement et maintien selon l'action ; absence de double déclenchement. +- Activation/désactivation et annulation pendant un maintien ; saisie dans un `TextBox` et perte/reprise de focus. +- Bouton tactile raccordé à la même action ; montage/démontage et absence de superposition. +- Changement de périphérique et bindings manette/VR lorsque les moyens de test le permettent. +- Validation serveur et révocation d'accès si l'action est protégée. + +Inspecter Output après les tests, corriger les erreurs liées au changement et rejouer le scénario corrigé. Retirer les instances et scripts de test temporaires. Un binding `Scriptable` peut tester les transitions logiques avec `InputBinding:Fire()` ; il ne prouve pas qu'une entrée matérielle ou tactile fonctionne. Laisser les signaux différés s'exécuter avant les assertions. + +Les entrées virtuelles MCP peuvent laisser Studio sans focus. Si le test réactive artificiellement un contexte, le signaler et tester séparément le comportement de focus ; ne pas modifier la logique de production pour contourner cette limite de test. Distinguer un clic simulé sur un bouton d'un essai sur écran tactile, et un mapping VR d'un essai avec casque. + +Terminer selon `AGENTS.md` : rapporter les vérifications et limites, puis inclure le commit des changements vérifiés. Actualiser `docs/input.md` si l'API ou les conventions changent. diff --git a/docs/audio.md b/docs/audio.md new file mode 100644 index 0000000..14f4cc0 --- /dev/null +++ b/docs/audio.md @@ -0,0 +1,36 @@ +# Audio + +Le système est local à chaque joueur : `AudioController` expose `mixer` par injection Flamework. Le catalogue `src/client/audio/catalog.ts` sépare les assets `SFX` et `Music`. Il est volontairement vide ; ajouter des assets audio autorisés pour l'expérience avant de les utiliser. + +```ts +// Inside a Flamework controller with AudioController injected: +this.audioController.mixer.playSfx("Confirm"); +this.audioController.mixer.playMusic("Lobby"); +this.audioController.mixer.stopMusic(); +``` + +Chaque SFX crée une lecture indépendante, ce qui permet leur superposition. La fin du son ou le handle `stop()` libère le lecteur, son Wire et sa connexion. Une seule musique joue à la fois, en boucle ; sélectionner à nouveau la même piste ne la redémarre pas. Une clé inconnue retourne `undefined` sans interrompre la musique courante. Le contrôleur libère tous les lecteurs et abonnements à sa destruction. + +## Raccordement de Settings + +Le producer Reflex `audioSettings` est la source des réglages pour l'interface et le mixer. Les abonnements sont appliqués à la frame suivante. Utiliser `getState()` pour lire, `subscribe()` pour observer (et appeler sa fonction de désabonnement au démontage), et ces actions pour modifier : + +```ts +import { audioSettings } from "client/audio/settings"; + +audioSettings.setVolume("SFX", 0.8); +audioSettings.setVolume("Music", 0.4); +audioSettings.setVolume("Master", 1); +audioSettings.setMuted("Music", true); +audioSettings.reset(); +``` + +Les sliders utilisent des valeurs entre 0 et 1. Les valeurs finies sont bornées ; NaN et les infinis sont ignorés. Le mute conserve la valeur du slider et laisse la musique avancer. Le volume audible vaut `clip.volume × channel.volume × Master.volume`, ou zéro si le canal ou Master est muet. Les sons démarrés après un changement utilisent également ces réglages. Les réglages durent la session ; une future persistance peut charger et sauvegarder les valeurs via ces mêmes actions. Aucune page Settings ni sauvegarde serveur n'est ajoutée ici. + +## Choix technique + +- [Objets audio Roblox](https://create.roblox.com/docs/audio/objects) : `AudioPlayer → AudioFader (SFX/Music) → AudioFader (Master) → AudioDeviceOutput`. Ce graphe préserve le mixage propre à chaque asset et suit la recommandation actuelle du moteur. +- `@rbxts/reflex` **4.3.1** ([documentation](https://github.com/littensy/reflex)) gère l'état réactif ; `@rbxts/trove` **1.3.0** ([documentation](https://github.com/Sleitnick/rbxts-trove)) gère le nettoyage. Les déclarations installées ont été contrôlées. +- `@rbxts/sound-manager` **2.3.4** ([mainteneur](https://github.com/xelnae-r/Sound-Manager)) a été comparé au moteur et son code installé inspecté. Ses registres non spatiaux utilisent `Sound`, désormais déconseillé par Roblox, et `setCategoryVolume` remplace les volumes des assets. Ses méthodes globales parcourent également les registres voisins. Il reste installé pour compatibilité avec le template mais n'est pas utilisé pour ce nouveau système. + +Le graphe actuel couvre la musique et les effets 2D (interface, notifications). Les sons spatiaux nécessiteront un routage avec `AudioEmitter` et `AudioListener` conservant ces mêmes réglages de canaux. diff --git a/docs/input.md b/docs/input.md new file mode 100644 index 0000000..c9cfe0b --- /dev/null +++ b/docs/input.md @@ -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. diff --git a/flamework.build b/flamework.build index aa011d5..b308eda 100644 --- a/flamework.build +++ b/flamework.build @@ -8,7 +8,10 @@ "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/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", + "crownstudio-roblox-ts-template:out/client/audio/mixer@AudioMixer": "client/audio/mixer@AudioMixer", + "crownstudio-roblox-ts-template:out/client/controllers/audio-controller@AudioController": "client/controllers/audio-controller@AudioController" }, "metadata": {} } \ No newline at end of file diff --git a/package.json b/package.json index c504e22..9aeb349 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crownstudio-roblox-ts-template", - "version": "1.0.0", + "version": "1.1.0", "private": true, "packageManager": "pnpm@11.21.0", "engines": { diff --git a/readme.md b/readme.md index a00d72b..ccfe095 100644 --- a/readme.md +++ b/readme.md @@ -89,4 +89,4 @@ Le serveur Blender peut exécuter du code Python dans Blender : utiliser uniquem L'[API Cloud Creator Store](https://create.roblox.com/docs/cloud/reference/features/creator-store) est une API Roblox distincte des serveurs MCP ci-dessus. Elle n'est pas activée ici : son usage nécessite une clé et un cas d'utilisation précis. -Version du template : 1.0.0 +Version du template : 1.1.0 diff --git a/src/client/audio/catalog.ts b/src/client/audio/catalog.ts new file mode 100644 index 0000000..54a3302 --- /dev/null +++ b/src/client/audio/catalog.ts @@ -0,0 +1,12 @@ +import { AudioChannel } from "./settings"; + +export interface AudioClip { + readonly assetId: string; + readonly volume?: number; + readonly playbackSpeed?: number; +} + +export type AudioCatalog = Readonly>>>; + +// Add only audio assets that the experience is authorized to use. +export const AUDIO_CATALOG: AudioCatalog = { SFX: {}, Music: {} }; diff --git a/src/client/audio/mixer.ts b/src/client/audio/mixer.ts new file mode 100644 index 0000000..e257509 --- /dev/null +++ b/src/client/audio/mixer.ts @@ -0,0 +1,120 @@ +import { Players, SoundService } from "@rbxts/services"; +import { Trove } from "@rbxts/trove"; +import { AudioCatalog, AudioClip } from "./catalog"; +import { AudioChannel, AudioSettings, audioSettings, selectAudioVolume } from "./settings"; + +export interface AudioPlayback { + readonly stop: () => void; +} + +export class AudioMixer { + private readonly trove = new Trove(); + private readonly root = new Instance("Folder"); + private readonly channels = new Map(); + private readonly master = new Instance("AudioFader"); + private readonly active = new Set(); + private music?: AudioPlayback; + private musicName?: string; + private destroyed = false; + + public constructor(private readonly catalog: AudioCatalog) { + this.root.Name = "LocalAudio"; + this.root.Parent = SoundService; + this.trove.add(this.root); + this.master.Name = "Master"; + this.master.Parent = this.root; + const output = new Instance("AudioDeviceOutput"); + output.Name = "Output"; + output.Player = Players.LocalPlayer; + output.Parent = this.root; + this.connect(this.master, output, this.root); + for (const channel of ["SFX", "Music"] as const) { + const fader = new Instance("AudioFader"); + fader.Name = channel; + fader.Parent = this.root; + this.channels.set(channel, fader); + this.connect(fader, this.master, fader); + } + this.applySettings(audioSettings.getState()); + this.trove.add(audioSettings.subscribe((settings) => this.applySettings(settings))); + } + + public playSfx(name: string): AudioPlayback | undefined { + const clip = this.catalog.SFX[name]; + return clip && !this.destroyed ? this.playClip("SFX", name, clip, false) : undefined; + } + + public playMusic(name: string): AudioPlayback | undefined { + const clip = this.catalog.Music[name]; + if (!clip || this.destroyed) return undefined; + if (this.musicName === name && this.music) return this.music; + this.stopMusic(); + this.musicName = name; + this.music = this.playClip("Music", name, clip, true); + return this.music; + } + + public stopMusic(): void { + this.music?.stop(); + this.music = undefined; + this.musicName = undefined; + } + + public destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.stopMusic(); + for (const playback of this.active) playback.stop(); + this.trove.destroy(); + } + + private playClip( + channel: AudioChannel, + name: string, + clip: AudioClip, + looping: boolean, + ): AudioPlayback { + const cleanup = new Trove(); + const player = new Instance("AudioPlayer"); + player.Name = name; + player.Asset = clip.assetId; + player.Volume = clip.volume ?? 1; + player.PlaybackSpeed = clip.playbackSpeed ?? 1; + player.Looping = looping; + player.Parent = this.channels.get(channel)!; + cleanup.add(player); + this.connect(player, this.channels.get(channel)!, player); + let stopped = false; + const playback: AudioPlayback = { + stop: () => { + if (stopped) return; + stopped = true; + this.active.delete(playback); + if (this.music === playback) { + this.music = undefined; + this.musicName = undefined; + } + player.Stop(); + cleanup.destroy(); + }, + }; + cleanup.add(player.Ended.Connect(() => playback.stop())); + this.active.add(playback); + player.Play(); + return playback; + } + + private applySettings(settings: AudioSettings): void { + this.master.Volume = selectAudioVolume(settings, "Master"); + for (const [channel, fader] of this.channels) { + fader.Volume = selectAudioVolume(settings, channel); + } + } + + private connect(source: Instance, target: Instance, parent: Instance): void { + const wire = new Instance("Wire"); + wire.SourceInstance = source; + wire.TargetInstance = target; + wire.Parent = parent; + } +} diff --git a/src/client/audio/settings.ts b/src/client/audio/settings.ts new file mode 100644 index 0000000..16512b8 --- /dev/null +++ b/src/client/audio/settings.ts @@ -0,0 +1,30 @@ +import { createProducer } from "@rbxts/reflex"; + +export type AudioChannel = "SFX" | "Music"; +export type AudioVolume = AudioChannel | "Master"; + +export interface AudioSettings { + readonly volumes: Readonly>; + readonly muted: Readonly>; +} + +export const DEFAULT_AUDIO_SETTINGS: AudioSettings = { + volumes: { Master: 1, SFX: 1, Music: 1 }, + muted: { Master: false, SFX: false, Music: false }, +}; + +export const audioSettings = createProducer(DEFAULT_AUDIO_SETTINGS, { + setVolume: (state: AudioSettings, channel: AudioVolume, volume: number): AudioSettings => { + if (volume !== volume || math.abs(volume) === math.huge) return state; + return { ...state, volumes: { ...state.volumes, [channel]: math.clamp(volume, 0, 1) } }; + }, + setMuted: (state: AudioSettings, channel: AudioVolume, muted: boolean): AudioSettings => ({ + ...state, + muted: { ...state.muted, [channel]: muted }, + }), + reset: (): AudioSettings => DEFAULT_AUDIO_SETTINGS, +}); + +export function selectAudioVolume(state: AudioSettings, channel: AudioVolume): number { + return state.muted[channel] ? 0 : state.volumes[channel]; +} diff --git a/src/client/controllers/audio-controller.ts b/src/client/controllers/audio-controller.ts new file mode 100644 index 0000000..76453ee --- /dev/null +++ b/src/client/controllers/audio-controller.ts @@ -0,0 +1,15 @@ +import { Controller, OnStart } from "@flamework/core"; +import { Trove } from "@rbxts/trove"; +import { AUDIO_CATALOG } from "client/audio/catalog"; +import { AudioMixer } from "client/audio/mixer"; + +@Controller() +export class AudioController implements OnStart { + public readonly mixer = new AudioMixer(AUDIO_CATALOG); + private readonly trove = new Trove(); + + public onStart(): void { + this.trove.add(() => this.mixer.destroy()); + this.trove.attachToInstance(script); + } +} diff --git a/src/client/controllers/cmdr-controller.ts b/src/client/controllers/cmdr-controller.ts index 885ee02..ceae02f 100644 --- a/src/client/controllers/cmdr-controller.ts +++ b/src/client/controllers/cmdr-controller.ts @@ -2,17 +2,51 @@ import { Controller, OnStart } from "@flamework/core"; import { CmdrClient } from "@rbxts/cmdr"; import { Players } from "@rbxts/services"; 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 { TEMPLATE_NAME } from "shared/template-name"; @Controller() export class CmdrController implements OnStart { private readonly trove = new Trove(); + private action?: ButtonAction; + private renderTouch?: () => void; public onStart(): void { - CmdrClient.SetActivationKeys([Enum.KeyCode.F2]); + CmdrClient.SetActivationKeys([]); CmdrClient.SetPlaceName(TEMPLATE_NAME); 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.updateAccess(), ); @@ -23,6 +57,8 @@ export class CmdrController implements OnStart { private updateAccess(): void { const enabled = Players.LocalPlayer.GetAttribute(CMDR_ACCESS_ATTRIBUTE) === true; CmdrClient.SetEnabled(enabled); + this.action?.setEnabled(enabled); + this.renderTouch?.(); if (!enabled) { CmdrClient.Hide(); } diff --git a/src/client/input/button-action.ts b/src/client/input/button-action.ts new file mode 100644 index 0000000..a33364a --- /dev/null +++ b/src/client/input/button-action.ts @@ -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?.(); + } +} diff --git a/src/client/ui/action-touch-button.tsx b/src/client/ui/action-touch-button.tsx new file mode 100644 index 0000000..a7f727a --- /dev/null +++ b/src/client/ui/action-touch-button.tsx @@ -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 ( + + 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} + > + + + + ); +}