Repository navigation
Expand file tree
/
Copy pathshotgun.lua
More file actions
771 lines (681 loc) · 29.6 KB
/
Copy pathshotgun.lua
File metadata and controls
771 lines (681 loc) · 29.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
-- shotgun.lua
-- Shotgun spread system: SHOT, FLECHETTE, and canister ammo fire
-- multiple pellet/dart projectiles spread in a narrow cone instead of a
-- single projectile.
--
-- Pellet counts (single-barrel):
-- ITEM_AMMO_SHOTGUN_SHOT → 10 ITEM_AMMO_PELLET (15° half-angle cone)
-- ITEM_AMMO_SHOTGUN_FLECHETTE → 10 ITEM_AMMO_DART (15° half-angle cone)
-- ITEM_AMMO_SHELL_CARTRIDGE_LIGHT_CANISTER → 30 ITEM_AMMO_PELLET (30° half-angle cone)
-- ITEM_AMMO_SHELL_CARTRIDGE_HEAVY_CANISTER → 100 ITEM_AMMO_PELLET (30° half-angle cone)
-- (Canister and SHOT/FLECHETTE are handled by ammo.lua; shotgun.lua defers them.)
--
-- Multi-barrel weapons (MULTIBARREL_WEAPONS) multiply these counts by the
-- number of barrels and consume that many ammo per shot. SLUG ammo from a
-- multi-barrel weapon fires one slug per barrel (no cone spread).
-- Single-barrel SLUG passes through untouched.
--
-- The shell item is redirected to land instantly at the shooter's tile
-- (same technique as ammo.lua) and then removed.
--
-- All mutable state lives in _G.__shotgun so enable/disable/status/debug
-- share the same runtime state across separate script invocations.
local eventful = require('plugins.eventful')
local repeatUtil = require('repeat-util')
local utils = require('utils')
local CALLBACK_ID = "shotgun"
local CALLBACK_ID_CLEANUP = "shotgun:cleanup"
local CLEANUP_INTERVAL = 1200
local S = _G.__shotgun or {}
_G.__shotgun = S
if S.enabled == nil then S.enabled = false end
if S.DEBUG == nil then S.DEBUG = false end
if S.intercepted == nil then S.intercepted = {} end
if S.subtype_cache == nil then S.subtype_cache = {} end
---------------------------------------------------------------------------
-- CONFIG (loaded from config_gun.lua)
---------------------------------------------------------------------------
local GCfg = _G.__GunConfig
if not GCfg or not GCfg._loaded then
dfhack.printerr("[shotgun] FATAL: __GunConfig not set — bw_autoload must run first")
return
end
-- Shell types handled by ammo.lua; return early to prevent double-interception.
-- Derived as any ammo with capacity (superset of the old explicit list, but safe
-- because shotgun.lua only acts on SHOTGUN_CONFIG / MULTIBARREL_SLUG entries).
local AMMO_LUA_SHELLS = GCfg.AMMO_LUA_SHELLS
-- cone_tan = tan(half_angle). Controls spread width at range.
-- 0.268 ≈ tan(15°): spread_radius at 8 tiles ≈ 2 tiles either side
-- 0.577 ≈ tan(30°): spread_radius at 8 tiles ≈ 4.6 tiles either side
local SHOTGUN_CONFIG = GCfg.SHOTGUN_CONFIG
-- Weapons with multiple barrels. Value = barrel count. All barrels fire simultaneously:
-- cone ammo spawns barrel_count × pellet_count projectiles; slug ammo one per barrel.
local MULTIBARREL_WEAPONS = GCfg.MULTIBARREL_WEAPONS
-- Slug ammo handled only when fired from a multi-barrel weapon.
-- Single-barrel slug passes through this script untouched.
local MULTIBARREL_SLUG = GCfg.MULTIBARREL_SLUG
---------------------------------------------------------------------------
-- DEBUG LOGGING
---------------------------------------------------------------------------
local function log(fmt, ...)
if not S.DEBUG then return end
local ok, msg = pcall(string.format, fmt, ...)
if not ok then msg = fmt end
local frame = -1
pcall(function() frame = df.global.world.frame_counter end)
print(("[shotgun] [frame=%d] %s"):format(frame, msg))
end
---------------------------------------------------------------------------
-- HELPERS
---------------------------------------------------------------------------
local function count_table(t)
local n = 0
for _ in pairs(t) do n = n + 1 end
return n
end
local function cleanup_intercepted()
if not S.enabled then return end
pcall(function()
local live = {}
for _, proj in utils.listpairs(df.global.world.projectiles.all) do
if proj and proj:getType() == df.projectile_type.Item then
live[proj.id] = true
end
end
for pid in pairs(S.intercepted) do
if not live[pid] then
S.intercepted[pid] = nil
end
end
end)
end
local function item_is_projectile(item_id)
local proj_list = df.global.world.projectiles
if proj_list then
for _, proj in ipairs(proj_list.all) do
if proj.item and proj.item.id == item_id then
return true
end
end
end
return false
end
local function find_firer_id(bow_id)
if bow_id < 0 then return -1 end
for _, unit in ipairs(df.global.world.units.active) do
if dfhack.units.isActive(unit) and not dfhack.units.isDead(unit) then
for _, inv_item in ipairs(unit.inventory) do
if inv_item.item.id == bow_id then
return unit.id
end
end
end
end
return -1
end
-- Cached ammo subtype index lookup by raw ID string.
local function get_subtype(ammo_id_str)
if S.subtype_cache[ammo_id_str] then
return S.subtype_cache[ammo_id_str]
end
local defs = df.global.world.raws.itemdefs.ammo
for i = 0, #defs - 1 do
local def = defs[i]
if def and def.id == ammo_id_str then
S.subtype_cache[ammo_id_str] = i
return i
end
end
return -1
end
-- Return the barrel count for the weapon that fired this shot, or 1.
local function get_barrel_count(bow_id)
if bow_id < 0 then return 1 end
local bow = df.item.find(bow_id)
if not bow then return 1 end
local ok, weapon_id = pcall(function()
return dfhack.items.getSubtypeDef(df.item_type.WEAPON, bow:getSubtype()).id
end)
if not ok or not weapon_id then return 1 end
return MULTIBARREL_WEAPONS[weapon_id] or 1
end
-- Find the quiver in a unit's inventory.
local function get_quiver(unit)
for _, inv_item in ipairs(unit.inventory) do
if inv_item.item:getType() == df.item_type.QUIVER then
return inv_item.item
end
end
return nil
end
-- Find the first ammo stack inside a container matching subtype + material.
local function find_ammo_in_container(container, sub, mat_t, mat_i)
local found = nil
pcall(function()
for _, ref in ipairs(container.general_refs) do
if ref:getType() == df.general_ref_type.CONTAINS_ITEM then
local c = df.item.find(ref.item_id)
if c and c:getType() == df.item_type.AMMO
and c:getSubtype() == sub
and c.mat_type == mat_t
and c.mat_index == mat_i then
found = c
return
end
end
end
end)
return found
end
-- Find matching ammo anywhere on a unit; skips exclude_id.
local function find_ammo_on_unit(unit, sub, mat_t, mat_i, exclude_id)
local found = nil
pcall(function()
for _, inv_item in ipairs(unit.inventory) do
local it = inv_item.item
if it and it.id ~= exclude_id then
if it:getType() == df.item_type.AMMO
and it:getSubtype() == sub
and it.mat_type == mat_t
and it.mat_index == mat_i then
found = it
return
end
local nested = find_ammo_in_container(it, sub, mat_t, mat_i)
if nested and nested.id ~= exclude_id then
found = nested
return
end
end
end
end)
return found
end
-- Consume `count` extra ammo from the firer's inventory (for extra barrels).
-- exclude_id is the projectile item itself so we never accidentally match it.
local function consume_extra_ammo(firer_id, sub_idx, mat_t, mat_i, exclude_id, count)
local firer = df.unit.find(firer_id)
if not firer or dfhack.units.isDead(firer) then
log("consume_extra_ammo: firer_id=%d not found or dead", firer_id)
return
end
local stack = nil
local quiver = get_quiver(firer)
if quiver then
stack = find_ammo_in_container(quiver, sub_idx, mat_t, mat_i)
if stack and stack.id == exclude_id then stack = nil end
end
if not stack then
stack = find_ammo_on_unit(firer, sub_idx, mat_t, mat_i, exclude_id)
end
if not stack then
log("consume_extra_ammo: no stack found sub=%d mat=%d/%d", sub_idx, mat_t, mat_i)
return
end
local sz = 0
pcall(function() sz = stack:getStackSize() end)
log("consume_extra_ammo: stack_id=%d size=%d consuming=%d", stack.id, sz, count)
if sz <= count then
pcall(dfhack.items.remove, stack)
else
pcall(function() stack:setStackSize(sz - count) end)
end
end
---------------------------------------------------------------------------
-- CONE SPREAD
---------------------------------------------------------------------------
-- Returns `count` target positions spread in a cone around the shot direction.
-- The spread is a triangular distribution (average of two uniform randoms),
-- which clusters more pellets at the centre than at the edges.
--
-- At 1-3 tiles the cone radius is less than 1 tile, so all pellets land on
-- the same tile (or one tile adjacent). Beyond that the spread widens
-- proportionally to range × cone_tan.
local function compute_spread(cx, cy, tx, ty, tz, count, cone_tan)
local dx, dy = tx - cx, ty - cy
local range = math.sqrt(dx * dx + dy * dy)
if range < 0.5 then range = 0.5 end
-- Perpendicular unit vector (rotate shot direction 90°)
local px, py = -dy / range, dx / range
-- Half-width of the cone at the target distance (in tiles)
local spread_radius = range * cone_tan
local targets = {}
for _ = 1, count do
-- Triangular distribution in [-spread_radius, spread_radius]:
-- average of two U[-1,1] randoms biases toward centre.
local r = (math.random() + math.random() - 1) * spread_radius
local offx = math.floor(px * r + 0.5)
local offy = math.floor(py * r + 0.5)
table.insert(targets, { x = tx + offx, y = ty + offy, z = tz })
end
return targets
end
---------------------------------------------------------------------------
-- SHELL ITEM REMOVAL
-- The shell is redirected to land instantly at the shooter's tile.
-- We poll until it is no longer a projectile, then remove it.
---------------------------------------------------------------------------
local function try_remove(item_id, attempt)
if not S.enabled then return end
local item = df.item.find(item_id)
if not item then
-- DF already destroyed it via its own ammo-survival roll — nothing to do.
log("try_remove: item_id=%d already destroyed after %d attempts", item_id, attempt)
return
end
local still_proj = false
pcall(function() still_proj = item_is_projectile(item_id) end)
if still_proj then
if attempt <= 30 then
dfhack.timeout(1, "ticks", function() try_remove(item_id, attempt + 1) end)
else
log("try_remove: item_id=%d timed out — leaving on ground", item_id)
end
return
end
local ok, err = pcall(dfhack.items.remove, item)
log("try_remove: item_id=%d removed ok=%s err=%s", item_id, tostring(ok), tostring(err))
end
---------------------------------------------------------------------------
-- PELLET / SLUG LAUNCH
-- Called 1 tick after intercept (outside the projectile-list iteration).
-- Used for both cone spread (multiple targets) and multibarrel slugs
-- (multiple projectiles all targeting the same tile, cone_tan = 0).
---------------------------------------------------------------------------
local function launch_pellets(spawn)
local pellet_idx = get_subtype(spawn.pellet_id)
log("launch_pellets: %s -> subtype_idx=%d count=%d", spawn.pellet_id, pellet_idx, spawn.count)
if pellet_idx < 0 then
log("launch_pellets: pellet type not found in raws, aborting")
return
end
-- Need a real unit as the createItem creator; fall back to any active unit.
local creator = df.unit.find(spawn.firer_unit_id)
if not creator then
for _, u in ipairs(df.global.world.units.active) do
if dfhack.units.isActive(u) and not dfhack.units.isDead(u) then
creator = u
break
end
end
end
local cx, cy, cz = spawn.cur_x, spawn.cur_y, spawn.cur_z
local origin = xyz2pos(cx, cy, cz)
local targets = compute_spread(cx, cy,
spawn.target_x, spawn.target_y, spawn.target_z,
spawn.count, spawn.cone_tan)
local launched = 0
-- Pre-scan: collect non-allied units within the shot cone once per shot.
local cone_units = {}
local cone_ok, cone_err = pcall(function()
local fu = df.unit.find(spawn.firer_unit_id)
if not fu then fu = dfhack.world.getAdventurer() end
if fu then
local dx = spawn.target_x - cx
local dy = spawn.target_y - cy
local shot_dist = math.sqrt(dx * dx + dy * dy)
if shot_dist >= 0.5 then
local nx = dx / shot_dist
local ny = dy / shot_dist
local c_tan = spawn.cone_tan or 0
for _, u in ipairs(df.global.world.units.active) do
if dfhack.units.isActive(u) and not dfhack.units.isDead(u)
and u.id ~= fu.id
and math.abs(u.pos.z - cz) <= 1 then
local is_ally =
(fu.civ_id ~= -1 and fu.civ_id == u.civ_id)
or (fu.military.squad_id ~= -1 and fu.military.squad_id == u.military.squad_id)
or (dfhack.units.isCitizen(fu) and dfhack.units.isCitizen(u))
if not is_ally then
local ux = u.pos.x - cx
local uy = u.pos.y - cy
local along = ux * nx + uy * ny
if along >= 1 and along <= shot_dist + 3 then
local perp = math.abs(ux * ny - uy * nx)
if perp <= along * c_tan + 1 then
table.insert(cone_units, u)
end
end
end
end
end
end
end
end)
if not cone_ok then
dfhack.printerr("[shotgun] cone_units scan error: " .. tostring(cone_err))
end
log("launch_pellets: cone_units=%d", #cone_units)
-- Proactively assign one pellet per cone unit based on best angular match.
local assignments = {} -- targets[] index → { id, pos }
for _, u in ipairs(cone_units) do
local ux = u.pos.x - cx
local uy = u.pos.y - cy
local ud = math.sqrt(ux * ux + uy * uy)
if ud >= 0.5 then
local unx = ux / ud
local uny = uy / ud
local best_idx = nil
local best_cross = math.huge
for i, tgt in ipairs(targets) do
if not assignments[i] then
local tdx = tgt.x - cx
local tdy = tgt.y - cy
local td = math.sqrt(tdx * tdx + tdy * tdy)
if td >= 0.5 then
local cross = math.abs(unx * (tdy / td) - uny * (tdx / td))
if cross < best_cross then
best_cross = cross
best_idx = i
end
end
end
end
if best_idx then
assignments[best_idx] = { id = u.id, pos = u.pos }
log("assigned pellet %d to unit %d (angular_err=%.3f)", best_idx, u.id, best_cross)
end
end
end
for i, tgt in ipairs(targets) do
local ok, err = pcall(function()
-- Create the pellet/dart/slug item
local items = dfhack.items.createItem(
creator, df.item_type.AMMO, pellet_idx, spawn.mat_type, spawn.mat_idx)
local pellet = items and items[1]
if not pellet then return end
pellet:setStackSize(1)
-- Place on ground so makeProjectile has a valid location to work from
dfhack.items.moveToGround(pellet, origin)
local proj = dfhack.items.makeProjectile(pellet)
if not proj then return end
-- Set origin / current position (shooter's tile)
proj.origin_pos.x = cx; proj.origin_pos.y = cy; proj.origin_pos.z = cz
proj.prev_pos.x = cx; proj.prev_pos.y = cy; proj.prev_pos.z = cz
proj.cur_pos.x = cx; proj.cur_pos.y = cy; proj.cur_pos.z = cz
-- Each projectile targets its own spread tile (or the same tile for slugs)
proj.target_pos.x = tgt.x
proj.target_pos.y = tgt.y
proj.target_pos.z = tgt.z
-- Set directional speed components so DF moves this projectile
-- tile-by-tile. makeProjectile() leaves speed_x/y/z = 0; without
-- them the pellet never traverses tiles and hit detection never fires.
pcall(function()
local pdx = tgt.x - cx
local pdy = tgt.y - cy
local pdist = math.sqrt(pdx * pdx + pdy * pdy)
if pdist < 0.5 then pdist = 0.5 end
local spd = spawn.orig_speed
proj.speed_x = math.floor(pdx / pdist * spd + 0.5)
proj.speed_y = math.floor(pdy / pdist * spd + 0.5)
proj.speed_z = 0
end)
-- Trajectory (copied from the original shell so speed/arcing matches)
proj.velocity = spawn.velocity
proj.accel_x = spawn.accel_x
proj.accel_y = spawn.accel_y
proj.accel_z = spawn.accel_z
-- Combat rolls from the original shot so hit probability is preserved
proj.hit_rating = spawn.hit_rating
proj.hit_chance_modifier = spawn.hit_chance_modifier
proj.min_hit_distance = spawn.min_hit_distance
proj.bow_id = spawn.bow_id
proj.distance_flown = 1 -- skip self-collision check on first tick
if spawn.fall_threshold then
pcall(function() proj.fall_threshold = spawn.fall_threshold end)
end
pcall(function()
local firer = df.unit.find(spawn.firer_unit_id)
if not firer then firer = dfhack.world.getAdventurer() end
proj.firer = firer
end)
-- Allow this pellet to hit neutral units (traders, wildlife, etc.)
-- not just declared enemies. Without this flag DF skips hit checks
-- for anyone who isn't an active combat target of the firer.
pcall(function() proj.flags.hits_friends = true end)
-- Use the pre-assigned unit for this pellet slot (if any).
-- Assigned pellets are redirected to the unit's actual tile so DF
-- performs a hit-roll; unassigned pellets fly free to their spread tile.
pcall(function()
local assign = assignments[i]
local effective = assign and { x = assign.pos.x, y = assign.pos.y, z = assign.pos.z } or tgt
proj.target_pos.x = effective.x
proj.target_pos.y = effective.y
proj.target_pos.z = effective.z
local pdx = effective.x - cx
local pdy = effective.y - cy
local pdist = math.sqrt(pdx * pdx + pdy * pdy)
if pdist >= 0.5 then
local spd = spawn.orig_speed
proj.speed_x = math.floor(pdx / pdist * spd + 0.5)
proj.speed_y = math.floor(pdy / pdist * spd + 0.5)
end
proj.spec_target_unit = assign and assign.id or -1
proj.target_bp = -1
end)
-- Prevent this script from re-intercepting spawned projectiles.
-- Essential for multibarrel slugs (ITEM_AMMO_SLUG is in MULTIBARREL_SLUG).
S.intercepted[proj.id] = true
launched = launched + 1
end)
if not ok then
log("launch_pellets: error on projectile — %s", tostring(err))
end
end
log("launch_pellets: launched %d/%d from (%d,%d,%d) toward (%d,%d,%d)",
launched, spawn.count, cx, cy, cz, spawn.target_x, spawn.target_y, spawn.target_z)
end
---------------------------------------------------------------------------
-- PROJECTILE INTERCEPT
---------------------------------------------------------------------------
local function on_proj_move(projectile)
local item = projectile.item
if not item then return end
if item:getType() ~= df.item_type.AMMO then return end
-- Resolve the ammo subtype raw ID string
local ok, sub_id = pcall(function()
return dfhack.items.getSubtypeDef(df.item_type.AMMO, item:getSubtype()).id
end)
if not ok or not sub_id then return end
-- Defer to ammo.lua for shell types it now owns.
if AMMO_LUA_SHELLS[sub_id] then return end
local cfg = SHOTGUN_CONFIG[sub_id]
local is_mb_slug = MULTIBARREL_SLUG[sub_id]
if not cfg and not is_mb_slug then return end -- not a shell we handle
-- Resolve barrel count before the intercepted guard so we can reject
-- single-barrel slugs without marking them as intercepted.
local bow_id = -1
pcall(function() bow_id = projectile.bow_id end)
local barrels = get_barrel_count(bow_id)
-- Single-barrel slug: pass through untouched.
if is_mb_slug and barrels <= 1 then return end
local proj_id = projectile.id
if S.intercepted[proj_id] then return end -- already handled
-- ── First intercept tick ─────────────────────────────────────────────
-- Capture all trajectory and combat data before we modify the projectile.
local firer_unit_id = find_firer_id(bow_id)
-- Integer subtype of the fired shell; needed for consume_extra_ammo.
local sub_idx = item:getSubtype()
local mat_type_val, mat_idx_val = -1, -1
pcall(function()
mat_type_val = item.mat_type
mat_idx_val = item.mat_index
end)
local spec_target_unit, target_bp = -1, -1
pcall(function()
spec_target_unit = projectile.spec_target_unit
target_bp = projectile.target_bp
end)
local vel, ax, ay, az = 0, 0, 0, 0
pcall(function()
vel = projectile.velocity
ax = projectile.accel_x
ay = projectile.accel_y
az = projectile.accel_z
end)
-- Capture native speed magnitude before the cone-ammo redirect zeroes it.
local orig_sx, orig_sy = 0, 0
pcall(function()
orig_sx = projectile.speed_x
orig_sy = projectile.speed_y
end)
local orig_speed = math.sqrt(orig_sx * orig_sx + orig_sy * orig_sy)
if orig_speed < 1 then orig_speed = 50000 end
local hit_rating, hit_chance_mod, min_hit_dist = 0, 0, 1
pcall(function()
hit_rating = projectile.hit_rating
hit_chance_mod = projectile.hit_chance_modifier
min_hit_dist = projectile.min_hit_distance
end)
local ft = nil
pcall(function() ft = projectile.fall_threshold end)
-- Determine per-mode spawn parameters.
local pellet_id, count, cone_tan
if cfg then
pellet_id = cfg.pellet
count = cfg.count * barrels -- multiply by barrel count
cone_tan = cfg.cone_tan
else
-- Multibarrel slug: spawn (barrels-1) extra slugs at the same target tile.
-- cone_tan=0 means compute_spread returns the exact target for every slug.
pellet_id = sub_id
count = barrels - 1
cone_tan = 0
end
log("on_proj_move: proj_id=%d sub=%s firer=%d barrels=%d count=%d vel=%d target=(%d,%d,%d)",
proj_id, sub_id, firer_unit_id, barrels, count, vel,
projectile.target_pos.x, projectile.target_pos.y, projectile.target_pos.z)
local spawn = {
pellet_id = pellet_id,
count = count,
cone_tan = cone_tan,
cur_x = projectile.cur_pos.x,
cur_y = projectile.cur_pos.y,
cur_z = projectile.cur_pos.z,
target_x = projectile.target_pos.x,
target_y = projectile.target_pos.y,
target_z = projectile.target_pos.z,
velocity = vel,
accel_x = ax,
accel_y = ay,
accel_z = az,
hit_rating = hit_rating,
hit_chance_modifier = hit_chance_mod,
min_hit_distance = min_hit_dist,
bow_id = bow_id,
firer_unit_id = firer_unit_id,
spec_target_unit = spec_target_unit,
target_bp = target_bp,
mat_type = mat_type_val,
mat_idx = mat_idx_val,
fall_threshold = ft,
orig_speed = orig_speed,
}
S.intercepted[proj_id] = true
if cfg then
-- ── CONE ammo (SHOT / FLECHETTE) ─────────────────────────────────
-- Redirect the shell to land instantly at the shooter's tile.
-- velocity=1 (not 0) + target=cur_pos causes DF to trigger impact immediately.
-- speed_x/y/z=0 ensures no visible movement.
-- bow_id=-1 detaches from the weapon so the shell doesn't register as an attack.
pcall(function()
projectile.speed_x = 0
projectile.speed_y = 0
projectile.speed_z = 0
projectile.velocity = 1
projectile.target_pos.x = projectile.cur_pos.x
projectile.target_pos.y = projectile.cur_pos.y
projectile.target_pos.z = projectile.cur_pos.z
projectile.bow_id = -1
end)
-- 1 tick later: launch all pellets (barrels × cfg.count)
dfhack.timeout(1, "ticks", function()
if S.enabled then launch_pellets(spawn) end
end)
-- 2 ticks later: remove the landed shell item
dfhack.timeout(2, "ticks", function() try_remove(item.id, 1) end)
-- 3 ticks later: consume extra ammo for the additional barrels
if barrels > 1 then
local item_id = item.id
dfhack.timeout(3, "ticks", function()
if S.enabled and firer_unit_id >= 0 then
consume_extra_ammo(firer_unit_id, sub_idx,
mat_type_val, mat_idx_val, item_id, barrels - 1)
end
end)
end
else
-- ── MULTIBARREL SLUG ─────────────────────────────────────────────
-- Original slug flies normally; spawn (barrels-1) additional slugs
-- at the same target, then consume the extra ammo.
local item_id = item.id
dfhack.timeout(1, "ticks", function()
if S.enabled then launch_pellets(spawn) end
end)
dfhack.timeout(2, "ticks", function()
if S.enabled and firer_unit_id >= 0 then
consume_extra_ammo(firer_unit_id, sub_idx,
mat_type_val, mat_idx_val, item_id, barrels - 1)
end
end)
end
end
---------------------------------------------------------------------------
-- ENABLE / DISABLE / STATUS / DEBUG
---------------------------------------------------------------------------
local function enable()
if S.enabled then
print("[shotgun] Already enabled")
return
end
repeatUtil.cancel(CALLBACK_ID_CLEANUP)
eventful.onProjItemCheckMovement[CALLBACK_ID] = on_proj_move
repeatUtil.scheduleEvery(CALLBACK_ID_CLEANUP, CLEANUP_INTERVAL, "ticks", cleanup_intercepted)
S.enabled = true
S.intercepted = {}
S.subtype_cache = {}
print("[shotgun] Enabled — SHOT/FLECHETTE fire pellet cones; canister handled by ammo.lua; double-barrels fire twice")
end
local function disable()
if not S.enabled then
print("[shotgun] Already disabled")
return
end
repeatUtil.cancel(CALLBACK_ID_CLEANUP)
eventful.onProjItemCheckMovement[CALLBACK_ID] = nil
S.enabled = false
S.intercepted = {}
print("[shotgun] Disabled")
S.DEBUG = false
end
local function status()
print(("[shotgun] Status: %s | Debug: %s | In-flight shells: %d"):format(
S.enabled and "ENABLED" or "DISABLED",
S.DEBUG and "ON" or "OFF",
count_table(S.intercepted)))
end
local function toggle_debug()
S.DEBUG = not S.DEBUG
print(("[shotgun] Debug logging %s"):format(S.DEBUG and "ON" or "OFF"))
if S.DEBUG then
print("[shotgun] Fire a SHOT/FLECHETTE/SLUG shell; output will appear in console.")
print(("[shotgun] Hook registered: %s"):format(
tostring(eventful.onProjItemCheckMovement[CALLBACK_ID] ~= nil)))
end
end
---------------------------------------------------------------------------
-- ARGUMENT PARSING
---------------------------------------------------------------------------
local args = { ... }
local command = args[1] or "enable"
if command == "enable" then
enable()
elseif command == "disable" then
disable()
elseif command == "status" then
status()
elseif command == "debug" then
toggle_debug()
else
print("[shotgun] Usage: shotgun [enable|disable|status|debug]")
end