Unify gamepad repeat model with accelerating hold and analog scaling
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
type GamepadAction = "up" | "down" | "left" | "right" | "select" | "back" | "menu"
|
||||
type DirectionAction = "up" | "down" | "left" | "right"
|
||||
|
||||
const GAMEPAD_AXIS_THRESHOLD = 0.55
|
||||
const GAMEPAD_REPEAT_MS = 90
|
||||
const GAMEPAD_VERTICAL_REPEAT_MS = 140
|
||||
const ANALOG_DEADZONE = 0.25
|
||||
const DIGITAL_INITIAL_REPEAT_DELAY_MS = 150
|
||||
const DIGITAL_REPEAT_START_MS = 90
|
||||
const DIGITAL_REPEAT_MIN_MS = 16
|
||||
const DIGITAL_ACCEL_RAMP_MS = 3500
|
||||
const ANALOG_REPEAT_MAX_MS = 180
|
||||
const ANALOG_REPEAT_MIN_MS = 16
|
||||
|
||||
const KEY_BY_ACTION: Partial<Record<GamepadAction, string>> = {
|
||||
up: "ArrowUp",
|
||||
@@ -23,33 +28,23 @@ const gamepadPressedState: Record<GamepadAction, boolean> = {
|
||||
menu: false,
|
||||
}
|
||||
|
||||
const gamepadLastTriggerAt: Record<GamepadAction, number> = {
|
||||
const rawButtonPressedState: Record<number, boolean> = {
|
||||
2: false,
|
||||
3: false,
|
||||
4: false,
|
||||
5: false,
|
||||
}
|
||||
|
||||
const analogLastTriggerAt: Record<DirectionAction, number> = {
|
||||
up: 0,
|
||||
down: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
select: 0,
|
||||
back: 0,
|
||||
menu: 0,
|
||||
}
|
||||
|
||||
const rawButtonLastTriggerAt: Record<number, number> = {
|
||||
2: 0,
|
||||
3: 0,
|
||||
4: 0,
|
||||
5: 0,
|
||||
}
|
||||
|
||||
function applyRawButtonEvent(button: number, now: number) {
|
||||
const canTrigger = now - (rawButtonLastTriggerAt[button] ?? 0) >= GAMEPAD_REPEAT_MS
|
||||
if (!canTrigger) return
|
||||
rawButtonLastTriggerAt[button] = now
|
||||
const event = new CustomEvent("mediahive:gamepad-button", {
|
||||
detail: { button },
|
||||
cancelable: true,
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
let digitalHoldStartedAt = 0
|
||||
let digitalLastRepeatAt = 0
|
||||
let digitalRepeatCount = 0
|
||||
|
||||
let gamepadFrameId: number | null = null
|
||||
let gamepadInstalled = false
|
||||
@@ -66,21 +61,7 @@ function dispatchKey(key: string) {
|
||||
)
|
||||
}
|
||||
|
||||
function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: number) {
|
||||
const wasPressed = gamepadPressedState[action]
|
||||
gamepadPressedState[action] = isPressed
|
||||
|
||||
if (!isPressed) return
|
||||
|
||||
const isVertical = action === "up" || action === "down"
|
||||
const isHorizontal = action === "left" || action === "right"
|
||||
const shouldRepeat = isVertical || isHorizontal
|
||||
const repeatMs = isVertical ? GAMEPAD_VERTICAL_REPEAT_MS : GAMEPAD_REPEAT_MS
|
||||
const canTrigger =
|
||||
!wasPressed || (shouldRepeat && now - gamepadLastTriggerAt[action] >= repeatMs)
|
||||
if (!canTrigger) return
|
||||
|
||||
gamepadLastTriggerAt[action] = now
|
||||
function dispatchGamepadAction(action: GamepadAction): void {
|
||||
const actionEvent = new CustomEvent("mediahive:gamepad-action", {
|
||||
detail: { action },
|
||||
cancelable: true,
|
||||
@@ -94,6 +75,62 @@ function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: numb
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchRawButton(button: number): void {
|
||||
const event = new CustomEvent("mediahive:gamepad-button", {
|
||||
detail: { button },
|
||||
cancelable: true,
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
function triggerSinglePressAction(action: GamepadAction, isPressed: boolean): void {
|
||||
const wasPressed = gamepadPressedState[action]
|
||||
gamepadPressedState[action] = isPressed
|
||||
if (isPressed && !wasPressed) {
|
||||
dispatchGamepadAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
function triggerSinglePressRawButton(button: number, isPressed: boolean): void {
|
||||
const wasPressed = rawButtonPressedState[button] ?? false
|
||||
rawButtonPressedState[button] = isPressed
|
||||
if (isPressed && !wasPressed) {
|
||||
dispatchRawButton(button)
|
||||
}
|
||||
}
|
||||
|
||||
function getDigitalRepeatIntervalMs(holdMs: number): number {
|
||||
const progress = Math.min(Math.max(holdMs, 0), DIGITAL_ACCEL_RAMP_MS) / DIGITAL_ACCEL_RAMP_MS
|
||||
return Math.round(
|
||||
DIGITAL_REPEAT_START_MS - (DIGITAL_REPEAT_START_MS - DIGITAL_REPEAT_MIN_MS) * progress,
|
||||
)
|
||||
}
|
||||
|
||||
function getAnalogRepeatIntervalMs(intensity: number): number {
|
||||
const normalized = Math.min(Math.max(intensity, 0), 1)
|
||||
return Math.round(ANALOG_REPEAT_MAX_MS - (ANALOG_REPEAT_MAX_MS - ANALOG_REPEAT_MIN_MS) * normalized)
|
||||
}
|
||||
|
||||
function normalizeAxisIntensity(rawValue: number): number {
|
||||
if (rawValue <= ANALOG_DEADZONE) return 0
|
||||
return Math.min((rawValue - ANALOG_DEADZONE) / (1 - ANALOG_DEADZONE), 1)
|
||||
}
|
||||
|
||||
function applyAnalogDirection(action: DirectionAction, intensity: number, now: number): void {
|
||||
if (intensity <= 0) {
|
||||
analogLastTriggerAt[action] = 0
|
||||
return
|
||||
}
|
||||
|
||||
const last = analogLastTriggerAt[action]
|
||||
const repeatMs = getAnalogRepeatIntervalMs(intensity)
|
||||
|
||||
if (last === 0 || now - last >= repeatMs) {
|
||||
analogLastTriggerAt[action] = now
|
||||
dispatchGamepadAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
function resetPressedState() {
|
||||
gamepadPressedState.up = false
|
||||
gamepadPressedState.down = false
|
||||
@@ -102,6 +139,17 @@ function resetPressedState() {
|
||||
gamepadPressedState.select = false
|
||||
gamepadPressedState.back = false
|
||||
gamepadPressedState.menu = false
|
||||
rawButtonPressedState[2] = false
|
||||
rawButtonPressedState[3] = false
|
||||
rawButtonPressedState[4] = false
|
||||
rawButtonPressedState[5] = false
|
||||
analogLastTriggerAt.up = 0
|
||||
analogLastTriggerAt.down = 0
|
||||
analogLastTriggerAt.left = 0
|
||||
analogLastTriggerAt.right = 0
|
||||
digitalHoldStartedAt = 0
|
||||
digitalLastRepeatAt = 0
|
||||
digitalRepeatCount = 0
|
||||
}
|
||||
|
||||
function pollGamepad() {
|
||||
@@ -110,56 +158,104 @@ function pollGamepad() {
|
||||
const connectedGamepads = gamepads.filter((gp): gp is Gamepad => Boolean(gp && gp.connected))
|
||||
|
||||
if (connectedGamepads.length > 0) {
|
||||
let up = false
|
||||
let down = false
|
||||
let left = false
|
||||
let right = false
|
||||
let digitalUp = false
|
||||
let digitalDown = false
|
||||
let digitalLeft = false
|
||||
let digitalRight = false
|
||||
let analogUpIntensity = 0
|
||||
let analogDownIntensity = 0
|
||||
let analogLeftIntensity = 0
|
||||
let analogRightIntensity = 0
|
||||
let select = false
|
||||
let back = false
|
||||
let menu = false
|
||||
let raw2 = false
|
||||
let raw3 = false
|
||||
let raw4 = false
|
||||
let raw5 = false
|
||||
|
||||
for (const gamepad of connectedGamepads) {
|
||||
const axisX = gamepad.axes[0] ?? 0
|
||||
const axisY = gamepad.axes[1] ?? 0
|
||||
|
||||
up = up || Boolean(gamepad.buttons[12]?.pressed) || axisY <= -GAMEPAD_AXIS_THRESHOLD
|
||||
down = down || Boolean(gamepad.buttons[13]?.pressed) || axisY >= GAMEPAD_AXIS_THRESHOLD
|
||||
left = left || Boolean(gamepad.buttons[14]?.pressed) || axisX <= -GAMEPAD_AXIS_THRESHOLD
|
||||
right = right || Boolean(gamepad.buttons[15]?.pressed) || axisX >= GAMEPAD_AXIS_THRESHOLD
|
||||
digitalUp = digitalUp || Boolean(gamepad.buttons[12]?.pressed)
|
||||
digitalDown = digitalDown || Boolean(gamepad.buttons[13]?.pressed)
|
||||
digitalLeft = digitalLeft || Boolean(gamepad.buttons[14]?.pressed)
|
||||
digitalRight = digitalRight || Boolean(gamepad.buttons[15]?.pressed)
|
||||
|
||||
const upIntensity = normalizeAxisIntensity(-axisY)
|
||||
const downIntensity = normalizeAxisIntensity(axisY)
|
||||
const leftIntensity = normalizeAxisIntensity(-axisX)
|
||||
const rightIntensity = normalizeAxisIntensity(axisX)
|
||||
if (upIntensity > analogUpIntensity) analogUpIntensity = upIntensity
|
||||
if (downIntensity > analogDownIntensity) analogDownIntensity = downIntensity
|
||||
if (leftIntensity > analogLeftIntensity) analogLeftIntensity = leftIntensity
|
||||
if (rightIntensity > analogRightIntensity) analogRightIntensity = rightIntensity
|
||||
|
||||
// Xbox mapping on standard gamepads: A=0, B=1, X=2, Y=3
|
||||
select = select || Boolean(gamepad.buttons[0]?.pressed)
|
||||
back = back || Boolean(gamepad.buttons[1]?.pressed)
|
||||
// X/Square = backspace (handled by raw button event)
|
||||
// Y/Triangle = space (handled by raw button event)
|
||||
menu = menu || Boolean(gamepad.buttons[3]?.pressed)
|
||||
|
||||
if (gamepad.buttons[2]?.pressed) {
|
||||
applyRawButtonEvent(2, now)
|
||||
}
|
||||
if (gamepad.buttons[3]?.pressed) {
|
||||
applyRawButtonEvent(3, now)
|
||||
raw2 = raw2 || Boolean(gamepad.buttons[2]?.pressed)
|
||||
raw3 = raw3 || Boolean(gamepad.buttons[3]?.pressed)
|
||||
raw4 = raw4 || Boolean(gamepad.buttons[4]?.pressed)
|
||||
raw5 = raw5 || Boolean(gamepad.buttons[5]?.pressed)
|
||||
}
|
||||
|
||||
triggerSinglePressAction("up", digitalUp)
|
||||
triggerSinglePressAction("down", digitalDown)
|
||||
triggerSinglePressAction("left", digitalLeft)
|
||||
triggerSinglePressAction("right", digitalRight)
|
||||
triggerSinglePressAction("select", select)
|
||||
triggerSinglePressAction("back", back)
|
||||
triggerSinglePressAction("menu", menu)
|
||||
triggerSinglePressRawButton(2, raw2)
|
||||
triggerSinglePressRawButton(3, raw3)
|
||||
triggerSinglePressRawButton(4, raw4)
|
||||
triggerSinglePressRawButton(5, raw5)
|
||||
|
||||
const hasDigitalRepeatable =
|
||||
digitalUp || digitalDown || digitalLeft || digitalRight || raw2 || raw3 || raw4 || raw5
|
||||
|
||||
if (!hasDigitalRepeatable) {
|
||||
digitalHoldStartedAt = 0
|
||||
digitalLastRepeatAt = 0
|
||||
digitalRepeatCount = 0
|
||||
} else {
|
||||
if (digitalHoldStartedAt === 0) {
|
||||
digitalHoldStartedAt = now
|
||||
digitalLastRepeatAt = now
|
||||
digitalRepeatCount = 0
|
||||
}
|
||||
|
||||
// Shoulder buttons for cursor movement (handled by raw button event)
|
||||
const lb = Boolean(gamepad.buttons[4]?.pressed)
|
||||
const rb = Boolean(gamepad.buttons[5]?.pressed)
|
||||
const holdMs = now - digitalHoldStartedAt
|
||||
const repeatMs =
|
||||
digitalRepeatCount === 0
|
||||
? DIGITAL_INITIAL_REPEAT_DELAY_MS
|
||||
: getDigitalRepeatIntervalMs(holdMs)
|
||||
|
||||
if (lb) {
|
||||
applyRawButtonEvent(4, now)
|
||||
}
|
||||
if (rb) {
|
||||
applyRawButtonEvent(5, now)
|
||||
if (now - digitalLastRepeatAt >= repeatMs) {
|
||||
if (digitalUp) dispatchGamepadAction("up")
|
||||
if (digitalDown) dispatchGamepadAction("down")
|
||||
if (digitalLeft) dispatchGamepadAction("left")
|
||||
if (digitalRight) dispatchGamepadAction("right")
|
||||
if (raw2) dispatchRawButton(2)
|
||||
if (raw3) dispatchRawButton(3)
|
||||
if (raw4) dispatchRawButton(4)
|
||||
if (raw5) dispatchRawButton(5)
|
||||
|
||||
digitalLastRepeatAt = now
|
||||
digitalRepeatCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
applyGamepadAction("up", up, now)
|
||||
applyGamepadAction("down", down, now)
|
||||
applyGamepadAction("left", left, now)
|
||||
applyGamepadAction("right", right, now)
|
||||
applyGamepadAction("select", select, now)
|
||||
applyGamepadAction("back", back, now)
|
||||
applyGamepadAction("menu", menu, now)
|
||||
// Analog repeat has no initial delay and speed increases with stick deflection.
|
||||
// If digital d-pad direction is held, it owns that direction to avoid duplicate repeats.
|
||||
applyAnalogDirection("up", digitalUp ? 0 : analogUpIntensity, now)
|
||||
applyAnalogDirection("down", digitalDown ? 0 : analogDownIntensity, now)
|
||||
applyAnalogDirection("left", digitalLeft ? 0 : analogLeftIntensity, now)
|
||||
applyAnalogDirection("right", digitalRight ? 0 : analogRightIntensity, now)
|
||||
} else {
|
||||
resetPressedState()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user