From 4ffa1895390066f2bbd347c0689a1b667a21fd24 Mon Sep 17 00:00:00 2001 From: 3gg <3gg@shellblade.net> Date: Fri, 27 Jun 2025 10:17:41 -0700 Subject: Initial commit --- CMakeLists.txt | 42 ++ demos/CMakeLists.txt | 2 + demos/checkerboard/CMakeLists.txt | 16 + demos/checkerboard/checkerboard.c | 166 +++++++ demos/isomap/CMakeLists.txt | 16 + demos/isomap/isomap.c | 105 +++++ include/isogfx/backend.h | 28 ++ include/isogfx/isogfx.h | 136 ++++++ src/backend.c | 199 ++++++++ src/isogfx.c | 952 ++++++++++++++++++++++++++++++++++++++ tools/mkasset.py | 324 +++++++++++++ 11 files changed, 1986 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 demos/CMakeLists.txt create mode 100644 demos/checkerboard/CMakeLists.txt create mode 100644 demos/checkerboard/checkerboard.c create mode 100644 demos/isomap/CMakeLists.txt create mode 100644 demos/isomap/isomap.c create mode 100644 include/isogfx/backend.h create mode 100644 include/isogfx/isogfx.h create mode 100644 src/backend.c create mode 100644 src/isogfx.c create mode 100644 tools/mkasset.py diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e4a677d --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.0) + +project(isogfx) + +set(CMAKE_C_STANDARD 17) +set(CMAKE_C_STANDARD_REQUIRED On) +set(CMAKE_C_EXTENSIONS Off) + +# isogfx + +add_library(isogfx + src/isogfx.c) + +target_include_directories(isogfx PUBLIC + include) + +target_link_libraries(isogfx PUBLIC + filesystem + mem + mempool) + +target_compile_options(isogfx PRIVATE -Wall -Wextra -Wpedantic) + +# Backend + +add_library(isogfx-backend + src/backend.c) + +target_include_directories(isogfx-backend PUBLIC + include) + +target_link_libraries(isogfx-backend PUBLIC + isogfx) + +target_link_libraries(isogfx-backend PRIVATE + gfx) + +target_compile_options(isogfx-backend PRIVATE -Wall -Wextra -Wpedantic) + +# Demos + +add_subdirectory(demos) diff --git a/demos/CMakeLists.txt b/demos/CMakeLists.txt new file mode 100644 index 0000000..c0a4101 --- /dev/null +++ b/demos/CMakeLists.txt @@ -0,0 +1,2 @@ +add_subdirectory(checkerboard) +add_subdirectory(isomap) diff --git a/demos/checkerboard/CMakeLists.txt b/demos/checkerboard/CMakeLists.txt new file mode 100644 index 0000000..d1691c6 --- /dev/null +++ b/demos/checkerboard/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.0) + +project(checkerboard) + +set(CMAKE_C_STANDARD 17) +set(CMAKE_C_STANDARD_REQUIRED On) +set(CMAKE_C_EXTENSIONS Off) + +add_executable(checkerboard + checkerboard.c) + +target_link_libraries(checkerboard PRIVATE + gfx-app + isogfx-backend) + +target_compile_options(checkerboard PRIVATE -Wall -Wextra -Wpedantic) diff --git a/demos/checkerboard/checkerboard.c b/demos/checkerboard/checkerboard.c new file mode 100644 index 0000000..dbc817c --- /dev/null +++ b/demos/checkerboard/checkerboard.c @@ -0,0 +1,166 @@ +#include +#include + +#include + +#include +#include +#include + +static const int WINDOW_WIDTH = 1408; +static const int WINDOW_HEIGHT = 960; +static const int MAX_FPS = 60; + +// Virtual screen dimensions. +static const int SCREEN_WIDTH = 704; +static const int SCREEN_HEIGHT = 480; + +static const int TILE_WIDTH = 32; +static const int TILE_HEIGHT = TILE_WIDTH / 2; +static const int WORLD_WIDTH = 20; +static const int WORLD_HEIGHT = 20; + +static const TileDesc tile_set[] = { + {.type = TileFromColour, + .width = TILE_WIDTH, + .height = TILE_HEIGHT, + .colour = (Pixel){.r = 0x38, .g = 0x3b, .b = 0x46, .a = 0xff}}, + {.type = TileFromColour, + .width = TILE_WIDTH, + .height = TILE_HEIGHT, + .colour = (Pixel){.r = 0xA5, .g = 0xb3, .b = 0xc0, .a = 0xff}}, + {.type = TileFromColour, + .width = TILE_WIDTH, + .height = TILE_HEIGHT, + .colour = (Pixel){.r = 0xdc, .g = 0x76, .b = 0x84, .a = 0xff}}, +}; + +typedef enum Colour { + Black, + White, + Red, +} Colour; + +typedef struct GfxAppState { + IsoBackend* backend; + IsoGfx* iso; + Tile red; + int xpick; + int ypick; +} GfxAppState; + +static void make_checkerboard(IsoGfx* iso, Tile black, Tile white) { + assert(iso); + for (int y = 0; y < isogfx_world_height(iso); ++y) { + for (int x = 0; x < isogfx_world_width(iso); ++x) { + const int odd_col = x & 1; + const int odd_row = y & 1; + const Tile value = (odd_row ^ odd_col) == 0 ? black : white; + isogfx_set_tile(iso, x, y, value); + } + } +} + +static bool init(GfxAppState* state, int argc, const char** argv) { + assert(state); + + (void)argc; + (void)argv; + + if (!(state->iso = isogfx_new(&(IsoGfxDesc){ + .screen_width = SCREEN_WIDTH, .screen_height = SCREEN_HEIGHT}))) { + return false; + } + IsoGfx* iso = state->iso; + + isogfx_resize(iso, SCREEN_WIDTH, SCREEN_HEIGHT); + + if (!isogfx_make_world( + iso, &(WorldDesc){ + .tile_width = TILE_WIDTH, + .tile_height = TILE_HEIGHT, + .world_width = WORLD_WIDTH, + .world_height = WORLD_HEIGHT})) { + return false; + } + + const Tile black = isogfx_make_tile(iso, &tile_set[Black]); + const Tile white = isogfx_make_tile(iso, &tile_set[White]); + state->red = isogfx_make_tile(iso, &tile_set[Red]); + make_checkerboard(iso, black, white); + + if (!(state->backend = IsoBackendInit(iso))) { + return false; + } + + return true; +} + +static void shutdown(GfxAppState* state) { + assert(state); + + IsoBackendShutdown(&state->backend); + isogfx_del(&state->iso); +} + +static void update(GfxAppState* state, double t, double dt) { + assert(state); + (void)dt; + + IsoGfx* iso = state->iso; + + isogfx_update(iso, t); + + // Get mouse position in window coordinates. + double mouse_x, mouse_y; + gfx_app_get_mouse_position(&mouse_x, &mouse_y); + + // Map from window coordinates to virtual screen coordinates. + IsoBackendGetMousePosition( + state->backend, mouse_x, mouse_y, &mouse_x, &mouse_y); + + isogfx_pick_tile(iso, mouse_x, mouse_y, &state->xpick, &state->ypick); + + printf("Picked tile: (%d, %d)\n", state->xpick, state->ypick); +} + +static void render(GfxAppState* state) { + assert(state); + + IsoGfx* iso = state->iso; + + isogfx_render(iso); + + if ((state->xpick != -1) && (state->ypick != -1)) { + isogfx_draw_tile(iso, state->xpick, state->ypick, state->red); + } + + IsoBackendRender(state->backend, iso); +} + +static void resize(GfxAppState* state, int width, int height) { + assert(state); + + IsoBackendResizeWindow(state->backend, state->iso, width, height); +} + +int main(int argc, const char** argv) { + GfxAppState state = {0}; + gfx_app_run( + &(GfxAppDesc){ + .argc = argc, + .argv = argv, + .width = WINDOW_WIDTH, + .height = WINDOW_HEIGHT, + .max_fps = MAX_FPS, + .update_delta_time = MAX_FPS > 0 ? 1.0 / (double)MAX_FPS : 0.0, + .title = "Isometric Renderer", + .app_state = &state}, + &(GfxAppCallbacks){ + .init = init, + .update = update, + .render = render, + .resize = resize, + .shutdown = shutdown}); + return 0; +} diff --git a/demos/isomap/CMakeLists.txt b/demos/isomap/CMakeLists.txt new file mode 100644 index 0000000..2dbfd32 --- /dev/null +++ b/demos/isomap/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.0) + +project(isomap) + +set(CMAKE_C_STANDARD 17) +set(CMAKE_C_STANDARD_REQUIRED On) +set(CMAKE_C_EXTENSIONS Off) + +add_executable(isomap + isomap.c) + +target_link_libraries(isomap PRIVATE + gfx-app + isogfx-backend) + +target_compile_options(isomap PRIVATE -Wall -Wextra -Wpedantic) diff --git a/demos/isomap/isomap.c b/demos/isomap/isomap.c new file mode 100644 index 0000000..a233659 --- /dev/null +++ b/demos/isomap/isomap.c @@ -0,0 +1,105 @@ +#include +#include + +#include + +#include +#include + +static const int WINDOW_WIDTH = 1408; +static const int WINDOW_HEIGHT = 960; +static const int MAX_FPS = 60; + +// Virtual screen dimensions. +static const int SCREEN_WIDTH = 704; +static const int SCREEN_HEIGHT = 480; + +typedef struct GfxAppState { + IsoBackend* backend; + IsoGfx* iso; + int xpick; + int ypick; + SpriteSheet stag_sheet; + Sprite stag; +} GfxAppState; + +static bool init(GfxAppState* state, int argc, const char** argv) { + assert(state); + (void)argc; + (void)argv; + + if (!(state->iso = isogfx_new(&(IsoGfxDesc){ + .screen_width = SCREEN_WIDTH, .screen_height = SCREEN_HEIGHT}))) { + return false; + } + IsoGfx* iso = state->iso; + + isogfx_resize(iso, SCREEN_WIDTH, SCREEN_HEIGHT); + + if (!isogfx_load_world(iso, "/home/jeanne/assets/tilemaps/demo1.tm")) { + return false; + } + + if (!isogfx_load_sprite_sheet( + iso, "/home/jeanne/assets/tilesets/scrabling/critters/stag/stag.ss", + &state->stag_sheet)) { + return false; + } + + state->stag = isogfx_make_sprite(iso, state->stag_sheet); + isogfx_set_sprite_position(iso, state->stag, 5, 4); + + if (!(state->backend = IsoBackendInit(iso))) { + return false; + } + + return true; +} + +static void shutdown(GfxAppState* state) { + assert(state); + // +} + +static void update(GfxAppState* state, double t, double dt) { + assert(state); + (void)dt; + + IsoGfx* iso = state->iso; + isogfx_update(iso, t); +} + +static void render(GfxAppState* state) { + assert(state); + + IsoGfx* iso = state->iso; + isogfx_render(iso); + IsoBackendRender(state->backend, iso); +} + +static void resize(GfxAppState* state, int width, int height) { + assert(state); + + IsoBackendResizeWindow(state->backend, state->iso, width, height); +} + +int main(int argc, const char** argv) { + GfxAppState state = {0}; + gfx_app_run( + &(GfxAppDesc){ + .argc = argc, + .argv = argv, + .width = WINDOW_WIDTH, + .height = WINDOW_HEIGHT, + .max_fps = MAX_FPS, + .update_delta_time = MAX_FPS > 0 ? 1.0 / (double)MAX_FPS : 0.0, + .title = "Isometric Renderer", + .app_state = &state}, + &(GfxAppCallbacks){ + .init = init, + .update = update, + .render = render, + .resize = resize, + .shutdown = shutdown}); + return 0; +} diff --git a/include/isogfx/backend.h b/include/isogfx/backend.h new file mode 100644 index 0000000..172991d --- /dev/null +++ b/include/isogfx/backend.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +typedef struct Gfx Gfx; +typedef struct IsoGfx IsoGfx; + +typedef struct IsoBackend IsoBackend; + +/// Initialize the backend. +IsoBackend* IsoBackendInit(const IsoGfx*); + +/// Shut down the backend. +void IsoBackendShutdown(IsoBackend**); + +/// Notify the backend of a window resize event. +/// This allows the backend to determine how to position and scale the iso +/// screen buffer on the graphics window. +void IsoBackendResizeWindow(IsoBackend*, const IsoGfx*, int width, int height); + +/// Render the iso screen to the graphics window. +void IsoBackendRender(const IsoBackend*, const IsoGfx*); + +/// Map window coordinates to iso space coordinates. +/// This takes into account any possible resizing done by the backend in +/// response to calls to IsoBackendResizeWindow(). +bool IsoBackendGetMousePosition( + const IsoBackend*, double window_x, double window_y, double* x, double* y); diff --git a/include/isogfx/isogfx.h b/include/isogfx/isogfx.h new file mode 100644 index 0000000..3421a7b --- /dev/null +++ b/include/isogfx/isogfx.h @@ -0,0 +1,136 @@ +/* + * Isometric rendering engine. + */ +#pragma once + +#include +#include + +typedef struct IsoGfx IsoGfx; + +/// Sprite sheet handle. +typedef uint16_t SpriteSheet; + +/// Sprite handle. +typedef uint16_t Sprite; + +/// Tile handle. +typedef uint16_t Tile; + +/// Colour channel. +typedef uint8_t Channel; + +typedef struct Pixel { + Channel r, g, b, a; +} Pixel; + +typedef enum TileDescType { + TileFromColour, + TileFromFile, + TileFromMemory, +} TileDescType; + +typedef struct TileDesc { + TileDescType type; + int width; /// Tile width in pixels. + int height; /// Tile height in pixels. + union { + Pixel colour; /// Constant colour tile. + struct { + const char* path; + } file; + struct { + const uint8_t* data; /// sizeof(Pixel) * width * height + } mem; + }; +} TileDesc; + +typedef struct WorldDesc { + int tile_width; /// Base tile width in pixels. + int tile_height; /// Base tile height in pixels. + int world_width; /// World width in tiles. + int world_height; /// World height in tiles. + int max_num_tiles; /// 0 for an implementation-defined default. +} WorldDesc; + +typedef struct IsoGfxDesc { + int screen_width; /// Screen width in pixels. + int screen_height; /// Screen height in pixels. + int max_num_sprites; /// 0 for an implementation-defined default. + int sprite_sheet_pool_size_bytes; /// 0 for an implementation-defined default. +} IsoGfxDesc; + +/// Create a new isometric graphics engine. +IsoGfx* isogfx_new(const IsoGfxDesc*); + +/// Destroy the isometric graphics engine. +void isogfx_del(IsoGfx**); + +/// Create an empty world. +bool isogfx_make_world(IsoGfx*, const WorldDesc*); + +/// Load a world from a tile map (.TM) file. +bool isogfx_load_world(IsoGfx*, const char* filepath); + +/// Return the world's width. +int isogfx_world_width(const IsoGfx*); + +/// Return the world's height. +int isogfx_world_height(const IsoGfx*); + +/// Create a new tile. +Tile isogfx_make_tile(IsoGfx*, const TileDesc*); + +/// Set the tile at position (x,y). +void isogfx_set_tile(IsoGfx*, int x, int y, Tile); + +/// Set the tiles in positions in the range (x0,y0) - (x1,y1). +void isogfx_set_tiles(IsoGfx*, int x0, int y0, int x1, int y1, Tile); + +/// Load a sprite sheet (.SS) file. +bool isogfx_load_sprite_sheet(IsoGfx*, const char* filepath, SpriteSheet*); + +/// Create an animated sprite. +Sprite isogfx_make_sprite(IsoGfx*, SpriteSheet); + +/// Destroy the sprite. +void isogfx_del_sprite(IsoGfx*, Sprite); + +/// Destroy all the sprites. +void isogfx_del_sprites(IsoGfx*); + +/// Set the sprite's position. +void isogfx_set_sprite_position(IsoGfx*, Sprite, int x, int y); + +/// Set the sprite's current animation. +void isogfx_set_sprite_animation(IsoGfx*, Sprite, int animation); + +/// Update the renderer. +/// +/// Currently this updates the sprite animations. +void isogfx_update(IsoGfx*, double t); + +/// Render the world. +void isogfx_render(IsoGfx*); + +/// Draw/overlay a tile at position (x,y). +/// +/// This function just renders a tile at position (x,y) and should be called +/// after isogfx_render() to obtain the correct result. To set the tile at +/// position (x,y) instead, use isogfx_set_tile(). +void isogfx_draw_tile(IsoGfx*, int x, int y, Tile); + +/// Resize the virtual screen's dimensions. +bool isogfx_resize(IsoGfx*, int screen_width, int screen_height); + +/// Get the virtual screen's dimensions. +void isogfx_get_screen_size(const IsoGfx*, int* width, int* height); + +/// Return a pointer to the virtual screen's colour buffer. +/// +/// Call after each call to isogfx_render() to retrieve the render output. +const Pixel* isogfx_get_screen_buffer(const IsoGfx*); + +/// Translate Cartesian to isometric coordinates. +void isogfx_pick_tile( + const IsoGfx*, double xcart, double ycart, int* xiso, int* yiso); diff --git a/src/backend.c b/src/backend.c new file mode 100644 index 0000000..db91647 --- /dev/null +++ b/src/backend.c @@ -0,0 +1,199 @@ +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +typedef struct IsoBackend { + Gfx* gfx; + Scene* scene; + /// The screen or "iso screen" refers to the colour buffer of the iso graphics + /// library. This texture is used to draw the iso screen onto the graphics + /// window. + Texture* screen_texture; + /// Window size. + int window_width; + int window_height; + /// The viewport refers to the area inside the window to which screen_texture + /// is drawn. It is a scaled version of the iso screen, scaled while + /// respecting the iso screen's aspect ratio to prevent distortion. + int viewport_x, viewport_y, viewport_width, viewport_height; + double stretch; // Stretch factor from iso screen dimensions to viewport + // dimensions. +} IsoBackend; + +IsoBackend* IsoBackendInit(const IsoGfx* iso) { + assert(iso); + + IsoBackend* backend = calloc(1, sizeof(IsoBackend)); + if (!backend) { + return 0; + } + + if (!(backend->gfx = gfx_init())) { + goto cleanup; + } + GfxCore* gfxcore = gfx_get_core(backend->gfx); + + int screen_width, screen_height; + isogfx_get_screen_size(iso, &screen_width, &screen_height); + + if (!(backend->screen_texture = gfx_make_texture( + gfxcore, &(TextureDesc){ + .width = screen_width, + .height = screen_height, + .dimension = Texture2D, + .format = TextureSRGBA8, + .filtering = NearestFiltering, + .wrap = ClampToEdge, + .mipmaps = false}))) { + goto cleanup; + } + + ShaderProgram* shader = gfx_make_view_texture_shader(gfxcore); + if (!shader) { + goto cleanup; + } + + Geometry* geometry = gfx_make_quad_11(gfxcore); + if (!geometry) { + goto cleanup; + } + + MaterialDesc material_desc = (MaterialDesc){.num_uniforms = 1}; + material_desc.uniforms[0] = (ShaderUniform){ + .type = UniformTexture, + .value.texture = backend->screen_texture, + .name = sstring_make("Texture")}; + Material* material = gfx_make_material(&material_desc); + if (!material) { + return false; + } + + const MeshDesc mesh_desc = + (MeshDesc){.geometry = geometry, .material = material, .shader = shader}; + Mesh* mesh = gfx_make_mesh(&mesh_desc); + if (!mesh) { + goto cleanup; + } + + SceneObject* object = + gfx_make_object(&(ObjectDesc){.num_meshes = 1, .meshes = {mesh}}); + if (!object) { + goto cleanup; + } + + backend->scene = gfx_make_scene(); + SceneNode* node = gfx_make_object_node(object); + SceneNode* root = gfx_get_scene_root(backend->scene); + gfx_set_node_parent(node, root); + + return backend; + +cleanup: + if (backend->gfx) { + gfx_destroy(&backend->gfx); + } + free(backend); + return 0; +} + +void IsoBackendShutdown(IsoBackend** ppApp) { + assert(ppApp); + + IsoBackend* app = *ppApp; + if (!app) { + return; + } + + gfx_destroy(&app->gfx); +} + +void IsoBackendResizeWindow( + IsoBackend* app, const IsoGfx* iso, int width, int height) { + assert(app); + assert(iso); + + app->window_width = width; + app->window_height = height; + + // Virtual screen dimensions. + int screen_width, screen_height; + isogfx_get_screen_size(iso, &screen_width, &screen_height); + + // Stretch the virtual screen onto the viewport while respecting the screen's + // aspect ratio to prevent distortion. + if (width > height) { // Wide screen. + app->stretch = (double)height / (double)screen_height; + app->viewport_width = (int)((double)screen_width * app->stretch); + app->viewport_height = height; + app->viewport_x = (width - app->viewport_width) / 2; + app->viewport_y = 0; + } else { // Tall screen. + app->stretch = (double)width / (double)screen_width; + app->viewport_width = width; + app->viewport_height = (int)((float)screen_height * app->stretch); + app->viewport_x = 0; + app->viewport_y = (height - app->viewport_height) / 2; + } +} + +void IsoBackendRender(const IsoBackend* app, const IsoGfx* iso) { + assert(app); + assert(iso); + + const Pixel* screen = isogfx_get_screen_buffer(iso); + assert(screen); + gfx_update_texture(app->screen_texture, &(TextureDataDesc){.pixels = screen}); + + GfxCore* gfxcore = gfx_get_core(app->gfx); + Renderer* renderer = gfx_get_renderer(app->gfx); + + // Clear the whole window. + gfx_set_viewport(gfxcore, 0, 0, app->window_width, app->window_height); + gfx_clear(gfxcore, vec4_make(0, 0, 0, 0)); + + // Draw to the subregion where the virtual screen can stretch without + // distortion. + gfx_set_viewport( + gfxcore, app->viewport_x, app->viewport_y, app->viewport_width, + app->viewport_height); + + // Render the iso screen. + gfx_start_frame(gfxcore); + gfx_render_scene( + renderer, &(RenderSceneParams){ + .mode = RenderDefault, .scene = app->scene, .camera = 0}); + gfx_end_frame(gfxcore); +} + +bool IsoBackendGetMousePosition( + const IsoBackend* app, double window_x, double window_y, double* x, + double* y) { + assert(app); + + // Translate from window coordinates to the subregion where the stretched + // iso screen is rendered. + const double screen_x = window_x - app->viewport_x; + const double screen_y = window_y - app->viewport_y; + + // Position may be out of bounds. + if ((0 <= screen_x) && (screen_x < app->viewport_width) && (0 <= screen_y) && + (screen_y < app->viewport_height)) { + // Scale back from the stretched subregion to the iso screen dimensions. + *x = screen_x / app->stretch; + *y = screen_y / app->stretch; + return true; + } else { + *x = -1; + *y = -1; + return false; + } +} diff --git a/src/isogfx.c b/src/isogfx.c new file mode 100644 index 0000000..52c4ae2 --- /dev/null +++ b/src/isogfx.c @@ -0,0 +1,952 @@ +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +/// Maximum number of tiles unless the user specifies a value. +#define DEFAULT_MAX_NUM_TILES 1024 + +/// Maximum number of sprites unless the user specifies a value. +#define DEFAULT_MAX_NUM_SPRITES 128 + +/// Size of sprite sheet pool in bytes unless the user specifies a value. +#define DEFAULT_SPRITE_SHEET_POOL_SIZE_BYTES (8 * 1024 * 1024) + +/// Default animation speed. +#define ANIMATION_FPS 10 + +/// Time between animation updates. +#define ANIMATION_UPDATE_DELTA (1.0 / ANIMATION_FPS) + +typedef struct ivec2 { + int x, y; +} ivec2; + +typedef struct vec2 { + double x, y; +} vec2; + +// ----------------------------------------------------------------------------- +// Tile set (TS) and tile map (TM) file formats. +// ----------------------------------------------------------------------------- + +/// Maximum length of path strings in .TS and .TM files. +#define MAX_PATH_LENGTH 128 + +typedef struct Ts_Tile { + uint16_t width; /// Tile width in pixels. + uint16_t height; /// Tile height in pixels. + Pixel pixels[1]; /// Count: width * height. +} Ts_Tile; + +typedef struct Ts_TileSet { + uint16_t num_tiles; + uint16_t max_tile_width; /// Maximum tile width in pixels. + uint16_t max_tile_height; /// Maximum tile height in pixels. + Ts_Tile tiles[1]; /// Count: num_tiles. +} Ts_TileSet; + +typedef struct Tm_Layer { + union { + char tileset_path[MAX_PATH_LENGTH]; // Relative to the Tm_Map file. + }; + Tile tiles[1]; /// Count: world_width * world_height. +} Tm_Layer; + +typedef struct Tm_Map { + uint16_t world_width; /// World width in number of tiles. + uint16_t world_height; /// World height in number of tiles. + uint16_t base_tile_width; + uint16_t base_tile_height; + uint16_t num_layers; + Tm_Layer layers[1]; // Count: num_layers. +} Tm_Map; + +static inline const Tm_Layer* tm_map_get_next_layer( + const Tm_Map* map, const Tm_Layer* layer) { + assert(map); + assert(layer); + return (const Tm_Layer*)((const uint8_t*)layer + sizeof(Tm_Layer) + + ((map->world_width * map->world_height - 1) * + sizeof(Tile))); +} + +static inline const Ts_Tile* ts_tileset_get_next_tile( + const Ts_TileSet* tileset, const Ts_Tile* tile) { + assert(tileset); + assert(tile); + return (const Ts_Tile*)((const uint8_t*)tile + sizeof(Ts_Tile) + + ((tile->width * tile->height - 1) * sizeof(Pixel))); +} + +// ----------------------------------------------------------------------------- +// Sprite sheet file format. +// ----------------------------------------------------------------------------- + +/// A row of sprites in a sprite sheet. +/// +/// Each row in a sprite sheet can have a different number of columns. +/// +/// The pixels of the row follow a "sprite-major" order. It contains the +/// 'sprite_width * sprite_height' pixels for the first column/sprite, then the +/// second column/sprite, etc. +/// +/// Pixels are 8-bit indices into the sprite sheet's colour palette. +typedef struct Ss_Row { + uint16_t num_cols; /// Number of columns in this row. + uint8_t pixels[1]; /// Count: num_cols * sprite_width * sprite_height. +} Ss_Row; + +typedef struct Ss_Palette { + uint16_t num_colours; + Pixel colours[1]; /// Count: num_colors. +} Ss_Palette; + +/// Sprite sheet top-level data definition. +/// +/// Sprite width and height are assumed constant throughout the sprite sheet. +typedef struct Ss_SpriteSheet { + uint16_t sprite_width; /// Sprite width in pixels. + uint16_t sprite_height; /// Sprite height in pixels. + uint16_t num_rows; + Ss_Palette palette; /// Variable size. + Ss_Row rows[1]; /// Count: num_rows. Variable offset. +} Ss_SpriteSheet; + +static inline const Ss_Row* get_sprite_sheet_row( + const Ss_SpriteSheet* sheet, int row) { + assert(sheet); + assert(row >= 0); + assert(row < sheet->num_rows); + // Skip over the palette. + const Ss_Row* rows = + (const Ss_Row*)(&sheet->palette.colours[0] + sheet->palette.num_colours); + return &rows[row]; +} + +static inline const uint8_t* get_sprite_sheet_sprite( + const Ss_SpriteSheet* sheet, const Ss_Row* row, int col) { + assert(sheet); + assert(row); + assert(col >= 0); + assert(col < row->num_cols); + const int sprite_offset = col * sheet->sprite_width * sheet->sprite_height; + const uint8_t* sprite = &row->pixels[sprite_offset]; + return sprite; +} + +// ----------------------------------------------------------------------------- +// Renderer state. +// ----------------------------------------------------------------------------- + +typedef struct TileData { + uint16_t width; + uint16_t height; + uint16_t pixels_handle; // Handle to the tile's pixels in the pixel pool. +} TileData; + +// File format is already convenient for working in memory. +typedef Ss_Row SpriteSheetRow; +typedef Ss_SpriteSheet SpriteSheetData; + +typedef struct SpriteData { + SpriteSheet sheet; // Handle to the sprite's sheet. + ivec2 position; + int animation; // Current animation. + int frame; // Current frame of animation. +} SpriteData; + +DEF_MEMPOOL_DYN(TilePool, TileData) +DEF_MEM_DYN(PixelPool, Pixel) + +DEF_MEMPOOL_DYN(SpritePool, SpriteData) +DEF_MEM_DYN(SpriteSheetPool, SpriteSheetData) + +typedef struct IsoGfx { + int screen_width; + int screen_height; + int tile_width; + int tile_height; + int world_width; + int world_height; + int max_num_sprites; + int sprite_sheet_pool_size_bytes; + double last_animation_time; + Tile* world; + Pixel* screen; + TilePool tiles; + PixelPool pixels; + SpritePool sprites; + SpriteSheetPool sheets; +} IsoGfx; + +// ----------------------------------------------------------------------------- +// Math and world / tile / screen access. +// ----------------------------------------------------------------------------- + +static inline ivec2 ivec2_add(ivec2 a, ivec2 b) { + return (ivec2){.x = a.x + b.x, .y = a.y + b.y}; +} + +static inline ivec2 ivec2_scale(ivec2 a, int s) { + return (ivec2){.x = a.x * s, .y = a.y * s}; +} + +static inline ivec2 iso2cart(ivec2 iso, int s, int t, int w) { + return (ivec2){ + .x = (iso.x - iso.y) * (s / 2) + (w / 2), .y = (iso.x + iso.y) * (t / 2)}; +} + +// Method 1. +// static inline vec2 cart2iso(vec2 cart, int s, int t, int w) { +// const double x = cart.x - (double)(w / 2); +// const double xiso = (x * t + cart.y * s) / (double)(s * t); +// return (vec2){ +// .x = (int)(xiso), .y = (int)((2.0 / (double)t) * cart.y - xiso)}; +//} + +// Method 2. +static inline vec2 cart2iso(vec2 cart, int s, int t, int w) { + const double one_over_s = 1. / (double)s; + const double one_over_t = 1. / (double)t; + const double x = cart.x - (double)(w / 2); + return (vec2){ + .x = (one_over_s * x + one_over_t * cart.y), + .y = (-one_over_s * x + one_over_t * cart.y)}; +} + +static const Pixel* tile_xy_const_ref( + const IsoGfx* iso, const TileData* tile, int x, int y) { + assert(iso); + assert(tile); + assert(x >= 0); + assert(y >= 0); + assert(x < tile->width); + assert(y < tile->height); + return &mem_get_chunk(&iso->pixels, tile->pixels_handle)[y * tile->width + x]; +} + +// static Pixel tile_xy(const IsoGfx* iso, const TileData* tile, int x, int y) { +// return *tile_xy_const_ref(iso, tile, x, y); +// } + +static Pixel* tile_xy_mut(const IsoGfx* iso, TileData* tile, int x, int y) { + return (Pixel*)tile_xy_const_ref(iso, tile, x, y); +} + +static inline const Tile* world_xy_const_ref(const IsoGfx* iso, int x, int y) { + assert(iso); + assert(x >= 0); + assert(y >= 0); + assert(x < iso->world_width); + assert(y < iso->world_height); + return &iso->world[y * iso->world_width + x]; +} + +static inline Tile world_xy(const IsoGfx* iso, int x, int y) { + return *world_xy_const_ref(iso, x, y); +} + +static inline Tile* world_xy_mut(IsoGfx* iso, int x, int y) { + return (Tile*)world_xy_const_ref(iso, x, y); +} + +static inline const Pixel* screen_xy_const_ref( + const IsoGfx* iso, int x, int y) { + assert(iso); + assert(x >= 0); + assert(y >= 0); + assert(x < iso->screen_width); + assert(y < iso->screen_height); + return &iso->screen[y * iso->screen_width + x]; +} + +static inline Pixel screen_xy(IsoGfx* iso, int x, int y) { + return *screen_xy_const_ref(iso, x, y); +} + +static inline Pixel* screen_xy_mut(IsoGfx* iso, int x, int y) { + return (Pixel*)screen_xy_const_ref(iso, x, y); +} + +static int calc_num_tile_blocks( + int base_tile_width, int base_tile_height, int tile_width, + int tile_height) { + const int base_tile_size = base_tile_width * base_tile_height; + const int tile_size = tile_width * tile_height; + const int num_blocks = tile_size / base_tile_size; + return num_blocks; +} + +// ----------------------------------------------------------------------------- +// Renderer, world and tile management. +// ----------------------------------------------------------------------------- + +IsoGfx* isogfx_new(const IsoGfxDesc* desc) { + assert(desc->screen_width > 0); + assert(desc->screen_height > 0); + // Part of our implementation assumes even widths and heights for precision. + assert((desc->screen_width & 1) == 0); + assert((desc->screen_height & 1) == 0); + + IsoGfx* iso = calloc(1, sizeof(IsoGfx)); + if (!iso) { + return 0; + } + + iso->screen_width = desc->screen_width; + iso->screen_height = desc->screen_height; + + iso->last_animation_time = 0.0; + + iso->max_num_sprites = desc->max_num_sprites == 0 ? DEFAULT_MAX_NUM_SPRITES + : desc->max_num_sprites; + iso->sprite_sheet_pool_size_bytes = desc->sprite_sheet_pool_size_bytes == 0 + ? DEFAULT_SPRITE_SHEET_POOL_SIZE_BYTES + : desc->sprite_sheet_pool_size_bytes; + + const int screen_size = desc->screen_width * desc->screen_height; + if (!(iso->screen = calloc(screen_size, sizeof(Pixel)))) { + goto cleanup; + } + + return iso; + +cleanup: + isogfx_del(&iso); + return 0; +} + +/// Destroy the world, its tile set, and the underlying pools. +static void destroy_world(IsoGfx* iso) { + assert(iso); + if (iso->world) { + free(iso->world); + iso->world = 0; + } + mempool_del(&iso->tiles); + mem_del(&iso->pixels); +} + +/// Destroy all loaded sprites and the underlying pools. +static void destroy_sprites(IsoGfx* iso) { + assert(iso); + mempool_del(&iso->sprites); + mem_del(&iso->sheets); +} + +void isogfx_del(IsoGfx** pIso) { + assert(pIso); + IsoGfx* iso = *pIso; + if (iso) { + destroy_world(iso); + destroy_sprites(iso); + if (iso->screen) { + free(iso->screen); + iso->screen = 0; + } + free(iso); + *pIso = 0; + } +} + +bool isogfx_make_world(IsoGfx* iso, const WorldDesc* desc) { + assert(iso); + assert(desc); + assert(desc->tile_width > 0); + assert(desc->tile_height > 0); + // Part of our implementation assumes even widths and heights for greater + // precision. + assert((desc->tile_width & 1) == 0); + assert((desc->tile_height & 1) == 0); + + // Handle recreation by destroying the previous world. + destroy_world(iso); + + iso->tile_width = desc->tile_width; + iso->tile_height = desc->tile_height; + iso->world_width = desc->world_width; + iso->world_height = desc->world_height; + + const int world_size = desc->world_width * desc->world_height; + const int tile_size = desc->tile_width * desc->tile_height; + const int tile_size_bytes = tile_size * (int)sizeof(Pixel); + const int tile_pool_size = + desc->max_num_tiles > 0 ? desc->max_num_tiles : DEFAULT_MAX_NUM_TILES; + + if (!(iso->world = calloc(world_size, sizeof(Tile)))) { + goto cleanup; + } + if (!mempool_make_dyn(&iso->tiles, world_size, sizeof(TileData))) { + goto cleanup; + } + if (!mem_make_dyn(&iso->pixels, tile_pool_size, tile_size_bytes)) { + goto cleanup; + } + + return true; + +cleanup: + destroy_world(iso); + return false; +} + +bool isogfx_load_world(IsoGfx* iso, const char* filepath) { + assert(iso); + assert(filepath); + + bool success = false; + + // Handle recreation by destroying the previous world. + destroy_world(iso); + + // Load the map. + printf("Load tile map: %s\n", filepath); + Tm_Map* map = read_file(filepath); + if (!map) { + goto cleanup; + } + + // Allocate memory for the map and tile sets. + const int world_size = map->world_width * map->world_height; + const int base_tile_size = map->base_tile_width * map->base_tile_height; + const int base_tile_size_bytes = base_tile_size * (int)sizeof(Pixel); + // TODO: Need to get the total number of tiles from the map. + const int tile_pool_size = DEFAULT_MAX_NUM_TILES; + + if (!(iso->world = calloc(world_size, sizeof(Tile)))) { + goto cleanup; + } + if (!mempool_make_dyn(&iso->tiles, tile_pool_size, sizeof(TileData))) { + goto cleanup; + } + if (!mem_make_dyn(&iso->pixels, tile_pool_size, base_tile_size_bytes)) { + goto cleanup; + } + + // Load the tile sets. + const Tm_Layer* layer = &map->layers[0]; + // TODO: Handle num_layers layers. + for (int i = 0; i < 1; ++i) { + const char* ts_path = layer->tileset_path; + + // Tile set path is relative to the tile map file. Make it relative to the + // current working directory before loading. + char ts_path_cwd[PATH_MAX] = {0}; + if (!path_make_relative(filepath, ts_path, ts_path_cwd, PATH_MAX)) { + goto cleanup; + } + + Ts_TileSet* tileset = read_file(ts_path_cwd); + if (!tileset) { + goto cleanup; + }; + + // Load tile data. + const Ts_Tile* tile = &tileset->tiles[0]; + for (uint16_t j = 0; j < tileset->num_tiles; ++j) { + // Tile dimensions should be a multiple of the base tile size. + assert((tile->width % map->base_tile_width) == 0); + assert((tile->height % map->base_tile_height) == 0); + + // Allocate N base tile size blocks for the tile. + const uint16_t tile_size = tile->width * tile->height; + const int num_blocks = tile_size / base_tile_size; + Pixel* pixels = mem_alloc(&iso->pixels, num_blocks); + assert(pixels); + memcpy(pixels, tile->pixels, tile_size * sizeof(Pixel)); + + // Allocate the tile data. + TileData* tile_data = mempool_alloc(&iso->tiles); + assert(tile_data); + tile_data->width = tile->width; + tile_data->height = tile->height; + tile_data->pixels_handle = + (uint16_t)mem_get_chunk_handle(&iso->pixels, pixels); + + tile = ts_tileset_get_next_tile(tileset, tile); + } + + printf("Loaded tile set (%u tiles): %s\n", tileset->num_tiles, ts_path_cwd); + + free(tileset); + layer = tm_map_get_next_layer(map, layer); + } + + // Load the map into the world. + layer = &map->layers[0]; + // TODO: Handle num_layers layers. + for (int i = 0; i < 1; ++i) { + memcpy(iso->world, layer->tiles, world_size * sizeof(Tile)); + + // TODO: We need to handle 'firsgid' in TMX files. + for (int j = 0; j < world_size; ++j) { + iso->world[j] -= 1; + } + + layer = tm_map_get_next_layer(map, layer); + } + + iso->world_width = map->world_width; + iso->world_height = map->world_height; + iso->tile_width = map->base_tile_width; + iso->tile_height = map->base_tile_height; + + success = true; + +cleanup: + if (map) { + free(map); + } + if (!success) { + destroy_world(iso); + } + return success; +} + +int isogfx_world_width(const IsoGfx* iso) { + assert(iso); + return iso->world_width; +} + +int isogfx_world_height(const IsoGfx* iso) { + assert(iso); + return iso->world_height; +} + +/// Create a tile mask procedurally. +static void make_tile_from_colour( + const IsoGfx* iso, Pixel colour, TileData* tile) { + assert(iso); + assert(tile); + + const int width = tile->width; + const int height = tile->height; + const int r = width / height; + + for (int y = 0; y < height / 2; ++y) { + const int mask_start = width / 2 - r * y - 1; + const int mask_end = width / 2 + r * y + 1; + for (int x = 0; x < width; ++x) { + const bool mask = (mask_start <= x) && (x <= mask_end); + const Pixel val = mask ? colour : (Pixel){.r = 0, .g = 0, .b = 0, .a = 0}; + + // Top half. + *tile_xy_mut(iso, tile, x, y) = val; + + // Bottom half reflects the top half. + const int y_reflected = height - y - 1; + *tile_xy_mut(iso, tile, x, y_reflected) = val; + } + } +} + +Tile isogfx_make_tile(IsoGfx* iso, const TileDesc* desc) { + assert(iso); + assert(desc); + // Client must create world before creating tiles. + assert(iso->tile_width > 0); + assert(iso->tile_height > 0); + + TileData* tile = mempool_alloc(&iso->tiles); + assert(tile); // TODO: Make this a hard assert. + + const int num_blocks = calc_num_tile_blocks( + iso->tile_width, iso->tile_height, desc->width, desc->height); + + Pixel* pixels = mem_alloc(&iso->pixels, num_blocks); + assert(pixels); // TODO: Make this a hard assert. + + tile->width = desc->width; + tile->height = desc->height; + tile->pixels_handle = mem_get_chunk_handle(&iso->pixels, pixels); + + switch (desc->type) { + case TileFromColour: + make_tile_from_colour(iso, desc->colour, tile); + break; + case TileFromFile: + assert(false); // TODO + break; + case TileFromMemory: + assert(false); // TODO + break; + } + + return (Tile)mempool_get_block_index(&iso->tiles, tile); +} + +void isogfx_set_tile(IsoGfx* iso, int x, int y, Tile tile) { + assert(iso); + *world_xy_mut(iso, x, y) = tile; +} + +void isogfx_set_tiles(IsoGfx* iso, int x0, int y0, int x1, int y1, Tile tile) { + assert(iso); + for (int y = y0; y < y1; ++y) { + for (int x = x0; x < x1; ++x) { + isogfx_set_tile(iso, x, y, tile); + } + } +} + +bool isogfx_load_sprite_sheet( + IsoGfx* iso, const char* filepath, SpriteSheet* p_sheet) { + assert(iso); + assert(filepath); + assert(p_sheet); + + bool success = false; + + // Lazy initialization of sprite pools. + if (mempool_capacity(&iso->sprites) == 0) { + if (!mempool_make_dyn( + &iso->sprites, iso->max_num_sprites, sizeof(SpriteData))) { + return false; + } + } + if (mem_capacity(&iso->sheets) == 0) { + // Using a block size of 1 byte for sprite sheet data. + if (!mem_make_dyn(&iso->sheets, iso->sprite_sheet_pool_size_bytes, 1)) { + return false; + } + } + + // Load sprite sheet file. + printf("Load sprite sheet: %s\n", filepath); + FILE* file = fopen(filepath, "rb"); + if (file == NULL) { + goto cleanup; + } + const size_t sheet_size = get_file_size(file); + SpriteSheetData* ss_sheet = mem_alloc(&iso->sheets, sheet_size); + if (!ss_sheet) { + goto cleanup; + } + if (fread(ss_sheet, sheet_size, 1, file) != 1) { + goto cleanup; + } + + *p_sheet = mem_get_chunk_handle(&iso->sheets, ss_sheet); + success = true; + +cleanup: + // Pools remain initialized since client may attempt to load other sprites. + if (file != NULL) { + fclose(file); + } + if (!success) { + if (ss_sheet) { + mem_free(&iso->sheets, &ss_sheet); + } + } + return success; +} + +Sprite isogfx_make_sprite(IsoGfx* iso, SpriteSheet sheet) { + assert(iso); + + SpriteData* sprite = mempool_alloc(&iso->sprites); + assert(sprite); + + sprite->sheet = sheet; + + return mempool_get_block_index(&iso->sprites, sprite); +} + +#define with_sprite(SPRITE, BODY) \ + { \ + SpriteData* data = mempool_get_block(&iso->sprites, sprite); \ + assert(data); \ + BODY; \ + } + +void isogfx_set_sprite_position(IsoGfx* iso, Sprite sprite, int x, int y) { + assert(iso); + with_sprite(sprite, { + data->position.x = x; + data->position.y = y; + }); +} + +void isogfx_set_sprite_animation(IsoGfx* iso, Sprite sprite, int animation) { + assert(iso); + with_sprite(sprite, { data->animation = animation; }); +} + +void isogfx_update(IsoGfx* iso, double t) { + assert(iso); + + // If this is the first time update() is called after initialization, just + // record the starting animation time. + if (iso->last_animation_time == 0.0) { + iso->last_animation_time = t; + return; + } + + if ((t - iso->last_animation_time) >= ANIMATION_UPDATE_DELTA) { + // TODO: Consider linking animated sprites in a list so that we only walk + // over those here and not also the static sprites. + mempool_foreach(&iso->sprites, sprite, { + const SpriteSheetData* sheet = mem_get_chunk(&iso->sheets, sprite->sheet); + assert(sheet); // TODO: Make this a hard assert inside the mem/pool. + const SpriteSheetRow* row = + get_sprite_sheet_row(sheet, sprite->animation); + sprite->frame = (sprite->frame + 1) % row->num_cols; + }); + + iso->last_animation_time = t; + } +} + +// ----------------------------------------------------------------------------- +// Rendering and picking. +// ----------------------------------------------------------------------------- + +typedef struct CoordSystem { + ivec2 o; /// Origin. + ivec2 x; + ivec2 y; +} CoordSystem; + +/// Create the basis for the isometric coordinate system with origin and vectors +/// expressed in the Cartesian system. +static CoordSystem make_iso_coord_system(const IsoGfx* iso) { + assert(iso); + const ivec2 o = {iso->screen_width / 2, 0}; + const ivec2 x = {.x = iso->tile_width / 2, .y = iso->tile_height / 2}; + const ivec2 y = {.x = -iso->tile_width / 2, .y = iso->tile_height / 2}; + return (CoordSystem){o, x, y}; +} + +/// Get the screen position of the top diamond-corner of the tile at world +/// (x,y). +static ivec2 GetTileScreenOrigin( + const CoordSystem iso_space, int world_x, int world_y) { + const ivec2 vx_offset = ivec2_scale(iso_space.x, world_x); + const ivec2 vy_offset = ivec2_scale(iso_space.y, world_y); + const ivec2 screen_origin = + ivec2_add(iso_space.o, ivec2_add(vx_offset, vy_offset)); + + return screen_origin; +} + +static Pixel alpha_blend(Pixel src, Pixel dst) { + if ((src.a == 255) || (dst.a == 0)) { + return src; + } + const uint16_t one_minus_alpha = 255 - src.a; +#define blend(s, d) \ + (Channel)( \ + (double)((uint16_t)s * (uint16_t)src.a + \ + (uint16_t)d * one_minus_alpha) / \ + 255.0) + return (Pixel){ + .r = blend(src.r, dst.r), + .g = blend(src.g, dst.g), + .b = blend(src.b, dst.b), + .a = src.a}; +} + +/// Draw a rectangle (tile or sprite). +/// +/// The rectangle's top-left corner is mapped to the screen space position given +/// by 'top_left'. +/// +/// The rectangle's pixels are assumed to be arranged in a linear, row-major +/// fashion. +/// +/// If indices are given, then the image is assumed to be colour-paletted, where +/// 'pixels' is the palette and 'indices' the pixel indices. Otherwise, the +/// image is assumed to be in plain RGBA format. +static void draw_rect( + IsoGfx* iso, ivec2 top_left, int rect_width, int rect_height, + const Pixel* pixels, const uint8_t* indices) { + assert(iso); + +#define rect_pixel(X, Y) \ + (indices ? pixels[indices[Y * rect_width + X]] : pixels[Y * rect_width + X]) + + // Rect origin can be outside screen bounds, so we must offset accordingly to + // draw only the visible portion. +#define max(a, b) (a > b ? a : b) + const int px_offset = max(0, -top_left.x); + const int py_offset = max(0, -top_left.y); + + // Rect can exceed screen bounds, so clip along Y and X as we draw. + for (int py = py_offset; + (py < rect_height) && (top_left.y + py < iso->screen_height); ++py) { + const int sy = top_left.y + py; + for (int px = px_offset; + (px < rect_width) && (top_left.x + px < iso->screen_width); ++px) { + const Pixel colour = rect_pixel(px, py); + if (colour.a > 0) { + const int sx = top_left.x + px; + const Pixel dst = screen_xy(iso, sx, sy); + const Pixel final = alpha_blend(colour, dst); + *screen_xy_mut(iso, sx, sy) = final; + } + } + } +} + +/// Draw a tile. +/// +/// 'screen_origin' is the screen coordinates of the top diamond-corner of the +/// tile (the base tile for super tiles). +/// World (0, 0) -> (screen_width / 2, 0). +static void draw_tile(IsoGfx* iso, ivec2 screen_origin, Tile tile) { + assert(iso); + + const TileData* tile_data = mempool_get_block(&iso->tiles, tile); + assert(tile_data); + const Pixel* pixels = tile_xy_const_ref(iso, tile_data, 0, 0); + + // Move from the top diamond-corner to the top-left corner of the tile image. + // For regular tiles, tile height == base tile height, so the y offset is 0. + // For super tiles, move as high up as the height of the tile. + const ivec2 offset = { + -(iso->tile_width / 2), tile_data->height - iso->tile_height}; + const ivec2 top_left = ivec2_add(screen_origin, offset); + + draw_rect(iso, top_left, tile_data->width, tile_data->height, pixels, 0); +} + +static void draw_world(IsoGfx* iso) { + assert(iso); + + const int W = iso->screen_width; + const int H = iso->screen_height; + + memset(iso->screen, 0, W * H * sizeof(Pixel)); + + const CoordSystem iso_space = make_iso_coord_system(iso); + + // TODO: Culling. + // Ex: map the screen corners to tile space to cull. + // Ex: walk in screen space and fetch the tile. + // The tile-centric approach might be more cache-friendly since the + // screen-centric approach would juggle multiple tiles throughout the scan. + for (int wy = 0; wy < iso->world_height; ++wy) { + for (int wx = 0; wx < iso->world_width; ++wx) { + const Tile tile = world_xy(iso, wx, wy); + const ivec2 screen_origin = GetTileScreenOrigin(iso_space, wx, wy); + draw_tile(iso, screen_origin, tile); + } + } +} + +static void draw_sprite( + IsoGfx* iso, ivec2 origin, const SpriteData* sprite, + const SpriteSheetData* sheet) { + assert(iso); + assert(sprite); + assert(sheet); + assert(sprite->animation >= 0); + assert(sprite->animation < sheet->num_rows); + assert(sprite->frame >= 0); + + const SpriteSheetRow* row = get_sprite_sheet_row(sheet, sprite->animation); + const uint8_t* frame = get_sprite_sheet_sprite(sheet, row, sprite->frame); + draw_rect( + iso, origin, sheet->sprite_width, sheet->sprite_height, + sheet->palette.colours, frame); +} + +static void draw_sprites(IsoGfx* iso) { + assert(iso); + + const CoordSystem iso_space = make_iso_coord_system(iso); + + mempool_foreach(&iso->sprites, sprite, { + const SpriteSheetData* sheet = mem_get_chunk(&iso->sheets, sprite->sheet); + assert(sheet); + + const ivec2 screen_origin = + GetTileScreenOrigin(iso_space, sprite->position.x, sprite->position.y); + draw_sprite(iso, screen_origin, sprite, sheet); + }); +} + +void isogfx_render(IsoGfx* iso) { + assert(iso); + draw_world(iso); + draw_sprites(iso); +} + +void isogfx_draw_tile(IsoGfx* iso, int x, int y, Tile tile) { + assert(iso); + assert(x >= 0); + assert(y >= 0); + assert(x < iso->world_width); + assert(y < iso->world_height); + + const CoordSystem iso_space = make_iso_coord_system(iso); + const ivec2 screen_origin = GetTileScreenOrigin(iso_space, x, y); + draw_tile(iso, screen_origin, tile); +} + +bool isogfx_resize(IsoGfx* iso, int screen_width, int screen_height) { + assert(iso); + assert(iso->screen); + + const int current_size = iso->screen_width * iso->screen_height; + const int new_size = screen_width * screen_height; + + if (new_size > current_size) { + Pixel* new_screen = calloc(new_size, sizeof(Pixel)); + if (new_screen) { + free(iso->screen); + iso->screen = new_screen; + } else { + return false; + } + } + iso->screen_width = screen_width; + iso->screen_height = screen_height; + return true; +} + +void isogfx_get_screen_size(const IsoGfx* iso, int* width, int* height) { + assert(iso); + assert(width); + assert(height); + *width = iso->screen_width; + *height = iso->screen_height; +} + +const Pixel* isogfx_get_screen_buffer(const IsoGfx* iso) { + assert(iso); + return iso->screen; +} + +void isogfx_pick_tile( + const IsoGfx* iso, double xcart, double ycart, int* xiso, int* yiso) { + assert(iso); + assert(xiso); + assert(yiso); + + const vec2 xy_iso = cart2iso( + (vec2){.x = xcart, .y = ycart}, iso->tile_width, iso->tile_height, + iso->screen_width); + + if ((0 <= xy_iso.x) && (xy_iso.x < iso->world_width) && (0 <= xy_iso.y) && + (xy_iso.y < iso->world_height)) { + *xiso = (int)xy_iso.x; + *yiso = (int)xy_iso.y; + } else { + *xiso = -1; + *yiso = -1; + } +} diff --git a/tools/mkasset.py b/tools/mkasset.py new file mode 100644 index 0000000..3ca8a1d --- /dev/null +++ b/tools/mkasset.py @@ -0,0 +1,324 @@ +# Converts assets to binary formats (.ts, .tm, .ss) for the engine. +# +# Input file formats: +# - Tiled tile set (.tsx) +# - Tiled tile map (.tmx) +# - Sprite sheets (.jpg, .png, etc), 1 row per animation. +# +# Output file formats: +# - Binary tile set file (.ts) +# - Binary tile map file (.tm) +# - Binary sprite sheet file (.ss) +# +import argparse +import ctypes +import os +from PIL import Image +import sys +from xml.etree import ElementTree + +# Maximum length of path strings in .TS and .TM files. +# Must match the engine's value. +MAX_PATH_LENGTH = 128 + + +def drop_extension(filepath): + return filepath[:filepath.rfind('.')] + + +def to_char_array(string, length): + """Convert a string to a fixed-length ASCII char array. + + The length of str must be at most length-1 so that the resulting string can + be null-terminated. + """ + assert (len(string) < length) + chars = string.encode("ascii") + nulls = ("\0" * (length - len(string))).encode("ascii") + return chars + nulls + + +def convert_tsx(input_filepath, output_filepath): + """Converts a Tiled .tsx tileset file to a .TS tile set file.""" + xml = ElementTree.parse(input_filepath) + root = xml.getroot() + + tile_count = int(root.attrib["tilecount"]) + max_tile_width = int(root.attrib["tilewidth"]) + max_tile_height = int(root.attrib["tileheight"]) + + print(f"Tile count: {tile_count}") + print(f"Max width: {max_tile_width}") + print(f"Max height: {max_tile_height}") + + with open(output_filepath, 'bw') as output: + output.write(ctypes.c_uint16(tile_count)) + output.write(ctypes.c_uint16(max_tile_width)) + output.write(ctypes.c_uint16(max_tile_height)) + + num_tile = 0 + for tile in root: + # Skip the "grid" and other non-tile elements. + if not tile.tag == "tile": + continue + + # Assuming tiles are numbered 0..N. + tile_id = int(tile.attrib["id"]) + assert (tile_id == num_tile) + num_tile += 1 + + image = tile[0] + tile_width = int(image.attrib["width"]) + tile_height = int(image.attrib["height"]) + tile_path = image.attrib["source"] + + output.write(ctypes.c_uint16(tile_width)) + output.write(ctypes.c_uint16(tile_height)) + + with Image.open(tile_path) as im: + bytes = im.convert('RGBA').tobytes() + output.write(bytes) + + +def convert_tmx(input_filepath, output_filepath): + """Converts a Tiled .tmx file to a .TM tile map file.""" + xml = ElementTree.parse(input_filepath) + root = xml.getroot() + + map_width = int(root.attrib["width"]) + map_height = int(root.attrib["height"]) + base_tile_width = int(root.attrib["tilewidth"]) + base_tile_height = int(root.attrib["tileheight"]) + num_layers = 1 + + print(f"Map width: {map_width}") + print(f"Map height: {map_height}") + print(f"Tile width: {base_tile_width}") + print(f"Tile height: {base_tile_height}") + + with open(output_filepath, 'bw') as output: + output.write(ctypes.c_uint16(map_width)) + output.write(ctypes.c_uint16(map_height)) + output.write(ctypes.c_uint16(base_tile_width)) + output.write(ctypes.c_uint16(base_tile_height)) + output.write(ctypes.c_uint16(num_layers)) + + tileset_path = None + + for child in root: + if child.tag == "tileset": + tileset = child + tileset_path = tileset.attrib["source"] + + print(f"Tile set: {tileset_path}") + + tileset_path = tileset_path.replace("tsx", "ts") + elif child.tag == "layer": + layer = child + layer_id = int(layer.attrib["id"]) + layer_width = int(layer.attrib["width"]) + layer_height = int(layer.attrib["height"]) + + print(f"Layer: {layer_id}") + print(f"Width: {layer_width}") + print(f"Height: {layer_height}") + + assert (tileset_path) + output.write(to_char_array(tileset_path, MAX_PATH_LENGTH)) + + # Assume the layer's dimensions matches the map's. + assert (layer_width == map_width) + assert (layer_height == map_height) + + data = layer[0] + # Handle other encodings later. + assert (data.attrib["encoding"] == "csv") + + csv = data.text.strip() + rows = csv.split('\n') + for row in rows: + tile_ids = [x.strip() for x in row.split(',') if x] + for tile_id in tile_ids: + output.write(ctypes.c_uint16(int(tile_id))) + + +def get_num_cols(image, sprite_width): + """Return the number of non-empty columns in the image. + + Assumes no gaps in the columns. + """ + assert (image.width % sprite_width == 0) + num_cols = image.width // sprite_width + + # Start the search from right to left. + for col in reversed(range(1, num_cols)): + left = (col - 1) * sprite_width + right = col * sprite_width + rect = image.crop((left, 0, right, image.height)) + min_max = rect.getextrema() + for (channel_min, channel_max) in min_max: + if channel_min != 0 or channel_max != 0: + # 'col' is the rightmost non-empty column. + # Assuming no gaps, col+1 is the number of non-empty columns. + return col + 1 + + return 0 + + +def get_sprite_sheet_rows(im, sprite_width, sprite_height): + """Gets the individual rows of a sprite sheet. + + The input sprite sheet can have any number of rows. + + Returns a list of lists [[sprite]], one inner list for the columns in each + row. + """ + # Sprite sheet's width and height must be integer multiples of the + # sprite's width and height. + assert (im.width % sprite_width == 0) + assert (im.height % sprite_height == 0) + + num_rows = im.height // sprite_height + + rows = [] + for row in range(num_rows): + # Get the number of columns. + upper = row * sprite_height + lower = (row + 1) * sprite_height + whole_row = im.crop((0, upper, im.width, lower)) + num_cols = get_num_cols(whole_row, sprite_width) + assert (num_cols > 0) + + # Crop the row into N columns. + cols = [] + for i in range(num_cols): + left = i * sprite_width + right = (i + 1) * sprite_width + sprite = im.crop((left, upper, right, lower)) + cols.append(sprite) + + assert (len(cols) == num_cols) + rows.append(cols) + + return rows + + +def make_image_from_rows(rows, sprite_width, sprite_height): + """Concatenate the rows into a single RGBA image.""" + im_width = sprite_width * max(len(row) for row in rows) + im_height = len(rows) * sprite_height + im = Image.new('RGBA', (im_width, im_height)) + y = 0 + for row in rows: + x = 0 + for sprite in row: + im.paste(sprite.convert('RGBA'), (x, y)) + x += sprite_width + y += sprite_height + return im + + +def convert_sprite_sheet(input_file_paths, sprite_width, sprite_height, + output_filepath): + """Converts a set of sprite sheet images into a binary sprite sheet file + (.ss). + + The input sprite sheets can have any number of rows, one row per animation. + All rows from all sprite sheets are concatenated in the output file. + + The sprite's width and height is assumed constant throughout the input + sprite sheets. + """ + rows = [] + for input_filepath in input_file_paths: + with Image.open(input_filepath) as sprite_sheet: + rows.extend( + get_sprite_sheet_rows(sprite_sheet, sprite_width, + sprite_height)) + + im = make_image_from_rows(rows, sprite_width, sprite_height) + im = im.convert(mode="P", palette=Image.ADAPTIVE, colors=256) + + # The sprite data in 'rows' is no longer needed. + # Keep just the number of columns per row. + rows = [len(row) for row in rows] + + with open(output_filepath, 'bw') as output: + output.write(ctypes.c_uint16(sprite_width)) + output.write(ctypes.c_uint16(sprite_height)) + output.write(ctypes.c_uint16(len(rows))) + + # Write palette. + # getpalette() returns 256 colors, but the palette might use less than + # that. getcolors() returns the number of unique colors. + # getpalette() also returns a flattened list, which is why we must *4. + num_colours = len(im.getcolors()) + colours = im.getpalette(rawmode="RGBA")[:4 * num_colours] + palette = [] + for i in range(0, 4 * num_colours, 4): + palette.append((colours[i], colours[i + 1], colours[i + 2], + colours[i + 3])) + + output.write(ctypes.c_uint16(len(palette))) + output.write(bytearray(colours)) + + print(f"Sprite width: {sprite_width}") + print(f"Sprite height: {sprite_height}") + print(f"Rows: {len(rows)}") + print(f"Colours: {len(palette)}") + + # print("Palette") + # for i, colour in enumerate(palette): + # print(f"{i}: {colour}") + + for row, num_columns in enumerate(rows): + output.write(ctypes.c_uint16(num_columns)) + upper = row * sprite_height + lower = (row + 1) * sprite_height + for col in range(num_columns): + left = col * sprite_width + right = (col + 1) * sprite_width + sprite = im.crop((left, upper, right, lower)) + sprite_bytes = sprite.tobytes() + + assert (len(sprite_bytes) == sprite_width * sprite_height) + output.write(sprite_bytes) + + # if (row == 0) and (col == 0): + # print(f"Sprite: ({len(sprite_bytes)})") + # print(list(sprite_bytes)) + # sprite.save("out.png") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("input", + nargs="+", + help="Input file (.tsx, .tmx) or path regex (sprite sheets)") + parser.add_argument("--width", type=int, help="Sprite width in pixels") + parser.add_argument("--height", type=int, help="Sprite height in pixels") + parser.add_argument("--out", help="Output file (sprite sheets)") + args = parser.parse_args() + + if ".tsx" in args.input: + output_filepath_no_ext = drop_extension(args.input) + output_filepath = output_filepath_no_ext + ".ts" + convert_tsx(args.input, output_filepath) + elif ".tmx" in args.input: + output_filepath_no_ext = drop_extension(args.input) + output_filepath = output_filepath_no_ext + ".tm" + convert_tmx(args.input, output_filepath) + else: + # Sprite sheets. + if not args.width or not args.height: + print("Sprite width and height must be given") + return 1 + output_filepath = args.out if args.out else "out.ss" + convert_sprite_sheet(args.input, args.width, args.height, + output_filepath) + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) -- cgit v1.2.3