Simple Dungeon

Ages ago — as in the days when Codea didn’t even have a text function, I started coding a simple roguelike dungeon. But I got involved in other things and never went too far in my effort.

Recently, after allowing Codea to sit idle for a long time, I thought I would play around with the idea again, and a couple of day’s efforts produced this very simple version.

My goal was to use nothing but resources included with Codea, but to produce something both simple enough to pick up immediately and fun enough to generate hours of frustration.

The code is far from pretty at this point, and there’s still a fair list of to do items. But it seemed worth passing along at this point, even it it presses the limits of paste-in code

Apologies for the sheer number of globals and for for a Floor class that’s really carrying more weight than it should. Things will be cleaned up in future versions.


-------------------
-- SimpleDungeon --
-------------------

-- Mark Sumner --
--  (c) 2019   --


function setup()
    -- gold is saved between sessions
    gold = 0
    if readLocalData("GOLD") then
        gold = math.floor(readLocalData("GOLD"))
    end
    displayMode(FULLSCREEN)
    dialog = Dialog(100, 400, "Try Again?")
    dialog.hasCancel = false
    showDialog = false
    hitTint = color(255, 255, 255, 255)
    loop = 0
    monsters = {}
    treasures = {}
    messages = {}
    cx = 5
    cy = 5
    keys = 0
    health = 5
    strength = 1
    weapon = 1
    exp = 0
    floorNum = 1
    floor = Floor(100, 100)
    rightBtn = Button(150, 150, "UI:Blue Slider Right")
    leftBtn = Button(50, 150, "UI:Blue Slider Left")
    upBtn = Button(100, 200, "UI:Blue Slider Up")
    downBtn = Button(100, 100, "UI:Blue Slider Down")
    found = false
    while not found do
        x = math.random(50)
        y = math.random(50)
        if floor.tiles[y][x] == "-" then
            cx = x
            cy = y
            found = true
        end
    end
    font("Optima-ExtraBlack")
end

function start()
    showDialog = false
    monsters = {}
    cx = 5
    cy = 5
    keys = 0
    gold = 0
    if readLocalData("GOLD") then
        gold = math.floor(readLocalData("GOLD"))
    end
    health = 3
    strength = 1
    weapon = 1
    exp = 0
    floorNum = 1
    floor = Floor(100, 100)
    found = false
    while not found do
        x = math.random(50)
        y = math.random(50)
        if floor.tiles[y][x] == "-" then
            cx = x
            cy = y
            found = true
        end
    end
end

function screenMap(x, y)
    dx = 0
    dy = 200
    bx = dx + x * 48 - floor.ox * 48 - 24
    by = dy + y * 48 + 32 - floor.oy * 48
    return bx, by
end

-- This function gets called once every frame
function draw()
    -- This sets a dark background color 
    background(40, 40, 50)
    floor:draw(cx, cy)
    stroke(0, 0, 0, 255)
    fill(0, 0, 0, 255)
    rectMode(CORNER)
    rect(0, 0, WIDTH, 270)
    tint(127, 127, 127, 255)
    rightBtn:draw()
    leftBtn:draw()
    upBtn:draw()
    downBtn:draw()
    noTint()
    for i, m in ipairs(monsters) do
        m:move()
        m:drawCard()
    end
    for i, m in ipairs(messages) do
        m:draw()
        if m.tint.a < 1 then table.remove(messages, i) end
    end
    fill(163, 163, 163, 255)
    textMode(CORNER)
    textAlign(LEFT)
    text("Keys", 240, 90)
    for i = 1, keys do
        sprite("Planet Cute:Key", 310 + i * 22, 100, 32, 28)
    end
    text("Floor ", 240, 60)
    fill(127, 128, 153, 255)
    text(floorNum, 325, 60)
    fill(163, 163, 163, 255)
    text("Health", 240, 120)
    for i = 1, health do
        sprite("Planet Cute:Heart",310 + i * 28,130,28,30)
    end
    text("Strength", 240, 150)
    for i = 1, strength do
        sprite("Small World:Sword",310 + i * 22,160,12,28)
    end
    text("Gold", 240, 180)
    fill(206, 161, 39, 255)
    text(gold, 325, 180)
    if showDialog then dialog:draw() end
    if health < 1 then showDialog = true end
