Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Making Dev Tools

You can make custom dev tools for your games. Just create a file for your tool, like leveleditor.lua and build it like an Usagi game. Then run usagi dev leveleditor.lua to launch your tool and have Usagi’s live reload. You can even require("myfile") and use the Lua code from your game in your tool. Pretty nice!

You could parse and load your game data from data directory using plain text or JSON. Combine this with Lua’s io module, and you can write files back to the disk:

Level = usagi.read_json("level1.json")
-- make changes to `Level`; then save it:
local f = io.open("data/level1.json", "w")
if f then
  f:write(usagi.to_json(Level))
  f:close()
  print("Saved data/leve1.json")
end

You can also use Lua tables in a similar way:

local level1 = require("level1")
-- make changes to `level1` based on interactions; then save it
local f = io.open("level1.lua", "w")
if f then
  f:write("return " .. usagi.dump(State.level) .. "\n")
  f:close()
  print("Saved level1.lua")
end

The file opening and writing are great candidates to wrap in a function. You could call that whenever your level changes or when you press certain keyboard keys to save. Since it’s all just simple data, you can track it in version control to make your changes less risky.

You can also use gfx.spr too to render your sprites from your game.

The benefit of building your own dev tools with Usagi is that the tool meets your game’s specific needs, you can reuse your source code, and you learn a lot!

Here are some ideas for what dev tools you could make:

  • Tile-based level editor
  • Database browser if you had all your game data in data files, like an enemy bestiary
  • Spawn scheduler for a shmup

Here’s an example of a dev tool I made for one of my shmups that lets me place enemies that spawn at a specific tick in the game. I interact with the UI with the mouse and it auto-saves everytime I make changes.

-- schedit - schedule editor

local level1 = require("level1")
local LANE = require("lane")
local Enemy = require("enemy")

local enemy_names = {}

for name, _v in pairs(Enemy.kind) do
  table.insert(enemy_names, name)
end
table.sort(enemy_names)
print("enemies: " .. usagi.dump(enemy_names))

local function current_enemy()
  return enemy_names[State.enemy_idx]
end

local function save_level()
  local f = io.open("level1.lua", "w")
  if f then
    f:write("return " .. usagi.dump(State.level) .. "\n")
    f:close()
    print("Saved level1.lua")
  end
end

Color = require("color")

function _config()
  return { name = "Schedule Editor", game_id = "com.neogeargame.schedit", game_width = 320, game_height = 320 }
end

function _init()
  State = {
    tick = 0,
    level = level1,
    enemy_idx = 1,
  }
end

local function next_with_spawn()
  local ticks = {}

  for tick, schedule in pairs(State.level) do
    if #schedule > 0 then
      table.insert(ticks, tick)
    end
  end

  table.sort(ticks)

  for _i, tick in ipairs(ticks) do
    if tick > State.tick then
      return tick
    end
  end

  return nil
end

local function prev_with_spawn()
  local ticks = {}

  for tick, schedule in pairs(State.level) do
    if #schedule > 0 then
      table.insert(ticks, tick)
    end
  end

  table.sort(ticks, function(a, b)
    return a > b
  end)

  for _i, tick in ipairs(ticks) do
    if tick < State.tick then
      return tick
    end
  end

  return nil
end

-- side-entry spawn slots for rook-style enemies that arc in from off-screen
local SIDE = {
  L = { x = -20, ui_x = 16 },
  R = { x = usagi.GAME_W + 20, ui_x = usagi.GAME_W - 16 },
}
local SIDE_SPAWN_Y = 20
local SLOT_Y = 58
local SLOT_H = 70

-- places `kind` at the schedule entry for the current tick at `x` (with optional `y`),
-- overwriting any spawn already at that x
local function place_at(x, y)
  print("place " .. current_enemy() .. " at x=" .. x .. (y and (",y=" .. y) or ""))

  local schedule = State.level[State.tick] or {}
  local set = false
  for i, spawn in ipairs(schedule) do
    if spawn.x == x then
      schedule[i].kind = current_enemy()
      schedule[i].y = y
      set = true
    end
  end

  if not set then
    table.insert(schedule, { kind = current_enemy(), x = x, y = y })
  end

  State.level[State.tick] = schedule
  save_level()
end

