/** * @file player.c * @brief The player's two hooks and its control bindings. * * Two of the six behaviour hooks on `akgl_Actor` are replaced here, and the * split between them is the frame's own order. `akgl_game_update` calls every * actor's `updatefunc`, then steps the physics, then draws -- so per-frame game * logic that is not movement (picking a coin up, falling in the pit) goes in * `updatefunc`, and anything that has to happen inside the physics step goes in * `movementlogicfunc`, which is the only hook the step calls. * * Two of the library's documented gaps are worked around here rather than * papered over. Both are in `TODO.md` under "Arcade physics feel". */ #include #include #include #include #include #include #include #include "sidescroller.h" /** @brief The player's collision box, as an offset into its 32x32 sprite frame. */ static SDL_FRect ss_player_body = { .x = SS_PLAYER_BOX_X, .y = SS_PLAYER_BOX_Y, .w = SS_PLAYER_BOX_W, .h = SS_PLAYER_BOX_H }; /** @brief Where the map put the player, so a death can put it back. */ static ss_ActorData ss_player_data; /** * @brief The rectangle an actor is tested against, given a 32x32 sprite frame. * * Smaller than the frame on purpose. Sprite art does not reach the edges, and a * hazard box the full size of the frame kills a player who is visibly nowhere * near it. */ static akerr_ErrorContext *hitbox(akgl_Actor *obj, float32_t inset, SDL_FRect *dest) { PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); dest->x = obj->x + inset; dest->y = obj->y + inset; dest->w = 32.0f - (inset * 2.0f); dest->h = 32.0f - (inset * 2.0f); SUCCEED_RETURN(errctx); } /** * @brief Put the player back where the map placed it, at rest. * * Everything the simulation carries between steps has to be cleared, not just * the position: `ey` is where gravity has been accumulating, and a player who * respawns still holding a full-speed fall lands dead again immediately. */ static akerr_ErrorContext *respawn(akgl_Actor *obj) { ss_ActorData *data = NULL; PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData"); data = (ss_ActorData *)obj->actorData; obj->x = data->home_x; obj->y = data->home_y; obj->ex = 0.0f; obj->ey = 0.0f; obj->tx = 0.0f; obj->ty = 0.0f; obj->vx = 0.0f; obj->vy = 0.0f; ss_game.deaths += 1; SDL_Log("Player died (%d) and respawned at %f, %f", ss_game.deaths, obj->x, obj->y); SUCCEED_RETURN(errctx); } /** * @brief The player's `movementlogicfunc`: friction, the jump, and terrain. * * Called by `akgl_physics_simulate` once per actor per step, *before* gravity, * drag, the velocity recomputation and `move`. */ static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt) { ss_Contact contact; float32_t friction = 0.0f; PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); /* The default logic still has to run: it is what copies the character's * speeds onto the actor and signs its acceleration by the movement bits. */ PASS(errctx, akgl_actor_logic_movement(obj, dt)); /* * Workaround 1: there is no friction anywhere in the arcade backend. * * akgl_actor_cmhf_left_off zeroes `tx` outright, so releasing a direction * stops the actor dead inside one frame -- correct for a top-down Zelda, * wrong for a sidescroller. This game binds its own `_off` handlers that * leave `tx` alone (see below) and decays it here instead, faster on the * ground than in the air. Below a pixel per second it is snapped to zero, * because an exponential decay never actually arrives. */ if ( AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT) && AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) { friction = SS_FRICTION_AIR; if ( ss_game.grounded == true ) { friction = SS_FRICTION_GROUND; } obj->tx -= obj->tx * friction * dt; if ( fabsf(obj->tx) < 1.0f ) { obj->tx = 0.0f; } } /* * A jump is an impulse straight into the environmental term, because `ey` is * the axis gravity accumulates on and the two have to cancel for the arc to * come back down. Thrust would not: `ty` is capped against the character's * `speed_y`, which is 0 for this character, so a jump written as thrust is * scaled to nothing by the ellipse cap in akgl_physics_simulate. * * `ss_game.grounded` is last step's verdict, one frame stale. That is the * ordering again -- this hook runs before the step it is deciding about -- * and one frame of coyote time is not a thing a player can feel. */ if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) { obj->ey = -SS_JUMP_SPEED; } ss_game.jump_requested = false; PASS(errctx, ss_collide_resolve(obj, &ss_player_body, dt, &contact)); ss_game.grounded = contact.grounded; /* * The character maps MOVING_UP to the jump sprites, so the state word says * "in the air" whether the actor is rising or falling. Nothing else reads * the bit here: `speed_y` and `acceleration_y` are both 0 in * character_ss_player.json, so the thrust it would authorise is capped to * nothing. */ if ( contact.grounded == true ) { AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP); } else { AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP); } SUCCEED_RETURN(errctx); } /** * @brief The player's `updatefunc`: the animation, then what it is touching. * * Runs in `akgl_game_update`'s actor sweep, before the physics step and before * anything is drawn. */ static akerr_ErrorContext *ss_player_update(akgl_Actor *obj) { SDL_FRect player; SDL_FRect other; bool hit = false; int i = 0; PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); /* Facing, then the animation frame. Replacing a hook does not mean * reimplementing it. */ PASS(errctx, akgl_actor_update(obj)); /* Off the bottom of the world. Nothing in the library stops an actor * leaving the map -- akgl_physics_arcade_move does not clamp -- so falling * out of the level is a thing the game has to notice for itself. */ if ( obj->y > (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) ) { PASS(errctx, respawn(obj)); SUCCEED_RETURN(errctx); } PASS(errctx, hitbox(obj, 8.0f, &player)); for ( i = 0; i < SS_COIN_COUNT; i++ ) { if ( ss_game.coins[i] == NULL ) { continue; } PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other)); PASS(errctx, akgl_collide_rectangles(&player, &other, &hit)); if ( hit == true ) { /* * Giving the pool slot back is what unregisters the actor and stops * it being drawn; there is no "despawn" call. Releasing another * actor from inside this sweep is safe -- akgl_game_update re-reads * `refcount` at the top of every iteration and skips a slot that has * gone free. */ PASS(errctx, akgl_heap_release_actor(ss_game.coins[i])); ss_game.coins[i] = NULL; ss_game.coins_taken += 1; SDL_Log("Collected coin %d of %d", ss_game.coins_taken, SS_COIN_COUNT); } } for ( i = 0; i < SS_HAZARD_COUNT; i++ ) { if ( ss_game.hazards[i] == NULL ) { continue; } PASS(errctx, hitbox(ss_game.hazards[i], 6.0f, &other)); PASS(errctx, akgl_collide_rectangles(&player, &other, &hit)); if ( hit == true ) { PASS(errctx, respawn(obj)); SUCCEED_RETURN(errctx); } } SUCCEED_RETURN(errctx); } /* * Workaround 2, the release half. * * akgl_actor_cmhf_left_off and _right_off clear the movement bit, zero `ax` * *and* zero `tx`. That last one is the "stops dead" behaviour; these two do * everything else it does and leave `tx` for ss_player_movement to decay. */ static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event) { PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); obj->ax = 0.0f; AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT); SUCCEED_RETURN(errctx); } static akerr_ErrorContext *ss_control_right_off(akgl_Actor *obj, SDL_Event *event) { PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); obj->ax = 0.0f; AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT); SUCCEED_RETURN(errctx); } /** @brief Ask for a jump. Whether one is allowed is ss_player_movement's call. */ static akerr_ErrorContext *ss_control_jump_on(akgl_Actor *obj, SDL_Event *event) { PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); ss_game.jump_requested = true; SUCCEED_RETURN(errctx); } /** * @brief Cut a rising jump short when the button is let go. * * Variable jump height for four lines, and it works because `ey` is an ordinary * field that nothing else owns between steps. */ static akerr_ErrorContext *ss_control_jump_off(akgl_Actor *obj, SDL_Event *event) { PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); if ( obj->ey < 0.0f ) { obj->ey *= 0.4f; } SUCCEED_RETURN(errctx); } akerr_ErrorContext *ss_player_bind(akgl_Actor *obj) { PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); ss_game.player = obj; /* Lift the spawn point clear of the step it overlaps *before* recording it, * so a respawn does not put the player back inside the geometry. */ PASS(errctx, ss_collide_settle(obj, &ss_player_body)); ss_player_data.home_x = obj->x; ss_player_data.home_y = obj->y; obj->actorData = (void *)&ss_player_data; /* * The default `facefunc` clears every facing bit and then sets one from the * movement bits -- so an actor that stops moving is left facing nowhere, its * state drops to bare ALIVE, and character_ss_player.json has no sprite for * that. The actor becomes invisible while standing still. * * Clearing this is the documented way out: akgl_actor_automatic_face leaves * an actor alone entirely when it is clear, and the facing bits stay * wherever the control handlers last put them. */ obj->movement_controls_face = false; /* Replace the hooks after akgl_actor_initialize, never before -- it * overwrites all six. The tilemap loader has already run it here. */ obj->movementlogicfunc = &ss_player_movement; obj->updatefunc = &ss_player_update; SUCCEED_RETURN(errctx); } akerr_ErrorContext *ss_player_controls(int controlmapid, char *actorname) { akgl_ControlMap *controlmap = NULL; akgl_Control control; PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, actorname, AKERR_NULLPOINTER, "actorname"); /* akgl_controller_pushmap checks the upper bound and not the lower one, so * a negative id indexes before the start of akgl_controlmaps. TODO.md, * "Known and still open" item 11. */ FAIL_NONZERO_RETURN( errctx, ((controlmapid < 0) || (controlmapid >= AKGL_MAX_CONTROL_MAPS)), AKERR_OUTOFBOUNDS, "Control map id %d is outside 0..%d", controlmapid, (AKGL_MAX_CONTROL_MAPS - 1) ); PASS(errctx, aksl_memset((void *)&control, 0x00, sizeof(akgl_Control))); controlmap = &akgl_controlmaps[controlmapid]; controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, actorname, NULL); FAIL_ZERO_RETURN( errctx, controlmap->target, AKGL_ERR_REGISTRY, "Actor %s is not in AKGL_REGISTRY_ACTOR; bind controls after the map is loaded", actorname ); /* * A control map listens to one keyboard and one gamepad, matched by id, and * 0 means "any". That is what a one-player game wants, and it is the only * thing that works: SDL_GetKeyboards() reports what is attached, but the id * a key event carries is chosen by the video backend, and on X11 it is * SDL_GLOBAL_KEYBOARD_ID (0) without XInput2 and the physical device's * sourceid with it -- never the SDL_DEFAULT_KEYBOARD_ID of 1 that SDL * registers when XInput2 is missing. This function used to bind * SDL_GetKeyboards()[0] and the arrow keys did nothing at all. * * Bind a specific id when two players share a machine on two keyboards. */ controlmap->kbid = 0; controlmap->jsid = 0; /* ---- keyboard ---- */ control.event_on = SDL_EVENT_KEY_DOWN; control.event_off = SDL_EVENT_KEY_UP; control.key = SDLK_LEFT; control.handler_on = &akgl_actor_cmhf_left_on; control.handler_off = &ss_control_left_off; PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); control.key = SDLK_RIGHT; control.handler_on = &akgl_actor_cmhf_right_on; control.handler_off = &ss_control_right_off; PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); control.key = SDLK_SPACE; control.handler_on = &ss_control_jump_on; control.handler_off = &ss_control_jump_off; PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); /* ---- gamepad ---- */ /* Clear the keycode first: a keyboard event is matched on `key` whatever * else the binding carries, and 0 is a keycode like any other. */ control.key = 0; control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN; control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP; control.button = SDL_GAMEPAD_BUTTON_DPAD_LEFT; control.handler_on = &akgl_actor_cmhf_left_on; control.handler_off = &ss_control_left_off; PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); control.button = SDL_GAMEPAD_BUTTON_DPAD_RIGHT; control.handler_on = &akgl_actor_cmhf_right_on; control.handler_off = &ss_control_right_off; PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); control.button = SDL_GAMEPAD_BUTTON_SOUTH; control.handler_on = &ss_control_jump_on; control.handler_off = &ss_control_jump_off; PASS(errctx, akgl_controller_pushmap(controlmapid, &control)); SDL_Log("Bound %s to keyboard %d and gamepad %d", actorname, controlmap->kbid, controlmap->jsid); SUCCEED_RETURN(errctx); } akerr_ErrorContext *ss_player_autoplay(int frame) { SDL_Event synthetic; PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player"); PASS(errctx, aksl_memset((void *)&synthetic, 0x00, sizeof(SDL_Event))); /* * Dispatch through akgl_controller_handle_event rather than calling the * handlers directly, so a scripted run exercises the binding table the way a * player does. The first version of this called the handlers, which meant * the smoke test passed while the control map matched nothing at all and the * arrow keys did nothing -- a test that cannot fail is not a test. * * SS_AUTOPLAY_KBID is deliberately not 0: the map binds keyboard 0 meaning * "any", and driving it from a non-zero device id is what proves that. */ if ( frame == 1 ) { synthetic.type = SDL_EVENT_KEY_DOWN; synthetic.key.which = SS_AUTOPLAY_KBID; synthetic.key.key = SDLK_RIGHT; PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic)); FAIL_ZERO_RETURN( errctx, AKGL_BITMASK_HAS(ss_game.player->state, AKGL_ACTOR_STATE_MOVING_RIGHT), AKGL_ERR_BEHAVIOR, "the right arrow did not reach the player; the control map matched nothing" ); } if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) { synthetic.type = SDL_EVENT_KEY_DOWN; synthetic.key.which = SS_AUTOPLAY_KBID; synthetic.key.key = SDLK_SPACE; PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic)); } else if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == SS_AUTOPLAY_JUMP_HOLD ) { synthetic.type = SDL_EVENT_KEY_UP; synthetic.key.which = SS_AUTOPLAY_KBID; synthetic.key.key = SDLK_SPACE; PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic)); } SUCCEED_RETURN(errctx); }