The sidescroller's controls did nothing. `akgl_controller_handle_event` matched `event->key.which == curmap->kbid` exactly, with no way to say "whatever keyboard the player is typing on", so the game did the obvious thing and bound `SDL_GetKeyboards()[0]`. That cannot work. The id a key event *carries* is chosen by the video backend and is not required to be an id `SDL_GetKeyboards()` reports. On X11 without XInput2 every key event carries `SDL_GLOBAL_KEYBOARD_ID`, which is 0, while `SDL_AddKeyboard` registers the attached keyboard as `SDL_DEFAULT_KEYBOARD_ID`, which is 1; with XInput2 the events carry the physical slave device's `sourceid` while `SDL_GetKeyboards()[0]` may be the master. Either way the map matched nothing and every key press was dropped. A `kbid` or `jsid` of 0 now matches any device of that kind. 0 is safe to spend: SDL documents `which` as 0 when the source is unknown or virtual, and joystick ids start at 1, so no real device is 0. A non-zero id still matches only that device, which is what keeps two local players on two keyboards apart, and there is a test for that half too. `util/charviewer.c` already passed 0 and depended on the old behaviour by accident; it works under XInput2 now as well. Two reasons this shipped, both closed: - Every test in tests/controller.c dispatched an event whose id equalled the id the map was bound with, so none of them could see it. - The sidescroller's smoke test called the control handlers directly instead of dispatching events, so it passed against a control map that matched nothing. It now pushes synthetic events through akgl_controller_handle_event from a deliberately non-zero device id and fails if the press does not arrive. Verified by breaking the binding and watching the run exit non-zero. `test_controller_wildcard_device_ids` was written first and failed against the unfixed library with "a map bound to keyboard 0 ignored a key press from device 11", which is the whole reason to trust it now that it passes. The controls were documented, in one sentence. Chapter 19 has a proper table of them, and chapter 15 explains why not to reach for SDL_GetKeyboards(). The header said the match was exact and now says what it does. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
370 lines
20 KiB
C
370 lines
20 KiB
C
/**
|
|
* @file controller.h
|
|
* @brief Input binding: SDL events in, actor state changes out.
|
|
*
|
|
* A control map ties one actor to one keyboard and one gamepad, and holds up to
|
|
* #AKGL_MAX_CONTROLS bindings. A binding says "when event X arrives from device
|
|
* Y carrying button or key Z, call this handler". Eight maps means up to eight
|
|
* locally controlled players, each on its own device.
|
|
*
|
|
* The host pumps SDL events into akgl_controller_handle_event(), which scans the
|
|
* maps in order and stops at the first binding that claims the event -- so a key
|
|
* bound in two maps only fires once, in the lower-numbered one.
|
|
*
|
|
* Alongside that, and independent of it, every key press is pushed into a small
|
|
* ring buffer that akgl_controller_poll_key() and akgl_controller_poll_keystroke()
|
|
* drain. That serves a caller that wants "is there a key waiting" without owning
|
|
* an event loop, and it sees keys whether or not a control map also claimed them.
|
|
*
|
|
* The two pollers read the same buffer and differ in what they hand back. A game
|
|
* asking "was the up arrow pressed" wants a keycode and nothing else, which is
|
|
* akgl_controller_poll_key(). A line editor asking "what did the user type" needs
|
|
* the modifier state and the character SDL composed -- there is no `"` keycode,
|
|
* and no keycode at all for a dead key or an IME commit -- which is
|
|
* akgl_controller_poll_keystroke().
|
|
*/
|
|
|
|
#ifndef _AKGL_CONTROLLER_H_
|
|
#define _AKGL_CONTROLLER_H_
|
|
|
|
#include <SDL3/SDL.h>
|
|
#include <akerror.h>
|
|
#include <akgl/types.h>
|
|
// The binding handlers below take an akgl_Actor *, so this header cannot be
|
|
// included before akgl/actor.h without one. akgl_Actor is a typedef of a named
|
|
// struct, and repeating a typedef is not C99, so it is included rather than
|
|
// forward-declared. actor.h reaches only types.h and character.h, so this does
|
|
// not close a cycle.
|
|
#include <akgl/actor.h>
|
|
|
|
/** @brief How many control maps exist -- effectively the local player limit. */
|
|
#define AKGL_MAX_CONTROL_MAPS 8
|
|
/** @brief Bindings per control map. The default map installed by akgl_controller_default uses 8 of them. */
|
|
#define AKGL_MAX_CONTROLS 32
|
|
|
|
/**
|
|
* @brief Keystrokes akgl_controller_handle_event() will hold for a poller.
|
|
*
|
|
* The Commodore keyboard buffer this serves held ten. Thirty-two is enough that
|
|
* a program which polls once per frame never loses a key to a fast typist, and
|
|
* small enough that the buffer stays a fixed-size object in the library's data
|
|
* segment.
|
|
*/
|
|
#define AKGL_CONTROLLER_KEY_BUFFER 32
|
|
|
|
/**
|
|
* @brief Bytes of composed text one buffered keystroke can carry, NUL included.
|
|
*
|
|
* A UTF-8 code point is at most four bytes, so this holds one character and its
|
|
* terminator with room to spare. An input event carrying more than fits -- an
|
|
* IME committing a whole word at once -- is truncated on a code point boundary
|
|
* rather than split through the middle of one.
|
|
*/
|
|
#define AKGL_CONTROLLER_KEYSTROKE_TEXT 8
|
|
|
|
/** @brief One buffered keystroke: which key, which modifiers, and what it typed. */
|
|
typedef struct {
|
|
SDL_Keycode key; /**< The keycode, or 0 for an entry that carries only composed text -- an IME commit, or a character finished by a dead key. */
|
|
SDL_Keymod mod; /**< Modifier state when the key went down. 0 on a text-only entry, whose text already reflects them. */
|
|
char text[AKGL_CONTROLLER_KEYSTROKE_TEXT]; /**< What the keystroke composed to, UTF-8. The empty string for a key that produces no character, such as an arrow or a bare modifier. */
|
|
} akgl_Keystroke;
|
|
|
|
/** @brief Maps one SDL input to pressed and released callbacks. */
|
|
typedef struct {
|
|
uint32_t event_on; /**< SDL event type that fires `handler_on`, e.g. `SDL_EVENT_KEY_DOWN`. */
|
|
uint32_t event_off; /**< SDL event type that fires `handler_off`, e.g. `SDL_EVENT_KEY_UP`. */
|
|
uint8_t button; /**< Gamepad button (`SDL_GamepadButton`) this binding is for. Only consulted for gamepad events. */
|
|
SDL_Keycode key; /**< Keycode this binding is for. Only consulted for keyboard events. */
|
|
uint8_t axis; /**< Analogue axis. Declared but not yet consulted by akgl_controller_handle_event. */
|
|
uint8_t axis_range_min; /**< Low end of the axis range that counts as "on". Not yet consulted. */
|
|
uint8_t axis_range_max; /**< High end of that range. Not yet consulted. */
|
|
akerr_ErrorContext AKERR_NOIGNORE *(*handler_on)(akgl_Actor *obj, SDL_Event *event); /**< Called with the map's target on `event_on`. Required if `event_on` can fire. */
|
|
akerr_ErrorContext AKERR_NOIGNORE *(*handler_off)(akgl_Actor *obj, SDL_Event *event); /**< Called with the map's target on `event_off`. */
|
|
} akgl_Control;
|
|
|
|
/** @brief Groups input bindings for one actor and its input devices. */
|
|
typedef struct {
|
|
akgl_Actor *target; /**< The actor these bindings drive. A `NULL` target makes the whole map inert, which is how an unused slot is spelled. */
|
|
uint16_t nextMap; /**< Number of bindings in use; the index akgl_controller_pushmap writes to next. */
|
|
akgl_Control controls[AKGL_MAX_CONTROLS]; /**< The bindings, scanned in order. */
|
|
SDL_KeyboardID kbid; /**< Keyboard this map listens to, or 0 for any. A non-zero id ignores every other keyboard, which is what keeps two players on two keyboards apart. */
|
|
SDL_JoystickID jsid; /**< Gamepad this map listens to, matched the same way. */
|
|
SDL_MouseID mouseid; /**< Mouse this map listens to. Declared but not yet consulted. */
|
|
SDL_PenID penid; /**< Pen this map listens to. Declared but not yet consulted. */
|
|
} akgl_ControlMap;
|
|
|
|
/** @brief Every control map. Zeroed by akgl_game_init; index it by the same id the functions below take. */
|
|
extern akgl_ControlMap akgl_controlmaps[AKGL_MAX_CONTROL_MAPS];
|
|
|
|
/**
|
|
* @brief Log every attached keyboard and its SDL id.
|
|
*
|
|
* A diagnostic, not a query: it writes to the SDL log rather than returning
|
|
* anything. Its use is finding the `kbid` to hand akgl_controller_default when
|
|
* more than one keyboard is attached.
|
|
*
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If SDL cannot enumerate keyboards. The message
|
|
* carries `SDL_GetError()`; note that "no keyboards attached" is
|
|
* reported by SDL as an empty list, not as a failure.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_list_keyboards(void);
|
|
|
|
/**
|
|
* @brief Dispatch one SDL event to whichever control map binds it.
|
|
*
|
|
* The entry point for the whole subsystem -- call it for every event the host
|
|
* pumps. A key press is recorded in the poll buffer first, whether or not any
|
|
* map wants it, and an `SDL_EVENT_TEXT_INPUT` is attached to the press it
|
|
* belongs to; then the maps are scanned in index order and, within a map,
|
|
* bindings in the order they were pushed. The **first** binding whose event type
|
|
* and device id and button/key all match wins, and the scan stops there.
|
|
*
|
|
* An event nothing binds is not an error: it returns success having done
|
|
* nothing, which is what lets a host pass every event through unconditionally.
|
|
*
|
|
* @param appstate Passed through from SDL's callback. Required -- but only as a
|
|
* non-`NULL` token: nothing here reads it. Pass any non-`NULL`
|
|
* pointer if your program has no app state.
|
|
* @param event The event to dispatch. Required.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p appstate or @p event is `NULL`.
|
|
* @throws AKERR_* Whatever the matched binding's handler raises.
|
|
*
|
|
* @warning A matched binding's handler pointer is not checked, so a control
|
|
* pushed with a `NULL` `handler_on` or `handler_off` crashes when its
|
|
* event arrives.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_event(void *appstate, SDL_Event *event);
|
|
|
|
/**
|
|
* @brief Set the movement and facing bits on the "player" actor for a D-pad or arrow press.
|
|
*
|
|
* A whole-application shortcut rather than a control-map binding: it looks the
|
|
* actor up by the literal name `"player"` in #AKGL_REGISTRY_ACTOR instead of
|
|
* being told which actor to act on, so it only ever drives one. The
|
|
* akgl_actor_cmhf_* handlers are the general form.
|
|
*
|
|
* Facing follows movement unless the actor sets `movement_controls_face`, which
|
|
* is how an actor that aims independently of the direction it walks opts out.
|
|
*
|
|
* @param appstate Passed through from SDL. Required as a non-`NULL` token only;
|
|
* never read.
|
|
* @param event The button or key event. Required. Both the gamepad and
|
|
* keyboard unions are read on every call, so the arm that does
|
|
* not correspond to the event type is read as garbage -- which
|
|
* works only because no real button and keycode pair collides.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p appstate or @p event is `NULL`, or if there is
|
|
* no actor registered under the name "player".
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_button_down(void *appstate, SDL_Event *event);
|
|
/**
|
|
* @brief Clear the movement bit on the "player" actor for a D-pad or arrow release, and reset its animation.
|
|
*
|
|
* The counterpart to akgl_controller_handle_button_down. It clears only the movement
|
|
* bit, leaving the facing bits alone so the actor keeps looking the way it was
|
|
* walking, and rewinds `curSpriteFrameId` to 0 so the next step starts from the
|
|
* first frame of the walk cycle rather than wherever it stopped.
|
|
*
|
|
* @param appstate Passed through from SDL. Required as a non-`NULL` token only.
|
|
* @param event The button or key release event. Required.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p appstate or @p event is `NULL`, or if there is
|
|
* no actor registered under the name "player".
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_button_up(void *appstate, SDL_Event *event);
|
|
/**
|
|
* @brief Open a gamepad that has just been plugged in, and log its mapping.
|
|
*
|
|
* SDL delivers no button events from an unopened gamepad, so a device that
|
|
* appears mid-session has to be opened here the way akgl_controller_open_gamepads
|
|
* opens the ones present at startup. A gamepad SDL has already opened is logged
|
|
* and left alone.
|
|
*
|
|
* The mapping is logged because a controller with no entry in the database
|
|
* produces no button events at all, and that is otherwise indistinguishable
|
|
* from a broken binding.
|
|
*
|
|
* @param appstate Passed through from SDL. Required as a non-`NULL` token only.
|
|
* @param event The SDL_EVENT_GAMEPAD_ADDED event. Required. The joystick id
|
|
* is read out of the `gbutton` arm rather than `gdevice`, which
|
|
* works because the two share their `which` field.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p appstate or @p event is `NULL`.
|
|
*
|
|
* @note A failed `SDL_OpenGamepad` is logged rather than reported: this returns
|
|
* success and the device stays silent.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_added(void *appstate, SDL_Event *event);
|
|
/**
|
|
* @brief Close a gamepad that has been unplugged.
|
|
*
|
|
* An unplugged device SDL still has open is a leaked handle, so this closes it.
|
|
* A removal for a device that was never opened is logged and otherwise ignored.
|
|
* Any control map still holding that `jsid` simply stops matching -- the map is
|
|
* not torn down.
|
|
*
|
|
* @param appstate Passed through from SDL. Required as a non-`NULL` token only.
|
|
* @param event The SDL_EVENT_GAMEPAD_REMOVED event. Required.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p appstate or @p event is `NULL`.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_removed(void *appstate, SDL_Event *event);
|
|
|
|
/**
|
|
* @brief Append a binding to a control map.
|
|
*
|
|
* The binding is copied, so the caller's `akgl_Control` can be a stack local
|
|
* reused across pushes -- which is exactly what akgl_controller_default does.
|
|
* Bindings can only be appended; there is no remove, and no way to reset a map
|
|
* short of zeroing it in ::akgl_controlmaps directly.
|
|
*
|
|
* @param controlmapid Which map to append to, 0 through
|
|
* #AKGL_MAX_CONTROL_MAPS - 1.
|
|
* @param control The binding to copy in. Required.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p control is `NULL`.
|
|
* @throws AKERR_OUTOFBOUNDS If @p controlmapid is at or above
|
|
* #AKGL_MAX_CONTROL_MAPS, or if the map already holds
|
|
* #AKGL_MAX_CONTROLS bindings.
|
|
*
|
|
* @warning A **negative** @p controlmapid is not rejected -- only the upper
|
|
* bound is checked -- and indexes before the start of
|
|
* ::akgl_controlmaps. TODO.md, "Known and still open" item 11.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_pushmap(int controlmapid, akgl_Control *control);
|
|
|
|
/**
|
|
* @brief Bind an actor to the arrow keys and the D-pad, in one call.
|
|
*
|
|
* Points the map at the named actor and pushes eight bindings: the four arrow
|
|
* keys and the four D-pad directions, each wired to the matching
|
|
* `akgl_actor_cmhf_*_on`/`_off` pair. It is the "just give me something that
|
|
* works" path -- a game wanting different keys builds its own bindings with
|
|
* akgl_controller_pushmap.
|
|
*
|
|
* @param controlmapid Which map to configure, 0 through
|
|
* #AKGL_MAX_CONTROL_MAPS - 1.
|
|
* @param actorname Registry name of the actor to drive. Required in practice,
|
|
* though a `NULL` is reported as "not found" rather than as
|
|
* a null pointer.
|
|
* @param kbid SDL keyboard id to listen to, or **0 for any keyboard**,
|
|
* which is what a single-player game wants. Do not pass
|
|
* `SDL_GetKeyboards()[0]`: that reports what is attached,
|
|
* while the id an event carries is chosen by the video
|
|
* backend, and the two need not agree. See
|
|
* akgl_controller_list_keyboards.
|
|
* @param jsid SDL gamepad id to listen to, or 0 for any gamepad,
|
|
* matched the same way.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_OUTOFBOUNDS If @p controlmapid is at or above
|
|
* #AKGL_MAX_CONTROL_MAPS, or if the map cannot hold eight more bindings.
|
|
* @throws AKGL_ERR_REGISTRY If @p actorname is not in #AKGL_REGISTRY_ACTOR --
|
|
* usually because the actor has not been created yet.
|
|
*
|
|
* @warning A negative @p controlmapid is not rejected. See
|
|
* akgl_controller_pushmap.
|
|
* @note It appends rather than replaces, so calling it twice on the same map
|
|
* leaves sixteen bindings and the first eight are the ones that fire.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_default(int controlmapid, char *actorname, int kbid, int jsid);
|
|
|
|
/**
|
|
* @brief Open every gamepad currently attached.
|
|
*
|
|
* SDL will not deliver button events from a gamepad nobody has opened, so this
|
|
* runs once at startup -- akgl_game_init calls it. Devices plugged in later are
|
|
* SDL_EVENT_GAMEPAD_ADDED events, handled elsewhere.
|
|
*
|
|
* No gamepads attached is success, not an error.
|
|
*
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If SDL reports gamepads present but cannot
|
|
* enumerate them, or if one of them cannot be opened. The message
|
|
* carries `SDL_GetError()`.
|
|
*
|
|
* @note The enumeration array is only freed on the success path, so a failure
|
|
* part-way through leaks it.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_open_gamepads(void);
|
|
|
|
/**
|
|
* @brief Take the oldest waiting keystroke, if there is one.
|
|
*
|
|
* The rest of this header is built around the host pumping SDL events into
|
|
* akgl_controller_handle_event(), which suits a game loop and does not suit an
|
|
* embedded interpreter asking "is there a key waiting, yes or no" without
|
|
* owning the event loop itself. Every key press that reaches
|
|
* akgl_controller_handle_event() is recorded in a fixed ring buffer first,
|
|
* whether or not a control map claims it, and this drains that buffer one
|
|
* keystroke per call.
|
|
*
|
|
* When no key is waiting the call still succeeds: @p available is set to
|
|
* `false` and @p keycode to 0. The caller polls, it does not block.
|
|
*
|
|
* A full buffer drops the *newest* keystroke rather than the oldest, so what
|
|
* was typed first is what is read first. This runs on whichever thread pumps
|
|
* events; it is not synchronized.
|
|
*
|
|
* Entries that carry only composed text and no keycode are discarded on the way
|
|
* past rather than reported as keycode 0: this form is for a caller that acts on
|
|
* keys, and there is no key to report. A caller that wants those characters uses
|
|
* akgl_controller_poll_keystroke() instead.
|
|
*
|
|
* @param keycode Receives the SDL keycode, or 0 when nothing was waiting.
|
|
* Required -- the return value is the error context.
|
|
* @param available Receives `true` when a keystroke was taken, `false` when the
|
|
* buffer was empty. Required. Check this rather than testing
|
|
* @p keycode against 0.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p keycode or @p available is `NULL`. Both are
|
|
* required; there is no "I only want to know whether one is waiting"
|
|
* form.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_poll_key(int *keycode, bool *available);
|
|
|
|
/**
|
|
* @brief Take the oldest waiting keystroke whole: key, modifiers and text.
|
|
*
|
|
* akgl_controller_poll_key() with nothing thrown away. A line editor needs all
|
|
* three: the keycode to recognise Backspace and Return, the modifier state to
|
|
* tell Ctrl-C from a `c`, and the composed text because a keycode cannot express
|
|
* what a shifted key produces on the user's own layout. `"`, `!`, `(`, `)`, `:`
|
|
* and `;` are all unreachable from a keycode alone, and so is every lower-case
|
|
* letter.
|
|
*
|
|
* The text is what SDL composed, taken from the `SDL_EVENT_TEXT_INPUT` that
|
|
* follows the key press -- the only correct way to get a character out of SDL,
|
|
* and what makes a keyboard layout, a compose key and a dead key work. **SDL
|
|
* only sends those events while text input is started**, so a host that wants
|
|
* the `text` field populated calls `SDL_StartTextInput()` on its window first.
|
|
* Without it, `key` and `mod` still arrive and `text` is always empty.
|
|
*
|
|
* The two pollers drain the same buffer, so a keystroke taken by one is not
|
|
* waiting for the other.
|
|
*
|
|
* @param dest Receives the keystroke. Required. Written only when a
|
|
* keystroke was waiting; zeroed otherwise, so `key` is 0 and
|
|
* `text` is the empty string.
|
|
* @param available Receives `true` when a keystroke was taken, `false` when the
|
|
* buffer was empty. Required.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p dest or @p available is `NULL`.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_poll_keystroke(akgl_Keystroke *dest, bool *available);
|
|
|
|
/**
|
|
* @brief Discard every keystroke waiting in the buffer.
|
|
*
|
|
* For a caller that has been ignoring input and does not want a backlog acted
|
|
* on the moment it starts polling again.
|
|
*
|
|
* This empties only the polling buffer. Keys already dispatched to control maps
|
|
* have had their effect and cannot be taken back.
|
|
*
|
|
* @return `NULL`. There is no failure path -- it resets two counters.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_flush_keys(void);
|
|
#endif // _AKGL_CONTROLLER_H_
|