retro-rpg/game/main.c
Camden Dixie O'Brien 77725a5ce4 Round coordinates in collision detection
This fixes an issue where the player could get stuck to an edge when
being aligned to the pixel grid due to the rounded coordinate being
outside an invalid tile but the truncated position being inside.  Also
we should be doing it anyway.
2025-01-04 02:13:40 +00:00

643 lines
16 KiB
C

/*
* Copyright (c) Camden Dixie O'Brien
* SPDX-License-Identifier: AGPL-3.0-only
*/
#include "engine_hooks.h"
#include <SDL2/SDL_image.h>
#include <assert.h>
#include <ctype.h>
#include <libxml/parser.h>
#include <math.h>
#include <stdbool.h>
#include <string.h>
#define SCALE 4
#define TILESIZE 16
#define VIEWWIDTH (16 * TILESIZE)
#define VIEWHEIGHT (12 * TILESIZE)
#define MAX_PATH_LEN 128
#define MAP_ASSET "/map.tmx"
#define MAPNLAYERS 2
#define MAPWIDTH 64
#define MAPHEIGHT 64
#define MAPSHIFTX 32
#define MAPSHIFTY 32
#define MAPMINX (TILESIZE * (-32))
#define MAPMAXX (TILESIZE * 32)
#define MAPMINY (TILESIZE * (-32))
#define MAPMAXY (TILESIZE * 32)
#define TSASSET "/overworld.png"
#define TSCOLS 40
#define PIDLE_ASSET "/player/idle.png"
#define PWALK_ASSET "/player/walk.png"
#define PWIDTH 48
#define PHEIGHT 64
#define PANIMLEN 8
#define PFBWIDTH 20
#define PFBHEIGHT 16
#define PFBOFFX -10
#define PFBOFFY 2
#define WALKSPEED 72 // pixels per second
#define BASEANIMPERIOD 100
#define MAXOBJTYPES 8
#define INITOBJCOLCAP 16
#define NELEMS(a) (sizeof(a) / sizeof(a[0]))
typedef enum {
SPRITE_DIR_DOWN = 0,
SPRITE_DIR_LEFT_DOWN = 1,
SPRITE_DIR_LEFT_UP = 2,
SPRITE_DIR_UP = 3,
SPRITE_DIR_RIGHT_UP = 4,
SPRITE_DIR_RIGHT_DOWN = 5,
} sprite_dir_t;
typedef struct {
bool left, right, up, down;
} input_state_t;
typedef struct {
int x, y;
} ivec_t;
typedef struct {
double x, y;
} dvec_t;
typedef struct {
dvec_t off, ext;
} dbox_t;
typedef struct {
int svar, animlen;
double speed;
dvec_t pos, dir;
ivec_t animstep, svarstep, ext;
dbox_t fbox;
SDL_Texture *tex;
} entity_t;
typedef struct {
unsigned animframes;
SDL_Rect src;
SDL_Texture *tex;
} objtype_t;
typedef struct {
unsigned type;
ivec_t pos;
} obj_t;
typedef struct {
size_t n, cap;
obj_t *buf;
} objcol_t;
typedef unsigned map_t[MAPNLAYERS][MAPWIDTH][MAPHEIGHT];
typedef struct {
const char *assetdir;
map_t map;
SDL_Texture *tstex, *pidle, *pwalk;
input_state_t input;
dvec_t vpos;
objtype_t objtypes[MAXOBJTYPES];
objcol_t objcol;
entity_t p;
} gamestate_t;
const engineconf_t game_conf = {
.win = {
.title = "2D Game",
.w = SCALE * VIEWWIDTH,
.h = SCALE * VIEWHEIGHT,
},
.memsize = sizeof(gamestate_t),
};
static const unsigned impassable[] = {
284, 485, 486, 525, 527, 566, 567, 731,
768, 770, 771, 804, 805, 806, 808, 845,
};
static inline double mag(dvec_t v)
{
return sqrt(v.x * v.x + v.y * v.y);
}
static inline double dot(dvec_t v, dvec_t u)
{
return v.x * u.x + v.y * u.y;
}
static inline unsigned
tileat(const map_t map, int layeridx, double x, double y)
{
const unsigned row = (unsigned)(floor(x / TILESIZE) + MAPSHIFTX);
const unsigned col = (unsigned)(floor(y / TILESIZE) + MAPSHIFTY);
if (row >= MAPWIDTH || col >= MAPHEIGHT)
return 0;
else
return map[layeridx][row][col];
}
static inline bool tilepassable(const map_t map, int x, int y)
{
for (unsigned l = 0; l < MAPNLAYERS; ++l) {
const unsigned id = tileat(map, l, x, y);
for (unsigned i = 0; i < NELEMS(impassable); ++i) {
if (impassable[i] == id)
return false;
}
}
return true;
}
static inline int propint(xmlNodePtr node, const char *prop)
{
xmlChar *str = xmlGetProp(node, (const xmlChar *)prop);
assert(str);
int val = atoi((const char *)str);
xmlFree(str);
return val;
}
static void maploadlayer(map_t map, xmlNodePtr layernode, int layeridx)
{
xmlNodePtr node = layernode->xmlChildrenNode;
while (NULL != node
&& xmlStrcmp(node->name, (const xmlChar *)"data") != 0)
node = node->next;
assert(NULL != node);
// Iterate through chunks and populate map array.
xmlNodePtr chunk_contents;
for (node = node->xmlChildrenNode; NULL != node; node = node->next) {
if (0 != xmlStrcmp(node->name, (const xmlChar *)"chunk"))
continue;
const int chunk_x = propint(node, "x");
const int chunk_y = propint(node, "y");
chunk_contents = node->xmlChildrenNode;
assert(
0 == xmlStrcmp(chunk_contents->name, (const xmlChar *)"text"));
int x = chunk_x + MAPSHIFTX, y = chunk_y + MAPSHIFTY;
xmlChar buf[10];
const xmlChar *p, *q;
q = chunk_contents->content;
while (isspace((const char)*q))
++q;
p = q;
while ('\0' != *q) {
switch (*q) {
case (xmlChar)',':
memset(buf, 0, sizeof(buf));
memcpy(buf, p, sizeof(xmlChar) * (q - p));
assert(x < MAPWIDTH && y < MAPHEIGHT);
map[layeridx][x][y] = (unsigned)atoi((const char *)buf);
++x;
p = ++q;
break;
case (xmlChar)'\n':
if (x < MAPWIDTH) {
memset(buf, 0, sizeof(buf));
memcpy(buf, p, sizeof(xmlChar) * (q - p));
assert(y < MAPHEIGHT);
map[layeridx][x][y] = (unsigned)atoi((const char *)buf);
}
x = chunk_x + MAPSHIFTX;
++y;
p = ++q;
break;
default:
++q;
break;
}
}
}
}
static unsigned
objtypeload(gamestate_t *state, const char *templ, SDL_Renderer *renderer)
{
static char buf[MAX_PATH_LEN];
assert(strlen(state->assetdir) + strlen(templ) + 1 < MAX_PATH_LEN);
strcpy(buf, state->assetdir);
strcat(buf, "/");
strcat(buf, templ);
// Identify object type ID
xmlDocPtr doc = xmlParseFile(buf);
assert(NULL != doc);
xmlNodePtr node = xmlDocGetRootElement(doc);
assert(NULL != node);
assert(xmlStrcmp(node->name, (const xmlChar *)"template") == 0);
node = node->xmlChildrenNode;
while (node != NULL
&& xmlStrcmp(node->name, (const xmlChar *)"object") != 0)
node = node->next;
assert(NULL != node);
unsigned id = propint(node, "gid");
assert(id < MAXOBJTYPES);
// Populate objtype struct if not already loaded
if (NULL == state->objtypes[id].tex) {
state->objtypes[id].src.x = 0;
state->objtypes[id].src.y = 0;
state->objtypes[id].src.w = propint(node, "width");
state->objtypes[id].src.h = propint(node, "height");
node = node->xmlChildrenNode;
while (node != NULL
&& xmlStrcmp(node->name, (const xmlChar *)"properties") != 0)
node = node->next;
assert(NULL != node);
for (node = node->xmlChildrenNode; NULL != node; node = node->next) {
if (xmlStrcmp(node->name, (const xmlChar *)"property") != 0)
continue;
xmlChar *key = xmlGetProp(node, (const xmlChar *)"name");
assert(key);
xmlChar *val = xmlGetProp(node, (const xmlChar *)"value");
assert(val);
if (xmlStrcmp(key, (const xmlChar *)"animframes") == 0) {
state->objtypes[id].animframes = atoi((const char *)val);
} else if (xmlStrcmp(key, (const xmlChar *)"assetpath") == 0) {
assert(
strlen(state->assetdir) + strlen((const char *)val)
< MAX_PATH_LEN);
strcpy(buf, state->assetdir);
strcat(buf, (const char *)val);
state->objtypes[id].tex = IMG_LoadTexture(renderer, buf);
assert(NULL != state->objtypes[id].tex);
} else {
assert(false);
}
xmlFree(key);
xmlFree(val);
}
}
xmlFreeDoc(doc);
return id;
}
static void
maploadobjects(gamestate_t *state, xmlNodePtr node, SDL_Renderer *renderer)
{
state->objcol.n = 0;
state->objcol.cap = INITOBJCOLCAP;
state->objcol.buf = malloc(sizeof(obj_t) * state->objcol.cap);
assert(state->objcol.buf);
node = node->xmlChildrenNode;
assert(NULL != node);
do {
if (xmlStrcmp(node->name, (const xmlChar *)"object") != 0)
continue;
// Get slot for object, growing buffer if needed.
if (state->objcol.n == state->objcol.cap) {
state->objcol.cap *= 2;
state->objcol.buf = realloc(
state->objcol.buf, sizeof(obj_t) * state->objcol.cap);
assert(state->objcol.buf);
}
obj_t *o = &state->objcol.buf[state->objcol.n++];
// Load object type
xmlChar *templ = xmlGetProp(node, (const xmlChar *)"template");
assert(templ);
o->type = objtypeload(state, (const char *)templ, renderer);
xmlFree(templ);
// Load object location
o->pos.x = propint(node, "x");
o->pos.y = propint(node, "y");
} while ((node = node->next) != NULL);
}
static void
mapload(gamestate_t *state, const char *path, SDL_Renderer *renderer)
{
xmlDocPtr doc = xmlParseFile(path);
assert(NULL != doc);
xmlNodePtr node = xmlDocGetRootElement(doc);
assert(0 == xmlStrcmp(node->name, (const xmlChar *)"map"));
node = node->xmlChildrenNode;
int layer = 0;
assert(NULL != node);
do {
if (xmlStrcmp(node->name, (const xmlChar *)"layer") == 0)
maploadlayer(state->map, node, layer++);
else if (xmlStrcmp(node->name, (const xmlChar *)"objectgroup") == 0)
maploadobjects(state, node, renderer);
} while ((node = node->next) != NULL);
xmlFreeDoc(doc);
}
static void mapdraw(const gamestate_t *state, SDL_Renderer *renderer)
{
const int startx = TILESIZE * floor(state->vpos.x / TILESIZE);
const int starty = TILESIZE * floor(state->vpos.y / TILESIZE);
const int endx = ceil(state->vpos.x + VIEWWIDTH);
const int endy = ceil(state->vpos.y + VIEWHEIGHT);
for (int l = 0; l < MAPNLAYERS; ++l) {
for (int y = starty; y < endy; y += TILESIZE) {
for (int x = startx; x < endx; x += TILESIZE) {
const unsigned tileid = tileat(state->map, l, x, y);
if (0 == tileid)
continue;
const SDL_Rect src = {
.x = TILESIZE * ((tileid - 1) % TSCOLS),
.y = TILESIZE * ((tileid - 1) / TSCOLS),
.w = TILESIZE,
.h = TILESIZE,
};
const SDL_Rect dest = {
.x = (int)rint(SCALE * (x - state->vpos.x)),
.y = (int)rint(SCALE * (y - state->vpos.y)),
.w = SCALE * TILESIZE,
.h = SCALE * TILESIZE,
};
SDL_RenderCopy(renderer, state->tstex, &src, &dest);
}
}
}
}
static void entityupdate(gamestate_t *state, entity_t *e, double dt)
{
if (0 == e->speed) {
// Round position to nearest integer to align with pixel grid.
e->pos.x = rint(e->pos.x);
e->pos.y = rint(e->pos.y);
return;
}
// Update sprite variant
if (e->dir.y >= 0) {
if (e->dir.x > 0)
e->svar = SPRITE_DIR_RIGHT_DOWN;
else if (e->dir.x < 0)
e->svar = SPRITE_DIR_LEFT_DOWN;
else
e->svar = SPRITE_DIR_DOWN;
} else if (e->dir.y < 0) {
if (e->dir.x > 0)
e->svar = SPRITE_DIR_RIGHT_UP;
else if (e->dir.x < 0)
e->svar = SPRITE_DIR_LEFT_UP;
else
e->svar = SPRITE_DIR_UP;
}
// Apply velocity (handling map collisions)
const double nextx = e->pos.x + dt * e->speed * e->dir.x;
const double nexty = e->pos.y + dt * e->speed * e->dir.y;
const dvec_t pfb
= { .x = nextx + e->fbox.off.x, .y = nexty + e->fbox.off.y };
bool valid = true;
valid &= tilepassable(state->map, (int)rint(pfb.x), (int)rint(pfb.y));
valid &= tilepassable(
state->map, (int)rint(pfb.x + e->fbox.ext.x), (int)rint(pfb.y));
valid &= tilepassable(
state->map, (int)rint(pfb.x), (int)rint(pfb.y + e->fbox.ext.y));
valid &= tilepassable(
state->map, (int)rint(pfb.x + e->fbox.ext.x),
(int)rint(pfb.y + e->fbox.ext.y));
if (valid) {
e->pos.x = nextx;
e->pos.y = nexty;
}
}
static void entitydraw(
const gamestate_t *state, SDL_Renderer *renderer, const entity_t *e,
uint64_t t)
{
const unsigned frame = (t / BASEANIMPERIOD) % e->animlen;
const SDL_Rect src = {
.x = e->animstep.x * frame + e->svarstep.x * e->svar,
.y = e->animstep.y * frame + e->svarstep.y * e->svar,
.w = e->ext.x,
.h = e->ext.y,
};
const SDL_Rect dest = {
.x = (int)rint(SCALE * (e->pos.x - e->ext.x / 2 - state->vpos.x)),
.y = (int)rint(SCALE * (e->pos.y - e->ext.y / 2 - state->vpos.y)),
.w = SCALE * e->ext.x,
.h = SCALE * e->ext.y,
};
SDL_RenderCopy(renderer, e->tex, &src, &dest);
}
static void
objsdraw(const gamestate_t *state, SDL_Renderer *renderer, uint64_t t)
{
for (unsigned i = 0; i < state->objcol.n; ++i) {
const obj_t *obj = &state->objcol.buf[i];
assert(obj->type < MAXOBJTYPES);
const objtype_t *type = &state->objtypes[obj->type];
assert(NULL != type->tex);
SDL_Rect src = type->src;
src.x += type->src.w * ((t / BASEANIMPERIOD) % type->animframes);
const int x = rint(SCALE * (obj->pos.x - state->vpos.x));
const int y
= rint(SCALE * (obj->pos.y - state->vpos.y - type->src.h));
SDL_Rect dest = {
.x = x,
.y = y,
.w = SCALE * type->src.w,
.h = SCALE * type->src.h,
};
SDL_RenderCopy(renderer, type->tex, &src, &dest);
}
}
void game_init(int argc, char *argv[], void *mem, SDL_Renderer *renderer)
{
char path[MAX_PATH_LEN];
gamestate_t *state = (gamestate_t *)mem;
assert(2 == argc);
state->assetdir = argv[1];
// Load map
assert(strlen(state->assetdir) + strlen(MAP_ASSET) < MAX_PATH_LEN);
strcpy(path, state->assetdir);
strcat(path, MAP_ASSET);
mapload(state, path, renderer);
// Load tileset
assert(strlen(state->assetdir) + strlen(TSASSET) < MAX_PATH_LEN);
strcpy(path, state->assetdir);
strcat(path, TSASSET);
state->tstex = IMG_LoadTexture(renderer, path);
assert(NULL != state->tstex);
// Load player spritesheets and initialize texture
assert(strlen(state->assetdir) + strlen(PIDLE_ASSET) < MAX_PATH_LEN);
strcpy(path, state->assetdir);
strcat(path, PIDLE_ASSET);
state->pidle = IMG_LoadTexture(renderer, path);
assert(NULL != state->pidle);
assert(strlen(state->assetdir) + strlen(PWALK_ASSET) < MAX_PATH_LEN);
strcpy(path, state->assetdir);
strcat(path, PWALK_ASSET);
state->pwalk = IMG_LoadTexture(renderer, path);
assert(NULL != state->pwalk);
// Set view position
state->vpos.x = -128;
state->vpos.y = -96;
// Initialize player state
state->p.svar = SPRITE_DIR_DOWN;
state->p.animlen = PANIMLEN;
state->p.animstep.x = PWIDTH;
state->p.svarstep.y = PHEIGHT;
state->p.fbox.off.x = PFBOFFX;
state->p.fbox.off.y = PFBOFFY;
state->p.fbox.ext.x = PFBWIDTH;
state->p.fbox.ext.y = PFBHEIGHT;
state->p.ext.x = PWIDTH;
state->p.ext.y = PHEIGHT;
state->p.tex = state->pidle;
}
void game_teardown(void *mem)
{
gamestate_t *state = (gamestate_t *)mem;
SDL_DestroyTexture(state->tstex);
SDL_DestroyTexture(state->pidle);
SDL_DestroyTexture(state->pwalk);
for (int i = 0; i < MAXOBJTYPES; ++i) {
if (NULL != state->objtypes[i].tex)
SDL_DestroyTexture(state->objtypes[i].tex);
else
break;
}
free(state->objcol.buf);
}
gamestatus_t game_evt(void *mem, const SDL_Event *evt)
{
gamestate_t *state = (gamestate_t *)mem;
switch (evt->type) {
case SDL_QUIT:
return GAMESTATUS_QUIT;
case SDL_KEYDOWN:
switch (evt->key.keysym.sym) {
case SDLK_LEFT:
state->input.left = true;
break;
case SDLK_RIGHT:
state->input.right = true;
break;
case SDLK_UP:
state->input.up = true;
break;
case SDLK_DOWN:
state->input.down = true;
break;
default:
break;
}
break;
case SDL_KEYUP:
switch (evt->key.keysym.sym) {
case SDLK_LEFT:
state->input.left = false;
break;
case SDLK_RIGHT:
state->input.right = false;
break;
case SDLK_UP:
state->input.up = false;
break;
case SDLK_DOWN:
state->input.down = false;
break;
default:
break;
}
break;
default:
break;
}
return GAMESTATUS_OK;
}
gamestatus_t game_update(void *mem, double dt)
{
gamestate_t *state = (gamestate_t *)mem;
// Calculate player velocity and update player
state->p.dir.x
= (state->input.left ? -1 : 0) + (state->input.right ? 1 : 0);
state->p.dir.y
= (state->input.up ? -1 : 0) + (state->input.down ? 1 : 0);
const double dmag = mag(state->p.dir);
if (dmag != 0) {
state->p.dir.x /= dmag;
state->p.dir.y /= dmag;
state->p.tex = state->pwalk;
state->p.speed = WALKSPEED;
} else {
state->p.tex = state->pidle;
state->p.speed = 0;
// Round view position to nearest integer to align with
// pixel grid.
state->vpos.x = rint(state->vpos.x);
state->vpos.y = rint(state->vpos.y);
}
entityupdate(state, &state->p, dt);
// Update view
const dvec_t pvdisp = {
.x = state->p.pos.x - (state->vpos.x + VIEWWIDTH / 2),
.y = state->p.pos.y - (state->vpos.y + VIEWHEIGHT / 2),
};
if (mag(pvdisp) > 72 && dot(pvdisp, state->p.dir) > 0) {
const double nextx
= state->vpos.x + dt * state->p.speed * state->p.dir.x;
const double nexty
= state->vpos.y + dt * state->p.speed * state->p.dir.y;
const bool validx = nextx >= MAPMINX && nextx < MAPMAXX;
const bool validy = nexty >= MAPMINY && nexty < MAPMAXY;
if (validx && validy) {
state->vpos.x = nextx;
state->vpos.y = nexty;
}
}
return GAMESTATUS_OK;
}
void game_render(const void *mem, SDL_Renderer *renderer, long unsigned t)
{
const gamestate_t *state = (const gamestate_t *)mem;
mapdraw(state, renderer);
objsdraw(state, renderer, t);
entitydraw(state, renderer, &state->p, t);
}