menu_lib.lua 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. --- Shared building blocks for the menu scripts.
  2. --
  3. -- Provides:
  4. -- * UiContainer.Cursor: a cursor over a fixed list of positions.
  5. -- * UiContainer.ListCursor: a cursor over a scrollable list.
  6. -- * UiContainer.Menu: a small state machine for menus (replaces the nested
  7. -- "if current_menu == ... if current_submenu == ..." ladders).
  8. -- * Formatting helpers and constants.
  9. --
  10. -- Menus still publish UiContainer.current_menu / UiContainer.current_submenu, so menus that haven't
  11. -- been migrated yet keep working next to the migrated ones.
  12. if UiContainer == nil then UiContainer = {} end
  13. ---------------------------------------------------------------------------------------------------
  14. -- Constants and formatting
  15. ---------------------------------------------------------------------------------------------------
  16. --- Status effect ids shown in menus.
  17. UiContainer.Status = {
  18. SADNESS = 4,
  19. FURY = 5,
  20. }
  21. --- Formats a money amount, right aligned.
  22. -- @param value The amount of gil.
  23. -- @param length Width of the field. Defaults to 8.
  24. UiContainer.format_money = function(value, length)
  25. return UiContainer.pad_value(value, length or 8)
  26. end
  27. --- Formats a number of seconds as HHH:MM.
  28. UiContainer.format_time = function(seconds)
  29. return UiContainer.pad_value(math.floor(seconds / 3600), 3) .. ":"
  30. .. UiContainer.zero_pad_value(math.floor((seconds % 3600) / 60), 2)
  31. end
  32. --- Calculates how much of the way to the next level a character has done.
  33. --
  34. -- Uses the EXP curve of the character (see Game.Growth.curves): the EXP needed to attain a level
  35. -- L is Mod * (L - 1)^2 / 10, where Mod is the gradient of the curve segment for L. The character
  36. -- stores the EXP still missing for the next level (exp_to_next).
  37. --
  38. -- @param character Entry of the Characters table.
  39. -- @return A number from 0 to 1. 0 if it can't be calculated (e.g. level 99).
  40. UiContainer.exp_progress = function(character)
  41. local definition = Game.Characters[character.char_id]
  42. if definition == nil or definition.curves == nil or definition.curves.exp == nil then
  43. return 0
  44. end
  45. local curve = Game.Growth.curves[definition.curves.exp]
  46. local level = character.level
  47. local target = level + 1
  48. if curve == nil or target > 99 then return 0 end
  49. local segment
  50. if target <= 11 then segment = 0
  51. elseif target <= 21 then segment = 1
  52. elseif target <= 31 then segment = 2
  53. elseif target <= 41 then segment = 3
  54. elseif target <= 51 then segment = 4
  55. elseif target <= 61 then segment = 5
  56. elseif target <= 81 then segment = 6
  57. else segment = 7 end
  58. local span = math.floor(curve[segment].gradient * level * level / 10)
  59. if span <= 0 or character.exp_to_next == nil then return 0 end
  60. return math.max(0, math.min(1, 1 - character.exp_to_next / span))
  61. end
  62. --- Fills a two-line bar (widgets "<prefix>1" and "<prefix>2").
  63. --
  64. -- @param prefix Path of the bar widgets, without the final 1/2.
  65. -- @param fraction Fill amount, 0 to 1.
  66. -- @param width Width in pixels of the full bar.
  67. UiContainer.set_bar = function(prefix, fraction, width)
  68. fraction = math.max(0, math.min(1, fraction))
  69. local pixels = math.floor(fraction * width)
  70. for line = 1, 2 do
  71. local widget = ui_manager:get_widget(prefix .. tostring(line))
  72. widget:set_visible(pixels > 0)
  73. widget:set_width(pixels)
  74. end
  75. end
  76. --- Is the cursor memory on? (Cursor: Memory in the config menu.)
  77. --
  78. -- If it is, the cursors of the menus and of the battle commands start where they were left. If not
  79. -- (Initial), they start at the first position.
  80. UiContainer.cursor_memory = function()
  81. return MenuSettings ~= nil and MenuSettings.config ~= nil and MenuSettings.config.cursor == 2
  82. end
  83. --- Maximum value of the limit bar of a character.
  84. UiContainer.LIMIT_BAR_MAX = 255
  85. --- Fills the limit bar of a character (see set_bar).
  86. --
  87. -- @param prefix Path of the bar widgets, without the final 1/2.
  88. -- @param character Entry of the Characters table.
  89. -- @param width Width in pixels of the full bar.
  90. UiContainer.set_limit_bar = function(prefix, character, width)
  91. UiContainer.set_bar(prefix, character.limit.bar / UiContainer.LIMIT_BAR_MAX, width)
  92. end
  93. --- Plays a menu sound.
  94. -- @param name One of "Cursor", "Back", "Error", "Window"...
  95. UiContainer.play_sound = function(name)
  96. audio_manager:play_sound(name)
  97. end
  98. --- Resolves a widget given its full path, or returns the argument if it is already a widget.
  99. local function resolve_widget(widget)
  100. if type(widget) == "string" then
  101. return ui_manager:get_widget(widget)
  102. end
  103. return widget
  104. end
  105. ---------------------------------------------------------------------------------------------------
  106. -- Cursor
  107. ---------------------------------------------------------------------------------------------------
  108. --- A cursor over a fixed list of positions (1..total).
  109. --
  110. -- The cursor widget must define animations "Position1".."PositionN".
  111. --
  112. -- self.cursor = UiContainer.Cursor.new{
  113. -- widget = "ItemMenu.Container.ItemOrder.Cursor",
  114. -- total = 8,
  115. -- on_move = function(position) ... end, -- optional
  116. -- }
  117. --
  118. -- Options:
  119. -- * widget: Path of the cursor widget (resolved lazily), or a widget. Optional.
  120. -- * total: Number of positions.
  121. -- * wrap: Wrap around the ends. Defaults to true.
  122. -- * sound: Sound played on move. Defaults to "Cursor". false for none.
  123. -- * on_move: Called with the new position after every move.
  124. UiContainer.Cursor = {}
  125. UiContainer.Cursor.__index = UiContainer.Cursor
  126. UiContainer.Cursor.new = function(options)
  127. local cursor = setmetatable({}, UiContainer.Cursor)
  128. cursor.widget = options.widget
  129. cursor.total = options.total or 1
  130. cursor.wrap = options.wrap ~= false
  131. cursor.sound = options.sound
  132. if cursor.sound == nil then cursor.sound = "Cursor" end
  133. cursor.on_move = options.on_move
  134. cursor.position = options.position or 1
  135. return cursor
  136. end
  137. --- Moves the cursor to a position and updates the widget. Plays no sound.
  138. function UiContainer.Cursor:set(position)
  139. self.position = position
  140. if self.widget ~= nil then
  141. resolve_widget(self.widget):set_default_animation("Position" .. self.position)
  142. end
  143. if self.on_move ~= nil then self.on_move(self.position) end
  144. end
  145. --- Refreshes the widget with the current position.
  146. function UiContainer.Cursor:refresh()
  147. self:set(self.position)
  148. end
  149. --- Moves by a delta. Returns true if the cursor actually moved.
  150. function UiContainer.Cursor:move(delta)
  151. local target = self.position + delta
  152. if target > self.total then
  153. if not self.wrap then
  154. if self.sound then UiContainer.play_sound("Error") end
  155. return false
  156. end
  157. target = 1
  158. elseif target < 1 then
  159. if not self.wrap then
  160. if self.sound then UiContainer.play_sound("Error") end
  161. return false
  162. end
  163. target = self.total
  164. end
  165. if self.sound then UiContainer.play_sound(self.sound) end
  166. self:set(target)
  167. return true
  168. end
  169. function UiContainer.Cursor:next() return self:move(1) end
  170. function UiContainer.Cursor:prev() return self:move(-1) end
  171. --- Currently highlighted position.
  172. function UiContainer.Cursor:index() return self.position end
  173. --- Handles a directional button.
  174. --
  175. -- @param button Button name.
  176. -- @param axis "vertical" (Up/Down) or "horizontal" (Left/Right).
  177. -- @return true if the button was a direction of the axis (even if the cursor didn't move).
  178. function UiContainer.Cursor:handle(button, axis)
  179. local forward, backward
  180. if axis == "horizontal" then
  181. forward, backward = Config.CONTROLS.RIGHT, Config.CONTROLS.LEFT
  182. else
  183. forward, backward = Config.CONTROLS.DOWN, Config.CONTROLS.UP
  184. end
  185. if button == forward then
  186. self:next()
  187. return true
  188. elseif button == backward then
  189. self:prev()
  190. return true
  191. end
  192. return false
  193. end
  194. --- Handlers for the direction buttons that move a cursor over a grid.
  195. --
  196. -- The cursor goes through the cells row by row, and wraps around at the ends of rows and columns.
  197. -- Up and down keep the column, or go to the last cell of a shorter row. Use them as the `buttons` of
  198. -- a state of a menu.
  199. --
  200. -- buttons = UiContainer.grid_buttons("level_cursor", 2),
  201. --
  202. -- @param cursor_name Name of the field of the menu that holds the cursor (a Cursor).
  203. -- @param columns Number of columns of the grid.
  204. UiContainer.grid_buttons = function(cursor_name, columns)
  205. local buttons = {}
  206. for _, button in ipairs({
  207. Config.CONTROLS.UP, Config.CONTROLS.DOWN, Config.CONTROLS.LEFT, Config.CONTROLS.RIGHT
  208. }) do
  209. buttons[button] = function(self, event)
  210. local cursor = self[cursor_name]
  211. local index = cursor:index() - 1
  212. local column = index % columns
  213. local row = math.floor(index / columns)
  214. local rows = math.ceil(cursor.total / columns)
  215. local function row_length(r) return math.min(columns, cursor.total - r * columns) end
  216. if button == Config.CONTROLS.LEFT then
  217. column = (column - 1) % row_length(row)
  218. elseif button == Config.CONTROLS.RIGHT then
  219. column = (column + 1) % row_length(row)
  220. else
  221. row = (row + ((button == Config.CONTROLS.DOWN) and 1 or -1)) % rows
  222. column = math.min(column, row_length(row) - 1)
  223. end
  224. UiContainer.play_sound("Cursor")
  225. cursor:set(row * columns + column + 1)
  226. end
  227. end
  228. return buttons
  229. end
  230. ---------------------------------------------------------------------------------------------------
  231. -- ListCursor
  232. ---------------------------------------------------------------------------------------------------
  233. --- A cursor over a scrollable list: `rows` visible rows over `count` items.
  234. --
  235. -- The cursor moves inside the window until it hits an edge, then the window scrolls. It never
  236. -- wraps.
  237. --
  238. -- Options:
  239. -- * widget: Cursor widget, with animations "Position1".."PositionRows".
  240. -- * rows: Visible rows.
  241. -- * count: Total items in the list. Can be changed later through the `count` field.
  242. -- * on_scroll: Called when the window scrolls (to repopulate the rows).
  243. -- * on_move: Called after every successful move.
  244. UiContainer.ListCursor = setmetatable({}, { __index = UiContainer.Cursor })
  245. UiContainer.ListCursor.__index = UiContainer.ListCursor
  246. UiContainer.ListCursor.new = function(options)
  247. local cursor = setmetatable({}, UiContainer.ListCursor)
  248. cursor.widget = options.widget
  249. cursor.rows = options.rows
  250. cursor.total = options.rows
  251. cursor.count = options.count or options.rows
  252. cursor.sound = options.sound
  253. if cursor.sound == nil then cursor.sound = "Cursor" end
  254. cursor.on_scroll = options.on_scroll
  255. cursor.on_move = options.on_move
  256. cursor.position = 1 -- Row inside the window.
  257. cursor.first = 1 -- First item shown in the window.
  258. return cursor
  259. end
  260. --- Index of the highlighted item in the whole list.
  261. function UiContainer.ListCursor:index()
  262. return self.first + self.position - 1
  263. end
  264. --- Resets to the top of the list.
  265. function UiContainer.ListCursor:reset()
  266. self.first = 1
  267. self:set(1)
  268. end
  269. function UiContainer.ListCursor:move(delta)
  270. local index = self:index() + delta
  271. if index < 1 or index > self.count then
  272. if self.sound then UiContainer.play_sound("Error") end
  273. return false
  274. end
  275. if self.sound then UiContainer.play_sound(self.sound) end
  276. local row = self.position + delta
  277. if row < 1 or row > self.rows then
  278. -- Scroll instead of moving the cursor.
  279. self.first = self.first + delta
  280. if self.on_scroll ~= nil then self.on_scroll(self.first) end
  281. if self.on_move ~= nil then self.on_move(self.position) end
  282. else
  283. self:set(row)
  284. end
  285. return true
  286. end
  287. ---------------------------------------------------------------------------------------------------
  288. -- Menu
  289. ---------------------------------------------------------------------------------------------------
  290. --- Menu state machine.
  291. --
  292. -- A menu is declared as a table of states. Each state describes how it reacts to the buttons, and
  293. -- UiContainer.Menu.new() adds the on_button / set_state plumbing:
  294. --
  295. -- UiContainer.PauseMenu = UiContainer.Menu.new{
  296. -- id = "pause", -- Value of UiContainer.current_menu while open.
  297. -- initial_state = "", -- State entered by open(). Defaults to "".
  298. -- states = {
  299. -- [""] = {
  300. -- cursor = function(self) return self.cursor end,
  301. -- axis = "vertical", -- Directional axis handled by the cursor.
  302. -- confirm = function(self, cursor) ... end,
  303. -- cancel = function(self) ... end,
  304. -- buttons = { -- Any other button.
  305. -- [Config.ACTIONS.L1] = function(self, event) ... end,
  306. -- },
  307. -- },
  308. -- },
  309. -- show = ..., hide = ...,
  310. -- }
  311. --
  312. -- Rules:
  313. -- * When a menu gives the control back (deactivate), the receiving menu's optional `on_resume`
  314. -- hook runs, so it can refresh data the submenu may have changed.
  315. -- * confirm and cancel only fire on "Press" (not on key repeat).
  316. -- * Directions fire on "Press" and "Repeat".
  317. -- * cancel is followed by the "Back" sound. confirm is followed by "Cursor". If either handler
  318. -- returns false, "Error" is played instead.
  319. -- * A state may set `confirm_sound = false` (or `cancel_sound = false`) when its handler plays its
  320. -- own sound.
  321. -- * A state may set `blocked = true` to swallow all input.
  322. -- * A state may define `capture = function(self, key)`, called with the name of the key when the
  323. -- player presses the key requested with input_manager:capture_next_key().
  324. UiContainer.Menu = {}
  325. --- Menus created with UiContainer.Menu.new(), by id.
  326. UiContainer.menus = {}
  327. --- Tells a menu that it has the control again after a submenu closed.
  328. --
  329. -- The menu runs its `on_resume` hook (if it has one), which is the place to refresh whatever the
  330. -- submenu could have changed (stats, equipment, limit level...). Menus not migrated to
  331. -- UiContainer.Menu should call this after giving the control back.
  332. --
  333. -- @param id Id of the menu receiving the control, e.g. "main".
  334. UiContainer.resume_menu = function(id)
  335. local menu = UiContainer.menus[id]
  336. if menu ~= nil and menu.on_resume ~= nil then
  337. menu:on_resume()
  338. end
  339. end
  340. local menu_methods = {}
  341. --- Switches to another state of the menu.
  342. function menu_methods.set_state(self, state)
  343. UiContainer.current_submenu = state
  344. end
  345. --- Gets the current state name.
  346. function menu_methods.get_state(self)
  347. return UiContainer.current_submenu
  348. end
  349. --- Marks this menu as the active one and enters its initial state.
  350. function menu_methods.activate(self)
  351. UiContainer.current_menu = self.id
  352. UiContainer.current_submenu = self.initial_state or ""
  353. end
  354. --- Gives the control back to another menu.
  355. --
  356. -- @param id Id of the menu receiving the control (e.g. "main"). Defaults to `self.parent`.
  357. function menu_methods.deactivate(self, id)
  358. UiContainer.current_menu = id or self.parent or ""
  359. UiContainer.current_submenu = ""
  360. UiContainer.resume_menu(UiContainer.current_menu)
  361. end
  362. --- Is this menu the one currently handling the input?
  363. function menu_methods.is_active(self)
  364. return UiContainer.current_menu == self.id
  365. end
  366. --- Dispatches a button to the current state.
  367. function menu_methods.on_button(self, button, event, key)
  368. if UiContainer.current_menu ~= self.id then
  369. if self.on_inactive_button ~= nil then
  370. return self:on_inactive_button(button, event) or 0
  371. end
  372. return 0
  373. end
  374. local state = self.states[UiContainer.current_submenu]
  375. if state == nil or state.blocked then return 0 end
  376. -- A key asked for with input_manager:capture_next_key() was pressed. The name of the key is the
  377. -- third argument.
  378. if button == "KEY_CAPTURE" then
  379. if state.capture ~= nil then state.capture(self, key) end
  380. return 0
  381. end
  382. local pressed = (event == "Press")
  383. local cursor = nil
  384. if state.cursor ~= nil then cursor = state.cursor(self) end
  385. if button == Config.ACTIONS.CANCEL then
  386. if pressed and state.cancel ~= nil then
  387. local result = state.cancel(self)
  388. if result == false then
  389. UiContainer.play_sound("Error")
  390. elseif state.cancel_sound ~= false then
  391. UiContainer.play_sound("Back")
  392. end
  393. end
  394. elseif button == Config.ACTIONS.CONFIRM then
  395. if pressed and state.confirm ~= nil then
  396. local result = state.confirm(self, cursor)
  397. if result == false then
  398. UiContainer.play_sound("Error")
  399. elseif state.confirm_sound ~= false then
  400. UiContainer.play_sound("Cursor")
  401. end
  402. end
  403. elseif cursor ~= nil and cursor:handle(button, state.axis) then
  404. return 0
  405. elseif state.buttons ~= nil and state.buttons[button] ~= nil then
  406. state.buttons[button](self, event)
  407. end
  408. return 0
  409. end
  410. --- Creates a menu from its definition.
  411. --
  412. -- Methods not defined by the menu are filled in with the defaults above (set_state, activate,
  413. -- deactivate, is_active, on_button).
  414. UiContainer.Menu.new = function(definition)
  415. for name, method in pairs(menu_methods) do
  416. if definition[name] == nil then definition[name] = method end
  417. end
  418. if definition.on_start == nil then
  419. definition.on_start = function(self) return 0 end
  420. end
  421. if definition.states == nil then definition.states = {} end
  422. if definition.id ~= nil then UiContainer.menus[definition.id] = definition end
  423. return definition
  424. end