dfhack/plugins/lua/buildingplan.lua

634 lines
21 KiB
Lua

local _ENV = mkmodule('plugins.buildingplan')
--[[
Native functions:
* bool isPlannableBuilding(df::building_type type, int16_t subtype, int32_t custom)
* bool isPlannedBuilding(df::building *bld)
* void addPlannedBuilding(df::building *bld)
* void doCycle()
* void scheduleCycle()
--]]
local argparse = require('argparse')
local gui = require('gui')
local guidm = require('gui.dwarfmode')
local overlay = require('plugins.overlay')
local utils = require('utils')
local widgets = require('gui.widgets')
require('dfhack.buildings')
local function process_args(opts, args)
if args[1] == 'help' then
opts.help = true
return
end
return argparse.processArgsGetopt(args, {
{'h', 'help', handler=function() opts.help = true end},
})
end
function parse_commandline(...)
local args, opts = {...}, {}
local positionals = process_args(opts, args)
if opts.help then
return false
end
local command = table.remove(positionals, 1)
if not command or command == 'status' then
printStatus()
elseif command == 'set' then
setSetting(positionals[1], positionals[2] == 'true')
else
return false
end
return true
end
function get_num_filters(btype, subtype, custom)
local filters = dfhack.buildings.getFiltersByType({}, btype, subtype, custom)
return filters and #filters or 0
end
--------------------------------
-- Planner Overlay
--
local uibs = df.global.buildreq
local function cur_building_has_no_area()
if uibs.building_type == df.building_type.Construction then return false end
local filters = dfhack.buildings.getFiltersByType({},
uibs.building_type, uibs.building_subtype, uibs.custom_type)
-- this works because all variable-size buildings have either no item
-- filters or a quantity of -1 for their first (and only) item
return filters and filters[1] and (not filters[1].quantity or filters[1].quantity > 0)
end
local function is_choosing_area()
return uibs.selection_pos.x >= 0
end
local function get_cur_area_dims()
if not is_choosing_area() then return 1, 1, 1 end
return math.abs(uibs.selection_pos.x - uibs.pos.x) + 1,
math.abs(uibs.selection_pos.y - uibs.pos.y) + 1,
math.abs(uibs.selection_pos.z - uibs.pos.z) + 1
end
local function get_cur_filters()
return dfhack.buildings.getFiltersByType({}, uibs.building_type,
uibs.building_subtype, uibs.custom_type)
end
local function is_plannable()
return get_cur_filters() and
not (uibs.building_type == df.building_type.Construction
and uibs.building_subtype == df.construction_type.TrackNSEW)
end
local function is_stairs()
return uibs.building_type == df.building_type.Construction
and uibs.building_subtype == df.construction_type.UpDownStair
end
local direction_panel_frame = {t=4, h=13, w=46, r=28}
local direction_panel_types = utils.invert{
df.building_type.Bridge,
df.building_type.ScrewPump,
df.building_type.WaterWheel,
df.building_type.AxleHorizontal,
df.building_type.Rollers,
}
local function has_direction_panel()
return direction_panel_types[uibs.building_type]
or (uibs.building_type == df.building_type.Trap
and uibs.building_subtype == df.trap_type.TrackStop)
end
local pressure_plate_panel_frame = {t=4, h=37, w=46, r=28}
local function has_pressure_plate_panel()
return uibs.building_type == df.building_type.Trap
and uibs.building_subtype == df.trap_type.PressurePlate
end
local function is_over_options_panel()
local frame = nil
if has_direction_panel() then
frame = direction_panel_frame
elseif has_pressure_plate_panel() then
frame = pressure_plate_panel_frame
else
return false
end
local v = widgets.Widget{frame=frame}
local rect = gui.mkdims_wh(0, 0, dfhack.screen.getWindowSize())
v:updateLayout(gui.ViewRect{rect=rect})
return v:getMousePos()
end
local function to_title_case(str)
str = str:gsub('(%a)([%w_]*)',
function (first, rest) return first:upper()..rest:lower() end)
str = str:gsub('_', ' ')
return str
end
function get_item_line_text(idx)
local filter = get_cur_filters()[idx]
local desc = 'Unknown'
if filter.has_tool_use then
desc = to_title_case(df.tool_uses[filter.has_tool_use])
end
if filter.item_type then
desc = to_title_case(df.item_type[filter.item_type])
end
if filter.flags2 and filter.flags2.building_material then
desc = "Generic material";
if filter.flags2.fire_safe then
desc = "Fire-safe material";
end
if filter.flags2.magma_safe then
desc = "Magma-safe material";
end
elseif filter.vector_id then
desc = to_title_case(df.job_item_vector_id[filter.vector_id])
end
if desc:endswith('s') then
desc = desc:sub(1,-2)
end
if desc == 'Trappart' then
desc = 'Mechanism'
end
local quantity = filter.quantity or 1
local dimx, dimy, dimz = get_cur_area_dims()
if quantity < 1 then
quantity = (((dimx * dimy) // 4) + 1) * dimz
else
quantity = quantity * dimx * dimy * dimz
end
desc = ('%d %s%s'):format(quantity, desc, quantity == 1 and '' or 's')
local available = countAvailableItems(uibs.building_type,
uibs.building_subtype, uibs.custom_type, idx - 1)
local note = available >= quantity and
'Can build now' or 'Will wait for item'
return ('%-21s%s%s'):format(desc:sub(1,21), (' '):rep(13), note)
end
ItemLine = defclass(ItemLine, widgets.Panel)
ItemLine.ATTRS{
idx=DEFAULT_NIL,
}
function ItemLine:init()
self.frame.h = 1
self.visible = function() return #get_cur_filters() >= self.idx end
self:addviews{
widgets.Label{
frame={t=0, l=0},
text={{text=function() return get_item_line_text(self.idx) end}},
},
widgets.Label{
frame={t=0, l=22},
text='[filter][x]',
},
}
end
PlannerOverlay = defclass(PlannerOverlay, overlay.OverlayWidget)
PlannerOverlay.ATTRS{
default_pos={x=6,y=9},
default_enabled=true,
viewscreens='dwarfmode/Building/Placement',
frame={w=54, h=9},
frame_style=gui.MEDIUM_FRAME,
frame_background=gui.CLEAR_PEN,
}
function PlannerOverlay:init()
self:addviews{
widgets.Label{
frame={},
auto_width=true,
text='No items required.',
visible=function() return #get_cur_filters() == 0 end,
},
ItemLine{frame={t=0, l=0}, idx=1},
ItemLine{frame={t=2, l=0}, idx=2},
ItemLine{frame={t=4, l=0}, idx=3},
ItemLine{frame={t=6, l=0}, idx=4},
widgets.CycleHotkeyLabel{
view_id="stairs_top_subtype",
frame={t=3, l=0},
key="CUSTOM_R",
label="Top Stair Type: ",
visible=is_stairs,
options={
{label='Auto', value='auto'},
{label='UpDown', value=df.construction_type.UpDownStair},
{label='Down', value=df.construction_type.DownStair},
},
},
widgets.CycleHotkeyLabel {
view_id="stairs_bottom_subtype",
frame={t=4, l=0},
key="CUSTOM_B",
label="Bottom Stair Type: ",
visible=is_stairs,
options={
{label='Auto', value='auto'},
{label='UpDown', value=df.construction_type.UpDownStair},
{label='Up', value=df.construction_type.UpStair},
},
},
widgets.Label{
frame={b=0, l=17},
text={
'Selected area: ',
{text=function()
return ('%dx%dx%d'):format(get_cur_area_dims())
end
},
},
visible=is_choosing_area,
},
}
end
function PlannerOverlay:do_config()
dfhack.run_script('gui/buildingplan')
end
function PlannerOverlay:onInput(keys)
if not is_plannable() then return false end
if keys.LEAVESCREEN or keys._MOUSE_R_DOWN then
return false
end
if PlannerOverlay.super.onInput(self, keys) then
return true
end
if keys._MOUSE_L_DOWN then
if is_over_options_panel() then return false end
if self:getMouseFramePos() then return true end
if #uibs.errors > 0 then return true end
local pos = dfhack.gui.getMousePos()
if pos then
if is_choosing_area() or cur_building_has_no_area() then
if #get_cur_filters() == 0 then
return false -- we don't add value; let the game place it
end
self:place_building()
uibs.selection_pos:clear()
return true
elseif not is_choosing_area() then
return false
end
end
end
return keys._MOUSE_L
end
function PlannerOverlay:render(dc)
if not is_plannable() then return end
PlannerOverlay.super.render(self, dc)
end
local to_pen = dfhack.pen.parse
local GOOD_PEN = to_pen{ch='o', fg=COLOR_GREEN,
tile=dfhack.screen.findGraphicsTile('CURSORS', 1, 2)}
local BAD_PEN = to_pen{ch='X', fg=COLOR_RED,
tile=dfhack.screen.findGraphicsTile('CURSORS', 3, 0)}
function PlannerOverlay:onRenderFrame(dc, rect)
PlannerOverlay.super.onRenderFrame(self, dc, rect)
if not is_choosing_area() then return end
local bounds = {
x1 = math.min(uibs.selection_pos.x, uibs.pos.x),
x2 = math.max(uibs.selection_pos.x, uibs.pos.x),
y1 = math.min(uibs.selection_pos.y, uibs.pos.y),
y2 = math.max(uibs.selection_pos.y, uibs.pos.y),
}
local pen = #uibs.errors > 0 and BAD_PEN or GOOD_PEN
local function get_overlay_pen(pos)
return pen
end
guidm.renderMapOverlay(get_overlay_pen, bounds)
end
function PlannerOverlay:place_building()
local direction = uibs.direction
local width, height, depth = get_cur_area_dims()
local _, adjusted_width, adjusted_height = dfhack.buildings.getCorrectSize(
width, height, uibs.building_type, uibs.building_subtype,
uibs.custom_type, direction)
-- get the upper-left corner of the building/area at min z-level
local has_selection = is_choosing_area()
local start_pos = xyz2pos(
has_selection and math.min(uibs.selection_pos.x, uibs.pos.x) or uibs.pos.x - adjusted_width//2,
has_selection and math.min(uibs.selection_pos.y, uibs.pos.y) or uibs.pos.y - adjusted_height//2,
has_selection and math.min(uibs.selection_pos.z, uibs.pos.z) or uibs.pos.z
)
if uibs.building_type == df.building_type.ScrewPump then
if direction == df.screw_pump_direction.FromSouth then
start_pos.y = start_pos.y + 1
elseif direction == df.screw_pump_direction.FromEast then
start_pos.x = start_pos.x + 1
end
end
local min_x, max_x = start_pos.x, start_pos.x
local min_y, max_y = start_pos.y, start_pos.y
local min_z, max_z = start_pos.z, start_pos.z
if adjusted_width == 1 and adjusted_height == 1
and (width > 1 or height > 1 or depth > 1) then
max_x = min_x + width - 1
max_y = min_y + height - 1
max_z = math.max(uibs.selection_pos.z, uibs.pos.z)
end
local blds = {}
local subtype = uibs.building_subtype
for z=min_z,max_z do for y=min_y,max_y do for x=min_x,max_x do
local pos = xyz2pos(x, y, z)
if is_stairs() then
if z == min_z then
subtype = self.subviews.stairs_bottom_subtype:getOptionValue()
if subtype == 'auto' then
local tt = dfhack.maps.getTileType(pos)
local shape = df.tiletype.attrs[tt].shape
if shape == df.tiletype_shape.STAIR_DOWN then
subtype = uibs.building_subtype
else
subtype = df.construction_type.UpStair
end
end
elseif z == max_z then
subtype = self.subviews.stairs_top_subtype:getOptionValue()
if subtype == 'auto' then
local tt = dfhack.maps.getTileType(pos)
local shape = df.tiletype.attrs[tt].shape
if shape == df.tiletype_shape.STAIR_UP then
subtype = uibs.building_subtype
else
subtype = df.construction_type.DownStair
end
end
else
subtype = uibs.building_subtype
end
end
local bld, err = dfhack.buildings.constructBuilding{pos=pos,
type=uibs.building_type, subtype=subtype, custom=uibs.custom_type,
width=adjusted_width, height=adjusted_height, direction=direction}
if err then
for _,b in ipairs(blds) do
dfhack.buildings.deconstruct(b)
end
dfhack.printerr(err .. (' (%d, %d, %d)'):format(pos.x, pos.y, pos.z))
return
end
-- assign fields for the types that need them. we can't pass them all in
-- to the call to constructBuilding since attempting to assign unrelated
-- fields to building types that don't support them causes errors.
for k,v in pairs(bld) do
if k == 'friction' then bld.friction = uibs.friction end
if k == 'use_dump' then bld.use_dump = uibs.use_dump end
if k == 'dump_x_shift' then bld.dump_x_shift = uibs.dump_x_shift end
if k == 'dump_y_shift' then bld.dump_y_shift = uibs.dump_y_shift end
if k == 'speed' then bld.speed = uibs.speed end
end
table.insert(blds, bld)
end end end
for _,bld in ipairs(blds) do
addPlannedBuilding(bld)
end
scheduleCycle()
end
InspectorOverlay = defclass(InspectorOverlay, overlay.OverlayWidget)
InspectorOverlay.ATTRS{
default_pos={x=-41,y=14},
default_enabled=true,
viewscreens='dwarfmode/ViewSheets/BUILDING',
frame={w=30, h=9},
frame_style=gui.MEDIUM_FRAME,
frame_background=gui.CLEAR_PEN,
}
function InspectorOverlay:init()
self:addviews{
widgets.Label{
frame={t=0, l=0},
text='Waiting for items:',
},
widgets.Label{
frame={t=1, l=0},
text='item1',
},
widgets.Label{
frame={t=2, l=0},
text='item2',
},
widgets.Label{
frame={t=3, l=0},
text='item3',
},
widgets.Label{
frame={t=4, l=0},
text='item4',
},
widgets.HotkeyLabel{
frame={t=5, l=0},
label='adjust filters',
key='CUSTOM_CTRL_F',
},
widgets.HotkeyLabel{
frame={t=6, l=0},
label='make top priority',
key='CUSTOM_CTRL_T',
},
}
end
function InspectorOverlay:onInput(keys)
if not isPlannedBuilding(dfhack.gui.getSelectedBuilding()) then
return false
end
return InspectorOverlay.super.onInput(self, keys)
end
function InspectorOverlay:render(dc)
if not isPlannedBuilding(dfhack.gui.getSelectedBuilding()) then
return
end
InspectorOverlay.super.render(self, dc)
end
OVERLAY_WIDGETS = {
planner=PlannerOverlay,
inspector=InspectorOverlay,
}
local dialogs = require('gui.dialogs')
local guidm = require('gui.dwarfmode')
-- returns whether the items matched by the specified filter can have a quality
-- rating. This also conveniently indicates whether an item can be decorated.
-- does not need the core suspended
-- reverse_idx is 0-based and is expected to be counted from the *last* filter
function item_can_be_improved(btype, subtype, custom, reverse_idx)
local filter = get_filter(btype, subtype, custom, reverse_idx)
if filter.flags2 and filter.flags2.building_material then
return false;
end
return filter.item_type ~= df.item_type.WOOD and
filter.item_type ~= df.item_type.BLOCKS and
filter.item_type ~= df.item_type.BAR and
filter.item_type ~= df.item_type.BOULDER
end
--
-- GlobalSettings dialog
--
local GlobalSettings = defclass(GlobalSettings, dialogs.MessageBox)
GlobalSettings.focus_path = 'buildingplan_globalsettings'
GlobalSettings.ATTRS{
settings = {}
}
function GlobalSettings:onDismiss()
for k,v in pairs(self.settings) do
-- call back into C++ to save changes
setSetting(k, v)
end
end
-- does not need the core suspended.
function show_global_settings_dialog(settings)
GlobalSettings{
frame_title="Buildingplan Global Settings",
settings=settings,
}:show()
end
function GlobalSettings:toggle_setting(name)
self.settings[name] = not self.settings[name]
end
function GlobalSettings:get_setting_string(name)
if self.settings[name] then return 'On' end
return 'Off'
end
function GlobalSettings:get_setting_pen(name)
if self.settings[name] then return COLOR_LIGHTGREEN end
return COLOR_LIGHTRED
end
function GlobalSettings:is_setting_enabled(name)
return self.settings[name]
end
function GlobalSettings:make_setting_label_token(text, key, name, width)
return {text=text, key=key, key_sep=': ', key_pen=COLOR_LIGHTGREEN,
on_activate=self:callback('toggle_setting', name), width=width}
end
function GlobalSettings:make_setting_value_token(name)
return {text=self:callback('get_setting_string', name),
enabled=self:callback('is_setting_enabled', name),
pen=self:callback('get_setting_pen', name),
dpen=COLOR_GRAY}
end
-- mockup:
--[[
Buildingplan Global Settings
e: Enable all: Off
Enables buildingplan for all building types. Use this to avoid having to
manually enable buildingplan for each building type that you want to plan.
Note that DFHack quickfort will use buildingplan to manage buildings
regardless of whether buildingplan is "enabled" for the building type.
Allowed types for generic, fire-safe, and magma-safe building material:
b: Blocks: On
s: Boulders: On
w: Wood: On
r: Bars: Off
Changes to these settings will be applied to newly-planned buildings.
A: Apply building material filter settings to existing planned buildings
Use this if your planned buildings can't be completed because the settings
above were too restrictive when the buildings were originally planned.
M: Edit list of materials to avoid
potash
pearlash
ash
coal
Buildingplan will avoid using these material types when a planned building's
material filter is set to 'any'. They can stil be matched when they are
explicitly allowed by a planned building's material filter. Changes to this
list take effect for existing buildings immediately.
g: Allow bags: Off
This allows bags to be placed where a 'coffer' is planned.
f: Legacy Quickfort Mode: Off
Compatibility mode for the legacy Python-based Quickfort application. This
setting is not needed for DFHack quickfort.
--]]
function GlobalSettings:init()
self.subviews.label:setText{
self:make_setting_label_token('Enable all', 'CUSTOM_E', 'all_enabled', 12),
self:make_setting_value_token('all_enabled'), '\n',
' Enables buildingplan for all building types. Use this to avoid having\n',
' to manually enable buildingplan for each building type that you want\n',
' to plan. Note that DFHack quickfort will use buildingplan to manage\n',
' buildings regardless of whether buildingplan is "enabled" for the\n',
' building type.\n',
'\n',
'Allowed types for generic, fire-safe, and magma-safe building material:\n',
self:make_setting_label_token('Blocks', 'CUSTOM_B', 'blocks', 10),
self:make_setting_value_token('blocks'), '\n',
self:make_setting_label_token('Boulders', 'CUSTOM_S', 'boulders', 10),
self:make_setting_value_token('boulders'), '\n',
self:make_setting_label_token('Wood', 'CUSTOM_W', 'logs', 10),
self:make_setting_value_token('logs'), '\n',
self:make_setting_label_token('Bars', 'CUSTOM_R', 'bars', 10),
self:make_setting_value_token('bars'), '\n',
' Changes to these settings will be applied to newly-planned buildings.\n',
' If no types are enabled above, then any building material is allowed.\n',
'\n',
self:make_setting_label_token('Legacy Quickfort Mode', 'CUSTOM_F',
'quickfort_mode', 23),
self:make_setting_value_token('quickfort_mode'), '\n',
' Compatibility mode for the legacy Python-based Quickfort application.\n',
' This setting is not needed for DFHack quickfort.'
}
end
return _ENV