end

function touched(touch)
    if showDialog then
        if dialog:touched(touch) == 1 then
            start()
        end
        return
    end
    if floor.showDialog then
        if floor:touched(touch) then
            floor:init(cx, cy)
            floorNum = floorNum + 1  
            found = false
            while not found do
                x = math.random(50)
                y = math.random(50)
                if floor.tiles[y][x] == "-" then
                    cx = x
                    cy = y
                    found = true
                end
            end
        end
        return
    end
    local dx = 0
    local dy = 0
    if rightBtn:touched(touch) then
        dx = 1
    elseif leftBtn:touched(touch) then
        dx = -1
    elseif upBtn:touched(touch) then
        dy = 1
    elseif downBtn:touched(touch) then
        dy = -1
    end
    for i, m in ipairs(monsters) do
        if cx + dx == m.x and cy + dy == m.y and m.health > 0 then
            -- hit monster
            sound("A Hero's Quest:Swing 2")
            if math.random(10) > 3 then
                m.health = m.health - (strength * weapon)
                sound("A Hero's Quest:Hit Monster 3")
                fm = FloatingMessage(strength * weapon, screenMap(m.x, m.y))
                fm.tint = color(0, 255, 3, 255)
                table.insert(messages, fm)
                if m.health < 1 then
                    exp = exp + m.strength * 2
                end
            else
                sound(SOUND_EXPLODE, 10272)
                local s = math.random(m.strength)
                fm = FloatingMessage(s, screenMap(m.x, m.y))
                fm.tint = color(255, 0, 18, 255)
                table.insert(messages, fm)
                health = health - s
                hitTint = color(255, 0, 0, 255)
            end
            return
        end
    end
    if floor:check(cx + dx, cy + dy) then 
        cx = cx + dx
        cy = cy + dy
        --sound(SOUND_HIT, 43129)
    else
        sound("A Hero's Quest:Hit 2")
    end
end

Floor = class()

function Floor:init()
    -- params
    self.dialog = Dialog(100, 400, "Go down a level?")
    self.dialog.hasCancel = true
    self.showDialog = false
    self.tiles = {}
    self.roomCoords = {}
    self.ox = 0
    self.oy = 0
    self.stairsX = 0
    self.stairsY = 0
    for y = 1, 50 do 
        self.tiles[y] = {}
        for x = 1, 50 do
            self.tiles[y][x] = "0"
        end
    end
    -- add some rooms
    numRooms = math.random(10) + 5
    for i= 1, numRooms do
        self:addRoom(i)
    end
    for i= 1, numRooms do
        self:carveTunnel(i)
    end
    found = 0
    while found < 2 do
        x = math.random(50)
        y = math.random(50)
        if self.tiles[y][x] == "-" and self.tiles[y - 1][x] == "-"then
            self.tiles[y][x] = "x"
            found = found + 1
        end
    end
    found = false
    while not found do
        x = math.random(50)
        y = math.random(50)
        if self.tiles[y][x] == "-" and self.tiles[y - 1][x] == "-" and
        self.tiles[y + 1][x + 1] == "-" and self.tiles[y - 1][x - 1] == "-" then
            self.tiles[y][x] = "X"
            found = true
        end
    end
    found = false
    while not found do
        x = math.random(50)
        y = math.random(50)
        if self.tiles[y][x] == "-" and self.tiles[y - 1][x] == "-" and
        self.tiles[y + 1][x + 1] == "-" and self.tiles[y - 1][x - 1] == "-" then
            self.tiles[y][x] = "c"
            found = true
        end
    end
    monsters = nil
    monsters = {}
    for i = 1, 1 + floorNum do
        m = Monster(floorNum, self)
        table.insert(monsters, m)
    end
end

