A New Hope, from a New Direction. Yes, that’s right, I’m thinking about starting over. Could this be a good idea?

Constant readers will recall that I feel the current objects aren’t helping me as much as they should. And in our “Zoom Ensemble” Friday meetings, we’ve discussed possible shortcomings in another member’s dungeon code. Some of us think his objects aren’t helping him either.

The dapper GeePaw Hill suggested that a world model focused on tiles of some finite size, making up rooms of N by M tiles might make for a simpler more manageable model. Neither the Zoom Ensemble person nor I are in a good position to do that, because our models are quite different from a tiled world. But as I thought more about it, GeePaw’s idea appealed to me.

So I did an experiment this morning, and I’d like to tell you about it.

Before rising, I thought about a big space full of wall blocks, and that one might dig out the block, making rooms. It seemed interesting in my mind. So when I got up, I did a quick search and found Roguelike Tutorials. Part 3 of the new tutorial there shows a python approach to digging out rooms. I didn’t run the python code nor copy it, but I was able to understand what the author was talking about. It inspired me to try a little sample of my own.

I started with a couple of quick Codea bits that drew some rectangles, to get a sense of size. I wound up with some code I’ll show you in a moment, but first let’s talk about the “design”. It’s pretty basic so far.

There’s an array of Tile objects. Actually it’s a two dimensional array, generated in one of the classic Codea approaches to a 2D array:

    Tiles = {}
    for x = 1,TileCount do
        Tiles[x] = {}
        for y = 1,TileCount do
            local tile = Tile:edge(x,y)
            Tiles[x][y] = tile
        end
    end

This code makes an array for each value of x and in that array at position x, stores Tiles at position y. So the “array” is actually stored row-wise. To draw it, we draw the tiles the same way:

    for x = 1,TileCount do
        for y = 1,TileCount do
            Tiles[x][y]:draw()
        end
    end

We could probably use ipairs here just as well, reducing the need for people to know TileCount. But this is just an experiment.

A Tile draws itself as a different-colored rectangle, based on its “kind”, which is edge, wall, or room. It goes like this:

function Tile:draw()
    pushMatrix()
    pushStyle()
    fill(self:fillColor())
    rect(self:gx(),self:gy(),TileSize)
    popStyle()
    popMatrix()
end

The fillColor() function returns a color. Details aren’t important now, but we draw edge in black, wall in grey, and room in white.

There’s a class called Room. When you create a Room, you give it an x and y tile address, and a width and height. The Room object resets the tiles in its rectangle, like this:

function Room:init(x,y,w,h)
    self.x1 = x
    self.y1 = y
    self.x2 = x + w
    self.y2 = y + h
    -- leave wall on all four sides
    for x = self.x1,self.x2 do
        for y = self.y1,self.y2 do
            local tile = self:correctTile(x,y)
            Tiles[x][y] = tile
        end
    end
end

function Room:correctTile(x,y)
    if x == self.x1 or x == self.x2 or y == self.y1 or y == self.y2 then
        return Tile:wall(x,y)
    else
        return Tile:room(x,y)
    end
end

We just iterate over the Room’s coordinates, putting wall tiles on the sides and room tiles inside. Presently I do nothing else with the Room. All it does at this point is carve out a space.

I have code in setup to create a few rooms:

function setup()
    if CodeaUnit then 
        codeaTestsVisible(true)
        runCodeaUnitTests() 
    end
    TileSize = 16
    TileCount = 60
    Tiles = {}
    for x = 1,TileCount do
        Tiles[x] = {}
        for y = 1,TileCount do
            local tile = Tile:edge(x,y)
            Tiles[x][y] = tile
        end
    end
    Room(5,5,10,10)
    Room(20,20,4,5)
    Room(25,10,8,15)
    Room(50,50,10,10)
end

And when you run the program, it looks like this:

rooms

That’s all it does. But it’s simple and it looks good.

If we were to work further with this model, we’d probably follow the outline from Roguelike Tutorials, creating non-intersecting rooms, carving halls between them, and saving the room definitions so as to support knowing where the player is, where the treasures and monsters are, and so on.

Much of the game play would just deal with tiles. When we go to move the player or a monster, we could just look at the tile it plans to move to and if it’s not a room tile, the move can’t happen. And so on.

I think this is a better model. It will offer some interesting challenges of its own, not least that we’ll probably need much more space than is available on the screen, so we’ll have zooming and scrolling to do. But we’ve done that before and we can do it again.

