From adbd2511beec8f1caa1752bdfd755cc2f62ba425 Mon Sep 17 00:00:00 2001
From: 3gg <3gg@shellblade.net>
Date: Sat, 9 Mar 2024 08:43:26 -0800
Subject: Make isogfx a library instead of an executable.

---
 gfx-iso/CMakeLists.txt                    |  29 ++-
 gfx-iso/app/app.h                         |  12 --
 gfx-iso/app/checkerboard.c                | 120 -----------
 gfx-iso/app/checkerboard.h                |   9 -
 gfx-iso/app/isogfx-demo.c                 |  79 --------
 gfx-iso/app/isogfx-demo.h                 |   9 -
 gfx-iso/app/main.c                        | 199 ------------------
 gfx-iso/asset/mkasset.py                  | 324 ------------------------------
 gfx-iso/demos/CMakeLists.txt              |   2 +
 gfx-iso/demos/checkerboard/CMakeLists.txt |  15 ++
 gfx-iso/demos/checkerboard/checkerboard.c | 114 +++++++++++
 gfx-iso/demos/isomap/CMakeLists.txt       |  15 ++
 gfx-iso/demos/isomap/isomap.c             |  72 +++++++
 gfx-iso/include/isogfx/app.h              |  22 ++
 gfx-iso/src/app.c                         | 198 ++++++++++++++++++
 gfx-iso/tools/mkasset.py                  | 324 ++++++++++++++++++++++++++++++
 16 files changed, 781 insertions(+), 762 deletions(-)
 delete mode 100644 gfx-iso/app/app.h
 delete mode 100644 gfx-iso/app/checkerboard.c
 delete mode 100644 gfx-iso/app/checkerboard.h
 delete mode 100644 gfx-iso/app/isogfx-demo.c
 delete mode 100644 gfx-iso/app/isogfx-demo.h
 delete mode 100644 gfx-iso/app/main.c
 delete mode 100644 gfx-iso/asset/mkasset.py
 create mode 100644 gfx-iso/demos/CMakeLists.txt
 create mode 100644 gfx-iso/demos/checkerboard/CMakeLists.txt
 create mode 100644 gfx-iso/demos/checkerboard/checkerboard.c
 create mode 100644 gfx-iso/demos/isomap/CMakeLists.txt
 create mode 100644 gfx-iso/demos/isomap/isomap.c
 create mode 100644 gfx-iso/include/isogfx/app.h
 create mode 100644 gfx-iso/src/app.c
 create mode 100644 gfx-iso/tools/mkasset.py

(limited to 'gfx-iso')

diff --git a/gfx-iso/CMakeLists.txt b/gfx-iso/CMakeLists.txt
index 993bbb3..673cb68 100644
--- a/gfx-iso/CMakeLists.txt
+++ b/gfx-iso/CMakeLists.txt
@@ -2,33 +2,42 @@ cmake_minimum_required(VERSION 3.0)
 
 project(isogfx)
 
-set(CMAKE_C_STANDARD 11)
+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 PRIVATE
+target_link_libraries(isogfx PUBLIC
   filesystem
   mem
   mempool)
 
 target_compile_options(isogfx PRIVATE -Wall -Wextra -Wpedantic)
 
-# Demo
+# App
 
-project(isogfx-app)
+add_library(isogfx-app
+  src/app.c)
 
-add_executable(isogfx-app
-  app/checkerboard.c
-  app/isogfx-demo.c
-  app/main.c)
+target_include_directories(isogfx-app PUBLIC
+  include)
 
