NPC item picker script

Started by alexgleason, October 24, 2018, 11:24:07 PM

Previous topic - Next topic
October 24, 2018, 11:24:07 PM Last Edit: October 24, 2018, 11:26:13 PM by alexgleason
This is sorta hard to explain, but basically I wanted the ability for any NPC to prompt the hero for an item, then the hero has the option to select an item in their inventory to give. It works like Paper Mario if you've ever played it. Demo:



My game revolves a lot around trading items, so it's important that this is streamlined. It allows writing code like this:

Code ( lua) Select
local bunny = map:get_entity("bunny1")

function bunny:on_interaction()
  game:start_dialog("bunny.want_carrot", function()
    bunny:ask_item(function(item)
      if item and item:get_name() == "carrot"
        item:set_variant(0)
        game:start_dialog("bunny.thanks_for_carrot")
      elseif item then
        -- a non-carrot item is given
        game:start_dialog("bunny.no_thanks")
      else
        -- The action was cancelled (item == nil). Do nothing.
      end
    end)
  end)
end


I'm not sure I organized my code the best way, but here it is:

scripts/menus/picker.lua:
Code ( lua) Select
-- ♡ Copying is an act of love. Please copy and share.

-- This menu ultimately exists to let you call npc:ask_item()
-- It's designed to be called from npc:ask_item() and really cannot stand alone

require("scripts/multi_events")

-- Items that could appear in this menu
local key_items = {
  'trade',
  'whiskey',
}
local player_items = {} -- Items the player actually has


-- Get the XY for an item sprite based on its position
local function get_slot_xy(i)
  local mx, my = 24, 32 -- offset from menu origin

  local r = math.ceil(i/4) -- row of this slot
  local ix = ((i-1)%4)*24
  local iy = (r-1)*24

  local x = mx + ix
  local y = my + iy

  return x, y
end


local picker = {} -- item picker menu


function picker:initialize()
  local font, font_size = "Comicoro", 16

  -- Graphics
  self.menu = sol.surface.create(144, 104)
  self.menu_bg = sol.sprite.create("menus/menu_bg")
  self.item_sprite = sol.sprite.create("entities/items")
  self.cursor_sprite = sol.sprite.create("menus/cursor")

  -- Player sprite
  self.player_sprite = sol.sprite.create("main_heroes/rachel")
  self.player_sprite:set_animation("walking")
  self.player_sprite:set_direction(3)

  -- NPC sprite
  self.npc_sprite = nil

  -- Question text
  self.title = sol.text_surface.create({text_key="menus.picker.question", font=font, font_size=font_size})
  self.title:set_color({0, 0, 0})

  -- Nope button
  self.nope_button = sol.surface.create(32, 16)
  self.nope_button:fill_color({198, 34, 0})
  local button_text = sol.text_surface.create({text_key="menus.picker.nope", font=font, font_size=font_size})
  button_text:draw(self.nope_button, 4, 8)
end


-- Called when the picker menu starts
function picker:on_started()
  sol.main.game:get_hero():freeze()
  sol.main.game._picker_enabled = true

  -- Set items the player has
  player_items = {}
  for i, v in ipairs(key_items) do
    if sol.main.game:has_item(v) then
      table.insert(player_items, v)
    end
  end

  -- Graphics
  self.npc_sprite = sol.sprite.create(self._npc:get_sprite():get_animation_set())
  self.npc_sprite:set_direction(3)

  -- Initialize properties
  self.cursor = 1
  self._selection = nil
end


-- Set/get the selected item (when "action" is pressed)
function picker:set_selection(item)
  self._selection = item
end

function picker:get_selection()
  return self._selection
end


-- Called every frame
function picker:on_draw(dst_surface)

  -- Draw BG graphics
  self.menu:draw(dst_surface, 56, 16)
  self.menu_bg:draw(self.menu)

  -- Draw characters
  local x, y
  x, y = self.player_sprite:get_origin()
  self.player_sprite:draw(self.menu, 24+x, 8+y) -- draw player sprite
  x, y = self.npc_sprite:get_origin()
  self.npc_sprite:draw(self.menu, 104+x, 8+y) -- draw NPC sprite

  -- Draw question
  self.title:draw(self.menu, 49, 16)

  -- Draw items, loop through inventory
  for i, item in ipairs(player_items) do
    if sol.main.game:has_item(item) then
      self.item_sprite:set_animation(item) -- all items are in one sheet
      local x, y = get_slot_xy(i) -- item slot XY
      local ox, oy = self.item_sprite:get_origin() -- origin offset
      self.item_sprite:draw(self.menu, x+ox, y+oy) -- draw item
      if self.cursor == i then
        self.cursor_sprite:draw(self.menu, x, y) -- draw cursor
      end
    end
  end

  -- Draw cancel button
  self.nope_button:draw(self.menu, 56, 80)
  if self.cursor == #player_items+1 then
    self.cursor_sprite:draw(self.menu, 64, 80) -- cancel button cursor
  end