-- removes the spawn at `x` for the current tick
local function rm_at(x)
  print("rm spawn at x=" .. x)

  local schedule = State.level[State.tick]
  if schedule then
    local to_rm = nil
    for i, spawn in ipairs(schedule) do
      if spawn.x == x then
        to_rm = i
      end
    end
    if to_rm then
      table.remove(schedule, to_rm)
    end
    save_level()
  end

  State.level[State.tick] = schedule
end

-- returns one of "L" / "R" / a lane index / nil, identifying the placement slot under the mouse
local function get_mouse_slot()
  local mx, my = input.mouse()
  local mp = { x = mx, y = my }

  for side, info in pairs(SIDE) do
    local rect = { x = info.ui_x - 4, y = SLOT_Y, w = 8, h = SLOT_H }
    if util.point_in_rect(mp, rect) then
      return side
    end
  end

  for i, x in ipairs(LANE) do
    local rect = { x = x - 4, y = SLOT_Y, w = 8, h = SLOT_H }
    if util.point_in_rect(mp, rect) then
      return i
    end
  end

  return nil
end

local function slot_x(slot)
  return type(slot) == "string" and SIDE[slot].x or LANE[slot]
end

local function slot_y(slot)
  return type(slot) == "string" and SIDE_SPAWN_Y or nil
end

local SCROLL_TICKS = 10
function _update(_dt)
  if input.pressed(input.LEFT) then
    State.enemy_idx -= 1
  end
  if input.pressed(input.RIGHT) then
    State.enemy_idx += 1
  end
  State.enemy_idx = util.clamp(State.enemy_idx, 1, #enemy_names)

  if input.pressed(input.DOWN) or input.mouse_scroll() == 1 then
    State.tick -= SCROLL_TICKS
  end
  if input.pressed(input.UP) or input.mouse_scroll() == -1 then
    State.tick += SCROLL_TICKS
  end

  if input.key_pressed(input.KEY_Q) then
    local prev_tick_with_spawn = prev_with_spawn()
    if prev_tick_with_spawn then
      State.tick = prev_tick_with_spawn
    end
  end
  if input.key_pressed(input.KEY_E) then
    local next_tick_with_spawn = next_with_spawn()
    if next_tick_with_spawn then
      State.tick = next_tick_with_spawn
    end
  end

  State.tick = util.clamp(State.tick, 0, 10000)

  if input.key_pressed(input.KEY_X) then
    save_level()
  end

  if input.mouse_pressed(input.MOUSE_LEFT) then
    local slot = get_mouse_slot()
    if slot then
      place_at(slot_x(slot), slot_y(slot))
    end
  end
  if input.mouse_pressed(input.MOUSE_RIGHT) then
    local slot = get_mouse_slot()
    if slot then
      rm_at(slot_x(slot))
    end
  end
end

function _draw()
  gfx.clear(Color.BLACK)
  gfx.text("tick: " .. State.tick, 8, 8, Color.WHITE)
  gfx.text(string.format("time: %.2fs", State.tick / 60), 92, 8, Color.PEACH)

  gfx.text("A/D = change enemy; W/S = change tick;\nQ/E = jump tick; LMB to place, RMB to rm",
    8, usagi.GAME_H - 40, Color.PEACH)

  local schedule = State.level[State.tick]

  for i, x in ipairs(LANE) do
    local txt = "lane " .. i
    local w, _h = usagi.measure_text(txt)
    gfx.text(txt, x - w / 2, 40, Color.LIGHT_BLUE)

    gfx.rect_fill(x - 4, SLOT_Y, 8, SLOT_H, Color.LIGHT_BLUE)
  end

  for side, info in pairs(SIDE) do
    local w, _h = usagi.measure_text(side)
    gfx.text(side, info.ui_x - w / 2, 40, Color.ORANGE)
    gfx.rect_fill(info.ui_x - 4, SLOT_Y, 8, SLOT_H, Color.ORANGE)
  end

  if schedule then
    for _i, spawn in ipairs(schedule) do
      local txt = spawn.kind
      local w, _h = usagi.measure_text(txt)
      -- side-spawned enemies have an off-screen x; clamp the label to the side's UI slot
      local label_x = spawn.x
      if spawn.x < 0 then
        label_x = SIDE.L.ui_x
      elseif spawn.x > usagi.GAME_W then
        label_x = SIDE.R.ui_x
      end
      gfx.text(txt, label_x - w / 2, 132, Color.PEACH)
    end
  end

  local mx, my = input.mouse()
  gfx.text(current_enemy(), mx, my, Color.LIGHT_GRAY)
end