Making a simple Isometric tile editor for Kenney.nl tiles

sign up sign in

EDIT (07.04.15): Updated to latest version of Haxe 3.2.0-rc2 and snõwkit

I started a tile editor with no real hopes of actually finishing it to the extent that I did. But I ended up having lots of fun implementing it, and found that the workflow was quite satisfying. I also created a GitHub repository if anyone wants to peek at the source here. (Binaries are also available)

It started when I looked at the Kenney.nl assets which are quite cool and adorable - Especially the isometric tilesets (found here). I tried the excellent Tiled but immediately found some challenges with it:

  • There was not real tile atlas support (for arbitrary tile sizes)
  • There were some offsets challenges with building tilesets which needed individual offsets to match their counterparts (roofs and second floors)
  • When creating maps, you immediately ran into some height problems when stacking buildings

So it was a perfect excuse of making my own editor! :)

I've been at it in small portions for a couple of weeks, so I cannot recall all the small challenges and twists - but I thought I would give some general insights to architecture which I think made sense and describe some small things that might be interesting.

Offseting test GIF

Requirements

The requirements for the editor at the start were something like:

  • Use Kenney.nl isometric tiles
  • Easily group tiles so that I could use the scroll wheel to scroll through specific types of tiles quickly without selecting them manually through a tile selector
  • Individual tile depth adjustment to compensate for some special behaviors
  • Global tile offset adjustment - since the we sometimes need a tile-specific offset
  • Graph editor per tile to map out road paths per tile and automatically connect them in the map
  • Save and load maps / tilesheets to disk
  • Simple UI - use shortcut keys as main interaction (Sven is developing mìnt which I'm looking forward to :) - but for now there were not a real need for much UI anyway)

All features are in now, working more or less with some known issues. So in principle it should be usable.

Starting out

I started out using my most naïve approach I could think of. Placing sprites in a Isometric grid. I used one tilesheet for test for quite a long time. I ended up mirroring some sprite data as tile data which is probably unnecessary - but I sort of stuck to this approach and it was working OK.

There has been some discussion around creating too smart/complex methods which I totally support. You can end up outsmarting yourself, and doing extra work to do unnecessary optimizations. A lot of the code is non-optimal but it works. A big thing here is since this is an editor I basically have nothing done in update loops - everything is user-initiated.

I know there are specific Isometric map implementations in luxe already, but they were lacking the ability to have individial sized tiles. Rather than trying to implement and change this in the core (which isn't the core focus of the current alphas), I decided to roll my own for the heck of it learning process.

Architecture

The architecture is not complex, but some general rules is helpful for organizing and deciding where to put what. In the beginning, everything was pretty tied together and not structured at all, but I was constantly refactoring to improve the layering. The steps were in general:

  1. Make it work
  2. Refactor - create new classes for encapsulating some aspects of logic / state / whatever
  3. Repeat

Some simple rules

I had some rules which were ironed out as I went along. I probably break some of them sometimes, but at least I try to be true to the following principles:

Anonymous structures are great, but

  • ALWAYS typedef them!
  • Also typedef stuff that might change in the future - I started with a map of Sprite but I knew that would change later == more work.
  • Always typedef parameters when passing them to events and also receiving events like this:
var sel_event : SelectEvent = { index: new_tile, tilesheet: sheet.index, group: null };

if (e.button == MouseButton.left)  
{
    Luxe.events.fire('select', sel_event);
}

This lets the compiler babysit you as much as possible - which is what we want.

Main.hx (entry point)

  • Core Parcel loading
  • Batcher initialization
  • State creation (pass batchers to states) - this allows for great flexibility and makes it easy to configure batchers at one single point.
views.add(new EditView(global_data, default_batcher, graph_batcher));  
views.add(new TestView(global_data, graph_batcher));  
views.add(new SelectorView(global_data, selector_batcher));  
views.add(new PathEditView(global_data, pathedit_batcher));  
  • Global object with global data to be shared between states
typedef GlobalData = {  
    map : IsometricMap,
    views : States,
    status: StatusTextBehavior,
    ui : Batcher,
    font: BitmapFont,
    mod_sticky: Float
}
  • Not 100% satisifed for the global data part - you end up doing import Main; for all views but it works for now.

States

  • Interactions and constructions of different objects
var tooltip_spr = new Entity({  
    name: 'edit_tt',
    pos: new Vector(96, Luxe.screen.h - 70),
    });

tooltip = tooltip_spr.add(new TileTooltipBehavior(global.ui, global.font));  
  • Mouse/keyboard input handling - this allows easily blocking of unwanted input when disabling states to overlap with others etc.
  • Save / load of data
  • Global access allowed

Components

  • Special object behavior that only affects a single (visual) object
  • Use events to pass information from/back to the states
Luxe.events.listen('IsometricMap.Snap', function(str:String) { set_grid(str); });  
Luxe.events.listen('TileSheetAtlased.TileId', function(idx:TileIndex) { set_tile(idx); });  
Luxe.events.listen('TileSheetAtlased.GroupId', function(str:String) { set_group(str); });  
  • No dependencies on states allowed
  • No global access allowed
  • Limit mouse/keyboard handling if possible

Separate classes

  • Avoid inheritance from anything
  • May contain containers with other objects and visual entities
class Graph  
{
    var nodes : Array<GraphNode>;
    var edges : Array<GraphEdge>;

    var batcher : Batcher = null;
    var depth : Float = 0;
  • Current pointers to selected data in the states (if needed) - this way you can have properties like 'current' in TileSheetCollection to avoid keeping this pointer information in outside classes like states. At least this makes sense for me.
  • Always clean up in destroy() function

Extension classes

  • Extension of functionality in classes like Rectangle
public static inline function mid(r:Rectangle) : Vector  
{
    return new Vector(r.x + r.w / 2, r.y + r.h / 2);
}

Keyboard shortcuts

One simple thing I decided was that I would have one modifier key (Ctrl) to do all special operations. The other keys would mostly be usable as freely assignable to groups for quickly switching through selected tiles. All keyboard shortcuts are described in the README file.

How I handled...

Here's how I handled various aspects when developing the editor.

Undo stack

I think an undo stack is important in editors. I instictively tried to press Ctrl-z combination when testing. So I added a simple undo stack which simply records tiles placed. If the tile is removed, it is stored with only the coordinate. If undoing, either place or remove the tile from the top of the undo stack. When creating an editor with specific actions, I like to create functions that reflect this 1:1, like for example:

  • restore_tile
  • toggle_ui
  • place_tile

This keeps the code easy to navigate.

One very curious thing for me was that I felt I was missing the keypresses when doing this combination quickly. I discovered that I pressed the keys in a fast sequence that missed the modifier flag. Since this "feel" is very important I decided to make a timer for the modifier key so that it remembers the state for a while.

override function onkeyup(e:luxe.KeyEvent)  
{
    if (e.keycode == Key.lctrl || e.keycode == Key.rctrl || e.mod.lctrl || e.mod.rctrl)
    {
        mod_key_timer = e.timestamp;
    }

    var mod_key_delta = (e.timestamp - mod_key_timer);

    if (mod_key_delta < global.mod_sticky) ...

Selector

The selector was one of the first things that I made. Kenney distributes atlas files with coords, and I liked the idea of only having one texture to work with. It is very easy to parse the XML code with native Haxe classes like this:

var fast = new haxe.xml.Fast(xml.firstElement());

for (st in fast.nodes.SubTexture)  
{
   sheet.atlas.push({
        graph: null, 
        offset: new Vector(),
        rect: 
            new Rectangle(Std.parseFloat(st.att.x), Std.parseFloat(st.att.y), 
            Std.parseFloat(st.att.width), Std.parseFloat(st.att.height)) 
            });
}

When working on groups, I didn't want to fiddle too much with advanced mouse selection, so I decided to only toggle the group assignment with keys. The overlays when selecting groups are handled by a separate selector Component which creates a set of rectangle geometries that can be set visible or invisible like this:

function create_indicators()  
{
    for (tile in sheet.atlas)
    {
        var r = tile.rect;

        var g = Luxe.draw.box({
            x: r.x,
            y: r.y,
            w: r.w,
            h: r.h,
            color : new luxe.Color(1, 1, 1, 0.5),
            visible: false,
            batcher: batcher,
            depth: sprite.depth + 1
            });

        indicator.push(g);
    }
}

When switching the different tilesheets I signal this to the tile select component which changes the texture and indicators.

Tooltip

Another example of creating a Component that handles the display of extra information that can be reused by several views. I then use the component as the reference rather than the Sprite or Entity like this:

tooltip.set_tile(hover.s, 'map: (${mp.x},${mp.y})', 'depth: $depth');  

Scene and named entities

I only use an extra Scene once - for the Test state. I tend to want to keep my own track of the entities I create myself, and destroy, iterate through them through my own structure. This means that I don't really need scenes for now. An alternative pattern is to create separate scenes and name the entities so that they can be looked up and iterated through in a more general way. I might look more into this in the future - and this might also be a way to separate display logic more from display code.

Test scene GIF

Paths

As stated in the beginning, one thing the tiles needed in order to have things drive on them was information about the path. I wanted to edit the path per tile and let the path automatically merge and create the global graph for the map. I made a separate view for this and added local graph information for each tile. There is one Graph object per tile and one global Graph object for the map. When the tile is placed, it merges the local graph with the global.

Graph edit and expansion GIF

In order to handle the removal of tiles I could have come up with a clever "unmerge" algorithm, but since that sounds complicated so I decided I simply made routines for recreating the entire graph on the map. This way it would at least become a stable outcome of it. The runtime is terrible, and may be troublesome for huge maps, but it simply recreates the graph if a tile is removed or upon user request. This is needed if the graph is modified on one tile after it is placed.

Serializing

I wanted to use the standard Haxe classes for JSON serializing. However, I was not happy with the readibility of just dumping my existing structures directly to file. I ended up creating very specialized structures for serializing. It was a bit of extra work, but I think it worked out OK in the end. The downside is that you require to populate a shadow copy of the structures only to serialize. For each data structure it has an equivalent *Serialize-typedef. Example:

typedef TileSheetAtlasedSerialize = {  
    name: String,
    image: String,
    atlas: Array<TileDataSerialize>,
    groups: Array<GroupSerialize>
};

typedef GroupSerialize = {  
    k: String, 
    v: Array<Int> 
};
...
public function to_json_data() : TileSheetAtlasedSerialize  
{
    var t_atlas = new Array<TileDataSerialize>();

    for (a in atlas)
    {
        var t_g = null;

        if (a.graph != null)
        {
            t_g = a.graph.to_json_data(); 
        }

        t_atlas.push({ rect: a.rect.to_array(), graph: t_g, offset: MyUtils.vector_to_pair(a.offset) });
    }

    var t_groups = new Array<GroupSerialize>();
    for (k in groups.keys())
    {
        t_groups.push({ k: k, v: groups[k] }); 
    }

    return { name: name, image: image.asset.id, atlas: t_atlas, groups: t_groups };
}

For textures - I assume in the deserializing routines that the assets are loaded in the resource cache. This means that the responsibility of handling them are not within the class itself. We have to use the asset loading system and disable the strict requirement (more on that in the next section).

Load / Save

When looking at loading and saving from arbitrary file locations with cross-platform file dialogs - I was pleased to see that snõw already took care of it. Together with the standard Haxe sys.io functions - this was a breeze.

    function open_map()
    {
        trace('Try to open map...');

        var path = Luxe.core.app.io.module.dialog_open('Open map...');

        if (path == null || path.length == 0)
        {
            trace('Could not open file - dialog_open failed or canceled');
            return;
        } 

        var content = null;

        try 
        {
            content = sys.io.File.getContent(path);
        } 
        catch(e:Dynamic)
        {
            MyUtils.ShowMessage('Failed to open file "$path", I think because "$e"', 'open_map');
            return;
        }

Async loading of textures when loading maps

This was a small quirk, since the loading process is asynchronous. The solution for loading was not super-elegant, but one key aspect was to disable the current state while loading and enabling when everything was loaded again. So at one point, the state disables itself until it fails at loading textures or all textures are loaded sucessfully. Each time a texture is processed and the onload function is called, we decrease a counter for the number of sheets that we need to load. When reached zero, we call a final load stage which re-enables the state.

function map_image_loaded(texture:phoenix.Texture)  
{
    data_buffer.img_left--;

    if (data_buffer.img_left > 0) return;

    map_final_load_stage();
}

UI

UI has a special batcher and is accessible through the Global object. To decrease some of the dependencies, events are used to listen for some of the status updates, for example:

Luxe.events.queue('TileSheetAtlased.TileId', { tilesheet: index, tile: atlas_pos });  

This event is wired in the view and calls the status bar behavior.

Coding Tools / Workflow

I finally bought a license for Sublime Text 3. Sven has done a good job of providing plugins so it is actually a breeze to work with. I did most testing through the Web target since the compile-test cycle is much quicker than the native target.

To iron out specific bugs, I just made some test maps which were pre-loaded in Main.hx so I didn't have to manually open a map with the native target code manually each time.

Wrap-up

If you read this far - congratulations ;) The GitHub repo is here. I think luxe is very cool to work with, in the limited spare time I have. The states concept made a lot of sense for this project, and it is addressing one of the patterns which I found lacking when working with Unity. I like how I can pick and choose the "best" from multiple worlds like OpenGL, ECS, batchers and make it fit my own needs. The Haxe platform with native support is also very powerful! Having tested Microsoft XNA, Haxe NME (before it morphed to lime+OpenFL) and Unity the luxe framework really hits my sweet spot.

Sven is doing a great job with the snowkit community and the framework, and has a very good approach to architecture. I also see other strong contributors, and hope it will be growing in the future. Maybe even do some more major contributions myself.

Next?

Well now the natural thing would be to make a great game with this editor... But I do honestly not have a good and simple game idea yet. Probably need a break :P.

I've just started working on a game dialog system where I can use FreeMind as input format - I've always wanted to implement this. So let's see... :)

Tags
luxetilesisometrickenney