function Floor:addRoom(n)
    -- attempt to create a random room
    tries = 0
    while tries < 50 do
        w = math.random(6) + 1
        h = math.random(6) + 1
        x = math.random(49 - w)
        y = math.random(49 - h)
        if self.tiles[y][x] == "0" and self.tiles[y][x + w] == "0" and
            self.tiles[y + h][x] == "0" and self.tiles[y + h][x + w] == "0" and
            self.tiles[y + (h // 2) ][x + (w // 2)] == "0" 
            then
            for rx = x, x + w do
                for ry = y, y + h do
                    if rx > 1 and rx < 50 and ry > 1 and ry < 50 then 
                        self.tiles[ry][rx] = "-"
                    end
                end
            end 
            self.roomCoords[n] = {x + math.random(w), y + math.random(h)}
            return
        end
        tries = tries + 1
    end
end

function Floor:carveTunnel(n)
    for i, r in ipairs(self.roomCoords) do
        r2 = nil
        if i < #self.roomCoords then 
            r2 = self.roomCoords[i + 1]
        else
            r2 = self.roomCoords[1]
        end
        sx = r[1]
        sy = r[2]
        ex = r2[1]
        ey = r2[2]
        
        -- draw a horizontal tunnel
        if ex < sx then
            for x = ex, sx do 
                self.tiles[sy][x] = "-"
            end
        else
            for x = sx, ex do 
                self.tiles[sy][x] = "-"
            end
        end
        
        -- draw a vertical tunnel
        if ey < sy then
            for y = ey, sy do 
                self.tiles[y][ex] = "-"
            end
        else
            for y = sy, ey do 
                self.tiles[y][ex] = "-"
            end
        end
        
    end
end

function Floor:draw(cx, cy)
    -- move to keep character more centered
    cols = WIDTH // 48
    rows = (HEIGHT - 200) // 48
    
    ox = cx - cols // 2
    oy = cy - rows // 2
    
    if ox < 0 then ox = 0 elseif ox > 50 - cols then ox = 50 - cols end
    if oy < 0 then oy = 0 elseif oy > 51 - rows then oy = 51 - rows end
    
    self.ox = ox
    self.oy = oy
    
    -- draw the section of floor around the cx, cy localtion
    my = 50
    mx = 50
    dx = 0
    dy = 200
    
    while self.ox + cols > 50 do
        self.ox = self.ox - 1
    end
    
    for y= my, self.oy + 1, -1 do 
        for x = 1 + self.ox, mx do 
            noTint()
            dist = math.sqrt((x - cx) * (x- cx) + (y - cy) * (y - cy))
            t = 255 - dist * 40
            tint(t,t,t,255)
            c = self.tiles[y][x]
            local bx, by = screenMap(x, y)
            if c == "-" then
                sprite("Blocks:Greysand",bx,by,48,80)
            end
            if c == "x" then
                sprite("Blocks:Greysand",bx,by,48,80)
                sprite("Planet Cute:Key",bx,by + 16,48,48)
            end
            if c == "X" then
                sprite("Blocks:Greysand",bx,by,48,80)
                sprite("Planet Cute:Door Tall Closed",bx,by + 16,48,80)
            end
            if c == "S" then
                tint(0, 0, 0, 255)
                sprite("Blocks:Greysand",bx,by,48,80)
                noTint()
                tint(127, 127, 127, 255)
                sprite("Cargo Bot:Crate Blue 1", bx,by + 16,48,64)
                noTint()
            end
            if c == "0" then
                sprite("Planet Cute:Stone Block",bx,by + 32,48,96)
            end
            if c == "W" then
                sprite("Planet Cute:Wall Block",bx,by + 32,48,96)
            end
            if c == "c" then
                sprite("Planet Cute:Chest Closed",bx,by + 32,48,96)
            end
            if c == "C" then
                sprite("Planet Cute:Chest Open",bx,by + 32,48,96)
            end
            if c == "d" then
                sprite("Blocks:Gravel Dirt",bx,by,48,80)
            end
        end
    end
    noTint()
    for i, m in ipairs(monsters) do
        m:draw(self.ox, self.oy)
    end
    for i, t in ipairs(treasures) do
        t:draw(self.ox, self.oy)
    end
    bx, by = screenMap(cx, cy)
    if health < 1 then 
        tint(127, 127, 127, 255) 
        sprite("Cargo Bot:Condition Yellow", bx, by + 32, 48, 48)
        fill(52, 52, 52, 255)
        textMode(CORNER)
        text("RIP", bx - 15, by + 24)
        floor.tiles[cy][cx] = "d"
    else
        tint(hitTint)
        sprite("Planet Cute:Character Boy",bx,by + 26,48,64)
        if weapon > 1 then
            sprite("Small World:Sword", bx + 16, by + 28, 9, 32)
        end
    end
    noTint()
    hitTint = color(255, 255, 255, 255)
    if self.showDialog then self.dialog:draw() end
end

function Floor:check(x, y)
    c = self.tiles[y][x]
    if c == "-" then return true end
    if c == "x" then 
        sound("Game Sounds One:Bell 2")
        keys = keys + 1
        self.tiles[y][x] = "-"
        return true 
    end
    if c == "X" and keys > 0 then 
        sound("A Hero's Quest:Door Close")
        keys = keys - 1
        self.tiles[y][x] = "S"
    end
    if c == "S" then 
        self.showDialog =true
    end
    if c == "c" and keys > 0 then 
        sound("A Hero's Quest:Door Open")
        keys = keys - 1
        self.tiles[y][x] = "C"
        t = Treasure(x, y)
        table.insert(treasures, t)
        -- reward routine
    end
    if c == "C" then 
        --return true
        for i, t in ipairs(treasures) do
            if t.x == x and t.y == y then
                if t.t == 1 then
                    -- coins
                    gold = gold + math.random(59 * floorNum) 
                    sound("A Hero's Quest:Bottle Break 1")
                    fm = FloatingMessage("+" .. gold .. " gold", 
                    screenMap(t.x, t.y))
                    saveLocalData("GOLD", gold)
                    fm.y = fm.y + 25 
                    fm.tint = color(224, 192, 132, 255)
                    table.insert(messages, fm)
                end
                if t.t == 2 then
                    -- weapon
                    weapon = 1 + math.random(floorNum) 
                    sound("A Hero's Quest:Sword Hit 4")
                    fm = FloatingMessage("+" .. weapon .. " sword",
                     screenMap(t.x, t.y))
                    fm.y = fm.y + 25 
                    fm.x = fm.x - 25 
                    fm.tint = color(131, 203, 224, 255)
                    table.insert(messages, fm)
                end
                if t.t == 3 then
                    -- heart
                    health = health + 1 
                    sound("A Hero's Quest:Defensive Cast 1")
                    fm = FloatingMessage("+ 1 health",screenMap(t.x, t.y))
                    fm.y = fm.y + 25 
                    fm.tint = color(223, 170, 186, 255)
                    table.insert(messages, fm)
                end
                table.remove(treasures, i)
            end
        end
    end
    return false
end

function Floor:touched(touch)
    local i
    i = self.dialog:touched(touch)
    if i == 1 then
        return true
    end
    if i == 2 then
        self.showDialog = false
    end
    return false
end
FloatingMessage = class()

function FloatingMessage:init(t, x, y)
    -- you can accept and set parameters here
    self.x = x
    self.y = y
    self.tint = color(201, 201, 201, 255)
    self.text = t
    self.timer = 60
end

function FloatingMessage:draw()
    pushStyle()
    self.y = self.y + 2
    fill(self.tint)
    self.tint.a = self.tint.a - 3
    fontSize(24)
    text(self.text, self.x, self.y) 
    popStyle()
end
Button = class()

function Button:init(x, y, image)
    -- you can accept and set parameters here
    self.x = x
    self.y = y
    self.image = image
end

function Button:draw()
    sprite(self.image, self.x, self.y)
end

function Button:touched(touch)
    if touch.state == BEGAN then
        if touch.x > self.x - 32 and touch.x < self.x + 32 and
        touch.y > self.y - 32 and touch.y < self.y + 32 then
            return true
        end
    end
    return false
end
Dialog = class()

function Dialog:init(x, y, text)
    -- you can accept and set parameters here
    self.x = x
    self.y = y
    self.hasCancel = true
    self.text = text
    self.yesBtn = Button(x + 200, y + 20, "UI:Green Box Check")
    self.noBtn = Button(x + 250, y + 20, "UI:Red Box Cross")
end

function Dialog:draw()
    strokeWidth(2)
    stroke(126, 126, 126, 255)
    fill(231, 231, 231, 255)
    rect(WIDTH / 2 - 150, HEIGHT / 2 - 50, 300, 100)
    fill(25, 25, 25, 255)
    textMode(CENTER)
    text(self.text, WIDTH / 2, HEIGHT / 2 + 20 )
    self.yesBtn.x = WIDTH / 2 + 100
    self.yesBtn.y = HEIGHT / 2 - 10
    self.noBtn.x = WIDTH / 2 - 100
    self.noBtn.y = HEIGHT / 2 - 10
    self.yesBtn:draw()
    if self.hasCancel then self.noBtn:draw() end
    
end

function Dialog:touched(touch)
    if self.yesBtn:touched(touch) then return 1 end
    if self.noBtn:touched(touch) then return 2 end
    return 0
end
Monster = class()

montypes = {}
montypes[1] = {"Pink Pudding", 1, 1, true, 
                "Platformer Art:Monster Moving", "Platformer Art:Monster Squished",
                color(187, 191, 187, 255)}
montypes[2] = {"Hum Bat", 2, 1, true, 
                "Platformer Art:Battor Flap 1", "Platformer Art:Battor Flap 2",
                color(129, 176, 108, 255)}
montypes[3] = {"Blue Goo", 2, 2, true, 
                "Platformer Art:Monster Moving", "Platformer Art:Monster Squished",
                color(137, 147, 223, 255)}
montypes[4] = {"Doom Scarab", 2, 3, true, 
                "Planet Cute:Enemy Bug", "Planet Cute:Enemy Bug",
                color(238, 238, 241, 255)}


function Monster:init(n, f)
    local m = montypes[math.random(n)]
    self.name = m[1]
    self.health = 1 * math.random(m[2])
    self.strength = math.random(m[3]) + 1
    self.aggressive = m[4]
    self.clock = ElapsedTime
    self.x = 0
    self.y = 0
    self.image1 = m[5]
    self.image2 = m[6]
    self.tint = m[7]
    sprite()
    found = false
    while not found do
        x = math.random(50)
        y = math.random(50)
        if f.tiles[y][x] == "-" then
            self.x = x
            self.y = y
            found = true
        end
    end
end

function Monster:move()
    if self.health < 1 then return end
    local dx, dy, d
    if ElapsedTime - self.clock < 0.5 then return end
    self.clock = ElapsedTime
    -- a second has passed, monster can move
    dist = math.sqrt((self.x - cx) * (self.x- cx) + (self.y - cy) * (self.y - cy))
    if self.aggressive and dist < 10 then
        d = math.random(100)
        if d > 50 then
            local dy = 0
            -- try to advance on player vertically
            if cy > self.y then dy = 1 end
            if cy < self.y then dy = -1 end
            if self:check(self.x, self.y + dy) then
                self.y = self.y + dy 
            end
        else
            local dx = 0
            -- try to advance horizontally
            if cx > self.x then dx = 1 end
            if cx < self.x then dx = -1 end
            if self:check(self.x + dx, self.y) then
                self.x = self.x + dx
            end
        end
    end
end

function Monster:draw(ox, oy)
    dist = math.sqrt((self.x - cx) * (self.x- cx) + (self.y - cy) * (self.y - cy))
    if dist > 7 then return end
    local x, y = screenMap(self.x, self.y)
    y = y + 32
    if self.health < 1 then
        tint(127, 127, 127, 255)
        sprite(self.image2, x, y, 48, 48)
        return
        sprite()
    end
    tint(self.tint)
    if ElapsedTime - self.clock > .2 then
        sprite(self.image1, x, y, 48, 48)
    else
        sprite(self.image2, x, y, 48, 48)
    end
    noTint()
end

function Monster:drawCard()
    pushStyle()
    dist = math.sqrt((self.x - cx) * (self.x- cx) + (self.y - cy) * (self.y - cy))
    if dist > 3 then return end
    fill(195, 192, 192, 255)
    rect(WIDTH - 220, 20, 200, 200)
    noFill()
    strokeWidth(2)
    stroke(0, 0, 0, 255)
    rect(WIDTH - 210, 30, 180, 180)
    fill(32, 31, 114, 255)
    textMode(CENTER)
    text(self.name, WIDTH - 120, 190)
    textMode(CORNER)
    if self.health > 0 then
        tint(self.tint)
        sprite(self.image1, WIDTH - 120, 145, 64, 64)
    else
        tint(127, 127, 127, 255)
        sprite(self.image2, WIDTH - 160, 145, 64, 64)
    end
    noTint()
    fill(0, 0, 0, 255)
    text("Health", WIDTH - 190, 40)
    text("Strength", WIDTH - 190, 70)
    text("Health", 230, 120)
    for i = 1, self.health do
        sprite("Planet Cute:Heart",WIDTH - 120 + i * 22,50,28,32)
    end
    text("Strength", 230, 159)
    for i = 1, strength do
        sprite("Small World:Sword",WIDTH - 120 + i * 22,85,12,32)
    end
end

function Monster:check(x, y)
    if x == cx and y == cy and health > 0 then
        -- hit character
        sound(SOUND_EXPLODE, 10272)
        local s = math.random(m.strength)
        fm = FloatingMessage(s, screenMap(cx, cy))
        fm.tint = color(255, 0, 18, 255)
        table.insert(messages, fm)
        health = health - s
        hitTint = color(255, 0, 0, 255)
        return false
    end
    if floor.tiles[y][x] == "-" then return true end
    return false
end

function Monster:touched(touch)
    -- Codea does not automatically call this method
end
Treasure = class()

function Treasure:init(x, y)
    -- you can accept and set parameters here
    self.x = x
    self.y = y
    self.t = math.random(3)
end

function Treasure:draw(ox, oy)
    dist = math.sqrt((self.x - cx) * (self.x- cx) + (self.y - cy) * (self.y - cy))
    if dist > 7 then return end
    local x, y = screenMap(self.x, self.y)
    noTint()
    if self.t == 1 then
        sprite("Small World:Treasure", x, y + 24)
    end
    if self.t == 2 then
        if floorNum < 1 then
            tint(127, 127, 127, 255)
        end
        sprite("Small World:Sword",x,y + 22)
    end
    if self.t == 3 then
        sprite("Small World:Heart Glow",x,y + 28)
    end
end

That’s all, folks.

The program digs out random rooms, then connects them with tunnels. On each level it leaves a treasure chest, a door to the next level, and two keys.

It populates each level with a couple of monsters which wanders aimlessly until the player is near.

To do:

  • Choice of character avatar.
  • Greater variety of rooms and textures.
  • Ranged weapons / Ranged monster attacks.
  • “Store” to spend gold on improvements

This is so cool @Mark. I hope you don’t mind but I’m going to try re-attaching it as a zip here so more people can get it easily

(To open it tap on the zip and then choose Open in "Codea")

Thanks, Simeon. I had honestly forgotten you could do that. I had another chance to knock it around today and am almost done. I’ll zip up a new version soon.

This one has an intro screen, multiple avatars, armor that can be purchased for gold, gems that blow up monsters, leveling up, more monsters, and bug fixes. After some testing and code refinement, I may compile this little beastie for the store.

@Mark before you do let me get you on the beta with the newly updated Xcode export (should be ready tonight or tomorrow night)

@Mark that’s great! Did something similar a while back but just focused on the navigation - didn’t progress it past a simple treasure hunt game for my daughter with a fixed map. Love how you’ve packaged up as a fully working game.

https://codea.io/talk/discussion/9197/simple-a-star-path-finding-for-dungeon-crawler-example#latest

@Simeon Thanks. I’ll hang on for the next release.
@West Honestly, your dungeon generator is much better. I just took the simplest possible approach, counting on random room placement to generate a few interesting floor plans. Just about the only thing I spent time on was making sure everything was connected.

very neat, mark!

in the new version, how do i quit the game?

@Mark - thanks, but yours is way more fun!

@RonJeffries I’m using Fullscreen_No_Buttons, so to exit back to the editor you need to leave Codea and come back, the easiest way is to just hit the home button and select the app again.

ah, thanks, that’s pretty much what i did