732 lines
25 KiB
C
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);
|
|
}
|