@hexium-softworks/inputservice 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +724 -0
- package/default.project.json +14 -0
- package/package.json +38 -0
- package/src/Client/InputServiceClient.luau +354 -0
- package/src/Client/InputServiceClient.spec.luau +71 -0
- package/src/Server/InputService.luau +186 -0
- package/src/Shared/InputServiceConfigUtils.luau +168 -0
- package/src/Shared/InputServiceConfigUtils.spec.luau +86 -0
- package/src/Shared/InputServiceConstants.luau +28 -0
- package/src/Shared/InputServiceInstanceUtils.luau +194 -0
- package/src/Shared/InputServiceInstanceUtils.spec.luau +100 -0
- package/src/Shared/InputServiceTypes.luau +63 -0
- package/src/jest.config.lua +3 -0
- package/src/node_modules.project.json +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
# InputService
|
|
2
|
+
|
|
3
|
+
A game-agnostic Nevermore package for Roblox's Input Action System.
|
|
4
|
+
|
|
5
|
+
InputService creates and edits real `InputContext`, `InputAction`, and
|
|
6
|
+
`InputBinding` instances in `ReplicatedStorage.Inputs`. The server owns shared
|
|
7
|
+
defaults, clients listen to actions and make local presentation changes, and no
|
|
8
|
+
client input is treated as server authority by this package.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pnpm add @hexium-softworks/inputservice
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This package expects a Roblox runtime that can create `InputContext`,
|
|
17
|
+
`InputAction`, and `InputBinding` instances.
|
|
18
|
+
|
|
19
|
+
## Model
|
|
20
|
+
|
|
21
|
+
InputService mirrors Roblox's Input Action System hierarchy:
|
|
22
|
+
|
|
23
|
+
```txt
|
|
24
|
+
ReplicatedStorage
|
|
25
|
+
Inputs
|
|
26
|
+
PlayContext
|
|
27
|
+
Sprint
|
|
28
|
+
Keyboard
|
|
29
|
+
Gamepad
|
|
30
|
+
Jump
|
|
31
|
+
Keyboard
|
|
32
|
+
MenuContext
|
|
33
|
+
Confirm
|
|
34
|
+
Back
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `InputContext` groups actions and controls priority, sinking, and enablement.
|
|
38
|
+
- `InputAction` represents something the player can do, such as sprint, jump,
|
|
39
|
+
confirm, or open inventory.
|
|
40
|
+
- `InputBinding` describes how an action is triggered, such as a key, UI button,
|
|
41
|
+
directional set, pointer index, or scriptable binding.
|
|
42
|
+
|
|
43
|
+
Server code should create shared gameplay defaults. Client code should connect to
|
|
44
|
+
signals, enable or disable local presentation contexts, add local-only bindings,
|
|
45
|
+
and fire scriptable bindings for UI or custom local input.
|
|
46
|
+
|
|
47
|
+
## Server Usage
|
|
48
|
+
|
|
49
|
+
Register one context:
|
|
50
|
+
|
|
51
|
+
```lua
|
|
52
|
+
local InputService = require("InputService")
|
|
53
|
+
|
|
54
|
+
local inputService = serviceBag:GetService(InputService)
|
|
55
|
+
|
|
56
|
+
inputService:RegisterContext({
|
|
57
|
+
Name = "PlayContext",
|
|
58
|
+
Enabled = true,
|
|
59
|
+
Priority = 2000,
|
|
60
|
+
Sink = true,
|
|
61
|
+
Actions = {
|
|
62
|
+
{
|
|
63
|
+
Name = "Sprint",
|
|
64
|
+
DisplayName = "Sprint",
|
|
65
|
+
Type = Enum.InputActionType.Bool,
|
|
66
|
+
Bindings = {
|
|
67
|
+
{
|
|
68
|
+
Name = "Keyboard",
|
|
69
|
+
KeyCode = Enum.KeyCode.LeftShift,
|
|
70
|
+
DisplayName = "Left Shift",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
Name = "Gamepad",
|
|
74
|
+
KeyCode = Enum.KeyCode.ButtonL3,
|
|
75
|
+
DisplayName = "L3",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Register multiple contexts at startup:
|
|
84
|
+
|
|
85
|
+
```lua
|
|
86
|
+
inputService:RegisterContexts({
|
|
87
|
+
{
|
|
88
|
+
Name = "Gameplay",
|
|
89
|
+
Enabled = true,
|
|
90
|
+
Priority = 2000,
|
|
91
|
+
Sink = false,
|
|
92
|
+
Actions = {
|
|
93
|
+
{
|
|
94
|
+
Name = "Sprint",
|
|
95
|
+
DisplayName = "Sprint",
|
|
96
|
+
Type = Enum.InputActionType.Bool,
|
|
97
|
+
Bindings = {
|
|
98
|
+
{
|
|
99
|
+
Name = "Keyboard",
|
|
100
|
+
KeyCode = Enum.KeyCode.LeftShift,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
Name = "Gamepad",
|
|
104
|
+
KeyCode = Enum.KeyCode.ButtonL3,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
Name = "Jump",
|
|
110
|
+
DisplayName = "Jump",
|
|
111
|
+
Type = Enum.InputActionType.Bool,
|
|
112
|
+
Bindings = {
|
|
113
|
+
{
|
|
114
|
+
Name = "Keyboard",
|
|
115
|
+
KeyCode = Enum.KeyCode.Space,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
Name = "Gamepad",
|
|
119
|
+
KeyCode = Enum.KeyCode.ButtonA,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
Name = "Move",
|
|
125
|
+
DisplayName = "Move",
|
|
126
|
+
Type = Enum.InputActionType.Direction2D,
|
|
127
|
+
Bindings = {
|
|
128
|
+
{
|
|
129
|
+
Name = "Wasd",
|
|
130
|
+
Up = Enum.KeyCode.W,
|
|
131
|
+
Down = Enum.KeyCode.S,
|
|
132
|
+
Left = Enum.KeyCode.A,
|
|
133
|
+
Right = Enum.KeyCode.D,
|
|
134
|
+
Vector2Scale = Vector2.new(1, 1),
|
|
135
|
+
ClampMagnitudeToOne = true,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
Name = "Menu",
|
|
143
|
+
Enabled = false,
|
|
144
|
+
Priority = 3000,
|
|
145
|
+
Sink = true,
|
|
146
|
+
Actions = {
|
|
147
|
+
{
|
|
148
|
+
Name = "Confirm",
|
|
149
|
+
DisplayName = "Confirm",
|
|
150
|
+
Type = Enum.InputActionType.Bool,
|
|
151
|
+
Bindings = {
|
|
152
|
+
{
|
|
153
|
+
Name = "Keyboard",
|
|
154
|
+
KeyCode = Enum.KeyCode.Return,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
Name = "Gamepad",
|
|
158
|
+
KeyCode = Enum.KeyCode.ButtonA,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
Name = "Back",
|
|
164
|
+
DisplayName = "Back",
|
|
165
|
+
Type = Enum.InputActionType.Bool,
|
|
166
|
+
Bindings = {
|
|
167
|
+
{
|
|
168
|
+
Name = "Keyboard",
|
|
169
|
+
KeyCode = Enum.KeyCode.Escape,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
Name = "Gamepad",
|
|
173
|
+
KeyCode = Enum.KeyCode.ButtonB,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
Name = "Vehicle",
|
|
181
|
+
Enabled = false,
|
|
182
|
+
Priority = 2500,
|
|
183
|
+
Sink = true,
|
|
184
|
+
Actions = {
|
|
185
|
+
{
|
|
186
|
+
Name = "Throttle",
|
|
187
|
+
DisplayName = "Throttle",
|
|
188
|
+
Type = Enum.InputActionType.Direction1D,
|
|
189
|
+
Bindings = {
|
|
190
|
+
{
|
|
191
|
+
Name = "Keyboard",
|
|
192
|
+
Up = Enum.KeyCode.W,
|
|
193
|
+
Down = Enum.KeyCode.S,
|
|
194
|
+
Scale = 1,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Update inputs later without rebuilding the whole tree:
|
|
204
|
+
|
|
205
|
+
```lua
|
|
206
|
+
inputService:ConfigureAction("Gameplay", {
|
|
207
|
+
Name = "Interact",
|
|
208
|
+
DisplayName = "Interact",
|
|
209
|
+
Type = Enum.InputActionType.Bool,
|
|
210
|
+
Bindings = {
|
|
211
|
+
{
|
|
212
|
+
Name = "Keyboard",
|
|
213
|
+
KeyCode = Enum.KeyCode.E,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
inputService:ConfigureBinding("Gameplay", "Jump", {
|
|
219
|
+
Name = "Keyboard",
|
|
220
|
+
KeyCode = Enum.KeyCode.Space,
|
|
221
|
+
DisplayName = "Space",
|
|
222
|
+
})
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Switch active modes by toggling contexts:
|
|
226
|
+
|
|
227
|
+
```lua
|
|
228
|
+
local function setMenuOpen(isOpen)
|
|
229
|
+
inputService:SetContextEnabled("Gameplay", not isOpen)
|
|
230
|
+
inputService:SetContextEnabled("Menu", isOpen)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
local function setInVehicle(isInVehicle)
|
|
234
|
+
inputService:SetContextEnabled("Vehicle", isInVehicle)
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Remove objects when a feature is unloaded:
|
|
239
|
+
|
|
240
|
+
```lua
|
|
241
|
+
inputService:RemoveBinding("Gameplay", "Jump", "Keyboard")
|
|
242
|
+
inputService:RemoveAction("Gameplay", "Interact")
|
|
243
|
+
inputService:RemoveContext("Vehicle")
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Client Usage
|
|
247
|
+
|
|
248
|
+
Read shared contexts and bind to action signals:
|
|
249
|
+
|
|
250
|
+
```lua
|
|
251
|
+
local InputServiceClient = require("InputServiceClient")
|
|
252
|
+
|
|
253
|
+
local inputServiceClient = serviceBag:GetService(InputServiceClient)
|
|
254
|
+
|
|
255
|
+
inputServiceClient:WaitForContext("Gameplay", 10)
|
|
256
|
+
|
|
257
|
+
local sprintPressed = inputServiceClient:BindPressed("Gameplay", "Sprint", function()
|
|
258
|
+
print("Sprint pressed")
|
|
259
|
+
end)
|
|
260
|
+
|
|
261
|
+
local sprintReleased = inputServiceClient:BindReleased("Gameplay", "Sprint", function()
|
|
262
|
+
print("Sprint released")
|
|
263
|
+
end)
|
|
264
|
+
|
|
265
|
+
local moveChanged = inputServiceClient:BindStateChanged("Gameplay", "Move", function(value)
|
|
266
|
+
print("Move changed", value)
|
|
267
|
+
end)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`BindPressed`, `BindReleased`, and `BindStateChanged` return connections. Store
|
|
271
|
+
and disconnect them with your maid/janitor/cleanup pattern:
|
|
272
|
+
|
|
273
|
+
```lua
|
|
274
|
+
maid:GiveTask(sprintPressed)
|
|
275
|
+
maid:GiveTask(sprintReleased)
|
|
276
|
+
maid:GiveTask(moveChanged)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
You can also access the full signal bundle:
|
|
280
|
+
|
|
281
|
+
```lua
|
|
282
|
+
local signals = inputServiceClient:GetActionSignals("Gameplay", "Jump")
|
|
283
|
+
|
|
284
|
+
maid:GiveTask(signals.EnabledChanged:Connect(function(enabled)
|
|
285
|
+
print("Jump enabled:", enabled)
|
|
286
|
+
end))
|
|
287
|
+
|
|
288
|
+
maid:GiveTask(signals.PreferredBindingChanged:Connect(function(binding)
|
|
289
|
+
print("Preferred jump binding:", binding)
|
|
290
|
+
end))
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Toggle local action state:
|
|
294
|
+
|
|
295
|
+
```lua
|
|
296
|
+
inputServiceClient:SetActionEnabled("Gameplay", "Sprint", false)
|
|
297
|
+
inputServiceClient:SetContextEnabled("Menu", true)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Local Contexts And UI Bindings
|
|
301
|
+
|
|
302
|
+
Clients can define local-only contexts. These are useful for UI, tutorials,
|
|
303
|
+
debug tools, accessibility overlays, and rebinding screens. They do not send any
|
|
304
|
+
claims or config changes to the server through this package.
|
|
305
|
+
|
|
306
|
+
```lua
|
|
307
|
+
local closeButton = playerGui.Inventory.CloseButton
|
|
308
|
+
|
|
309
|
+
inputServiceClient:DefineLocalContext({
|
|
310
|
+
Name = "InventoryUi",
|
|
311
|
+
Enabled = false,
|
|
312
|
+
Priority = 4000,
|
|
313
|
+
Sink = true,
|
|
314
|
+
Actions = {
|
|
315
|
+
{
|
|
316
|
+
Name = "Close",
|
|
317
|
+
DisplayName = "Close",
|
|
318
|
+
Type = Enum.InputActionType.Bool,
|
|
319
|
+
Bindings = {
|
|
320
|
+
{
|
|
321
|
+
Name = "Keyboard",
|
|
322
|
+
KeyCode = Enum.KeyCode.Escape,
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
Name = "Button",
|
|
326
|
+
UIButton = closeButton,
|
|
327
|
+
DisplayName = "Close",
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
inputServiceClient:BindPressed("InventoryUi", "Close", function()
|
|
335
|
+
inputServiceClient:SetContextEnabled("InventoryUi", false)
|
|
336
|
+
end)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Configure a local binding at runtime:
|
|
340
|
+
|
|
341
|
+
```lua
|
|
342
|
+
inputServiceClient:ConfigureBinding("InventoryUi", "Close", {
|
|
343
|
+
Name = "Gamepad",
|
|
344
|
+
KeyCode = Enum.KeyCode.ButtonB,
|
|
345
|
+
DisplayName = "Back",
|
|
346
|
+
})
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Fire a binding from custom client code when the underlying `InputBinding` type in
|
|
350
|
+
your Roblox runtime supports `:Fire()`:
|
|
351
|
+
|
|
352
|
+
```lua
|
|
353
|
+
inputServiceClient:ConfigureAction("InventoryUi", {
|
|
354
|
+
Name = "Close",
|
|
355
|
+
Type = Enum.InputActionType.Bool,
|
|
356
|
+
Bindings = {
|
|
357
|
+
{
|
|
358
|
+
Name = "Script",
|
|
359
|
+
Type = Enum.InputBindingType.Scriptable,
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
inputServiceClient:FireBinding("InventoryUi", "Close", "Script", true)
|
|
365
|
+
inputServiceClient:FireBinding("InventoryUi", "Close", "Script", false)
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## Blend And Nevermore UI Patterns
|
|
369
|
+
|
|
370
|
+
InputService does not depend on Blend, Rx, Binder, or BaseObject, but it fits
|
|
371
|
+
well with those Nevermore patterns:
|
|
372
|
+
|
|
373
|
+
- Use a service to define the shared input tree and expose game-specific state.
|
|
374
|
+
- Use `BaseObject` or a pane/controller object to own UI and input connections.
|
|
375
|
+
- Use `Maid` for every connection, mounted Blend tree, and temporary tool input.
|
|
376
|
+
- Use `Blend.State` for the current list of visible input hints.
|
|
377
|
+
- Use `PreferredBindingChanged` to update labels when the player's device changes.
|
|
378
|
+
- Use a short-lived maid for tool-specific actions so unequipping the tool removes
|
|
379
|
+
its input context and hint rows at the same time.
|
|
380
|
+
|
|
381
|
+
The pattern is to keep actions in the input tree, then render a separate list of
|
|
382
|
+
human-readable rows. General actions, such as `F` to interact, stay in the list
|
|
383
|
+
all the time. Tool actions are pushed while the tool is equipped and cleaned up
|
|
384
|
+
when it is unequipped.
|
|
385
|
+
|
|
386
|
+
```lua
|
|
387
|
+
local Blend = require("Blend")
|
|
388
|
+
local Maid = require("Maid")
|
|
389
|
+
|
|
390
|
+
local InputHintPane = {}
|
|
391
|
+
InputHintPane.ClassName = "InputHintPane"
|
|
392
|
+
InputHintPane.__index = InputHintPane
|
|
393
|
+
|
|
394
|
+
function InputHintPane.new(inputServiceClient, playerGui)
|
|
395
|
+
local self = setmetatable({}, InputHintPane)
|
|
396
|
+
|
|
397
|
+
self._maid = Maid.new()
|
|
398
|
+
self._inputServiceClient = inputServiceClient
|
|
399
|
+
self._rows = Blend.State({})
|
|
400
|
+
|
|
401
|
+
self._maid:GiveTask(self:_render(playerGui):Subscribe())
|
|
402
|
+
self:_setAlwaysAvailableRows()
|
|
403
|
+
|
|
404
|
+
return self
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
function InputHintPane:_render(playerGui)
|
|
408
|
+
return Blend.New "ScreenGui" {
|
|
409
|
+
Name = "InputHints",
|
|
410
|
+
Parent = playerGui,
|
|
411
|
+
ResetOnSpawn = false,
|
|
412
|
+
|
|
413
|
+
Blend.New "Frame" {
|
|
414
|
+
AnchorPoint = Vector2.new(1, 1),
|
|
415
|
+
Position = UDim2.fromScale(0.98, 0.96),
|
|
416
|
+
Size = UDim2.fromOffset(280, 160),
|
|
417
|
+
BackgroundTransparency = 1,
|
|
418
|
+
|
|
419
|
+
Blend.New "UIListLayout" {
|
|
420
|
+
Padding = UDim.new(0, 6),
|
|
421
|
+
SortOrder = Enum.SortOrder.LayoutOrder,
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
Blend.ComputedPairs(self._rows, function(_, row)
|
|
425
|
+
return Blend.New "TextLabel" {
|
|
426
|
+
Size = UDim2.new(1, 0, 0, 28),
|
|
427
|
+
BackgroundTransparency = 0.25,
|
|
428
|
+
TextXAlignment = Enum.TextXAlignment.Left,
|
|
429
|
+
Text = string.format("[%s] %s", row.BindingText, row.DisplayName),
|
|
430
|
+
LayoutOrder = row.LayoutOrder,
|
|
431
|
+
}
|
|
432
|
+
end),
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
function InputHintPane:_setRows(rows)
|
|
438
|
+
self._rows.Value = rows
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
function InputHintPane:_setAlwaysAvailableRows()
|
|
442
|
+
self:_setRows({
|
|
443
|
+
{
|
|
444
|
+
ContextName = "Gameplay",
|
|
445
|
+
ActionName = "Interact",
|
|
446
|
+
DisplayName = "Interact",
|
|
447
|
+
BindingText = "F",
|
|
448
|
+
LayoutOrder = 100,
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
ContextName = "Gameplay",
|
|
452
|
+
ActionName = "Sprint",
|
|
453
|
+
DisplayName = "Sprint",
|
|
454
|
+
BindingText = "LeftShift",
|
|
455
|
+
LayoutOrder = 110,
|
|
456
|
+
},
|
|
457
|
+
})
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
function InputHintPane:Destroy()
|
|
461
|
+
self._maid:DoCleaning()
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
return InputHintPane
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
For real UI, avoid hard-coding binding text forever. Start with a fallback, then
|
|
468
|
+
listen to each action's `PreferredBindingChanged` signal and rewrite that row
|
|
469
|
+
when Roblox chooses a better keyboard, gamepad, or touch binding for the player.
|
|
470
|
+
|
|
471
|
+
```lua
|
|
472
|
+
local function getBindingText(binding)
|
|
473
|
+
if not binding then
|
|
474
|
+
return "?"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
if binding.DisplayName ~= "" then
|
|
478
|
+
return binding.DisplayName
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
if binding.KeyCode ~= Enum.KeyCode.Unknown then
|
|
482
|
+
return binding.KeyCode.Name
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
if binding.UIButton then
|
|
486
|
+
return binding.UIButton.Name
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
return binding.Name
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
function InputHintPane:_watchPreferredBinding(row)
|
|
493
|
+
local signals = self._inputServiceClient:GetActionSignals(row.ContextName, row.ActionName)
|
|
494
|
+
|
|
495
|
+
local function update(binding)
|
|
496
|
+
local nextRows = table.clone(self._rows.Value)
|
|
497
|
+
|
|
498
|
+
for index, existing in nextRows do
|
|
499
|
+
if existing.ContextName == row.ContextName and existing.ActionName == row.ActionName then
|
|
500
|
+
local nextRow = table.clone(existing)
|
|
501
|
+
nextRow.BindingText = getBindingText(binding)
|
|
502
|
+
nextRows[index] = nextRow
|
|
503
|
+
break
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
self._rows.Value = nextRows
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
local action = self._inputServiceClient:GetAction(row.ContextName, row.ActionName)
|
|
511
|
+
if action then
|
|
512
|
+
update(action.PreferredBinding)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
return signals.PreferredBindingChanged:Connect(update)
|
|
516
|
+
end
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
Tool-specific input works best as a lifetime-scoped layer. When the user equips a
|
|
520
|
+
tool, define or enable a local context for the tool, add its rows to the hint
|
|
521
|
+
list, and store all of that work in a tool maid. When the tool is unequipped, the
|
|
522
|
+
maid cleans the UI rows and disables or removes the tool context.
|
|
523
|
+
|
|
524
|
+
```lua
|
|
525
|
+
function InputHintPane:SetEquippedTool(tool)
|
|
526
|
+
if self._toolMaid then
|
|
527
|
+
self._toolMaid:DoCleaning()
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
self._toolMaid = Maid.new()
|
|
531
|
+
self._maid._toolMaid = self._toolMaid
|
|
532
|
+
|
|
533
|
+
if not tool then
|
|
534
|
+
self:_setAlwaysAvailableRows()
|
|
535
|
+
return
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
local contextName = "Tool:" .. tool.Name
|
|
539
|
+
local toolRows = {
|
|
540
|
+
{
|
|
541
|
+
ContextName = contextName,
|
|
542
|
+
ActionName = "Primary",
|
|
543
|
+
DisplayName = tool.Name .. " Primary",
|
|
544
|
+
BindingText = "MouseLeftButton",
|
|
545
|
+
LayoutOrder = 200,
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
ContextName = contextName,
|
|
549
|
+
ActionName = "Reload",
|
|
550
|
+
DisplayName = "Reload",
|
|
551
|
+
BindingText = "R",
|
|
552
|
+
LayoutOrder = 210,
|
|
553
|
+
},
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
self._inputServiceClient:DefineLocalContext({
|
|
557
|
+
Name = contextName,
|
|
558
|
+
Enabled = true,
|
|
559
|
+
Priority = 2600,
|
|
560
|
+
Sink = false,
|
|
561
|
+
Actions = {
|
|
562
|
+
{
|
|
563
|
+
Name = "Primary",
|
|
564
|
+
DisplayName = tool.Name .. " Primary",
|
|
565
|
+
Type = Enum.InputActionType.Bool,
|
|
566
|
+
Bindings = {
|
|
567
|
+
{
|
|
568
|
+
Name = "Mouse",
|
|
569
|
+
KeyCode = Enum.KeyCode.MouseLeftButton,
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
Name = "Reload",
|
|
575
|
+
DisplayName = "Reload",
|
|
576
|
+
Type = Enum.InputActionType.Bool,
|
|
577
|
+
Bindings = {
|
|
578
|
+
{
|
|
579
|
+
Name = "Keyboard",
|
|
580
|
+
KeyCode = Enum.KeyCode.R,
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
local rows = table.clone(self._rows.Value)
|
|
588
|
+
for _, row in toolRows do
|
|
589
|
+
table.insert(rows, row)
|
|
590
|
+
self._toolMaid:GiveTask(self:_watchPreferredBinding(row))
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
self:_setRows(rows)
|
|
594
|
+
|
|
595
|
+
self._toolMaid:GiveTask(function()
|
|
596
|
+
self._inputServiceClient:SetContextEnabled(contextName, false)
|
|
597
|
+
self:_setAlwaysAvailableRows()
|
|
598
|
+
end)
|
|
599
|
+
end
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
In a full Nevermore game, the tool layer usually comes from a service or Binder
|
|
603
|
+
instead of from the pane directly:
|
|
604
|
+
|
|
605
|
+
```lua
|
|
606
|
+
function ToolInputHintService:Start()
|
|
607
|
+
local inputHintPane = InputHintPane.new(
|
|
608
|
+
self._serviceBag:GetService(InputServiceClient),
|
|
609
|
+
Players.LocalPlayer:WaitForChild("PlayerGui")
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
self._maid:GiveTask(inputHintPane)
|
|
613
|
+
|
|
614
|
+
self._maid:GiveTask(self._equippedTool.Changed:Connect(function(tool)
|
|
615
|
+
inputHintPane:SetEquippedTool(tool)
|
|
616
|
+
end))
|
|
617
|
+
end
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
That keeps the responsibilities clean: `InputServiceClient` owns input instances,
|
|
621
|
+
the tool service decides which tool is active, and the Blend pane only renders
|
|
622
|
+
the current rows. The same shape works for ability bars, contextual prompts,
|
|
623
|
+
vehicle controls, build-mode hotkeys, and accessibility overlays.
|
|
624
|
+
|
|
625
|
+
## Config Reference
|
|
626
|
+
|
|
627
|
+
Every config requires a non-empty `Name` with no control characters or `/`.
|
|
628
|
+
Unknown fields are rejected so typos fail early.
|
|
629
|
+
|
|
630
|
+
Context fields:
|
|
631
|
+
|
|
632
|
+
| Field | Type | Notes |
|
|
633
|
+
| --- | --- | --- |
|
|
634
|
+
| `Name` | `string` | Required. Used as the `InputContext.Name`. |
|
|
635
|
+
| `Enabled` | `boolean?` | Sets whether the context is active. |
|
|
636
|
+
| `Priority` | `number?` | Higher-priority contexts can win over lower-priority contexts. |
|
|
637
|
+
| `Sink` | `boolean?` | Controls whether the context sinks input. |
|
|
638
|
+
| `Actions` | `{ InputActionConfig }?` | Actions to create or update under this context. |
|
|
639
|
+
|
|
640
|
+
Action fields:
|
|
641
|
+
|
|
642
|
+
| Field | Type | Notes |
|
|
643
|
+
| --- | --- | --- |
|
|
644
|
+
| `Name` | `string` | Required. Used as the `InputAction.Name`. |
|
|
645
|
+
| `DisplayName` | `string?` | Human-readable action name. |
|
|
646
|
+
| `Enabled` | `boolean?` | Sets whether the action is active. |
|
|
647
|
+
| `Type` | `Enum.InputActionType?` | The action value type, such as `Bool`, `Direction1D`, `Direction2D`, `Direction3D`, or `ViewportPosition`. |
|
|
648
|
+
| `Bindings` | `{ InputBindingConfig }?` | Bindings to create or update under this action. |
|
|
649
|
+
|
|
650
|
+
Binding fields:
|
|
651
|
+
|
|
652
|
+
| Field | Type | Notes |
|
|
653
|
+
| --- | --- | --- |
|
|
654
|
+
| `Name` | `string` | Required. Used as the `InputBinding.Name`. |
|
|
655
|
+
| `Type` | `Enum.InputBindingType?` | Binding type for runtimes that require it. |
|
|
656
|
+
| `KeyCode` | `Enum.KeyCode?` | Keyboard, mouse, or gamepad key/button binding. |
|
|
657
|
+
| `UIButton` | `GuiButton?` | UI button binding. |
|
|
658
|
+
| `UIModifier` | `GuiButton?` | UI modifier button. |
|
|
659
|
+
| `PrimaryModifier` | `Enum.KeyCode?` | Primary keyboard/gamepad modifier. |
|
|
660
|
+
| `SecondaryModifier` | `Enum.KeyCode?` | Secondary keyboard/gamepad modifier. |
|
|
661
|
+
| `DisplayName` | `string?` | Human-readable binding name. |
|
|
662
|
+
| `DisplayImage` | `Content?` | Binding icon/image content. |
|
|
663
|
+
| `PressedThreshold` | `number?` | Pressed threshold. Must be greater than or equal to `ReleasedThreshold` when both are set. |
|
|
664
|
+
| `ReleasedThreshold` | `number?` | Released threshold. |
|
|
665
|
+
| `ResponseCurve` | `number?` | Response curve value. |
|
|
666
|
+
| `Scale` | `number?` | Scalar output scale. |
|
|
667
|
+
| `Vector2Scale` | `Vector2?` | Vector2 output scale. |
|
|
668
|
+
| `Vector3Scale` | `Vector3?` | Vector3 output scale. |
|
|
669
|
+
| `PointerIndex` | `number?` | Pointer index for pointer-style bindings. |
|
|
670
|
+
| `ClampMagnitudeToOne` | `boolean?` | Clamp vector magnitude to one. |
|
|
671
|
+
| `Up` | `Enum.KeyCode?` | Up or positive direction key. |
|
|
672
|
+
| `Down` | `Enum.KeyCode?` | Down or negative direction key. |
|
|
673
|
+
| `Left` | `Enum.KeyCode?` | Left direction key. |
|
|
674
|
+
| `Right` | `Enum.KeyCode?` | Right direction key. |
|
|
675
|
+
| `Forward` | `Enum.KeyCode?` | Forward direction key for 3D actions. |
|
|
676
|
+
| `Backward` | `Enum.KeyCode?` | Backward direction key for 3D actions. |
|
|
677
|
+
|
|
678
|
+
## API Reference
|
|
679
|
+
|
|
680
|
+
Server `InputService`:
|
|
681
|
+
|
|
682
|
+
| Method | Description |
|
|
683
|
+
| --- | --- |
|
|
684
|
+
| `GetRootFolder()` | Returns `ReplicatedStorage.Inputs`. |
|
|
685
|
+
| `RegisterContext(config)` | Creates or updates one shared context. |
|
|
686
|
+
| `RegisterContexts(configs)` | Creates or updates many shared contexts. |
|
|
687
|
+
| `GetContext(contextName)` | Returns an `InputContext?`. |
|
|
688
|
+
| `GetAction(contextName, actionName)` | Returns an `InputAction?`. |
|
|
689
|
+
| `GetBinding(contextName, actionName, bindingName)` | Returns an `InputBinding?`. |
|
|
690
|
+
| `ConfigureAction(contextName, actionConfig)` | Creates or updates one action under an existing context. |
|
|
691
|
+
| `ConfigureBinding(contextName, actionName, bindingConfig)` | Creates or updates one binding under an existing action. |
|
|
692
|
+
| `SetContextEnabled(contextName, enabled)` | Enables or disables a context. |
|
|
693
|
+
| `SetActionEnabled(contextName, actionName, enabled)` | Enables or disables an action. |
|
|
694
|
+
| `RemoveContext(contextName)` | Removes a context and returns whether one was removed. |
|
|
695
|
+
| `RemoveAction(contextName, actionName)` | Removes an action and returns whether one was removed. |
|
|
696
|
+
| `RemoveBinding(contextName, actionName, bindingName)` | Removes a binding and returns whether one was removed. |
|
|
697
|
+
|
|
698
|
+
Client `InputServiceClient`:
|
|
699
|
+
|
|
700
|
+
| Method | Description |
|
|
701
|
+
| --- | --- |
|
|
702
|
+
| `GetRootFolder()` | Returns or creates the local root folder. |
|
|
703
|
+
| `GetContext(contextName)` | Returns an `InputContext?`. |
|
|
704
|
+
| `WaitForContext(contextName, timeoutSeconds?)` | Waits for a shared context to replicate. |
|
|
705
|
+
| `DefineLocalContext(config)` | Creates or updates a local-only context. |
|
|
706
|
+
| `ConfigureAction(contextName, actionConfig)` | Creates or updates an action under an existing context. |
|
|
707
|
+
| `ConfigureBinding(contextName, actionName, bindingConfig)` | Creates or updates a binding under an existing action. |
|
|
708
|
+
| `GetAction(contextName, actionName)` | Returns an `InputAction?`. |
|
|
709
|
+
| `GetBinding(contextName, actionName, bindingName)` | Returns an `InputBinding?`. |
|
|
710
|
+
| `GetActionSignals(contextName, actionName)` | Returns cached pressed, released, state, enabled, and preferred-binding signals. |
|
|
711
|
+
| `BindPressed(contextName, actionName, callback)` | Connects to an action's pressed signal. |
|
|
712
|
+
| `BindReleased(contextName, actionName, callback)` | Connects to an action's released signal. |
|
|
713
|
+
| `BindStateChanged(contextName, actionName, callback)` | Connects to an action's state changed signal. |
|
|
714
|
+
| `SetContextEnabled(contextName, enabled)` | Enables or disables a context locally. |
|
|
715
|
+
| `SetActionEnabled(contextName, actionName, enabled)` | Enables or disables an action locally. |
|
|
716
|
+
| `FireBinding(contextName, actionName, bindingName, state)` | Fires a scriptable binding with `boolean`, `number`, `Vector2`, or `Vector3` state. |
|
|
717
|
+
|
|
718
|
+
## Security
|
|
719
|
+
|
|
720
|
+
- No remotes are created for input claims, rebinding, or context changes.
|
|
721
|
+
- Server APIs accept configs only from server code.
|
|
722
|
+
- Client-side input signals should be treated as intent only.
|
|
723
|
+
- Client-created contexts and bindings are local convenience only.
|
|
724
|
+
- Games must validate gameplay effects on the server.
|