Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
67 changes: 65 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand All @@ -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 |
| --- | --- |
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]
Expand Down
65 changes: 65 additions & 0 deletions src/freight_fate/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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())
Expand Down Expand Up @@ -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()
Expand Down
Loading