Initial commit

This commit is contained in:
Thomas
2026-02-28 18:00:58 +00:00
commit c66c12ae68
587 changed files with 239570 additions and 0 deletions

49
src/engine/animation.c Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 */