end


-- Called when a button is pressed
function picker:on_command_pressed(command)

  -- D-Pad controls
  if command == "up" then
    self.cursor = self.cursor - 4 -- up and down navigate between rows
  elseif command == "down" then
    self.cursor = self.cursor + 4
  elseif command == "left" then
    self.cursor = self.cursor - 1
  elseif command == "right" then
    self.cursor = self.cursor + 1
  end

  -- Cursor must be between 1 and #player_items+1 (cancel)
  self.cursor = math.max(1, self.cursor)
  self.cursor = math.min(self.cursor, #player_items+1)

  -- Handle selection
  if command == "action" then
    if self.cursor ~= #player_items+1 then
      local item = sol.main.game:get_item(player_items[self.cursor])
      self:set_selection(item)
    end
    sol.menu.stop(self)
    sol.main.game:get_hero():unfreeze()
    sol.main.game._picker_enabled = false
  end

  return true
end


-- Initialize picker when the game starts
local game_meta = sol.main.get_metatable("game")
game_meta:register_event("on_started", function(self)
  picker:initialize()
end)


return picker


scripts/npc_ask_item.lua:
Code ( lua) Select
-- ♡ Copying is an act of love. Please copy and share.

require("scripts/multi_events")
local picker = require("scripts/menus/picker")


-- Add npc:ask_item(callback)

local npc_meta =  sol.main.get_metatable("npc")

-- Start a picker menu for the NPC
function npc_meta:ask_item(callback)
  picker._npc = self
  sol.menu.start(self:get_map(), picker)
  function picker:on_finished()
    callback(self:get_selection())
    picker._npc = nil
    self.on_finished = nil -- destroy this function between calls
  end
end


-- Initialize game with picker menu

local game_meta = sol.main.get_metatable("game")

-- Check whether a picker is already active
function game_meta:is_picker_enabled()
  return self._picker_enabled
end

-- Initialize game with _picker_enabled attribute
game_meta:register_event("on_started", function(self)
  self._picker_enabled = false
end)


Finally, I've just added the script to features.lua:
Code ( lua) Select
require("scripts/npc_ask_item") -- NPC item picker menu
RIP Aaron Swartz

That's nifty! Excellent work.

You should consider defining the items eligible to appear in the picker as part of the item scripts with some sort of custom property rather than inside the picker script itself. Perhaps add an is_giftable() method to the item metatable?

Quote from: llamazing on October 25, 2018, 02:15:11 AM
That's nifty! Excellent work.

You should consider defining the items eligible to appear in the picker as part of the item scripts with some sort of custom property rather than inside the picker script itself. Perhaps add an is_giftable() method to the item metatable?

Thanks!

In general, items you trade will always be non-equipment items (as with most Zelda games). So I'm planning to add something like item:set_key_item(boolean), where "true" means a non-equipment item.

I'm not sure how fancy I want to get yet, but I've been thinking about npc:ask_item(filter, callback) where "filter" is a function like filter(item) that returns true if the given item should be listed.

Eg,

Code ( lua) Select
local function filter(item)
  if item:get_name() == "bad_item" then
    return false
  end
  return item:is_key_item()
end

npc:ask_item(filter, function(item)
  print(item:get_name()
)



The only thing blocking me is that Solarus doesn't seem to have a way to list all the possible items in the quest, so I can't loop through them.
RIP Aaron Swartz

Quote from: alexgleason on October 25, 2018, 03:15:55 AM
The only thing blocking me is that Solarus doesn't seem to have a way to list all the possible items in the quest, so I can't loop through them.

Here's a bit of a hack, but you can get the names of all items in your quest by reading the contents of the project_db.dat file:
Code (lua) Select
local function get_all_items()
    local all_items = {}

    local env = {}
    function env.item(properties)
        local id = properties.id
        assert(type(id)=="string", "item id must be a string")

        table.insert(all_items, id)
    end

    setmetatable(env, {__index = function() return function() end end})

    local chunk = sol.main.load_file("project_db.dat")
    setfenv(chunk, env)
    chunk()

    return all_items
end


Then you can use game:get_item(item_name) to convert the item names into item objects.

Quote from: llamazing on October 25, 2018, 04:32:32 AM
Here's a bit of a hack, but you can get the names of all items in your quest by reading the contents of the project_db.dat file:

Woaahh, thank you!! I considered doing something like this but I didn't know how. It's great to have help from a Lua wizard. ;D Thank you.
RIP Aaron Swartz

I had to stare at your code, read about Lua environments, and stare some more for like 30 minutes, until it finally clicked. quest_db is a lua file that makes function calls (item()), so we can simply define item(), load quest_db, and set its environment to our custom function(s). Awesome.

I did notice that Lua 5.2 drops setfenv() apparently. I'm guessing Solarus runs on an older version?
RIP Aaron Swartz


Note that Solarus 1.6 will have a new feature sol.main.get_resource_ids("item").

October 25, 2018, 05:16:44 PM #8 Last Edit: October 26, 2018, 05:29:37 PM by alexgleason
Quote from: Christopho on October 25, 2018, 09:50:40 AM
Note that Solarus 1.6 will have a new feature sol.main.get_resource_ids("item").

Nice! Using llamazing's code I created a polyfill for the feature.

scripts/polyfills/get_resource_ids.lua:
Code ( lua) Select
-- Polyfill for sol.main.get_resource_ids(resource_type)
--   See: http://www.solarus-games.org/doc/1.6/lua_api_main.html#lua_api_main_get_resource_ids
--
-- Note that v1.6 also allows dynamically adding new resources at runtime, so
-- parsing project_db.dat will no longer be a viable way to get all resource IDs
--
-- Originally created by llamazing, modified here
--   Source: http://forum.solarus-games.org/index.php/topic,1256.msg7443.html#msg7443

local function get_resource_ids(resource_type)
  local all_resources = {}

  local env = {}
  env[resource_type] = function(properties)
      local id = properties.id
      assert(type(id)=="string", "id must be a string")

      table.insert(all_resources, id)
  end

  setmetatable(env, {__index = function() return function() end end})

  local chunk = sol.main.load_file("project_db.dat")
  setfenv(chunk, env)
  chunk()

  return all_resources
end

-- Only use this polyfill if Solarus < 1.6
if not sol.main.get_resource_ids then
  sol.main.get_resource_ids = get_resource_ids
end


Added to scripts/features.lua:
Code ( lua) Select
require("scripts/polyfills/get_resource_ids")

It totally works!

> for _, item in ipairs(sol.main.get_resource_ids("item")) do print(item) end
stick
trade
vacuum
whiskey
> for _, map in ipairs(sol.main.get_resource_ids("map")) do print(map) end
0000
Water_Dungeon_final
bar
beach
beach_house_1
beach_house_2
beach_house_3
...
RIP Aaron Swartz

October 25, 2018, 09:59:58 PM #9 Last Edit: October 28, 2018, 12:48:50 AM by alexgleason
I ended up with npc:prompt_item(callback, [filter])

scripts/npc_prompt_item.lua
https://gitlab.com/voadi/voadi/blob/07cfbfd037ba12545cc46fcada04c18b1deb0c88/data/scripts/npc_prompt_item.lua

scripts/menus/picker.lua
https://gitlab.com/voadi/voadi/blob/07cfbfd037ba12545cc46fcada04c18b1deb0c88/data/scripts/menus/picker.lua

When the menu starts, it loops through all the game's items and applies the filter (if given). If a filter isn't given, it uses a default filter which checks that item:is_key_item() == true (a quest-specific, custom property).

Thanks again llamazing (and Christopho) for your help. :)
RIP Aaron Swartz

Quote from: alexgleason on October 25, 2018, 05:16:44 PM
Nice! Using llamazing's code I created a polyfill for the feature.

scripts/get_resource_ids.lua:
Code ( lua) Select
-- Polyfill for sol.main.get_resource_ids(resource_type)
--   See: http://www.solarus-games.org/doc/1.6/lua_api_main.html#lua_api_main_get_resource_ids
--
-- Originally created by llamazing
--   Source: http://forum.solarus-games.org/index.php/topic,1256.msg7443.html#msg7443

function sol.main.get_resource_ids(resource_type)

btw, you should change line 7 so that it doesn't overwrite the real get_resource_ids() function once v1.6 is released. Something like the following:
Code (lua) Select
sol.main.get_resource_ids = sol.main.get_resource_ids or function(resource_type)

That way if sol.main.get_resource_ids is already defined (as a function) then the first half of the conditional evaluates to true and the second half of the conditional won't be evaluated (thus your custom function is ignored and the actual API function won't be overwritten).

Note that v1.6 also allows dynamically adding new resources at runtime, so parsing the project_db.dat will no longer be a viable way to get all resource ids: issue #630

Quote from: llamazing on October 26, 2018, 05:19:07 AM
btw, you should change line 7 so that it doesn't overwrite the real get_resource_ids() function once v1.6 is released.
Quote from: llamazing on October 26, 2018, 05:19:07 AM
Note that v1.6 also allows dynamically adding new resources at runtime, so parsing the project_db.dat will no longer be a viable way to get all resource ids: issue #630

Updated my post above. Thanks!
RIP Aaron Swartz