Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
4a7694c
feat: start implementing functionality for multi controller
RoushilS Jul 21, 2026
a21de87
fix: working version
RoushilS Jul 21, 2026
4ec88c7
feat: dropdown for gamepad selection
RoushilS Jul 21, 2026
20e199a
feat: logical gamepad slot assigning
RoushilS Jul 21, 2026
a454314
feat: add slot parameter
RoushilS Jul 22, 2026
8f97243
feat: implement playerslot across subclasses
RoushilS Jul 22, 2026
0e436c8
feat: sync with dropdsown
RoushilS Jul 22, 2026
2671b5b
feat: use correct slot...
RoushilS Jul 22, 2026
2f600b6
update keysused to be a method
RoushilS Jul 22, 2026
fee25b4
style:fix
RoushilS Jul 22, 2026
81b5456
fix: fix test
RoushilS Jul 22, 2026
421ca1a
feat: refactor controller input
RoushilS Jul 28, 2026
d49ebac
style:fix
RoushilS Jul 28, 2026
501ee74
Update InputSystem.test.ts
RoushilS Jul 28, 2026
0fee914
style:fix
RoushilS Jul 28, 2026
d27a733
fix: comment review
RoushilS Aug 4, 2026
c4883f3
style:fix
RoushilS Aug 4, 2026
00d574b
Merge branch 'dev' into roushils/151/multi-controller-support
RoushilS Aug 5, 2026
9432087
feat: add nametag differentiator
RoushilS Aug 5, 2026
36cfd35
Merge branch 'dev' into roushils/151/multi-controller-support
RoushilS Aug 10, 2026
5ef0658
fix: anyone can edit scheme bindings
RoushilS Aug 18, 2026
7c996e3
format:fix
RoushilS Aug 18, 2026
4268a0d
Merge branch 'dev' into roushils/151/multi-controller-support
RoushilS Aug 18, 2026
32323d2
Merge branch 'dev' into roushils/151/multi-controller-support
PepperLola Aug 19, 2026
970e8d8
fix: allow gpindex to be null
RoushilS Aug 19, 2026
2f95a16
Merge branch 'roushils/151/multi-controller-support' of https://githu…
RoushilS Aug 19, 2026
e8f870b
format:fix
RoushilS Aug 19, 2026
f9c76c6
fix: nametag and indexing
RoushilS Aug 20, 2026
df8b173
fix: replace any with SynthesisBrain
RoushilS Aug 20, 2026
00afa11
fix: refactor test along with new indexing system
RoushilS Aug 20, 2026
14091ca
fix: re-commit
RoushilS Aug 20, 2026
e8201b3
Revert "fix: re-commit"
RoushilS Aug 20, 2026
e7a5562
fix: refactor slot system.
RoushilS Aug 21, 2026
7b18b3a
chore: format
RoushilS Aug 21, 2026
4d6dddc
fix: correct outdated comments
RoushilS Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions fission/src/mirabuf/MirabufSceneObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
}

public get descriptiveName(): string {
return `${this.miraType === MiraType.ROBOT ? `[${this.multiplayerOwnerName ?? (this.brain instanceof SynthesisBrain ? this.brain.inputSchemeName : "Magic")}] ` : ""}${this.assemblyName}`
return `${this.miraType === MiraType.ROBOT ? `[${this.multiplayerOwnerName ?? (this.brain instanceof SynthesisBrain ? this.brain.inputSchemeLabel : "Magic")}] ` : ""}${this.assemblyName}`
}

