forked from tas/major_tom
Initial commit
This commit is contained in:
49
src/engine/animation.c
Normal file
49
src/engine/animation.c
Normal file
@@ -0,0 +1,49 @@
|
||||
#include "engine/animation.h"
|
||||
|
||||
void animation_set(Animation *a, const AnimDef *def) {
|
||||
if (a->def == def) return; /* don't restart same anim */
|
||||
a->def = def;
|
||||
a->current_frame = 0;
|
||||
a->timer = 0.0f;
|
||||
a->finished = false;
|
||||
}
|
||||
|
||||
void animation_update(Animation *a, float dt) {
|
||||
if (!a->def || a->finished) return;
|
||||
|
||||
a->timer += dt;
|
||||
while (a->timer >= a->def->frames[a->current_frame].duration) {
|
||||
float dur = a->def->frames[a->current_frame].duration;
|
||||
if (dur <= 0.0f) break; /* avoid infinite loop on zero-duration frames */
|
||||
a->timer -= dur;
|
||||
a->current_frame++;
|
||||
|
||||
if (a->current_frame >= a->def->frame_count) {
|
||||
if (a->def->looping) {
|
||||
a->current_frame = 0;
|
||||
} else {
|
||||
a->current_frame = a->def->frame_count - 1;
|
||||
a->finished = true;
|
||||
a->timer = 0.0f;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SDL_Rect animation_current_rect(const Animation *a) {
|
||||
if (!a->def || a->def->frame_count == 0) {
|
||||
return (SDL_Rect){0, 0, 0, 0};
|
||||
}
|
||||
return a->def->frames[a->current_frame].src;
|
||||
}
|
||||
|
||||
bool animation_is_last_frame(const Animation *a) {
|
||||
if (!a->def) return true;
|
||||
return a->current_frame >= a->def->frame_count - 1;
|
||||
}
|
||||
|
||||
SDL_Texture *animation_texture(const Animation *a) {
|
||||
if (!a->def) return NULL;
|
||||
return a->def->texture;
|
||||
}
|
||||
41
src/engine/animation.h
Normal file
41
src/engine/animation.h
Normal file
@@ -0,0 +1,41 @@
|
||||
#ifndef JNR_ANIMATION_H
|
||||
#define JNR_ANIMATION_H
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
typedef struct AnimFrame {
|
||||
SDL_Rect src; /* source rect in spritesheet */
|
||||
float duration; /* seconds this frame shows */
|
||||
} AnimFrame;
|
||||
|
||||
typedef struct AnimDef {
|
||||
AnimFrame *frames;
|
||||
int frame_count;
|
||||
bool looping;
|
||||
SDL_Texture *texture; /* spritesheet for this anim (NULL = use default) */
|
||||
} AnimDef;
|
||||
|
||||
typedef struct Animation {
|
||||
const AnimDef *def;
|
||||
int current_frame;
|
||||
float timer;
|
||||
bool finished;
|
||||
} Animation;
|
||||
|
||||
/* Set a new animation definition (resets playback) */
|
||||
void animation_set(Animation *a, const AnimDef *def);
|
||||
|
||||
/* Update animation timer, advance frames */
|
||||
void animation_update(Animation *a, float dt);
|
||||
|
||||
/* Get the current frame's source rect */
|
||||
SDL_Rect animation_current_rect(const Animation *a);
|
||||
|
||||
/* Check if this is the last frame (useful for one-shot anims) */
|
||||
bool animation_is_last_frame(const Animation *a);
|
||||
|
||||
/* Get the texture for the current animation (NULL if none set) */
|
||||
SDL_Texture *animation_texture(const Animation *a);
|
||||
|
||||
#endif /* JNR_ANIMATION_H */
|
||||
106
src/engine/assets.c
Normal file
106
src/engine/assets.c
Normal file
@@ -0,0 +1,106 @@
|
||||
#include "engine/assets.h"
|
||||
#include "config.h"
|
||||
#include <SDL2/SDL_image.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct AssetEntry {
|
||||
char path[ASSET_PATH_MAX];
|
||||
SDL_Texture *texture;
|
||||
Sound sound;
|
||||
Music music;
|
||||
} AssetEntry;
|
||||
|
||||
static AssetEntry s_assets[MAX_ASSETS];
|
||||
static int s_asset_count = 0;
|
||||
static SDL_Renderer *s_renderer = NULL;
|
||||
|
||||
void assets_init(SDL_Renderer *renderer) {
|
||||
s_renderer = renderer;
|
||||
s_asset_count = 0;
|
||||
memset(s_assets, 0, sizeof(s_assets));
|
||||
|
||||
/* Initialize SDL_image */
|
||||
int img_flags = IMG_INIT_PNG;
|
||||
if (!(IMG_Init(img_flags) & img_flags)) {
|
||||
fprintf(stderr, "IMG_Init failed: %s\n", IMG_GetError());
|
||||
}
|
||||
}
|
||||
|
||||
static int find_asset(const char *path) {
|
||||
for (int i = 0; i < s_asset_count; i++) {
|
||||
if (strcmp(s_assets[i].path, path) == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
SDL_Texture *assets_get_texture(const char *path) {
|
||||
int idx = find_asset(path);
|
||||
if (idx >= 0) return s_assets[idx].texture;
|
||||
|
||||
if (s_asset_count >= MAX_ASSETS) {
|
||||
fprintf(stderr, "Asset limit reached!\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
SDL_Texture *tex = IMG_LoadTexture(s_renderer, path);
|
||||
if (!tex) {
|
||||
fprintf(stderr, "Failed to load texture: %s (%s)\n", path, IMG_GetError());
|
||||
return NULL;
|
||||
}
|
||||
|
||||
idx = s_asset_count++;
|
||||
strncpy(s_assets[idx].path, path, ASSET_PATH_MAX - 1);
|
||||
s_assets[idx].texture = tex;
|
||||
|
||||
printf("Loaded texture: %s\n", path);
|
||||
return tex;
|
||||
}
|
||||
|
||||
Sound assets_get_sound(const char *path) {
|
||||
int idx = find_asset(path);
|
||||
if (idx >= 0) return s_assets[idx].sound;
|
||||
|
||||
if (s_asset_count >= MAX_ASSETS) {
|
||||
fprintf(stderr, "Asset limit reached!\n");
|
||||
return (Sound){0};
|
||||
}
|
||||
|
||||
Sound s = audio_load_sound(path);
|
||||
idx = s_asset_count++;
|
||||
strncpy(s_assets[idx].path, path, ASSET_PATH_MAX - 1);
|
||||
s_assets[idx].sound = s;
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
Music assets_get_music(const char *path) {
|
||||
int idx = find_asset(path);
|
||||
if (idx >= 0) return s_assets[idx].music;
|
||||
|
||||
if (s_asset_count >= MAX_ASSETS) {
|
||||
fprintf(stderr, "Asset limit reached!\n");
|
||||
return (Music){0};
|
||||
}
|
||||
|
||||
Music m = audio_load_music(path);
|
||||
idx = s_asset_count++;
|
||||
strncpy(s_assets[idx].path, path, ASSET_PATH_MAX - 1);
|
||||
s_assets[idx].music = m;
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
void assets_free_all(void) {
|
||||
for (int i = 0; i < s_asset_count; i++) {
|
||||
if (s_assets[i].texture) {
|
||||
SDL_DestroyTexture(s_assets[i].texture);
|
||||
}
|
||||
/* Sound/Music chunks are freed by SDL_mixer on close */
|
||||
}
|
||||
s_asset_count = 0;
|
||||
memset(s_assets, 0, sizeof(s_assets));
|
||||
IMG_Quit();
|
||||
}
|
||||
22
src/engine/assets.h
Normal file
22
src/engine/assets.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#ifndef JNR_ASSETS_H
|
||||
#define JNR_ASSETS_H
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include "engine/audio.h"
|
||||
|
||||
/* Initialize asset manager (call after engine_init) */
|
||||
void assets_init(SDL_Renderer *renderer);
|
||||
|
||||
/* Load / retrieve cached texture by path */
|
||||
SDL_Texture *assets_get_texture(const char *path);
|
||||
|
||||
/* Load / retrieve cached sound by path */
|
||||
Sound assets_get_sound(const char *path);
|
||||
|
||||
/* Load / retrieve cached music by path */
|
||||
Music assets_get_music(const char *path);
|
||||
|
||||
/* Free all loaded assets */
|
||||
void assets_free_all(void);
|
||||
|
||||
#endif /* JNR_ASSETS_H */
|
||||
103
src/engine/audio.c
Normal file
103
src/engine/audio.c
Normal file
@@ -0,0 +1,103 @@
|
||||
#include "engine/audio.h"
|
||||
#include "config.h"
|
||||
#include <SDL2/SDL_mixer.h>
|
||||
#include <stdio.h>
|
||||
|
||||
static bool s_initialized = false;
|
||||
|
||||
bool audio_init(void) {
|
||||
/* Initialize MP3 (and OGG) decoder support */
|
||||
int mix_flags = MIX_INIT_MP3 | MIX_INIT_OGG;
|
||||
int mix_initted = Mix_Init(mix_flags);
|
||||
if ((mix_initted & mix_flags) != mix_flags) {
|
||||
fprintf(stderr, "Mix_Init: some formats not available: %s\n", Mix_GetError());
|
||||
/* Continue anyway — WAV still works */
|
||||
}
|
||||
|
||||
if (Mix_OpenAudio(AUDIO_FREQUENCY, MIX_DEFAULT_FORMAT,
|
||||
AUDIO_CHANNELS, AUDIO_CHUNK_SIZE) < 0) {
|
||||
fprintf(stderr, "Mix_OpenAudio failed: %s\n", Mix_GetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Log what SDL_mixer actually opened */
|
||||
{
|
||||
int freq, channels;
|
||||
Uint16 format;
|
||||
if (Mix_QuerySpec(&freq, &format, &channels)) {
|
||||
printf("Audio device: %d Hz, %d channels, format=0x%04X\n",
|
||||
freq, channels, format);
|
||||
}
|
||||
}
|
||||
|
||||
Mix_AllocateChannels(MAX_SFX_CHANNELS);
|
||||
s_initialized = true;
|
||||
printf("Audio initialized.\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
Sound audio_load_sound(const char *path) {
|
||||
Sound s = {0};
|
||||
if (!s_initialized) return s;
|
||||
s.chunk = Mix_LoadWAV(path);
|
||||
if (!s.chunk) {
|
||||
fprintf(stderr, "Failed to load sound: %s (%s)\n", path, Mix_GetError());
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
Music audio_load_music(const char *path) {
|
||||
Music m = {0};
|
||||
if (!s_initialized) {
|
||||
fprintf(stderr, "audio_load_music: audio not initialized\n");
|
||||
return m;
|
||||
}
|
||||
m.music = Mix_LoadMUS(path);
|
||||
if (!m.music) {
|
||||
fprintf(stderr, "Failed to load music: %s (%s)\n", path, Mix_GetError());
|
||||
} else {
|
||||
printf("Loaded music: %s\n", path);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
void audio_play_sound(Sound s, int volume) {
|
||||
if (!s_initialized || !s.chunk) return;
|
||||
int ch = Mix_PlayChannel(-1, (Mix_Chunk *)s.chunk, 0);
|
||||
if (ch >= 0) {
|
||||
Mix_Volume(ch, volume);
|
||||
}
|
||||
}
|
||||
|
||||
void audio_play_music(Music m, bool loop) {
|
||||
if (!s_initialized) {
|
||||
fprintf(stderr, "audio_play_music: audio not initialized\n");
|
||||
return;
|
||||
}
|
||||
if (!m.music) {
|
||||
fprintf(stderr, "audio_play_music: music handle is NULL\n");
|
||||
return;
|
||||
}
|
||||
if (Mix_PlayMusic((Mix_Music *)m.music, loop ? -1 : 1) < 0) {
|
||||
fprintf(stderr, "Mix_PlayMusic failed: %s\n", Mix_GetError());
|
||||
}
|
||||
}
|
||||
|
||||
void audio_stop_music(void) {
|
||||
if (!s_initialized) return;
|
||||
Mix_HaltMusic();
|
||||
}
|
||||
|
||||
void audio_set_music_volume(int volume) {
|
||||
if (!s_initialized) return;
|
||||
Mix_VolumeMusic(volume);
|
||||
}
|
||||
|
||||
void audio_shutdown(void) {
|
||||
if (!s_initialized) return;
|
||||
Mix_HaltMusic();
|
||||
Mix_CloseAudio();
|
||||
Mix_Quit();
|
||||
s_initialized = false;
|
||||
printf("Audio shut down.\n");
|
||||
}
|
||||
23
src/engine/audio.h
Normal file
23
src/engine/audio.h
Normal file
@@ -0,0 +1,23 @@
|
||||
#ifndef JNR_AUDIO_H
|
||||
#define JNR_AUDIO_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
typedef struct Sound {
|
||||
void *chunk; /* Mix_Chunk* */
|
||||
} Sound;
|
||||
|
||||
typedef struct Music {
|
||||
void *music; /* Mix_Music* */
|
||||
} Music;
|
||||
|
||||
bool audio_init(void);
|
||||
Sound audio_load_sound(const char *path);
|
||||
Music audio_load_music(const char *path);
|
||||
void audio_play_sound(Sound s, int volume); /* 0-128, fire and forget */
|
||||
void audio_play_music(Music m, bool loop);
|
||||
void audio_stop_music(void);
|
||||
void audio_set_music_volume(int volume); /* 0-128 */
|
||||
void audio_shutdown(void);
|
||||
|
||||
#endif /* JNR_AUDIO_H */
|
||||
90
src/engine/camera.c
Normal file
90
src/engine/camera.c
Normal file
@@ -0,0 +1,90 @@
|
||||
#include "engine/camera.h"
|
||||
#include <stdlib.h>
|
||||
#include <math.h>
|
||||
|
||||
void camera_init(Camera *c, float vp_w, float vp_h) {
|
||||
c->pos = vec2_zero();
|
||||
c->viewport = vec2(vp_w, vp_h);
|
||||
c->bounds_min = vec2_zero();
|
||||
c->bounds_max = vec2(vp_w, vp_h); /* default: one screen */
|
||||
c->smoothing = 5.0f;
|
||||
c->deadzone = vec2(30.0f, 20.0f);
|
||||
c->look_ahead = vec2(40.0f, 0.0f);
|
||||
}
|
||||
|
||||
void camera_set_bounds(Camera *c, float world_w, float world_h) {
|
||||
c->bounds_min = vec2_zero();
|
||||
c->bounds_max = vec2(world_w, world_h);
|
||||
}
|
||||
|
||||
void camera_follow(Camera *c, Vec2 target, Vec2 velocity, float dt) {
|
||||
/* Target is the center point we want to follow */
|
||||
Vec2 desired;
|
||||
desired.x = target.x - c->viewport.x * 0.5f;
|
||||
desired.y = target.y - c->viewport.y * 0.5f;
|
||||
|
||||
/* Look-ahead: shift camera in the direction of movement */
|
||||
if (velocity.x > 10.0f) desired.x += c->look_ahead.x;
|
||||
else if (velocity.x < -10.0f) desired.x -= c->look_ahead.x;
|
||||
|
||||
/* Smooth follow using exponential decay */
|
||||
float t = 1.0f - expf(-c->smoothing * dt);
|
||||
c->pos = vec2_lerp(c->pos, desired, t);
|
||||
|
||||
/* Clamp to world bounds */
|
||||
c->pos.x = clampf(c->pos.x, c->bounds_min.x,
|
||||
c->bounds_max.x - c->viewport.x);
|
||||
c->pos.y = clampf(c->pos.y, c->bounds_min.y,
|
||||
c->bounds_max.y - c->viewport.y);
|
||||
}
|
||||
|
||||
Vec2 camera_world_to_screen(const Camera *c, Vec2 world_pos) {
|
||||
Vec2 screen = vec2_sub(world_pos, c->pos);
|
||||
/* Apply shake offset */
|
||||
screen.x += c->shake_offset.x;
|
||||
screen.y += c->shake_offset.y;
|
||||
return screen;
|
||||
}
|
||||
|
||||
Vec2 camera_screen_to_world(const Camera *c, Vec2 screen_pos) {
|
||||
return vec2_add(screen_pos, c->pos);
|
||||
}
|
||||
|
||||
/* ── Screen shake ────────────────────────────────── */
|
||||
|
||||
static float randf_sym(void) {
|
||||
/* Random float in [-1, 1] */
|
||||
return ((float)rand() / (float)RAND_MAX) * 2.0f - 1.0f;
|
||||
}
|
||||
|
||||
void camera_shake(Camera *c, float intensity, float duration) {
|
||||
/* Take the stronger shake if one is already active */
|
||||
if (intensity > c->shake_intensity || c->shake_timer <= 0) {
|
||||
c->shake_intensity = intensity;
|
||||
c->shake_timer = duration;
|
||||
}
|
||||
}
|
||||
|
||||
void camera_update_shake(Camera *c, float dt) {
|
||||
if (c->shake_timer <= 0) {
|
||||
c->shake_offset = vec2_zero();
|
||||
return;
|
||||
}
|
||||
|
||||
c->shake_timer -= dt;
|
||||
|
||||
/* Decay intensity as shake expires */
|
||||
float t = c->shake_timer > 0 ? c->shake_timer / 0.3f : 0; /* ~0.3s reference */
|
||||
if (t > 1.0f) t = 1.0f;
|
||||
float current = c->shake_intensity * t;
|
||||
|
||||
/* Random offset, rounded to whole pixels for crisp pixel art */
|
||||
c->shake_offset.x = roundf(randf_sym() * current);
|
||||
c->shake_offset.y = roundf(randf_sym() * current);
|
||||
|
||||
if (c->shake_timer <= 0) {
|
||||
c->shake_timer = 0;
|
||||
c->shake_intensity = 0;
|
||||
c->shake_offset = vec2_zero();
|
||||
}
|
||||
}
|
||||
37
src/engine/camera.h
Normal file
37
src/engine/camera.h
Normal file
@@ -0,0 +1,37 @@
|
||||
#ifndef JNR_CAMERA_H
|
||||
#define JNR_CAMERA_H
|
||||
|
||||
#include "util/vec2.h"
|
||||
#include "config.h"
|
||||
|
||||
typedef struct Camera {
|
||||
Vec2 pos; /* world position of top-left corner */
|
||||
Vec2 viewport; /* screen dimensions in game pixels */
|
||||
Vec2 bounds_min; /* level bounds (top-left) */
|
||||
Vec2 bounds_max; /* level bounds (bottom-right) */
|
||||
float smoothing; /* higher = slower follow (0=instant) */
|
||||
Vec2 deadzone; /* half-size of deadzone rect */
|
||||
Vec2 look_ahead; /* pixels to lead in move direction */
|
||||
/* Screen shake */
|
||||
float shake_timer; /* remaining shake time */
|
||||
float shake_intensity; /* current max pixel offset */
|
||||
Vec2 shake_offset; /* current frame shake displacement */
|
||||
} Camera;
|
||||
|
||||
void camera_init(Camera *c, float vp_w, float vp_h);
|
||||
void camera_set_bounds(Camera *c, float world_w, float world_h);
|
||||
void camera_follow(Camera *c, Vec2 target, Vec2 velocity, float dt);
|
||||
|
||||
/* Convert world position to screen position */
|
||||
Vec2 camera_world_to_screen(const Camera *c, Vec2 world_pos);
|
||||
|
||||
/* Convert screen position to world position */
|
||||
Vec2 camera_screen_to_world(const Camera *c, Vec2 screen_pos);
|
||||
|
||||
/* Trigger screen shake (intensity in pixels, duration in seconds) */
|
||||
void camera_shake(Camera *c, float intensity, float duration);
|
||||
|
||||
/* Update shake (call once per frame, after camera_follow) */
|
||||
void camera_update_shake(Camera *c, float dt);
|
||||
|
||||
#endif /* JNR_CAMERA_H */
|
||||
177
src/engine/core.c
Normal file
177
src/engine/core.c
Normal file
@@ -0,0 +1,177 @@
|
||||
#include "engine/core.h"
|
||||
#include "engine/input.h"
|
||||
#include "engine/renderer.h"
|
||||
#include "engine/audio.h"
|
||||
#include "engine/assets.h"
|
||||
#include <stdio.h>
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten.h>
|
||||
#endif
|
||||
|
||||
Engine g_engine = {0};
|
||||
|
||||
static GameCallbacks s_callbacks = {0};
|
||||
|
||||
void engine_set_callbacks(GameCallbacks cb) {
|
||||
s_callbacks = cb;
|
||||
}
|
||||
|
||||
bool engine_init(void) {
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMECONTROLLER) != 0) {
|
||||
fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
int win_w = SCREEN_WIDTH * WINDOW_SCALE;
|
||||
int win_h = SCREEN_HEIGHT * WINDOW_SCALE;
|
||||
|
||||
g_engine.window = SDL_CreateWindow(
|
||||
WINDOW_TITLE,
|
||||
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||
win_w, win_h,
|
||||
SDL_WINDOW_SHOWN
|
||||
);
|
||||
if (!g_engine.window) {
|
||||
fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
g_engine.renderer = SDL_CreateRenderer(
|
||||
g_engine.window, -1,
|
||||
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC
|
||||
);
|
||||
if (!g_engine.renderer) {
|
||||
fprintf(stderr, "SDL_CreateRenderer failed: %s\n", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Set logical size for pixel-perfect scaling */
|
||||
SDL_RenderSetLogicalSize(g_engine.renderer, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "0"); /* nearest neighbor */
|
||||
|
||||
/* Initialize subsystems */
|
||||
input_init();
|
||||
renderer_init(g_engine.renderer);
|
||||
audio_init();
|
||||
assets_init(g_engine.renderer);
|
||||
|
||||
g_engine.running = true;
|
||||
g_engine.tick = 0;
|
||||
g_engine.fps = 0.0f;
|
||||
|
||||
printf("Engine initialized: %dx%d (x%d scale)\n",
|
||||
SCREEN_WIDTH, SCREEN_HEIGHT, WINDOW_SCALE);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Frame-loop state (file-scope so engine_frame can access it) ── */
|
||||
static uint64_t s_freq;
|
||||
static uint64_t s_prev_time;
|
||||
static float s_accumulator;
|
||||
static float s_fps_timer;
|
||||
static int s_fps_frames;
|
||||
|
||||
/*
|
||||
* engine_frame -- execute exactly one iteration of the game loop.
|
||||
*
|
||||
* Called repeatedly by the platform layer:
|
||||
* Native : tight while-loop in engine_run()
|
||||
* Browser : emscripten_set_main_loop() via requestAnimationFrame
|
||||
*/
|
||||
static void engine_frame(void) {
|
||||
uint64_t current_time = SDL_GetPerformanceCounter();
|
||||
float frame_time = (float)(current_time - s_prev_time) / (float)s_freq;
|
||||
s_prev_time = current_time;
|
||||
|
||||
/* Clamp frame time to avoid spiral of death */
|
||||
if (frame_time > MAX_FRAME_SKIP * DT) {
|
||||
frame_time = MAX_FRAME_SKIP * DT;
|
||||
}
|
||||
|
||||
/* FPS counter */
|
||||
s_fps_timer += frame_time;
|
||||
s_fps_frames++;
|
||||
if (s_fps_timer >= 1.0f) {
|
||||
g_engine.fps = (float)s_fps_frames / s_fps_timer;
|
||||
s_fps_timer = 0.0f;
|
||||
s_fps_frames = 0;
|
||||
}
|
||||
|
||||
/* ── Input ────────────────────────────── */
|
||||
input_poll();
|
||||
if (input_quit_requested()) {
|
||||
g_engine.running = false;
|
||||
#ifdef __EMSCRIPTEN__
|
||||
if (s_callbacks.shutdown) {
|
||||
s_callbacks.shutdown();
|
||||
}
|
||||
emscripten_cancel_main_loop();
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
/* ── Fixed timestep update ────────────── */
|
||||
s_accumulator += frame_time;
|
||||
while (s_accumulator >= DT) {
|
||||
if (s_callbacks.update) {
|
||||
s_callbacks.update(DT);
|
||||
}
|
||||
input_consume();
|
||||
g_engine.tick++;
|
||||
s_accumulator -= DT;
|
||||
}
|
||||
|
||||
/* ── Render ───────────────────────────── */
|
||||
g_engine.interpolation = s_accumulator / DT;
|
||||
renderer_clear();
|
||||
if (s_callbacks.render) {
|
||||
s_callbacks.render(g_engine.interpolation);
|
||||
}
|
||||
renderer_present();
|
||||
}
|
||||
|
||||
void engine_run(void) {
|
||||
if (s_callbacks.init) {
|
||||
s_callbacks.init();
|
||||
}
|
||||
|
||||
/* Initialise frame-loop timing state */
|
||||
s_freq = SDL_GetPerformanceFrequency();
|
||||
s_prev_time = SDL_GetPerformanceCounter();
|
||||
s_accumulator = 0.0f;
|
||||
s_fps_timer = 0.0f;
|
||||
s_fps_frames = 0;
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
/*
|
||||
* Hand control to the browser's requestAnimationFrame loop.
|
||||
* fps=0 : match display refresh rate
|
||||
* simulate_infinite_loop=1 : don't return from this function
|
||||
* (so the rest of engine_run never executes until shutdown)
|
||||
*/
|
||||
emscripten_set_main_loop(engine_frame, 0, 1);
|
||||
#else
|
||||
while (g_engine.running) {
|
||||
engine_frame();
|
||||
}
|
||||
#endif
|
||||
|
||||
if (s_callbacks.shutdown) {
|
||||
s_callbacks.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
void engine_shutdown(void) {
|
||||
assets_free_all();
|
||||
audio_shutdown();
|
||||
renderer_shutdown();
|
||||
input_shutdown();
|
||||
|
||||
if (g_engine.renderer) SDL_DestroyRenderer(g_engine.renderer);
|
||||
if (g_engine.window) SDL_DestroyWindow(g_engine.window);
|
||||
SDL_Quit();
|
||||
|
||||
printf("Engine shut down cleanly.\n");
|
||||
}
|
||||
40
src/engine/core.h
Normal file
40
src/engine/core.h
Normal file
@@ -0,0 +1,40 @@
|
||||
#ifndef JNR_CORE_H
|
||||
#define JNR_CORE_H
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include "config.h"
|
||||
|
||||
typedef struct Engine {
|
||||
bool running;
|
||||
SDL_Window *window;
|
||||
SDL_Renderer *renderer;
|
||||
uint32_t tick; /* current physics tick */
|
||||
float interpolation; /* 0.0-1.0 for render interpolation */
|
||||
float fps; /* smoothed frames-per-second */
|
||||
} Engine;
|
||||
|
||||
/* Global engine instance */
|
||||
extern Engine g_engine;
|
||||
|
||||
bool engine_init(void);
|
||||
void engine_run(void);
|
||||
void engine_shutdown(void);
|
||||
|
||||
/* Called by the game layer - set these before engine_run() */
|
||||
typedef void (*GameInitFn)(void);
|
||||
typedef void (*GameUpdateFn)(float dt);
|
||||
typedef void (*GameRenderFn)(float interpolation);
|
||||
typedef void (*GameShutdownFn)(void);
|
||||
|
||||
typedef struct GameCallbacks {
|
||||
GameInitFn init;
|
||||
GameUpdateFn update;
|
||||
GameRenderFn render;
|
||||
GameShutdownFn shutdown;
|
||||
} GameCallbacks;
|
||||
|
||||
void engine_set_callbacks(GameCallbacks cb);
|
||||
|
||||
#endif /* JNR_CORE_H */
|
||||
89
src/engine/entity.c
Normal file
89
src/engine/entity.c
Normal file
@@ -0,0 +1,89 @@
|
||||
#include "engine/entity.h"
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
void entity_manager_init(EntityManager *em) {
|
||||
memset(em, 0, sizeof(EntityManager));
|
||||
}
|
||||
|
||||
Entity *entity_spawn(EntityManager *em, EntityType type, Vec2 pos) {
|
||||
/* Find a free slot */
|
||||
for (int i = 0; i < MAX_ENTITIES; i++) {
|
||||
if (!em->entities[i].active) {
|
||||
Entity *e = &em->entities[i];
|
||||
memset(e, 0, sizeof(Entity));
|
||||
e->type = type;
|
||||
e->active = true;
|
||||
e->body.pos = pos;
|
||||
e->body.gravity_scale = 1.0f;
|
||||
e->health = 1;
|
||||
e->max_health = 1;
|
||||
if (i >= em->count) em->count = i + 1;
|
||||
return e;
|
||||
}
|
||||
}
|
||||
fprintf(stderr, "Warning: entity limit reached (%d)\n", MAX_ENTITIES);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void entity_destroy(EntityManager *em, Entity *e) {
|
||||
if (!e || !e->active) return;
|
||||
|
||||
/* Call type-specific destructor */
|
||||
if (em->destroy_fn[e->type]) {
|
||||
em->destroy_fn[e->type](e);
|
||||
}
|
||||
|
||||
e->active = false;
|
||||
e->type = ENT_NONE;
|
||||
|
||||
/* Shrink count if possible */
|
||||
while (em->count > 0 && !em->entities[em->count - 1].active) {
|
||||
em->count--;
|
||||
}
|
||||
}
|
||||
|
||||
void entity_update_all(EntityManager *em, float dt, const Tilemap *map) {
|
||||
for (int i = 0; i < em->count; i++) {
|
||||
Entity *e = &em->entities[i];
|
||||
if (!e->active) continue;
|
||||
|
||||
/* Check if dead */
|
||||
if (e->health <= 0 && !(e->flags & ENTITY_INVINCIBLE)) {
|
||||
e->flags |= ENTITY_DEAD;
|
||||
}
|
||||
|
||||
/* Call type-specific update */
|
||||
if (em->update_fn[e->type]) {
|
||||
em->update_fn[e->type](e, dt, map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void entity_render_all(EntityManager *em, const Camera *cam) {
|
||||
for (int i = 0; i < em->count; i++) {
|
||||
Entity *e = &em->entities[i];
|
||||
if (!e->active) continue;
|
||||
|
||||
if (em->render_fn[e->type]) {
|
||||
em->render_fn[e->type](e, cam);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void entity_manager_clear(EntityManager *em) {
|
||||
for (int i = 0; i < em->count; i++) {
|
||||
if (em->entities[i].active) {
|
||||
entity_destroy(em, &em->entities[i]);
|
||||
}
|
||||
}
|
||||
em->count = 0;
|
||||
}
|
||||
|
||||
void entity_register(EntityManager *em, EntityType type,
|
||||
EntityUpdateFn update, EntityRenderFn render,
|
||||
EntityDestroyFn destroy) {
|
||||
em->update_fn[type] = update;
|
||||
em->render_fn[type] = render;
|
||||
em->destroy_fn[type] = destroy;
|
||||
}
|
||||
70
src/engine/entity.h
Normal file
70
src/engine/entity.h
Normal file
@@ -0,0 +1,70 @@
|
||||
#ifndef JNR_ENTITY_H
|
||||
#define JNR_ENTITY_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include "config.h"
|
||||
#include "engine/physics.h"
|
||||
#include "engine/animation.h"
|
||||
#include "util/vec2.h"
|
||||
|
||||
/* Forward declarations */
|
||||
typedef struct Tilemap Tilemap;
|
||||
typedef struct Camera Camera;
|
||||
|
||||
typedef enum EntityType {
|
||||
ENT_NONE = 0,
|
||||
ENT_PLAYER,
|
||||
ENT_ENEMY_GRUNT,
|
||||
ENT_ENEMY_FLYER,
|
||||
ENT_PROJECTILE,
|
||||
ENT_PICKUP,
|
||||
ENT_PARTICLE,
|
||||
ENT_TYPE_COUNT
|
||||
} EntityType;
|
||||
|
||||
/* Entity flags */
|
||||
#define ENTITY_INVINCIBLE (1 << 0)
|
||||
#define ENTITY_FACING_LEFT (1 << 1)
|
||||
#define ENTITY_DEAD (1 << 2)
|
||||
|
||||
typedef struct Entity {
|
||||
EntityType type;
|
||||
bool active;
|
||||
Body body;
|
||||
Animation anim;
|
||||
int health;
|
||||
int max_health;
|
||||
int damage;
|
||||
uint32_t flags;
|
||||
float timer; /* general-purpose timer */
|
||||
void *data; /* type-specific data pointer */
|
||||
} Entity;
|
||||
|
||||
/* Function pointer types for entity behaviors */
|
||||
typedef void (*EntityUpdateFn)(Entity *self, float dt, const Tilemap *map);
|
||||
typedef void (*EntityRenderFn)(Entity *self, const Camera *cam);
|
||||
typedef void (*EntityDestroyFn)(Entity *self);
|
||||
|
||||
typedef struct EntityManager {
|
||||
Entity entities[MAX_ENTITIES];
|
||||
int count;
|
||||
/* dispatch tables */
|
||||
EntityUpdateFn update_fn[ENT_TYPE_COUNT];
|
||||
EntityRenderFn render_fn[ENT_TYPE_COUNT];
|
||||
EntityDestroyFn destroy_fn[ENT_TYPE_COUNT];
|
||||
} EntityManager;
|
||||
|
||||
void entity_manager_init(EntityManager *em);
|
||||
Entity *entity_spawn(EntityManager *em, EntityType type, Vec2 pos);
|
||||
void entity_destroy(EntityManager *em, Entity *e);
|
||||
void entity_update_all(EntityManager *em, float dt, const Tilemap *map);
|
||||
void entity_render_all(EntityManager *em, const Camera *cam);
|
||||
void entity_manager_clear(EntityManager *em);
|
||||
|
||||
/* Register behavior functions for an entity type */
|
||||
void entity_register(EntityManager *em, EntityType type,
|
||||
EntityUpdateFn update, EntityRenderFn render,
|
||||
EntityDestroyFn destroy);
|
||||
|
||||
#endif /* JNR_ENTITY_H */
|
||||
99
src/engine/input.c
Normal file
99
src/engine/input.c
Normal file
@@ -0,0 +1,99 @@
|
||||
#include "engine/input.h"
|
||||
#include <string.h>
|
||||
|
||||
static bool s_current[ACTION_COUNT];
|
||||
static bool s_previous[ACTION_COUNT];
|
||||
|
||||
/*
|
||||
* Latched states: accumulate press/release events across frames
|
||||
* so that a press is never lost even if no update tick runs
|
||||
* during the frame it was detected.
|
||||
*/
|
||||
static bool s_latched_pressed[ACTION_COUNT];
|
||||
static bool s_latched_released[ACTION_COUNT];
|
||||
|
||||
static bool s_quit_requested;
|
||||
|
||||
/* Default key bindings (primary + alternate) */
|
||||
static SDL_Scancode s_bindings[ACTION_COUNT] = {
|
||||
[ACTION_LEFT] = SDL_SCANCODE_LEFT,
|
||||
[ACTION_RIGHT] = SDL_SCANCODE_RIGHT,
|
||||
[ACTION_UP] = SDL_SCANCODE_UP,
|
||||
[ACTION_DOWN] = SDL_SCANCODE_DOWN,
|
||||
[ACTION_JUMP] = SDL_SCANCODE_Z,
|
||||
[ACTION_SHOOT] = SDL_SCANCODE_X,
|
||||
[ACTION_DASH] = SDL_SCANCODE_C,
|
||||
[ACTION_PAUSE] = SDL_SCANCODE_ESCAPE,
|
||||
};
|
||||
|
||||
/* Alternate bindings (0 = no alternate) */
|
||||
static SDL_Scancode s_alt_bindings[ACTION_COUNT] = {
|
||||
[ACTION_SHOOT] = SDL_SCANCODE_SPACE,
|
||||
};
|
||||
|
||||
void input_init(void) {
|
||||
memset(s_current, 0, sizeof(s_current));
|
||||
memset(s_previous, 0, sizeof(s_previous));
|
||||
memset(s_latched_pressed, 0, sizeof(s_latched_pressed));
|
||||
memset(s_latched_released, 0, sizeof(s_latched_released));
|
||||
s_quit_requested = false;
|
||||
}
|
||||
|
||||
void input_poll(void) {
|
||||
/* Save previous state */
|
||||
memcpy(s_previous, s_current, sizeof(s_current));
|
||||
s_quit_requested = false;
|
||||
|
||||
/* Process SDL events */
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
switch (event.type) {
|
||||
case SDL_QUIT:
|
||||
s_quit_requested = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* Read keyboard state */
|
||||
const Uint8 *keys = SDL_GetKeyboardState(NULL);
|
||||
for (int i = 0; i < ACTION_COUNT; i++) {
|
||||
s_current[i] = keys[s_bindings[i]];
|
||||
if (s_alt_bindings[i] && keys[s_alt_bindings[i]]) {
|
||||
s_current[i] = true;
|
||||
}
|
||||
|
||||
/* Latch edges: once set, stays true until consumed */
|
||||
if (s_current[i] && !s_previous[i]) {
|
||||
s_latched_pressed[i] = true;
|
||||
}
|
||||
if (!s_current[i] && s_previous[i]) {
|
||||
s_latched_released[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void input_consume(void) {
|
||||
/* Clear latched states after an update tick has read them */
|
||||
memset(s_latched_pressed, 0, sizeof(s_latched_pressed));
|
||||
memset(s_latched_released, 0, sizeof(s_latched_released));
|
||||
}
|
||||
|
||||
bool input_pressed(Action a) {
|
||||
return s_latched_pressed[a];
|
||||
}
|
||||
|
||||
bool input_held(Action a) {
|
||||
return s_current[a];
|
||||
}
|
||||
|
||||
bool input_released(Action a) {
|
||||
return s_latched_released[a];
|
||||
}
|
||||
|
||||
bool input_quit_requested(void) {
|
||||
return s_quit_requested;
|
||||
}
|
||||
|
||||
void input_shutdown(void) {
|
||||
/* Nothing to clean up */
|
||||
}
|
||||
30
src/engine/input.h
Normal file
30
src/engine/input.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#ifndef JNR_INPUT_H
|
||||
#define JNR_INPUT_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <SDL2/SDL.h>
|
||||
|
||||
typedef enum Action {
|
||||
ACTION_LEFT,
|
||||
ACTION_RIGHT,
|
||||
ACTION_UP,
|
||||
ACTION_DOWN,
|
||||
ACTION_JUMP,
|
||||
ACTION_SHOOT,
|
||||
ACTION_DASH,
|
||||
ACTION_PAUSE,
|
||||
ACTION_COUNT
|
||||
} Action;
|
||||
|
||||
void input_init(void);
|
||||
void input_poll(void); /* call once per frame before updates */
|
||||
void input_consume(void); /* call after each fixed update tick */
|
||||
bool input_pressed(Action a); /* pressed since last consume */
|
||||
bool input_held(Action a); /* currently held down */
|
||||
bool input_released(Action a); /* released since last consume */
|
||||
void input_shutdown(void);
|
||||
|
||||
/* Returns true if SDL_QUIT was received */
|
||||
bool input_quit_requested(void);
|
||||
|
||||
#endif /* JNR_INPUT_H */
|
||||
296
src/engine/parallax.c
Normal file
296
src/engine/parallax.c
Normal file
@@ -0,0 +1,296 @@
|
||||
#include "engine/parallax.h"
|
||||
#include "engine/camera.h"
|
||||
#include <stdlib.h>
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────── */
|
||||
|
||||
static float randf(void) {
|
||||
return (float)rand() / (float)RAND_MAX;
|
||||
}
|
||||
|
||||
static uint8_t clamp_u8(int v) {
|
||||
if (v < 0) return 0;
|
||||
if (v > 255) return 255;
|
||||
return (uint8_t)v;
|
||||
}
|
||||
|
||||
/* ── Init / Free ─────────────────────────────────────── */
|
||||
|
||||
void parallax_init(Parallax *p) {
|
||||
memset(p, 0, sizeof(Parallax));
|
||||
}
|
||||
|
||||
void parallax_set_far(Parallax *p, SDL_Texture *tex, float scroll_x, float scroll_y) {
|
||||
p->far_layer.texture = tex;
|
||||
p->far_layer.scroll_x = scroll_x;
|
||||
p->far_layer.scroll_y = scroll_y;
|
||||
p->far_layer.active = true;
|
||||
p->far_layer.owns_texture = false; /* asset manager owns it */
|
||||
SDL_QueryTexture(tex, NULL, NULL, &p->far_layer.tex_w, &p->far_layer.tex_h);
|
||||
}
|
||||
|
||||
void parallax_set_near(Parallax *p, SDL_Texture *tex, float scroll_x, float scroll_y) {
|
||||
p->near_layer.texture = tex;
|
||||
p->near_layer.scroll_x = scroll_x;
|
||||
p->near_layer.scroll_y = scroll_y;
|
||||
p->near_layer.active = true;
|
||||
p->near_layer.owns_texture = false; /* asset manager owns it */
|
||||
SDL_QueryTexture(tex, NULL, NULL, &p->near_layer.tex_w, &p->near_layer.tex_h);
|
||||
}
|
||||
|
||||
/* ── Procedural star generation ──────────────────────── */
|
||||
|
||||
void parallax_generate_stars(Parallax *p, SDL_Renderer *renderer) {
|
||||
/* Create a 640x360 texture (screen-sized, tiles seamlessly) */
|
||||
int w = SCREEN_WIDTH;
|
||||
int h = SCREEN_HEIGHT;
|
||||
|
||||
SDL_Texture *tex = SDL_CreateTexture(renderer,
|
||||
SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h);
|
||||
if (!tex) return;
|
||||
|
||||
SDL_SetRenderTarget(renderer, tex);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
/* Clear to transparent (bg_color will show through) */
|
||||
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
|
||||
SDL_RenderClear(renderer);
|
||||
|
||||
/* Seed for reproducible star patterns */
|
||||
unsigned int saved_seed = (unsigned int)rand();
|
||||
srand(42);
|
||||
|
||||
/* Small dim stars (distant) — lots of them */
|
||||
for (int i = 0; i < 120; i++) {
|
||||
int x = (int)(randf() * w);
|
||||
int y = (int)(randf() * h);
|
||||
uint8_t brightness = (uint8_t)(100 + (int)(randf() * 80));
|
||||
/* Slight color tints: bluish, yellowish, or white */
|
||||
uint8_t r = brightness, g = brightness, b = brightness;
|
||||
float tint = randf();
|
||||
if (tint < 0.2f) {
|
||||
b = clamp_u8(brightness + 40); /* blue tint */
|
||||
} else if (tint < 0.35f) {
|
||||
r = clamp_u8(brightness + 30);
|
||||
g = clamp_u8(brightness + 20); /* warm tint */
|
||||
}
|
||||
SDL_SetRenderDrawColor(renderer, r, g, b, (uint8_t)(150 + (int)(randf() * 105)));
|
||||
SDL_Rect dot = {x, y, 1, 1};
|
||||
SDL_RenderFillRect(renderer, &dot);
|
||||
}
|
||||
|
||||
/* Medium stars */
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int x = (int)(randf() * w);
|
||||
int y = (int)(randf() * h);
|
||||
uint8_t brightness = (uint8_t)(180 + (int)(randf() * 75));
|
||||
uint8_t r = brightness, g = brightness, b = brightness;
|
||||
float tint = randf();
|
||||
if (tint < 0.25f) {
|
||||
b = 255; r = clamp_u8(brightness - 20); /* blue star */
|
||||
} else if (tint < 0.4f) {
|
||||
r = 255; g = clamp_u8(brightness - 10); /* warm star */
|
||||
}
|
||||
SDL_SetRenderDrawColor(renderer, r, g, b, 255);
|
||||
SDL_Rect dot = {x, y, 1, 1};
|
||||
SDL_RenderFillRect(renderer, &dot);
|
||||
/* Cross-halo for slightly brighter stars */
|
||||
if (randf() < 0.4f) {
|
||||
SDL_SetRenderDrawColor(renderer, r, g, b, 80);
|
||||
SDL_Rect h1 = {x - 1, y, 3, 1};
|
||||
SDL_Rect h2 = {x, y - 1, 1, 3};
|
||||
SDL_RenderFillRect(renderer, &h1);
|
||||
SDL_RenderFillRect(renderer, &h2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bright feature stars (few, prominent) */
|
||||
for (int i = 0; i < 6; i++) {
|
||||
int x = (int)(randf() * w);
|
||||
int y = (int)(randf() * h);
|
||||
/* Core pixel */
|
||||
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
|
||||
SDL_Rect core = {x, y, 2, 2};
|
||||
SDL_RenderFillRect(renderer, &core);
|
||||
/* Glow cross */
|
||||
uint8_t glow_a = (uint8_t)(120 + (int)(randf() * 80));
|
||||
float tint = randf();
|
||||
uint8_t gr = 200, gg = 210, gb = 255; /* default blue-white */
|
||||
if (tint < 0.3f) { gr = 255; gg = 220; gb = 180; } /* warm */
|
||||
SDL_SetRenderDrawColor(renderer, gr, gg, gb, glow_a);
|
||||
SDL_Rect cross_h = {x - 1, y, 4, 2};
|
||||
SDL_Rect cross_v = {x, y - 1, 2, 4};
|
||||
SDL_RenderFillRect(renderer, &cross_h);
|
||||
SDL_RenderFillRect(renderer, &cross_v);
|
||||
}
|
||||
|
||||
srand(saved_seed); /* restore randomness */
|
||||
|
||||
SDL_SetRenderTarget(renderer, NULL);
|
||||
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
|
||||
|
||||
p->far_layer.texture = tex;
|
||||
p->far_layer.tex_w = w;
|
||||
p->far_layer.tex_h = h;
|
||||
p->far_layer.scroll_x = 0.05f;
|
||||
p->far_layer.scroll_y = 0.05f;
|
||||
p->far_layer.active = true;
|
||||
p->far_layer.owns_texture = true;
|
||||
}
|
||||
|
||||
/* ── Procedural nebula generation ────────────────────── */
|
||||
|
||||
void parallax_generate_nebula(Parallax *p, SDL_Renderer *renderer) {
|
||||
int w = SCREEN_WIDTH;
|
||||
int h = SCREEN_HEIGHT;
|
||||
|
||||
SDL_Texture *tex = SDL_CreateTexture(renderer,
|
||||
SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h);
|
||||
if (!tex) return;
|
||||
|
||||
SDL_SetRenderTarget(renderer, tex);
|
||||
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
||||
|
||||
/* Clear to transparent */
|
||||
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
|
||||
SDL_RenderClear(renderer);
|
||||
|
||||
unsigned int saved_seed = (unsigned int)rand();
|
||||
srand(137);
|
||||
|
||||
/* Paint soft nebula blobs — clusters of semi-transparent rects
|
||||
* that overlap to create soft, cloudy shapes */
|
||||
|
||||
/* Nebula color palette (space purples, blues, teals) */
|
||||
typedef struct { uint8_t r, g, b; } NebColor;
|
||||
NebColor palette[] = {
|
||||
{ 60, 20, 100}, /* deep purple */
|
||||
{ 30, 40, 120}, /* dark blue */
|
||||
{ 20, 60, 90}, /* teal */
|
||||
{ 80, 15, 60}, /* magenta */
|
||||
{ 40, 50, 100}, /* slate blue */
|
||||
};
|
||||
int palette_count = sizeof(palette) / sizeof(palette[0]);
|
||||
|
||||
/* Paint 4-5 nebula clouds */
|
||||
for (int cloud = 0; cloud < 5; cloud++) {
|
||||
float cx = randf() * w;
|
||||
float cy = randf() * h;
|
||||
NebColor col = palette[cloud % palette_count];
|
||||
|
||||
/* Each cloud is ~30-50 overlapping soft rects */
|
||||
int blobs = 30 + (int)(randf() * 20);
|
||||
for (int b = 0; b < blobs; b++) {
|
||||
float angle = randf() * (float)(2.0 * M_PI);
|
||||
float dist = randf() * 80.0f + randf() * 40.0f;
|
||||
int bx = (int)(cx + cosf(angle) * dist);
|
||||
int by = (int)(cy + sinf(angle) * dist);
|
||||
int bw = 8 + (int)(randf() * 20);
|
||||
int bh = 8 + (int)(randf() * 16);
|
||||
|
||||
/* Vary color slightly per blob */
|
||||
uint8_t br = clamp_u8(col.r + (int)(randf() * 30 - 15));
|
||||
uint8_t bg = clamp_u8(col.g + (int)(randf() * 30 - 15));
|
||||
uint8_t bb = clamp_u8(col.b + (int)(randf() * 30 - 15));
|
||||
uint8_t ba = (uint8_t)(8 + (int)(randf() * 18)); /* very subtle */
|
||||
|
||||
SDL_SetRenderDrawColor(renderer, br, bg, bb, ba);
|
||||
SDL_Rect rect = {bx - bw / 2, by - bh / 2, bw, bh};
|
||||
SDL_RenderFillRect(renderer, &rect);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scattered dust particles (tiny dim dots between clouds) */
|
||||
for (int i = 0; i < 60; i++) {
|
||||
int x = (int)(randf() * w);
|
||||
int y = (int)(randf() * h);
|
||||
NebColor col = palette[(int)(randf() * palette_count)];
|
||||
SDL_SetRenderDrawColor(renderer, col.r, col.g, col.b,
|
||||
(uint8_t)(30 + (int)(randf() * 40)));
|
||||
SDL_Rect dot = {x, y, 2, 2};
|
||||
SDL_RenderFillRect(renderer, &dot);
|
||||
}
|
||||
|
||||
srand(saved_seed);
|
||||
|
||||
SDL_SetRenderTarget(renderer, NULL);
|
||||
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
|
||||
|
||||
p->near_layer.texture = tex;
|
||||
p->near_layer.tex_w = w;
|
||||
p->near_layer.tex_h = h;
|
||||
p->near_layer.scroll_x = 0.15f;
|
||||
p->near_layer.scroll_y = 0.10f;
|
||||
p->near_layer.active = true;
|
||||
p->near_layer.owns_texture = true;
|
||||
}
|
||||
|
||||
/* ── Render ──────────────────────────────────────────── */
|
||||
|
||||
static void render_layer(const ParallaxLayer *layer, const Camera *cam,
|
||||
SDL_Renderer *renderer) {
|
||||
if (!layer->active || !layer->texture) return;
|
||||
|
||||
int tw = layer->tex_w;
|
||||
int th = layer->tex_h;
|
||||
if (tw <= 0 || th <= 0) return;
|
||||
|
||||
/* Calculate scroll offset from camera position */
|
||||
float offset_x = cam->pos.x * layer->scroll_x;
|
||||
float offset_y = cam->pos.y * layer->scroll_y;
|
||||
|
||||
/* Wrap to texture bounds (fmod that handles negatives) */
|
||||
int ix = (int)offset_x % tw;
|
||||
int iy = (int)offset_y % th;
|
||||
if (ix < 0) ix += tw;
|
||||
if (iy < 0) iy += th;
|
||||
|
||||
/* Tile the texture across the screen.
|
||||
* We need to cover SCREEN_WIDTH x SCREEN_HEIGHT, starting
|
||||
* from the wrapped offset. Draw a 2x2 grid of tiles to
|
||||
* ensure full coverage regardless of offset. */
|
||||
for (int ty = -1; ty <= 1; ty++) {
|
||||
for (int tx = -1; tx <= 1; tx++) {
|
||||
SDL_Rect dst = {
|
||||
tx * tw - ix,
|
||||
ty * th - iy,
|
||||
tw,
|
||||
th
|
||||
};
|
||||
/* Skip tiles entirely off-screen */
|
||||
if (dst.x + dst.w < 0 || dst.x >= SCREEN_WIDTH) continue;
|
||||
if (dst.y + dst.h < 0 || dst.y >= SCREEN_HEIGHT) continue;
|
||||
|
||||
SDL_RenderCopy(renderer, layer->texture, NULL, &dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void parallax_render(const Parallax *p, const Camera *cam, SDL_Renderer *renderer) {
|
||||
if (!p || !cam) return;
|
||||
render_layer(&p->far_layer, cam, renderer);
|
||||
render_layer(&p->near_layer, cam, renderer);
|
||||
}
|
||||
|
||||
/* ── Free ────────────────────────────────────────────── */
|
||||
|
||||
void parallax_free(Parallax *p) {
|
||||
/* Only free textures we generated — asset manager owns file-loaded ones */
|
||||
if (p->far_layer.texture && p->far_layer.owns_texture) {
|
||||
SDL_DestroyTexture(p->far_layer.texture);
|
||||
}
|
||||
p->far_layer.texture = NULL;
|
||||
p->far_layer.active = false;
|
||||
|
||||
if (p->near_layer.texture && p->near_layer.owns_texture) {
|
||||
SDL_DestroyTexture(p->near_layer.texture);
|
||||
}
|
||||
p->near_layer.texture = NULL;
|
||||
p->near_layer.active = false;
|
||||
}
|
||||
46
src/engine/parallax.h
Normal file
46
src/engine/parallax.h
Normal file
@@ -0,0 +1,46 @@
|
||||
#ifndef JNR_PARALLAX_H
|
||||
#define JNR_PARALLAX_H
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include <stdbool.h>
|
||||
#include "config.h"
|
||||
|
||||
/* Forward declarations */
|
||||
typedef struct Camera Camera;
|
||||
|
||||
/* A single parallax layer */
|
||||
typedef struct ParallaxLayer {
|
||||
SDL_Texture *texture; /* the background image */
|
||||
int tex_w, tex_h; /* texture dimensions in pixels */
|
||||
float scroll_x; /* horizontal scroll factor (0=fixed, 1=full) */
|
||||
float scroll_y; /* vertical scroll factor */
|
||||
bool active; /* whether this layer is in use */
|
||||
bool owns_texture; /* true if we generated it (must free) */
|
||||
} ParallaxLayer;
|
||||
|
||||
/* Parallax background system — up to two layers */
|
||||
typedef struct Parallax {
|
||||
ParallaxLayer far_layer; /* distant background (stars) */
|
||||
ParallaxLayer near_layer; /* mid-ground background (nebula) */
|
||||
} Parallax;
|
||||
|
||||
/* Initialize parallax (zeroes everything) */
|
||||
void parallax_init(Parallax *p);
|
||||
|
||||
/* Set a layer from an existing texture (loaded via assets) */
|
||||
void parallax_set_far(Parallax *p, SDL_Texture *tex, float scroll_x, float scroll_y);
|
||||
void parallax_set_near(Parallax *p, SDL_Texture *tex, float scroll_x, float scroll_y);
|
||||
|
||||
/* Generate procedural starfield texture (far layer) */
|
||||
void parallax_generate_stars(Parallax *p, SDL_Renderer *renderer);
|
||||
|
||||
/* Generate procedural nebula/dust texture (near layer) */
|
||||
void parallax_generate_nebula(Parallax *p, SDL_Renderer *renderer);
|
||||
|
||||
/* Render both layers (call before tile/entity rendering) */
|
||||
void parallax_render(const Parallax *p, const Camera *cam, SDL_Renderer *renderer);
|
||||
|
||||
/* Free generated textures (not file-loaded ones — asset manager owns those) */
|
||||
void parallax_free(Parallax *p);
|
||||
|
||||
#endif /* JNR_PARALLAX_H */
|
||||
394
src/engine/particle.c
Normal file
394
src/engine/particle.c
Normal file
@@ -0,0 +1,394 @@
|
||||
#include "engine/particle.h"
|
||||
#include "engine/renderer.h"
|
||||
#include "engine/physics.h"
|
||||
#include "engine/camera.h"
|
||||
#include <stdlib.h>
|
||||
#include <math.h>
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
static Particle s_particles[MAX_PARTICLES];
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────── */
|
||||
|
||||
static float randf(void) {
|
||||
return (float)rand() / (float)RAND_MAX;
|
||||
}
|
||||
|
||||
static float randf_range(float lo, float hi) {
|
||||
return lo + randf() * (hi - lo);
|
||||
}
|
||||
|
||||
static uint8_t clamp_u8(int v) {
|
||||
if (v < 0) return 0;
|
||||
if (v > 255) return 255;
|
||||
return (uint8_t)v;
|
||||
}
|
||||
|
||||
/* ── Core API ────────────────────────────────────────── */
|
||||
|
||||
void particle_init(void) {
|
||||
for (int i = 0; i < MAX_PARTICLES; i++) {
|
||||
s_particles[i].active = false;
|
||||
}
|
||||
}
|
||||
|
||||
static Particle *alloc_particle(void) {
|
||||
/* Find a free slot */
|
||||
for (int i = 0; i < MAX_PARTICLES; i++) {
|
||||
if (!s_particles[i].active) return &s_particles[i];
|
||||
}
|
||||
/* Steal the oldest (lowest remaining life) */
|
||||
float min_life = 1e9f;
|
||||
int min_idx = 0;
|
||||
for (int i = 0; i < MAX_PARTICLES; i++) {
|
||||
if (s_particles[i].life < min_life) {
|
||||
min_life = s_particles[i].life;
|
||||
min_idx = i;
|
||||
}
|
||||
}
|
||||
return &s_particles[min_idx];
|
||||
}
|
||||
|
||||
void particle_emit(const ParticleBurst *b) {
|
||||
for (int i = 0; i < b->count; i++) {
|
||||
Particle *p = alloc_particle();
|
||||
|
||||
/* Random angle within spread cone */
|
||||
float angle = b->direction + randf_range(-b->spread, b->spread);
|
||||
float speed = randf_range(b->speed_min, b->speed_max);
|
||||
|
||||
p->pos = b->origin;
|
||||
p->vel = vec2(cosf(angle) * speed, sinf(angle) * speed);
|
||||
p->life = randf_range(b->life_min, b->life_max);
|
||||
p->max_life = p->life;
|
||||
p->size = randf_range(b->size_min, b->size_max);
|
||||
p->drag = b->drag;
|
||||
p->gravity_scale = b->gravity_scale;
|
||||
p->active = true;
|
||||
|
||||
/* Color with optional variation */
|
||||
p->color = b->color;
|
||||
if (b->color_vary) {
|
||||
int vary = 30;
|
||||
p->color.r = clamp_u8(b->color.r + (int)randf_range(-vary, vary));
|
||||
p->color.g = clamp_u8(b->color.g + (int)randf_range(-vary, vary));
|
||||
p->color.b = clamp_u8(b->color.b + (int)randf_range(-vary, vary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void particle_update(float dt) {
|
||||
float gravity = physics_get_gravity();
|
||||
|
||||
for (int i = 0; i < MAX_PARTICLES; i++) {
|
||||
Particle *p = &s_particles[i];
|
||||
if (!p->active) continue;
|
||||
|
||||
p->life -= dt;
|
||||
if (p->life <= 0) {
|
||||
p->active = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Apply gravity */
|
||||
p->vel.y += gravity * p->gravity_scale * dt;
|
||||
|
||||
/* Apply drag */
|
||||
if (p->drag > 0) {
|
||||
float factor = 1.0f - p->drag * dt;
|
||||
if (factor < 0) factor = 0;
|
||||
p->vel.x *= factor;
|
||||
p->vel.y *= factor;
|
||||
}
|
||||
|
||||
/* Move */
|
||||
p->pos.x += p->vel.x * dt;
|
||||
p->pos.y += p->vel.y * dt;
|
||||
}
|
||||
}
|
||||
|
||||
void particle_render(const Camera *cam) {
|
||||
for (int i = 0; i < MAX_PARTICLES; i++) {
|
||||
Particle *p = &s_particles[i];
|
||||
if (!p->active) continue;
|
||||
|
||||
/* Alpha fade based on remaining life */
|
||||
float t = p->life / p->max_life;
|
||||
SDL_Color c = p->color;
|
||||
c.a = (uint8_t)(t * 255.0f);
|
||||
|
||||
/* Shrink slightly as particle ages */
|
||||
float sz = p->size * (0.3f + 0.7f * t);
|
||||
if (sz < 0.5f) sz = 0.5f;
|
||||
|
||||
renderer_draw_rect(p->pos, vec2(sz, sz), c, LAYER_PARTICLES, cam);
|
||||
}
|
||||
}
|
||||
|
||||
void particle_clear(void) {
|
||||
for (int i = 0; i < MAX_PARTICLES; i++) {
|
||||
s_particles[i].active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Presets ─────────────────────────────────────────── */
|
||||
|
||||
void particle_emit_death_puff(Vec2 pos, SDL_Color color) {
|
||||
ParticleBurst b = {
|
||||
.origin = pos,
|
||||
.count = 12,
|
||||
.speed_min = 30.0f,
|
||||
.speed_max = 100.0f,
|
||||
.life_min = 0.2f,
|
||||
.life_max = 0.5f,
|
||||
.size_min = 1.5f,
|
||||
.size_max = 3.5f,
|
||||
.spread = (float)M_PI, /* full circle */
|
||||
.direction = 0,
|
||||
.drag = 3.0f,
|
||||
.gravity_scale = 0.3f,
|
||||
.color = color,
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&b);
|
||||
}
|
||||
|
||||
void particle_emit_landing_dust(Vec2 pos) {
|
||||
ParticleBurst b = {
|
||||
.origin = pos,
|
||||
.count = 6,
|
||||
.speed_min = 20.0f,
|
||||
.speed_max = 60.0f,
|
||||
.life_min = 0.15f,
|
||||
.life_max = 0.35f,
|
||||
.size_min = 1.0f,
|
||||
.size_max = 2.5f,
|
||||
.spread = 0.6f, /* ~35 degrees */
|
||||
.direction = -(float)M_PI * 0.5f, /* upward */
|
||||
.drag = 4.0f,
|
||||
.gravity_scale = 0.0f,
|
||||
.color = {180, 170, 150, 255}, /* dusty tan */
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&b);
|
||||
}
|
||||
|
||||
void particle_emit_spark(Vec2 pos, SDL_Color color) {
|
||||
ParticleBurst b = {
|
||||
.origin = pos,
|
||||
.count = 5,
|
||||
.speed_min = 40.0f,
|
||||
.speed_max = 120.0f,
|
||||
.life_min = 0.1f,
|
||||
.life_max = 0.25f,
|
||||
.size_min = 1.0f,
|
||||
.size_max = 2.0f,
|
||||
.spread = (float)M_PI,
|
||||
.direction = 0,
|
||||
.drag = 2.0f,
|
||||
.gravity_scale = 0.5f,
|
||||
.color = color,
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&b);
|
||||
}
|
||||
|
||||
void particle_emit_jetpack_burst(Vec2 pos, Vec2 dash_dir) {
|
||||
/* Exhaust fires opposite to dash direction */
|
||||
float exhaust_angle = atan2f(-dash_dir.y, -dash_dir.x);
|
||||
|
||||
/* Big initial burst — hot core (white/yellow) */
|
||||
ParticleBurst core = {
|
||||
.origin = pos,
|
||||
.count = 18,
|
||||
.speed_min = 80.0f,
|
||||
.speed_max = 200.0f,
|
||||
.life_min = 0.15f,
|
||||
.life_max = 0.35f,
|
||||
.size_min = 2.0f,
|
||||
.size_max = 4.0f,
|
||||
.spread = 0.5f, /* ~30 degree cone */
|
||||
.direction = exhaust_angle,
|
||||
.drag = 3.0f,
|
||||
.gravity_scale = 0.1f,
|
||||
.color = {255, 220, 120, 255}, /* bright yellow */
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&core);
|
||||
|
||||
/* Outer plume — orange/red, wider spread */
|
||||
ParticleBurst plume = {
|
||||
.origin = pos,
|
||||
.count = 14,
|
||||
.speed_min = 50.0f,
|
||||
.speed_max = 150.0f,
|
||||
.life_min = 0.2f,
|
||||
.life_max = 0.45f,
|
||||
.size_min = 1.5f,
|
||||
.size_max = 3.5f,
|
||||
.spread = 0.8f, /* wider cone */
|
||||
.direction = exhaust_angle,
|
||||
.drag = 2.5f,
|
||||
.gravity_scale = 0.2f,
|
||||
.color = {255, 120, 40, 255}, /* orange */
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&plume);
|
||||
|
||||
/* Smoke tail — grey, slow, lingers */
|
||||
ParticleBurst smoke = {
|
||||
.origin = pos,
|
||||
.count = 8,
|
||||
.speed_min = 20.0f,
|
||||
.speed_max = 60.0f,
|
||||
.life_min = 0.3f,
|
||||
.life_max = 0.6f,
|
||||
.size_min = 2.0f,
|
||||
.size_max = 4.5f,
|
||||
.spread = 1.0f,
|
||||
.direction = exhaust_angle,
|
||||
.drag = 4.0f,
|
||||
.gravity_scale = -0.1f, /* slight float-up */
|
||||
.color = {160, 150, 140, 200}, /* smoke grey */
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&smoke);
|
||||
}
|
||||
|
||||
void particle_emit_jetpack_trail(Vec2 pos, Vec2 dash_dir) {
|
||||
/* Continuous trail — fewer particles per frame, same exhaust direction */
|
||||
float exhaust_angle = atan2f(-dash_dir.y, -dash_dir.x);
|
||||
|
||||
/* Hot sparks */
|
||||
ParticleBurst sparks = {
|
||||
.origin = pos,
|
||||
.count = 3,
|
||||
.speed_min = 60.0f,
|
||||
.speed_max = 160.0f,
|
||||
.life_min = 0.1f,
|
||||
.life_max = 0.25f,
|
||||
.size_min = 1.5f,
|
||||
.size_max = 3.0f,
|
||||
.spread = 0.4f,
|
||||
.direction = exhaust_angle,
|
||||
.drag = 3.0f,
|
||||
.gravity_scale = 0.1f,
|
||||
.color = {255, 200, 80, 255}, /* yellow-orange */
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&sparks);
|
||||
|
||||
/* Smoke wisps */
|
||||
ParticleBurst smoke = {
|
||||
.origin = pos,
|
||||
.count = 2,
|
||||
.speed_min = 15.0f,
|
||||
.speed_max = 40.0f,
|
||||
.life_min = 0.2f,
|
||||
.life_max = 0.4f,
|
||||
.size_min = 1.5f,
|
||||
.size_max = 3.5f,
|
||||
.spread = 0.7f,
|
||||
.direction = exhaust_angle,
|
||||
.drag = 3.5f,
|
||||
.gravity_scale = -0.1f,
|
||||
.color = {140, 130, 120, 180},
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&smoke);
|
||||
}
|
||||
|
||||
void particle_emit_muzzle_flash(Vec2 pos, Vec2 shoot_dir) {
|
||||
float angle = atan2f(shoot_dir.y, shoot_dir.x);
|
||||
|
||||
/* Perpendicular angle for the up/down flare lines */
|
||||
float perp = angle + (float)M_PI * 0.5f;
|
||||
|
||||
/* Forward beam flash — bright cyan, fast, tight cone */
|
||||
ParticleBurst beam = {
|
||||
.origin = pos,
|
||||
.count = 5,
|
||||
.speed_min = 120.0f,
|
||||
.speed_max = 250.0f,
|
||||
.life_min = 0.03f,
|
||||
.life_max = 0.07f,
|
||||
.size_min = 1.5f,
|
||||
.size_max = 2.5f,
|
||||
.spread = 0.15f, /* very tight forward */
|
||||
.direction = angle,
|
||||
.drag = 6.0f,
|
||||
.gravity_scale = 0.0f,
|
||||
.color = {100, 220, 255, 255}, /* cyan */
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&beam);
|
||||
|
||||
/* Vertical flare — perpendicular lines (up) */
|
||||
ParticleBurst flare_up = {
|
||||
.origin = pos,
|
||||
.count = 3,
|
||||
.speed_min = 60.0f,
|
||||
.speed_max = 140.0f,
|
||||
.life_min = 0.02f,
|
||||
.life_max = 0.06f,
|
||||
.size_min = 1.0f,
|
||||
.size_max = 2.0f,
|
||||
.spread = 0.12f,
|
||||
.direction = perp,
|
||||
.drag = 8.0f,
|
||||
.gravity_scale = 0.0f,
|
||||
.color = {160, 240, 255, 255}, /* light cyan */
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&flare_up);
|
||||
|
||||
/* Vertical flare — perpendicular lines (down) */
|
||||
ParticleBurst flare_down = flare_up;
|
||||
flare_down.direction = perp + (float)M_PI; /* opposite perpendicular */
|
||||
particle_emit(&flare_down);
|
||||
|
||||
/* Bright white core — instant pop at barrel */
|
||||
ParticleBurst core = {
|
||||
.origin = pos,
|
||||
.count = 2,
|
||||
.speed_min = 5.0f,
|
||||
.speed_max = 20.0f,
|
||||
.life_min = 0.03f,
|
||||
.life_max = 0.06f,
|
||||
.size_min = 2.5f,
|
||||
.size_max = 4.0f,
|
||||
.spread = (float)M_PI, /* all directions, stays near barrel */
|
||||
.direction = 0,
|
||||
.drag = 10.0f,
|
||||
.gravity_scale = 0.0f,
|
||||
.color = {200, 255, 255, 255}, /* white-cyan glow */
|
||||
.color_vary = false,
|
||||
};
|
||||
particle_emit(&core);
|
||||
}
|
||||
|
||||
void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir) {
|
||||
/* Small dust puffs pushed away from the wall */
|
||||
float away_angle = (wall_dir < 0) ? 0.0f : (float)M_PI; /* away from wall */
|
||||
|
||||
ParticleBurst dust = {
|
||||
.origin = pos,
|
||||
.count = 2,
|
||||
.speed_min = 15.0f,
|
||||
.speed_max = 40.0f,
|
||||
.life_min = 0.1f,
|
||||
.life_max = 0.25f,
|
||||
.size_min = 1.0f,
|
||||
.size_max = 2.0f,
|
||||
.spread = 0.8f,
|
||||
.direction = away_angle,
|
||||
.drag = 4.0f,
|
||||
.gravity_scale = -0.1f, /* slight float-up */
|
||||
.color = {180, 170, 150, 200}, /* dusty tan */
|
||||
.color_vary = true,
|
||||
};
|
||||
particle_emit(&dust);
|
||||
}
|
||||
82
src/engine/particle.h
Normal file
82
src/engine/particle.h
Normal file
@@ -0,0 +1,82 @@
|
||||
#ifndef JNR_PARTICLE_H
|
||||
#define JNR_PARTICLE_H
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include <stdbool.h>
|
||||
#include "util/vec2.h"
|
||||
|
||||
/* Forward declaration */
|
||||
typedef struct Camera Camera;
|
||||
|
||||
#define MAX_PARTICLES 1024
|
||||
|
||||
/* A single particle — lightweight, no collision */
|
||||
typedef struct Particle {
|
||||
Vec2 pos;
|
||||
Vec2 vel;
|
||||
SDL_Color color;
|
||||
float life; /* remaining lifetime (seconds) */
|
||||
float max_life; /* initial lifetime (for alpha fade) */
|
||||
float size; /* render size in pixels */
|
||||
float drag; /* velocity damping per second (0-1) */
|
||||
float gravity_scale; /* 0 = no gravity, 1 = full gravity */
|
||||
bool active;
|
||||
} Particle;
|
||||
|
||||
/* Burst configuration for particle_emit() */
|
||||
typedef struct ParticleBurst {
|
||||
Vec2 origin; /* world position to emit from */
|
||||
int count; /* number of particles */
|
||||
float speed_min; /* min initial speed */
|
||||
float speed_max; /* max initial speed */
|
||||
float life_min; /* min lifetime */
|
||||
float life_max; /* max lifetime */
|
||||
float size_min; /* min particle size */
|
||||
float size_max; /* max particle size */
|
||||
float spread; /* cone half-angle in radians */
|
||||
float direction; /* base angle in radians (0=right) */
|
||||
float drag; /* velocity damping */
|
||||
float gravity_scale; /* gravity multiplier */
|
||||
SDL_Color color; /* base color */
|
||||
bool color_vary; /* randomly vary color slightly */
|
||||
} ParticleBurst;
|
||||
|
||||
/* Initialize the particle system */
|
||||
void particle_init(void);
|
||||
|
||||
/* Emit a burst of particles */
|
||||
void particle_emit(const ParticleBurst *burst);
|
||||
|
||||
/* Update all active particles */
|
||||
void particle_update(float dt);
|
||||
|
||||
/* Render all active particles */
|
||||
void particle_render(const Camera *cam);
|
||||
|
||||
/* Remove all active particles */
|
||||
void particle_clear(void);
|
||||
|
||||
/* ── Preset bursts for common effects ──────────────── */
|
||||
|
||||
/* Small puff of debris (enemy death, impact) */
|
||||
void particle_emit_death_puff(Vec2 pos, SDL_Color color);
|
||||
|
||||
/* Landing dust when player hits ground */
|
||||
void particle_emit_landing_dust(Vec2 pos);
|
||||
|
||||
/* Small spark burst (projectile hit wall) */
|
||||
void particle_emit_spark(Vec2 pos, SDL_Color color);
|
||||
|
||||
/* Jetpack exhaust burst (dash start — big plume opposite to dash_dir) */
|
||||
void particle_emit_jetpack_burst(Vec2 pos, Vec2 dash_dir);
|
||||
|
||||
/* Jetpack exhaust trail (call each frame while dashing) */
|
||||
void particle_emit_jetpack_trail(Vec2 pos, Vec2 dash_dir);
|
||||
|
||||
/* Muzzle flash (short bright burst in shoot direction) */
|
||||
void particle_emit_muzzle_flash(Vec2 pos, Vec2 shoot_dir);
|
||||
|
||||
/* Wall slide dust (small puffs while scraping against a wall) */
|
||||
void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir);
|
||||
|
||||
#endif /* JNR_PARTICLE_H */
|
||||
145
src/engine/physics.c
Normal file
145
src/engine/physics.c
Normal file
@@ -0,0 +1,145 @@
|
||||
#include "engine/physics.h"
|
||||
#include "engine/tilemap.h"
|
||||
#include "config.h"
|
||||
#include <math.h>
|
||||
|
||||
static float s_gravity = DEFAULT_GRAVITY;
|
||||
|
||||
void physics_init(void) {
|
||||
s_gravity = DEFAULT_GRAVITY;
|
||||
}
|
||||
|
||||
void physics_set_gravity(float gravity) {
|
||||
s_gravity = gravity;
|
||||
}
|
||||
|
||||
float physics_get_gravity(void) {
|
||||
return s_gravity;
|
||||
}
|
||||
|
||||
static void resolve_tilemap_x(Body *body, const Tilemap *map) {
|
||||
if (!map) return;
|
||||
|
||||
/* Check tiles the body overlaps after X movement */
|
||||
int left = world_to_tile(body->pos.x);
|
||||
int right = world_to_tile(body->pos.x + body->size.x - 1);
|
||||
int top = world_to_tile(body->pos.y);
|
||||
int bottom = world_to_tile(body->pos.y + body->size.y - 1);
|
||||
|
||||
body->on_wall_left = false;
|
||||
body->on_wall_right = false;
|
||||
|
||||
for (int ty = top; ty <= bottom; ty++) {
|
||||
for (int tx = left; tx <= right; tx++) {
|
||||
if (!tilemap_is_solid(map, tx, ty)) continue;
|
||||
|
||||
float tile_left = tile_to_world(tx);
|
||||
float tile_right = tile_to_world(tx + 1);
|
||||
|
||||
if (body->vel.x > 0) {
|
||||
/* Moving right -> push left */
|
||||
body->pos.x = tile_left - body->size.x;
|
||||
body->vel.x = 0;
|
||||
body->on_wall_right = true;
|
||||
} else if (body->vel.x < 0) {
|
||||
/* Moving left -> push right */
|
||||
body->pos.x = tile_right;
|
||||
body->vel.x = 0;
|
||||
body->on_wall_left = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void resolve_tilemap_y(Body *body, const Tilemap *map) {
|
||||
if (!map) return;
|
||||
|
||||
int left = world_to_tile(body->pos.x);
|
||||
int right = world_to_tile(body->pos.x + body->size.x - 1);
|
||||
int top = world_to_tile(body->pos.y);
|
||||
int bottom = world_to_tile(body->pos.y + body->size.y - 1);
|
||||
|
||||
body->on_ground = false;
|
||||
body->on_ceiling = false;
|
||||
|
||||
for (int ty = top; ty <= bottom; ty++) {
|
||||
for (int tx = left; tx <= right; tx++) {
|
||||
uint32_t flags = tilemap_flags_at(map, tx, ty);
|
||||
if (!(flags & TILE_SOLID)) {
|
||||
/* Check one-way platforms: only block when falling down */
|
||||
if ((flags & TILE_PLATFORM) && body->vel.y > 0) {
|
||||
float tile_top = tile_to_world(ty);
|
||||
float body_bottom = body->pos.y + body->size.y;
|
||||
/* Only resolve if we just crossed into the tile */
|
||||
if (body_bottom - body->vel.y * DT <= tile_top + 2.0f) {
|
||||
body->pos.y = tile_top - body->size.y;
|
||||
body->vel.y = 0;
|
||||
body->on_ground = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
float tile_top = tile_to_world(ty);
|
||||
float tile_bottom = tile_to_world(ty + 1);
|
||||
|
||||
if (body->vel.y > 0) {
|
||||
/* Falling -> land on top of tile */
|
||||
body->pos.y = tile_top - body->size.y;
|
||||
body->vel.y = 0;
|
||||
body->on_ground = true;
|
||||
} else if (body->vel.y < 0) {
|
||||
/* Rising -> bonk on ceiling */
|
||||
body->pos.y = tile_bottom;
|
||||
body->vel.y = 0;
|
||||
body->on_ceiling = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void physics_update(Body *body, float dt, const Tilemap *map) {
|
||||
/* Apply gravity */
|
||||
body->vel.y += s_gravity * body->gravity_scale * dt;
|
||||
|
||||
/* Clamp fall speed */
|
||||
if (body->vel.y > MAX_FALL_SPEED) {
|
||||
body->vel.y = MAX_FALL_SPEED;
|
||||
}
|
||||
|
||||
/* Move X, resolve X */
|
||||
body->pos.x += body->vel.x * dt;
|
||||
resolve_tilemap_x(body, map);
|
||||
|
||||
/* Move Y, resolve Y */
|
||||
body->pos.y += body->vel.y * dt;
|
||||
resolve_tilemap_y(body, map);
|
||||
|
||||
/* Ground-stick probe: if not detected as on_ground but vel.y is
|
||||
* near zero (just landed or standing), check one pixel below.
|
||||
* Prevents flickering between grounded/airborne each frame due to
|
||||
* sub-pixel gravity accumulation. */
|
||||
if (!body->on_ground && body->vel.y >= 0 && body->vel.y < s_gravity * dt * 2.0f && map) {
|
||||
int left = world_to_tile(body->pos.x);
|
||||
int right = world_to_tile(body->pos.x + body->size.x - 1);
|
||||
int probe = world_to_tile(body->pos.y + body->size.y + 1.0f);
|
||||
for (int tx = left; tx <= right; tx++) {
|
||||
if (tilemap_is_solid(map, tx, probe)) {
|
||||
body->on_ground = true;
|
||||
body->vel.y = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool physics_overlap(const Body *a, const Body *b) {
|
||||
return physics_aabb_overlap(a->pos, a->size, b->pos, b->size);
|
||||
}
|
||||
|
||||
bool physics_aabb_overlap(Vec2 pos_a, Vec2 size_a, Vec2 pos_b, Vec2 size_b) {
|
||||
return pos_a.x < pos_b.x + size_b.x &&
|
||||
pos_a.x + size_a.x > pos_b.x &&
|
||||
pos_a.y < pos_b.y + size_b.y &&
|
||||
pos_a.y + size_a.y > pos_b.y;
|
||||
}
|
||||
37
src/engine/physics.h
Normal file
37
src/engine/physics.h
Normal file
@@ -0,0 +1,37 @@
|
||||
#ifndef JNR_PHYSICS_H
|
||||
#define JNR_PHYSICS_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include "config.h"
|
||||
#include "util/vec2.h"
|
||||
|
||||
/* Forward declaration */
|
||||
typedef struct Tilemap Tilemap;
|
||||
|
||||
typedef struct Body {
|
||||
Vec2 pos; /* top-left of AABB */
|
||||
Vec2 vel; /* pixels per second */
|
||||
Vec2 size; /* AABB dimensions */
|
||||
float gravity_scale; /* 0 for flying, 1 for normal */
|
||||
bool on_ground;
|
||||
bool on_ceiling;
|
||||
bool on_wall_left;
|
||||
bool on_wall_right;
|
||||
} Body;
|
||||
|
||||
void physics_init(void);
|
||||
|
||||
/* Set / get the current world gravity (pixels/s^2) */
|
||||
void physics_set_gravity(float gravity);
|
||||
float physics_get_gravity(void);
|
||||
|
||||
/* Move body, apply gravity, resolve against tilemap */
|
||||
void physics_update(Body *body, float dt, const Tilemap *map);
|
||||
|
||||
/* Simple AABB overlap test between two bodies */
|
||||
bool physics_overlap(const Body *a, const Body *b);
|
||||
|
||||
/* Check if two AABBs defined by pos+size overlap */
|
||||
bool physics_aabb_overlap(Vec2 pos_a, Vec2 size_a, Vec2 pos_b, Vec2 size_b);
|
||||
|
||||
#endif /* JNR_PHYSICS_H */
|
||||
95
src/engine/renderer.c
Normal file
95
src/engine/renderer.c
Normal file
@@ -0,0 +1,95 @@
|
||||
#include "engine/renderer.h"
|
||||
#include "engine/camera.h"
|
||||
#include "config.h"
|
||||
#include <string.h>
|
||||
|
||||
static SDL_Renderer *s_renderer;
|
||||
|
||||
static Sprite s_queue[MAX_SPRITES];
|
||||
static int s_queue_count;
|
||||
static SDL_Color s_clear_color = {30, 30, 46, 255}; /* default dark blue */
|
||||
|
||||
void renderer_init(SDL_Renderer *r) {
|
||||
s_renderer = r;
|
||||
s_queue_count = 0;
|
||||
}
|
||||
|
||||
void renderer_set_clear_color(SDL_Color color) {
|
||||
s_clear_color = color;
|
||||
}
|
||||
|
||||
void renderer_clear(void) {
|
||||
SDL_SetRenderDrawColor(s_renderer,
|
||||
s_clear_color.r, s_clear_color.g, s_clear_color.b, s_clear_color.a);
|
||||
SDL_RenderClear(s_renderer);
|
||||
s_queue_count = 0;
|
||||
}
|
||||
|
||||
void renderer_submit(const Sprite *sprite) {
|
||||
if (s_queue_count >= MAX_SPRITES) return;
|
||||
s_queue[s_queue_count++] = *sprite;
|
||||
}
|
||||
|
||||
void renderer_flush(const Camera *cam) {
|
||||
/* Draw sprites layer by layer */
|
||||
for (int layer = 0; layer < LAYER_COUNT; layer++) {
|
||||
for (int i = 0; i < s_queue_count; i++) {
|
||||
Sprite *s = &s_queue[i];
|
||||
if ((int)s->layer != layer) continue;
|
||||
|
||||
/* Convert world to screen coords (HUD stays in screen space) */
|
||||
Vec2 screen_pos = s->pos;
|
||||
if (layer != LAYER_HUD && cam) {
|
||||
screen_pos = camera_world_to_screen(cam, s->pos);
|
||||
}
|
||||
|
||||
SDL_Rect dst = {
|
||||
(int)screen_pos.x,
|
||||
(int)screen_pos.y,
|
||||
(int)s->size.x,
|
||||
(int)s->size.y
|
||||
};
|
||||
|
||||
SDL_RendererFlip flip = SDL_FLIP_NONE;
|
||||
if (s->flip_x) flip |= SDL_FLIP_HORIZONTAL;
|
||||
if (s->flip_y) flip |= SDL_FLIP_VERTICAL;
|
||||
|
||||
if (s->alpha < 255) {
|
||||
SDL_SetTextureAlphaMod(s->texture, s->alpha);
|
||||
}
|
||||
|
||||
SDL_RenderCopyEx(s_renderer, s->texture, &s->src, &dst,
|
||||
s->rotation, NULL, flip);
|
||||
|
||||
if (s->alpha < 255) {
|
||||
SDL_SetTextureAlphaMod(s->texture, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void renderer_present(void) {
|
||||
SDL_RenderPresent(s_renderer);
|
||||
}
|
||||
|
||||
void renderer_draw_rect(Vec2 pos, Vec2 size, SDL_Color color,
|
||||
DrawLayer layer, const Camera *cam) {
|
||||
Vec2 screen_pos = pos;
|
||||
if (layer != LAYER_HUD && cam) {
|
||||
screen_pos = camera_world_to_screen(cam, pos);
|
||||
}
|
||||
|
||||
SDL_Rect rect = {
|
||||
(int)screen_pos.x,
|
||||
(int)screen_pos.y,
|
||||
(int)size.x,
|
||||
(int)size.y
|
||||
};
|
||||
|
||||
SDL_SetRenderDrawColor(s_renderer, color.r, color.g, color.b, color.a);
|
||||
SDL_RenderFillRect(s_renderer, &rect);
|
||||
}
|
||||
|
||||
void renderer_shutdown(void) {
|
||||
s_queue_count = 0;
|
||||
}
|
||||
46
src/engine/renderer.h
Normal file
46
src/engine/renderer.h
Normal file
@@ -0,0 +1,46 @@
|
||||
#ifndef JNR_RENDERER_H
|
||||
#define JNR_RENDERER_H
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include <stdbool.h>
|
||||
#include "config.h"
|
||||
#include "util/vec2.h"
|
||||
|
||||
/* Forward declaration */
|
||||
typedef struct Camera Camera;
|
||||
|
||||
typedef enum DrawLayer {
|
||||
LAYER_BG_FAR, /* parallax background - far */
|
||||
LAYER_BG_NEAR, /* parallax background - near */
|
||||
LAYER_TILES_BG, /* tile background layer */
|
||||
LAYER_ENTITIES, /* players, enemies, items */
|
||||
LAYER_TILES_FG, /* tile foreground layer */
|
||||
LAYER_PARTICLES, /* effects */
|
||||
LAYER_HUD, /* UI overlay (screen-space) */
|
||||
LAYER_COUNT
|
||||
} DrawLayer;
|
||||
|
||||
typedef struct Sprite {
|
||||
SDL_Texture *texture;
|
||||
SDL_Rect src; /* source rect in spritesheet */
|
||||
Vec2 pos; /* world position (top-left) */
|
||||
Vec2 size; /* render size in world pixels */
|
||||
bool flip_x;
|
||||
bool flip_y;
|
||||
DrawLayer layer;
|
||||
uint8_t alpha; /* 0-255 */
|
||||
double rotation; /* degrees, clockwise */
|
||||
} Sprite;
|
||||
|
||||
void renderer_init(SDL_Renderer *r);
|
||||
void renderer_set_clear_color(SDL_Color color);
|
||||
void renderer_clear(void);
|
||||
void renderer_submit(const Sprite *sprite);
|
||||
void renderer_flush(const Camera *cam);
|
||||
void renderer_present(void);
|
||||
void renderer_shutdown(void);
|
||||
|
||||
/* Convenience: draw a filled rect (debug / HUD) */
|
||||
void renderer_draw_rect(Vec2 pos, Vec2 size, SDL_Color color, DrawLayer layer, const Camera *cam);
|
||||
|
||||
#endif /* JNR_RENDERER_H */
|
||||
219
src/engine/tilemap.c
Normal file
219
src/engine/tilemap.c
Normal file
@@ -0,0 +1,219 @@
|
||||
#include "engine/tilemap.h"
|
||||
#include "engine/camera.h"
|
||||
#include "engine/assets.h"
|
||||
#include <SDL2/SDL_image.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) {
|
||||
fprintf(stderr, "Failed to open level: %s\n", path);
|
||||
return false;
|
||||
}
|
||||
|
||||
memset(map, 0, sizeof(Tilemap));
|
||||
|
||||
char line[1024];
|
||||
char tileset_path[256] = {0};
|
||||
int current_layer = -1; /* 0=collision, 1=bg, 2=fg */
|
||||
int row = 0;
|
||||
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
/* Skip comments and empty lines */
|
||||
if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') continue;
|
||||
|
||||
/* Parse directives */
|
||||
if (strncmp(line, "TILESET ", 8) == 0) {
|
||||
sscanf(line + 8, "%255s", tileset_path);
|
||||
} else if (strncmp(line, "SIZE ", 5) == 0) {
|
||||
sscanf(line + 5, "%d %d", &map->width, &map->height);
|
||||
if (map->width <= 0 || map->height <= 0 ||
|
||||
map->width > 4096 || map->height > 4096) {
|
||||
fprintf(stderr, "Invalid map size: %dx%d\n", map->width, map->height);
|
||||
fclose(f);
|
||||
return false;
|
||||
}
|
||||
int total = map->width * map->height;
|
||||
map->collision_layer = calloc(total, sizeof(uint16_t));
|
||||
map->bg_layer = calloc(total, sizeof(uint16_t));
|
||||
map->fg_layer = calloc(total, sizeof(uint16_t));
|
||||
} else if (strncmp(line, "SPAWN ", 6) == 0) {
|
||||
float sx, sy;
|
||||
sscanf(line + 6, "%f %f", &sx, &sy);
|
||||
map->player_spawn = vec2(sx * TILE_SIZE, sy * TILE_SIZE);
|
||||
} else if (strncmp(line, "GRAVITY ", 8) == 0) {
|
||||
sscanf(line + 8, "%f", &map->gravity);
|
||||
} else if (strncmp(line, "TILEDEF ", 8) == 0) {
|
||||
int id, tx, ty;
|
||||
uint32_t flags;
|
||||
sscanf(line + 8, "%d %d %d %u", &id, &tx, &ty, &flags);
|
||||
if (id < MAX_TILE_DEFS) {
|
||||
map->tile_defs[id].tex_x = (uint16_t)tx;
|
||||
map->tile_defs[id].tex_y = (uint16_t)ty;
|
||||
map->tile_defs[id].flags = flags;
|
||||
if (id >= map->tile_def_count) {
|
||||
map->tile_def_count = id + 1;
|
||||
}
|
||||
}
|
||||
} else if (strncmp(line, "BG_COLOR ", 9) == 0) {
|
||||
int r, g, b;
|
||||
if (sscanf(line + 9, "%d %d %d", &r, &g, &b) == 3) {
|
||||
map->bg_color = (SDL_Color){
|
||||
(uint8_t)r, (uint8_t)g, (uint8_t)b, 255
|
||||
};
|
||||
map->has_bg_color = true;
|
||||
}
|
||||
} else if (strncmp(line, "PARALLAX_FAR ", 13) == 0) {
|
||||
sscanf(line + 13, "%255s", map->parallax_far_path);
|
||||
} else if (strncmp(line, "PARALLAX_NEAR ", 14) == 0) {
|
||||
sscanf(line + 14, "%255s", map->parallax_near_path);
|
||||
} else if (strncmp(line, "MUSIC ", 6) == 0) {
|
||||
sscanf(line + 6, "%255s", map->music_path);
|
||||
} else if (strncmp(line, "ENTITY ", 7) == 0) {
|
||||
if (map->entity_spawn_count < MAX_ENTITY_SPAWNS) {
|
||||
EntitySpawn *es = &map->entity_spawns[map->entity_spawn_count];
|
||||
float tx, ty;
|
||||
if (sscanf(line + 7, "%31s %f %f", es->type_name, &tx, &ty) == 3) {
|
||||
es->x = tx * TILE_SIZE;
|
||||
es->y = ty * TILE_SIZE;
|
||||
map->entity_spawn_count++;
|
||||
}
|
||||
}
|
||||
} else if (strncmp(line, "LAYER ", 6) == 0) {
|
||||
row = 0;
|
||||
if (strstr(line, "collision")) current_layer = 0;
|
||||
else if (strstr(line, "bg")) current_layer = 1;
|
||||
else if (strstr(line, "fg")) current_layer = 2;
|
||||
} else if (current_layer >= 0 && map->width > 0) {
|
||||
/* Parse tile row */
|
||||
uint16_t *layer = NULL;
|
||||
switch (current_layer) {
|
||||
case 0: layer = map->collision_layer; break;
|
||||
case 1: layer = map->bg_layer; break;
|
||||
case 2: layer = map->fg_layer; break;
|
||||
}
|
||||
if (layer && row < map->height) {
|
||||
char *tok = strtok(line, " \t\n\r");
|
||||
for (int col = 0; col < map->width && tok; col++) {
|
||||
layer[row * map->width + col] = (uint16_t)atoi(tok);
|
||||
tok = strtok(NULL, " \t\n\r");
|
||||
}
|
||||
row++;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
/* Load tileset texture */
|
||||
if (tileset_path[0] && renderer) {
|
||||
map->tileset = assets_get_texture(tileset_path);
|
||||
if (map->tileset) {
|
||||
int tex_w;
|
||||
SDL_QueryTexture(map->tileset, NULL, NULL, &tex_w, NULL);
|
||||
map->tileset_cols = tex_w / TILE_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
printf("Loaded level: %s (%dx%d tiles)\n", path, map->width, map->height);
|
||||
return true;
|
||||
}
|
||||
|
||||
void tilemap_render_layer(const Tilemap *map, const uint16_t *layer,
|
||||
const Camera *cam, SDL_Renderer *renderer) {
|
||||
if (!map || !layer || !map->tileset) return;
|
||||
|
||||
/* Only render visible tiles */
|
||||
int start_x = 0, start_y = 0;
|
||||
int end_x = map->width, end_y = map->height;
|
||||
|
||||
if (cam) {
|
||||
start_x = (int)(cam->pos.x / TILE_SIZE) - 1;
|
||||
start_y = (int)(cam->pos.y / TILE_SIZE) - 1;
|
||||
end_x = start_x + (int)(cam->viewport.x / TILE_SIZE) + 3;
|
||||
end_y = start_y + (int)(cam->viewport.y / TILE_SIZE) + 3;
|
||||
|
||||
if (start_x < 0) start_x = 0;
|
||||
if (start_y < 0) start_y = 0;
|
||||
if (end_x > map->width) end_x = map->width;
|
||||
if (end_y > map->height) end_y = map->height;
|
||||
}
|
||||
|
||||
for (int y = start_y; y < end_y; y++) {
|
||||
for (int x = start_x; x < end_x; x++) {
|
||||
uint16_t tile_id = layer[y * map->width + x];
|
||||
if (tile_id == 0) continue; /* 0 = empty */
|
||||
|
||||
/* Look up tile definition for source rect */
|
||||
SDL_Rect src;
|
||||
if (tile_id < map->tile_def_count &&
|
||||
(map->tile_defs[tile_id].tex_x || map->tile_defs[tile_id].tex_y)) {
|
||||
src.x = map->tile_defs[tile_id].tex_x * TILE_SIZE;
|
||||
src.y = map->tile_defs[tile_id].tex_y * TILE_SIZE;
|
||||
} else {
|
||||
/* Fallback: tile_id maps directly to tileset grid position */
|
||||
int cols = map->tileset_cols > 0 ? map->tileset_cols : 16;
|
||||
src.x = ((tile_id - 1) % cols) * TILE_SIZE;
|
||||
src.y = ((tile_id - 1) / cols) * TILE_SIZE;
|
||||
}
|
||||
src.w = TILE_SIZE;
|
||||
src.h = TILE_SIZE;
|
||||
|
||||
Vec2 world_pos = vec2(tile_to_world(x), tile_to_world(y));
|
||||
Vec2 screen_pos = cam ? camera_world_to_screen(cam, world_pos) : world_pos;
|
||||
|
||||
SDL_Rect dst = {
|
||||
(int)screen_pos.x,
|
||||
(int)screen_pos.y,
|
||||
TILE_SIZE,
|
||||
TILE_SIZE
|
||||
};
|
||||
|
||||
SDL_RenderCopy(renderer, map->tileset, &src, &dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool tilemap_is_solid(const Tilemap *map, int tile_x, int tile_y) {
|
||||
if (!map) return false;
|
||||
if (tile_x < 0 || tile_x >= map->width) return true; /* out of bounds = solid */
|
||||
if (tile_y < 0) return false; /* above map = open */
|
||||
if (tile_y >= map->height) return true; /* below map = solid */
|
||||
|
||||
uint16_t tile_id = map->collision_layer[tile_y * map->width + tile_x];
|
||||
if (tile_id == 0) return false;
|
||||
|
||||
/* Check tile def flags */
|
||||
if (tile_id < map->tile_def_count) {
|
||||
return (map->tile_defs[tile_id].flags & TILE_SOLID) != 0;
|
||||
}
|
||||
|
||||
/* Default: non-zero collision tile is solid */
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t tilemap_flags_at(const Tilemap *map, int tile_x, int tile_y) {
|
||||
if (!map) return 0;
|
||||
if (tile_x < 0 || tile_x >= map->width) return TILE_SOLID;
|
||||
if (tile_y < 0) return 0;
|
||||
if (tile_y >= map->height) return TILE_SOLID;
|
||||
|
||||
uint16_t tile_id = map->collision_layer[tile_y * map->width + tile_x];
|
||||
if (tile_id == 0) return 0;
|
||||
|
||||
if (tile_id < map->tile_def_count) {
|
||||
return map->tile_defs[tile_id].flags;
|
||||
}
|
||||
|
||||
return TILE_SOLID; /* default non-zero = solid */
|
||||
}
|
||||
|
||||
void tilemap_free(Tilemap *map) {
|
||||
if (!map) return;
|
||||
free(map->collision_layer);
|
||||
free(map->bg_layer);
|
||||
free(map->fg_layer);
|
||||
/* Don't free tileset - asset manager owns it */
|
||||
memset(map, 0, sizeof(Tilemap));
|
||||
}
|
||||
69
src/engine/tilemap.h
Normal file
69
src/engine/tilemap.h
Normal file
@@ -0,0 +1,69 @@
|
||||
#ifndef JNR_TILEMAP_H
|
||||
#define JNR_TILEMAP_H
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <math.h>
|
||||
#include "config.h"
|
||||
#include "util/vec2.h"
|
||||
|
||||
/* Forward declarations */
|
||||
typedef struct Camera Camera;
|
||||
|
||||
typedef enum TileFlag {
|
||||
TILE_SOLID = 1 << 0,
|
||||
TILE_PLATFORM = 1 << 1, /* one-way, passable from below */
|
||||
TILE_LADDER = 1 << 2,
|
||||
TILE_HAZARD = 1 << 3, /* spikes, lava, etc. */
|
||||
TILE_WATER = 1 << 4,
|
||||
} TileFlag;
|
||||
|
||||
typedef struct TileDef {
|
||||
uint16_t tex_x, tex_y; /* position in tileset (in tiles) */
|
||||
uint32_t flags;
|
||||
} TileDef;
|
||||
|
||||
/* Entity spawn entry read from .lvl files */
|
||||
typedef struct EntitySpawn {
|
||||
char type_name[32]; /* e.g. "grunt", "flyer" */
|
||||
float x, y; /* world position (pixels) */
|
||||
} EntitySpawn;
|
||||
|
||||
typedef struct Tilemap {
|
||||
int width, height; /* map size in tiles */
|
||||
uint16_t *bg_layer; /* background tile IDs */
|
||||
uint16_t *collision_layer; /* solid geometry tile IDs */
|
||||
uint16_t *fg_layer; /* foreground decoration IDs */
|
||||
TileDef tile_defs[MAX_TILE_DEFS];
|
||||
int tile_def_count;
|
||||
SDL_Texture *tileset;
|
||||
int tileset_cols; /* columns in tileset image */
|
||||
Vec2 player_spawn;
|
||||
float gravity; /* level gravity (px/s^2), 0 = use default */
|
||||
char music_path[ASSET_PATH_MAX]; /* level music file path */
|
||||
SDL_Color bg_color; /* background clear color */
|
||||
bool has_bg_color; /* true if BG_COLOR was set */
|
||||
char parallax_far_path[ASSET_PATH_MAX]; /* far bg image path */
|
||||
char parallax_near_path[ASSET_PATH_MAX]; /* near bg image path */
|
||||
EntitySpawn entity_spawns[MAX_ENTITY_SPAWNS];
|
||||
int entity_spawn_count;
|
||||
} Tilemap;
|
||||
|
||||
bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer);
|
||||
void tilemap_render_layer(const Tilemap *map, const uint16_t *layer,
|
||||
const Camera *cam, SDL_Renderer *renderer);
|
||||
bool tilemap_is_solid(const Tilemap *map, int tile_x, int tile_y);
|
||||
uint32_t tilemap_flags_at(const Tilemap *map, int tile_x, int tile_y);
|
||||
void tilemap_free(Tilemap *map);
|
||||
|
||||
/* World-pixel to tile coordinate helpers */
|
||||
static inline int world_to_tile(float world_coord) {
|
||||
return (int)floorf(world_coord / TILE_SIZE);
|
||||
}
|
||||
|
||||
static inline float tile_to_world(int tile_coord) {
|
||||
return (float)(tile_coord * TILE_SIZE);
|
||||
}
|
||||
|
||||
#endif /* JNR_TILEMAP_H */
|
||||
265
src/game/enemy.c
Normal file
265
src/game/enemy.c
Normal file
@@ -0,0 +1,265 @@
|
||||
#include "game/enemy.h"
|
||||
#include "game/sprites.h"
|
||||
#include "game/projectile.h"
|
||||
#include "engine/physics.h"
|
||||
#include "engine/renderer.h"
|
||||
#include <stdlib.h>
|
||||
#include <math.h>
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
* GRUNT - ground patrol enemy
|
||||
* ════════════════════════════════════════════════════ */
|
||||
|
||||
static EntityManager *s_grunt_em = NULL;
|
||||
|
||||
static void grunt_update(Entity *self, float dt, const Tilemap *map) {
|
||||
GruntData *gd = (GruntData *)self->data;
|
||||
if (!gd) return;
|
||||
|
||||
Body *body = &self->body;
|
||||
|
||||
/* Death sequence */
|
||||
if (self->flags & ENTITY_DEAD) {
|
||||
animation_set(&self->anim, &anim_grunt_death);
|
||||
animation_update(&self->anim, dt);
|
||||
gd->death_timer -= dt;
|
||||
body->vel.x = 0;
|
||||
if (gd->death_timer <= 0) {
|
||||
entity_destroy(s_grunt_em, self);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Patrol: walk in one direction, reverse when hitting a wall
|
||||
or about to walk off a ledge */
|
||||
body->vel.x = gd->patrol_dir * GRUNT_SPEED;
|
||||
|
||||
/* Set facing direction */
|
||||
if (gd->patrol_dir < 0) self->flags |= ENTITY_FACING_LEFT;
|
||||
else self->flags &= ~ENTITY_FACING_LEFT;
|
||||
|
||||
/* Physics */
|
||||
physics_update(body, dt, map);
|
||||
|
||||
/* Turn around on wall collision */
|
||||
if (body->on_wall_left || body->on_wall_right) {
|
||||
gd->patrol_dir = -gd->patrol_dir;
|
||||
}
|
||||
|
||||
/* Turn around at ledge: check if there's ground ahead */
|
||||
if (body->on_ground) {
|
||||
float check_x = (gd->patrol_dir > 0) ?
|
||||
body->pos.x + body->size.x + 2.0f :
|
||||
body->pos.x - 2.0f;
|
||||
float check_y = body->pos.y + body->size.y + 4.0f;
|
||||
|
||||
int tx = world_to_tile(check_x);
|
||||
int ty = world_to_tile(check_y);
|
||||
|
||||
if (!tilemap_is_solid(map, tx, ty)) {
|
||||
gd->patrol_dir = -gd->patrol_dir;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
if (fabsf(body->vel.x) > 1.0f) {
|
||||
animation_set(&self->anim, &anim_grunt_walk);
|
||||
} else {
|
||||
animation_set(&self->anim, &anim_grunt_idle);
|
||||
}
|
||||
animation_update(&self->anim, dt);
|
||||
}
|
||||
|
||||
static void grunt_render(Entity *self, const Camera *cam) {
|
||||
(void)cam;
|
||||
Body *body = &self->body;
|
||||
|
||||
if (g_spritesheet && self->anim.def) {
|
||||
SDL_Rect src = animation_current_rect(&self->anim);
|
||||
|
||||
Vec2 render_pos = vec2(
|
||||
body->pos.x - 2.0f,
|
||||
body->pos.y
|
||||
);
|
||||
|
||||
Sprite spr = {
|
||||
.texture = g_spritesheet,
|
||||
.src = src,
|
||||
.pos = render_pos,
|
||||
.size = vec2(SPRITE_CELL, SPRITE_CELL),
|
||||
.flip_x = (self->flags & ENTITY_FACING_LEFT) != 0,
|
||||
.flip_y = false,
|
||||
.layer = LAYER_ENTITIES,
|
||||
.alpha = 255,
|
||||
};
|
||||
renderer_submit(&spr);
|
||||
} else {
|
||||
SDL_Color color = {200, 60, 60, 255};
|
||||
renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam);
|
||||
}
|
||||
}
|
||||
|
||||
static void grunt_destroy(Entity *self) {
|
||||
free(self->data);
|
||||
self->data = NULL;
|
||||
}
|
||||
|
||||
void grunt_register(EntityManager *em) {
|
||||
entity_register(em, ENT_ENEMY_GRUNT, grunt_update, grunt_render, grunt_destroy);
|
||||
s_grunt_em = em;
|
||||
}
|
||||
|
||||
Entity *grunt_spawn(EntityManager *em, Vec2 pos) {
|
||||
Entity *e = entity_spawn(em, ENT_ENEMY_GRUNT, pos);
|
||||
if (!e) return NULL;
|
||||
|
||||
e->body.size = vec2(GRUNT_WIDTH, GRUNT_HEIGHT);
|
||||
e->body.gravity_scale = 1.0f;
|
||||
e->health = GRUNT_HEALTH;
|
||||
e->max_health = GRUNT_HEALTH;
|
||||
e->damage = 1;
|
||||
|
||||
GruntData *gd = calloc(1, sizeof(GruntData));
|
||||
gd->patrol_dir = 1.0f;
|
||||
gd->death_timer = 0.3f;
|
||||
e->data = gd;
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
* FLYER - flying enemy
|
||||
* ════════════════════════════════════════════════════ */
|
||||
|
||||
static EntityManager *s_flyer_em = NULL;
|
||||
|
||||
/* Helper: find the player entity in the manager */
|
||||
static Entity *find_player(EntityManager *em) {
|
||||
for (int i = 0; i < em->count; i++) {
|
||||
Entity *e = &em->entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER) return e;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void flyer_update(Entity *self, float dt, const Tilemap *map) {
|
||||
(void)map; /* flyers don't collide with tiles */
|
||||
FlyerData *fd = (FlyerData *)self->data;
|
||||
if (!fd) return;
|
||||
|
||||
Body *body = &self->body;
|
||||
|
||||
/* Death sequence */
|
||||
if (self->flags & ENTITY_DEAD) {
|
||||
animation_set(&self->anim, &anim_flyer_death);
|
||||
animation_update(&self->anim, dt);
|
||||
/* Fall when dead */
|
||||
body->vel.y += physics_get_gravity() * dt;
|
||||
if (body->vel.y > MAX_FALL_SPEED) body->vel.y = MAX_FALL_SPEED;
|
||||
body->pos.y += body->vel.y * dt;
|
||||
fd->death_timer -= dt;
|
||||
if (fd->death_timer <= 0) {
|
||||
entity_destroy(s_flyer_em, self);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Bob up and down */
|
||||
fd->bob_timer += dt;
|
||||
float bob_offset = sinf(fd->bob_timer * FLYER_BOB_SPD) * FLYER_BOB_AMP;
|
||||
body->pos.y = fd->base_y + bob_offset;
|
||||
|
||||
/* Chase player if in range */
|
||||
Entity *player = find_player(s_flyer_em);
|
||||
if (player && player->active && !(player->flags & ENTITY_DEAD)) {
|
||||
float px = player->body.pos.x + player->body.size.x * 0.5f;
|
||||
float fx = body->pos.x + body->size.x * 0.5f;
|
||||
float dist = px - fx;
|
||||
|
||||
if (fabsf(dist) < FLYER_DETECT) {
|
||||
/* Move toward player */
|
||||
if (dist < -2.0f) {
|
||||
body->pos.x -= FLYER_SPEED * dt;
|
||||
self->flags |= ENTITY_FACING_LEFT;
|
||||
} else if (dist > 2.0f) {
|
||||
body->pos.x += FLYER_SPEED * dt;
|
||||
self->flags &= ~ENTITY_FACING_LEFT;
|
||||
}
|
||||
|
||||
/* Shoot at player */
|
||||
fd->shoot_timer -= dt;
|
||||
if (fd->shoot_timer <= 0 && s_flyer_em) {
|
||||
fd->shoot_timer = FLYER_SHOOT_CD;
|
||||
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
|
||||
float bx = facing_left ?
|
||||
body->pos.x - 4.0f :
|
||||
body->pos.x + body->size.x;
|
||||
float by = body->pos.y + body->size.y * 0.4f;
|
||||
projectile_spawn(s_flyer_em, vec2(bx, by), facing_left, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
animation_set(&self->anim, &anim_flyer_fly);
|
||||
animation_update(&self->anim, dt);
|
||||
}
|
||||
|
||||
static void flyer_render(Entity *self, const Camera *cam) {
|
||||
(void)cam;
|
||||
Body *body = &self->body;
|
||||
|
||||
if (g_spritesheet && self->anim.def) {
|
||||
SDL_Rect src = animation_current_rect(&self->anim);
|
||||
|
||||
Vec2 render_pos = vec2(
|
||||
body->pos.x - 1.0f,
|
||||
body->pos.y - 2.0f
|
||||
);
|
||||
|
||||
Sprite spr = {
|
||||
.texture = g_spritesheet,
|
||||
.src = src,
|
||||
.pos = render_pos,
|
||||
.size = vec2(SPRITE_CELL, SPRITE_CELL),
|
||||
.flip_x = (self->flags & ENTITY_FACING_LEFT) != 0,
|
||||
.flip_y = false,
|
||||
.layer = LAYER_ENTITIES,
|
||||
.alpha = 255,
|
||||
};
|
||||
renderer_submit(&spr);
|
||||
} else {
|
||||
SDL_Color color = {150, 50, 180, 255};
|
||||
renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam);
|
||||
}
|
||||
}
|
||||
|
||||
static void flyer_destroy(Entity *self) {
|
||||
free(self->data);
|
||||
self->data = NULL;
|
||||
}
|
||||
|
||||
void flyer_register(EntityManager *em) {
|
||||
entity_register(em, ENT_ENEMY_FLYER, flyer_update, flyer_render, flyer_destroy);
|
||||
s_flyer_em = em;
|
||||
}
|
||||
|
||||
Entity *flyer_spawn(EntityManager *em, Vec2 pos) {
|
||||
Entity *e = entity_spawn(em, ENT_ENEMY_FLYER, pos);
|
||||
if (!e) return NULL;
|
||||
|
||||
e->body.size = vec2(FLYER_WIDTH, FLYER_HEIGHT);
|
||||
e->body.gravity_scale = 0.0f; /* flyers don't fall */
|
||||
e->health = FLYER_HEALTH;
|
||||
e->max_health = FLYER_HEALTH;
|
||||
e->damage = 1;
|
||||
|
||||
FlyerData *fd = calloc(1, sizeof(FlyerData));
|
||||
fd->base_y = pos.y;
|
||||
fd->death_timer = 0.5f;
|
||||
fd->shoot_timer = FLYER_SHOOT_CD;
|
||||
e->data = fd;
|
||||
|
||||
return e;
|
||||
}
|
||||
47
src/game/enemy.h
Normal file
47
src/game/enemy.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#ifndef JNR_ENEMY_H
|
||||
#define JNR_ENEMY_H
|
||||
|
||||
#include "engine/entity.h"
|
||||
#include "engine/camera.h"
|
||||
#include "engine/tilemap.h"
|
||||
|
||||
/* ── Grunt enemy ───────────────────────────────────── */
|
||||
/* A ground-patrolling enemy that walks back and forth */
|
||||
|
||||
#define GRUNT_WIDTH 12
|
||||
#define GRUNT_HEIGHT 16
|
||||
#define GRUNT_SPEED 40.0f /* horizontal walk speed (px/s) */
|
||||
#define GRUNT_HEALTH 2
|
||||
|
||||
typedef struct GruntData {
|
||||
float patrol_dir; /* 1.0 or -1.0 */
|
||||
float death_timer; /* countdown after dying */
|
||||
} GruntData;
|
||||
|
||||
void grunt_register(EntityManager *em);
|
||||
Entity *grunt_spawn(EntityManager *em, Vec2 pos);
|
||||
|
||||
/* ── Flyer enemy ───────────────────────────────────── */
|
||||
/* A flying enemy that bobs up and down, moves toward */
|
||||
/* the player when in range */
|
||||
|
||||
#define FLYER_WIDTH 14
|
||||
#define FLYER_HEIGHT 12
|
||||
#define FLYER_SPEED 50.0f /* horizontal chase speed (px/s) */
|
||||
#define FLYER_BOB_AMP 20.0f /* vertical bob amplitude */
|
||||
#define FLYER_BOB_SPD 2.5f /* bob frequency (radians/s) */
|
||||
#define FLYER_HEALTH 1
|
||||
#define FLYER_DETECT 120.0f /* detection range (px) */
|
||||
#define FLYER_SHOOT_CD 2.0f /* seconds between shots */
|
||||
|
||||
typedef struct FlyerData {
|
||||
float bob_timer; /* for sine wave bobbing */
|
||||
float base_y; /* original y position */
|
||||
float death_timer;
|
||||
float shoot_timer; /* cooldown for shooting */
|
||||
} FlyerData;
|
||||
|
||||
void flyer_register(EntityManager *em);
|
||||
Entity *flyer_spawn(EntityManager *em, Vec2 pos);
|
||||
|
||||
#endif /* JNR_ENEMY_H */
|
||||
405
src/game/level.c
Normal file
405
src/game/level.c
Normal file
@@ -0,0 +1,405 @@
|
||||
#include "game/level.h"
|
||||
#include "game/player.h"
|
||||
#include "game/enemy.h"
|
||||
#include "game/projectile.h"
|
||||
#include "game/sprites.h"
|
||||
#include "engine/core.h"
|
||||
#include "engine/renderer.h"
|
||||
#include "engine/physics.h"
|
||||
#include "engine/particle.h"
|
||||
#include "engine/audio.h"
|
||||
#include "engine/input.h"
|
||||
#include "engine/camera.h"
|
||||
#include "engine/assets.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ── Sound effects ───────────────────────────────── */
|
||||
static Sound s_sfx_hit;
|
||||
static Sound s_sfx_enemy_death;
|
||||
static bool s_sfx_loaded = false;
|
||||
|
||||
bool level_load(Level *level, const char *path) {
|
||||
memset(level, 0, sizeof(Level));
|
||||
|
||||
/* Initialize subsystems */
|
||||
entity_manager_init(&level->entities);
|
||||
camera_init(&level->camera, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
particle_init();
|
||||
|
||||
/* Load combat sound effects */
|
||||
if (!s_sfx_loaded) {
|
||||
s_sfx_hit = audio_load_sound("assets/sounds/hitHurt.wav");
|
||||
s_sfx_enemy_death = audio_load_sound("assets/sounds/teleport.wav");
|
||||
s_sfx_loaded = true;
|
||||
}
|
||||
|
||||
/* Generate spritesheet */
|
||||
sprites_init_anims();
|
||||
if (!sprites_generate(g_engine.renderer)) {
|
||||
fprintf(stderr, "Warning: failed to generate spritesheet\n");
|
||||
}
|
||||
|
||||
/* Register entity types */
|
||||
player_register(&level->entities);
|
||||
player_set_entity_manager(&level->entities);
|
||||
grunt_register(&level->entities);
|
||||
flyer_register(&level->entities);
|
||||
projectile_register(&level->entities);
|
||||
|
||||
/* Load tilemap */
|
||||
if (!tilemap_load(&level->map, path, g_engine.renderer)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Apply level gravity (0 = use default) */
|
||||
if (level->map.gravity > 0) {
|
||||
physics_set_gravity(level->map.gravity);
|
||||
} else {
|
||||
physics_set_gravity(DEFAULT_GRAVITY);
|
||||
}
|
||||
|
||||
/* Apply level background color */
|
||||
if (level->map.has_bg_color) {
|
||||
renderer_set_clear_color(level->map.bg_color);
|
||||
}
|
||||
|
||||
/* Initialize parallax backgrounds */
|
||||
parallax_init(&level->parallax);
|
||||
if (level->map.parallax_far_path[0]) {
|
||||
SDL_Texture *far_tex = assets_get_texture(level->map.parallax_far_path);
|
||||
if (far_tex) parallax_set_far(&level->parallax, far_tex, 0.05f, 0.05f);
|
||||
}
|
||||
if (level->map.parallax_near_path[0]) {
|
||||
SDL_Texture *near_tex = assets_get_texture(level->map.parallax_near_path);
|
||||
if (near_tex) parallax_set_near(&level->parallax, near_tex, 0.15f, 0.10f);
|
||||
}
|
||||
/* Generate procedural backgrounds for any layers not loaded from file */
|
||||
if (!level->parallax.far_layer.active) {
|
||||
parallax_generate_stars(&level->parallax, g_engine.renderer);
|
||||
}
|
||||
if (!level->parallax.near_layer.active) {
|
||||
parallax_generate_nebula(&level->parallax, g_engine.renderer);
|
||||
}
|
||||
|
||||
/* Set camera bounds to level size */
|
||||
camera_set_bounds(&level->camera,
|
||||
(float)(level->map.width * TILE_SIZE),
|
||||
(float)(level->map.height * TILE_SIZE));
|
||||
|
||||
/* Spawn player at map spawn point */
|
||||
Entity *player = player_spawn(&level->entities, level->map.player_spawn);
|
||||
if (!player) {
|
||||
fprintf(stderr, "Failed to spawn player!\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Spawn entities from level data */
|
||||
for (int i = 0; i < level->map.entity_spawn_count; i++) {
|
||||
EntitySpawn *es = &level->map.entity_spawns[i];
|
||||
Vec2 pos = vec2(es->x, es->y);
|
||||
|
||||
if (strcmp(es->type_name, "grunt") == 0) {
|
||||
grunt_spawn(&level->entities, pos);
|
||||
} else if (strcmp(es->type_name, "flyer") == 0) {
|
||||
flyer_spawn(&level->entities, pos);
|
||||
} else {
|
||||
fprintf(stderr, "Unknown entity type: %s\n", es->type_name);
|
||||
}
|
||||
}
|
||||
|
||||
/* Load level music (playback deferred to first update —
|
||||
* browsers require user interaction before playing audio) */
|
||||
if (level->map.music_path[0]) {
|
||||
level->music = audio_load_music(level->map.music_path);
|
||||
}
|
||||
level->music_started = false;
|
||||
|
||||
printf("Level loaded successfully.\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Collision handling ──────────────────────────── */
|
||||
|
||||
/* Forward declaration for shake access */
|
||||
static Camera *s_active_camera = NULL;
|
||||
|
||||
static void damage_entity(Entity *target, int damage) {
|
||||
target->health -= damage;
|
||||
if (target->health <= 0) {
|
||||
target->flags |= ENTITY_DEAD;
|
||||
|
||||
/* Death particles — centered on entity */
|
||||
Vec2 center = vec2(
|
||||
target->body.pos.x + target->body.size.x * 0.5f,
|
||||
target->body.pos.y + target->body.size.y * 0.5f
|
||||
);
|
||||
SDL_Color death_color;
|
||||
if (target->type == ENT_ENEMY_GRUNT) {
|
||||
death_color = (SDL_Color){200, 60, 60, 255}; /* red debris */
|
||||
} else if (target->type == ENT_ENEMY_FLYER) {
|
||||
death_color = (SDL_Color){140, 80, 200, 255}; /* purple puff */
|
||||
} else {
|
||||
death_color = (SDL_Color){200, 200, 200, 255}; /* grey */
|
||||
}
|
||||
particle_emit_death_puff(center, death_color);
|
||||
|
||||
/* Screen shake on kill */
|
||||
if (s_active_camera) {
|
||||
camera_shake(s_active_camera, 2.0f, 0.15f);
|
||||
}
|
||||
|
||||
audio_play_sound(s_sfx_enemy_death, 80);
|
||||
}
|
||||
}
|
||||
|
||||
static void damage_player(Entity *player, int damage, Entity *source) {
|
||||
PlayerData *ppd = (PlayerData *)player->data;
|
||||
damage_entity(player, damage);
|
||||
|
||||
/* Screen shake on player hit (stronger) */
|
||||
if (s_active_camera) {
|
||||
camera_shake(s_active_camera, 4.0f, 0.2f);
|
||||
}
|
||||
|
||||
audio_play_sound(s_sfx_hit, 100);
|
||||
|
||||
if (player->health > 0 && ppd) {
|
||||
ppd->inv_timer = PLAYER_INV_TIME;
|
||||
player->flags |= ENTITY_INVINCIBLE;
|
||||
|
||||
/* Knockback away from source */
|
||||
if (source) {
|
||||
float knock_dir = (player->body.pos.x < source->body.pos.x) ?
|
||||
-1.0f : 1.0f;
|
||||
player->body.vel.x = knock_dir * 150.0f;
|
||||
player->body.vel.y = -150.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool is_enemy(const Entity *e) {
|
||||
return e->type == ENT_ENEMY_GRUNT || e->type == ENT_ENEMY_FLYER;
|
||||
}
|
||||
|
||||
static void handle_collisions(EntityManager *em) {
|
||||
/* Find the player */
|
||||
Entity *player = NULL;
|
||||
for (int i = 0; i < em->count; i++) {
|
||||
Entity *e = &em->entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER && !(e->flags & ENTITY_DEAD)) {
|
||||
player = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < em->count; i++) {
|
||||
Entity *a = &em->entities[i];
|
||||
if (!a->active) continue;
|
||||
|
||||
/* ── Projectile vs entities ──────────── */
|
||||
if (a->type == ENT_PROJECTILE) {
|
||||
if (projectile_is_impacting(a)) continue;
|
||||
bool from_player = projectile_is_from_player(a);
|
||||
|
||||
for (int j = 0; j < em->count; j++) {
|
||||
Entity *b = &em->entities[j];
|
||||
if (!b->active || b == a) continue;
|
||||
if (b->flags & ENTITY_DEAD) continue;
|
||||
|
||||
bool hit = false;
|
||||
|
||||
/* Player bullet hits enemies */
|
||||
if (from_player && is_enemy(b)) {
|
||||
if (physics_overlap(&a->body, &b->body)) {
|
||||
damage_entity(b, a->damage);
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enemy bullet hits player */
|
||||
if (!from_player && b->type == ENT_PLAYER &&
|
||||
!(b->flags & ENTITY_INVINCIBLE)) {
|
||||
if (physics_overlap(&a->body, &b->body)) {
|
||||
damage_player(b, a->damage, NULL);
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
projectile_hit(a);
|
||||
/* If projectile was destroyed or is impacting, stop checking */
|
||||
if (!a->active || projectile_is_impacting(a)) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Enemy contact damage to player ──── */
|
||||
if (player && !(player->flags & ENTITY_INVINCIBLE) &&
|
||||
is_enemy(a) && !(a->flags & ENTITY_DEAD)) {
|
||||
if (physics_overlap(&a->body, &player->body)) {
|
||||
/* Check if player is stomping (falling onto enemy from above) */
|
||||
bool stomping = (player->body.vel.y > 0) &&
|
||||
(player->body.pos.y + player->body.size.y <
|
||||
a->body.pos.y + a->body.size.y * 0.5f);
|
||||
|
||||
if (stomping) {
|
||||
damage_entity(a, 2);
|
||||
player->body.vel.y = -PLAYER_JUMP_FORCE * 0.7f;
|
||||
} else {
|
||||
damage_player(player, a->damage, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void level_update(Level *level, float dt) {
|
||||
/* Start music on first update (deferred so browser audio context
|
||||
* is unlocked by the first user interaction / keypress) */
|
||||
if (!level->music_started && level->music.music) {
|
||||
audio_set_music_volume(64);
|
||||
audio_play_music(level->music, true);
|
||||
level->music_started = true;
|
||||
}
|
||||
|
||||
/* Set camera pointer for collision shake triggers */
|
||||
s_active_camera = &level->camera;
|
||||
|
||||
/* Update all entities */
|
||||
entity_update_all(&level->entities, dt, &level->map);
|
||||
|
||||
/* Handle collisions */
|
||||
handle_collisions(&level->entities);
|
||||
|
||||
/* Check for player respawn */
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) {
|
||||
player_respawn(e, level->map.player_spawn);
|
||||
/* Re-snap camera to player immediately */
|
||||
Vec2 center = vec2(
|
||||
e->body.pos.x + e->body.size.x * 0.5f,
|
||||
e->body.pos.y + e->body.size.y * 0.5f
|
||||
);
|
||||
camera_follow(&level->camera, center, vec2_zero(), dt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* Update particles */
|
||||
particle_update(dt);
|
||||
|
||||
/* Find player for camera tracking */
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER) {
|
||||
float look_offset = player_get_look_up_offset(e);
|
||||
Vec2 center = vec2(
|
||||
e->body.pos.x + e->body.size.x * 0.5f,
|
||||
e->body.pos.y + e->body.size.y * 0.5f + look_offset
|
||||
);
|
||||
camera_follow(&level->camera, center, e->body.vel, dt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* Update screen shake */
|
||||
camera_update_shake(&level->camera, dt);
|
||||
}
|
||||
|
||||
void level_render(Level *level, float interpolation) {
|
||||
(void)interpolation; /* TODO: use for render interpolation */
|
||||
|
||||
Camera *cam = &level->camera;
|
||||
|
||||
/* Render parallax backgrounds (behind everything) */
|
||||
parallax_render(&level->parallax, cam, g_engine.renderer);
|
||||
|
||||
/* Render tile layers */
|
||||
tilemap_render_layer(&level->map, level->map.bg_layer,
|
||||
cam, g_engine.renderer);
|
||||
tilemap_render_layer(&level->map, level->map.collision_layer,
|
||||
cam, g_engine.renderer);
|
||||
|
||||
/* Render entities */
|
||||
entity_render_all(&level->entities, cam);
|
||||
|
||||
/* Render particles (between entities and foreground) */
|
||||
particle_render(cam);
|
||||
|
||||
/* Render foreground tiles */
|
||||
tilemap_render_layer(&level->map, level->map.fg_layer,
|
||||
cam, g_engine.renderer);
|
||||
|
||||
/* Render HUD - health display */
|
||||
Entity *player = NULL;
|
||||
for (int i = 0; i < level->entities.count; i++) {
|
||||
Entity *e = &level->entities.entities[i];
|
||||
if (e->active && e->type == ENT_PLAYER) {
|
||||
player = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (player) {
|
||||
/* Draw health hearts */
|
||||
for (int i = 0; i < player->max_health; i++) {
|
||||
SDL_Color heart_color;
|
||||
if (i < player->health) {
|
||||
heart_color = (SDL_Color){220, 50, 50, 255}; /* red = full */
|
||||
} else {
|
||||
heart_color = (SDL_Color){80, 80, 80, 255}; /* grey = empty */
|
||||
}
|
||||
Vec2 pos = vec2(8.0f + i * 14.0f, 8.0f);
|
||||
Vec2 size = vec2(10.0f, 10.0f);
|
||||
renderer_draw_rect(pos, size, heart_color, LAYER_HUD, cam);
|
||||
}
|
||||
|
||||
/* Draw jetpack charge indicators */
|
||||
int charges, max_charges;
|
||||
float recharge_pct;
|
||||
if (player_get_dash_charges(player, &charges, &max_charges, &recharge_pct)) {
|
||||
for (int i = 0; i < max_charges; i++) {
|
||||
float bx = 8.0f + i * 10.0f;
|
||||
float by = 22.0f;
|
||||
float bw = 7.0f;
|
||||
float bh = 5.0f;
|
||||
|
||||
/* Background (empty slot) */
|
||||
renderer_draw_rect(vec2(bx, by), vec2(bw, bh),
|
||||
(SDL_Color){50, 50, 60, 255}, LAYER_HUD, cam);
|
||||
|
||||
if (i < charges) {
|
||||
/* Full charge — bright orange */
|
||||
renderer_draw_rect(vec2(bx, by), vec2(bw, bh),
|
||||
(SDL_Color){255, 180, 50, 255}, LAYER_HUD, cam);
|
||||
} else if (i == charges) {
|
||||
/* Currently recharging — partial fill */
|
||||
float fill = recharge_pct * bw;
|
||||
if (fill > 0.5f) {
|
||||
renderer_draw_rect(vec2(bx, by), vec2(fill, bh),
|
||||
(SDL_Color){200, 140, 40, 180}, LAYER_HUD, cam);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Flush the renderer */
|
||||
renderer_flush(cam);
|
||||
}
|
||||
|
||||
void level_free(Level *level) {
|
||||
audio_stop_music();
|
||||
entity_manager_clear(&level->entities);
|
||||
particle_clear();
|
||||
parallax_free(&level->parallax);
|
||||
tilemap_free(&level->map);
|
||||
|
||||
/* Free spritesheet */
|
||||
if (g_spritesheet) {
|
||||
SDL_DestroyTexture(g_spritesheet);
|
||||
g_spritesheet = NULL;
|
||||
}
|
||||
}
|
||||
24
src/game/level.h
Normal file
24
src/game/level.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#ifndef JNR_LEVEL_H
|
||||
#define JNR_LEVEL_H
|
||||
|
||||
#include "engine/tilemap.h"
|
||||
#include "engine/entity.h"
|
||||
#include "engine/camera.h"
|
||||
#include "engine/audio.h"
|
||||
#include "engine/parallax.h"
|
||||
|
||||
typedef struct Level {
|
||||
Tilemap map;
|
||||
EntityManager entities;
|
||||
Camera camera;
|
||||
Parallax parallax;
|
||||
Music music;
|
||||
bool music_started;
|
||||
} Level;
|
||||
|
||||
bool level_load(Level *level, const char *path);
|
||||
void level_update(Level *level, float dt);
|
||||
void level_render(Level *level, float interpolation);
|
||||
void level_free(Level *level);
|
||||
|
||||
#endif /* JNR_LEVEL_H */
|
||||
650
src/game/player.c
Normal file
650
src/game/player.c
Normal file
@@ -0,0 +1,650 @@
|
||||
#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 = instant death */
|
||||
float level_bottom = (float)(map->height * TILE_SIZE) + 64.0f;
|
||||
if (self->body.pos.y > level_bottom) {
|
||||
self->health = 0;
|
||||
self->flags |= ENTITY_DEAD;
|
||||
pd->respawn_timer = 0.3f; /* shorter delay for pit death */
|
||||
return;
|
||||
}
|
||||
|
||||
/* 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 ─────────────────────── */
|
||||
|
||||
/* Recharge jetpack charges over time */
|
||||
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 = PLAYER_DASH_RECHARGE;
|
||||
} 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);
|
||||
|
||||
/* 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--;
|
||||
pd->dash_recharge_timer = 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);
|
||||
|
||||
audio_play_sound(s_sfx_dash, 96);
|
||||
return;
|
||||
}
|
||||
|
||||
/* ── 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 (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) {
|
||||
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;
|
||||
|
||||
PlayerData *pd = calloc(1, sizeof(PlayerData));
|
||||
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) {
|
||||
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 (recharge_pct) {
|
||||
if (pd->dash_charges >= pd->dash_max_charges) {
|
||||
*recharge_pct = 1.0f;
|
||||
} else {
|
||||
*recharge_pct = 1.0f - (pd->dash_recharge_timer / PLAYER_DASH_RECHARGE);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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 */
|
||||
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->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);
|
||||
}
|
||||
96
src/game/player.h
Normal file
96
src/game/player.h
Normal file
@@ -0,0 +1,96 @@
|
||||
#ifndef JNR_PLAYER_H
|
||||
#define JNR_PLAYER_H
|
||||
|
||||
#include "engine/entity.h"
|
||||
#include "engine/camera.h"
|
||||
#include "engine/tilemap.h"
|
||||
|
||||
/* Player physics tuning */
|
||||
#define PLAYER_SPEED 150.0f /* horizontal speed (px/s) */
|
||||
#define PLAYER_ACCEL 1200.0f /* ground acceleration */
|
||||
#define PLAYER_DECEL 1600.0f /* ground deceleration (friction)*/
|
||||
#define PLAYER_AIR_ACCEL 600.0f /* air acceleration */
|
||||
#define PLAYER_JUMP_FORCE 320.0f /* initial jump velocity */
|
||||
#define PLAYER_JUMP_CUT 0.4f /* multiplier when releasing jump*/
|
||||
#define PLAYER_COYOTE_TIME 0.08f /* seconds after leaving edge */
|
||||
#define PLAYER_JUMP_BUFFER 0.1f /* seconds before landing */
|
||||
|
||||
#define PLAYER_WIDTH 12
|
||||
#define PLAYER_HEIGHT 16
|
||||
|
||||
/* Sprite dimensions (art is larger than hitbox) */
|
||||
#define PLAYER_SPRITE_W 24
|
||||
#define PLAYER_SPRITE_H 24
|
||||
#define PLAYER_DEATH_SPRITE_W 32
|
||||
#define PLAYER_DEATH_SPRITE_H 32
|
||||
|
||||
/* Shooting */
|
||||
#define PLAYER_SHOOT_COOLDOWN 0.12f /* seconds between shots */
|
||||
|
||||
/* Dash / Jetpack */
|
||||
#define PLAYER_DASH_SPEED 350.0f /* dash velocity (px/s) */
|
||||
#define PLAYER_DASH_DURATION 0.15f /* seconds the dash lasts */
|
||||
#define PLAYER_DASH_MAX_CHARGES 3 /* max jetpack charges */
|
||||
#define PLAYER_DASH_RECHARGE 3.0f /* seconds to recharge one charge*/
|
||||
|
||||
/* Invincibility after taking damage */
|
||||
#define PLAYER_INV_TIME 1.5f /* seconds of invincibility */
|
||||
|
||||
/* Aim direction (for shooting) */
|
||||
typedef enum AimDir {
|
||||
AIM_FORWARD, /* horizontal, based on facing */
|
||||
AIM_UP, /* straight up */
|
||||
AIM_DIAG_UP, /* 45 degrees up + forward */
|
||||
} AimDir;
|
||||
|
||||
typedef struct PlayerData {
|
||||
float coyote_timer;
|
||||
float jump_buffer_timer;
|
||||
bool jumping;
|
||||
bool was_on_ground; /* on_ground last frame (landing detect) */
|
||||
float shoot_cooldown;
|
||||
float inv_timer; /* invincibility timer */
|
||||
/* Dash / Jetpack */
|
||||
float dash_timer; /* remaining dash time (0=not dashing) */
|
||||
int dash_charges; /* available jetpack charges */
|
||||
int dash_max_charges; /* max charges (for HUD) */
|
||||
float dash_recharge_timer; /* time until next charge restored*/
|
||||
Vec2 dash_dir; /* direction of current dash */
|
||||
/* Aiming */
|
||||
AimDir aim_dir; /* current aim direction */
|
||||
bool looking_up; /* holding up without moving */
|
||||
float look_up_timer; /* how long up has been held */
|
||||
/* Death / Respawn */
|
||||
float respawn_timer; /* countdown after death anim finishes */
|
||||
Vec2 spawn_point; /* where to respawn */
|
||||
} PlayerData;
|
||||
|
||||
/* Register player entity type with the entity manager */
|
||||
void player_register(EntityManager *em);
|
||||
|
||||
/* Entity callbacks */
|
||||
void player_update(Entity *self, float dt, const Tilemap *map);
|
||||
void player_render(Entity *self, const Camera *cam);
|
||||
void player_destroy(Entity *self);
|
||||
|
||||
/* Spawn a fully configured player entity */
|
||||
Entity *player_spawn(EntityManager *em, Vec2 pos);
|
||||
|
||||
/* Set the entity manager the player lives in (for spawning projectiles) */
|
||||
void player_set_entity_manager(EntityManager *em);
|
||||
|
||||
/* Get the player's current look-up offset for the camera */
|
||||
float player_get_look_up_offset(const Entity *self);
|
||||
|
||||
/* Get jetpack dash charge info for HUD (returns false if entity is not player) */
|
||||
bool player_get_dash_charges(const Entity *self, int *charges, int *max_charges,
|
||||
float *recharge_pct);
|
||||
|
||||
/* Check if the player is requesting a respawn (death anim finished + timer expired).
|
||||
* Returns true when respawn should occur. */
|
||||
bool player_wants_respawn(const Entity *self);
|
||||
|
||||
/* Reset the player to alive state at the given position */
|
||||
void player_respawn(Entity *self, Vec2 pos);
|
||||
|
||||
#endif /* JNR_PLAYER_H */
|
||||
430
src/game/projectile.c
Normal file
430
src/game/projectile.c
Normal file
@@ -0,0 +1,430 @@
|
||||
#include "game/projectile.h"
|
||||
#include "game/sprites.h"
|
||||
#include "engine/physics.h"
|
||||
#include "engine/renderer.h"
|
||||
#include "engine/particle.h"
|
||||
#include <stdlib.h>
|
||||
#include <math.h>
|
||||
|
||||
static EntityManager *s_proj_em = NULL;
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
* Built-in weapon definitions
|
||||
* ════════════════════════════════════════════════════ */
|
||||
|
||||
const ProjectileDef WEAPON_PLASMA = {
|
||||
.name = "plasma",
|
||||
.speed = 400.0f,
|
||||
.damage = 1,
|
||||
.lifetime = 2.0f,
|
||||
.gravity_scale = 0.0f,
|
||||
.pierce_count = 0,
|
||||
.bounce_count = 0,
|
||||
.homing_strength = 0.0f,
|
||||
.hitbox_w = 8.0f,
|
||||
.hitbox_h = 8.0f,
|
||||
.flags = 0,
|
||||
.anim_fly = NULL, /* set in projectile_register after sprites_init_anims */
|
||||
.anim_impact = NULL,
|
||||
};
|
||||
|
||||
const ProjectileDef WEAPON_SPREAD = {
|
||||
.name = "spread",
|
||||
.speed = 350.0f,
|
||||
.damage = 1,
|
||||
.lifetime = 0.8f, /* short range */
|
||||
.gravity_scale = 0.0f,
|
||||
.pierce_count = 0,
|
||||
.bounce_count = 0,
|
||||
.homing_strength = 0.0f,
|
||||
.hitbox_w = 6.0f,
|
||||
.hitbox_h = 6.0f,
|
||||
.flags = 0,
|
||||
.anim_fly = NULL,
|
||||
.anim_impact = NULL,
|
||||
};
|
||||
|
||||
const ProjectileDef WEAPON_LASER = {
|
||||
.name = "laser",
|
||||
.speed = 600.0f,
|
||||
.damage = 1,
|
||||
.lifetime = 1.5f,
|
||||
.gravity_scale = 0.0f,
|
||||
.pierce_count = 3, /* passes through 3 enemies */
|
||||
.bounce_count = 0,
|
||||
.homing_strength = 0.0f,
|
||||
.hitbox_w = 10.0f,
|
||||
.hitbox_h = 4.0f,
|
||||
.flags = PROJ_PIERCING,
|
||||
.anim_fly = NULL,
|
||||
.anim_impact = NULL,
|
||||
};
|
||||
|
||||
const ProjectileDef WEAPON_ROCKET = {
|
||||
.name = "rocket",
|
||||
.speed = 200.0f,
|
||||
.damage = 3,
|
||||
.lifetime = 3.0f,
|
||||
.gravity_scale = 0.1f, /* slight drop */
|
||||
.pierce_count = 0,
|
||||
.bounce_count = 0,
|
||||
.homing_strength = 0.0f,
|
||||
.hitbox_w = 10.0f,
|
||||
.hitbox_h = 6.0f,
|
||||
.flags = PROJ_GRAVITY,
|
||||
.anim_fly = NULL,
|
||||
.anim_impact = NULL,
|
||||
};
|
||||
|
||||
const ProjectileDef WEAPON_BOUNCE = {
|
||||
.name = "bounce",
|
||||
.speed = 300.0f,
|
||||
.damage = 1,
|
||||
.lifetime = 4.0f,
|
||||
.gravity_scale = 0.5f,
|
||||
.pierce_count = 0,
|
||||
.bounce_count = 3, /* bounces off 3 walls */
|
||||
.homing_strength = 0.0f,
|
||||
.hitbox_w = 6.0f,
|
||||
.hitbox_h = 6.0f,
|
||||
.flags = PROJ_BOUNCY | PROJ_GRAVITY,
|
||||
.anim_fly = NULL,
|
||||
.anim_impact = NULL,
|
||||
};
|
||||
|
||||
const ProjectileDef WEAPON_ENEMY_FIRE = {
|
||||
.name = "enemy_fire",
|
||||
.speed = 180.0f,
|
||||
.damage = 1,
|
||||
.lifetime = 3.0f,
|
||||
.gravity_scale = 0.0f,
|
||||
.pierce_count = 0,
|
||||
.bounce_count = 0,
|
||||
.homing_strength = 0.0f,
|
||||
.hitbox_w = 8.0f,
|
||||
.hitbox_h = 8.0f,
|
||||
.flags = 0,
|
||||
.anim_fly = NULL,
|
||||
.anim_impact = NULL,
|
||||
};
|
||||
|
||||
/* ── Mutable copies with animation pointers ────────── */
|
||||
/* We need mutable copies because the AnimDef pointers */
|
||||
/* aren't available at compile time (set after init). */
|
||||
|
||||
static ProjectileDef s_weapon_plasma;
|
||||
static ProjectileDef s_weapon_spread;
|
||||
static ProjectileDef s_weapon_laser;
|
||||
static ProjectileDef s_weapon_rocket;
|
||||
static ProjectileDef s_weapon_bounce;
|
||||
static ProjectileDef s_weapon_enemy_fire;
|
||||
|
||||
static void init_weapon_defs(void) {
|
||||
s_weapon_plasma = WEAPON_PLASMA;
|
||||
s_weapon_plasma.anim_fly = &anim_bullet;
|
||||
s_weapon_plasma.anim_impact = &anim_bullet_impact;
|
||||
|
||||
s_weapon_spread = WEAPON_SPREAD;
|
||||
s_weapon_spread.anim_fly = &anim_bullet;
|
||||
s_weapon_spread.anim_impact = &anim_bullet_impact;
|
||||
|
||||
s_weapon_laser = WEAPON_LASER;
|
||||
s_weapon_laser.anim_fly = &anim_bullet;
|
||||
s_weapon_laser.anim_impact = &anim_bullet_impact;
|
||||
|
||||
s_weapon_rocket = WEAPON_ROCKET;
|
||||
s_weapon_rocket.anim_fly = &anim_bullet;
|
||||
s_weapon_rocket.anim_impact = &anim_bullet_impact;
|
||||
|
||||
s_weapon_bounce = WEAPON_BOUNCE;
|
||||
s_weapon_bounce.anim_fly = &anim_bullet;
|
||||
s_weapon_bounce.anim_impact = &anim_bullet_impact;
|
||||
|
||||
s_weapon_enemy_fire = WEAPON_ENEMY_FIRE;
|
||||
s_weapon_enemy_fire.anim_fly = &anim_enemy_bullet;
|
||||
s_weapon_enemy_fire.anim_impact = &anim_bullet_impact;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
* Projectile entity callbacks
|
||||
* ════════════════════════════════════════════════════ */
|
||||
|
||||
static void resolve_wall_bounce(Body *body, const Tilemap *map, int *bounces_left) {
|
||||
/* Check if the center of the projectile is inside a solid tile */
|
||||
int cx = world_to_tile(body->pos.x + body->size.x * 0.5f);
|
||||
int cy = world_to_tile(body->pos.y + body->size.y * 0.5f);
|
||||
|
||||
if (!tilemap_is_solid(map, cx, cy)) return;
|
||||
if (*bounces_left <= 0) return;
|
||||
|
||||
(*bounces_left)--;
|
||||
|
||||
/* Determine bounce axis by checking neighboring tiles */
|
||||
int left_tile = world_to_tile(body->pos.x);
|
||||
int right_tile = world_to_tile(body->pos.x + body->size.x);
|
||||
int top_tile = world_to_tile(body->pos.y);
|
||||
int bot_tile = world_to_tile(body->pos.y + body->size.y);
|
||||
|
||||
bool hit_h = tilemap_is_solid(map, cx, top_tile - 1) == false &&
|
||||
tilemap_is_solid(map, cx, bot_tile + 1) == false;
|
||||
bool hit_v = tilemap_is_solid(map, left_tile - 1, cy) == false &&
|
||||
tilemap_is_solid(map, right_tile + 1, cy) == false;
|
||||
|
||||
if (hit_h || (!hit_h && !hit_v)) {
|
||||
body->vel.y = -body->vel.y;
|
||||
}
|
||||
if (hit_v || (!hit_h && !hit_v)) {
|
||||
body->vel.x = -body->vel.x;
|
||||
}
|
||||
|
||||
/* Push out of solid tile */
|
||||
body->pos.x += body->vel.x * DT;
|
||||
body->pos.y += body->vel.y * DT;
|
||||
}
|
||||
|
||||
static void projectile_update(Entity *self, float dt, const Tilemap *map) {
|
||||
ProjectileData *pd = (ProjectileData *)self->data;
|
||||
if (!pd || !pd->def) return;
|
||||
|
||||
const ProjectileDef *def = pd->def;
|
||||
Body *body = &self->body;
|
||||
|
||||
/* ── Impact animation phase ──────────────── */
|
||||
if (pd->proj_flags & PROJ_IMPACT) {
|
||||
animation_update(&self->anim, dt);
|
||||
if (self->anim.finished) {
|
||||
entity_destroy(s_proj_em, self);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* ── Apply gravity if flagged ────────────── */
|
||||
if (def->flags & PROJ_GRAVITY) {
|
||||
body->vel.y += physics_get_gravity() * def->gravity_scale * dt;
|
||||
if (body->vel.y > MAX_FALL_SPEED) body->vel.y = MAX_FALL_SPEED;
|
||||
}
|
||||
|
||||
/* ── Move ────────────────────────────────── */
|
||||
body->pos.x += body->vel.x * dt;
|
||||
body->pos.y += body->vel.y * dt;
|
||||
|
||||
/* ── Tilemap collision ───────────────────── */
|
||||
int cx = world_to_tile(body->pos.x + body->size.x * 0.5f);
|
||||
int cy = world_to_tile(body->pos.y + body->size.y * 0.5f);
|
||||
if (tilemap_is_solid(map, cx, cy)) {
|
||||
if ((def->flags & PROJ_BOUNCY) && pd->bounces_left > 0) {
|
||||
resolve_wall_bounce(body, map, &pd->bounces_left);
|
||||
} else {
|
||||
projectile_hit(self);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Lifetime ────────────────────────────── */
|
||||
pd->lifetime -= dt;
|
||||
if (pd->lifetime <= 0) {
|
||||
entity_destroy(s_proj_em, self);
|
||||
return;
|
||||
}
|
||||
|
||||
/* ── Homing ──────────────────────────────── */
|
||||
if ((def->flags & PROJ_HOMING) && def->homing_strength > 0 && s_proj_em) {
|
||||
/* Find nearest valid target */
|
||||
bool is_player_proj = (pd->proj_flags & PROJ_FROM_PLAYER) != 0;
|
||||
Entity *best = NULL;
|
||||
float best_dist = 99999.0f;
|
||||
|
||||
Vec2 proj_center = vec2(
|
||||
body->pos.x + body->size.x * 0.5f,
|
||||
body->pos.y + body->size.y * 0.5f
|
||||
);
|
||||
|
||||
for (int i = 0; i < s_proj_em->count; i++) {
|
||||
Entity *e = &s_proj_em->entities[i];
|
||||
if (!e->active || (e->flags & ENTITY_DEAD)) continue;
|
||||
|
||||
/* Player bullets target enemies, enemy bullets target player */
|
||||
if (is_player_proj) {
|
||||
if (e->type != ENT_ENEMY_GRUNT && e->type != ENT_ENEMY_FLYER) continue;
|
||||
} else {
|
||||
if (e->type != ENT_PLAYER) continue;
|
||||
}
|
||||
|
||||
Vec2 target_center = vec2(
|
||||
e->body.pos.x + e->body.size.x * 0.5f,
|
||||
e->body.pos.y + e->body.size.y * 0.5f
|
||||
);
|
||||
float d = vec2_dist(proj_center, target_center);
|
||||
if (d < best_dist) {
|
||||
best_dist = d;
|
||||
best = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
Vec2 target_center = vec2(
|
||||
best->body.pos.x + best->body.size.x * 0.5f,
|
||||
best->body.pos.y + best->body.size.y * 0.5f
|
||||
);
|
||||
Vec2 to_target = vec2_norm(vec2_sub(target_center, proj_center));
|
||||
Vec2 cur_dir = vec2_norm(body->vel);
|
||||
float spd = vec2_len(body->vel);
|
||||
|
||||
/* Steer toward target */
|
||||
Vec2 new_dir = vec2_norm(vec2_lerp(cur_dir, to_target,
|
||||
def->homing_strength * dt));
|
||||
body->vel = vec2_scale(new_dir, spd);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Animation ───────────────────────────── */
|
||||
animation_update(&self->anim, dt);
|
||||
}
|
||||
|
||||
static void projectile_render(Entity *self, const Camera *cam) {
|
||||
(void)cam;
|
||||
Body *body = &self->body;
|
||||
ProjectileData *pd = (ProjectileData *)self->data;
|
||||
|
||||
if (g_spritesheet && self->anim.def) {
|
||||
SDL_Rect src = animation_current_rect(&self->anim);
|
||||
|
||||
/* Center the 16x16 sprite on the smaller hitbox */
|
||||
Vec2 render_pos = vec2(
|
||||
body->pos.x + body->size.x * 0.5f - SPRITE_CELL * 0.5f,
|
||||
body->pos.y + body->size.y * 0.5f - SPRITE_CELL * 0.5f
|
||||
);
|
||||
|
||||
bool flip_x = false;
|
||||
bool flip_y = false;
|
||||
if (pd && !(pd->proj_flags & PROJ_IMPACT)) {
|
||||
flip_x = (body->vel.x < 0);
|
||||
/* Flip vertically for downward-only projectiles */
|
||||
flip_y = (body->vel.y > 0 && fabsf(body->vel.x) < 1.0f);
|
||||
}
|
||||
|
||||
Sprite spr = {
|
||||
.texture = g_spritesheet,
|
||||
.src = src,
|
||||
.pos = render_pos,
|
||||
.size = vec2(SPRITE_CELL, SPRITE_CELL),
|
||||
.flip_x = flip_x,
|
||||
.flip_y = flip_y,
|
||||
.layer = LAYER_ENTITIES,
|
||||
.alpha = 255,
|
||||
};
|
||||
renderer_submit(&spr);
|
||||
} else {
|
||||
/* Fallback colored rectangle */
|
||||
SDL_Color color = (pd && (pd->proj_flags & PROJ_FROM_PLAYER)) ?
|
||||
(SDL_Color){100, 220, 255, 255} :
|
||||
(SDL_Color){255, 180, 50, 255};
|
||||
renderer_draw_rect(body->pos, body->size, color, LAYER_ENTITIES, cam);
|
||||
}
|
||||
}
|
||||
|
||||
static void projectile_destroy_fn(Entity *self) {
|
||||
free(self->data);
|
||||
self->data = NULL;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
* Public API
|
||||
* ════════════════════════════════════════════════════ */
|
||||
|
||||
void projectile_register(EntityManager *em) {
|
||||
entity_register(em, ENT_PROJECTILE, projectile_update,
|
||||
projectile_render, projectile_destroy_fn);
|
||||
s_proj_em = em;
|
||||
init_weapon_defs();
|
||||
}
|
||||
|
||||
Entity *projectile_spawn_def(EntityManager *em, const ProjectileDef *def,
|
||||
Vec2 pos, Vec2 dir, bool from_player) {
|
||||
Entity *e = entity_spawn(em, ENT_PROJECTILE, pos);
|
||||
if (!e) return NULL;
|
||||
|
||||
e->body.size = vec2(def->hitbox_w, def->hitbox_h);
|
||||
e->body.gravity_scale = 0.0f; /* gravity handled manually via def */
|
||||
|
||||
/* Normalize direction and scale to projectile speed */
|
||||
Vec2 norm_dir = vec2_norm(dir);
|
||||
e->body.vel = vec2_scale(norm_dir, def->speed);
|
||||
|
||||
e->health = 1;
|
||||
e->damage = def->damage;
|
||||
|
||||
ProjectileData *pd = calloc(1, sizeof(ProjectileData));
|
||||
pd->def = def;
|
||||
pd->lifetime = def->lifetime;
|
||||
pd->pierces_left = def->pierce_count;
|
||||
pd->bounces_left = def->bounce_count;
|
||||
|
||||
if (from_player) {
|
||||
pd->proj_flags |= PROJ_FROM_PLAYER;
|
||||
}
|
||||
|
||||
if (def->anim_fly) {
|
||||
animation_set(&e->anim, def->anim_fly);
|
||||
}
|
||||
|
||||
e->data = pd;
|
||||
return e;
|
||||
}
|
||||
|
||||
Entity *projectile_spawn_dir(EntityManager *em, Vec2 pos, Vec2 dir, bool from_player) {
|
||||
const ProjectileDef *def = from_player ? &s_weapon_plasma : &s_weapon_enemy_fire;
|
||||
return projectile_spawn_def(em, def, pos, dir, from_player);
|
||||
}
|
||||
|
||||
Entity *projectile_spawn(EntityManager *em, Vec2 pos, bool facing_left, bool from_player) {
|
||||
Vec2 dir = facing_left ? vec2(-1, 0) : vec2(1, 0);
|
||||
return projectile_spawn_dir(em, pos, dir, from_player);
|
||||
}
|
||||
|
||||
void projectile_hit(Entity *proj) {
|
||||
if (!proj || !proj->data) return;
|
||||
ProjectileData *pd = (ProjectileData *)proj->data;
|
||||
|
||||
/* If piercing and has pierces left, don't destroy */
|
||||
if ((pd->def->flags & PROJ_PIERCING) && pd->pierces_left > 0) {
|
||||
pd->pierces_left--;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Emit impact sparks */
|
||||
Vec2 hit_center = vec2(
|
||||
proj->body.pos.x + proj->body.size.x * 0.5f,
|
||||
proj->body.pos.y + proj->body.size.y * 0.5f
|
||||
);
|
||||
SDL_Color spark_color = (pd->proj_flags & PROJ_FROM_PLAYER) ?
|
||||
(SDL_Color){100, 220, 255, 255} : /* cyan for player bullets */
|
||||
(SDL_Color){255, 180, 50, 255}; /* orange for enemy bullets */
|
||||
particle_emit_spark(hit_center, spark_color);
|
||||
|
||||
/* Switch to impact animation */
|
||||
pd->proj_flags |= PROJ_IMPACT;
|
||||
proj->body.vel = vec2(0, 0);
|
||||
|
||||
if (pd->def->anim_impact) {
|
||||
animation_set(&proj->anim, pd->def->anim_impact);
|
||||
proj->anim.current_frame = 0;
|
||||
proj->anim.timer = 0;
|
||||
proj->anim.finished = false;
|
||||
} else {
|
||||
/* No impact animation, destroy immediately */
|
||||
entity_destroy(s_proj_em, proj);
|
||||
}
|
||||
}
|
||||
|
||||
bool projectile_is_impacting(const Entity *proj) {
|
||||
if (!proj || !proj->data) return true;
|
||||
const ProjectileData *pd = (const ProjectileData *)proj->data;
|
||||
return (pd->proj_flags & PROJ_IMPACT) != 0;
|
||||
}
|
||||
|
||||
bool projectile_is_from_player(const Entity *proj) {
|
||||
if (!proj || !proj->data) return false;
|
||||
const ProjectileData *pd = (const ProjectileData *)proj->data;
|
||||
return (pd->proj_flags & PROJ_FROM_PLAYER) != 0;
|
||||
}
|
||||
75
src/game/projectile.h
Normal file
75
src/game/projectile.h
Normal file
@@ -0,0 +1,75 @@
|
||||
#ifndef JNR_PROJECTILE_H
|
||||
#define JNR_PROJECTILE_H
|
||||
|
||||
#include "engine/entity.h"
|
||||
#include "engine/camera.h"
|
||||
#include "engine/tilemap.h"
|
||||
#include "engine/animation.h"
|
||||
|
||||
/* ── Projectile behavior flags ─────────────────────── */
|
||||
#define PROJ_FROM_PLAYER (1 << 0) /* owned by player (vs enemy) */
|
||||
#define PROJ_IMPACT (1 << 1) /* currently playing impact anim */
|
||||
#define PROJ_PIERCING (1 << 2) /* passes through enemies */
|
||||
#define PROJ_BOUNCY (1 << 3) /* bounces off walls */
|
||||
#define PROJ_GRAVITY (1 << 4) /* affected by gravity */
|
||||
#define PROJ_HOMING (1 << 5) /* tracks nearest target */
|
||||
|
||||
/* ── Projectile definition (weapon type) ───────────── */
|
||||
/* Describes how a class of projectile behaves. */
|
||||
/* These are static data - shared by all instances. */
|
||||
typedef struct ProjectileDef {
|
||||
const char *name; /* e.g. "plasma", "rocket" */
|
||||
float speed; /* travel speed (px/s) */
|
||||
int damage; /* damage per hit */
|
||||
float lifetime; /* seconds before auto-destroy */
|
||||
float gravity_scale; /* 0 = no gravity, 1 = full */
|
||||
int pierce_count; /* 0 = destroy on first hit */
|
||||
int bounce_count; /* 0 = destroy on wall hit */
|
||||
float homing_strength;/* 0 = none, higher = tighter turn */
|
||||
float hitbox_w; /* collision box width */
|
||||
float hitbox_h; /* collision box height */
|
||||
uint32_t flags; /* PROJ_* behavior flags */
|
||||
const AnimDef *anim_fly; /* animation while flying */
|
||||
const AnimDef *anim_impact; /* animation on hit (NULL = instant) */
|
||||
} ProjectileDef;
|
||||
|
||||
/* ── Per-instance data ─────────────────────────────── */
|
||||
typedef struct ProjectileData {
|
||||
const ProjectileDef *def; /* shared definition */
|
||||
uint32_t proj_flags; /* runtime flags (PROJ_FROM_PLAYER, PROJ_IMPACT) */
|
||||
float lifetime; /* remaining lifetime */
|
||||
int pierces_left; /* remaining pierces */
|
||||
int bounces_left; /* remaining bounces */
|
||||
} ProjectileData;
|
||||
|
||||
/* ── Built-in weapon definitions ───────────────────── */
|
||||
extern const ProjectileDef WEAPON_PLASMA; /* player default */
|
||||
extern const ProjectileDef WEAPON_SPREAD; /* 3-way fan */
|
||||
extern const ProjectileDef WEAPON_LASER; /* fast, piercing */
|
||||
extern const ProjectileDef WEAPON_ROCKET; /* slow, high damage */
|
||||
extern const ProjectileDef WEAPON_BOUNCE; /* ricochets off walls */
|
||||
extern const ProjectileDef WEAPON_ENEMY_FIRE; /* enemy fireball */
|
||||
|
||||
/* ── API ───────────────────────────────────────────── */
|
||||
void projectile_register(EntityManager *em);
|
||||
|
||||
/* Spawn a projectile from a definition + direction vector */
|
||||
Entity *projectile_spawn_def(EntityManager *em, const ProjectileDef *def,
|
||||
Vec2 pos, Vec2 dir, bool from_player);
|
||||
|
||||
/* Convenience: spawn with default player/enemy weapon */
|
||||
Entity *projectile_spawn_dir(EntityManager *em, Vec2 pos, Vec2 dir, bool from_player);
|
||||
|
||||
/* Legacy convenience: horizontal shot */
|
||||
Entity *projectile_spawn(EntityManager *em, Vec2 pos, bool facing_left, bool from_player);
|
||||
|
||||
/* Trigger impact on a projectile (used by collision system) */
|
||||
void projectile_hit(Entity *proj);
|
||||
|
||||
/* Check if a projectile is currently impacting (not collidable) */
|
||||
bool projectile_is_impacting(const Entity *proj);
|
||||
|
||||
/* Check if a projectile belongs to the player */
|
||||
bool projectile_is_from_player(const Entity *proj);
|
||||
|
||||
#endif /* JNR_PROJECTILE_H */
|
||||
728
src/game/sprites.c
Normal file
728
src/game/sprites.c
Normal file
@@ -0,0 +1,728 @@
|
||||
#include "game/sprites.h"
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
SDL_Texture *g_spritesheet = NULL;
|
||||
|
||||
/* ── Pixel art data ─────────────────────────────────
|
||||
* Each sprite is defined as a 16x16 grid of hex color values.
|
||||
* 0x00000000 = transparent
|
||||
* Colors are RGBA packed as 0xRRGGBBAA
|
||||
*/
|
||||
|
||||
#define T 0x00000000 /* transparent */
|
||||
|
||||
/* Color palette */
|
||||
#define BLK 0x1a1a2eFF /* black/dark */
|
||||
#define WHT 0xeeeeeaFF /* white */
|
||||
#define SKN 0xe8b796FF /* skin tone */
|
||||
#define SKD 0xc48e6aFF /* skin dark */
|
||||
#define BLU 0x4a7cbdFF /* blue */
|
||||
#define BLD 0x365e8fFF /* blue dark */
|
||||
#define BLL 0x6fa8dcFF /* blue light */
|
||||
#define RED 0xd94444FF /* red */
|
||||
#define RDD 0xa83232FF /* red dark */
|
||||
#define RDL 0xff6666FF /* red light */
|
||||
#define GRN 0x4caf50FF /* green */
|
||||
#define GRD 0x388e3cFF /* green dark */
|
||||
#define YLW 0xffd54fFF /* yellow */
|
||||
#define YLD 0xd4a017FF /* yellow dark */
|
||||
#define ORG 0xff9800FF /* orange */
|
||||
#define ORD 0xcc7a00FF /* orange dark */
|
||||
#define PRP 0x9c27b0FF /* purple */
|
||||
#define PRD 0x7b1fa2FF /* purple dark */
|
||||
#define PRL 0xce93d8FF /* purple light */
|
||||
#define GRY 0x888888FF /* grey */
|
||||
#define GYD 0x555555FF /* grey dark */
|
||||
#define GYL 0xbbbbbbFF /* grey light */
|
||||
#define BRN 0x8d6e63FF /* brown */
|
||||
#define BRD 0x5d4037FF /* brown dark */
|
||||
#define CYN 0x4dd0e1FF /* cyan */
|
||||
#define CYD 0x00acc1FF /* cyan dark */
|
||||
|
||||
/* ── Player sprites ────────────────────────────────── */
|
||||
|
||||
/* Player idle frame 1 - heroic adventurer */
|
||||
static const uint32_t player_idle1[16*16] = {
|
||||
T, T, T, T, T, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLL, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLK, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, SKN, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, BLK, SKN, SKN, BLK, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKN, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKD, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, BLU, BLU, BLU, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, T, T, T, T, T,
|
||||
T, T, SKN, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, SKN, T, T, T, T,
|
||||
T, T, SKN, T, BLU, BLU, BLU, BLU, BLU, BLU, T, SKN, T, T, T, T,
|
||||
T, T, T, T, BLD, BLD, BLU, BLU, BLD, BLD, T, T, T, T, T, T,
|
||||
T, T, T, T, BLD, BLD, T, T, BLD, BLD, T, T, T, T, T, T,
|
||||
T, T, T, T, BRN, BRN, T, T, BRN, BRN, T, T, T, T, T, T,
|
||||
T, T, T, T, BRD, BRD, T, T, BRD, BRD, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Player idle frame 2 - slight bob */
|
||||
static const uint32_t player_idle2[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLL, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLK, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, SKN, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, BLK, SKN, SKN, BLK, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKN, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKD, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, BLU, BLU, BLU, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, T, T, T, T, T,
|
||||
T, T, SKN, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, SKN, T, T, T, T,
|
||||
T, T, T, T, BLD, BLD, BLU, BLU, BLD, BLD, T, T, T, T, T, T,
|
||||
T, T, T, T, BLD, BLD, T, T, BLD, BLD, T, T, T, T, T, T,
|
||||
T, T, T, T, BRN, BRN, T, T, BRN, BRN, T, T, T, T, T, T,
|
||||
T, T, T, T, BRD, BRD, T, T, BRD, BRD, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Player run frame 1 */
|
||||
static const uint32_t player_run1[16*16] = {
|
||||
T, T, T, T, T, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLL, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLK, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, SKN, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, BLK, SKN, SKN, BLK, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKN, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKD, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, BLU, BLU, BLU, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T,
|
||||
T, T, SKN, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, T, T, T, T, T,
|
||||
T, T, SKN, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, SKN, T, T, T, T,
|
||||
T, T, T, T, BLU, BLU, BLU, BLU, BLU, BLU, T, SKN, T, T, T, T,
|
||||
T, T, T, T, T, BLD, BLU, BLU, BLD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, BLD, T, T, T, BLD, T, T, T, T, T, T,
|
||||
T, T, T, T, BRN, BRN, T, T, T, BRN, BRN, T, T, T, T, T,
|
||||
T, T, T, BRD, BRD, T, T, T, T, T, BRD, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Player run frame 2 */
|
||||
static const uint32_t player_run2[16*16] = {
|
||||
T, T, T, T, T, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLL, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLK, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, SKN, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, BLK, SKN, SKN, BLK, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKN, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKD, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, BLU, BLU, BLU, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, SKN, T, T, T, T,
|
||||
T, T, T, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, SKN, T, T, T, T,
|
||||
T, T, SKN, T, BLU, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLD, T, BLU, BLU, T, BLD, T, T, T, T, T, T,
|
||||
T, T, T, T, BLD, T, T, T, BLD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, BRN, T, BRN, BRN, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, BRD, T, BRD, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Player run frame 3 */
|
||||
static const uint32_t player_run3[16*16] = {
|
||||
T, T, T, T, T, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLL, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLK, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, SKN, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, BLK, SKN, SKN, BLK, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKN, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKD, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, BLU, BLU, BLU, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, T, T, T, T, T,
|
||||
T, T, SKN, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, SKN, T, T, T, T,
|
||||
T, T, SKN, T, BLU, BLU, BLU, BLU, BLU, BLU, T, SKN, T, T, T, T,
|
||||
T, T, T, T, BLD, BLD, BLU, BLU, BLD, BLD, T, T, T, T, T, T,
|
||||
T, T, T, BLD, T, T, T, T, T, T, BLD, T, T, T, T, T,
|
||||
T, T, BRN, BRN, T, T, T, T, T, BRN, BRN, T, T, T, T, T,
|
||||
T, T, T, BRD, T, T, T, T, BRD, BRD, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Player run frame 4 - same as run2 mirrored leg positions */
|
||||
static const uint32_t player_run4[16*16] = {
|
||||
T, T, T, T, T, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLL, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLK, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, SKN, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, BLK, SKN, SKN, BLK, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKN, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKD, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, BLU, BLU, BLU, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T,
|
||||
T, T, SKN, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, T, T, T, T, T,
|
||||
T, T, T, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, SKN, T, T, T, T,
|
||||
T, T, SKN, T, BLU, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, T, BLD, BLU, BLU, BLD, T, T, T, T, T, T, T,
|
||||
T, T, T, BLD, T, T, T, T, T, BLD, T, T, T, T, T, T,
|
||||
T, T, BRN, BRN, T, T, T, T, BRN, BRN, T, T, T, T, T, T,
|
||||
T, T, BRD, T, T, T, T, T, T, BRD, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Player jump */
|
||||
static const uint32_t player_jump[16*16] = {
|
||||
T, T, T, T, T, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLL, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLK, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, SKN, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, BLK, SKN, SKN, BLK, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKN, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, SKN, T, SKN, SKN, SKD, SKD, SKN, SKN, T, SKN, T, T, T, T,
|
||||
T, T, SKN, BLU, BLU, BLU, BLU, BLU, BLU, BLU, BLU, SKN, T, T, T, T,
|
||||
T, T, T, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, T, T, T, T, T,
|
||||
T, T, T, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLD, BLD, T, T, BLD, BLD, T, T, T, T, T, T,
|
||||
T, T, T, BRN, BRN, T, T, T, BRN, BRN, T, T, T, T, T, T,
|
||||
T, T, T, BRD, BRD, T, T, T, T, BRN, BRN, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, BRD, BRD, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Player fall */
|
||||
static const uint32_t player_fall[16*16] = {
|
||||
T, T, T, T, T, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLU, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLL, BLL, BLL, BLL, BLL, BLU, T, T, T, T, T,
|
||||
T, T, T, T, BLK, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, SKN, SKN, SKN, SKN, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, BLK, SKN, BLK, SKN, SKN, BLK, SKN, BLK, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKN, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, T, T, T, SKN, SKN, SKD, SKD, SKN, SKN, T, T, T, T, T, T,
|
||||
T, SKN, SKN, BLU, BLU, BLU, BLU, BLU, BLU, BLU, BLU, SKN, SKN, T, T, T,
|
||||
T, T, T, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, T, T, T, T, T,
|
||||
T, T, T, BLD, BLU, BLU, BLU, BLU, BLU, BLU, BLD, T, T, T, T, T,
|
||||
T, T, T, T, BLU, BLU, BLU, BLU, BLU, BLU, T, T, T, T, T, T,
|
||||
T, T, T, T, BLD, BLD, T, T, BLD, BLD, T, T, T, T, T, T,
|
||||
T, T, T, T, BRN, BRN, T, T, T, BRN, T, T, T, T, T, T,
|
||||
T, T, T, BRN, BRD, T, T, T, T, BRN, T, T, T, T, T, T,
|
||||
T, T, T, BRD, T, T, T, T, T, BRD, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* ── Grunt enemy sprites ───────────────────────────── */
|
||||
/* Grunt: Red spiky enemy that patrols back and forth */
|
||||
|
||||
static const uint32_t grunt_idle1[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, RED, T, T, T, T, T, RED, T, T, T, T, T,
|
||||
T, T, T, T, RED, RED, T, T, T, RED, RED, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, WHT, BLK, RED, WHT, BLK, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, BLK, BLK, BLK, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, T, T, RDD, RED, RED, RDD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RDD, T, T, RDD, RDD, T, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RDD, T, T, RDD, RDD, T, T, T, T, T, T,
|
||||
T, T, T, T, BLK, BLK, T, T, BLK, BLK, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
static const uint32_t grunt_idle2[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, RED, T, T, T, T, T, RED, T, T, T, T, T,
|
||||
T, T, T, T, RED, RED, T, T, T, RED, RED, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, WHT, BLK, RED, WHT, BLK, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, BLK, BLK, BLK, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, T, T, RDD, RED, RED, RDD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RDD, T, T, RDD, RDD, T, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RDD, T, T, RDD, RDD, T, T, T, T, T, T,
|
||||
T, T, T, T, BLK, BLK, T, T, BLK, BLK, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Grunt walk frames (same body, different leg positions) */
|
||||
static const uint32_t grunt_walk1[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, RED, T, T, T, T, T, RED, T, T, T, T, T,
|
||||
T, T, T, T, RED, RED, T, T, T, RED, RED, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, WHT, BLK, RED, WHT, BLK, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, BLK, BLK, BLK, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, T, T, RDD, RED, RED, RDD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, RDD, T, T, RDD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, RDD, T, T, T, T, RDD, T, T, T, T, T, T,
|
||||
T, T, T, T, BLK, T, T, T, T, BLK, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
static const uint32_t grunt_walk2[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, RED, T, T, T, T, T, RED, T, T, T, T, T,
|
||||
T, T, T, T, RED, RED, T, T, T, RED, RED, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, WHT, BLK, RED, WHT, BLK, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, BLK, BLK, BLK, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, T, RDD, RED, RED, RED, RED, RED, RDD, T, T, T, T, T,
|
||||
T, T, T, T, T, RDD, RED, RED, RDD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, RDD, T, T, T, T, RDD, T, T, T, T, T, T,
|
||||
T, T, T, T, T, RDD, T, T, RDD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, BLK, T, T, BLK, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Grunt death frame */
|
||||
static const uint32_t grunt_death[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T,
|
||||
T, T, RDD, RED, RED, BLK, BLK, RED, BLK, BLK, RED, RED, RDD, T, T, T,
|
||||
T, T, RDD, RED, RED, RED, RED, BLK, RED, RED, RED, RED, RDD, T, T, T,
|
||||
T, T, T, RDD, RED, RED, RED, RED, RED, RED, RED, RDD, T, T, T, T,
|
||||
T, T, T, T, RDD, RDD, RDD, RDD, RDD, RDD, RDD, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* ── Flyer enemy sprites ───────────────────────────── */
|
||||
/* Flying enemy: Purple bat-like creature */
|
||||
|
||||
static const uint32_t flyer_idle1[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, PRP, PRP, PRP, PRP, PRP, T, T, T, T, T, T,
|
||||
T, T, T, T, PRP, PRP, PRP, PRP, PRP, PRP, PRP, T, T, T, T, T,
|
||||
T, T, T, PRP, PRP, YLW, BLK, PRP, YLW, BLK, PRP, PRP, T, T, T, T,
|
||||
T, T, T, PRD, PRP, PRP, PRP, PRP, PRP, PRP, PRP, PRD, T, T, T, T,
|
||||
T, T, T, PRD, PRP, PRP, BLK, BLK, PRP, PRP, PRP, PRD, T, T, T, T,
|
||||
T, T, PRL, PRD, PRP, PRP, PRP, PRP, PRP, PRP, PRP, PRD, PRL, T, T, T,
|
||||
T, PRL, PRL, T, PRD, PRP, PRP, PRP, PRP, PRP, PRD, T, PRL, PRL, T, T,
|
||||
PRL, PRL, T, T, T, PRD, PRP, PRP, PRD, T, T, T, T, PRL, PRL, T,
|
||||
PRL, T, T, T, T, T, PRD, PRD, T, T, T, T, T, T, PRL, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
static const uint32_t flyer_idle2[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, PRP, PRP, PRP, PRP, PRP, T, T, T, T, T, T,
|
||||
T, T, T, T, PRP, PRP, PRP, PRP, PRP, PRP, PRP, T, T, T, T, T,
|
||||
T, T, T, PRP, PRP, YLW, BLK, PRP, YLW, BLK, PRP, PRP, T, T, T, T,
|
||||
T, T, T, PRD, PRP, PRP, PRP, PRP, PRP, PRP, PRP, PRD, T, T, T, T,
|
||||
T, T, T, PRD, PRP, PRP, BLK, BLK, PRP, PRP, PRP, PRD, T, T, T, T,
|
||||
T, T, T, PRD, PRP, PRP, PRP, PRP, PRP, PRP, PRP, PRD, T, T, T, T,
|
||||
T, PRL, T, T, PRD, PRP, PRP, PRP, PRP, PRP, PRD, T, T, PRL, T, T,
|
||||
PRL, PRL, PRL, T, T, PRD, PRP, PRP, PRD, T, T, T, PRL, PRL, PRL, T,
|
||||
T, T, PRL, PRL, T, T, PRD, PRD, T, T, T, PRL, PRL, T, T, T,
|
||||
T, T, T, PRL, PRL, T, T, T, T, T, PRL, PRL, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Flyer death */
|
||||
static const uint32_t flyer_death[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, PRP, PRP, PRP, PRP, PRP, T, T, T, T, T, T,
|
||||
T, T, T, T, PRP, PRP, PRP, PRP, PRP, PRP, PRP, T, T, T, T, T,
|
||||
T, T, T, PRP, PRP, BLK, BLK, PRP, BLK, BLK, PRP, PRP, T, T, T, T,
|
||||
T, T, T, PRD, PRP, PRP, PRP, PRP, PRP, PRP, PRP, PRD, T, T, T, T,
|
||||
T, T, T, PRD, PRP, PRP, PRP, BLK, PRP, PRP, PRP, PRD, T, T, T, T,
|
||||
T, T, T, T, PRD, PRP, PRP, PRP, PRP, PRP, PRD, T, T, T, T, T,
|
||||
T, T, T, PRL, T, PRD, PRP, PRP, PRD, T, PRL, T, T, T, T, T,
|
||||
T, T, PRL, T, T, T, PRD, PRD, T, T, T, PRL, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* ── Projectile sprites ────────────────────────────── */
|
||||
|
||||
/* Player bullet frame 1 */
|
||||
static const uint32_t bullet1[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, CYD, CYN, CYN, CYD, T, T, T, T, T, T,
|
||||
T, T, T, T, T, CYD, CYN, WHT, WHT, CYN, CYD, T, T, T, T, T,
|
||||
T, T, T, T, T, CYN, WHT, WHT, WHT, WHT, CYN, T, T, T, T, T,
|
||||
T, T, T, T, T, CYN, WHT, WHT, WHT, WHT, CYN, T, T, T, T, T,
|
||||
T, T, T, T, T, CYD, CYN, WHT, WHT, CYN, CYD, T, T, T, T, T,
|
||||
T, T, T, T, T, T, CYD, CYN, CYN, CYD, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Player bullet frame 2 - glow variation */
|
||||
static const uint32_t bullet2[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, CYD, CYD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, CYN, WHT, WHT, CYN, T, T, T, T, T, T,
|
||||
T, T, T, T, T, CYN, WHT, WHT, WHT, WHT, CYN, T, T, T, T, T,
|
||||
T, T, T, T, CYD, WHT, WHT, WHT, WHT, WHT, WHT, CYD, T, T, T, T,
|
||||
T, T, T, T, CYD, WHT, WHT, WHT, WHT, WHT, WHT, CYD, T, T, T, T,
|
||||
T, T, T, T, T, CYN, WHT, WHT, WHT, WHT, CYN, T, T, T, T, T,
|
||||
T, T, T, T, T, T, CYN, WHT, WHT, CYN, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, CYD, CYD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Impact effect frame 1 */
|
||||
static const uint32_t impact1[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, WHT, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, WHT, T, T, T, WHT, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, CYN, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, CYN, CYN, WHT, CYN, CYN, T, T, T, T, T, T,
|
||||
T, T, T, T, T, CYN, WHT, WHT, WHT, CYN, T, T, T, T, T, T,
|
||||
T, T, WHT, T, CYN, WHT, WHT, WHT, WHT, WHT, CYN, T, WHT, T, T, T,
|
||||
T, T, T, T, T, CYN, WHT, WHT, WHT, CYN, T, T, T, T, T, T,
|
||||
T, T, T, T, T, CYN, CYN, WHT, CYN, CYN, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, CYN, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, WHT, T, T, T, WHT, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, WHT, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Impact effect frame 2 - expanding */
|
||||
static const uint32_t impact2[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, CYD, T, T, T, T, T, CYD, T, T, T, T, T,
|
||||
T, T, T, T, T, T, CYN, T, CYN, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, CYN, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, CYN, WHT, CYN, T, T, T, T, T, T, T,
|
||||
T, T, T, CYN, T, CYN, T, WHT, T, CYN, T, CYN, T, T, T, T,
|
||||
T, T, T, T, CYN, WHT, WHT, WHT, WHT, WHT, CYN, T, T, T, T, T,
|
||||
T, T, T, CYN, T, CYN, T, WHT, T, CYN, T, CYN, T, T, T, T,
|
||||
T, T, T, T, T, T, CYN, WHT, CYN, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, CYN, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, CYN, T, CYN, T, T, T, T, T, T, T,
|
||||
T, T, T, T, CYD, T, T, T, T, T, CYD, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Impact effect frame 3 - fading */
|
||||
static const uint32_t impact3[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, CYD, T, T, T, T, T, T, T, CYD, T, T, T, T,
|
||||
T, T, T, T, T, T, T, CYD, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, CYD, T, CYD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, CYD, T, T, T, CYD, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, CYD, T, CYD, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, CYD, T, T, T, T, T, T, T, T,
|
||||
T, T, T, CYD, T, T, T, T, T, T, T, CYD, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
/* Enemy bullet */
|
||||
static const uint32_t enemy_bullet1[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, ORD, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, ORG, YLW, ORG, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, ORD, YLW, WHT, YLW, ORD, T, T, T, T, T, T,
|
||||
T, T, T, T, T, ORD, YLW, WHT, YLW, ORD, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, ORG, YLW, ORG, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, ORD, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
static const uint32_t enemy_bullet2[16*16] = {
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, ORD, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, ORG, YLW, ORG, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, ORG, YLW, WHT, YLW, ORG, T, T, T, T, T, T,
|
||||
T, T, T, T, ORD, YLW, WHT, WHT, WHT, YLW, ORD, T, T, T, T, T,
|
||||
T, T, T, T, ORD, YLW, WHT, WHT, WHT, YLW, ORD, T, T, T, T, T,
|
||||
T, T, T, T, T, ORG, YLW, WHT, YLW, ORG, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, ORG, YLW, ORG, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, ORD, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
|
||||
};
|
||||
|
||||
|
||||
/* ── Spritesheet generation ────────────────────────── */
|
||||
|
||||
/* All sprite definitions for the sheet - row, column, pixel data */
|
||||
typedef struct SpriteDef {
|
||||
int row;
|
||||
int col;
|
||||
const uint32_t *pixels;
|
||||
} SpriteDef;
|
||||
|
||||
static const SpriteDef s_sprite_defs[] = {
|
||||
/* Row 0: Player */
|
||||
{0, 0, player_idle1},
|
||||
{0, 1, player_idle2},
|
||||
{0, 2, player_run1},
|
||||
{0, 3, player_run2},
|
||||
{0, 4, player_run3},
|
||||
{0, 5, player_run4},
|
||||
{0, 6, player_jump},
|
||||
{0, 7, player_fall},
|
||||
|
||||
/* Row 1: Grunt */
|
||||
{1, 0, grunt_idle1},
|
||||
{1, 1, grunt_idle2},
|
||||
{1, 2, grunt_walk1},
|
||||
{1, 3, grunt_walk2},
|
||||
{1, 4, grunt_walk1}, /* reuse frame */
|
||||
{1, 5, grunt_walk2}, /* reuse frame */
|
||||
{1, 6, grunt_death},
|
||||
|
||||
/* Row 2: Flyer */
|
||||
{2, 0, flyer_idle1},
|
||||
{2, 1, flyer_idle2},
|
||||
{2, 2, flyer_idle1}, /* reuse for fly anim */
|
||||
{2, 3, flyer_idle2},
|
||||
{2, 4, flyer_idle1},
|
||||
{2, 5, flyer_idle2},
|
||||
{2, 6, flyer_death},
|
||||
|
||||
/* Row 3: Projectiles */
|
||||
{3, 0, bullet1},
|
||||
{3, 1, bullet2},
|
||||
{3, 2, impact1},
|
||||
{3, 3, impact2},
|
||||
{3, 4, impact3},
|
||||
{3, 5, enemy_bullet1},
|
||||
{3, 6, enemy_bullet2},
|
||||
};
|
||||
|
||||
#define SHEET_COLS 8
|
||||
#define SHEET_ROWS 4
|
||||
|
||||
SDL_Texture *sprites_generate(SDL_Renderer *renderer) {
|
||||
int w = SHEET_COLS * SPRITE_CELL;
|
||||
int h = SHEET_ROWS * SPRITE_CELL;
|
||||
|
||||
SDL_Surface *surface = SDL_CreateRGBSurfaceWithFormat(
|
||||
0, w, h, 32, SDL_PIXELFORMAT_RGBA8888);
|
||||
if (!surface) {
|
||||
fprintf(stderr, "Failed to create sprite surface: %s\n", SDL_GetError());
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Clear to transparent */
|
||||
SDL_FillRect(surface, NULL, 0);
|
||||
|
||||
/* Blit each sprite definition */
|
||||
int count = sizeof(s_sprite_defs) / sizeof(s_sprite_defs[0]);
|
||||
for (int i = 0; i < count; i++) {
|
||||
const SpriteDef *def = &s_sprite_defs[i];
|
||||
int ox = def->col * SPRITE_CELL;
|
||||
int oy = def->row * SPRITE_CELL;
|
||||
|
||||
uint32_t *pixels = (uint32_t *)surface->pixels;
|
||||
int pitch = surface->pitch / 4; /* pitch in uint32_t */
|
||||
|
||||
for (int y = 0; y < SPRITE_CELL; y++) {
|
||||
for (int x = 0; x < SPRITE_CELL; x++) {
|
||||
uint32_t rgba = def->pixels[y * SPRITE_CELL + x];
|
||||
if (rgba == 0) continue; /* transparent */
|
||||
|
||||
/* Convert from our RRGGBBAA to surface format */
|
||||
uint8_t r = (rgba >> 24) & 0xFF;
|
||||
uint8_t g = (rgba >> 16) & 0xFF;
|
||||
uint8_t b = (rgba >> 8) & 0xFF;
|
||||
uint8_t a = (rgba) & 0xFF;
|
||||
|
||||
pixels[(oy + y) * pitch + (ox + x)] =
|
||||
SDL_MapRGBA(surface->format, r, g, b, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SDL_Texture *tex = SDL_CreateTextureFromSurface(renderer, surface);
|
||||
SDL_FreeSurface(surface);
|
||||
|
||||
if (!tex) {
|
||||
fprintf(stderr, "Failed to create sprite texture: %s\n", SDL_GetError());
|
||||
return NULL;
|
||||
}
|
||||
|
||||
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
|
||||
|
||||
g_spritesheet = tex;
|
||||
printf("Generated spritesheet: %dx%d (%d sprites)\n", w, h, count);
|
||||
return tex;
|
||||
}
|
||||
|
||||
|
||||
/* ── Animation definitions ─────────────────────────── */
|
||||
|
||||
#define FRAME(col, row, dur) \
|
||||
{ .src = {(col)*SPRITE_CELL, (row)*SPRITE_CELL, SPRITE_CELL, SPRITE_CELL}, .duration = (dur) }
|
||||
|
||||
/* Player */
|
||||
static AnimFrame s_player_idle_frames[] = {
|
||||
FRAME(0, 0, 0.6f),
|
||||
FRAME(1, 0, 0.6f),
|
||||
};
|
||||
|
||||
static AnimFrame s_player_run_frames[] = {
|
||||
FRAME(2, 0, 0.1f),
|
||||
FRAME(3, 0, 0.1f),
|
||||
FRAME(4, 0, 0.1f),
|
||||
FRAME(5, 0, 0.1f),
|
||||
};
|
||||
|
||||
static AnimFrame s_player_jump_frames[] = {
|
||||
FRAME(6, 0, 1.0f),
|
||||
};
|
||||
|
||||
static AnimFrame s_player_fall_frames[] = {
|
||||
FRAME(7, 0, 1.0f),
|
||||
};
|
||||
|
||||
/* Grunt */
|
||||
static AnimFrame s_grunt_idle_frames[] = {
|
||||
FRAME(0, 1, 0.5f),
|
||||
FRAME(1, 1, 0.5f),
|
||||
};
|
||||
|
||||
static AnimFrame s_grunt_walk_frames[] = {
|
||||
FRAME(2, 1, 0.15f),
|
||||
FRAME(3, 1, 0.15f),
|
||||
FRAME(4, 1, 0.15f),
|
||||
FRAME(5, 1, 0.15f),
|
||||
};
|
||||
|
||||
static AnimFrame s_grunt_death_frames[] = {
|
||||
FRAME(6, 1, 0.3f),
|
||||
};
|
||||
|
||||
/* Flyer */
|
||||
static AnimFrame s_flyer_idle_frames[] = {
|
||||
FRAME(0, 2, 0.3f),
|
||||
FRAME(1, 2, 0.3f),
|
||||
};
|
||||
|
||||
static AnimFrame s_flyer_fly_frames[] = {
|
||||
FRAME(2, 2, 0.12f),
|
||||
FRAME(3, 2, 0.12f),
|
||||
FRAME(4, 2, 0.12f),
|
||||
FRAME(5, 2, 0.12f),
|
||||
};
|
||||
|
||||
static AnimFrame s_flyer_death_frames[] = {
|
||||
FRAME(6, 2, 0.3f),
|
||||
};
|
||||
|
||||
/* Projectiles */
|
||||
static AnimFrame s_bullet_frames[] = {
|
||||
FRAME(0, 3, 0.08f),
|
||||
FRAME(1, 3, 0.08f),
|
||||
};
|
||||
|
||||
static AnimFrame s_impact_frames[] = {
|
||||
FRAME(2, 3, 0.05f),
|
||||
FRAME(3, 3, 0.05f),
|
||||
FRAME(4, 3, 0.08f),
|
||||
};
|
||||
|
||||
static AnimFrame s_enemy_bullet_frames[] = {
|
||||
FRAME(5, 3, 0.1f),
|
||||
FRAME(6, 3, 0.1f),
|
||||
};
|
||||
|
||||
/* Exported animation definitions */
|
||||
AnimDef anim_player_idle;
|
||||
AnimDef anim_player_run;
|
||||
AnimDef anim_player_jump;
|
||||
AnimDef anim_player_fall;
|
||||
|
||||
AnimDef anim_grunt_idle;
|
||||
AnimDef anim_grunt_walk;
|
||||
AnimDef anim_grunt_death;
|
||||
|
||||
AnimDef anim_flyer_idle;
|
||||
AnimDef anim_flyer_fly;
|
||||
AnimDef anim_flyer_death;
|
||||
|
||||
AnimDef anim_bullet;
|
||||
AnimDef anim_bullet_impact;
|
||||
AnimDef anim_enemy_bullet;
|
||||
|
||||
void sprites_init_anims(void) {
|
||||
anim_player_idle = (AnimDef){s_player_idle_frames, 2, true, NULL};
|
||||
anim_player_run = (AnimDef){s_player_run_frames, 4, true, NULL};
|
||||
anim_player_jump = (AnimDef){s_player_jump_frames, 1, false, NULL};
|
||||
anim_player_fall = (AnimDef){s_player_fall_frames, 1, false, NULL};
|
||||
|
||||
anim_grunt_idle = (AnimDef){s_grunt_idle_frames, 2, true, NULL};
|
||||
anim_grunt_walk = (AnimDef){s_grunt_walk_frames, 4, true, NULL};
|
||||
anim_grunt_death = (AnimDef){s_grunt_death_frames, 1, false, NULL};
|
||||
|
||||
anim_flyer_idle = (AnimDef){s_flyer_idle_frames, 2, true, NULL};
|
||||
anim_flyer_fly = (AnimDef){s_flyer_fly_frames, 4, true, NULL};
|
||||
anim_flyer_death = (AnimDef){s_flyer_death_frames, 1, false, NULL};
|
||||
|
||||
anim_bullet = (AnimDef){s_bullet_frames, 2, true, NULL};
|
||||
anim_bullet_impact = (AnimDef){s_impact_frames, 3, false, NULL};
|
||||
anim_enemy_bullet = (AnimDef){s_enemy_bullet_frames, 2, true, NULL};
|
||||
}
|
||||
49
src/game/sprites.h
Normal file
49
src/game/sprites.h
Normal file
@@ -0,0 +1,49 @@
|
||||
#ifndef JNR_SPRITES_H
|
||||
#define JNR_SPRITES_H
|
||||
|
||||
#include <SDL2/SDL.h>
|
||||
#include "engine/animation.h"
|
||||
|
||||
/* Sprite sheet layout:
|
||||
* Each sprite cell is 16x16 pixels.
|
||||
* The sheet is organized in rows:
|
||||
*
|
||||
* Row 0: Player idle (2 frames), run (4 frames), jump (1), fall (1)
|
||||
* Row 1: Grunt idle (2 frames), walk (4 frames), death (2 frames)
|
||||
* Row 2: Flyer idle (2 frames), fly (4 frames), death (2 frames)
|
||||
* Row 3: Projectiles: bullet (2 frames), impact (3 frames), enemy bullet (2)
|
||||
*/
|
||||
|
||||
#define SPRITE_CELL 16
|
||||
|
||||
/* Generate the spritesheet texture (call after renderer is ready) */
|
||||
SDL_Texture *sprites_generate(SDL_Renderer *renderer);
|
||||
|
||||
/* ── Player animations ─────────────────────────── */
|
||||
extern AnimDef anim_player_idle;
|
||||
extern AnimDef anim_player_run;
|
||||
extern AnimDef anim_player_jump;
|
||||
extern AnimDef anim_player_fall;
|
||||
|
||||
/* ── Grunt animations ──────────────────────────── */
|
||||
extern AnimDef anim_grunt_idle;
|
||||
extern AnimDef anim_grunt_walk;
|
||||
extern AnimDef anim_grunt_death;
|
||||
|
||||
/* ── Flyer animations ──────────────────────────── */
|
||||
extern AnimDef anim_flyer_idle;
|
||||
extern AnimDef anim_flyer_fly;
|
||||
extern AnimDef anim_flyer_death;
|
||||
|
||||
/* ── Projectile animations ─────────────────────── */
|
||||
extern AnimDef anim_bullet;
|
||||
extern AnimDef anim_bullet_impact;
|
||||
extern AnimDef anim_enemy_bullet;
|
||||
|
||||
/* Initialize all animation definitions */
|
||||
void sprites_init_anims(void);
|
||||
|
||||
/* The global spritesheet texture */
|
||||
extern SDL_Texture *g_spritesheet;
|
||||
|
||||
#endif /* JNR_SPRITES_H */
|
||||
52
src/main.c
Normal file
52
src/main.c
Normal file
@@ -0,0 +1,52 @@
|
||||
#include "engine/core.h"
|
||||
#include "engine/input.h"
|
||||
#include "game/level.h"
|
||||
#include <stdio.h>
|
||||
|
||||
static Level s_level;
|
||||
|
||||
static void game_init(void) {
|
||||
if (!level_load(&s_level, "assets/levels/level01.lvl")) {
|
||||
fprintf(stderr, "Failed to load level!\n");
|
||||
g_engine.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void game_update(float dt) {
|
||||
/* Quit on escape */
|
||||
if (input_pressed(ACTION_PAUSE)) {
|
||||
g_engine.running = false;
|
||||
return;
|
||||
}
|
||||
|
||||
level_update(&s_level, dt);
|
||||
}
|
||||
|
||||
static void game_render(float interpolation) {
|
||||
level_render(&s_level, interpolation);
|
||||
}
|
||||
|
||||
static void game_shutdown(void) {
|
||||
level_free(&s_level);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
||||
if (!engine_init()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
engine_set_callbacks((GameCallbacks){
|
||||
.init = game_init,
|
||||
.update = game_update,
|
||||
.render = game_render,
|
||||
.shutdown = game_shutdown,
|
||||
});
|
||||
|
||||
engine_run();
|
||||
engine_shutdown();
|
||||
|
||||
return 0;
|
||||
}
|
||||
53
src/util/darray.h
Normal file
53
src/util/darray.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#ifndef JNR_DARRAY_H
|
||||
#define JNR_DARRAY_H
|
||||
|
||||
/*
|
||||
* Simple type-safe dynamic array using macros.
|
||||
*
|
||||
* Usage:
|
||||
* DArray(int) nums = {0};
|
||||
* da_push(&nums, 42);
|
||||
* da_push(&nums, 99);
|
||||
* for (int i = 0; i < nums.count; i++) printf("%d\n", nums.items[i]);
|
||||
* da_free(&nums);
|
||||
*/
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define DArray(T) struct { T *items; int count; int capacity; }
|
||||
|
||||
#define da_push(arr, val) do { \
|
||||
if ((arr)->count >= (arr)->capacity) { \
|
||||
(arr)->capacity = (arr)->capacity ? (arr)->capacity * 2 : 8; \
|
||||
(arr)->items = realloc((arr)->items, \
|
||||
(arr)->capacity * sizeof(*(arr)->items)); \
|
||||
} \
|
||||
(arr)->items[(arr)->count++] = (val); \
|
||||
} while (0)
|
||||
|
||||
#define da_pop(arr) ((arr)->items[--(arr)->count])
|
||||
|
||||
#define da_clear(arr) ((arr)->count = 0)
|
||||
|
||||
#define da_free(arr) do { \
|
||||
free((arr)->items); \
|
||||
(arr)->items = NULL; \
|
||||
(arr)->count = 0; \
|
||||
(arr)->capacity = 0; \
|
||||
} while (0)
|
||||
|
||||
#define da_remove(arr, idx) do { \
|
||||
if ((idx) < (arr)->count - 1) { \
|
||||
memmove(&(arr)->items[(idx)], &(arr)->items[(idx) + 1], \
|
||||
((arr)->count - (idx) - 1) * sizeof(*(arr)->items)); \
|
||||
} \
|
||||
(arr)->count--; \
|
||||
} while (0)
|
||||
|
||||
/* Swap-remove: O(1) but doesn't preserve order */
|
||||
#define da_remove_fast(arr, idx) do { \
|
||||
(arr)->items[(idx)] = (arr)->items[--(arr)->count]; \
|
||||
} while (0)
|
||||
|
||||
#endif /* JNR_DARRAY_H */
|
||||
82
src/util/vec2.h
Normal file
82
src/util/vec2.h
Normal file
@@ -0,0 +1,82 @@
|
||||
#ifndef JNR_VEC2_H
|
||||
#define JNR_VEC2_H
|
||||
|
||||
#include <math.h>
|
||||
|
||||
typedef struct Vec2 {
|
||||
float x, y;
|
||||
} Vec2;
|
||||
|
||||
static inline Vec2 vec2(float x, float y) {
|
||||
return (Vec2){ x, y };
|
||||
}
|
||||
|
||||
static inline Vec2 vec2_zero(void) {
|
||||
return (Vec2){ 0.0f, 0.0f };
|
||||
}
|
||||
|
||||
static inline Vec2 vec2_add(Vec2 a, Vec2 b) {
|
||||
return (Vec2){ a.x + b.x, a.y + b.y };
|
||||
}
|
||||
|
||||
static inline Vec2 vec2_sub(Vec2 a, Vec2 b) {
|
||||
return (Vec2){ a.x - b.x, a.y - b.y };
|
||||
}
|
||||
|
||||
static inline Vec2 vec2_scale(Vec2 v, float s) {
|
||||
return (Vec2){ v.x * s, v.y * s };
|
||||
}
|
||||
|
||||
static inline float vec2_dot(Vec2 a, Vec2 b) {
|
||||
return a.x * b.x + a.y * b.y;
|
||||
}
|
||||
|
||||
static inline float vec2_len(Vec2 v) {
|
||||
return sqrtf(v.x * v.x + v.y * v.y);
|
||||
}
|
||||
|
||||
static inline float vec2_len_sq(Vec2 v) {
|
||||
return v.x * v.x + v.y * v.y;
|
||||
}
|
||||
|
||||
static inline Vec2 vec2_norm(Vec2 v) {
|
||||
float len = vec2_len(v);
|
||||
if (len < 0.0001f) return vec2_zero();
|
||||
return vec2_scale(v, 1.0f / len);
|
||||
}
|
||||
|
||||
static inline Vec2 vec2_lerp(Vec2 a, Vec2 b, float t) {
|
||||
return (Vec2){
|
||||
a.x + (b.x - a.x) * t,
|
||||
a.y + (b.y - a.y) * t
|
||||
};
|
||||
}
|
||||
|
||||
static inline float vec2_dist(Vec2 a, Vec2 b) {
|
||||
return vec2_len(vec2_sub(a, b));
|
||||
}
|
||||
|
||||
static inline Vec2 vec2_clamp(Vec2 v, Vec2 min, Vec2 max) {
|
||||
Vec2 r;
|
||||
r.x = v.x < min.x ? min.x : (v.x > max.x ? max.x : v.x);
|
||||
r.y = v.y < min.y ? min.y : (v.y > max.y ? max.y : v.y);
|
||||
return r;
|
||||
}
|
||||
|
||||
static inline float clampf(float v, float min, float max) {
|
||||
return v < min ? min : (v > max ? max : v);
|
||||
}
|
||||
|
||||
static inline float lerpf(float a, float b, float t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
static inline float signf(float v) {
|
||||
return (v > 0.0f) - (v < 0.0f);
|
||||
}
|
||||
|
||||
static inline float absf(float v) {
|
||||
return v < 0.0f ? -v : v;
|
||||
}
|
||||
|
||||
#endif /* JNR_VEC2_H */
|
||||
Reference in New Issue
Block a user