retro-rpg/game/main.c
Camden Dixie O'Brien e7e1b40dd9 Take ceiling of endx and endy in mapdraw()
This fixes an issue where tiles around the right and bottom edges
would not be drawn when vpos was just under a tile boundary.  I'm
guessing it wasn't noticable before fixing the frame rate as the buggy
frames were only on-screen for a hilariously short amount of time.
2025-01-02 15:43:31 +00:00

633 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 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;
xmlChar *x_attr, *y_attr;
x_attr = xmlGetProp(node, (const xmlChar *)"x");
y_attr = xmlGetProp(node, (const xmlChar *)"y");
const int chunk_x = atoi((const char *)x_attr);
const int chunk_y = atoi((const char *)y_attr);
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
= atoi((const char *)xmlGetProp(node, (const xmlChar *)"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
= atoi((const char *)xmlGetProp(node, (const xmlChar *)"width"));
state->objtypes[id].src.h = atoi(
(const char *)xmlGetProp(node, (const xmlChar *)"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;
const char *key
= (const char *)xmlGetProp(node, (const xmlChar *)"name");
const char *val
= (const char *)xmlGetProp(node, (const xmlChar *)"value");
if (strcmp(key, "animframes") == 0) {
state->objtypes[id].animframes = atoi(val);
} else if (strcmp(key, "assetpath") == 0) {
assert(strlen(state->assetdir) + strlen(val) < MAX_PATH_LEN);
strcpy(buf, state->assetdir);
strcat(buf, val);
state->objtypes[id].tex = IMG_LoadTexture(renderer, buf);
assert(NULL != state->objtypes[id].tex);
} else {
assert(false);
}
}
}
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
const xmlChar *templ = xmlGetProp(node, (const xmlChar *)"template");
assert(NULL != templ);
o->type = objtypeload(state, (const char *)templ, renderer);
// Load object location and set size from objtype
o->pos.x
= atoi((const char *)xmlGetProp(node, (const xmlChar *)"x"));
o->pos.y
= atoi((const char *)xmlGetProp(node, (const xmlChar *)"y"));
} while ((node = node->next) != NULL);
}
static void
mapload(gamestate_t *state, const char *path, SDL_Renderer *renderer)
{
xmlDocPtr doc;
xmlNodePtr node;
doc = xmlParseFile(path);
assert(NULL != doc);
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);
}
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, pfb.x, pfb.y);
valid &= tilepassable(state->map, pfb.x + e->fbox.ext.x, pfb.y);
valid &= tilepassable(state->map, pfb.x, pfb.y + e->fbox.ext.y);
valid &= tilepassable(
state->map, pfb.x + e->fbox.ext.x, 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;
}
}
gamestatus_t game_evthandle(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, unsigned 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 / 1000.0;
} 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);
}