public get assemblyName() {
Expand Down Expand Up @@ -245,7 +245,7 @@ class MirabufSceneObject extends SceneObject implements ContextSupplier {
const name =
this.nameOverride ??
(this._brain?.isSynthesis()
? this._brain.inputSchemeName
? this._brain.inputSchemeLabel
: this._brain?.isWPILib()
? "Magic"
: "Not Configured!")
Expand Down
132 changes: 98 additions & 34 deletions fission/src/systems/input/InputSchemeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,65 +109,129 @@ class InputSchemeManager {
return allSchemes
}

/** Creates an array of every input scheme that is not currently in use by a robot */
private static get _availableInputSchemes(): InputSchemeAvailability[] {
/**
* Computes the availability of every scheme.
*
* Gamepad schemes are shareable: one only counts as in-use for a candidate assignment when the
* *same layout* is already running on the *same controller slot* the candidate robot would use.
* Keyboard/touch schemes remain fully in-use once bound to any robot, and still conflict on shared keys.
*
* @param candidateBrainIndex - The brain being configured, excluded from its own in-use calculation.
* @param candidateSlot - The controller slot the candidate robot would use; drives the gamepad rules.
*/
private static computeAvailableSchemes(
candidateBrainIndex?: number,
candidateSlot?: number
): InputSchemeAvailability[] {
const allSchemes = this.allInputSchemes

// Remove schemes that have conflicts
const isGamepadKey = (key: KeyDescriptor) => key != null && key.includes("gamepad")
const record = (map: Map<KeyDescriptor, string[]>, key: KeyDescriptor, schemeName: string) => {
const entry = map.get(key)
if (entry != null) entry.push(schemeName)
else map.set(key, [schemeName])
}

const usedKeyMap = new Map<KeyDescriptor, string[]>()
const gamepadUsedKeyMap = new Map<KeyDescriptor, string[]>()
// Controller slots each gamepad layout is already assigned to on other robots.
const gamepadSlotsByScheme = new Map<string, Set<number>>()
const result: Record<string, InputSchemeAvailability> = {}
for (const scheme of InputSystem.brainIndexSchemeMap.values()) {

for (const [brainIndex, scheme] of InputSystem.brainIndexSchemeMap) {
if (brainIndex === candidateBrainIndex) continue

if (scheme.usesGamepad) {
const slot = InputSystem.getPlayerSlot(brainIndex)
const slots = gamepadSlotsByScheme.get(scheme.schemeName) ?? new Set<number>()
slots.add(slot)
gamepadSlotsByScheme.set(scheme.schemeName, slots)

scheme.inputs.forEach(input =>
input
.keysUsed(slot)
.filter(isGamepadKey)
.forEach(key => record(gamepadUsedKeyMap, key, scheme.schemeName))
)
continue
}

result[scheme.schemeName] = {
scheme,
status: InputSchemeUseType.IN_USE,
}
scheme?.inputs?.forEach(input => {
input.keysUsed
.filter(key => key != null)
.forEach(key => {
const entry = usedKeyMap.get(key)
if (entry != null) {
entry.push(scheme.schemeName)
} else {
usedKeyMap.set(key, [scheme.schemeName])
}
})
})
scheme?.inputs?.forEach(input =>
input
.keysUsed()
.filter(key => key != null && !isGamepadKey(key))
.forEach(key => record(usedKeyMap, key, scheme.schemeName))
)
}

allSchemes.forEach(scheme => {
const conflictingSchemes = scheme.inputs.flatMap(input =>
input.keysUsed.flatMap(key => usedKeyMap.get(key) ?? [])
)
if (conflictingSchemes.length > 0) {
result[scheme.schemeName] ??= {
scheme,
status: InputSchemeUseType.CONFLICT,
conflictingSchemeNames: [...new Set(conflictingSchemes)].join(", "),
}
} else {
result[scheme.schemeName] ??= {
scheme,
status: InputSchemeUseType.AVAILABLE,
if (scheme.usesGamepad) {
if (
candidateSlot != null &&
(gamepadSlotsByScheme.get(scheme.schemeName)?.has(candidateSlot) ?? false)
) {
result[scheme.schemeName] ??= { scheme, status: InputSchemeUseType.IN_USE }
return
}
const conflictingSchemes =
candidateSlot == null
? []
: scheme.inputs.flatMap(input =>
input
.keysUsed(candidateSlot)
.filter(isGamepadKey)
.flatMap(key => gamepadUsedKeyMap.get(key) ?? [])
)
result[scheme.schemeName] ??=
conflictingSchemes.length > 0
? {
scheme,
status: InputSchemeUseType.CONFLICT,
conflictingSchemeNames: [...new Set(conflictingSchemes)].join(", "),
}
: { scheme, status: InputSchemeUseType.AVAILABLE }
return
}

const conflictingSchemes = scheme.inputs.flatMap(input =>
input
.keysUsed()
.filter(key => key != null && !isGamepadKey(key))
.flatMap(key => usedKeyMap.get(key) ?? [])
)
result[scheme.schemeName] ??=
conflictingSchemes.length > 0
? {
scheme,
status: InputSchemeUseType.CONFLICT,
conflictingSchemeNames: [...new Set(conflictingSchemes)].join(", "),
}
: { scheme, status: InputSchemeUseType.AVAILABLE }
})
return Object.values(result)
}

/** Creates an array of every input scheme that is not currently in use by a robot */
public static availableInputSchemesByType(driveType?: DriveType): InputSchemeAvailability[] {
const allSchemes = this._availableInputSchemes
/** Creates an array of every input scheme, annotated with availability for the given controller slot. */
public static availableInputSchemesByType(
driveType?: DriveType,
candidateBrainIndex?: number,
candidateSlot?: number
): InputSchemeAvailability[] {
const allSchemes = this.computeAvailableSchemes(candidateBrainIndex, candidateSlot)
if (driveType == null) {
return allSchemes
}
return allSchemes.filter(entry => entry.scheme.supportedDrivetrains.includes(driveType))
}

/** Creates an array of every input scheme that is not currently in use by a robot */
/** Creates an array of every input scheme, annotated with availability for the given brain's controller slot. */
public static availableInputSchemesByBrain(brainIndex: number): InputSchemeAvailability[] {
const driveType = SynthesisBrain.brainIndexMap.get(brainIndex)?.driveType
return this.availableInputSchemesByType(driveType)
return this.availableInputSchemesByType(driveType, brainIndex, InputSystem.getPlayerSlot(brainIndex))
}

/**
Expand Down
112 changes: 96 additions & 16 deletions fission/src/systems/input/InputSystem.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { KeyCode } from "@/systems/input/KeyboardTypes.ts"
import { globalAddToast } from "@/ui/components/GlobalUIControls"
import { TouchControlsAxes } from "@/ui/components/TouchControls"
import World from "../World"
import WorldSystem from "../WorldSystem"
Expand All @@ -20,8 +21,8 @@ class InputSystem extends WorldSystem {
/** Whether the command palette is currently open, which blocks robot input */
private static _isCommandPaletteOpen: boolean = false

private static _gpIndex: number | null
public static gamepad: Gamepad | null
private static _gpIndexes: number[] = []
public static gamepads: (Gamepad | null)[] = []

/** Normalized joystick positions (-1 to 1) set by TouchControls component via react-joystick-component */
private static _leftJoystickPos: { x: number; y: number } = { x: 0, y: 0 }
Expand All @@ -34,12 +35,51 @@ class InputSystem extends WorldSystem {
return this._brainIndexSchemeMap
}

/**
* Maps a brain index to the logical controller slot (0 = first connected gamepad) that drives it.
* Controller assignment is per-robot, so this is intentionally separate from the (shared) input scheme.
*/
public static brainIndexPlayerSlotMap: Map<number, number> = new Map()

public static setBrainIndexSchemeMapping(index: number, scheme: InputScheme) {
this.brainIndexSchemeMap.set(index, scheme)
World.analyticsSystem?.event("Scheme Applied", {
isCustomized: scheme.customized,
schemeName: scheme.schemeName,
})

InputSystem.warnIfControllerShared(index)
}

/** @returns the controller slot assigned to the given brain, defaulting to slot 0. */
public static getPlayerSlot(brainIndex: number): number {
return InputSystem.brainIndexPlayerSlotMap.get(brainIndex) ?? 0
}

/** Assigns a controller slot to a brain and warns if that controller now drives multiple robots. */
public static setPlayerSlot(brainIndex: number, slot: number) {
InputSystem.brainIndexPlayerSlotMap.set(brainIndex, slot)
InputSystem.warnIfControllerShared(brainIndex)
}

/**
* Warns the user when a brain's gamepad scheme results in a single physical controller
* driving more than one robot. Gamepad schemes are intentionally shareable, so this is an
* informational heads-up rather than a block.
*/
private static warnIfControllerShared(brainIndex: number) {
const scheme = InputSystem.brainIndexSchemeMap.get(brainIndex)
if (scheme == null || !scheme.usesGamepad) return

const slot = InputSystem.getPlayerSlot(brainIndex)
let robotsOnSlot = 0
for (const [boundIndex, boundScheme] of InputSystem.brainIndexSchemeMap) {
if (boundScheme.usesGamepad && InputSystem.getPlayerSlot(boundIndex) === slot) robotsOnSlot++
}

if (robotsOnSlot >= 2) {
globalAddToast("warning", `Controller ${slot + 1} is now controlling ${robotsOnSlot} robots.`)
}
}
public static getBrainIndexSchemeMapping(index: number): InputScheme | undefined {
return this.brainIndexSchemeMap.get(index)
Expand Down Expand Up @@ -100,8 +140,15 @@ class InputSystem extends WorldSystem {

public update(_: number): void {
// Fetch current gamepad information
if (InputSystem._gpIndex == null) InputSystem.gamepad = null
else InputSystem.gamepad = navigator.getGamepads()[InputSystem._gpIndex]
const rawGamepads = navigator.getGamepads()

for (const lookupIndex of InputSystem._gpIndexes) {
if (lookupIndex == null || rawGamepads[lookupIndex] == null) {
InputSystem.gamepads[lookupIndex] = null
} else {
InputSystem.gamepads[lookupIndex] = rawGamepads[lookupIndex]
}
}

if (!document.hasFocus()) this.clearKeyData()

Expand Down Expand Up @@ -163,7 +210,10 @@ class InputSystem extends WorldSystem {
)
}

InputSystem._gpIndex = event.gamepad.index
const newIndex = event.gamepad.index
if (!InputSystem._gpIndexes.includes(newIndex)) {
InputSystem._gpIndexes.push(newIndex)
}
}

/* Called once when a gamepad is first disconnected */
Expand All @@ -172,7 +222,13 @@ class InputSystem extends WorldSystem {
console.log("Gamepad disconnected from index %d: %s", event.gamepad.index, event.gamepad.id)
}

InputSystem._gpIndex = null
const removedIndex = event.gamepad.index

InputSystem._gpIndexes = InputSystem._gpIndexes.filter(idx => idx !== removedIndex)

if (InputSystem.gamepads[removedIndex]) {
InputSystem.gamepads[removedIndex] = null
}
}

/**
Expand Down Expand Up @@ -204,7 +260,11 @@ class InputSystem extends WorldSystem {

if (targetScheme == null || targetInput == null) return 0

return targetInput.getValue(targetScheme.usesGamepad, targetScheme.usesTouchControls)
return targetInput.getValue(
targetScheme.usesGamepad,
targetScheme.usesTouchControls,
InputSystem.getPlayerSlot(brainIndex)
)
}

/**
Expand All @@ -223,16 +283,27 @@ class InputSystem extends WorldSystem {
)
}

/**
* @param {number} playerSlot The logical player slot.
* @returns {Gamepad | null} The gamepad in that slot, or null if the slot is unoccupied.
*/
public static getGamepadBySlot(playerSlot: number): Gamepad | null {
const rawIndex = InputSystem._gpIndexes[playerSlot]
if (rawIndex == null) return null
return InputSystem.gamepads[rawIndex] ?? null

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const rawIndex = InputSystem._gpIndexes[playerSlot]
if (rawIndex == null) return null
return InputSystem.gamepads[rawIndex] ?? null
return InputSystem.gamepads[playerSlot]

Given how _gpIndexes is constructed, InputSystem._gpIndexes[playerSlot] will just return playerSlot, meaning you can skip the lookup entirely. The ?? null is also only important if InputSystem.gamepads[playerSlot] could return undefined and you want to guarantee the function returns null; in this case I don't think that's possible.

}

/**
* @param {number} axisNumber The joystick axis index. Must be an integer.
* @param {number} playerSlot The logical player slot for the gamepad (0 = first connected). Must be an integer.
* @returns {number} A number between -1 and 1 based on the position of this axis or 0 if no gamepad is connected or the axis is not found.
*/
public static getGamepadAxis(axisNumber: number): number {
if (InputSystem.gamepad == null) return 0

if (axisNumber < 0 || axisNumber >= InputSystem.gamepad.axes.length) return 0
public static getGamepadAxis(axisNumber: number, playerSlot: number = 0): number {
const targetGamepad = InputSystem.getGamepadBySlot(playerSlot)
if (targetGamepad == null) return 0
if (axisNumber < 0 || axisNumber >= targetGamepad.axes.length) return 0

const value = InputSystem.gamepad.axes[axisNumber]
const value = targetGamepad.axes[axisNumber]

// Return value with a deadband
return Math.abs(value) < 0.15 ? 0 : value
Expand All @@ -241,19 +312,28 @@ class InputSystem extends WorldSystem {
/**
*
* @param {number} buttonNumber - The gamepad button index. Must be an integer.
* @param {number} playerSlot - The logical player slot for the gamepad (0 = first connected). Must be an integer.
* @returns {boolean} True if the button is pressed, false if not, a gamepad isn't connected, or the button can't be found.
*/
public static isGamepadButtonPressed(buttonNumber: number): boolean {
if (InputSystem.gamepad == null) return false
public static isGamepadButtonPressed(buttonNumber: number, playerSlot: number = 0): boolean {
const targetGamepad = InputSystem.getGamepadBySlot(playerSlot)
if (targetGamepad == null) return false

if (buttonNumber < 0 || buttonNumber >= InputSystem.gamepad.buttons.length) return false
if (buttonNumber < 0 || buttonNumber >= targetGamepad.buttons.length) return false

const button = InputSystem.gamepad.buttons[buttonNumber]
const button = targetGamepad.buttons[buttonNumber]
if (button == null) return false

return button.pressed
}

/**
* @returns {number} The number of currently connected gamepads, effectively all useable slots
*/
public static getConnectedPlayerCount(): number {
return InputSystem._gpIndexes.length
}

/** Returns a number between -1 and 1 from the touch controls */
public static getTouchControlsAxis(axisType: TouchControlsAxes): number {
if (axisType === TouchControlsAxes.LEFT_X) return InputSystem._leftJoystickPos.x
Expand Down
Loading
Loading