Gary's Lament — a text unadventure

Hi folks!! was so glad to read your comments! they even helped me through a dark week where I started to wonder if defold was right for RPGs at all, but I’ve struggled through and I got some proper time to spend on the project this week. Have made some really great progress today.

first, the good stuff: http://masseur-square-72034.netlify.com/

that’s the first level at feature parity I think! I haven’t tested it much but I’m happy it’s mostly working. I had to solve a few problems to get there though so here they are:

Maps

This was what really discouraged me. In my JS implementation I’d written an importer for the Tiled JSON output and was just munging the data structures about to get what I wanted.

I wanted to do this idiomatically with Defold so I ditched Tiled and tried to work it out with the tilemaps and objects and such. Long story short I eventually realised that these aren’t well suited for the tiled RPG game I’m making — more for King-style physics-y object-y stuff. The biggest problem was realising this, after I decided I’d just port my Tiled stuff it got easy.

So now I’ve got:

  • My maps as static PNG images (not tiles), loaded as sprites
  • The Lua export of Tiled maps
  • A swiss-army-knife of functions I use for things like “is there a wall on the tile I’m facing?”. The hard work was already done here in my JS codebase.

For example:

M.position_is_walkable = function(map, pos)
  -- Does the collection of Room objects in Tiled
  -- include an object extending to the cell we're
  -- querying?
  local is_in_room = _.include(M.get_objects_by_type(map, "Room"), function(object)
    return M.object_covers(object, pos)
  end

  -- And are all the furniture objects not covering
  -- the cell we're querying?
  local is_not_on_furniture = _.all(M.get_objects_by_type(map, "Furniture"), function(n, object)
    return not M.object_covers(object, pos)
  end

  return is_in_room and is_not_on_furniture
end

M.object_covers = function(object, pos)
  -- Only works for rectangular objects
  local px_pos = M.tile_pos_to_px_pos(pos)
  return (
    (px_pos.x >= object.x) and
    (px_pos.x < (object.x + object.width)) and
    (px_pos.y >= object.y) and
    (px_pos.y < (object.y + object.height))
  )
end

I’m realising that the functional toolbelt I’m using (Moses) has a pretty messy API. I was searching for stuff like none, some, and even include with predicates. These exist in Moses but some take predicate functions, some take properties, some return booleans, some the key of the field, some the field itself, some take predicate functions but it’s not documented…

So I’ll be moving on at some point. I did take a look at luafun but I seem to recall having non-specific trouble with it and Defold. I’ll try it again. Short of that I’ll write my own…

The maps themselves look like this in Tiled:

I had a neat idea after a while of wrestling with converting coordinate spaces between Defold and Tiled (Defold increments from south-west to north-east, Tiled increments from north-west to south-east). I just moved all my maps below the x-axis like this:

Because then my coordinate transformation is just: { x = x, y = -y } :slight_smile: Well there’s actually a few off-by-ones but I mostly got to avoid those. And then I had to make the up arrow decrement the user’s y position instead of increment and vice versa… but really it was much simpler!

It’s a shame to be doing much of this myself rather than leveraging the framework, and it did give me some pause as to whether I was going down the right lines — but I think even with this Defold is still doing quite a lot of the work. Just not the maps stuff.

Text

I took @britzl’s advice and took a look at Lua’s coroutines — pretty handy! And I used them liberally in my text code. I’m not too ashamed to admit that I lost many 5 minute chunks to having misspelt yield in various places. I worked with Ruby for years and I still make that mistake all the time…

Each ‘Talker’ object in Tiled (confusingly named an engagement in the lua code — will be refactoring this) has an attribute specifying which text fn it should call upon to do its talking for it. I did keep all this information in the Tiled map for a while but it became very unweildy, so in the JS version I stored them in JSON and here they’re going straight in the Lua, e.g.

-- from some_texts.lua
M.window = Text.make_simple_sequence_text({
  "It's January, but the flowers are already blooming.",
  "You think some of the new shoots might be daffodils, but you've not lived here long enough to know for sure.",
  "You hope they will be OK. They shouldn't be out. The earth is changing."
})
-- from Text.lua:
M.make_simple_sequence_text = function (lines)
    return function ()
        return coroutine.create(function ()
            for n, line in pairs(lines) do
                coroutine.yield(line)
            end
        end)
    end
end
-- so then, e.g.:

local text_to_read = "window"

local text_routine = current_map_texts()[text_to_read]()

while coroutine.status(text_routine) == "suspended" do
  print(coroutine.resume(text_routine))
end

-- And we get:
--   It's January, but the flowers are already blooming.
--   You think some of the new shoots might be daffodils, but you've not lived here long enough to know for sure
--   [...]

You can probably see how this would be extended in future to allow conditionals etc.

Text animation

This was a tricky one. Here’s the rules:

  • Hit return next to an item and the text starts typewriter-animating, e.g. 1 letter at a time
  • If the user hits return again before the text has finished animating, it jumps to the end of the animation
  • If the user hits return after the text has finished animating:
    • If there is another section, it animates that
    • Otherwise, it closes the dialogue

Which I operationalised to a state machine like this:

machine.create({
  initial = "stopped",
  events = {
    -- Start animating a new section
    { name = "animate",  from = {"showing", "stopped"},   to = "animating" },
    -- Complete the animation naturally
    { name = "complete", from = "animating",              to = "showing" },
    -- Skip the rest of the animation
    { name = "skip",     from = "animating",              to = "showing" },
    -- Close the dialogue panel
    { name = "finish",   from = "showing",                to = "stopped" },
  },
})

Not totally happy with those names but they’ll do for now. I’ve set the state machine to post a message to the GUI script whenever it changes state — which is a nice flow that I think I’ll use again in future.

-- main script

-- This is simplified
function respond_to_engagement_intent()
  -- e.g. if the user has hit enter
  if not current_text_routine then
    -- [...] start a new text routine
  end
  if text_machine():is("animating") then
    -- if we're currently animating, skip to the end of the animation
    text_machine():skip()
  else
    -- Get our next text
    -- Shame `status` isn't more useful to us here...
    local status, text = coroutine.resume(current_text_routine)
    if coroutine.status(current_text_routine) == "suspended" then
      -- Presuming the coroutine hasn't finished yet, animate the text
      -- maybe there's a way of avoiding this conditional if we organised
      -- the coroutine differently... hmm
      text_machine():animate(text)
    else
      -- Otherwise, finish up
      text_machine():finish()
  end
end

-- gui script

function on_message(self, message_id, message, sender)
  if hash("text_machine_state_change") == message_id then
    if message.event == "animate" then
      gui.set_enabled(self.dialog_panel_id, true)
      initialize_text_animation(self, get_current_wrapped_text())
    elseif message.event == "skip" or message.event == "complete" then
      self.text_animation = nil
      gui.set_text(self.dialog_text_id, get_current_wrapped_text())
    elseif message.event == "finish" then
      gui.set_enabled(self.dialog_panel_id, false)
    end
  end
end

Then I used another coroutine to manage the typewriter animation:

function initialize_text_animation(self, text)
    self.text_animation = coroutine.create(function()
        for i = 1, string.len(text) do
            gui.set_text(self.dialog_text_id, string.sub(text, 0, i))
            coroutine.yield()
        end
    end)
    step_text_animation(self)
end

function step_text_animation(self)
    if not self.text_animation then return end
    if coroutine.status(self.text_animation) == "suspended" then
        coroutine.resume(self.text_animation)
        timer.frames(timers, 2, function() step_text_animation(self) end)
    else
        text_machine():complete()
    end
end

Why not just recursively call the timer here and be done with the coroutine? Good question. It would have been plausible to do it that way. I’m not sure if it would have been cleaner but it might have been.

Note: I got bit by a shared-contexts gotcha when using @britzl’s timer module (thanks btw!). If you just call timer.update in one on_update in your main script and just use the timer scheduling elsewhere you’re all cool — until you decide you want to schedule something in a GUI script. Then the contexts get confused and you get an unusual error.

I resolved this by making the timer stateless and making the caller responsible for storing the table of timeouts. So now instead of:

function on_update(self, dt)
  timer.update(dt)
  timer.frames(1, function() print("one frame later") end)
end

It’s:

function init(self)
  self.timers = {}
end

function on_update(self, dt)
  timer.update(self.timers, dt)
  timer.frames(self.timers, 1, function() print("one frame later") end)
end

That way the context is always going to be correct, as long as you don’t do anything weird. Note that in fact I didn’t put timers on self but instead made it a local as all my scripts so far are singletons… I’m sure I’ll end up paying for that later.

Next steps & miscellanea

I’ve still got a few refactors I want to make. Mostly just clean-up and name changes from today’s work — and maybe it’s time to remove some concepts that aren’t proving their worth… but more functionally:

  1. Get north/south portals working
  2. Add the second level
  3. Add some sort of level selector (maybe, or just make them separate projects or whatever)

I also spent some time trying to figure out how to make a redux-style state store work in Lua/Defold. There are some nice features of Lua that make it a compelling idea but ultimately I got stuck on the same thing that tripped me up in JS: how to deal with stuff-in-progress. E.g. when I’m walking from one tile to another, what should the store be reflecting and how can we bounce control back and forth between the store and the UI code without making it an unholy mess. I spiked out using coroutines there too but it was a real nightmare.

Seems like in games it’s difficult to cleanly separate async stuff like animation from the ‘business logic’. Possibly it’s not even worth trying and it’s my web dev brain making me think it’s an issue when it’s not :slight_smile: I’m sure there’s some way of making it work nicely, maybe even a good paradigm in there, but it’s not what I’m here for so I rolled it all back.

Anyway — thanks for reading if you are! Or thanks for skimming ahaha. See you next time!!

8 Likes