-target_link_libraries(isogfx-app PRIVATE
-  gfx
+target_link_libraries(isogfx-app PUBLIC
   gfx-app
   isogfx)
+
+target_link_libraries(isogfx-app PRIVATE
+  gfx)
+
+target_compile_options(isogfx-app PRIVATE -Wall -Wextra -Wpedantic)
+
+# Demos
+
+add_subdirectory(demos)
diff --git a/gfx-iso/app/app.h b/gfx-iso/app/app.h
deleted file mode 100644
index 25e55eb..0000000
--- a/gfx-iso/app/app.h
+++ /dev/null
@@ -1,12 +0,0 @@
-#pragma once
-
-typedef struct IsoGfx    IsoGfx;
-typedef struct IsoGfxApp IsoGfxApp;
-
-typedef struct IsoGfxApp {
-  int   pixel_scale; // 0 or 1 for 1:1 scale.
-  void* state;
-  void (*shutdown)(IsoGfx*, void* state);
-  void (*update)(IsoGfx*, void* state, double t, double dt);
-  void (*render)(IsoGfx*, void* state);
-} IsoGfxApp;
diff --git a/gfx-iso/app/checkerboard.c b/gfx-iso/app/checkerboard.c
deleted file mode 100644
index 8b394c4..0000000
--- a/gfx-iso/app/checkerboard.c
+++ /dev/null
@@ -1,120 +0,0 @@
-#include "isogfx-demo.h"
-
-#include <gfx/gfx_app.h>
-#include <isogfx/isogfx.h>
-
-#include <assert.h>
-#include <stdbool.h>
-#include <stdio.h>
-#include <stdlib.h>
-
-static const int TILE_WIDTH   = 64;
-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 State {
-  Tile red;
-  int  xpick;
-  int  ypick;
-} State;
-
-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 void shutdown(IsoGfx* iso, void* app_state) {
-  assert(iso);
-  if (app_state) {
-    free(app_state);
-  }
-}
-
-static void update(IsoGfx* iso, void* app_state, double t, double dt) {
-  assert(iso);
-  assert(app_state);
-  State* state = (State*)(app_state);
-
-  double mouse_x, mouse_y;
-  gfx_app_get_mouse_position(&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(IsoGfx* iso, void* app_state) {
-  assert(iso);
-  assert(app_state);
-  State* state = (State*)(app_state);
-
-  isogfx_render(iso);
-  if ((state->xpick != -1) && (state->ypick != -1)) {
-    isogfx_draw_tile(iso, state->xpick, state->ypick, state->red);
-  }
-}
-
-bool make_checkerboard_app(IsoGfx* iso, IsoGfxApp* app) {
-  assert(iso);
-  assert(app);
-
-  State* state = calloc(1, sizeof(State));
-  if (!state) {
-    return false;
-  }
-
-  if (!isogfx_make_world(
-          iso, &(WorldDesc){
-                   .tile_width   = TILE_WIDTH,
-                   .tile_height  = TILE_HEIGHT,
-                   .world_width  = WORLD_WIDTH,
-                   .world_height = WORLD_HEIGHT})) {
-    goto cleanup;
-  }
-
-  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);
-  isogfx_render(iso);
-
-  app->state    = state;
-  app->shutdown = shutdown;
-  app->update   = update;
-  app->render   = render;
-
-  return true;
-
-cleanup:
-  free(state);
-  return false;
-}
diff --git a/gfx-iso/app/checkerboard.h b/gfx-iso/app/checkerboard.h
deleted file mode 100644
index 61725a5..0000000
--- a/gfx-iso/app/checkerboard.h
+++ /dev/null
@@ -1,9 +0,0 @@
-#pragma once
-
-#include "app.h"
-
-#include <stdbool.h>
-
-typedef struct IsoGfxApp IsoGfxApp;
-
-bool make_checkerboard_app(IsoGfx*, IsoGfxApp*);
diff --git a/gfx-iso/app/isogfx-demo.c b/gfx-iso/app/isogfx-demo.c
deleted file mode 100644
index 9889275..0000000
--- a/gfx-iso/app/isogfx-demo.c
+++ /dev/null
@@ -1,79 +0,0 @@
-#include "isogfx-demo.h"
-
-#include <gfx/gfx_app.h>
-#include <isogfx/isogfx.h>
-
-#include <assert.h>
-#include <stdbool.h>
-#include <stdio.h>
-#include <stdlib.h>
-
-typedef struct State {
-  int         xpick;
-  int         ypick;
-  SpriteSheet stag_sheet;
-  Sprite      stag;
-} State;
-
-static void shutdown(IsoGfx* iso, void* app_state) {
-  assert(iso);
-  if (app_state) {
-    free(app_state);
-  }
-}
-
-static void update(IsoGfx* iso, void* app_state, double t, double dt) {
-  assert(iso);
-  assert(app_state);
-  State* state = (State*)(app_state);
-
-  double mouse_x, mouse_y;
-  gfx_app_get_mouse_position(&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(IsoGfx* iso, void* app_state) {
-  assert(iso);
-  assert(app_state);
-  State* state = (State*)(app_state);
-
-  isogfx_render(iso);
-}
-
-bool make_demo_app(IsoGfx* iso, IsoGfxApp* app) {
-  assert(iso);
-  assert(app);
-
-  State* state = calloc(1, sizeof(State));
-  if (!state) {
-    return false;
-  }
-
-  if (!isogfx_load_world(iso, "/home/jeanne/assets/tilemaps/demo1.tm")) {
-    goto cleanup;
-  }
-
-  if (!isogfx_load_sprite_sheet(
-          iso, "/home/jeanne/assets/tilesets/scrabling/critters/stag/stag.ss",
-          &state->stag_sheet)) {
-    goto cleanup;
-  }
-
-  state->stag = isogfx_make_sprite(iso, state->stag_sheet);
-  isogfx_set_sprite_position(iso, state->stag, 5, 4);
-
-  app->pixel_scale = 2;
-  app->state       = state;
-  app->shutdown    = shutdown;
-  app->update      = update;
-  app->render      = render;
-
-  return true;
-
-cleanup:
-  free(state);
-  return false;
-}
diff --git a/gfx-iso/app/isogfx-demo.h b/gfx-iso/app/isogfx-demo.h
deleted file mode 100644
index d099824..0000000
--- a/gfx-iso/app/isogfx-demo.h
+++ /dev/null
@@ -1,9 +0,0 @@
-#pragma once
-
-#include "app.h"
-
-#include <stdbool.h>
-
-typedef struct IsoGfxApp IsoGfxApp;
-
-bool make_demo_app(IsoGfx*, IsoGfxApp*);
diff --git a/gfx-iso/app/main.c b/gfx-iso/app/main.c
deleted file mode 100644
index 050a42f..0000000
--- a/gfx-iso/app/main.c
+++ /dev/null
@@ -1,199 +0,0 @@
-#include "app.h"
-#include "checkerboard.h"
-#include "isogfx-demo.h"
-
-#include <isogfx/isogfx.h>
-
-#include <gfx/gfx.h>
-#include <gfx/gfx_app.h>
-#include <gfx/render_backend.h>
-#include <gfx/renderer.h>
-#include <gfx/scene.h>
-#include <gfx/util/geometry.h>
-#include <gfx/util/shader.h>
-
-#include <assert.h>
-#include <stdbool.h>
-#include <stdlib.h>
-
-static const int SCREEN_WIDTH  = 1408;
-static const int SCREEN_HEIGHT = 960;
-
-typedef struct State {
-  Gfx*      gfx;
-  IsoGfx*   iso;
-  IsoGfxApp app;
-  Texture*  screen_texture;
-  Scene*    scene;
-} State;
-
-static bool init(const GfxAppDesc* desc, void** app_state) {
-  State* state = calloc(1, sizeof(State));
-  if (!state) {
-    return false;
-  }
-
-  if (!(state->iso = isogfx_new(&(IsoGfxDesc){
-            .screen_width = SCREEN_WIDTH, .screen_height = SCREEN_HEIGHT}))) {
-    goto cleanup;
-  }
-  //  if (!make_checkerboard_app(state->iso, &state->app)) {
-  //    goto cleanup;
-  //  }
-  if (!make_demo_app(state->iso, &state->app)) {
-    goto cleanup;
-  }
-
-  // Apply pixel scaling if requested by the app.
-  int texture_width, texture_height;
-  if (state->app.pixel_scale > 1) {
-    texture_width  = SCREEN_WIDTH / state->app.pixel_scale;
-    texture_height = SCREEN_HEIGHT / state->app.pixel_scale;
-    isogfx_resize(state->iso, texture_width, texture_height);
-  } else {
-    texture_width  = SCREEN_WIDTH;
-    texture_height = SCREEN_HEIGHT;
-  }
-
-  if (!(state->gfx = gfx_init())) {
-    goto cleanup;
-  }
-  RenderBackend* render_backend = gfx_get_render_backend(state->gfx);
-
-  if (!(state->screen_texture = gfx_make_texture(
-            render_backend, &(TextureDesc){
-                                .width     = texture_width,
-                                .height    = texture_height,
-                                .dimension = Texture2D,
-                                .format    = TextureSRGBA8,
-                                .filtering = NearestFiltering,
-                                .wrap      = ClampToEdge,
-                                .mipmaps   = false}))) {
-    goto cleanup;
-  }
-
-  ShaderProgram* shader = gfx_make_view_texture_shader(render_backend);
-  if (!shader) {
-    goto cleanup;
-  }
-
-  Geometry* geometry = gfx_make_quad_11(render_backend);
-  if (!geometry) {
-    goto cleanup;
-  }
-
-  MaterialDesc material_desc = (MaterialDesc){.num_uniforms = 1};
-  material_desc.uniforms[0]  = (ShaderUniform){
-       .type          = UniformTexture,
-       .value.texture = state->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;
-  }
-
-  state->scene    = gfx_make_scene();
-  SceneNode* node = gfx_make_object_node(object);
-  SceneNode* root = gfx_get_scene_root(state->scene);
-  gfx_set_node_parent(node, root);
-
-  *app_state = state;
-  return true;
-
-cleanup:
-  if (state->gfx) {
-    gfx_destroy(&state->gfx);
-  }
-  free(state);
-  return false;
-}
-
-static void shutdown(void* app_state) {
-  assert(app_state);
-  State* state = (State*)(app_state);
-
-  if (state->app.state) {
-    assert(state->iso);
-    (*state->app.shutdown)(state->iso, state->app.state);
-  }
-  isogfx_del(&state->iso);
-  gfx_destroy(&state->gfx);
-  free(app_state);
-}
-
-static void update(void* app_state, double t, double dt) {
-  assert(app_state);
-  State* state = (State*)(app_state);
-
-  isogfx_update(state->iso, t);
-
-  assert(state->app.update);
-  (*state->app.update)(state->iso, state->app.state, t, dt);
-}
-
-static void render(void* app_state) {
-  assert(app_state);
-  State* state = (State*)(app_state);
-
-  assert(state->app.render);
-  (*state->app.render)(state->iso, state->app.state);
-
-  const Pixel* screen = isogfx_get_screen_buffer(state->iso);
-  assert(screen);
-  gfx_update_texture(
-      state->screen_texture, &(TextureDataDesc){.pixels = screen});
-
-  RenderBackend* render_backend = gfx_get_render_backend(state->gfx);
-  Renderer*      renderer       = gfx_get_renderer(state->gfx);
-
-  gfx_start_frame(render_backend);
-  gfx_render_scene(
-      renderer, &(RenderSceneParams){
-                    .mode = RenderDefault, .scene = state->scene, .camera = 0});
-  gfx_end_frame(render_backend);
-}
-
-static void resize(void* app_state, int width, int height) {
-  assert(app_state);
-  State* state = (State*)(app_state);
-
-  RenderBackend* render_backend = gfx_get_render_backend(state->gfx);
-  gfx_set_viewport(render_backend, width, height);
-}
-
-int main(int argc, const char** argv) {
-  const int initial_width  = SCREEN_WIDTH;
-  const int initial_height = SCREEN_HEIGHT;
-  const int max_fps        = 60;
-
-  gfx_app_run(
-      &(GfxAppDesc){
-          .argc              = argc,
-          .argv              = argv,
-          .width             = initial_width,
-          .height            = initial_height,
-          .max_fps           = max_fps,
-          .update_delta_time = max_fps > 0 ? 1.0 / (double)max_fps : 0.0,
-          .title             = "Isometric Renderer"},
-      &(GfxAppCallbacks){
-          .init     = init,
-          .update   = update,
-          .render   = render,
-          .resize   = resize,
-          .shutdown = shutdown});
-
-  return 0;
-}
diff --git a/gfx-iso/asset/mkasset.py b/gfx-iso/asset/mkasset.py
deleted file mode 100644
index 3ca8a1d..0000000
--- a/gfx-iso/asset/mkasset.py
+++ /dev/null
@@ -1,324 +0,0 @@
-# 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())
diff --git a/gfx-iso/demos/CMakeLists.txt b/gfx-iso/demos/CMakeLists.txt
new file mode 100644
index 0000000..c0a4101
--- /dev/null
+++ b/gfx-iso/demos/CMakeLists.txt
@@ -0,0 +1,2 @@
+add_subdirectory(checkerboard)
+add_subdirectory(isomap)
diff --git a/gfx-iso/demos/checkerboard/CMakeLists.txt b/gfx-iso/demos/checkerboard/CMakeLists.txt
new file mode 100644
index 0000000..f178262
--- /dev/null
+++ b/gfx-iso/demos/checkerboard/CMakeLists.txt
@@ -0,0 +1,15 @@
+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
+  isogfx-app)
+
+target_compile_options(checkerboard PRIVATE -Wall -Wextra -Wpedantic)
diff --git a/gfx-iso/demos/checkerboard/checkerboard.c b/gfx-iso/demos/checkerboard/checkerboard.c
new file mode 100644
index 0000000..9730aea
--- /dev/null
+++ b/gfx-iso/demos/checkerboard/checkerboard.c
@@ -0,0 +1,114 @@
+#include <isogfx/app.h>
+#include <isogfx/isogfx.h>
+
+#include <assert.h>
+#include <stdbool.h>
+#include <stdio.h>
+
+static const int TILE_WIDTH   = 64;
+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 IsoGfxAppState {
+  Tile red;
+  int  xpick;
+  int  ypick;
+} IsoGfxAppState;
+
+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(
+    IsoGfxAppState* state, IsoGfx* iso, int argc, const char** argv) {
+  assert(state);
+  assert(iso);
+
+  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);
+
+  return true;
+}
+
+static void shutdown(IsoGfxAppState* state, IsoGfx* iso) {
+  assert(state);
+  assert(iso);
+}
+
+static void update(IsoGfxAppState* state, IsoGfx* iso, double t, double dt) {
+  assert(state);
+  assert(iso);
+
+  double mouse_x, mouse_y;
+  gfx_app_get_mouse_position(&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(IsoGfxAppState* state, IsoGfx* iso) {
+  assert(state);
+  assert(iso);
+
+  isogfx_render(iso);
+
+  if ((state->xpick != -1) && (state->ypick != -1)) {
+    isogfx_draw_tile(iso, state->xpick, state->ypick, state->red);
+  }
+}
+
+int main(int argc, const char** argv) {
+  IsoGfxAppState state = {0};
+  iso_run(
+      argc, argv,
+      &(IsoGfxApp){
+          .state    = &state,
+          .init     = init,
+          .shutdown = shutdown,
+          .update   = update,
+          .render   = render,
+      });
+  return 0;
+}
diff --git a/gfx-iso/demos/isomap/CMakeLists.txt b/gfx-iso/demos/isomap/CMakeLists.txt
new file mode 100644
index 0000000..13edcc7
--- /dev/null
+++ b/gfx-iso/demos/isomap/CMakeLists.txt
@@ -0,0 +1,15 @@
+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
+  isogfx-app)
+
+target_compile_options(isomap PRIVATE -Wall -Wextra -Wpedantic)
diff --git a/gfx-iso/demos/isomap/isomap.c b/gfx-iso/demos/isomap/isomap.c
new file mode 100644
index 0000000..d204d28
--- /dev/null
+++ b/gfx-iso/demos/isomap/isomap.c
@@ -0,0 +1,72 @@
+#include <isogfx/app.h>
+#include <isogfx/isogfx.h>
+
+#include <assert.h>
+#include <stdbool.h>
+
+typedef struct IsoGfxAppState {
+  int         xpick;
+  int         ypick;
+  SpriteSheet stag_sheet;
+  Sprite      stag;
+} IsoGfxAppState;
+
+static bool init(
+    IsoGfxAppState* state, IsoGfx* iso, int argc, const char** argv) {
+  assert(state);
+  assert(iso);
+
+  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);
+
+  return true;
+}
+
+static void shutdown(IsoGfxAppState* state, IsoGfx* iso) {
+  assert(state);
+  assert(iso);
+}
+
+static void update(IsoGfxAppState* state, IsoGfx* iso, double t, double dt) {
+  assert(state);
+  assert(iso);
+
+  double mouse_x, mouse_y;
+  gfx_app_get_mouse_position(&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(IsoGfxAppState* state, IsoGfx* iso) {
+  assert(state);
+  assert(iso);
+
+  isogfx_render(iso);
+}
+
+int main(int argc, const char** argv) {
+  IsoGfxAppState state = {0};
+  iso_run(
+      argc, argv,
+      &(IsoGfxApp){
+          .pixel_scale = 2,
+          .state       = &state,
+          .init        = init,
+          .shutdown    = shutdown,
+          .update      = update,
+          .render      = render,
+      });
+  return 0;
+}
diff --git a/gfx-iso/include/isogfx/app.h b/gfx-iso/include/isogfx/app.h
new file mode 100644
index 0000000..0a0fcc1
--- /dev/null
+++ b/gfx-iso/include/isogfx/app.h
@@ -0,0 +1,22 @@
+#pragma once
+
+#include <gfx/gfx_app.h>
+
+#include <stdbool.h>
+
+typedef struct IsoGfx    IsoGfx;
+typedef struct IsoGfxApp IsoGfxApp;
+
+typedef struct IsoGfxAppState IsoGfxAppState;
+
+typedef struct IsoGfxApp {
+  int             pixel_scale; // 0 or 1 for 1:1 scale.
+  IsoGfxAppState* state;
+
+  bool (*init)(IsoGfxAppState*, IsoGfx*, int argc, const char** argv);
+  void (*shutdown)(IsoGfxAppState*, IsoGfx*);
+  void (*update)(IsoGfxAppState*, IsoGfx*, double t, double dt);
+  void (*render)(IsoGfxAppState*, IsoGfx*);
+} IsoGfxApp;
+
+void iso_run(int argc, const char** argv, IsoGfxApp*);
diff --git a/gfx-iso/src/app.c b/gfx-iso/src/app.c
new file mode 100644
index 0000000..079ac96
--- /dev/null
+++ b/gfx-iso/src/app.c
@@ -0,0 +1,198 @@
+#include <isogfx/app.h>
+#include <isogfx/isogfx.h>
+
+#include <gfx/gfx.h>
+#include <gfx/gfx_app.h>
+#include <gfx/render_backend.h>
+#include <gfx/renderer.h>
+#include <gfx/scene.h>
+#include <gfx/util/geometry.h>
+#include <gfx/util/shader.h>
+
+#include <assert.h>
+#include <stdbool.h>
+#include <stdlib.h>
+
+static const int SCREEN_WIDTH  = 1408;
+static const int SCREEN_HEIGHT = 960;
+static const int MAX_FPS       = 60;
+
+typedef struct AppState {
+  Gfx*       gfx;
+  IsoGfx*    iso;
+  IsoGfxApp* app;
+  Texture*   screen_texture;
+  Scene*     scene;
+} AppState;
+
+typedef struct GfxAppState {
+  AppState state;
+} GfxAppState;
+
+static bool init(GfxAppState* gfx_app_state, int argc, const char** argv) {
+  assert(gfx_app_state);
+  AppState* state = &gfx_app_state->state;
+
+  IsoGfxApp* app = state->app;
+
+  if (!(state->iso = isogfx_new(&(IsoGfxDesc){
+            .screen_width = SCREEN_WIDTH, .screen_height = SCREEN_HEIGHT}))) {
+    goto cleanup;
+  }
+
+  if (!(*app->init)(app->state, state->iso, argc, argv)) {
+    goto cleanup;
+  }
+
+  // Apply pixel scaling if requested by the app.
+  int texture_width, texture_height;
+  if (app->pixel_scale > 1) {
+    texture_width  = SCREEN_WIDTH / app->pixel_scale;
+    texture_height = SCREEN_HEIGHT / app->pixel_scale;
+    isogfx_resize(state->iso, texture_width, texture_height);
+  } else {
+    texture_width  = SCREEN_WIDTH;
+    texture_height = SCREEN_HEIGHT;
+  }
+
+  if (!(state->gfx = gfx_init())) {
+    goto cleanup;
+  }
+  RenderBackend* render_backend = gfx_get_render_backend(state->gfx);
+
+  if (!(state->screen_texture = gfx_make_texture(
+            render_backend, &(TextureDesc){
+                                .width     = texture_width,
+                                .height    = texture_height,
+                                .dimension = Texture2D,
+                                .format    = TextureSRGBA8,
+                                .filtering = NearestFiltering,
+                                .wrap      = ClampToEdge,
+                                .mipmaps   = false}))) {
+    goto cleanup;
+  }
+
+  ShaderProgram* shader = gfx_make_view_texture_shader(render_backend);
+  if (!shader) {
+    goto cleanup;
+  }
+
+  Geometry* geometry = gfx_make_quad_11(render_backend);
+  if (!geometry) {
+    goto cleanup;
+  }
+
+  MaterialDesc material_desc = (MaterialDesc){.num_uniforms = 1};
+  material_desc.uniforms[0]  = (ShaderUniform){
+       .type          = UniformTexture,
+       .value.texture = state->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;
+  }
+
+  state->scene    = gfx_make_scene();
+  SceneNode* node = gfx_make_object_node(object);
+  SceneNode* root = gfx_get_scene_root(state->scene);
+  gfx_set_node_parent(node, root);
+
+  return true;
+
+cleanup:
+  if (state->gfx) {
+    gfx_destroy(&state->gfx);
+  }
+  free(state);
+  return false;
+}
+
+static void shutdown(GfxAppState* gfx_app_state) {
+  assert(gfx_app_state);
+  AppState* state = &gfx_app_state->state;
+
+  if (state->app) {
+    assert(state->iso);
+    (*state->app->shutdown)(state->app->state, state->iso);
+  }
+
+  isogfx_del(&state->iso);
+  gfx_destroy(&state->gfx);
+}
+
+static void update(GfxAppState* gfx_app_state, double t, double dt) {
+  assert(gfx_app_state);
+  AppState* state = &gfx_app_state->state;
+
+  isogfx_update(state->iso, t);
+
+  assert(state->app->update);
+  (*state->app->update)(state->app->state, state->iso, t, dt);
+}
+
+static void render(GfxAppState* gfx_app_state) {
+  assert(gfx_app_state);
+  AppState* state = &gfx_app_state->state;
+
+  assert(state->app->render);
+  (*state->app->render)(state->app->state, state->iso);
+
+  const Pixel* screen = isogfx_get_screen_buffer(state->iso);
+  assert(screen);
+  gfx_update_texture(
+      state->screen_texture, &(TextureDataDesc){.pixels = screen});
+
+  RenderBackend* render_backend = gfx_get_render_backend(state->gfx);
+  Renderer*      renderer       = gfx_get_renderer(state->gfx);
+
+  gfx_start_frame(render_backend);
+  gfx_render_scene(
+      renderer, &(RenderSceneParams){
+                    .mode = RenderDefault, .scene = state->scene, .camera = 0});
+  gfx_end_frame(render_backend);
+}
+
+static void resize(GfxAppState* gfx_app_state, int width, int height) {
+  assert(gfx_app_state);
+  AppState* state = &gfx_app_state->state;
+
+  RenderBackend* render_backend = gfx_get_render_backend(state->gfx);
+  gfx_set_viewport(render_backend, width, height);
+}
+
+void iso_run(int argc, const char** argv, IsoGfxApp* app) {
+  GfxAppState app_state = {
+      .state = (AppState){
+                          .app = app,
+                          }
+  };
+  gfx_app_run(
+      &(GfxAppDesc){
+          .argc              = argc,
+          .argv              = argv,
+          .width             = SCREEN_WIDTH,
+          .height            = SCREEN_HEIGHT,
+          .max_fps           = MAX_FPS,
+          .update_delta_time = MAX_FPS > 0 ? 1.0 / (double)MAX_FPS : 0.0,
+          .title             = "Isometric Renderer",
+          .app_state         = &app_state},
+      &(GfxAppCallbacks){
+          .init     = init,
+          .update   = update,
+          .render   = render,
+          .resize   = resize,
+          .shutdown = shutdown});
+}
diff --git a/gfx-iso/tools/mkasset.py b/gfx-iso/tools/mkasset.py
new file mode 100644
index 0000000..3ca8a1d
--- /dev/null
+++ b/gfx-iso/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