Files
major_tom/src/game/player.c
2026-03-05 17:24:20 +00:00

732 lines
25 KiB
C

#include "game/player.h"
#include "game/sprites.h"
#include "game/projectile.h"
#include "engine/input.h"
#include "engine/physics.h"
#include "engine/renderer.h"
#include "engine/particle.h"
#include "engine/audio.h"
#include "engine/assets.h"
#include <stdlib.h>
#include <string.h>
#include <math.h>
static EntityManager *s_em = NULL;
/* ── Sound effects ───────────────────────────────── */
static Sound s_sfx_jump;
static Sound s_sfx_shoot;
static Sound s_sfx_dash;
static bool s_sfx_loaded = false;
/* ── Weapon sprite ───────────────────────────────── */
static SDL_Texture *s_weapon_tex = NULL;
static SDL_Rect s_weapon_src = {0}; /* cropped source rect */
static float s_weapon_render_w = 0; /* display width in game px */
static float s_weapon_render_h = 0; /* display height in game px */
/* Max camera offset when looking up (pixels) */
#define LOOK_UP_OFFSET 80.0f
#define LOOK_UP_DELAY 0.3f /* seconds of holding up before camera pans */
#define LOOK_UP_SPEED 3.0f /* camera pan speed multiplier */
/* Respawn timing */
#define RESPAWN_DELAY 1.0f /* seconds after death anim before respawn */
/* ── Astronaut sprite animations ─────────────────── */
/* Built from the PNG spritesheets at load time */
static bool s_anims_loaded = false;
/* Idle: 144x24, 6 frames of 24x24 */
static AnimFrame s_idle_frames[6];
static AnimDef s_anim_idle;
/* Run: 144x24, 6 frames of 24x24 */
static AnimFrame s_run_frames[6];
static AnimDef s_anim_run;
/* Jump: 120x24, 5 frames of 24x24 */
/* We split this: first 3 = ascending, last 2 = falling */
static AnimFrame s_jump_frames[3];
static AnimDef s_anim_jump;
static AnimFrame s_fall_frames[2];
static AnimDef s_anim_fall;
/* Death: 128x32, 4 frames of 32x32 */
static AnimFrame s_death_frames[4];
static AnimDef s_anim_death;
static void build_strip_frames(AnimFrame *out, int count, int frame_w, int frame_h,
float duration) {
for (int i = 0; i < count; i++) {
out[i].src = (SDL_Rect){ i * frame_w, 0, frame_w, frame_h };
out[i].duration = duration;
}
}
static void load_astronaut_anims(void) {
if (s_anims_loaded) return;
SDL_Texture *tex_idle = assets_get_texture("assets/sprites/player/Astronaut/Astronaut_Idle.png");
SDL_Texture *tex_run = assets_get_texture("assets/sprites/player/Astronaut/Astronaut_Run.png");
SDL_Texture *tex_jump = assets_get_texture("assets/sprites/player/Astronaut/Astronaut_Jump.png");
SDL_Texture *tex_death = assets_get_texture("assets/sprites/player/Astronaut/Astronaut_Death.png");
/* Idle: 6 frames, 24x24 */
build_strip_frames(s_idle_frames, 6, PLAYER_SPRITE_W, PLAYER_SPRITE_H, 0.15f);
s_anim_idle = (AnimDef){
.frames = s_idle_frames,
.frame_count = 6,
.looping = true,
.texture = tex_idle,
};
/* Run: 6 frames, 24x24 */
build_strip_frames(s_run_frames, 6, PLAYER_SPRITE_W, PLAYER_SPRITE_H, 0.1f);
s_anim_run = (AnimDef){
.frames = s_run_frames,
.frame_count = 6,
.looping = true,
.texture = tex_run,
};
/* Jump (ascending): first 3 frames from jump sheet */
for (int i = 0; i < 3; i++) {
s_jump_frames[i].src = (SDL_Rect){ i * PLAYER_SPRITE_W, 0,
PLAYER_SPRITE_W, PLAYER_SPRITE_H };
s_jump_frames[i].duration = 0.12f;
}
s_anim_jump = (AnimDef){
.frames = s_jump_frames,
.frame_count = 3,
.looping = false,
.texture = tex_jump,
};
/* Fall (descending): last 2 frames from jump sheet */
for (int i = 0; i < 2; i++) {
s_fall_frames[i].src = (SDL_Rect){ (3 + i) * PLAYER_SPRITE_W, 0,
PLAYER_SPRITE_W, PLAYER_SPRITE_H };
s_fall_frames[i].duration = 0.15f;
}
s_anim_fall = (AnimDef){
.frames = s_fall_frames,
.frame_count = 2,
.looping = false,
.texture = tex_jump, /* same texture, different frames */
};
/* Death: 4 frames, 32x32 */
build_strip_frames(s_death_frames, 4, PLAYER_DEATH_SPRITE_W, PLAYER_DEATH_SPRITE_H, 0.2f);
s_anim_death = (AnimDef){
.frames = s_death_frames,
.frame_count = 4,
.looping = false,
.texture = tex_death,
};
s_anims_loaded = true;
/* Load weapon sprite (mac) */
if (!s_weapon_tex) {
s_weapon_tex = assets_get_texture("assets/sprites/weapons/mac.png");
if (s_weapon_tex) {
/* Crop to actual gun pixels within the 128x128 canvas */
s_weapon_src = (SDL_Rect){ 52, 51, 30, 17 };
/* Scale to fit the 24x24 character — gun ~12x7 game pixels */
s_weapon_render_w = 12.0f;
s_weapon_render_h = 7.0f;
}
}
/* Load player sound effects */
if (!s_sfx_loaded) {
s_sfx_jump = audio_load_sound("assets/sounds/jump.wav");
s_sfx_shoot = audio_load_sound("assets/sounds/laserShoot.wav");
s_sfx_dash = audio_load_sound("assets/sounds/dash.wav");
s_sfx_loaded = true;
}
}
/* ── Entity manager ref ──────────────────────────── */
void player_set_entity_manager(EntityManager *em) {
s_em = em;
}
void player_register(EntityManager *em) {
entity_register(em, ENT_PLAYER, player_update, player_render, player_destroy);
}
/* ── Update ──────────────────────────────────────── */
void player_update(Entity *self, float dt, const Tilemap *map) {
PlayerData *pd = (PlayerData *)self->data;
if (!pd) return;
/* Handle death */
if (self->flags & ENTITY_DEAD) {
animation_set(&self->anim, &s_anim_death);
animation_update(&self->anim, dt);
/* After death animation finishes, count down to respawn */
if (self->anim.finished) {
pd->respawn_timer -= dt;
}
return;
}
/* Fall off bottom of level — lose 1 HP and auto-dash upward */
float level_bottom = (float)(map->height * TILE_SIZE);
if (self->body.pos.y + self->body.size.y > level_bottom &&
!(self->flags & ENTITY_INVINCIBLE)) {
self->health--;
if (self->health <= 0) {
/* Out of HP — die as before */
self->health = 0;
self->flags |= ENTITY_DEAD;
pd->respawn_timer = 0.3f;
return;
}
/* Drain all jetpack charges */
pd->dash_charges = 0;
pd->dash_recharge_timer = PLAYER_DASH_RECHARGE;
/* Auto-trigger upward dash (ignores charge requirement) */
pd->dash_timer = PLAYER_DASH_DURATION;
pd->dash_dir = vec2(0.0f, -1.0f);
self->body.vel.y = 0;
/* Grant invincibility so this doesn't re-trigger immediately */
pd->inv_timer = PLAYER_INV_TIME;
self->flags |= ENTITY_INVINCIBLE;
/* Effects */
Vec2 burst_pos = vec2(
self->body.pos.x + self->body.size.x * 0.5f,
self->body.pos.y + self->body.size.y
);
particle_emit_jetpack_burst(burst_pos, vec2(0.0f, -1.0f));
audio_play_sound(s_sfx_dash, 96);
}
/* Update invincibility */
if (pd->inv_timer > 0) {
pd->inv_timer -= dt;
if (pd->inv_timer <= 0) {
pd->inv_timer = 0;
self->flags &= ~ENTITY_INVINCIBLE;
}
}
Body *body = &self->body;
/* ── Read directional input ──────────────── */
bool hold_left = input_held(ACTION_LEFT);
bool hold_right = input_held(ACTION_RIGHT);
bool hold_up = input_held(ACTION_UP);
bool hold_down = input_held(ACTION_DOWN);
/* ── Determine aim direction ─────────────── */
if (hold_up && (hold_left || hold_right)) {
pd->aim_dir = AIM_DIAG_UP;
} else if (hold_up) {
pd->aim_dir = AIM_UP;
} else {
pd->aim_dir = AIM_FORWARD;
}
/* ── Look up tracking ────────────────────── */
bool standing_still = body->on_ground && fabsf(body->vel.x) < 10.0f;
if (hold_up && standing_still && !hold_left && !hold_right) {
pd->look_up_timer += dt;
pd->looking_up = (pd->look_up_timer > LOOK_UP_DELAY);
} else {
pd->look_up_timer = 0;
pd->looking_up = false;
}
/* ── Dash / Jetpack ─────────────────────── */
/* Count down jetpack boost timer */
if (pd->jetpack_boost_timer > 0) {
pd->jetpack_boost_timer -= dt;
if (pd->jetpack_boost_timer < 0)
pd->jetpack_boost_timer = 0;
}
/* Recharge jetpack charges over time */
float recharge_rate = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE
: PLAYER_DASH_RECHARGE;
if (pd->dash_charges < pd->dash_max_charges) {
pd->dash_recharge_timer -= dt;
if (pd->dash_recharge_timer <= 0) {
pd->dash_charges++;
/* Reset timer for next charge (if still not full) */
if (pd->dash_charges < pd->dash_max_charges) {
pd->dash_recharge_timer = recharge_rate;
} else {
pd->dash_recharge_timer = 0;
}
}
}
if (pd->dash_timer > 0) {
/* Currently dashing */
pd->dash_timer -= dt;
body->vel.x = pd->dash_dir.x * PLAYER_DASH_SPEED;
body->vel.y = pd->dash_dir.y * PLAYER_DASH_SPEED;
/* Jetpack trail particles every frame */
Vec2 exhaust_pos = vec2(
body->pos.x + body->size.x * 0.5f,
body->pos.y + body->size.y * 0.5f
);
particle_emit_jetpack_trail(exhaust_pos, pd->dash_dir);
/* Blue flame trail when boost is active */
if (pd->jetpack_boost_timer > 0) {
particle_emit_jetpack_boost_trail(exhaust_pos, pd->dash_dir);
}
/* Skip normal movement during dash */
physics_update(body, dt, map);
animation_update(&self->anim, dt);
return;
}
if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
pd->dash_charges--;
/* Start recharge timer only if not already recharging */
if (pd->dash_recharge_timer <= 0) {
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
}
pd->dash_timer = PLAYER_DASH_DURATION;
/* Determine dash direction from input */
Vec2 dash = vec2_zero();
if (hold_left) dash.x = -1.0f;
if (hold_right) dash.x = 1.0f;
if (hold_up) dash.y = -1.0f;
if (hold_down && !body->on_ground) dash.y = 1.0f;
/* Default: dash in facing direction */
if (dash.x == 0.0f && dash.y == 0.0f) {
dash.x = (self->flags & ENTITY_FACING_LEFT) ? -1.0f : 1.0f;
}
pd->dash_dir = vec2_norm(dash);
/* Grant brief invincibility during dash */
pd->inv_timer = PLAYER_DASH_DURATION;
self->flags |= ENTITY_INVINCIBLE;
/* Cancel vertical velocity for upward/horizontal dashes */
if (pd->dash_dir.y <= 0) {
body->vel.y = 0;
}
/* Jetpack burst at dash start */
Vec2 exhaust_pos = vec2(
body->pos.x + body->size.x * 0.5f,
body->pos.y + body->size.y * 0.5f
);
particle_emit_jetpack_burst(exhaust_pos, pd->dash_dir);
/* Blue flame accents when boost powerup is active */
if (pd->jetpack_boost_timer > 0) {
particle_emit_jetpack_boost_burst(exhaust_pos, pd->dash_dir);
}
audio_play_sound(s_sfx_dash, 96);
return;
}
/* ── Jetpack boost idle glow ─────────────── */
/* Ambient blue flame from the player's back while boost is active
* and not dashing. Emits from the rear center of the sprite. */
if (pd->jetpack_boost_timer > 0) {
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
Vec2 back_pos = vec2(
facing_left ? body->pos.x + body->size.x - 1.0f
: body->pos.x + 1.0f,
body->pos.y + body->size.y * 0.45f
);
particle_emit_jetpack_boost_idle(back_pos, facing_left);
}
/* ── Horizontal movement ─────────────────── */
float target_vx = 0.0f;
if (hold_left) target_vx -= PLAYER_SPEED;
if (hold_right) target_vx += PLAYER_SPEED;
/* Set facing direction */
if (target_vx < 0) self->flags |= ENTITY_FACING_LEFT;
if (target_vx > 0) self->flags &= ~ENTITY_FACING_LEFT;
/* Acceleration / deceleration */
float accel = body->on_ground ? PLAYER_ACCEL : PLAYER_AIR_ACCEL;
if (target_vx != 0.0f) {
if (body->vel.x < target_vx) {
body->vel.x += accel * dt;
if (body->vel.x > target_vx) body->vel.x = target_vx;
} else if (body->vel.x > target_vx) {
body->vel.x -= accel * dt;
if (body->vel.x < target_vx) body->vel.x = target_vx;
}
} else {
float decel = body->on_ground ? PLAYER_DECEL : PLAYER_AIR_ACCEL;
if (body->vel.x > 0) {
body->vel.x -= decel * dt;
if (body->vel.x < 0) body->vel.x = 0;
} else if (body->vel.x < 0) {
body->vel.x += decel * dt;
if (body->vel.x > 0) body->vel.x = 0;
}
}
/* ── Coyote time ─────────────────────────── */
if (body->on_ground) {
pd->coyote_timer = PLAYER_COYOTE_TIME;
} else {
pd->coyote_timer -= dt;
}
/* ── Jump buffer ─────────────────────────── */
if (input_pressed(ACTION_JUMP)) {
pd->jump_buffer_timer = PLAYER_JUMP_BUFFER;
} else {
pd->jump_buffer_timer -= dt;
}
/* ── Jump execution ──────────────────────── */
if (pd->jump_buffer_timer > 0 && pd->coyote_timer > 0) {
body->vel.y = -PLAYER_JUMP_FORCE;
pd->jumping = true;
pd->jump_buffer_timer = 0;
pd->coyote_timer = 0;
audio_play_sound(s_sfx_jump, 96);
}
/* Variable jump height: cut velocity on release */
if (pd->jumping && input_released(ACTION_JUMP) && body->vel.y < 0) {
body->vel.y *= PLAYER_JUMP_CUT;
pd->jumping = false;
}
if (body->on_ground && body->vel.y >= 0) {
pd->jumping = false;
}
/* ── Shooting ────────────────────────────── */
pd->shoot_cooldown -= dt;
if (pd->has_gun && input_pressed(ACTION_SHOOT) && pd->shoot_cooldown <= 0 && s_em) {
pd->shoot_cooldown = PLAYER_SHOOT_COOLDOWN;
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
float forward = facing_left ? -1.0f : 1.0f;
Vec2 shoot_dir;
Vec2 bullet_pos;
switch (pd->aim_dir) {
case AIM_UP:
shoot_dir = vec2(0, -1.0f);
bullet_pos = vec2(
body->pos.x + body->size.x * 0.5f - 4.0f,
body->pos.y - 8.0f
);
break;
case AIM_DIAG_UP:
shoot_dir = vec2(forward, -1.0f);
bullet_pos = vec2(
facing_left ? body->pos.x - 4.0f : body->pos.x + body->size.x - 4.0f,
body->pos.y - 4.0f
);
break;
case AIM_FORWARD:
default:
shoot_dir = vec2(forward, 0);
bullet_pos = vec2(
facing_left ? body->pos.x - 8.0f : body->pos.x + body->size.x,
body->pos.y + body->size.y * 0.15f
);
break;
}
projectile_spawn_dir(s_em, bullet_pos, shoot_dir, true);
/* Muzzle flash slightly ahead of bullet origin (at barrel tip) */
Vec2 flash_pos = vec2(
bullet_pos.x + shoot_dir.x * 4.0f,
bullet_pos.y + shoot_dir.y * 4.0f + 3.0f
);
particle_emit_muzzle_flash(flash_pos, shoot_dir);
audio_play_sound(s_sfx_shoot, 80);
}
/* ── Physics ─────────────────────────────── */
physics_update(body, dt, map);
/* ── Landing detection ───────────────────── */
if (body->on_ground && !pd->was_on_ground) {
/* Just landed — emit dust at feet */
Vec2 feet = vec2(
body->pos.x + body->size.x * 0.5f,
body->pos.y + body->size.y
);
particle_emit_landing_dust(feet);
}
pd->was_on_ground = body->on_ground;
/* ── Animation ───────────────────────────── */
if (!body->on_ground) {
if (body->vel.y < 0) {
animation_set(&self->anim, &s_anim_jump);
} else {
animation_set(&self->anim, &s_anim_fall);
}
} else if (fabsf(body->vel.x) > 10.0f) {
animation_set(&self->anim, &s_anim_run);
} else {
animation_set(&self->anim, &s_anim_idle);
}
animation_update(&self->anim, dt);
}
/* ── Render ──────────────────────────────────────── */
void player_render(Entity *self, const Camera *cam) {
if (self->flags & ENTITY_DEAD) {
/* Render death animation if available */
if (!self->anim.def) return;
if (self->anim.finished) return;
}
PlayerData *pd = (PlayerData *)self->data;
Body *body = &self->body;
/* Flash during invincibility (but not during dash) */
if (pd && pd->inv_timer > 0 && pd->dash_timer <= 0) {
int blink = (int)(pd->inv_timer / 0.1f);
if (blink % 2) return;
}
/* Get texture from animation (per-anim texture) or fall back to global */
SDL_Texture *tex = animation_texture(&self->anim);
if (!tex) tex = g_spritesheet;
if (tex && self->anim.def) {
SDL_Rect src = animation_current_rect(&self->anim);
/* Determine sprite size for this frame */
float spr_w = (float)src.w;
float spr_h = (float)src.h;
/* Center the sprite on the hitbox, compensating for bottom padding
* in the astronaut spritesheets (4px transparent below feet) */
Vec2 render_pos = vec2(
body->pos.x + body->size.x * 0.5f - spr_w * 0.5f,
body->pos.y + body->size.y - spr_h + 4.0f
);
Sprite spr = {
.texture = tex,
.src = src,
.pos = render_pos,
.size = vec2(spr_w, spr_h),
.flip_x = (self->flags & ENTITY_FACING_LEFT) != 0,
.flip_y = false,
.layer = LAYER_ENTITIES,
.alpha = 255,
.rotation = 0.0,
};
renderer_submit(&spr);
/* ── Weapon overlay ─────────────────── */
if (s_weapon_tex && !(self->flags & ENTITY_DEAD) && pd && pd->has_gun) {
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
/* Anchor gun to the player sprite position (not body pos)
* so it moves in exact lockstep — no sub-pixel jitter. */
float anchor_x = (float)(int)(render_pos.x + spr_w * 0.5f);
float anchor_y = (float)(int)(render_pos.y + spr_h * 0.5f);
/* Offset from sprite center to grip point */
float gun_offset_x = 6.0f; /* pixels in front of center */
float gun_offset_y = 1.0f; /* slightly below center */
/* Rotation angle based on aim direction */
double gun_rotation = 0.0;
switch (pd->aim_dir) {
case AIM_UP:
gun_rotation = -90.0;
gun_offset_x = 0.0f;
gun_offset_y = -4.0f;
break;
case AIM_DIAG_UP:
gun_rotation = -45.0;
gun_offset_x = 1.0f;
gun_offset_y = -3.0f;
break;
case AIM_FORWARD:
default:
gun_rotation = 0.0;
break;
}
/* Flip adjustments for facing left */
if (facing_left) {
gun_offset_x = -gun_offset_x;
gun_rotation = -gun_rotation;
}
/* Position gun relative to anchor, snapped to integer pixels */
Vec2 gun_pos = vec2(
(float)(int)(anchor_x + gun_offset_x - s_weapon_render_w * 0.5f),
(float)(int)(anchor_y + gun_offset_y - s_weapon_render_h * 0.5f)
);
Sprite gun = {
.texture = s_weapon_tex,
.src = s_weapon_src,
.pos = gun_pos,
.size = vec2(s_weapon_render_w, s_weapon_render_h),
.flip_x = facing_left,
.flip_y = false,
.layer = LAYER_ENTITIES,
.alpha = 255,
.rotation = gun_rotation,
};
renderer_submit(&gun);
}
} else {
/* Fallback: colored rectangle */
SDL_Color color;
if (body->on_ground) {
color = (SDL_Color){100, 200, 100, 255};
} else {
color = (SDL_Color){100, 150, 255, 255};
}
renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam);
}
}
/* ── Lifecycle ───────────────────────────────────── */
void player_destroy(Entity *self) {
free(self->data);
self->data = NULL;
}
Entity *player_spawn(EntityManager *em, Vec2 pos) {
/* Ensure astronaut animations are loaded */
load_astronaut_anims();
Entity *e = entity_spawn(em, ENT_PLAYER, pos);
if (!e) return NULL;
e->body.size = vec2(PLAYER_WIDTH, PLAYER_HEIGHT);
e->body.gravity_scale = 1.0f;
e->health = 3;
e->max_health = 3;
e->flags |= ENTITY_ALWAYS_UPDATE;
PlayerData *pd = calloc(1, sizeof(PlayerData));
pd->has_gun = true; /* armed by default; moon level overrides */
pd->dash_charges = PLAYER_DASH_MAX_CHARGES;
pd->dash_max_charges = PLAYER_DASH_MAX_CHARGES;
pd->respawn_timer = RESPAWN_DELAY;
pd->spawn_point = pos;
e->data = pd;
return e;
}
float player_get_look_up_offset(const Entity *self) {
if (!self || !self->data) return 0.0f;
const PlayerData *pd = (const PlayerData *)self->data;
if (!pd->looking_up) return 0.0f;
/* Smoothly ramp up the offset */
float t = (pd->look_up_timer - LOOK_UP_DELAY) * LOOK_UP_SPEED;
if (t > 1.0f) t = 1.0f;
return -LOOK_UP_OFFSET * t;
}
bool player_get_dash_charges(const Entity *self, int *charges, int *max_charges,
float *recharge_pct, bool *boosted) {
if (!self || !self->data || self->type != ENT_PLAYER) return false;
const PlayerData *pd = (const PlayerData *)self->data;
if (charges) *charges = pd->dash_charges;
if (max_charges) *max_charges = pd->dash_max_charges;
if (boosted) *boosted = pd->jetpack_boost_timer > 0;
if (recharge_pct) {
float rate = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
if (pd->dash_charges >= pd->dash_max_charges) {
*recharge_pct = 1.0f;
} else {
*recharge_pct = 1.0f - (pd->dash_recharge_timer / rate);
}
}
return true;
}
void player_give_gun(Entity *self) {
if (!self || !self->data || self->type != ENT_PLAYER) return;
PlayerData *pd = (PlayerData *)self->data;
pd->has_gun = true;
}
bool player_has_gun(const Entity *self) {
if (!self || !self->data || self->type != ENT_PLAYER) return false;
const PlayerData *pd = (const PlayerData *)self->data;
return pd->has_gun;
}
bool player_wants_respawn(const Entity *self) {
if (!self || !self->data || self->type != ENT_PLAYER) return false;
if (!(self->flags & ENTITY_DEAD)) return false;
const PlayerData *pd = (const PlayerData *)self->data;
return pd->respawn_timer <= 0;
}
void player_respawn(Entity *self, Vec2 pos) {
if (!self || !self->data) return;
PlayerData *pd = (PlayerData *)self->data;
/* Reset entity state */
self->health = self->max_health;
self->flags = 0; /* clear DEAD, INVINCIBLE, etc. */
self->body.pos = pos;
self->body.vel = vec2_zero();
/* Grant brief invincibility on respawn */
pd->inv_timer = PLAYER_INV_TIME;
self->flags |= ENTITY_INVINCIBLE;
/* Reset player-specific state (preserve has_gun across respawn) */
pd->coyote_timer = 0;
pd->jump_buffer_timer = 0;
pd->jumping = false;
pd->was_on_ground = false;
pd->shoot_cooldown = 0;
pd->dash_timer = 0;
pd->dash_charges = pd->dash_max_charges;
pd->dash_recharge_timer = 0;
pd->jetpack_boost_timer = 0;
pd->aim_dir = AIM_FORWARD;
pd->looking_up = false;
pd->look_up_timer = 0;
pd->respawn_timer = RESPAWN_DELAY;
/* Reset animation */
animation_set(&self->anim, &s_anim_idle);
}