My inclination is to treat D1 as an experiment–one that perhaps went on a bit too long–and to take what we’ve learned and begin again. We should talk about whether and when such a thing is reasonable.

Is This Good, Bad, or Other?

We started this project on November 2, and today is December 1. So we have burned a month of elapsed time, and probably between 40 and 60 hours of programming time, or a week and a half. We need to ask ourselves whether we’d be better off:

  • Continuing with the current model;
  • Starting over with the tile model;
  • Refactoring the current model to a tile model.

I rarely recommend a rewrite of a real project that has reached production. On the other hand, as a manager I would seriously consider asking for a rewrite of any project that went for a month with no discernible progress. The longer the thing has gone on, the harder it is to scrap what already exists. The “sunk cost” trap is a strong one: we’ve done all that work and don’t want to waste it.

The problem is, the work we’ve done is often full of waste. As such, the cost is already lost and can’t really be preserved by holding on to an inferior design.

I have the luxury of doing whatever I want. In this case, I expect progress to be visibly faster, for at least two reasons. First, I think this tile model really is simpler, and simpler is faster. Second, I’ve learned a lot from the first version and that learning should help me to do things, and avoid at least some trouble, along the way.

But we’ll find out, won’t we? I’ll paste all the code below, and include a zip file as usual.


-- D2
-- RJ 20201201

--[[
Some rough considerations:

Tile size of 32x32 looks pretty good on iPad screen.
The screen is 1366x1024, giving ~42x32 tiles on screen at a time.
Let's assume 32x32, leaving space for on-screen controls and info.
Rooms seem to want to be at least 6 by 6, or 7x7 if we count their walls.
We could allow long thin rooms, etc, but there will be hallways.

Roughly, if we're going to allow 50 rooms on a level, we'll need room (!)
for 7ish rooms vertical and horizontal, or around 50 or 60 tiles in each direction.
And that may be too small. Let's imagine 100 tiles in each direction, which would require
3200 pixels, or about 3x the screen size available.

So we'll be scrolling and zooming. 

]]--

function setup()
    if CodeaUnit then 
        codeaTestsVisible(true)
        runCodeaUnitTests() 
    end
    TileSize = 16
    TileCount = 60
    Tiles = {}
    for x = 1,TileCount do
        Tiles[x] = {}
        for y = 1,TileCount do
            local tile = Tile:edge(x,y)
            Tiles[x][y] = tile
        end
    end
    Room(5,5,10,10)
    Room(20,20,4,5)
    Room(25,10,8,15)
    Room(50,50,10,10)
end

function draw()
    if CodeaUnit then showCodeaUnitTests() end
    fill(0)
    stroke(255)
    strokeWidth(1)
    for x = 1,TileCount do
        for y = 1,TileCount do
            Tiles[x][y]:draw()
        end
    end
end

-- Tile
-- RJ 20201201

Tile = class()

local TileWall = 1
local TileRoom = 2
local TileEdge = 3

function Tile:room(x,y)
    return Tile(x,y,TileRoom)
end

function Tile:wall(x,y)
    return Tile(x,y,TileWall)
end

function Tile:edge(x,y)
    return Tile(x,y,TileEdge)
end

function Tile:init(x,y,kind)
    self.x = x
    self.y = y
    self.kind = kind
end

function Tile:draw()
    pushMatrix()
    pushStyle()
    fill(self:fillColor())
    rect(self:gx(),self:gy(),TileSize)
    popStyle()
    popMatrix()
end

function Tile:fillColor()
    local c = self:fillColors()[self.kind]
    return c or color(255,0,0)
end

function Tile:fillColors()
    if not FillColors then
        FillColors = {color(128,128,128),   color(255),   color(0) }
    end
    return FillColors
end

function Tile:gx()
    return self.x*TileSize
end

function Tile:gy()
    return self.y*TileSize
end

function Tile:__tostring()
    return string.format("Tile[%d][%d]: %d", self.x, self.y, self.kind)
end

-- Room
-- RJ 20201201

Room = class()

function Room:init(x,y,w,h)
    self.x1 = x
    self.y1 = y
    self.x2 = x + w
    self.y2 = y + h
    -- leave wall on all four sides
    for x = self.x1,self.x2 do
        for y = self.y1,self.y2 do
            local tile = self:correctTile(x,y)
            Tiles[x][y] = tile
        end
    end
end

function Room:correctTile(x,y)
    if x == self.x1 or x == self.x2 or y == self.y1 or y == self.y2 then
        return Tile:wall(x,y)
    else
        return Tile:room(x,y)
    end
end

D2.zip