diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a450fe..76b178d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # Changelog ## Unreleased +### Added + +- **Game controllers are now supported, alongside the keyboard.** Plug in an + Xbox, PlayStation, or other compatible controller and drive by feel: the right + and left triggers are the gas and brake, the left stick steers, the left bumper + is the clutch, and the A and X buttons shift up and down. Menus map to the + D-pad, the A button confirms, the B button goes back, and the Back button reads + controller help. The first controller is picked up automatically, hot-plugging + and unplugging are detected mid-game (unplugging pauses the drive), and spoken + prompts name controller buttons when you are on a pad and keys when you are on + the keyboard. Turn it off under Settings, Gameplay, Controller. The keyboard + always stays active. + ### Fixed - **Trucks into New York now take the George Washington Bridge, not the Holland diff --git a/README.md b/README.md index 246af9b..cc8b6fe 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,13 @@ the file from quarantine. ## Controls -### Menus +Freight Fate plays with the keyboard or a game controller, and both stay active +at all times. Spoken prompts name whichever you last used, so "press X to take +it" on the keyboard becomes "press D-pad down to take it" on a controller. + +### Keyboard + +#### Menus | Key | Action | | --- | --- | @@ -171,7 +177,7 @@ the file from quarantine. | Any letter | Jump to the next item starting with it | | F1 | Contextual help | -### Driving +#### Driving | Key | Action | | --- | --- | @@ -197,6 +203,63 @@ the file from quarantine. | F1 | List all controls | | Escape | Pause menu | +### Controller + +Plug in an Xbox, PlayStation, or other compatible controller and the game picks +up the first one automatically. It detects a controller connected or unplugged +mid-game — unplugging pauses the drive — and you can switch back to the keyboard +at any time. Button names below use the Xbox layout; the equivalents map +automatically on other pads. Turn controller support off under Settings → +Gameplay → Controller if you prefer keyboard only. + +#### Menus + +| Button | Action | +| --- | --- | +| D-pad Up / Down | Navigate | +| D-pad Left / Right | Adjust the selected option (hold to repeat) | +| A | Select (like Enter) | +| B | Back (like Escape) | +| Back / Select | Contextual help (like F1) | + +#### Driving + +| Button | Action | +| --- | --- | +| Left stick | Steering | +| Right trigger | Throttle | +| Left trigger | Brake (press fully for the hardest stop) | +| Left bumper (LB) | Clutch (manual mode) | +| A | Shift up a gear (manual mode) | +| X | Shift down a gear (manual mode) | +| Y | Adaptive cruise on / off | +| B | Speak speed, gear, RPM | +| D-pad Up | Route progress | +| D-pad Down | Take exit / signal a pull-over | +| D-pad Left | Weather and forecast | +| D-pad Right | Clock, deadline, hours of service | +| Left stick click (L3) | Horn | +| Right stick click (R3) | Engine brake toggle | +| Start | Pause / resume | +| Back / Select | Controller help | + +Hold the right bumper (RB) as a modifier for a second layer of driving bindings: + +| Button | Action | +| --- | --- | +| RB + A | Start / stop engine | +| RB + B | Fuel and range | +| RB + Y | Release / set parking brake | +| RB + D-pad Up | Next listed highway exit | +| RB + D-pad Down | Refuel and rest (stopped at a rest stop) | +| RB + D-pad Left / Right | Lower / raise cruise speed | +| RB + Start | Driving status menu | + +The left and right triggers are analog: hold them wherever you like for partial +throttle or braking, rather than the ramped hold the arrow keys use. There is no +controller emergency brake — press the left trigger all the way for the hardest +stop. + ### Air-brake model Freight Fate models air pressure without asking players to run a full CDL diff --git a/pyproject.toml b/pyproject.toml index 3e85075..3494b0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,10 @@ dependencies = [ [project.scripts] freight-fate = "freight_fate.app:main" +# Controller input diagnostic: logs the game's GameController events alongside +# the raw joystick layer to a file, for tracking down pads (e.g. DualSense) +# whose triggers/shoulders do not map through. Run: uv run controller-diagnostics +controller-diagnostics = "freight_fate.controller_diagnostics:main" [project.urls] Repository = "https://github.com/Orinks/Freight-fate" @@ -55,6 +59,12 @@ tooling = [ "osmium>=4.0.2", "openrouteservice>=2.2.0", ] +# Controller input diagnostic (`uv run controller-diagnostics`). It reuses the +# game's own input stack, so it needs only the core pygame dependency; listed +# here so the group resolves standalone via `uv run --group controller-diagnostics`. +controller-diagnostics = [ + "pygame>=2.6", +] [build-system] requires = ["hatchling"] diff --git a/src/freight_fate/app.py b/src/freight_fate/app.py index 15a42f8..0384fc9 100644 --- a/src/freight_fate/app.py +++ b/src/freight_fate/app.py @@ -12,6 +12,7 @@ from . import __version__ from .achievements import AchievementAward, award from .audio import AudioEngine +from .controller import ControllerManager from .data.world import World, get_world from .discord_presence import DiscordPresence from .models.economy import Economy @@ -28,6 +29,16 @@ WINDOW_SIZE = (900, 640) FPS = 60 + +_CONTROLLER_EVENTS = frozenset( + { + pygame.CONTROLLERBUTTONDOWN, + pygame.CONTROLLERBUTTONUP, + pygame.CONTROLLERAXISMOTION, + pygame.CONTROLLERDEVICEADDED, + pygame.CONTROLLERDEVICEREMOVED, + } +) BG_COLOR = (12, 12, 16) TEXT_COLOR = (235, 235, 225) HILIGHT_COLOR = (255, 210, 90) @@ -52,6 +63,7 @@ def __init__(self, app: App) -> None: self._app = app self.speech: Speech = app.speech self.audio: AudioEngine = app.audio + self.controller: ControllerManager = app.controller self.settings: Settings = app.settings self.world: World = app.world self.economy: Economy = app.economy @@ -150,6 +162,18 @@ def apply_presence(self) -> None: """Reflect the Discord presence setting (e.g. after a settings change).""" self._app.presence.set_enabled(self.settings.discord_presence) + def apply_controller(self) -> None: + """Reflect the controller setting (e.g. after a settings change).""" + self.controller.set_enabled(self.settings.controller_enabled) + + def apply_haptics(self) -> None: + """Reflect the haptics setting (e.g. after a settings change).""" + self.controller.set_haptics_enabled(self.settings.haptics_enabled) + + def control_hint(self, action: str) -> str: + """Name a control for a spoken prompt, following the active device.""" + return self.controller.hint(action) + def apply_speech(self) -> None: self.speech.select_event_backend( self.settings.event_backend if self.settings.sapi_events else None @@ -253,6 +277,10 @@ def award_achievement( class App: def __init__(self) -> None: os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1") + # Opt PS4/PS5 pads into HIDAPI rumble so their motors work like Xbox + # pads. Must be set before pygame.init(); Xbox/XInput needs no flag. + os.environ.setdefault("SDL_JOYSTICK_HIDAPI_PS4_RUMBLE", "1") + os.environ.setdefault("SDL_JOYSTICK_HIDAPI_PS5_RUMBLE", "1") if os.environ.get("FREIGHT_FATE_NO_SPEECH"): os.environ["SDL_VIDEODRIVER"] = "dummy" os.environ["SDL_AUDIODRIVER"] = "dummy" @@ -269,6 +297,10 @@ def __init__(self) -> None: self.world = get_world() self.economy = Economy() self.presence = DiscordPresence(enabled=self.settings.discord_presence) + self.controller = ControllerManager( + enabled=self.settings.controller_enabled, + haptics=self.settings.haptics_enabled, + ) self.ctx = GameContext(self) self.ctx.apply_volumes() self.ctx.apply_speech() @@ -304,6 +336,18 @@ def reset_to(self, state: State) -> None: self.states.pop().exit() self.push_state(state) + def _dispatch_controller(self, event: pygame.event.Event) -> None: + """Feed a controller event to the manager, then to the active state. + + The manager updates its cached axis/modifier/hot-plug state first; + only button presses are then handed to the state, which routes them + to the same methods keyboard events already call. + """ + self.controller.process_event(event) + button_event = event.type in (pygame.CONTROLLERBUTTONDOWN, pygame.CONTROLLERBUTTONUP) + if button_event and self.controller.active and self.state is not None: + self.state.handle_controller(event, self.controller) + # -- main loop ------------------------------------------------------------ def run(self, max_frames: int | None = None) -> None: @@ -321,8 +365,28 @@ def run(self, max_frames: int | None = None) -> None: for event in pygame.event.get(): if event.type == pygame.QUIT: self.running = False + elif event.type in _CONTROLLER_EVENTS: + self._dispatch_controller(event) elif self.state is not None: + if event.type == pygame.KEYDOWN: + self.controller.note_keyboard() self.state.handle_event(event) + # Auto-repeat (held D-pad left/right) and analog smoothing. + # Synthetic repeats go straight to the state (bypassing the + # manager, whose press state must not be reset) and only where + # the menu wants adjust-repeat -- driving keeps D-pad discrete. + repeats = self.controller.tick(dt) + state = self.state + if state is not None and getattr(state, "wants_controller_repeat", False): + for event in repeats: + state.handle_controller(event, self.controller) + if self.controller.take_disconnect(): + self.ctx.say( + "Controller disconnected. You can keep playing with the " + "keyboard, or reconnect your controller.", + ) + if self.state is not None: + self.state.on_controller_disconnect() if self.state is not None: self.state.update(dt) self.presence.update(self.state.presence()) @@ -363,6 +427,7 @@ def shutdown(self) -> None: self.ctx.profile.save() self.settings.save() self.presence.shutdown() + self.controller.shutdown() self.audio.shutdown() self.speech.shutdown() pygame.quit() diff --git a/src/freight_fate/controller.py b/src/freight_fate/controller.py new file mode 100644 index 0000000..389b281 --- /dev/null +++ b/src/freight_fate/controller.py @@ -0,0 +1,356 @@ +"""Game-controller support, isolated in one module. + +Freight Fate is keyboard- and screen-reader-first; this adds an *optional* +second input device that never displaces the keyboard. It uses the newer +``pygame._sdl2.controller`` API, which maps any recognized pad onto the Xbox +button layout, so friendly names (A/B/X/Y, bumpers, D-pad) are the same +regardless of the physical controller. + +Design notes: + +* Event-driven. Buttons and hot-plug arrive as SDL ``CONTROLLER*`` events. + The only continuous reads are the analog axes (steering, throttle, brake, + clutch): those are cached from ``CONTROLLERAXISMOTION`` events and smoothed + on :meth:`ControllerManager.tick`, so the driving loop reads cached state + rather than polling the device. +* Headless-safe. If the controller subsystem or a device is unavailable (CI, + dummy SDL drivers), the manager degrades to "no controller" without raising. +* One active controller. Several may be connected; we bind the first and, if + it is unplugged, raise a disconnect signal the app uses to pause and speak. +""" + +from __future__ import annotations + +import contextlib +import logging +from enum import Enum, auto + +import pygame + +from .input_hints import CONTROLLER, KEYBOARD, control_hint +from .rumble import RumbleEngine + +log = logging.getLogger(__name__) + +# Deadzones from the design spec: 10% for the sticks, 4% for the triggers. +STICK_DEADZONE = 0.10 +TRIGGER_DEADZONE = 0.04 +AXIS_MAX = 32767.0 + +# Held D-pad left/right auto-repeat for adjusting menu options. +REPEAT_DELAY_S = 0.30 # wait before the first repeat +REPEAT_FAST_S = 0.03 # fastest repeat once fully accelerated +REPEAT_RAMP_S = 1.50 # time held over which it accelerates + +# Minimal analog smoothing: triggers and clutch reach their target quickly but +# not instantly, matching how the keyboard ramps feel today. +SMOOTH_RATE = 18.0 + + +class ControllerAction(Enum): + MENU_UP = auto() + MENU_DOWN = auto() + ADJUST_LEFT = auto() + ADJUST_RIGHT = auto() + CONFIRM = auto() + BACK = auto() + HELP = auto() + + +_BUTTON_LABELS = { + pygame.CONTROLLER_BUTTON_A: "A button", + pygame.CONTROLLER_BUTTON_B: "B button", + pygame.CONTROLLER_BUTTON_X: "X button", + pygame.CONTROLLER_BUTTON_Y: "Y button", + pygame.CONTROLLER_BUTTON_LEFTSHOULDER: "left bumper", + pygame.CONTROLLER_BUTTON_RIGHTSHOULDER: "right bumper", + pygame.CONTROLLER_BUTTON_LEFTSTICK: "left stick click", + pygame.CONTROLLER_BUTTON_RIGHTSTICK: "right stick click", + pygame.CONTROLLER_BUTTON_DPAD_UP: "D-pad up", + pygame.CONTROLLER_BUTTON_DPAD_DOWN: "D-pad down", + pygame.CONTROLLER_BUTTON_DPAD_LEFT: "D-pad left", + pygame.CONTROLLER_BUTTON_DPAD_RIGHT: "D-pad right", + pygame.CONTROLLER_BUTTON_BACK: "Back button", + pygame.CONTROLLER_BUTTON_START: "Start button", +} + +_MENU_ACTIONS = { + pygame.CONTROLLER_BUTTON_DPAD_UP: ControllerAction.MENU_UP, + pygame.CONTROLLER_BUTTON_DPAD_DOWN: ControllerAction.MENU_DOWN, + pygame.CONTROLLER_BUTTON_DPAD_LEFT: ControllerAction.ADJUST_LEFT, + pygame.CONTROLLER_BUTTON_DPAD_RIGHT: ControllerAction.ADJUST_RIGHT, + pygame.CONTROLLER_BUTTON_A: ControllerAction.CONFIRM, + pygame.CONTROLLER_BUTTON_B: ControllerAction.BACK, + pygame.CONTROLLER_BUTTON_BACK: ControllerAction.HELP, +} + + +def _deadzone(value: float, dead: float) -> float: + """Rescale a normalized axis so it is 0 inside the deadzone and reaches + full range at the edge, avoiding a sudden jump just past the threshold.""" + magnitude = abs(value) + if magnitude <= dead: + return 0.0 + scaled = (magnitude - dead) / (1.0 - dead) + return scaled if value >= 0 else -scaled + + +class ControllerManager: + """Owns the active controller and translates its events for the game.""" + + def __init__(self, enabled: bool = True, haptics: bool = True) -> None: + self.enabled = enabled + self._haptics_enabled = haptics + self.active_device = KEYBOARD # which device the player last used + self._controller = None + self._instance_id: int | None = None + self._name = "" + self._disconnected = False # latched until the app consumes it + # Haptics live in their own pygame-free engine; we only supply the + # guarded device send/stop and drive it once per frame in tick(). + self.rumble = RumbleEngine(send=self._device_rumble, stop=self._device_stop_rumble) + + # Raw (event) and smoothed (tick) analog targets. The clutch is a digital + # bumper, so it stays instant like the keyboard Shift and is not smoothed. + self._steer_target = 0.0 + self._throttle_target = 0.0 + self._brake_target = 0.0 + self._clutch = 0.0 + self._throttle = 0.0 + self._brake = 0.0 + self.modifier = False # right bumper held -> secondary bindings + + # D-pad left/right auto-repeat. + self._repeat_button: int | None = None + self._repeat_countdown = 0.0 + self._repeat_held = 0.0 + + try: + from pygame._sdl2 import controller as sdl_controller + + self._sdl = sdl_controller + self._sdl.init() + self._open_first() + except Exception: # pragma: no cover - platform/driver dependent + log.info("Controller subsystem unavailable; keyboard only", exc_info=True) + self._sdl = None + + # -- device lifecycle ----------------------------------------------------- + + @property + def connected(self) -> bool: + return self._controller is not None + + @property + def active(self) -> bool: + """True when a controller is connected and controller input is on.""" + return self.enabled and self._controller is not None + + @property + def name(self) -> str: + return self._name + + def set_enabled(self, enabled: bool) -> None: + self.enabled = enabled + if not enabled: + self._reset_analog() + self.active_device = KEYBOARD + + def set_haptics_enabled(self, enabled: bool) -> None: + self._haptics_enabled = enabled + if not enabled: + self.rumble.reset() + + # -- haptics device layer ------------------------------------------------- + + def _device_rumble(self, low: float, high: float, duration_ms: int) -> None: + if not (self.active and self._haptics_enabled) or self._controller is None: + return + with contextlib.suppress(Exception): # pragma: no cover - driver dependent + self._controller.rumble(low, high, duration_ms) + + def _device_stop_rumble(self) -> None: + if self._controller is None: + return + with contextlib.suppress(Exception): # pragma: no cover - driver dependent + self._controller.stop_rumble() + + def _open_first(self) -> None: + if self._sdl is None: + return + for index in range(self._sdl.get_count()): + if self._sdl.is_controller(index): + try: + self._controller = self._sdl.Controller(index) + except Exception: # pragma: no cover - driver dependent + continue + self._instance_id = self._controller.id + try: + self._name = self._sdl.name_forindex(index) or "controller" + except Exception: # pragma: no cover + self._name = "controller" + log.info("Controller connected: %s", self._name) + return + + def take_disconnect(self) -> bool: + """Return and clear the pending-disconnect flag (app pauses on True).""" + if self._disconnected: + self._disconnected = False + return True + return False + + def _reset_analog(self) -> None: + self._steer_target = self._throttle_target = 0.0 + self._brake_target = self._clutch = 0.0 + self._throttle = self._brake = 0.0 + self.modifier = False + self._repeat_button = None + self.rumble.reset() + + # -- input notification --------------------------------------------------- + + def note_keyboard(self) -> None: + """Record that the keyboard was just used, so hints name keys.""" + self.active_device = KEYBOARD + + @property + def device(self) -> str: + return CONTROLLER if (self.active and self.active_device == CONTROLLER) else KEYBOARD + + def hint(self, action: str) -> str: + """Control phrase for ``action`` naming whatever device is in use.""" + return control_hint(action, self.device) + + # -- event handling ------------------------------------------------------- + + def process_event(self, event: pygame.event.Event) -> None: + """Update device/axis/modifier state from a CONTROLLER* event.""" + etype = event.type + if etype == pygame.CONTROLLERDEVICEADDED: + if self._controller is None: + self._open_first() + return + if etype == pygame.CONTROLLERDEVICEREMOVED: + if self._instance_id is not None and event.instance_id == self._instance_id: + self._controller = None + self._instance_id = None + self._reset_analog() + self._disconnected = True + log.info("Controller disconnected") + return + if not self.active or event.instance_id != self._instance_id: + return + if etype == pygame.CONTROLLERAXISMOTION: + self._on_axis(event.axis, event.value) + elif etype == pygame.CONTROLLERBUTTONDOWN: + self.active_device = CONTROLLER + self._on_button_down(event.button) + elif etype == pygame.CONTROLLERBUTTONUP: + self._on_button_up(event.button) + + def _on_axis(self, axis: int, raw: int) -> None: + value = raw / AXIS_MAX + if axis == pygame.CONTROLLER_AXIS_LEFTX: + self._steer_target = _deadzone(value, STICK_DEADZONE) + if self._steer_target: + self.active_device = CONTROLLER + elif axis == pygame.CONTROLLER_AXIS_TRIGGERRIGHT: + self._throttle_target = _deadzone(max(0.0, value), TRIGGER_DEADZONE) + if self._throttle_target: + self.active_device = CONTROLLER + elif axis == pygame.CONTROLLER_AXIS_TRIGGERLEFT: + self._brake_target = _deadzone(max(0.0, value), TRIGGER_DEADZONE) + if self._brake_target: + self.active_device = CONTROLLER + + def _on_button_down(self, button: int) -> None: + if button == pygame.CONTROLLER_BUTTON_RIGHTSHOULDER: + self.modifier = True + elif button == pygame.CONTROLLER_BUTTON_LEFTSHOULDER: + self._clutch = 1.0 # instant, like holding Shift + if button in ( + pygame.CONTROLLER_BUTTON_DPAD_LEFT, + pygame.CONTROLLER_BUTTON_DPAD_RIGHT, + ): + self._repeat_button = button + self._repeat_countdown = REPEAT_DELAY_S + self._repeat_held = 0.0 + + def _on_button_up(self, button: int) -> None: + if button == pygame.CONTROLLER_BUTTON_RIGHTSHOULDER: + self.modifier = False + elif button == pygame.CONTROLLER_BUTTON_LEFTSHOULDER: + self._clutch = 0.0 + if button == self._repeat_button: + self._repeat_button = None + + def menu_action(self, event: pygame.event.Event) -> ControllerAction | None: + """The semantic menu action for a CONTROLLERBUTTONDOWN, or None.""" + if event.type != pygame.CONTROLLERBUTTONDOWN: + return None + return _MENU_ACTIONS.get(event.button) + + def button_label(self, button: int) -> str: + return _BUTTON_LABELS.get(button, "a button") + + # -- per-frame update ----------------------------------------------------- + + def tick(self, dt: float) -> list[pygame.event.Event]: + """Smooth analog axes and return synthetic D-pad repeat events.""" + # Always drive the rumble engine so queued effects decay and expire even + # when no controller is bound; the device send is guarded either way. + self.rumble.tick(dt) + if not self.active: + return [] + self._throttle = self._smooth(self._throttle, self._throttle_target, dt) + self._brake = self._smooth(self._brake, self._brake_target, dt) + return self._repeats(dt) + + @staticmethod + def _smooth(current: float, target: float, dt: float) -> float: + return current + (target - current) * min(1.0, SMOOTH_RATE * dt) + + def _repeats(self, dt: float) -> list[pygame.event.Event]: + if self._repeat_button is None: + return [] + self._repeat_held += dt + self._repeat_countdown -= dt + events: list[pygame.event.Event] = [] + while self._repeat_countdown <= 0.0: + events.append( + pygame.event.Event( + pygame.CONTROLLERBUTTONDOWN, + button=self._repeat_button, + instance_id=self._instance_id, + ) + ) + fraction = min(1.0, self._repeat_held / REPEAT_RAMP_S) + interval = REPEAT_DELAY_S - (REPEAT_DELAY_S - REPEAT_FAST_S) * fraction + self._repeat_countdown += interval + return events + + # -- analog reads for the driving loop ------------------------------------ + + @property + def steering(self) -> float: + return self._steer_target if self.active else 0.0 + + @property + def throttle(self) -> float: + return self._throttle if self.active else 0.0 + + @property + def brake(self) -> float: + return self._brake if self.active else 0.0 + + @property + def clutch(self) -> float: + return self._clutch if self.active else 0.0 + + def shutdown(self) -> None: + self.rumble.reset() # silence the pad before we drop it + self._controller = None + if self._sdl is not None: + with contextlib.suppress(Exception): # pragma: no cover + self._sdl.quit() + self._sdl = None diff --git a/src/freight_fate/controller_diagnostics.py b/src/freight_fate/controller_diagnostics.py new file mode 100644 index 0000000..a74fca1 --- /dev/null +++ b/src/freight_fate/controller_diagnostics.py @@ -0,0 +1,275 @@ +"""Controller input diagnostic tool. + +Freight Fate reads controllers through SDL's *GameController* API +(``pygame._sdl2.controller``), which remaps any recognized pad onto the Xbox +button layout. On some pads -- notably the DualSense -- the D-pad, sticks, stick +clicks, and face buttons come through, but the triggers and shoulder buttons do +not. That is the signature of an SDL controller-*mapping* gap for that specific +device: the raw pad may still be emitting those inputs on the lower *joystick* +layer, but SDL never surfaces them as GameController events. + +To tell those cases apart, this tool listens on **both** layers at once and logs +them side by side: + +* ``[GC ]`` -- the GameController (``CONTROLLER*``) events, exactly what the game + itself sees. +* ``[JOY]`` -- the raw joystick (``JOY*``) events plus each device's GUID, name, + and axis/button counts, i.e. what SDL actually delivers before mapping. + +Press LT/RT/LB/RB on the problem pad and read which layer (if any) reports them: + +* ``[JOY]`` only -> a mapping gap; fixable via an ``SDL_GAMECONTROLLERCONFIG`` + mapping or a HIDAPI hint. +* neither -> the input never reaches SDL. +* both -> the issue is elsewhere in the game's input path. + +Everything is printed to the terminal and written to ``controller-diagnostics.log`` +in the current directory (overwritten on each launch). Exit with Ctrl+C; the log +file is left in place for review. +""" + +from __future__ import annotations + +import contextlib +import logging +import os +from pathlib import Path + +LOG_FILENAME = "controller-diagnostics.log" + +# The game normalizes analog axes against this; we report both raw and +# normalized so the numbers line up with what controller.py works with. +AXIS_MAX = 32767.0 + +log = logging.getLogger("freight_fate.controller_diagnostics") + + +def _prepare_environment() -> None: + """Reproduce the game's pre-``pygame.init()`` environment. + + These hints change how SDL binds PlayStation pads (they opt the DS4/DualSense + into HIDAPI), so matching them here keeps the diagnostic honest: the pad is + enumerated exactly as it is when the game runs. Mirrors ``app.App.__init__``. + """ + os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1") + os.environ.setdefault("SDL_JOYSTICK_HIDAPI_PS4_RUMBLE", "1") + os.environ.setdefault("SDL_JOYSTICK_HIDAPI_PS5_RUMBLE", "1") + + +def _configure_logging(log_path: Path) -> None: + """Log to the terminal and to a fresh file (overwritten each launch).""" + fmt = logging.Formatter("%(asctime)s %(levelname)s: %(message)s") + + stream = logging.StreamHandler() + stream.setFormatter(fmt) + + file_handler = logging.FileHandler(log_path, mode="w", encoding="utf-8") + file_handler.setFormatter(fmt) + + log.setLevel(logging.INFO) + log.handlers = [stream, file_handler] + log.propagate = False + + +def _build_reverse_map(prefix: str) -> dict[int, str]: + """Map pygame constant values back to friendly names for a given prefix. + + e.g. ``CONTROLLER_BUTTON_`` -> ``{0: "a", 1: "b", ...}`` so an event's button + index prints as a name instead of a bare number. + """ + import pygame + + mapping: dict[int, str] = {} + for name in dir(pygame): + if name.startswith(prefix): + value = getattr(pygame, name) + if isinstance(value, int): + mapping.setdefault(value, name[len(prefix) :].lower()) + return mapping + + +def _hat_str(value: tuple[int, int]) -> str: + x, y = value + vertical = {1: "up", -1: "down"}.get(y, "") + horizontal = {1: "right", -1: "left"}.get(x, "") + label = " ".join(part for part in (vertical, horizontal) if part) or "centered" + return f"{value} ({label})" + + +def _log_inventory(sdl_controller, pygame) -> None: + """Print a one-time snapshot of every device on both layers. + + The joystick GUID plus axis/button counts are the decisive clues for a + mapping gap: a DualSense with the "wrong" GUID or an unexpected axis count is + exactly what SDL fails to map onto the trigger/shoulder controls. + """ + log.info("=== Device inventory ===") + + gc_count = sdl_controller.get_count() + log.info("SDL sees %d device slot(s).", gc_count) + for index in range(gc_count): + is_ctrl = sdl_controller.is_controller(index) + try: + name = sdl_controller.name_forindex(index) or "unknown" + except Exception: # pragma: no cover - driver dependent + name = "unknown" + log.info( + " slot %d: recognized as GameController=%s, name=%r", + index, + is_ctrl, + name, + ) + + joy_count = pygame.joystick.get_count() + log.info("Raw joystick layer sees %d device(s).", joy_count) + for index in range(joy_count): + joy = pygame.joystick.Joystick(index) + with contextlib.suppress(Exception): # pragma: no cover - driver dependent + joy.init() + try: + power = joy.get_power_level() + except Exception: # pragma: no cover - not on every backend + power = "unknown" + log.info( + " joystick %d: name=%r guid=%s axes=%d buttons=%d hats=%d power=%s", + index, + joy.get_name(), + joy.get_guid(), + joy.get_numaxes(), + joy.get_numbuttons(), + joy.get_numhats(), + power, + ) + log.info("=== Press controls now. Triggers=LT/RT, shoulders=LB/RB. Ctrl+C to exit. ===") + + +def _open_controllers(sdl_controller) -> list: + """Open every recognized GameController so it emits CONTROLLER* events. + + Same open pattern as ``controller.ControllerManager._open_first``, but we + bind *all* of them rather than just the first, so a diagnostic session covers + whatever the tester has plugged in. + """ + controllers = [] + for index in range(sdl_controller.get_count()): + if sdl_controller.is_controller(index): + try: + controllers.append(sdl_controller.Controller(index)) + except Exception: # pragma: no cover - driver dependent + log.warning("Could not open GameController at slot %d", index, exc_info=True) + return controllers + + +def _run_loop(pygame, axis_names: dict[int, str], button_names: dict[int, str]) -> None: + clock = pygame.time.Clock() + while True: + for event in pygame.event.get(): + etype = event.type + + if etype == pygame.QUIT: + return + + # -- GameController layer (what the game sees) -------------------- + elif etype == pygame.CONTROLLERAXISMOTION: + name = axis_names.get(event.axis, f"axis{event.axis}") + log.info( + "[GC ] AXIS %-13s raw=%+6d norm=%+.3f (device %s)", + name, + event.value, + event.value / AXIS_MAX, + event.instance_id, + ) + elif etype in (pygame.CONTROLLERBUTTONDOWN, pygame.CONTROLLERBUTTONUP): + action = "DOWN" if etype == pygame.CONTROLLERBUTTONDOWN else "UP " + name = button_names.get(event.button, f"button{event.button}") + log.info( + "[GC ] BTN %-13s %s (device %s)", + name, + action, + event.instance_id, + ) + elif etype == pygame.CONTROLLERDEVICEADDED: + log.info("[GC ] device added (slot %s)", getattr(event, "device_index", "?")) + elif etype == pygame.CONTROLLERDEVICEREMOVED: + log.info("[GC ] device removed (device %s)", getattr(event, "instance_id", "?")) + + # -- Raw joystick layer (what SDL delivers pre-mapping) ---------- + elif etype == pygame.JOYAXISMOTION: + log.info( + "[JOY] AXIS index=%-2d value=%+.3f (device %s)", + event.axis, + event.value, + event.instance_id, + ) + elif etype in (pygame.JOYBUTTONDOWN, pygame.JOYBUTTONUP): + action = "DOWN" if etype == pygame.JOYBUTTONDOWN else "UP " + log.info( + "[JOY] BTN index=%-2d %s (device %s)", + event.button, + action, + event.instance_id, + ) + elif etype == pygame.JOYHATMOTION: + log.info( + "[JOY] HAT index=%-2d %s (device %s)", + event.hat, + _hat_str(event.value), + event.instance_id, + ) + elif etype == pygame.JOYDEVICEADDED: + joy = pygame.joystick.Joystick(event.device_index) + joy.init() + log.info( + "[JOY] device added: name=%r guid=%s", + joy.get_name(), + joy.get_guid(), + ) + elif etype == pygame.JOYDEVICEREMOVED: + log.info("[JOY] device removed (device %s)", getattr(event, "instance_id", "?")) + + clock.tick(60) + + +def main() -> int: + _prepare_environment() + + log_path = Path.cwd() / LOG_FILENAME + _configure_logging(log_path) + + import pygame + + log.info("Controller diagnostics starting.") + log.info("Logging to %s (overwritten on each launch).", log_path) + + pygame.init() + pygame.display.set_caption("Freight Fate - Controller Diagnostics") + # A visible window keeps the OS event pump alive; on Windows, controller and + # joystick events are not delivered reliably to a windowless process. + pygame.display.set_mode((480, 160)) + + # Both input layers, initialized together so the same physical pad reports on + # each: the GameController layer the game uses, and the raw joystick layer. + from pygame._sdl2 import controller as sdl_controller + + sdl_controller.init() + pygame.joystick.init() + + controllers = _open_controllers(sdl_controller) + _log_inventory(sdl_controller, pygame) + + axis_names = _build_reverse_map("CONTROLLER_AXIS_") + button_names = _build_reverse_map("CONTROLLER_BUTTON_") + + try: + _run_loop(pygame, axis_names, button_names) + except KeyboardInterrupt: + log.info("Interrupted (Ctrl+C). Exiting.") + finally: + controllers.clear() # drop references before quitting SDL + pygame.quit() + log.info("Controller diagnostics stopped. Log saved to %s", log_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/freight_fate/input_hints.py b/src/freight_fate/input_hints.py new file mode 100644 index 0000000..ae7aaed --- /dev/null +++ b/src/freight_fate/input_hints.py @@ -0,0 +1,61 @@ +"""Device-aware control hints for spoken prompts. + +Freight Fate speaks a lot of instructions that name a control -- "press P to +release the parking brake", "hold the Up arrow to accelerate". With controller +support those prompts must name the *right* control for whichever device the +player is actually using. This module keeps a single table mapping a semantic +action to its keyboard phrase and its controller phrase, plus one lookup +function, so the surrounding sentences stay natural and there is exactly one +place to edit a control name. + +The phrases are fragments meant to slot into a sentence after a verb, e.g. +``f"Press {control_hint('take_exit', device)} to take it."`` The keyboard side +matches the wording the game already used. +""" + +from __future__ import annotations + +KEYBOARD = "keyboard" +CONTROLLER = "controller" + +# action -> (keyboard phrase, controller phrase) +_HINTS: dict[str, tuple[str, str]] = { + "accelerate": ("the Up arrow", "the right trigger"), + "brake": ("the Down arrow", "the left trigger"), + "emergency_brake": ("B", "the left trigger fully"), + "clutch": ("Left Shift", "the left bumper"), + "gear_first": ("1", "the A button"), + "gears": ("1 through 0", "the A and X buttons"), + "reverse": ("Backspace", "the X button"), + "neutral": ("N", "neutral"), + "engine": ("E", "right bumper plus A"), + "parking_brake": ("P", "right bumper plus Y"), + "take_exit": ("X", "D-pad down"), + "rest": ("T", "right bumper plus D-pad down"), + "cruise_set": ("K", "the Y button"), + "cruise_adjust": ("plus and minus", "right bumper plus D-pad left or right"), + "speed": ("Space", "the B button"), + "status_menu": ("Tab", "right bumper plus Start"), + "fuel": ("F", "right bumper plus B"), + "clock": ("C", "D-pad right"), + "route": ("R", "D-pad up"), + "next_exit": ("Shift R", "right bumper plus D-pad up"), + "weather": ("V", "D-pad left"), + "lane": ("L", "the left stick"), + "horn": ("H", "the left stick click"), + "engine_brake": ("J", "the right stick click"), + "pause": ("Escape", "Start"), + "help": ("F1", "the Back button"), + "stop_event_voice": ("Left or Right Control", "the Back button"), +} + + +def control_hint(action: str, device: str = KEYBOARD) -> str: + """Phrase naming ``action``'s control for the active input ``device``. + + Falls back to the keyboard phrase for an unknown device, and returns the + action name itself if it is not in the table (so a typo is audible in a + test rather than crashing a prompt mid-drive). + """ + kb, pad = _HINTS.get(action, (action, action)) + return pad if device == CONTROLLER else kb diff --git a/src/freight_fate/rumble.py b/src/freight_fate/rumble.py new file mode 100644 index 0000000..92bd89f --- /dev/null +++ b/src/freight_fate/rumble.py @@ -0,0 +1,195 @@ +"""Controller haptics (rumble), isolated from the device layer. + +Freight Fate is audio-first; rumble only *reinforces* cues a player already +hears. This module owns the *shape* of every effect and knows nothing about +pygame or SDL: :class:`RumbleEngine` is handed a ``send(low, high, duration_ms)`` +and a ``stop()`` callable by :class:`~freight_fate.controller.ControllerManager`, +so it drives a real pad in the game and a list-recording fake in tests. + +The two motors map onto the request's geography: + +* ``low_frequency`` -- the large **left-grip** motor. +* ``high_frequency`` -- the small **right-grip** motor. + +So "starting at the right and moving to the left" is a high->low sweep, and an +alert "blip of the high-frequency side" is a short right-grip buzz. + +Effects come in two kinds: + +* **One-shots** (hazard sweep, alert blip, collision impact): an envelope + ``fn(t)`` over normalized time ``0..1`` that runs for a fixed duration and + then drops itself. +* **Continuous** (rumble strip, hard-brake shudder): a level refreshed every + frame while the condition holds. A short TTL means the caller never has to + send an explicit "off" -- the effect stops on its own a few frames after the + refreshes stop. + +Every frame :meth:`tick` combines whatever is active (per-motor max), issues a +single device call, and stops the device once on the active->idle edge. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable +from dataclasses import dataclass, field + +# Re-issued every frame with a duration a few frames long, so a dropped frame +# never leaves an audible gap; each new call replaces the last on SDL. +FRAME_RUMBLE_MS = 120 + +# Continuous effects are refreshed each frame; if the refresh stops, the effect +# lapses this many seconds later (a few frames at 60 fps). +CONTINUOUS_TTL_S = 0.05 + +# Hazard sweep: two overlapping raised-cosine bumps across a 0.75 s window, the +# right (high) leading and the left (low) trailing. +HAZARD_DURATION_MS = 750 +_HAZARD_HIGH_CENTER, _HAZARD_HIGH_HALF, _HAZARD_HIGH_PEAK = 0.25, 0.32, 0.85 +_HAZARD_LOW_CENTER, _HAZARD_LOW_HALF, _HAZARD_LOW_PEAK = 0.62, 0.34, 1.0 + +# Alert blip: a short right-grip (high) buzz. +ALERT_INTENSITY = 0.6 +ALERT_DURATION_MS = 120 + +# Collision impact: a heavy low thump that decays, with a brief high crack. +IMPACT_DURATION_MS = 350 + +# Rumble strip: both motors buzz between a non-zero floor and a ceiling, the +# right side pulsing faster than the left -- a deliberately harsh, alternating +# feel that never fully releases either motor. +_STRIP_LOW_HZ = 9.0 +_STRIP_HIGH_HZ = 16.0 + +# Hard braking: a continuous low shudder scaled by brake force, with a light +# high texture on top. +_BRAKE_SHUDDER_HZ = 22.0 +_BRAKE_TEXTURE_HZ = 30.0 + + +def _clamp01(value: float) -> float: + return 0.0 if value < 0.0 else 1.0 if value > 1.0 else value + + +def _bump(t: float, center: float, half: float, peak: float) -> float: + """A raised-cosine (Hann) bump peaking at ``center`` with half-width + ``half``; zero outside the window. Used to shape the hazard sweep.""" + d = abs(t - center) + if d >= half: + return 0.0 + return peak * 0.5 * (1.0 + math.cos(math.pi * d / half)) + + +def _osc(phase: float, hz: float) -> float: + """A 0..1 oscillation at ``hz`` cycles per second.""" + return 0.5 * (1.0 + math.sin(2.0 * math.pi * hz * phase)) + + +@dataclass +class _OneShot: + duration: float # seconds + fn: Callable[[float], tuple[float, float]] # t in 0..1 -> (low, high) + elapsed: float = 0.0 + + +@dataclass +class RumbleEngine: + """Schedules and mixes haptic effects, driving an injected device.""" + + send: Callable[[float, float, int], None] + stop: Callable[[], None] + + _phase: float = 0.0 + _oneshots: list[_OneShot] = field(default_factory=list) + _strip_level: float = 0.0 + _strip_ttl: float = 0.0 + _brake_level: float = 0.0 + _brake_ttl: float = 0.0 + _active: bool = False + + # -- one-shot effects ----------------------------------------------------- + + def alert( + self, intensity: float = ALERT_INTENSITY, duration_ms: int = ALERT_DURATION_MS + ) -> None: + """A short high-frequency blip that accompanies an alert.""" + amp = _clamp01(intensity) + self._oneshots.append(_OneShot(duration_ms / 1000.0, lambda _t: (0.0, amp))) + + def hazard(self) -> None: + """The 750 ms right->left sweep for a communicated hazard.""" + + def envelope(t: float) -> tuple[float, float]: + high = _bump(t, _HAZARD_HIGH_CENTER, _HAZARD_HIGH_HALF, _HAZARD_HIGH_PEAK) + low = _bump(t, _HAZARD_LOW_CENTER, _HAZARD_LOW_HALF, _HAZARD_LOW_PEAK) + return low, high + + self._oneshots.append(_OneShot(HAZARD_DURATION_MS / 1000.0, envelope)) + + def impact(self, severity: float) -> None: + """A heavy low thump for a collision (louder than an alert blip).""" + sev = _clamp01(severity) + low_peak = 0.6 + 0.4 * sev + high_peak = 0.4 * sev + + def envelope(t: float) -> tuple[float, float]: + low = low_peak * (1.0 - t) ** 1.5 # quick attack, decaying thump + high = high_peak * max(0.0, 1.0 - 4.0 * t) # brief crack at the hit + return low, high + + self._oneshots.append(_OneShot(IMPACT_DURATION_MS / 1000.0, envelope)) + + # -- continuous effects (refresh each frame while active) ----------------- + + def rumble_strip(self, level: float) -> None: + """Refresh the harsh rumble-strip buzz; ``level`` is 0..1.""" + self._strip_level = _clamp01(level) + self._strip_ttl = CONTINUOUS_TTL_S + + def hard_brake(self, level: float) -> None: + """Refresh the hard-braking shudder; ``level`` is 0..1.""" + self._brake_level = _clamp01(level) + self._brake_ttl = CONTINUOUS_TTL_S + + # -- per-frame drive ------------------------------------------------------ + + def tick(self, dt: float) -> None: + self._phase += dt + low = high = 0.0 + + self._strip_ttl -= dt + if self._strip_ttl > 0.0: + s = 0.55 + 0.45 * self._strip_level + low = max(low, s * (0.55 + 0.45 * _osc(self._phase, _STRIP_LOW_HZ))) + high = max(high, s * (0.60 + 0.40 * _osc(self._phase, _STRIP_HIGH_HZ))) + + self._brake_ttl -= dt + if self._brake_ttl > 0.0: + shudder = 0.85 + 0.15 * _osc(self._phase, _BRAKE_SHUDDER_HZ) + low = max(low, (0.35 + 0.55 * self._brake_level) * shudder) + high = max(high, 0.15 * self._brake_level * _osc(self._phase, _BRAKE_TEXTURE_HZ)) + + for eff in self._oneshots: + eff.elapsed += dt + self._oneshots = [e for e in self._oneshots if e.elapsed < e.duration] + for eff in self._oneshots: + elow, ehigh = eff.fn(eff.elapsed / eff.duration) + low = max(low, elow) + high = max(high, ehigh) + + low, high = _clamp01(low), _clamp01(high) + if low > 0.0 or high > 0.0: + self.send(low, high, FRAME_RUMBLE_MS) + self._active = True + elif self._active: + self.stop() + self._active = False + + def reset(self) -> None: + """Drop every effect and silence the device (disconnect / haptics off).""" + self._oneshots.clear() + self._strip_ttl = self._brake_ttl = 0.0 + self._strip_level = self._brake_level = 0.0 + if self._active: + self._active = False + self.stop() diff --git a/src/freight_fate/settings.py b/src/freight_fate/settings.py index 302763b..a5eaab4 100644 --- a/src/freight_fate/settings.py +++ b/src/freight_fate/settings.py @@ -40,6 +40,8 @@ class Settings: update_channel: str = "" # "stable"/"dev"; "" follows this build's channel skipped_update: str = "" # release tag the player chose to skip discord_presence: bool = True # show broad activity in Discord (privacy-safe) + controller_enabled: bool = True # accept game-controller input alongside the keyboard + haptics_enabled: bool = True # rumble/vibration feedback on the controller @property def path(self): @@ -78,6 +80,10 @@ def load(cls) -> Settings: s.update_channel = "" if not isinstance(s.event_backend, str) or not s.event_backend: s.event_backend = "SAPI" + if not isinstance(s.controller_enabled, bool): + s.controller_enabled = True + if not isinstance(s.haptics_enabled, bool): + s.haptics_enabled = True for attr in ( "master_volume", "sfx_volume", diff --git a/src/freight_fate/states/base.py b/src/freight_fate/states/base.py index 8d4f4bd..d796482 100644 --- a/src/freight_fate/states/base.py +++ b/src/freight_fate/states/base.py @@ -45,6 +45,38 @@ def exit(self) -> None: def handle_event(self, event: pygame.event.Event) -> None: pass + # Controller buttons a plain keyboard-driven state understands, translated + # into the key events it already handles. This keeps simple screens (update + # prompts, name entry, the help reader) usable from a controller without + # bespoke code. MenuState and the driving state override this entirely. + _CONTROLLER_KEYS = None # built lazily to avoid importing pygame constants early + + def handle_controller(self, event: pygame.event.Event, manager) -> None: + if event.type != pygame.CONTROLLERBUTTONDOWN: + return + keys = State._controller_key_map() + key = keys.get(event.button) + if key is not None: + self.handle_event(pygame.event.Event(pygame.KEYDOWN, key=key, unicode="")) + + @staticmethod + def _controller_key_map() -> dict[int, int]: + if State._CONTROLLER_KEYS is None: + State._CONTROLLER_KEYS = { + pygame.CONTROLLER_BUTTON_A: pygame.K_RETURN, + pygame.CONTROLLER_BUTTON_B: pygame.K_ESCAPE, + pygame.CONTROLLER_BUTTON_BACK: pygame.K_F1, + pygame.CONTROLLER_BUTTON_DPAD_UP: pygame.K_UP, + pygame.CONTROLLER_BUTTON_DPAD_DOWN: pygame.K_DOWN, + pygame.CONTROLLER_BUTTON_DPAD_LEFT: pygame.K_LEFT, + pygame.CONTROLLER_BUTTON_DPAD_RIGHT: pygame.K_RIGHT, + } + return State._CONTROLLER_KEYS + + def on_controller_disconnect(self) -> None: + """Called when the active controller is unplugged. The driving state + pauses; menus keep going on the keyboard.""" + def update(self, dt: float) -> None: update_music_rotation = getattr(self.ctx, "update_music_rotation", None) if update_music_rotation is not None: @@ -172,6 +204,34 @@ def handle_event(self, event: pygame.event.Event) -> None: elif event.unicode and event.unicode.isalnum(): self._first_letter_jump(event.unicode.lower()) + # -- controller ----------------------------------------------------------- + + # Menus opt into held D-pad left/right auto-repeat for adjusting options. + wants_controller_repeat = True + + def adjust(self, direction: int) -> None: + """Change the current option (D-pad left/right). No-op unless the menu + has adjustable options; ``SettingsCategoryState`` overrides this.""" + + def handle_controller(self, event: pygame.event.Event, manager) -> None: + from ..controller import ControllerAction + + action = manager.menu_action(event) + if action == ControllerAction.MENU_DOWN: + self.move(1) + elif action == ControllerAction.MENU_UP: + self.move(-1) + elif action == ControllerAction.ADJUST_RIGHT: + self.adjust(1) + elif action == ControllerAction.ADJUST_LEFT: + self.adjust(-1) + elif action == ControllerAction.CONFIRM: + self.activate() + elif action == ControllerAction.BACK: + self.go_back() + elif action == ControllerAction.HELP: + self.ctx.say(self.current_help()) + def _first_letter_jump(self, char: str) -> None: if not self.items: return diff --git a/src/freight_fate/states/driving.py b/src/freight_fate/states/driving.py index 1f450f3..63fc81d 100644 --- a/src/freight_fate/states/driving.py +++ b/src/freight_fate/states/driving.py @@ -118,10 +118,11 @@ def __init__( self._low_air_said = self.truck.air_low_warning self._spring_brake_said = self.truck.spring_brakes_active self._brake_lockout_cue_timer = 0.0 + self._brake_air_hissed = False # rising-edge guard for the brake-apply hiss self._lane_rumble_timer = 0.0 self._lane_guidance_state = "center" self._reverse_cue_active = False - self._status_text = "Press E to start the engine." + self._status_text = f"Press {self.ctx.control_hint('engine')} to start the engine." def _terse_speech(self) -> bool: return self.ctx.settings.speech_verbosity == 0 @@ -269,7 +270,8 @@ def enter(self) -> None: f"It is {now}. Transmission is {mode}. " f"Weather: {self.weather.describe()}. " f"You are parked. {self._engine_entry_instruction()} " - "When air pressure is ready, press P to release the parking brake.", + "When air pressure is ready, press " + f"{self.ctx.control_hint('parking_brake')} to release the parking brake.", interrupt=False, ) else: @@ -291,7 +293,7 @@ def enter(self) -> None: f"Transmission is {mode}. " f"Weather: {self.weather.describe()}. " f"{self._engine_entry_instruction()} " - "F1 lists the controls.", + f"{self.ctx.control_hint('help')} lists the controls.", interrupt=False, ) if self.tutorial: @@ -304,7 +306,9 @@ def enter(self) -> None: def _engine_entry_instruction(self) -> str: if self.truck.engine_on: return "Engine idling; build air pressure if needed." - return "Press E to start the engine and build air pressure." + return ( + f"Press {self.ctx.control_hint('engine')} to start the engine and build air pressure." + ) def _parked_entry_status(self) -> str: engine = "Engine idling" if self.truck.engine_on else "Engine off" diff --git a/src/freight_fate/states/driving_controls.py b/src/freight_fate/states/driving_controls.py index 40ab129..de5c088 100644 --- a/src/freight_fate/states/driving_controls.py +++ b/src/freight_fate/states/driving_controls.py @@ -32,11 +32,7 @@ def handle_event(self, event: pygame.event.Event) -> None: elif key in GEAR_KEYS and not tr.automatic: self._manual_shift(GEAR_KEYS[key]) elif key == pygame.K_j: - if self.truck.throttle > 0.05 and not self.truck.engine_brake: - self.ctx.say("Release the accelerator before turning the engine brake on.") - return - self.truck.engine_brake = not self.truck.engine_brake - self.ctx.say("Engine brake on." if self.truck.engine_brake else "Engine brake off.") + self._toggle_engine_brake() elif key == pygame.K_p: self._toggle_parking_brake() elif key == pygame.K_h: @@ -78,53 +74,174 @@ def handle_event(self, event: pygame.event.Event) -> None: elif key == pygame.K_u: self._speak_upcoming() elif key == pygame.K_F1: - objective_help = ( - f"Your current objective is pickup: drive to {self._pickup_facility_text()}, " - "stop at the gate, then check in and load. " - if self.phase == DRIVE_PHASE_PICKUP - else "Pickup and loading are complete. At your destination, stop, " - "then dock and deliver. " - ) - self.ctx.say( - "Hold Up arrow to accelerate, Down arrow to brake. " - "When stopped in automatic, hold Down arrow to reverse slowly; " - "touch Up arrow to brake and return to forward. " - "Hold B for the emergency brake, the hardest possible stop. " - "K sets adaptive cruise at your current speed; bad weather " - "increases the following gap, sharp posted-limit drops make it " - "slow early, and braking cancels. Plus and minus, including " - "the keypad keys, raise and lower the cruise speed by five, so " - "you can dial it up to the speed you want; it will not hold " - "above the posted limit. " - "X takes the next announced exit, called out by its number " - "when known: slow to 45 for the ramp, then brake to a stop for " - "the rest stop menu. X also signals a pull-over if a trooper " - "lights you up for speeding: signal, then brake to a stop. " - "C also speaks the date and season. " - "E starts the engine, and stops it only below 5 miles per hour. " - "Air pressure must build before the truck can move. " - "Press P to release or set the parking brake; if pressure is " - "below 100 psi, wait with the engine running. " - f"{objective_help}" - "Space speed, and cruise set speed when cruise is on. " - "S posted speed limit. Tab status menu. F fuel. " - "C clock, deadline, and hours of service. " - "R route. Shift R next listed highway exit. V weather. L lane position. " - "A repeats the last announcement. U reads what is coming up: " - "imposed limits, stops, and exits ahead. " - "Left or Right Control stops the driving event voice. " - "Left and Right arrows steer when lane drift is enabled. " - "T route POI menu when already stopped " - "at one: available actions may include fuel, break, sleep, " - "inspect, roadside assistance, or save when source-backed. H horn. " - "J engine brake. Escape pause menu. " - + ( - "" - if self.truck.transmission.automatic - else "Hold Left Shift for clutch, then 1 through 0 for gears, " - "Backspace for reverse, N for neutral." - ) + self._speak_driving_help() + + def _objective_help(self) -> str: + return ( + f"Your current objective is pickup: drive to {self._pickup_facility_text()}, " + "stop at the gate, then check in and load. " + if self.phase == DRIVE_PHASE_PICKUP + else "Pickup and loading are complete. At your destination, stop, " + "then dock and deliver. " + ) + + def _speak_driving_help(self) -> None: + """F1 help: keyboard or controller layout, following the device in use.""" + if self.ctx.controller.device == "controller": + self._speak_controller_help() + else: + self._speak_keyboard_help() + + def _speak_keyboard_help(self) -> None: + objective_help = self._objective_help() + self.ctx.say( + "Hold Up arrow to accelerate, Down arrow to brake. " + "When stopped in automatic, hold Down arrow to reverse slowly; " + "touch Up arrow to brake and return to forward. " + "Hold B for the emergency brake, the hardest possible stop. " + "K sets adaptive cruise at your current speed; bad weather " + "increases the following gap, sharp posted-limit drops make it " + "slow early, and braking cancels. Plus and minus, including " + "the keypad keys, raise and lower the cruise speed by five, so " + "you can dial it up to the speed you want; it will not hold " + "above the posted limit. " + "X takes the next announced exit, called out by its number " + "when known: slow to 45 for the ramp, then brake to a stop for " + "the rest stop menu. X also signals a pull-over if a trooper " + "lights you up for speeding: signal, then brake to a stop. " + "C also speaks the date and season. " + "E starts the engine, and stops it only below 5 miles per hour. " + "Air pressure must build before the truck can move. " + "Press P to release or set the parking brake; if pressure is " + "below 100 psi, wait with the engine running. " + f"{objective_help}" + "Space speed, and cruise set speed when cruise is on. " + "S posted speed limit. Tab status menu. F fuel. " + "C clock, deadline, and hours of service. " + "R route. Shift R next listed highway exit. V weather. L lane position. " + "A repeats the last announcement. U reads what is coming up: " + "imposed limits, stops, and exits ahead. " + "Left or Right Control stops the driving event voice. " + "Left and Right arrows steer when lane drift is enabled. " + "T route POI menu when already stopped " + "at one: available actions may include fuel, break, sleep, " + "inspect, roadside assistance, or save when source-backed. H horn. " + "J engine brake. Escape pause menu. " + + ( + "" + if self.truck.transmission.automatic + else "Hold Left Shift for clutch, then 1 through 0 for gears, " + "Backspace for reverse, N for neutral." ) + ) + + def _speak_controller_help(self) -> None: + """Controller layout help, spoken from the Back button or F1 on a pad.""" + manual = not self.truck.transmission.automatic + gears = ( + "The A button shifts up a gear and the X button shifts down, while " + "you hold the left bumper for the clutch. " + if manual + else "" + ) + self.ctx.say( + "Right trigger is the gas, left trigger the brake; press the left " + "trigger fully for the hardest stop. The left stick steers when lane " + "drift is on. " + f"{gears}" + "The Y button sets adaptive cruise; hold the right bumper and press " + "D-pad left or right to lower or raise the cruise speed by five. " + "D-pad down takes the next announced exit, or signals a pull-over. " + "D-pad up reads your route, D-pad left the weather, D-pad right the " + "clock. The B button speaks your speed. Click the left stick to honk, " + "the right stick to toggle the engine brake. " + "Hold the right bumper for the second layer: plus A starts or stops " + "the engine, plus B reads fuel, plus Y sets or releases the parking " + "brake, plus D-pad up reads the next listed exit, plus D-pad down " + "opens rest-stop actions, and plus Start opens the status menu. " + "Start pauses and unpauses. The Back button repeats this help. " + f"{self._objective_help()}" + ) + + def _toggle_engine_brake(self) -> None: + if self.truck.throttle > 0.05 and not self.truck.engine_brake: + self.ctx.say("Release the accelerator before turning the engine brake on.") + return + self.truck.engine_brake = not self.truck.engine_brake + self.ctx.say("Engine brake on." if self.truck.engine_brake else "Engine brake off.") + + def _shift_relative(self, delta: int) -> None: + """Controller next/previous gear: step one gear from the current one.""" + tr = self.truck.transmission + if tr.automatic: + return + target = max(REVERSE, min(tr.num_gears, tr.gear + delta)) + if target != tr.gear: + self._manual_shift(target) + + def handle_controller(self, event: pygame.event.Event, manager) -> None: + button = event.button + if event.type == pygame.CONTROLLERBUTTONUP: + if button == pygame.CONTROLLER_BUTTON_LEFTSTICK: + self.ctx.audio.horn_stop() # release L3 to stop the horn + return + if event.type != pygame.CONTROLLERBUTTONDOWN: + return + if manager.modifier: + self._handle_controller_modified(button) + return + if button == pygame.CONTROLLER_BUTTON_A: + self._shift_relative(1) + elif button == pygame.CONTROLLER_BUTTON_X: + self._shift_relative(-1) + elif button == pygame.CONTROLLER_BUTTON_B: + self._speak_speed() + elif button == pygame.CONTROLLER_BUTTON_Y: + self._toggle_cruise() + elif button == pygame.CONTROLLER_BUTTON_START: + self.ctx.audio.horn_stop() + self.ctx.push_state(PauseMenuState(self.ctx, self)) + elif button == pygame.CONTROLLER_BUTTON_LEFTSTICK: + self.ctx.audio.horn_start() + elif button == pygame.CONTROLLER_BUTTON_RIGHTSTICK: + self._toggle_engine_brake() + elif button == pygame.CONTROLLER_BUTTON_DPAD_UP: + self.ctx.say(self.trip.progress_summary(self.ctx.settings.imperial_units)) + elif button == pygame.CONTROLLER_BUTTON_DPAD_DOWN: + if self._pull_over is not None: + self._signal_pull_over() + else: + self._take_exit() + elif button == pygame.CONTROLLER_BUTTON_DPAD_LEFT: + self._speak_weather() + elif button == pygame.CONTROLLER_BUTTON_DPAD_RIGHT: + self._speak_clock() + elif button == pygame.CONTROLLER_BUTTON_BACK: + self._speak_controller_help() + + def _handle_controller_modified(self, button: int) -> None: + """Secondary bindings while the right bumper (modifier) is held.""" + if button == pygame.CONTROLLER_BUTTON_DPAD_UP: + self.ctx.say(self.trip.next_exit_context()) + elif button == pygame.CONTROLLER_BUTTON_DPAD_DOWN: + self._try_rest_stop() + elif button == pygame.CONTROLLER_BUTTON_DPAD_LEFT: + self._adjust_cruise(-CRUISE_STEP_MPH) + elif button == pygame.CONTROLLER_BUTTON_DPAD_RIGHT: + self._adjust_cruise(CRUISE_STEP_MPH) + elif button == pygame.CONTROLLER_BUTTON_A: + self._toggle_engine() + elif button == pygame.CONTROLLER_BUTTON_B: + self._speak_fuel() + elif button == pygame.CONTROLLER_BUTTON_Y: + self._toggle_parking_brake() + elif button == pygame.CONTROLLER_BUTTON_START: + self.ctx.push_state(DrivingStatusState(self.ctx, self)) + + def on_controller_disconnect(self) -> None: + # Pause so an unplugged pad mid-drive does not leave the truck rolling. + self.ctx.audio.horn_stop() + self.ctx.push_state(PauseMenuState(self.ctx, self)) def _toggle_engine(self) -> None: t = self.truck @@ -162,14 +279,15 @@ def _air_start_instruction(self) -> str: t = self.truck if self._terse_speech(): return f"Air pressure {t.air_pressure_psi:.0f} psi." + brake_hint = self.ctx.control_hint("parking_brake") if t.parking_brake: if t.air_ready: - return "Air pressure ready. Press P to release the parking brake." + return f"Air pressure ready. Press {brake_hint} to release the parking brake." return ( f"Air pressure {t.air_pressure_psi:.0f} psi. " - "Wait for 100 psi, then press P to release the parking brake." + f"Wait for 100 psi, then press {brake_hint} to release the parking brake." ) - return "Air pressure ready. Hold the Up arrow to drive." + return f"Air pressure ready. Hold {self.ctx.control_hint('accelerate')} to drive." def _toggle_parking_brake(self) -> None: t = self.truck @@ -207,7 +325,9 @@ def _manual_shift(self, gear: int) -> None: self.tutorial.on_gear_engaged() elif result.grind: self.ctx.audio.play("vehicle/gear_grind") - self.ctx.say("Grinding gears! Hold Left Shift to press the clutch first.") + self.ctx.say( + f"Grinding gears! Hold {self.ctx.control_hint('clutch')} to press the clutch first." + ) else: self.ctx.say(result.message) diff --git a/src/freight_fate/states/driving_core.py b/src/freight_fate/states/driving_core.py index d7e5e07..fdab2ef 100644 --- a/src/freight_fate/states/driving_core.py +++ b/src/freight_fate/states/driving_core.py @@ -170,7 +170,8 @@ def begin(self) -> None: if self.ctx.settings.speech_verbosity == 0: return self.ctx.say( - "This is your first run, so let's walk through it. First: press E to start the engine.", + "This is your first run, so let's walk through it. First: press " + f"{self.ctx.control_hint('engine')} to start the engine.", interrupt=False, ) @@ -184,15 +185,18 @@ def on_engine_started(self) -> None: if self.ctx.settings.automatic_transmission: self.ctx.say( "Now let air pressure build. When you hear air ready, " - "press P to release the parking brake, then hold the " - "Up arrow to accelerate. The transmission shifts for you.", + f"press {self.ctx.control_hint('parking_brake')} to release " + f"the parking brake, then hold {self.ctx.control_hint('accelerate')} " + "to accelerate. The transmission shifts for you.", interrupt=False, ) else: self.ctx.say( "Now let air pressure build. When you hear air ready, " - "press P to release the parking brake, then hold Left " - "Shift, press 1 for first gear, and release the clutch.", + f"press {self.ctx.control_hint('parking_brake')} to release " + f"the parking brake, then hold {self.ctx.control_hint('clutch')}, " + f"select {self.ctx.control_hint('gear_first')} for first gear, " + "and release the clutch.", interrupt=False, ) @@ -204,7 +208,8 @@ def on_parking_brake_released(self) -> None: message = ( "Parking brake released." if self.ctx.settings.speech_verbosity == 0 - else "Parking brake released. Now hold the Up arrow to accelerate." + else "Parking brake released. Now hold " + f"{self.ctx.control_hint('accelerate')} to accelerate." ) self.ctx.say(message, interrupt=False) elif self.stage == 1: @@ -225,7 +230,7 @@ def on_gear_engaged(self) -> None: message = ( "In gear." if self.ctx.settings.speech_verbosity == 0 - else "In gear. Now hold the Up arrow to accelerate." + else f"In gear. Now hold {self.ctx.control_hint('accelerate')} to accelerate." ) self.ctx.say(message, interrupt=False) @@ -237,10 +242,13 @@ def update(self, dt: float, truck) -> None: self.ctx.say("Rolling.", interrupt=False) else: self.ctx.say( - "You are rolling. Press Space anytime for your speed, Tab for a " - "full report, and F1 to hear all the controls. Watch for hazard " - "warnings, and brake hard when you hear them. Hold B for the " - "emergency brake when you need to stop fast. Safe travels.", + "You are rolling. Press " + f"{self.ctx.control_hint('speed')} anytime for your speed, " + f"{self.ctx.control_hint('status_menu')} for a full report, and " + f"{self.ctx.control_hint('help')} to hear all the controls. " + "Watch for hazard warnings, and brake hard when you hear them. " + f"Press {self.ctx.control_hint('emergency_brake')} when you need " + "to stop fast. Safe travels.", interrupt=False, ) self.ctx.profile.tutorial_done = True @@ -250,16 +258,21 @@ def update(self, dt: float, truck) -> None: if self.ctx.settings.speech_verbosity == 0: return if self.stage == 0: - self.ctx.say("Reminder: press E to start the engine.", interrupt=False) + self.ctx.say( + f"Reminder: press {self.ctx.control_hint('engine')} to start the engine.", + interrupt=False, + ) elif truck.parking_brake: self.ctx.say( - "Reminder: wait for air pressure to reach 100 psi, " - "then press P to release the parking brake.", + "Reminder: wait for air pressure to reach 100 psi, then press " + f"{self.ctx.control_hint('parking_brake')} to release the parking brake.", interrupt=False, ) else: self.ctx.say( - "Reminder: hold Left Shift, press 1, then release the shift key.", + f"Reminder: hold {self.ctx.control_hint('clutch')}, " + f"select {self.ctx.control_hint('gear_first')}, " + "then release the clutch.", interrupt=False, ) diff --git a/src/freight_fate/states/driving_events.py b/src/freight_fate/states/driving_events.py index d7a9dc7..40af6c8 100644 --- a/src/freight_fate/states/driving_events.py +++ b/src/freight_fate/states/driving_events.py @@ -20,6 +20,7 @@ def _handle_trip_event(self, event) -> None: if self._cruise_mph is not None: self._cancel_cruise() # hands back on the wheel to brake self.ctx.audio.play(sound or "ui/warning") + self.ctx.controller.rumble.hazard() # 750 ms right->left sweep # The deadline is braking physics plus reaction slack. The physics # part is whatever full service brakes need from the current speed # on this surface; the rolled window covers hearing the warning and @@ -136,6 +137,7 @@ def _handle_inspection(self, event) -> None: evidence = ["HOS/ELD violation"] evidence_text = ", ".join(evidence) self.ctx.audio.play("ui/error") + self.ctx.controller.rumble.alert() serious_hos = ( self.ctx.settings.hos_mode not in hos.HOS_NON_ENFORCED_MODES and self.hos.in_violation(self.ctx.settings.hos_mode) @@ -270,7 +272,10 @@ def _destination_exit_announcement(self, stop, ahead: float) -> str: phrase = self._destination_exit_phrase(stop) if self._terse_speech(): return f"In {ahead:.0f} miles, {phrase}, destination exit." - return f"In {ahead:.0f} miles, {phrase}, destination exit. Press X to take it." + return ( + f"In {ahead:.0f} miles, {phrase}, destination exit. " + f"Press {self.ctx.control_hint('take_exit')} to take it." + ) def _check_destination_exit(self) -> None: stop = self._destination_exit_stop() @@ -562,7 +567,8 @@ def _handle_out_of_fuel(self) -> None: self.ctx.audio.play("ui/error") self.ctx.say_event( f"You ran out of fuel. Roadside rescue brought thirty " - f"gallons for {fee:,.0f} dollars. Press E to restart " + f"gallons for {fee:,.0f} dollars. Press " + f"{self.ctx.control_hint('engine')} to restart " "the engine, and plan your fuel stops.", interrupt=True, ) @@ -643,7 +649,8 @@ def _handle_missed_destination_exit(self) -> None: else: reroute_text = ( "You continue to the next safe turnaround and loop back onto " - "the approach. The destination exit is ahead again; press X " + "the approach. The destination exit is ahead again; press " + f"{self.ctx.control_hint('take_exit')} " "when you are close enough to take it." ) self.ctx.audio.play("ui/warning") diff --git a/src/freight_fate/states/driving_menu_states.py b/src/freight_fate/states/driving_menu_states.py index a73275d..4e489c1 100644 --- a/src/freight_fate/states/driving_menu_states.py +++ b/src/freight_fate/states/driving_menu_states.py @@ -289,6 +289,16 @@ def _emergency_shoulder_sleep(self) -> None: ) ) + def handle_controller(self, event, manager) -> None: + # Start pauses and unpauses, so it resumes from the pause menu too. + if ( + event.type == pygame.CONTROLLERBUTTONDOWN + and event.button == pygame.CONTROLLER_BUTTON_START + ): + self._resume() + return + super().handle_controller(event, manager) + def _resume(self) -> None: self.ctx.audio.play("ui/unpause") self.ctx.pop_state() diff --git a/src/freight_fate/states/driving_rest_states.py b/src/freight_fate/states/driving_rest_states.py index 8b06a25..0c3c7b1 100644 --- a/src/freight_fate/states/driving_rest_states.py +++ b/src/freight_fate/states/driving_rest_states.py @@ -546,8 +546,9 @@ def go_back(self) -> None: self.ctx.audio.play("ui/menu_back") self.ctx.pop_state() self.ctx.say( - "Back on the road. The parking brake is set. Press E to " - "start the engine if needed, then P to release the brake " + "Back on the road. The parking brake is set. Press " + f"{self.ctx.control_hint('engine')} to start the engine if needed, then " + f"{self.ctx.control_hint('parking_brake')} to release the brake " "and drive on.", interrupt=True, ) @@ -600,7 +601,7 @@ def _drive_on(self) -> None: self.ctx.pop_state() self.ctx.say( "Back on the road. The next stop is announced as you " - "approach it. Press E to start the engine.", + f"approach it. Press {self.ctx.control_hint('engine')} to start the engine.", interrupt=True, ) diff --git a/src/freight_fate/states/driving_updates.py b/src/freight_fate/states/driving_updates.py index 905c954..b18ed19 100644 --- a/src/freight_fate/states/driving_updates.py +++ b/src/freight_fate/states/driving_updates.py @@ -20,12 +20,20 @@ def update(self, dt: float) -> None: ramp = dt * 2.2 self._brake_lockout_cue_timer = max(0.0, self._brake_lockout_cue_timer - dt) self._lane_rumble_timer = max(0.0, self._lane_rumble_timer - dt) - accelerating = keys[pygame.K_UP] - braking_key = keys[pygame.K_DOWN] + # Controller triggers/clutch are analog held positions blended in below; + # the keyboard keys keep their ramped behavior so both devices work. + pad = self.ctx.controller + pad_on = pad.active + pad_throttle = pad.throttle if pad_on else 0.0 + pad_brake = pad.brake if pad_on else 0.0 + key_up = keys[pygame.K_UP] + key_down = keys[pygame.K_DOWN] + accelerating = key_up or pad_throttle > 0.05 + braking_key = key_down or pad_brake > 0.05 backing = self._update_reverse_controls(accelerating, braking_key) if accelerating and not backing and t.air_brakes_holding: self._maybe_say_air_brake_lockout() - if accelerating and not backing: + if key_up and not backing: if t.engine_brake: t.engine_brake = False self.ctx.say_event("Engine brake off.", interrupt=False) @@ -34,14 +42,21 @@ def update(self, dt: float) -> None: t.throttle = min(0.45, t.throttle + ramp) else: t.throttle = max(0.0, t.throttle - ramp * 2) - braking = (braking_key and not backing) or (accelerating and t.velocity_mps < -0.1) - if braking: - new_brake = min(1.0, t.brake + ramp * 1.5) - if t.brake < 0.05 and new_brake >= 0.05 and abs(t.velocity_mps) > 1: - self.ctx.audio.play("vehicle/brake_air", volume=0.6) - t.brake = new_brake + if pad_throttle > 0.05 and not backing: + if t.engine_brake: + t.engine_brake = False + self.ctx.say_event("Engine brake off.", interrupt=False) + t.throttle = max(t.throttle, pad_throttle) + # Keyboard ramps the brake up and down; the analog trigger sets a direct + # held floor on top of that. + braking_ramp = (key_down and not backing) or (accelerating and t.velocity_mps < -0.1) + if braking_ramp: + t.brake = min(1.0, t.brake + ramp * 1.5) else: t.brake = max(0.0, t.brake - ramp * 3) + if pad_brake > 0.05 and not backing: + t.brake = max(t.brake, pad_brake) + braking = braking_ramp or (pad_brake > 0.05 and not backing) emergency = keys[pygame.K_b] if emergency: # no ramp: slams to full application instantly, plus spring brakes @@ -50,8 +65,28 @@ def update(self, dt: float) -> None: t.throttle = 0.0 t.brake = 1.0 t.emergency_brake = emergency + # Hard braking (emergency or heavy service) shudders the pad while it + # lasts; the engine's TTL lets it lapse a few frames after we stop. Only + # while moving *forward*: rolling backward, the sim ramps the service + # brake to full on its own to arrest the reverse before shifting to + # drive, and that must not read as a hard stop and buzz the whole time. + if t.velocity_mps > 1 and (emergency or t.brake >= 0.85): + self.ctx.controller.rumble.hard_brake(1.0 if emergency else t.brake) + # Air hiss only on the rising edge of applying the brake. A hysteresis + # flag (arm at 0.05, release below 0.02) keeps a steady analog trigger -- + # or a held key -- from retriggering the sound frame after frame. The + # emergency brake plays its own louder cue, so it only arms the flag. + if t.brake >= 0.05: + if not self._brake_air_hissed and not emergency and abs(t.velocity_mps) > 1: + self.ctx.audio.play("vehicle/brake_air", volume=0.6) + self._brake_air_hissed = True + elif t.brake < 0.02: + self._brake_air_hissed = False clutch_pressed = keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT] - t.transmission.clutch = 1.0 if clutch_pressed and not t.transmission.automatic else 0.0 + clutch_val = 1.0 if clutch_pressed else 0.0 + if pad_on: + clutch_val = max(clutch_val, pad.clutch) + t.transmission.clutch = clutch_val if not t.transmission.automatic else 0.0 self._update_lane(keys, dt) self._update_cruise(dt, braking, accelerating) @@ -70,7 +105,8 @@ def update(self, dt: float) -> None: self.ctx.audio.engine_stop() if t.stalled: self.ctx.say_event( - "The engine stalled. Press E to restart, and use a lower gear at low speed.", + f"The engine stalled. Press {self.ctx.control_hint('engine')} to restart, " + "and use a lower gear at low speed.", interrupt=True, ) elif t.fuel_gal <= 0: @@ -124,16 +160,18 @@ def _maybe_say_air_brake_lockout(self) -> None: if self._terse_speech() else ( f"Air pressure {t.air_pressure_psi:.0f} psi. Wait for 100 psi, " - "then press P to release the parking brake." + f"then press {self.ctx.control_hint('parking_brake')} " + "to release the parking brake." ) ) self.ctx.say_event(message, interrupt=False) elif t.parking_brake: - self._set_status("Parking brake set. Press P to release it.") + brake_hint = self.ctx.control_hint("parking_brake") + self._set_status(f"Parking brake set. Press {brake_hint} to release it.") message = ( "Parking brake set." if self._terse_speech() - else "Parking brake set. Press P to release it." + else f"Parking brake set. Press {brake_hint} to release it." ) self.ctx.say_event(message, interrupt=False) @@ -144,6 +182,7 @@ def _update_air_brake_announcements( if t.air_low_warning and t.engine_on and (not was_low or not self._low_air_said): self._low_air_said = True self.ctx.audio.play("vehicle/low_air_buzzer", volume=0.7) + self.ctx.controller.rumble.alert() message = ( f"Low air: {t.air_pressure_psi:.0f} psi." if self._terse_speech() @@ -159,6 +198,7 @@ def _update_air_brake_announcements( if t.spring_brakes_active and not was_spring and not self._spring_brake_said: self._spring_brake_said = True self.ctx.audio.play("vehicle/low_air_buzzer", volume=0.9) + self.ctx.controller.rumble.alert() message = ( "Spring brakes applied." if self._terse_speech() @@ -178,13 +218,14 @@ def _update_air_brake_announcements( # re-announce it. self._air_ready_said = True self.ctx.audio.play("vehicle/air_dryer_purge", volume=0.65) - self._set_status("Air ready. Press P to release the parking brake.") + brake_hint = self.ctx.control_hint("parking_brake") + self._set_status(f"Air ready. Press {brake_hint} to release the parking brake.") message = ( f"Air ready: {t.air_pressure_psi:.0f} psi." if self._terse_speech() else ( f"Air pressure ready at {t.air_pressure_psi:.0f} psi. " - "Press P to release the parking brake." + f"Press {brake_hint} to release the parking brake." ) ) self.ctx.say_event(message, interrupt=False) @@ -237,6 +278,7 @@ def _update_hours_and_fatigue(self, dt: float) -> None: if mode not in hos.HOS_NON_ENFORCED_MODES: for message in self.hos.check_warnings(mode): self.ctx.audio.play("ui/warning") + self.ctx.controller.rumble.alert() self.ctx.say_event(message, interrupt=hos.warning_is_urgent(message)) self.trip.hos_violation = mode not in hos.HOS_NON_ENFORCED_MODES and self.hos.in_violation( mode @@ -284,6 +326,11 @@ def _update_lane(self, keys, dt: float) -> None: steer -= 1.0 if keys[pygame.K_RIGHT]: steer += 1.0 + # The left stick provides analog steering when the keys are idle. + if steer == 0.0: + pad = self.ctx.controller + if pad.active and pad.steering: + steer = pad.steering self.lane.steering = steer leg = self.route.legs[self.trip.current_leg_index] curve = 0.0 @@ -365,6 +412,10 @@ def _update_audio(self, dt: float = 0.0) -> None: ): self._lane_rumble_timer = 0.8 audio.play("vehicle/rumble_strip", volume=0.25 + rumble * 0.45, pan=self._lane_pan()) + if rumble > 0.0 and self.ctx.settings.steering_assist != "off": + # Harsh, continuous pad buzz while over the rumble strip; refreshed + # each frame, it stops on its own once steered back off. + self.ctx.controller.rumble.rumble_strip(rumble) night = is_night(self.trip.current_hour) if night: audio.set_ambient("ambient/night") @@ -442,6 +493,7 @@ def _update_hazard(self, dt: float) -> None: if self.truck.speed_mph <= HAZARD_SAFE_MPH: self._hazard_deadline = None self.ctx.audio.play("events/hazard_clear", volume=0.75) + self.ctx.controller.rumble.alert(intensity=0.4) self.ctx.say_event("Hazard avoided. Well done.", interrupt=False) self.ctx.award_achievement("hazard_avoided", event=True) return @@ -450,6 +502,7 @@ def _update_hazard(self, dt: float) -> None: self._hazard_deadline = None self.ctx.audio.play("vehicle/collision") severity = min(1.0, self.truck.speed_mph / 70.0) + self.ctx.controller.rumble.impact(severity) self.truck.apply_collision(severity) self.ctx.say_event( f"Collision! The truck took damage. " @@ -489,6 +542,7 @@ def _begin_microsleep(self) -> None: self._cancel_cruise() # the nod takes your hands off the wheel self._microsleep_deadline = MICROSLEEP_REACTION_S self.ctx.audio.play("vehicle/rumble_strip", volume=1.0) + self.ctx.controller.rumble.alert() self.ctx.say_event("You are nodding off. Steer or brake now to stay awake!", interrupt=True) def _update_microsleep(self, keys, dt: float) -> None: @@ -572,6 +626,7 @@ def _update_speeding(self, dt: float) -> None: self.speeding_strikes += 1 after = _speeding_settlement_fine(self.speeding_strikes) self.ctx.audio.play("ui/warning") + self.ctx.controller.rumble.alert() # Surface the cost the moment the strike lands instead of only as a # silent deduction at delivery, so the price of speeding is felt now. if after > before: @@ -610,6 +665,7 @@ def _begin_pull_over(self, limit: float) -> None: patrol = self.trip.active_patrol_at(self.trip.position_mi) where = patrol.reason if patrol is not None else "patrol" self.ctx.audio.play("events/police_siren") + self.ctx.controller.rumble.alert() self.ctx.say_event( f"Lights and siren behind you. A trooper on this {where} clocked you " f"at {self.ctx.settings.speed_text(self.truck.speed_mph)} in a " diff --git a/src/freight_fate/states/main_menu.py b/src/freight_fate/states/main_menu.py index 4664776..ea31bd7 100644 --- a/src/freight_fate/states/main_menu.py +++ b/src/freight_fate/states/main_menu.py @@ -807,6 +807,20 @@ def build_items(self) -> list[MenuItem]: "is shared, never your save files or personal details. " "Has no effect if Discord is not running.", ), + MenuItem( + lambda: f"Controller: {'enabled' if s.controller_enabled else 'disabled'}", + lambda: self._toggle_controller(1), + help="Accept game-controller input alongside the keyboard. " + "The keyboard always stays active. The first connected " + "controller is used automatically.", + ), + MenuItem( + lambda: f"Haptics: {'enabled' if s.haptics_enabled else 'disabled'}", + lambda: self._toggle_haptics(1), + help="Rumble feedback on the controller for hazards, hard " + "braking, and the rumble strip. Has no effect without a " + "controller connected.", + ), MenuItem("Back", self.go_back), ] if self.category == "audio": @@ -875,6 +889,10 @@ def handle_event(self, event: pygame.event.Event) -> None: else: super().handle_event(event) + def adjust(self, direction: int) -> None: + # D-pad left/right on a controller maps to the same per-item adjust. + self._adjust(direction) + def _adjust(self, direction: int) -> None: if self.category == "speech": actions = [action for _, action, _ in self._speech_control_specs()] @@ -887,6 +905,7 @@ def _adjust(self, direction: int) -> None: self._cycle_hos, self._cycle_steering, self._toggle_discord_presence, + self._toggle_controller, ], "audio": [ lambda d: self._volume("master_volume", 0.1 * d), @@ -1050,6 +1069,16 @@ def _toggle_discord_presence(self, _d: int) -> None: self.ctx.apply_presence() self._announce() + def _toggle_controller(self, _d: int) -> None: + self.ctx.settings.controller_enabled = not self.ctx.settings.controller_enabled + self.ctx.apply_controller() + self._announce() + + def _toggle_haptics(self, _d: int) -> None: + self.ctx.settings.haptics_enabled = not self.ctx.settings.haptics_enabled + self.ctx.apply_haptics() + self._announce() + def _cycle_verbosity(self, d: int) -> None: self.ctx.settings.speech_verbosity = (self.ctx.settings.speech_verbosity + d) % 3 self._announce() diff --git a/src/freight_fate/states/main_menu_help.py b/src/freight_fate/states/main_menu_help.py index 07ca0db..2f09d72 100644 --- a/src/freight_fate/states/main_menu_help.py +++ b/src/freight_fate/states/main_menu_help.py @@ -116,6 +116,32 @@ "Escape opens the pause menu.", ], ), + ( + "Controller", + [ + "A game controller works alongside the keyboard, which stays active.", + "The first connected controller is used automatically, and the game", + "detects one plugged in or unplugged while you play. Turn controller", + "support off under Settings, Gameplay if you prefer keyboard only.", + "Button names use the Xbox layout: A, B, X, Y, the bumpers, and the D-pad.", + "In menus: D-pad up and down move, D-pad left and right adjust an option,", + "the A button confirms like Enter, the B button goes back like Escape,", + "and the Back button reads help like F1.", + "Driving: right trigger is the gas, left trigger the brake; press the", + "left trigger fully for the hardest stop. The left stick steers.", + "Hold the left bumper for the clutch; the A button shifts up a gear and", + "the X button shifts down. The Y button sets adaptive cruise.", + "The B button speaks your speed. Click the left stick to honk the horn,", + "the right stick to toggle the engine brake. Start pauses and unpauses.", + "D-pad up reads your route, down takes the next exit, left the weather,", + "and right the clock.", + "Hold the right bumper for the second layer: plus A starts or stops the", + "engine, plus B reads fuel, plus Y sets or releases the parking brake,", + "plus D-pad up reads the next listed exit, plus D-pad down opens rest-stop", + "actions, plus D-pad left and right lower and raise the cruise speed, and", + "plus Start opens the status menu.", + ], + ), ( "On the road", [ diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..a99c3fa --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,292 @@ +"""Controller support: hint routing, the manager, menus, and driving.""" + +import pygame +from driving_feature_helpers import quiet_trip, start_drive + +from freight_fate.controller import ControllerAction, ControllerManager +from freight_fate.input_hints import CONTROLLER, KEYBOARD, control_hint + + +def _button(button, instance_id=0): + return pygame.event.Event(pygame.CONTROLLERBUTTONDOWN, button=button, instance_id=instance_id) + + +def _button_up(button, instance_id=0): + return pygame.event.Event(pygame.CONTROLLERBUTTONUP, button=button, instance_id=instance_id) + + +def _axis(axis, value, instance_id=0): + return pygame.event.Event( + pygame.CONTROLLERAXISMOTION, axis=axis, value=value, instance_id=instance_id + ) + + +def force_controller(app): + """Pretend a controller is connected so the manager reports active.""" + c = app.controller + c.enabled = True + c._controller = object() + c._instance_id = 0 + c.active_device = CONTROLLER + return c + + +# -- pure hint table --------------------------------------------------------- + + +def test_control_hint_follows_device(): + assert control_hint("take_exit", KEYBOARD) == "X" + assert control_hint("take_exit", CONTROLLER) == "D-pad down" + assert control_hint("accelerate", KEYBOARD) == "the Up arrow" + assert control_hint("accelerate", CONTROLLER) == "the right trigger" + + +def test_control_hint_unknown_action_is_audible(): + # An unknown action returns itself rather than crashing a live prompt. + assert control_hint("not_a_real_action", CONTROLLER) == "not_a_real_action" + + +# -- manager ----------------------------------------------------------------- + + +def test_manager_without_device_is_inactive(): + # Force the no-controller branch so the test does not depend on whether a + # physical pad happens to be plugged into the machine running the suite. + m = ControllerManager(enabled=True) + m._controller = None + m._instance_id = None + assert not m.active + assert m.device == KEYBOARD + assert m.tick(0.016) == [] + m.shutdown() + + +def test_menu_action_mapping(): + m = ControllerManager(enabled=True) + force = lambda b: m.menu_action(_button(b)) # noqa: E731 + assert force(pygame.CONTROLLER_BUTTON_DPAD_UP) == ControllerAction.MENU_UP + assert force(pygame.CONTROLLER_BUTTON_DPAD_DOWN) == ControllerAction.MENU_DOWN + assert force(pygame.CONTROLLER_BUTTON_DPAD_LEFT) == ControllerAction.ADJUST_LEFT + assert force(pygame.CONTROLLER_BUTTON_DPAD_RIGHT) == ControllerAction.ADJUST_RIGHT + assert force(pygame.CONTROLLER_BUTTON_A) == ControllerAction.CONFIRM + assert force(pygame.CONTROLLER_BUTTON_B) == ControllerAction.BACK + assert force(pygame.CONTROLLER_BUTTON_BACK) == ControllerAction.HELP + # A button-up carries no menu action. + assert m.menu_action(_button_up(pygame.CONTROLLER_BUTTON_A)) is None + + +def test_trigger_deadzone_and_smoothing(): + m = ControllerManager(enabled=True) + m._controller = object() + m._instance_id = 0 + # Below the 4% trigger deadzone -> still zero. + m.process_event(_axis(pygame.CONTROLLER_AXIS_TRIGGERRIGHT, int(32767 * 0.02))) + m.tick(0.016) + assert m.throttle == 0.0 + # Full press smooths up toward 1.0 over a few frames. + m.process_event(_axis(pygame.CONTROLLER_AXIS_TRIGGERRIGHT, 32767)) + for _ in range(30): + m.tick(0.016) + assert m.throttle > 0.95 + m.shutdown() + + +def test_clutch_is_instant_like_shift(): + # The left bumper is a digital button, so the clutch engages and releases + # immediately -- matching the keyboard Shift -- with no smoothing lag. + m = ControllerManager(enabled=True) + m._controller = object() + m._instance_id = 0 + m.process_event(_button(pygame.CONTROLLER_BUTTON_LEFTSHOULDER)) + assert m.clutch == 1.0 # no tick needed + m.process_event(_button_up(pygame.CONTROLLER_BUTTON_LEFTSHOULDER)) + assert m.clutch == 0.0 + m.shutdown() + + +def test_modifier_tracks_right_bumper(): + m = ControllerManager(enabled=True) + m._controller = object() + m._instance_id = 0 + assert not m.modifier + m.process_event(_button(pygame.CONTROLLER_BUTTON_RIGHTSHOULDER)) + assert m.modifier + m.process_event(_button_up(pygame.CONTROLLER_BUTTON_RIGHTSHOULDER)) + assert not m.modifier + m.shutdown() + + +def test_dpad_hold_auto_repeats(): + m = ControllerManager(enabled=True) + m._controller = object() + m._instance_id = 0 + m.process_event(_button(pygame.CONTROLLER_BUTTON_DPAD_LEFT)) + # Nothing before the initial delay, then repeats accumulate while held. + assert m.tick(0.1) == [] + first = m.tick(0.25) # crosses the 0.3s initial delay + assert len(first) == 1 + assert first[0].button == pygame.CONTROLLER_BUTTON_DPAD_LEFT + # Release stops further repeats. + m.process_event(_button_up(pygame.CONTROLLER_BUTTON_DPAD_LEFT)) + assert m.tick(1.0) == [] + m.shutdown() + + +def test_disconnect_latches_once(): + m = ControllerManager(enabled=True) + m._controller = object() + m._instance_id = 7 + m.process_event(pygame.event.Event(pygame.CONTROLLERDEVICEREMOVED, instance_id=7)) + assert not m.connected + assert m.take_disconnect() is True + assert m.take_disconnect() is False # consumed + m.shutdown() + + +def test_disabled_manager_ignores_controller(): + m = ControllerManager(enabled=True) + m._controller = object() + m._instance_id = 0 + m.set_enabled(False) + assert not m.active + m.process_event(_axis(pygame.CONTROLLER_AXIS_TRIGGERRIGHT, 32767)) + assert m.throttle == 0.0 + m.shutdown() + + +# -- menus ------------------------------------------------------------------- + + +def test_menu_dpad_moves_and_adjusts(monkeypatch): + from freight_fate.app import App + from freight_fate.states.main_menu import SettingsCategoryState + + app = App() + force_controller(app) + app.push_state(SettingsCategoryState(app.ctx, "gameplay")) + state = app.state + start_index = state.index + app._dispatch_controller(_button(pygame.CONTROLLER_BUTTON_DPAD_DOWN)) + assert app.state.index == start_index + 1 + # Move to the Units item and adjust it with D-pad right. + app.state.index = 0 + before = app.ctx.settings.imperial_units + app._dispatch_controller(_button(pygame.CONTROLLER_BUTTON_DPAD_RIGHT)) + assert app.ctx.settings.imperial_units != before + app.shutdown() + + +def test_setting_toggle_gates_controller(): + from freight_fate.app import App + + app = App() + c = force_controller(app) + app.ctx.settings.controller_enabled = False + app.ctx.apply_controller() + assert not c.active + app.shutdown() + + +# -- driving ----------------------------------------------------------------- + + +def test_analog_trigger_drives_throttle(monkeypatch): + from freight_fate.app import App + + app = App() + force_controller(app) + driving = start_drive(app) + quiet_trip(driving) + app._dispatch_controller(_axis(pygame.CONTROLLER_AXIS_TRIGGERRIGHT, 32767)) + for _ in range(20): + app.controller.tick(0.016) + driving.update(1 / 60) + assert driving.truck.throttle > 0.5 + app.shutdown() + + +def test_held_partial_trigger_does_not_machinegun_brake_sound(monkeypatch): + from freight_fate.app import App + + app = App() + force_controller(app) + driving = start_drive(app) + quiet_trip(driving) + driving.truck.velocity_mps = 15.0 + hisses = [] + real_play = app.ctx.audio.play + monkeypatch.setattr( + app.ctx.audio, + "play", + lambda key, volume=1.0: hisses.append(key) if key == "vehicle/brake_air" else real_play, + ) + # A light, steady trigger position (~30%) held for many frames. + app._dispatch_controller(_axis(pygame.CONTROLLER_AXIS_TRIGGERLEFT, int(32767 * 0.30))) + for _ in range(40): + app.controller.tick(1 / 60) + driving.update(1 / 60) + assert driving.truck.brake > 0.2 # the brake is genuinely applied + assert len(hisses) <= 1 # ...but the hiss fires once, not every frame + app.shutdown() + + +def test_controller_info_buttons_speak(monkeypatch): + from freight_fate.app import App + + app = App() + force_controller(app) + driving = start_drive(app) + quiet_trip(driving) + spoken = [] + monkeypatch.setattr(app.ctx, "say", lambda text, interrupt=True: spoken.append(text)) + # B button speaks speed; RB+B speaks fuel. + app._dispatch_controller(_button(pygame.CONTROLLER_BUTTON_B)) + assert any("per hour" in t for t in spoken) + spoken.clear() + app._dispatch_controller(_button(pygame.CONTROLLER_BUTTON_RIGHTSHOULDER)) + app._dispatch_controller(_button(pygame.CONTROLLER_BUTTON_B)) + assert any("fuel" in t.lower() or "range" in t.lower() for t in spoken) + app.shutdown() + + +def test_controller_disconnect_pauses_driving(): + from freight_fate.app import App + from freight_fate.states.driving_menu_states import PauseMenuState + + app = App() + force_controller(app) + driving = start_drive(app) + driving.on_controller_disconnect() + assert isinstance(app.state, PauseMenuState) + app.shutdown() + + +def test_plain_state_not_trapped_by_controller(): + # A non-menu keyboard state (the update check screen) must still be + # dismissable with the controller via the base translation to key events. + from freight_fate.app import App + from freight_fate.states.main_menu import MainMenuState + from freight_fate.states.update import UpdateCheckState + + app = App() + force_controller(app) + app.push_state(MainMenuState(app.ctx)) + app.push_state(UpdateCheckState(app.ctx)) + depth = len(app.states) + app._dispatch_controller(_button(pygame.CONTROLLER_BUTTON_B)) # B -> Escape + assert len(app.states) == depth - 1 + app.shutdown() + + +def test_hint_switches_with_active_device(): + from freight_fate.app import App + + app = App() + force_controller(app) + # A controller button marks the controller active. + app._dispatch_controller(_button(pygame.CONTROLLER_BUTTON_DPAD_DOWN)) + assert app.ctx.control_hint("take_exit") == "D-pad down" + # A keyboard press flips hints back to key names. + app.controller.note_keyboard() + assert app.ctx.control_hint("take_exit") == "X" + app.shutdown() diff --git a/tests/test_rumble.py b/tests/test_rumble.py new file mode 100644 index 0000000..4b0e8d5 --- /dev/null +++ b/tests/test_rumble.py @@ -0,0 +1,188 @@ +"""Controller haptics: the pure RumbleEngine and the manager's device guard.""" + +from freight_fate.controller import ControllerManager +from freight_fate.rumble import RumbleEngine + + +class Recorder: + """Stands in for the pad: records every send and stop the engine issues.""" + + def __init__(self): + self.calls = [] # (low, high, duration_ms) + self.stops = 0 + + def send(self, low, high, duration_ms): + self.calls.append((low, high, duration_ms)) + + def stop(self): + self.stops += 1 + + +def _engine(): + r = Recorder() + return RumbleEngine(send=r.send, stop=r.stop), r + + +def _crossings(values): + """How often a series crosses its own mean -- a proxy for its rate.""" + mean = sum(values) / len(values) + return sum(1 for a, b in zip(values, values[1:], strict=False) if (a - mean) * (b - mean) < 0) + + +# -- one-shot effects -------------------------------------------------------- + + +def test_alert_is_high_only_and_stops(): + e, r = _engine() + e.alert() + e.tick(0.05) # still inside the 120 ms blip + low, high, _ = r.calls[-1] + assert low == 0.0 + assert high > 0.0 + e.tick(0.1) # now past the blip: engine goes idle and stops once + assert r.stops == 1 + + +def test_hazard_sweep_leads_right_then_left_with_overlap(): + e, r = _engine() + e.hazard() + samples = [] # (t, low, high) whenever the engine actually drove the pad + t = 0.0 + for _ in range(40): + before = len(r.calls) + e.tick(0.025) + t += 0.025 + if len(r.calls) > before: + low, high, _ = r.calls[-1] + samples.append((t, low, high)) + peak_high_t = max(samples, key=lambda s: s[2])[0] + peak_low_t = max(samples, key=lambda s: s[1])[0] + # Right (high) grip leads, left (low) grip trails. + assert peak_high_t < peak_low_t + # They overlap: some moment has both motors clearly running. + assert any(low > 0.1 and high > 0.1 for _, low, high in samples) + # The whole thing is about 750 ms long. + assert 0.7 <= samples[-1][0] <= 0.8 + + +def test_impact_is_a_decaying_low_thump(): + e, r = _engine() + e.impact(1.0) + e.tick(0.02) + early_low = r.calls[-1][0] + for _ in range(20): # past the 350 ms life + e.tick(0.02) + # It leads with the low motor and decays over its short life. + assert early_low > 0.5 + assert r.stops == 1 # lapses on its own + + +# -- continuous effects ------------------------------------------------------ + + +def test_rumble_strip_never_releases_a_motor_and_pulses_right_faster(): + e, r = _engine() + lows, highs = [], [] + for _ in range(120): # ~0.6 s of refreshed drift + e.rumble_strip(1.0) + e.tick(0.005) + low, high, _ = r.calls[-1] + lows.append(low) + highs.append(high) + # Harsh: both motors stay buzzing, never fully at zero. + assert min(lows) > 0.0 + assert min(highs) > 0.0 + # The right (high) side pulses faster than the left (low) side. + assert _crossings(highs) > _crossings(lows) + + +def test_rumble_strip_stops_after_refreshes_end(): + e, r = _engine() + for _ in range(3): + e.rumble_strip(1.0) + e.tick(0.016) + stops_before = r.stops + for _ in range(5): # stop refreshing; TTL lapses within a few frames + e.tick(0.016) + assert r.stops == stops_before + 1 + + +def test_hard_brake_low_scales_with_level(): + (ea, ra), (eb, rb) = _engine(), _engine() + ea.hard_brake(1.0) + eb.hard_brake(0.5) + ea.tick(0.016) + eb.tick(0.016) # identical phase, so only the level differs + assert ra.calls[-1][0] > rb.calls[-1][0] + + +def test_combine_takes_the_per_motor_max(): + e, r = _engine() + e.rumble_strip(0.2) # gentle both-motor buzz + e.hard_brake(1.0) # strong low shudder + e.tick(0.016) + combined_low = r.calls[-1][0] + + e2, r2 = _engine() + e2.rumble_strip(0.2) + e2.tick(0.016) + strip_only_low = r2.calls[-1][0] + # The louder source wins each motor; low is at least the strip-only low. + assert combined_low >= strip_only_low + + +def test_reset_clears_effects_and_stops(): + e, r = _engine() + e.rumble_strip(1.0) + e.tick(0.016) + e.reset() + assert r.stops >= 1 + r.calls.clear() + e.tick(0.016) # nothing left to drive + assert r.calls == [] + + +# -- manager device guard ---------------------------------------------------- + + +class FakeDevice: + def __init__(self): + self.calls = [] + self.stops = 0 + + def rumble(self, low, high, duration_ms): + self.calls.append((low, high, duration_ms)) + return True + + def stop_rumble(self): + self.stops += 1 + + +def _manager(haptics): + m = ControllerManager(enabled=True, haptics=haptics) + m._controller = FakeDevice() + m._instance_id = 0 + return m + + +def test_manager_drives_the_pad_when_haptics_enabled(): + m = _manager(haptics=True) + m.rumble.alert() + m.tick(0.05) + assert m._controller.calls # the blip reached the device + + +def test_manager_stays_silent_when_haptics_disabled(): + m = _manager(haptics=False) + m.rumble.alert() + m.tick(0.05) + assert m._controller.calls == [] # guarded off, no device call + + +def test_set_haptics_enabled_toggles_and_stops(): + m = _manager(haptics=True) + m.set_haptics_enabled(False) + assert m._controller.stops >= 1 # reset silenced the pad + m.rumble.alert() + m.tick(0.05) + assert m._controller.calls == [] diff --git a/uv.lock b/uv.lock index 3ba2914..059ffcc 100644 --- a/uv.lock +++ b/uv.lock @@ -408,6 +408,9 @@ dependencies = [ build = [ { name = "nuitka" }, ] +controller-diagnostics = [ + { name = "pygame" }, +] dev = [ { name = "hypothesis" }, { name = "pre-commit" }, @@ -435,6 +438,7 @@ requires-dist = [ [package.metadata.requires-dev] build = [{ name = "nuitka", specifier = ">=4.0.8" }] +controller-diagnostics = [{ name = "pygame", specifier = ">=2.6" }] dev = [ { name = "hypothesis", specifier = ">=6.155.7" }, { name = "pre-commit", specifier = ">=4.6.0" },