Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ GunsAndGadgets brings together weapon assembly and gun combat. Players build fir

- **Modular assembly** — select a weapon type and combine barrels, loaders, chambers, actions, and stocks where the design requires them.
- **Meaningful parts** — components contribute to accuracy, damage, range, capacity, reload speed, fire rate, spread, and piercing.
- **Ammunition compatibility** — weapons accept their supported calibres and consume ammunition as they are loaded and fired.
- **Ammunition compatibility** — weapons accept their supported calibres and consume ammunition as they are loaded and fired. Crouch and right-click to choose which carried calibre the next reload uses; the choice is saved on the gun.
- **Reloading and handling** — timed reloads, progress feedback, character attribute effects, and loaded or unloaded appearances communicate weapon state.
- **Projectile combat** — shot trajectories, impacts, sounds, and visual effects support firearm and launcher attacks.
- **Weapon appearances** — skin support keeps custom gun designs connected to their firing and reload states.
Expand Down
108 changes: 95 additions & 13 deletions src/main/java/net/tfminecraft/gunsandgadgets/manager/GunManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

Expand Down Expand Up @@ -52,6 +53,7 @@
import org.bukkit.scheduler.BukkitRunnable;

import io.lumine.mythic.lib.api.item.NBTItem;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import net.tfminecraft.tlibs.TLibs;
import net.tfminecraft.tlibs.objects.api.subapi.ItemSkinPreserver;
import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter;
Expand All @@ -72,10 +74,14 @@ public class GunManager implements Listener {
private final NamespacedKey reloadAmmoKey = new NamespacedKey(GunsAndGadgets.getInstance(), "reload_ammo");
private final NamespacedKey reloadAmountKey = new NamespacedKey(GunsAndGadgets.getInstance(), "reload_amount");
private final NamespacedKey loadedAmmoKey = new NamespacedKey(GunsAndGadgets.getInstance(), "ammo_loaded");
// Ammunition the player picked for the next reload; absent means the first carried caliber.
private final NamespacedKey selectedAmmoKey = new NamespacedKey(GunsAndGadgets.getInstance(), "ammo_selected");

private final Map<UUID, Boolean> reloading = new HashMap<>();
// Preserve the exact inputs through config reloads, including their item metadata.
private final Map<UUID, Collection<ItemStack>> reloadRefunds = new HashMap<>();
// Clicking a block fires the interact event for each hand; switch ammo once per click.
private final Map<UUID, Integer> lastAmmoSwitchTick = new HashMap<>();

@EventHandler
public void preventOldMuskets(UntargetedWeaponUseEvent e) {
Expand Down Expand Up @@ -157,6 +163,13 @@ public void handleGunUse(Player player, Cancellable event) {
if (reloading.getOrDefault(id, false)) {
return;
}
if (player.isSneaking()) {
Integer tick = Bukkit.getCurrentTick();
if (!tick.equals(lastAmmoSwitchTick.put(id, tick))) {
cycleAmmo(player, item);
}
return;
}
reloading.put(id, true);

SkinData skin = SkinLoader.get().get(skinId);
Expand Down Expand Up @@ -227,10 +240,15 @@ public void handleGunUse(Player player, Cancellable event) {
.getOrDefault(capacityKey, PersistentDataType.INTEGER, 1);

// 🔫 Try to consume ammo before reload
Collection<Ammunition> calibers = Caliber.get(item); // your helper from before
String taken = takeAmmo(player, capacity, calibers);
List<Ammunition> calibers = Caliber.get(item); // your helper from before
Ammunition selected = getSelectedAmmo(meta, calibers);
String taken = takeAmmo(player, capacity, selected == null ? calibers : List.of(selected));

if (taken.equals("none")) {
if (selected != null) {
sendActionBar(player, "§cYou have no " + getAmmoName(selected)
+ "§c left. Crouch and right-click to choose another.");
}
reloading.remove(id);
return;
}
Expand All @@ -239,6 +257,9 @@ public void handleGunUse(Player player, Cancellable event) {
String[] split = taken.split("\\.");
String ammoId = split[0];
int takenAmount = Integer.parseInt(split[1]);
if (calibers.size() > 1) {
sendActionBar(player, "§7Loading " + getAmmoName(AmmunitionLoader.getByString(ammoId)));
}

// Save ammo info in PDC (for refund or finalize)
meta.getPersistentDataContainer().set(reloadAmmoKey, PersistentDataType.STRING, ammoId);
Expand Down Expand Up @@ -585,17 +606,8 @@ private String makeProgressBar(double progress, int bars, String filled, String
private String takeAmmo(Player p, int amount, Collection<Ammunition> calibers) {
if (amount <= 0) return "none";
for (Ammunition ammo : calibers) {
int totalFound = 0;
java.util.List<ItemStack> matchingStacks = new ArrayList<>();

// Count how many of this ammo we have
for (ItemStack item : p.getInventory().getContents()) {
if (item == null) continue;
if (TLibs.getItemAPI().getChecker().checkItemWithPath(item, ammo.getInput())) {
totalFound += item.getAmount();
matchingStacks.add(item);
}
}
java.util.List<ItemStack> matchingStacks = findAmmo(p, ammo);
int totalFound = countStacks(matchingStacks);

if (totalFound > 0) {
// Take min(capacity, found)
Expand All @@ -609,6 +621,76 @@ private String takeAmmo(Player p, int amount, Collection<Ammunition> calibers) {
return "none";
}

/** Stacks of this ammunition in the player's inventory, in slot order. */
private java.util.List<ItemStack> findAmmo(Player p, Ammunition ammo) {
java.util.List<ItemStack> matchingStacks = new ArrayList<>();
for (ItemStack item : p.getInventory().getContents()) {
if (item == null) continue;
if (TLibs.getItemAPI().getChecker().checkItemWithPath(item, ammo.getInput())) {
matchingStacks.add(item);
}
}
return matchingStacks;
}

private int countStacks(java.util.List<ItemStack> stacks) {
int total = 0;
for (ItemStack item : stacks) total += item.getAmount();
return total;
}

/** The player's pick for the next reload, or null when unset or no longer a caliber of this gun. */
private Ammunition getSelectedAmmo(ItemMeta meta, List<Ammunition> calibers) {
String selectedId = meta.getPersistentDataContainer().get(selectedAmmoKey, PersistentDataType.STRING);
for (Ammunition ammo : calibers) {
if (ammo.getKey().equals(selectedId)) return ammo;
}
return null;
}

/**
* Crouch + right-click: pick the next caliber the player carries for the next reload.
* Bullets already loaded stay loaded.
*/
private void cycleAmmo(Player player, ItemStack item) {
List<Ammunition> calibers = Caliber.get(item);
List<Ammunition> carried = new ArrayList<>();
for (Ammunition ammo : calibers) {
if (countStacks(findAmmo(player, ammo)) > 0) carried.add(ammo);
}
if (carried.isEmpty()) {
sendActionBar(player, "§cYou carry no shot this weapon can fire.");
return;
}

ItemMeta meta = item.getItemMeta();
Ammunition current = getSelectedAmmo(meta, calibers);
// Without a pick, reloads use the first carried caliber, so step past that one.
int index = calibers.indexOf(current != null ? current : carried.get(0));
Ammunition next;
do {
index = (index + 1) % calibers.size();
next = calibers.get(index);
} while (!carried.contains(next));

meta.getPersistentDataContainer().set(selectedAmmoKey, PersistentDataType.STRING, next.getKey());
item.setItemMeta(meta);
player.getInventory().setItemInMainHand(item);
sendActionBar(player, "§7Next load: " + getAmmoName(next)
+ " §8(" + countStacks(findAmmo(player, next)) + " carried)");
player.playSound(player.getLocation(), Sound.BLOCK_LEVER_CLICK, 1f, 1.5f);
}

// Keep the existing legacy text representation of ammunition item names.
@SuppressWarnings("deprecation")
private String getAmmoName(Ammunition ammo) {
return StringFormatter.getName(TLibs.getItemAPI().getCreator().getItemFromPath(ammo.getInput()));
}

private void sendActionBar(Player player, String legacyText) {
player.sendActionBar(LegacyComponentSerializer.legacySection().deserialize(legacyText));
}


/**
* Remove a certain number of items of a specific type from a player's inventory.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,9 @@ public ItemStack createOutputItem(GunType type, Collection<GunPart> parts, boole
lore.add("§7• " + ammoKey);
}
}
if (calibers.size() > 1) {
lore.add("§8Crouch and right-click to change shot");
}

// ✅ Save calibers in PDC
NamespacedKey calibersKey = new NamespacedKey(GunsAndGadgets.getInstance(), "calibers");
Expand Down Expand Up @@ -506,6 +509,7 @@ private void copyRuntimeAmmoPdc(ItemStack from, ItemStack to) {
NamespacedKey reloadAmmoKey = new NamespacedKey(GunsAndGadgets.getInstance(), "reload_ammo");
NamespacedKey reloadAmountKey = new NamespacedKey(GunsAndGadgets.getInstance(), "reload_amount");
NamespacedKey lastFireKey = new NamespacedKey(GunsAndGadgets.getInstance(), "last_fire");
NamespacedKey selectedAmmoKey = new NamespacedKey(GunsAndGadgets.getInstance(), "ammo_selected");

if (fromPdc.has(bulletsKey, PersistentDataType.INTEGER)) {
toPdc.set(bulletsKey, PersistentDataType.INTEGER,
Expand Down Expand Up @@ -542,6 +546,13 @@ private void copyRuntimeAmmoPdc(ItemStack from, ItemStack to) {
toPdc.remove(lastFireKey);
}

String selectedAmmo = fromPdc.get(selectedAmmoKey, PersistentDataType.STRING);
if (selectedAmmo != null) {
toPdc.set(selectedAmmoKey, PersistentDataType.STRING, selectedAmmo);
} else {
toPdc.remove(selectedAmmoKey);
}

to.setItemMeta(toMeta);
}

Expand Down Expand Up @@ -593,23 +604,23 @@ private void storeSounds(GunType gunType, Collection<GunPart> parts,

/**
* Finds the calibers for the selected parts.
* If an override is present, uses the last override only.
* If an override is present, the last part with one supplies every caliber.
*/
private List<String> resolveCalibers(Collection<GunPart> parts) {
List<String> overrides = new ArrayList<>();
List<String> override = null;
List<String> normal = new ArrayList<>();

for (GunPart part : parts) {
if (part.getCaliberOverrides() != null && !part.getCaliberOverrides().isEmpty()) {
overrides.addAll(part.getCaliberOverrides());
override = part.getCaliberOverrides();
} else if (!part.getCalibers().isEmpty()) {
normal.addAll(part.getCalibers());
}
}

if (!overrides.isEmpty()) {
// ✅ Only the last override matters
return List.of(overrides.get(overrides.size() - 1));
if (override != null) {
// ✅ Only the last override matters, with all of its calibers
return List.copyOf(override);
}
return normal;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -950,4 +950,142 @@ void slotSelectionKeepsHealthyGunAndInventoryProtectionIgnoresMetadataFreeItems(
manager.onInventoryClick(e);
verify(e, never()).setCancelled(true);
}

// Three calibers told apart by item type: iron nuggets, gold nuggets (steel), copper (bronze).
void threeCalibers() {
for (String[] entry :
new String[][] {{"iron", "iron_nugget"}, {"steel", "gold_nugget"}, {"bronze", "copper_ingot"}}) {
var config = new YamlConfiguration();
config.set("input", "v." + entry[1]);
AmmunitionLoader.get().put(entry[0], new Ammunition(entry[0], config));
}
var checker = TLibs.getItemAPI().getChecker();
var creator = TLibs.getItemAPI().getCreator();
doAnswer(
i ->
((ItemStack) i.getArgument(0))
.getType()
.getKey()
.getKey()
.equals(((String) i.getArgument(1)).substring(2)))
.when(checker)
.checkItemWithPath(any(), anyString());
doAnswer(i -> new ItemStack(Material.matchMaterial(((String) i.getArgument(0)).substring(2))))
.when(creator)
.getItemFromPath(anyString());
set("calibers", "iron;steel;bronze");
player.getInventory().setItemInMainHand(gun);
}

String actionBar() {
var bar = player.nextActionBar();
return bar == null
? null
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(bar);
}

String selected() {
return player
.getInventory()
.getItemInMainHand()
.getItemMeta()
.getPersistentDataContainer()
.get(key("ammo_selected"), PersistentDataType.STRING);
}

@Test
void crouchClickCyclesCarriedCalibersWithoutFiringOrReloading() {
threeCalibers();
setInt("bullets_loaded", 1);
set("ammo_loaded", "iron");
player.getInventory().setItemInMainHand(gun);
player.getInventory().setItem(2, new ItemStack(Material.COPPER_INGOT, 4));
player.setSneaking(true);
try (var shooter = mockStatic(ProjectileShooter.class)) {
verify(use()).setCancelled(true);
// Iron loads by default, steel is not carried, so the first pick is bronze.
assertEquals("bronze", selected());
assertTrue(actionBar().matches("Next load: .*Copper.* \\(4 carried\\)"));
// The off-hand event of the same block click must not switch again.
use();
assertEquals("bronze", selected());
assertNull(player.nextActionBar());
server.getScheduler().performTicks(1);
use();
assertEquals("iron", selected());
assertTrue(actionBar().contains("(5 carried)"));
shooter.verifyNoInteractions();
}
assertEquals(1, readInt(player.getInventory().getItemInMainHand(), "bullets_loaded"));
assertEquals(5, player.getInventory().getItem(1).getAmount());
assertEquals(4, player.getInventory().getItem(2).getAmount());
}

@Test
void crouchClickWithoutUsableAmmoSaysSo() {
threeCalibers();
player.getInventory().setItem(1, null);
player.setSneaking(true);
use();
assertEquals("You carry no shot this weapon can fire.", actionBar());
assertNull(selected());
}

@Test
void reloadUsesSelectedCaliber() {
threeCalibers();
set("ammo_selected", "steel");
player.getInventory().setItemInMainHand(gun);
player.getInventory().setItem(2, new ItemStack(Material.GOLD_NUGGET, 2));
use();
assertTrue(actionBar().matches("Loading .*Gold.*"));
server.getScheduler().performTicks(12);
ItemStack held = player.getInventory().getItemInMainHand();
assertEquals(2, readInt(held, "bullets_loaded"));
assertEquals(
"steel",
held.getItemMeta().getPersistentDataContainer().get(key("ammo_loaded"), PersistentDataType.STRING));
assertEquals(5, player.getInventory().getItem(1).getAmount());
assertEquals("steel", selected());
}

@Test
void reloadRefusesOtherAmmoWhenSelectedCaliberRunsOut() {
threeCalibers();
set("ammo_selected", "steel");
player.getInventory().setItemInMainHand(gun);
use();
assertTrue(
actionBar().matches("You have no .*Gold.* left\\. Crouch and right-click to choose another\\."));
assertEquals(5, player.getInventory().getItem(1).getAmount());
assertEquals(0, readInt(player.getInventory().getItemInMainHand(), "reload_amount"));
// The refusal must not leave the player stuck in a reload.
player.getInventory().setItem(2, new ItemStack(Material.GOLD_NUGGET, 1));
use();
assertEquals(1, readInt(player.getInventory().getItemInMainHand(), "reload_amount"));
}

@Test
void selectionThatIsNoLongerACaliberFallsBackToFirstCarried() {
threeCalibers();
set("ammo_selected", "mythril");
player.getInventory().setItemInMainHand(gun);
use();
assertEquals(3, readInt(player.getInventory().getItemInMainHand(), "reload_amount"));
assertEquals(
"iron",
player
.getInventory()
.getItemInMainHand()
.getItemMeta()
.getPersistentDataContainer()
.get(key("reload_ammo"), PersistentDataType.STRING));
}

@Test
void singleCaliberReloadShowsNoAmmoName() {
use();
assertNull(player.nextActionBar());
}
}
Loading