I have been playing around with something lately, and I have just gotten it to a basically functional point (although I will revisit it later and add/tweak a few things). I figured that in the meantime you might like to have it for some double barrel muskets or something.
----changes the cooldown between each time a creature fires a projectile weapon----
--author thefriendlyhacker
--based on work by Roses, expwnent, zaporozhets and Putnam.
local usage = [====[
ranged/ranged-rof
===========================
This triggers when a unit fires a projectile, and changes the delay (in ticks) until the unit's
next attack.
The new delay is set according to the following formula:
New Delay = Base Delay + Old Delay * Delay Multiplier
Base delay is set by -delayBase, and Delay Multiplier by -delayMult
For reference, dabbling firers have a normal delay of 80 ticks, legendary +5 firers 48 ticks, and legendary +100 firers 40 ticks.
Several arguments can be added to restrict a particular command to select weapons/ammo (see below)
A set of values can be passed through -delayBase and/or -delayMult. When only one of the args is multi-value, the other is treated as an equal sized set of identical values. If both are multi-value sets, they must be of equal length.
The script steps through the set of values when a unit fires a projectile weapon, and keeps track of
the unit's position in the set on a per command, per weapon basis. Beginning at the first
number in the set, the script iterates through the entire pair of sets, using each pair of numbers
to calculate the delay for a single firing event. Effectively, this allows for variable delay
times. This can simulate, for example, burst fire weapons, multi-barreled firearms, or autoloading
weapons with a reload time.
After a delay (calculated using -resetBase and -resetMult similar to the equation above), the position in a unit's firing delay set is reset to the first position.
Data does not persist across saves, which will mean that loading saves made in the middle of combat will influence that combat when using variable delays. Outside of combat, this shouldn't matter.
Usage::
-name str
name of the command. Not optional.
-clear name (not implemented atm)
unregisters a named command. If arg is used with no name, unregisters every command
-reset name (not implemented atm)
clears all persistant data associated with the named command. If used with no name, clears all data
-reqProjType subtype or [ subtype1 subtype2 ... ]
only runs command if the projectile is one of the appropriate subtypes
example: ITEM_AMMO_BOLTS
-reqWeaponType subtype or [ subtype1 subtype2 ... ]
only runs command if the weapon is of one of the appropriate subtypes
example: ITEM_WEAPON_CROSSBOW
-reqProjMat mat or [ mat1 mat2 ... ]
only runs command if the projectile is one of the appropriate material(s)
examples: INORGANIC:IRON, CREATURE_MAT:DWARF:BRAIN
-reqWeaponMat mat or [ mat1 mat2 ... ]
only runs command if the weapon is one of the appropriate material(s)
same format as -reqProjMat
-timeBase nbr or [ nbr1 nbr2 ... ] (default 0)
-timeMult nbr or [ nbr1 nbr2 ... ](default 0)
timeBase and timeMult are used to set the time until the next attack
at least one must be set, and the number can be negative or non-integer
if they are both multi-element tables, they must be of equal length
-timeBase is a flat number of ticks
-timeMulti is a multiplier of the base firing delay of the unit
see above for details
-neverReset
never resets position in a loop
-resetBase nbr (default 0)
-resetMult nbr (default 5)
determines how long until a unit's position in the command's firing delay set is reset
works similarly to timeBase and timeMult, see above for details
]====]
eventful = require 'plugins.eventful'
utils = require 'utils'
--holds individual commands as anon functions
rangedTriggers=rangedTriggers or {}
--3 dimension sparse table for command/creature/firing weapon combos,
--holds the position of a unit in a command's delay set, on a per weapon basis
rangedArrayPos=rangedArrayPos or {}
--
rangedResetTimeouts=rangedResetTimeouts or {}
eventful.enableEvent(eventful.eventType.UNLOAD,1)
eventful.onUnload.rangedTriggerModule = function()
rangedTriggers = {}
rangedArrayPos = {}
rangedResetTimeouts = {}
end
function eventfulFunc(projectile)
if projectile.distance_flown~=0 then return end
for funcName,func in pairs(rangedTriggers) do
if func(projectile,funcName) then return end
end
end
eventful.onProjItemCheckMovement.rangedTriggerModule = eventfulFunc
function getCommandFunc(reqProjMats,reqProjTypes,reqWeaponMats,reqWeaponTypes, timeBase, timeMult, resetBase, resetMult, neverReset)
return function (proj,funcName)
if not proj.firer then return false end
local weapon=df.item.find(proj.bow_id)
--dismembered limbs require this
if not weapon then return false end
--first off,checks to see if this conforms to the command requirements
local found=false
for _,mat in ipairs(reqProjMats) do
if mat==dfhack.matinfo.decode(proj.item):getToken() then found=true end
end
if #reqProjMats>0 and not found then return false end
found=false
for _,mat in ipairs(reqWeaponMats) do
if mat==dfhack.matinfo.decode(weapon):getToken() then found=true end
end
if #reqWeaponMats>0 and not found then return false end
found=false
if #reqProjTypes>0 and proj.item:getSubtype() ~= -1 then
for _,itype in ipairs(reqProjTypes) do
if dfhack.items.getSubtypeDef(proj.item:getType(),proj.item:getSubtype()).id == itype then
found=true
end
end
end
if #reqProjTypes>0 and not found then return false end
found=false
if #reqWeaponTypes>0 and weapon:getSubtype() ~= -1 then
for _,itype in ipairs(reqWeaponTypes) do
if dfhack.items.getSubtypeDef(weapon:getType(),weapon:getSubtype()).id == itype then
found=true
end
end
end
if #reqWeaponTypes>0 and not found then return false end
--now, do internal stuff, initialize any rangedArrayPos data when necessary and set/reset the delay trigger
local oldThinkCounter=proj.firer.counters.think_counter
if #timeBase>1 then
if not rangedArrayPos[funcName][proj.firer.id] then rangedArrayPos[funcName][proj.firer.id]={} end
if not rangedArrayPos[funcName][proj.firer.id][proj.bow_id] then rangedArrayPos[funcName][proj.firer.id][proj.bow_id]=1 end
if not neverReset then
local delayTime=math.max(1,math.floor(oldThinkCounter*resetMult+resetBase))
if not rangedResetTimeouts[funcName][proj.firer.id] then rangedResetTimeouts[funcName][proj.firer.id]={} end
if rangedResetTimeouts[funcName][proj.firer.id][proj.bow_id] and dfhack.timeout_active(rangedResetTimeouts[funcName][proj.firer.id][proj.bow_id]) then
dfhack.timeout_active(rangedResetTimeouts[funcName][proj.firer.id][proj.bow_id],nil)
end
--these get used later when the projectile has probably been deleted
--hence the need to store locally so we don't have dangling references causing segfaults
local pid=proj.firer.id
local bowid=proj.bow_id
rangedResetTimeouts[funcName][pid][bowid]=dfhack.timeout(delayTime,'ticks',function() rangedArrayPos[funcName][pid][bowid]=1 end)
end
end
--now the meaty bit - calculate and set the new think_counter time
if #timeBase>1 then
proj.firer.counters.think_counter=math.max(1,math.floor(oldThinkCounter*timeMult[rangedArrayPos[funcName][proj.firer.id][proj.bow_id]]+timeBase[rangedArrayPos[funcName][proj.firer.id][proj.bow_id]]))
else
proj.firer.counters.think_counter=math.max(1,math.floor(oldThinkCounter*timeMult[1]+timeBase[1]))
end
--iterate through the array of times
if #timeBase>1 then
rangedArrayPos[funcName][proj.firer.id][proj.bow_id]=rangedArrayPos[funcName][proj.firer.id][proj.bow_id]+1
if rangedArrayPos[funcName][proj.firer.id][proj.bow_id]>#timeBase then
rangedArrayPos[funcName][proj.firer.id][proj.bow_id]=1
end
end
return true
end
end
local validArgs = utils.invert({
'help',
'clear',
'reset',
'name',
'reqProjMat',
'reqProjType',
'reqWeaponMat',
'reqWeaponType',
'timeBase',
'timeMult',
'neverReset',
'delayBase',
'delayMult'
})
if moduleMode then return end
local args = utils.processArgs({...}, validArgs)
if args.help then
print(usage)
return
end
if args.clear then
return --todo, code below won't work
--rangedTriggers[args.clear]=nil
--rangedArrayPos[args.clear]=nil
--if dfhack.timeoutActive(rangedDelayedReset[args.clear]) then
-- dfhack.timeoutActive(rangedDelayedReset[args.clear],nil)
--end
end
if args.reset then
return --todo
end
local reqProjMats={}
local reqProjTypes={}
local reqWeaponMats={}
local reqWeaponTypes={}
local timeBase={0}
local timeMult={0}
local resetBase=0
local resetMult=5
local neverReset=false
if type(args.reqProjMat)=='string' then
reqProjMats={args.reqProjMat}
elseif type(args.reqProjMat)=='table' then
reqProjMats=args.reqProjMat
end
if type(args.reqProjType)=='string' then
reqProjTypes={args.reqProjType}
elseif type(args.reqProjType)=='table' then
reqProjTypes=args.reqProjType
end
if type(args.reqWeaponMat)=='string' then
reqWeaponMats={args.reqWeaponMat}
elseif type(args.reqWeaponMat)=='table' then
reqWeaponMats=args.reqWeaponsMat
end
if type(args.reqWeaponType)=='string' then
reqWeaponTypes={args.reqWeaponType}
elseif type(args.reqWeaponType)=='table' then
reqWeaponTypes=args.reqWeaponType
end
if not args.timeBase and not args.timeMult then
error("-timeBase or -timeMult must be defined")
end
if args.timeBase and type(args.timeBase)=='string' then
timeBase={args.timeBase}
elseif args.timeBase then
timeBase=args.timeBase
end
if args.timeMult and type(args.timeMult)=='string' then
timeMult={args.timeMult}
elseif args.timeMult then
timeMult=args.timeMult
end
for i,numb in ipairs(timeBase) do
timeBase[i]=tonumber(numb)
if not timeBase[i] then
error("all elements of timeBase must be numbers")
end
end
for i,numb in ipairs(timeMult) do
timeMult[i]=tonumber(numb)
if not timeMult[i] then
error("all elements of timeMult must be numbers")
end
end
if #timeBase>1 and #timeMult>1 and #timeBase~=#timeMult then
error("-timeBase and -timeMult must be the same length when they are both tables")
end
if #timeBase>1 and #timeMult==1 then
for i=#timeMult+1,#timeBase do
timeMult[i]=timeMult[1]
end
end
if #timeMult>1 and #timeBase==1 then
for i=#timeBase+1,#timeMult do
timeBase[i]=timeBase[1]
end
end
if args.resetBase then
resetBase=tonumber(args.resetBase)
if not resetBase then error("resetBase must be a number") end
end
if args.resetMult then
resetMult=tonumber(args.resetMult)
if not resetMult then error("resetMult must be a number") end
end
if args.neverReset then
neverReset=true
end
if not args.name then error("-name must be specified") end
rangedTriggers[args.name]=getCommandFunc(reqProjMats,reqProjTypes,reqWeaponMats,reqWeaponTypes, timeBase, timeMult, resetBase, resetMult, neverReset)
rangedArrayPos[args.name]={}
rangedResetTimeouts[args.name]={}
I *think* it works without any issues, but I wouldn't be surprised if I screwed something up somewhere, so test it and tell me how it goes. Also tell me if the documentation is understandable - my technical writing skills are mediocre at best.
Of course, anyone else is welcome to use it as well if they want to.
EDIT:I missed a couple of bugs - only the last script call would actually function correctly, and passing tables to both timeMult and timeBase would throw an error. Those are now fixed. Also, now it uses the built in item find(id) function instead of my own home rolled one. Code has been updated above.