-- Front-end for the siege engine plugin. --[[=begin gui/siege-engine ================ An in-game interface for `siege-engine`. Bind it to a key (the example config uses :kbd:`Alt`:kbd:`a`) and activate after selecting a siege engine in :kbd:`q` mode. .. image:: /docs/images/siege-engine.png The main mode displays the current target, selected ammo item type, linked stockpiles and the allowed operator skill range. The map tile color is changed to signify if it can be hit by the selected engine: green for fully reachable, blue for out of range, red for blocked, yellow for partially blocked. Pressing :kbd:`r` changes into the target selection mode, which works by highlighting two points with :kbd:`Enter` like all designations. When a target area is set, the engine projectiles are aimed at that area, or units within it (this doesn't actually change the original aiming code, instead the projectile trajectory parameters are rewritten as soon as it appears). After setting the target in this way for one engine, you can 'paste' the same area into others just by pressing :kbd:`p` in the main page of this script. The area to paste is kept until you quit DF, or select another area manually. Pressing :kbd:`t` switches to a mode for selecting a stockpile to take ammo from. Exiting from the siege engine script via :kbd:`Esc` reverts the view to the state prior to starting the script. :kbd:`Shift`:kbd:`Esc` retains the current viewport, and also exits from the :kbd:`q` mode to main menu. =end]] local utils = require 'utils' local gui = require 'gui' local guidm = require 'gui.dwarfmode' local dlg = require 'gui.dialogs' local plugin = require 'plugins.siege-engine' local wmap = df.global.world.map local LEGENDARY = df.skill_rating.Legendary -- Globals kept between script calls last_target_min = last_target_min or nil last_target_max = last_target_max or nil local item_choices = { { caption = 'boulders (default)', item_type = df.item_type.BOULDER }, { caption = 'blocks', item_type = df.item_type.BLOCKS }, { caption = 'weapons', item_type = df.item_type.WEAPON }, { caption = 'trap components', item_type = df.item_type.TRAPCOMP }, { caption = 'bins', item_type = df.item_type.BIN }, { caption = 'barrels', item_type = df.item_type.BARREL }, { caption = 'cages', item_type = df.item_type.CAGE }, { caption = 'anything', item_type = -1 }, } local item_choice_idx = {} for i,v in ipairs(item_choices) do item_choice_idx[v.item_type] = i end SiegeEngine = defclass(SiegeEngine, guidm.MenuOverlay) SiegeEngine.focus_path = 'siege-engine' SiegeEngine.ATTRS{ building = DEFAULT_NIL } function SiegeEngine:init() self:assign{ center = utils.getBuildingCenter(self.building), selected_pile = 1, mode_main = { render = self:callback 'onRenderBody_main', input = self:callback 'onInput_main', }, mode_aim = { render = self:callback 'onRenderBody_aim', input = self:callback 'onInput_aim', }, mode_pile = { render = self:callback 'onRenderBody_pile', input = self:callback 'onInput_pile', } } end function SiegeEngine:onShow() SiegeEngine.super.onShow(self) self.old_cursor = guidm.getCursorPos() self.old_viewport = self:getViewport() self.mode = self.mode_main self:showCursor(false) end function SiegeEngine:onDestroy() if self.save_profile then plugin.saveWorkshopProfile(self.building) end if not self.no_select_building then self:selectBuilding(self.building, self.old_cursor, self.old_viewport, 10) end end function SiegeEngine:onGetSelectedBuilding() return df.global.world.selected_building end function SiegeEngine:showCursor(enable) local cursor = guidm.getCursorPos() if cursor and not enable then self.cursor = cursor self.target_select_first = nil guidm.clearCursorPos() elseif not cursor and enable then local view = self:getViewport() cursor = self.cursor if not cursor or not view:isVisible(cursor) then cursor = view:getCenter() end self.cursor = nil guidm.setCursorPos(cursor) end end function SiegeEngine:centerViewOn(pos) local cursor = guidm.getCursorPos() if cursor then guidm.setCursorPos(pos) else self.cursor = pos end self:getViewport():centerOn(pos):set() end function SiegeEngine:zoomToTarget() local target_min, target_max = plugin.getTargetArea(self.building) if target_min then local cx = math.floor((target_min.x + target_max.x)/2) local cy = math.floor((target_min.y + target_max.y)/2) local cz = math.floor((target_min.z + target_max.z)/2) local pos = plugin.adjustToTarget(self.building, xyz2pos(cx,cy,cz)) self:centerViewOn(pos) end end function paint_target_grid(dc, view, origin, p1, p2) local r1, sz, r2 = guidm.getSelectionRange(p1, p2) if view.z < r1.z or view.z > r2.z then return end local p1 = view:tileToScreen(r1) local p2 = view:tileToScreen(r2) local org = view:tileToScreen(origin) dc:pen{ fg = COLOR_CYAN, bg = COLOR_CYAN, ch = '+', bold = true } -- Frame dc:fill(p1.x,p1.y,p1.x,p2.y) dc:fill(p1.x,p1.y,p2.x,p1.y) dc:fill(p2.x,p1.y,p2.x,p2.y) dc:fill(p1.x,p2.y,p2.x,p2.y) -- Grid local gxmin = org.x+10*math.ceil((p1.x-org.x)/10) local gxmax = org.x+10*math.floor((p2.x-org.x)/10) local gymin = org.y+10*math.ceil((p1.y-org.y)/10) local gymax = org.y+10*math.floor((p2.y-org.y)/10) for x = gxmin,gxmax,10 do for y = gymin,gymax,10 do dc:fill(p1.x,y,p2.x,y) dc:fill(x,p1.y,x,p2.y) end end end function SiegeEngine:renderTargetView(target_min, target_max) local view = self:getViewport() local map = self.df_layout.map local map_dc = gui.Painter.new(map) plugin.paintAimScreen( self.building, view:getPos(), xy2pos(map.x1, map.y1), view:getSize() ) if target_min and math.floor(dfhack.getTickCount()/500) % 2 == 0 then paint_target_grid(map_dc, view, self.center, target_min, target_max) end local cursor = guidm.getCursorPos() if cursor then local cx, cy, cz = pos2xyz(view:tileToScreen(cursor)) if cz == 0 then map_dc:seek(cx,cy):char('X', COLOR_YELLOW) end end end function SiegeEngine:scrollPiles(delta) local links = plugin.getStockpileLinks(self.building) if links then self.selected_pile = 1+(self.selected_pile+delta-1) % #links return links[self.selected_pile] end end function SiegeEngine:renderStockpiles(dc, links, nlines) local idx = (self.selected_pile-1) % #links local page = math.floor(idx/nlines) for i = page*nlines,math.min(#links,(page+1)*nlines)-1 do local color = COLOR_BROWN if i == idx then color = COLOR_YELLOW end dc:newline(2):string(utils.getBuildingName(links[i+1]), color) end end function SiegeEngine:onRenderBody_main(dc) dc:newline(1):pen(COLOR_WHITE):string("Target: ") local target_min, target_max = plugin.getTargetArea(self.building) if target_min then dc:string( (target_max.x-target_min.x+1).."x".. (target_max.y-target_min.y+1).."x".. (target_max.z-target_min.z+1).." Rect" ) else dc:string("None (default)") end dc:newline(3):key('CUSTOM_R'):string(": Rectangle") if last_target_min then dc:string(", "):key('CUSTOM_P'):string(": Paste") end dc:newline(3) if target_min then dc:key('CUSTOM_X'):string(": Clear, ") dc:key('CUSTOM_Z'):string(": Zoom") end dc:newline():newline(1) if self.building.type == df.siegeengine_type.Ballista then dc:string("Uses ballista arrows") else local item = plugin.getAmmoItem(self.building) dc:key('CUSTOM_U'):string(": Use ") if item_choice_idx[item] then dc:string(item_choices[item_choice_idx[item]].caption) else dc:string(df.item_type[item]) end end dc:newline():newline(1) dc:key('CUSTOM_T'):string(": Take from stockpile"):newline(3) local links = plugin.getStockpileLinks(self.building) local bottom = dc.height - 5 if links then dc:key('CUSTOM_D'):string(": Delete, ") dc:key('CUSTOM_O'):string(": Zoom"):newline() self:renderStockpiles(dc, links, bottom-2-dc:cursorY()) dc:newline():newline() end local prof = self.building:getWorkshopProfile() or {} dc:seek(1,math.max(dc:cursorY(),19)) dc:key('CUSTOM_G'):key('CUSTOM_H'):key('CUSTOM_J'):key('CUSTOM_K') dc:string(': ') dc:string(df.skill_rating.attrs[prof.min_level or 0].caption):string('-') dc:string(df.skill_rating.attrs[math.min(LEGENDARY,prof.max_level or 3000)].caption) dc:newline():newline() if self.target_select_first then self:renderTargetView(self.target_select_first, guidm.getCursorPos()) else self:renderTargetView(target_min, target_max) end end function SiegeEngine:setTargetArea(p1, p2) self.target_select_first = nil if not plugin.setTargetArea(self.building, p1, p2) then dlg.showMessage( 'Set Target Area', 'Could not set the target area', COLOR_LIGHTRED ) else last_target_min = p1 last_target_max = p2 end end function SiegeEngine:setAmmoItem(choice) if self.building.type == df.siegeengine_type.Ballista then return end if not plugin.setAmmoItem(self.building, choice.item_type) then dlg.showMessage( 'Set Ammo Item', 'Could not set the ammo item', COLOR_LIGHTRED ) end end function SiegeEngine:onInput_main(keys) if keys.CUSTOM_R then self:showCursor(true) self.target_select_first = nil self.mode = self.mode_aim elseif keys.CUSTOM_P and last_target_min then self:setTargetArea(last_target_min, last_target_max) elseif keys.CUSTOM_U then local item = plugin.getAmmoItem(self.building) local idx = 1 + (item_choice_idx[item] or 0) % #item_choices self:setAmmoItem(item_choices[idx]) elseif keys.CUSTOM_Z then self:zoomToTarget() elseif keys.CUSTOM_X then plugin.clearTargetArea(self.building) elseif keys.SECONDSCROLL_UP then self:scrollPiles(-1) elseif keys.SECONDSCROLL_DOWN then self:scrollPiles(1) elseif keys.CUSTOM_D then local pile = self:scrollPiles(0) if pile then plugin.removeStockpileLink(self.building, pile) end elseif keys.CUSTOM_O then local pile = self:scrollPiles(0) if pile then self:centerViewOn(utils.getBuildingCenter(pile)) end elseif keys.CUSTOM_T then self:showCursor(true) self.mode = self.mode_pile self:sendInputToParent('CURSOR_DOWN_Z') self:sendInputToParent('CURSOR_UP_Z') elseif keys.CUSTOM_G then local prof = plugin.saveWorkshopProfile(self.building) prof.min_level = math.max(0, prof.min_level-1) plugin.saveWorkshopProfile(self.building) elseif keys.CUSTOM_H then local prof = plugin.saveWorkshopProfile(self.building) prof.min_level = math.min(LEGENDARY, prof.min_level+1) plugin.saveWorkshopProfile(self.building) elseif keys.CUSTOM_J then local prof = plugin.saveWorkshopProfile(self.building) prof.max_level = math.max(0, math.min(LEGENDARY,prof.max_level)-1) plugin.saveWorkshopProfile(self.building) elseif keys.CUSTOM_K then local prof = plugin.saveWorkshopProfile(self.building) prof.max_level = math.min(LEGENDARY, prof.max_level+1) if prof.max_level >= LEGENDARY then prof.max_level = 3000 end plugin.saveWorkshopProfile(self.building) elseif self:simulateViewScroll(keys) then self.cursor = nil else return false end return true end local status_table = { ok = { pen = COLOR_GREEN, msg = "Target accessible" }, out_of_range = { pen = COLOR_CYAN, msg = "Target out of range" }, blocked = { pen = COLOR_RED, msg = "Target obstructed" }, semi_blocked = { pen = COLOR_BROWN, msg = "Partially obstructed" }, } function SiegeEngine:onRenderBody_aim(dc) local cursor = guidm.getCursorPos() local first = self.target_select_first dc:newline(1):string('Select target rectangle'):newline() local info = status_table[plugin.getTileStatus(self.building, cursor)] if info then dc:newline(2):string(info.msg, info.pen) else dc:newline(2):string('ERROR', COLOR_RED) end dc:newline():newline(1):key('SELECT') if first then dc:string(": Finish rectangle") else dc:string(": Start rectangle") end dc:newline() local target_min, target_max = plugin.getTargetArea(self.building) if target_min then dc:newline(1):key('CUSTOM_Z'):string(": Zoom to current target") end if first then self:renderTargetView(first, cursor) else local target_min, target_max = plugin.getTargetArea(self.building) self:renderTargetView(target_min, target_max) end end function SiegeEngine:onInput_aim(keys) if keys.SELECT then local cursor = guidm.getCursorPos() if self.target_select_first then self:setTargetArea(self.target_select_first, cursor) self.mode = self.mode_main self:showCursor(false) else self.target_select_first = cursor end elseif keys.CUSTOM_Z then self:zoomToTarget() elseif keys.LEAVESCREEN then self.mode = self.mode_main self:showCursor(false) elseif self:simulateCursorMovement(keys) then self.cursor = nil else return false end return true end function SiegeEngine:onRenderBody_pile(dc) dc:newline(1):string('Select pile to take from'):newline():newline(2) local sel = df.global.world.selected_building if df.building_stockpilest:is_instance(sel) then dc:string(utils.getBuildingName(sel), COLOR_GREEN):newline():newline(1) if plugin.isLinkedToPile(self.building, sel) then dc:string("Already taking from here"):newline():newline(2) dc:key('CUSTOM_D'):string(": Delete link") else dc:key('SELECT'):string(": Take from this pile") end elseif sel then dc:string(utils.getBuildingName(sel), COLOR_DARKGREY) dc:newline():newline(1) dc:string("Not a stockpile",COLOR_LIGHTRED) else dc:string("No building selected", COLOR_DARKGREY) end end function SiegeEngine:onInput_pile(keys) if keys.SELECT then local sel = df.global.world.selected_building if df.building_stockpilest:is_instance(sel) and not plugin.isLinkedToPile(self.building, sel) then plugin.addStockpileLink(self.building, sel) df.global.world.selected_building = self.building self.mode = self.mode_main self:showCursor(false) end elseif keys.CUSTOM_D then local sel = df.global.world.selected_building if df.building_stockpilest:is_instance(sel) then plugin.removeStockpileLink(self.building, sel) end elseif keys.LEAVESCREEN then df.global.world.selected_building = self.building self.mode = self.mode_main self:showCursor(false) elseif self:propagateMoveKeys(keys) then -- else return false end return true end function SiegeEngine:onRenderBody(dc) dc:clear() dc:seek(1,1):pen(COLOR_WHITE):string(utils.getBuildingName(self.building)):newline() self.mode.render(dc) dc:seek(1, math.max(dc:cursorY(), 21)):pen(COLOR_WHITE) dc:key('LEAVESCREEN'):string(": Back, ") dc:key('CUSTOM_C'):string(": Recenter") end function SiegeEngine:onInput(keys) if self.mode.input(keys) then -- elseif keys.CUSTOM_C then self:centerViewOn(self.center) elseif keys.LEAVESCREEN then self:dismiss() elseif keys.LEAVESCREEN_ALL then self:dismiss() self.no_select_building = true guidm.clearCursorPos() df.global.ui.main.mode = df.ui_sidebar_mode.Default df.global.world.selected_building = nil end end if not string.match(dfhack.gui.getCurFocus(), '^dwarfmode/QueryBuilding/Some/SiegeEngine') then qerror("This script requires a siege engine selected in 'q' mode") end local building = df.global.world.selected_building if not df.building_siegeenginest:is_instance(building) then qerror("A siege engine must be selected") end if building:getBuildStage() < building:getMaxBuildStage() then qerror("This engine is not completely built yet") end local list = SiegeEngine{ building = building } list:show()