1
0
mirror of https://github.com/Refactorio/RedMew.git synced 2024-12-04 09:42:30 +02:00
RedMew/features/retailer.lua
RedRafe 57813c4455
Update CS features (#1449)
* Fix spy command

* Fix markets being always accessible from map view
2024-11-22 20:54:42 +00:00

766 lines
24 KiB
Lua

--[[
The Retailer provides a market replacement GUI with additional
functionality. To register a market, you need the market entity and a name
for the market group:
Retailer.add_market(group_name, market_entity)
If you don't have a group name or don't want to re-use it, you can generate
a group name with:
local group_name = Retailer.generate_group_id()
To change the description displayed in the market GUI, you can call:
Retailer.set_market_group_label(group_name, 'Name of your market')
To add, remove, disable and enable items, you can call their respective
functions. Note that each item can be added only once and will replace the
previous if set again. Calling any of those functions will trigger a GUI
redraw for all markets in that group in the next tick.
When an item is bought from the market, it will raise an event you can
listen to: Retailer.events.on_market_purchase. Items can be registered
with different types. By default the type is 'item', which will insert the
item(s) bought into the player's inventory. Anything else will merely
remove the coins and trigger the event. You can listen to this event and do
whatever custom handling you want.
Items support the following structure:
{
name: the (raw) item inserted in inventory, does nothing when type is not item
name_label: the name shown in the GUI. If omitted and a prototype exists for 'name', it will use that LocalisedString, can be a LocalisedString
sprite: a custom sprite, will use 'item/<name>' if omitted
price: the price of an item, supports floats (0.95 for example)
description: an additional description displayed in the tooltip, can be a LocalisedString
disabled: whether or not the item should be disabled by default
disabled_reason: the reason the item is disabled, can be a LocalisedString
}
]]
require 'utils.table'
local Global = require 'utils.global'
local Gui = require 'utils.gui'
local Event = require 'utils.event'
local Token = require 'utils.token'
local Schedule = require 'utils.task'
local math = require 'utils.math'
local Color = require 'resources.color_presets'
local ScoreTracker = require 'utils.score_tracker'
local LocaleBuilder = require 'utils.locale_builder'
local format_number = require 'util'.format_number
local format = string.format
local size = table.size
local pairs = pairs
local tonumber = tonumber
local type = type
local change_for_global = ScoreTracker.change_for_global
local change_for_player = ScoreTracker.change_for_player
local coins_spent_name = 'coins-spent'
local currency_name = 'coin'
local currency_item_name = {'item-name.coin'}
local set_timeout_in_ticks = Schedule.set_timeout_in_ticks
local clamp = math.clamp
local floor = math.floor
local ceil = math.ceil
local raise_event = script.raise_event
local market_frame_name = Gui.uid_name()
local market_frame_close_button_name = Gui.uid_name()
local item_button_name = Gui.uid_name()
local count_slider_name = Gui.uid_name()
local count_text_name = Gui.uid_name()
local price_format = '%.2f'
local translate_single_newline = {'', '\n'}
local translate_double_newline = {'', '\n\n'}
local Retailer = {}
Retailer.events = {
--- Triggered when a purchase is made
-- Event {
-- item = item,
-- count = count,
-- player = player,
-- group_name = group_name,
-- }
on_market_purchase = Event.generate_event_name('on_market_purchase'),
}
Retailer.item_types = {
--- expects an array of item prototypes that can be inserted directly via
--- player.insert() called 'items' in the item prototype.
item_package = 'item_package',
}
local do_update_market_gui -- token
---Global storage
---Markets are indexed by the position "x,y" and contains the group it belongs to
---Items are indexed by the group name and is a list indexed by the item name and contains the prices per item
---players_in_market_view is a list of {position, group_name} data
local memory = {
id = 0,
markets = {},
items = {},
group_label = {},
players_in_market_view = {},
market_gui_refresh_scheduled = {},
limited_items = {},
}
Global.register(memory, function (tbl)
memory = tbl
end)
local function schedule_market_gui_refresh(group_name)
if memory.market_gui_refresh_scheduled[group_name] then
-- already scheduled
return
end
set_timeout_in_ticks(1, do_update_market_gui, {group_name = group_name})
memory.market_gui_refresh_scheduled[group_name] = true
end
---Generates a unique identifier for a market group name, as alternative for a custom name.
function Retailer.generate_group_id()
local id = memory.id + 1
memory.id = id
return 'market-' .. id
end
---Sets the name of the market group, provides a user friendly label in the GUI.
---@param group_name string
---@param label string
function Retailer.set_market_group_label(group_name, label)
memory.group_label[group_name] = label
schedule_market_gui_refresh(group_name)
end
---Gets the name of the market group.
---@param group_name string
function Retailer.get_market_group_label(group_name)
return memory.group_label[group_name] or 'Market'
end
---Returns all item for the group_name retailer.
---@param market_group string
function Retailer.get_items(market_group)
return memory.items[market_group] or {}
end
---Removes an item from the markets for the group_name retailer.
---@param group_name string
---@param item_name string
function Retailer.remove_item(group_name, item_name)
if not memory.items[group_name] then
return
end
memory.items[group_name][item_name] = nil
schedule_market_gui_refresh(group_name)
end
---Returns the remaining market group item limit or -1 if there is none for a given player.
---@param market_group string
---@param item_name string
---@param player_index number
function Retailer.get_player_item_limit(market_group, item_name, player_index)
local item = Retailer.get_items(market_group)[item_name]
if not item then
Debug.print({message = 'Item not registered in the Retailer', data = {
market_group = market_group,
item_name = item_name,
}})
return -1
end
return memory.limited_items[market_group][item_name][player_index] or item.player_limit
end
---Returns the configured market group item limit or -1 if there is none.
---@param market_group string
---@param item_name string
function Retailer.get_item_limit(market_group, item_name)
local item = Retailer.get_items(market_group)[item_name]
if not item then
Debug.print({message = 'Item not registered in the Retailer', data = {
market_group = market_group,
item_name = item_name,
}})
return -1
end
return item.player_limit
end
---sets the configured market group item limit for a given player
---@param market_group string
---@param item_name string
---@param player_index number
---@param new_limit number
function Retailer.set_player_item_limit(market_group, item_name, player_index, new_limit)
if new_limit < 0 then
Debug.print({message = 'Cannot set a negative item limit', data = {
market_group = market_group,
item_name = item_name,
new_limit = new_limit,
}})
return
end
local item = Retailer.get_items(market_group)[item_name]
if not item then
Debug.print({message = 'Item not registered in the Retailer', data = {
market_group = market_group,
item_name = item_name,
}})
return -1
end
if new_limit > item.player_limit then
Debug.print({message = 'Cannot set an item limit higher than the item prototype defined', data = {
market_group = market_group,
item_name = item_name,
new_limit = new_limit,
}})
new_limit = item.player_limit
end
memory.limited_items[market_group][item_name][player_index] = new_limit
end
local function redraw_market_items(data)
local grid = data.grid
Gui.clear(grid)
local count = data.count
local market_items = data.market_items
local player_index = data.player_index
local player_coins = game.get_player(player_index).get_item_count(currency_name)
if size(market_items) == 0 then
grid.add({type = 'label', caption = {'retailer.no_items_in_market'}})
return
end
local limited_items = memory.limited_items[data.market_group]
for i, item in pairs(market_items) do
local has_stack_limit = item.stack_limit ~= -1
local stack_limit = has_stack_limit and item.stack_limit or count
local stack_count = has_stack_limit and stack_limit < count and item.stack_limit or count
local player_limit = item.player_limit
local has_player_limit = player_limit ~= -1
if has_player_limit then
local item_name = item.name
player_limit = limited_items[item_name][player_index]
if player_limit == nil then
-- no limit set yet
player_limit = item.player_limit
limited_items[item_name][player_index] = item.player_limit
end
if player_limit < stack_count then
-- ensure the stack count is never higher than the item limit for the player
stack_count = player_limit
end
end
local player_bought_max_total = has_player_limit and stack_count == 0
local price = item.price
local tooltip = LocaleBuilder({'', item.name_label})
local description = item.description
local total_price = ceil(price * stack_count)
local disabled = item.disabled == true
local message
if total_price == 0 and player_limit == 0 then
message = {'retailer.item_sold_out'}
elseif total_price == 0 then
message = {'retailer.item_is_free'}
else
message = {'', '[img=item.', currency_name, '] ', format_number(total_price, true)}
end
local missing_coins = total_price - player_coins
local is_missing_coins = missing_coins > 0
if price ~= 0 then
tooltip = tooltip
:add(translate_single_newline)
:add({'', currency_item_name, ': ', format(price_format, price)})
end
if description then
tooltip = tooltip:add(translate_single_newline)
if type(description) == 'table' then
tooltip = tooltip:add(description)
else
tooltip = tooltip:add({'', description})
end
end
if disabled then
tooltip = tooltip
:add(translate_double_newline)
:add(item.disabled_reason or {'retailer.generic_item_disabled_message'})
elseif is_missing_coins then
tooltip = tooltip
:add(translate_double_newline)
:add({'retailer.not_enough_currency', missing_coins, currency_item_name, stack_count})
end
if has_player_limit then
local item_player_limit = item.player_limit
tooltip = tooltip
:add(translate_double_newline)
:add({'retailer.item_with_player_limit_description', item_player_limit - player_limit, item_player_limit})
end
local button = grid.add({type = 'flow'}).add({
type = 'sprite-button',
name = item_button_name,
sprite = item.sprite,
number = stack_count,
tooltip = tooltip,
})
button.style = 'slot_button'
Gui.set_data(button, {index = i, data = data, stack_count = stack_count})
local label = grid.add({type = 'label', caption = message})
local label_style = label.style
label_style.width = 93
label_style.height = 32
label_style.font = 'default-bold'
label_style.vertical_align = 'center'
if disabled or player_bought_max_total then
label_style.font_color = Color.dark_grey
button.enabled = false
elseif is_missing_coins then
label_style.font_color = Color.red
button.enabled = false
end
end
end
local function do_coin_label(coin_count, label)
label.caption = {'', coin_count, ' ', currency_item_name, ' ', {'common.available'}}
label.style.font = 'default-bold'
end
local function draw_market_frame(player, group_name)
local frame = player.gui.center.add({
type = 'frame',
name = market_frame_name,
caption = Retailer.get_market_group_label(group_name),
direction = 'vertical',
})
local scroll_pane = frame.add({type = 'scroll-pane'})
local scroll_style = scroll_pane.style
scroll_style.maximal_height = 600
local grid = scroll_pane.add({type = 'table', column_count = 10})
local market_items = Retailer.get_items(group_name)
local player_coins = player.get_item_count(currency_name)
local data = {
grid = grid,
count = 1,
market_items = market_items,
market_group = group_name,
player_index = player.index,
}
local coin_label = frame.add({type = 'label'})
do_coin_label(player_coins, coin_label)
data.coin_label = coin_label
redraw_market_items(data)
local bottom_grid = frame.add({type = 'table', column_count = 2})
bottom_grid.add({type = 'label', caption = {'', {'common.quantity'}, ': '}}).style.font = 'default-bold'
local count_text = bottom_grid.add({
type = 'text-box',
name = count_text_name,
text = '1',
})
local count_slider = frame.add({
type = 'slider',
name = count_slider_name,
minimum_value = 1,
maximum_value = 7,
value = 1,
})
frame.add({name = market_frame_close_button_name, type = 'button', caption = 'Close'})
count_slider.style.width = 115
count_text.style.width = 45
data.slider = count_slider
data.text = count_text
Gui.set_data(count_slider, data)
Gui.set_data(count_text, data)
Gui.set_data(frame, data)
return frame
end
---Returns the group name of the market at the given position, nil if not registered.
---@param position <table> Position
local function get_market_group_name(position)
return memory.markets[(position.x or position[1]) .. ',' .. (position.y or position[2])]
end
---Sets the group name for a market at a given position.
---@param position <table> Position
---@param group_name <string>
local function set_market_group_name(position, group_name)
memory.markets[(position.x or position[1]) .. ',' .. (position.y or position[2])] = group_name
end
local function close_market_gui(player)
local element = player.gui.center
memory.players_in_market_view[player.index] = nil
if element and element.valid then
element = element[market_frame_name]
if element and element.valid then
Gui.destroy(element)
end
end
player.opened = nil
end
local function check_player_in_range(player, market)
if not(player and player.valid and player.character and player.character.valid) then
return
end
local player_position = player.physical_position
local market_position = market.position
local delta_x = player_position.x - market_position.x
local delta_y = player_position.y - market_position.y
local reach_distance = player.character.reach_distance * 1.05
if delta_x * delta_x + delta_y * delta_y > reach_distance * reach_distance then
close_market_gui(player)
return false
end
return true
end
Event.add(defines.events.on_gui_opened, function (event)
if not event.gui_type == defines.gui_type.entity then
return
end
local entity = event.entity
if not entity or not entity.valid then
return
end
local position = entity.position
local group_name = get_market_group_name(position)
if not group_name then
return
end
local player = game.get_player(event.player_index)
if not player or not player.valid then
return
end
if not check_player_in_range(player, entity) then
return
end
memory.players_in_market_view[player.index] = {
position = position,
group_name = group_name,
}
local frame = draw_market_frame(player, group_name)
player.opened = frame
end)
Gui.on_custom_close(market_frame_name, function (event)
local element = event.element
memory.players_in_market_view[event.player.index] = nil
Gui.destroy(element)
end)
Gui.on_click(market_frame_close_button_name, function (event)
close_market_gui(event.player)
end)
Event.add(defines.events.on_player_died, function (event)
local player = game.get_player(event.player_index or 0)
if not player or not player.valid then
return
end
close_market_gui(player)
end)
Gui.on_value_changed(count_slider_name, function (event)
local element = event.element
local data = Gui.get_data(element)
local value = floor(element.slider_value)
local count
if value % 2 == 0 then
count = 10 ^ (value * 0.5) * 0.5
else
count = 10 ^ ((value - 1) * 0.5)
end
data.count = count
data.text.text = tostring(count)
redraw_market_items(data)
end)
Gui.on_text_changed(count_text_name, function (event)
local element = event.element
local data = Gui.get_data(element)
local count = tonumber(element.text)
if count then
count = floor(count)
count = clamp(count, 1, 1000)
data.count = count
data.text.text = tostring(count)
else
data.count = 1
end
redraw_market_items(data)
end)
Gui.on_click(item_button_name, function (event)
local player = event.player
local element = event.element
local button_data = Gui.get_data(element)
local data = button_data.data
local stack_count = button_data.stack_count
local item = data.market_items[button_data.index]
if not item then
player.print({'retailer.item_no_longer_available'})
return
end
if item.disabled then
player.print({'retailer.item_disabled_reason', item.name_label, {item.disabled_reason or ''}})
return
end
local name = item.name
local price = item.price
local cost = ceil(price * stack_count)
local coin_count = player.get_item_count(currency_name)
if cost > coin_count then
player.print({'retailer.not_enough_currency', cost - coin_count, currency_item_name, cost})
return
end
local market_group = data.market_group
if item.player_limit ~= -1 then
local limited_item = memory.limited_items[market_group][name]
limited_item[player.index] = limited_item[player.index] - stack_count
end
if item.type == 'item' then
local inserted = player.insert({name = name, count = stack_count})
if inserted < stack_count then
player.print({'retailer.no_inventory_space'})
if inserted > 0 then
player.remove_item({name = name, count = inserted})
end
return
end
end
if cost > 0 then
player.remove_item({name = currency_name, count = cost})
end
redraw_market_items(data)
change_for_player(player.index, coins_spent_name, cost)
change_for_global(coins_spent_name, cost)
do_coin_label(coin_count - cost, data.coin_label)
raise_event(Retailer.events.on_market_purchase, {
item = item,
count = stack_count,
player = player,
group_name = market_group,
})
end)
---Add a market to the group_name retailer.
---@param group_name string
---@param market_entity LuaEntity
function Retailer.add_market(group_name, market_entity)
set_market_group_name(market_entity.position, group_name)
end
---Returns the group name of the market, nil if not registered.
---@param market_entity LuaEntity
function Retailer.get_market_group_name(market_entity)
return get_market_group_name(market_entity.position)
end
---Sets an item for all the group_name markets.
---@param group_name string
---@param prototype table with item name and price
function Retailer.set_item(group_name, prototype)
if not memory.items[group_name] then
memory.items[group_name] = {}
end
if not memory.limited_items[group_name] then
memory.limited_items[group_name] = {}
end
local item_name = prototype.name
local name_label = prototype.name_label
if not name_label then
local item_prototype = prototypes.item[item_name]
name_label = item_prototype and item_prototype.localised_name
end
prototype.name_label = name_label or item_name
prototype.sprite = prototype.sprite or 'item/' .. item_name
prototype.type = prototype.type or 'item'
if not prototype.stack_limit then
prototype.stack_limit = -1
end
if not prototype.player_limit then
prototype.player_limit = -1
end
memory.items[group_name][item_name] = prototype
memory.limited_items[group_name][item_name] = {}
schedule_market_gui_refresh(group_name)
end
---Enables a market item by group name and item name if it's registered.
---@param group_name string
---@param item_name string
function Retailer.enable_item(group_name, item_name)
if not memory.items[group_name] then
return
end
local prototype = memory.items[group_name][item_name]
if not prototype then
return
end
prototype.disabled = false
prototype.disabled_reason = false
schedule_market_gui_refresh(group_name)
end
---Disables a market item by group name and item name if it's registered.
---@param group_name string
---@param item_name string
---@param disabled_reason string
function Retailer.disable_item(group_name, item_name, disabled_reason)
if not memory.items[group_name] then
return
end
local prototype = memory.items[group_name][item_name]
if not prototype then
return
end
prototype.disabled = true
prototype.disabled_reason = disabled_reason
schedule_market_gui_refresh(group_name)
end
do_update_market_gui = Token.register(function(params)
local group_name = params.group_name
for player_index, view_data in pairs(memory.players_in_market_view) do
if group_name == view_data.group_name then
local player = game.get_player(player_index)
if player and player.valid then
local frame = player.gui.center[market_frame_name]
if not frame or not frame.valid then
-- player already closed the market GUI and somehow this was not reported
memory.players_in_market_view[player_index] = nil
else
redraw_market_items(Gui.get_data(frame))
end
else
-- player is no longer in the game, remove it from the market view
memory.players_in_market_view[player_index] = nil
end
end
end
-- mark it as updated
memory.market_gui_refresh_scheduled[group_name] = nil
end)
Event.on_nth_tick(37, function()
for player_index, view_data in pairs(memory.players_in_market_view) do
local player = game.get_player(player_index)
if player and player.valid then
if not check_player_in_range(player, view_data) then
return
end
else
-- player is no longer in the game, remove it from the market view
memory.players_in_market_view[player_index] = nil
end
end
end)
Event.add(Retailer.events.on_market_purchase, function (event)
local package = event.item
if package.type ~= Retailer.item_types.item_package then
return
end
local player_insert = event.player.insert
for _, item in pairs(package.items) do
item.count = item.count * event.count
player_insert(item)
end
end)
return Retailer