diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/BlockShipsPlugin.java b/blockships/src/main/java/anon/def9a2a4/blockships/BlockShipsPlugin.java index 1ded4f8..b028ce4 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/BlockShipsPlugin.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/BlockShipsPlugin.java @@ -36,6 +36,9 @@ public void onEnable() { saveDefaultConfig(); + // Auto-add missing config keys from new plugin versions + ConfigValidator.migrateConfig(this); + // Check for config mismatches ConfigValidator.checkConfigMismatches(this); @@ -46,6 +49,9 @@ public void onEnable() { BlockConfigManager.initialize(this); BlockConfigManager.getInstance().loadConfig(); + // Load help book content from bundled YAML + HelpBookContent.load(this); + // Check for ProtocolLib for WASD input detection if (Bukkit.getPluginManager().getPlugin("ProtocolLib") == null) { getLogger().warning("=================================================="); @@ -219,6 +225,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } // Reload block configuration BlockConfigManager.getInstance().reloadConfig(); + // Reload help book content + HelpBookContent.load(this); // Reload special drowned config if (specialDrownedListener != null) { specialDrownedListener.reloadConfig(); @@ -240,20 +248,13 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (args.length < 2) { sender.sendMessage("Usage: /blockships give "); - sender.sendMessage("Available items:"); - sender.sendMessage(" - ship_wheel"); - var shipsSection = getConfig().getConfigurationSection("ships"); - if (shipsSection != null) { - for (String shipType : shipsSection.getKeys(false)) { - sender.sendMessage(" - " + shipType); - } - } + sendGiveableItems(sender); return true; } String itemType = args[1].toLowerCase(); - // Handle ship_wheel specially + // Ship wheel if (itemType.equals("ship_wheel")) { ItemStack wheel = displayShip.createShipWheelItem(); player.getInventory().addItem(wheel); @@ -261,27 +262,33 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } - // Verify ship type exists in config - if (!getConfig().contains("ships." + itemType)) { - sender.sendMessage("Unknown item: " + itemType); - sender.sendMessage("Available items:"); - sender.sendMessage(" - ship_wheel"); - var shipsSection = getConfig().getConfigurationSection("ships"); - if (shipsSection != null) { - for (String type : shipsSection.getKeys(false)) { - sender.sendMessage(" - " + type); - } - } + // Captain's Manual (written book) + if (itemType.equals("captains_manual")) { + ItemStack manual = HelpBookContent.createWrittenBook(); + player.getInventory().addItem(manual); + sender.sendMessage("Gave you a Captain's Manual!"); return true; } - // Create ship kit with default wood (SPRUCE) and banner (WHITE) - ItemStack defaultBanner = new ItemStack(Material.WHITE_BANNER); - ItemStack shipKit = DisplayShip.createShipKit(itemType, defaultBanner, "SPRUCE", this); + // Custom items (ship_engine, balloon, etc.) + if (getConfig().contains("custom-items." + itemType)) { + ItemStack item = displayShip.getItemFactory().createItem(itemType, "_DEFAULT", null); + player.getInventory().addItem(item); + sender.sendMessage("Gave you a " + itemType + "!"); + return true; + } + + // Ship kits + if (getConfig().contains("ships." + itemType)) { + ItemStack defaultBanner = new ItemStack(Material.WHITE_BANNER); + ItemStack shipKit = DisplayShip.createShipKit(itemType, defaultBanner, "SPRUCE", this); + player.getInventory().addItem(shipKit); + sender.sendMessage("Gave you a " + itemType + " ship kit!"); + return true; + } - // Give to player - player.getInventory().addItem(shipKit); - sender.sendMessage("Gave you a " + itemType + " ship kit!"); + sender.sendMessage("Unknown item: " + itemType); + sendGiveableItems(sender); return true; } @@ -486,6 +493,39 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return false; } + private void sendGiveableItems(CommandSender sender) { + sender.sendMessage("Available items:"); + sender.sendMessage(" - ship_wheel"); + sender.sendMessage(" - captains_manual"); + var customItemsSection = getConfig().getConfigurationSection("custom-items"); + if (customItemsSection != null) { + for (String key : customItemsSection.getKeys(false)) { + sender.sendMessage(" - " + key); + } + } + var shipsSection = getConfig().getConfigurationSection("ships"); + if (shipsSection != null) { + for (String key : shipsSection.getKeys(false)) { + sender.sendMessage(" - " + key); + } + } + } + + private List getGiveableItemNames() { + List items = new ArrayList<>(); + items.add("ship_wheel"); + items.add("captains_manual"); + var customItemsSection = getConfig().getConfigurationSection("custom-items"); + if (customItemsSection != null) { + items.addAll(customItemsSection.getKeys(false)); + } + var shipsSection = getConfig().getConfigurationSection("ships"); + if (shipsSection != null) { + items.addAll(shipsSection.getKeys(false)); + } + return items; + } + @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { if (!command.getName().equalsIgnoreCase("blockships")) { @@ -522,15 +562,7 @@ public List onTabComplete(CommandSender sender, Command command, String String subcommand = args[0].toLowerCase(); if (subcommand.equals("give") && sender.hasPermission("blockships.give")) { - // Complete with ship_wheel and ship types from config - List types = new ArrayList<>(); - types.add("ship_wheel"); - var shipsSection = getConfig().getConfigurationSection("ships"); - if (shipsSection != null) { - types.addAll(shipsSection.getKeys(false)); - } - - for (String type : types) { + for (String type : getGiveableItemNames()) { if (type.toLowerCase().startsWith(args[1].toLowerCase())) { completions.add(type); } diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/ConfigValidator.java b/blockships/src/main/java/anon/def9a2a4/blockships/ConfigValidator.java index d479077..62005eb 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/ConfigValidator.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/ConfigValidator.java @@ -1,5 +1,7 @@ package anon.def9a2a4.blockships; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.java.JavaPlugin; import java.io.*; @@ -74,4 +76,40 @@ private static String readFile(File file) throws IOException { } return result.toString(StandardCharsets.UTF_8); } + + /** + * Adds missing config keys from the bundled JAR config into the user's config. + * Only adds keys that don't exist — never overwrites existing values. + * Controlled by the "auto-migrate-config" setting (default: true). + */ + public static void migrateConfig(JavaPlugin plugin) { + if (!plugin.getConfig().getBoolean("auto-migrate-config", true)) { + return; + } + + try (InputStream jarStream = plugin.getResource("config.yml")) { + if (jarStream == null) return; + + YamlConfiguration jarConfig = YamlConfiguration.loadConfiguration( + new InputStreamReader(jarStream, StandardCharsets.UTF_8)); + + var diskConfig = plugin.getConfig(); + int added = 0; + + for (String key : jarConfig.getKeys(true)) { + if (!jarConfig.isConfigurationSection(key) && !diskConfig.contains(key)) { + diskConfig.set(key, jarConfig.get(key)); + added++; + } + } + + if (added > 0) { + plugin.saveConfig(); + plugin.reloadConfig(); + plugin.getLogger().info("Config migration: added " + added + " new config key(s)"); + } + } catch (IOException e) { + plugin.getLogger().warning("Config migration failed: " + e.getMessage()); + } + } } diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/CustomItem.java b/blockships/src/main/java/anon/def9a2a4/blockships/CustomItem.java index 5231a77..ce5ac87 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/CustomItem.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/CustomItem.java @@ -23,19 +23,21 @@ public class CustomItem { private final Material baseMaterial; private final String textureSet; private final String variantSource; + private final boolean enchantGlint; private final Plugin plugin; private final ItemTextureManager textureManager; private final NamespacedKey itemIdKey; private final NamespacedKey variantKey; public CustomItem(String id, String displayNameTemplate, Material baseMaterial, - String textureSet, String variantSource, + String textureSet, String variantSource, boolean enchantGlint, Plugin plugin, ItemTextureManager textureManager) { this.id = id; this.displayNameTemplate = displayNameTemplate; this.baseMaterial = baseMaterial; this.textureSet = textureSet; this.variantSource = variantSource; + this.enchantGlint = enchantGlint; this.plugin = plugin; this.textureManager = textureManager; this.itemIdKey = new NamespacedKey(plugin, "custom_item_id"); @@ -89,6 +91,11 @@ public ItemStack create(String variant) { } } + // Apply enchantment glint + if (enchantGlint) { + meta.setEnchantmentGlintOverride(true); + } + item.setItemMeta(meta); return item; } diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/DisplayShip.java b/blockships/src/main/java/anon/def9a2a4/blockships/DisplayShip.java index a3fe37e..c785655 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/DisplayShip.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/DisplayShip.java @@ -15,6 +15,9 @@ import org.bukkit.event.Listener; import org.bukkit.event.block.Action; import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.EntityDeathEvent; @@ -356,6 +359,10 @@ public void shutdown() { ShipRegistry.clear(); } + public ItemFactory getItemFactory() { + return itemFactory; + } + public ItemTextureManager getTextureManager() { return textureManager; } @@ -928,57 +935,66 @@ public void onCraftShipKit(PrepareItemCraftEvent e) { String configPath = plugin.getConfig().contains("ships." + shipType) ? "ships." : "custom-items."; String recipePath = configPath + shipType + ".recipe"; - // Load recipe pattern and ingredients - List pattern = plugin.getConfig().getStringList(recipePath + ".pattern"); - if (pattern.isEmpty() || pattern.size() != 3) { - e.getInventory().setResult(null); - return; - } + boolean shapeless = plugin.getConfig().getBoolean(recipePath + ".shapeless", false); - // Build ingredient map - Map> ingredientMap = new HashMap<>(); - var ingredientsSection = plugin.getConfig().getConfigurationSection(recipePath + ".ingredients"); - if (ingredientsSection != null) { - for (String key : ingredientsSection.getKeys(false)) { - List ingredientStrings = plugin.getConfig().getStringList(recipePath + ".ingredients." + key); - try { - List ingredients = RecipeIngredient.parseList(ingredientStrings, plugin, this.textureManager); - ingredientMap.put(key.charAt(0), ingredients); - } catch (IllegalArgumentException ex) { - plugin.getLogger().warning("Failed to parse ingredient for " + shipType + ": " + ex.getMessage()); - e.getInventory().setResult(null); - return; - } + // For shapeless recipes, Bukkit already validated ingredient matching + // For shaped recipes, use RecipeValidator for variant extraction + String variant = null; + ItemStack banner = null; + String balloonColor = null; + + if (shapeless) { + // Shapeless: Bukkit handles validation. Extract banner if present. + banner = RecipeValidator.extractBanner(e.getInventory()); + } else { + // Shaped: full pattern-based validation + List pattern = plugin.getConfig().getStringList(recipePath + ".pattern"); + if (pattern.isEmpty() || pattern.size() != 3) { + e.getInventory().setResult(null); + return; } - } - // Validate crafting with RecipeValidator - RecipeValidator.ValidationResult validation = RecipeValidator.validateCrafting( - e.getInventory(), - pattern, - ingredientMap - ); + Map> ingredientMap = new HashMap<>(); + var ingredientsSection = plugin.getConfig().getConfigurationSection(recipePath + ".ingredients"); + if (ingredientsSection != null) { + for (String key : ingredientsSection.getKeys(false)) { + List ingredientStrings = plugin.getConfig().getStringList(recipePath + ".ingredients." + key); + try { + List ingredients = RecipeIngredient.parseList(ingredientStrings, plugin, this.textureManager); + ingredientMap.put(key.charAt(0), ingredients); + } catch (IllegalArgumentException ex) { + plugin.getLogger().warning("Failed to parse ingredient for " + shipType + ": " + ex.getMessage()); + e.getInventory().setResult(null); + return; + } + } + } - if (!validation.isValid()) { - e.getInventory().setResult(null); - return; - } + RecipeValidator.ValidationResult validation = RecipeValidator.validateCrafting( + e.getInventory(), + pattern, + ingredientMap + ); - // Extract banner (for ship customization) - ItemStack banner = RecipeValidator.extractBanner(e.getInventory()); + if (!validation.isValid()) { + e.getInventory().setResult(null); + return; + } - // Get primary variant (wood type, wool color, etc.) - String variant = validation.getPrimaryVariant(); + banner = RecipeValidator.extractBanner(e.getInventory()); + variant = validation.getPrimaryVariant(); - // For airships, extract balloon color from the crafting matrix - String balloonColor = null; - if (plugin.getConfig().getString("ships." + shipType + ".type", "").equals("airship")) { - balloonColor = extractBalloonColor(e.getInventory()); + if (plugin.getConfig().getString("ships." + shipType + ".type", "").equals("airship")) { + balloonColor = extractBalloonColor(e.getInventory()); + } } // Create item using unified ItemFactory ItemStack result; - if (plugin.getConfig().contains("custom-items." + shipType)) { + if ("captains_manual".equals(shipType)) { + // Captain's Manual: create a written book with help content + result = HelpBookContent.createWrittenBook(); + } else if (plugin.getConfig().contains("custom-items." + shipType)) { // Custom items result = itemFactory.createItem(shipType, variant, banner); } else { @@ -988,6 +1004,27 @@ public void onCraftShipKit(PrepareItemCraftEvent e) { e.getInventory().setResult(result); } + @EventHandler + public void onCraftNonConsumable(org.bukkit.event.inventory.CraftItemEvent event) { + if (!(event.getRecipe() instanceof Keyed keyed)) return; + if (!keyed.getKey().getNamespace().equals(plugin.getName().toLowerCase())) return; + String recipeKey = keyed.getKey().getKey(); + if (!recipeKey.equals("captains_manual_kit_recipe")) return; + + // Return the ship wheel to the player after crafting + for (ItemStack item : event.getInventory().getMatrix()) { + if (item != null && isShipWheel(item)) { + ItemStack wheelCopy = item.clone(); + wheelCopy.setAmount(1); + org.bukkit.entity.HumanEntity crafter = event.getWhoClicked(); + Bukkit.getScheduler().runTask(plugin, () -> { + crafter.getInventory().addItem(wheelCopy); + }); + break; + } + } + } + @EventHandler public void onUse(PlayerInteractEvent e) { if (e.getAction() != Action.RIGHT_CLICK_BLOCK && e.getAction() != Action.RIGHT_CLICK_AIR) return; @@ -1277,6 +1314,13 @@ private void handleShulkerInteraction(PlayerInteractEntityEvent e, Shulker shulk } } + // Check if this shulker is a ship engine - open fuel GUI + if (storageBlockIndex >= 0 && inst.model.engineBlockIndices.contains(storageBlockIndex)) { + anon.def9a2a4.blockships.customships.EngineMenuGUI.open(player, inst, storageBlockIndex); + e.setCancelled(true); + return; + } + // Check if this shulker has storage if (storageBlockIndex >= 0) { Inventory storage = inst.storages.get(storageBlockIndex); @@ -2164,6 +2208,135 @@ public void onShipWheelBreak(BlockBreakEvent event) { } } + // ===== Engine Menu GUI event handlers ===== + + @EventHandler + public void onEngineMenuClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof anon.def9a2a4.blockships.customships.EngineMenuGUI.EngineMenuHolder) + && !(event.getInventory().getHolder() instanceof anon.def9a2a4.blockships.customships.EngineMenuGUI.EngineBlockMenuHolder)) return; + + int slot = event.getRawSlot(); + // Allow fuel slot interactions, block everything else in the top inventory + if (slot >= 0 && slot < 9) { + if (!anon.def9a2a4.blockships.customships.EngineMenuGUI.isFuelSlot(slot)) { + event.setCancelled(true); + return; + } + // Validate fuel: if placing an item, check it's valid fuel + ItemStack cursor = event.getCursor(); + if (cursor != null && cursor.getType() != Material.AIR) { + if (!anon.def9a2a4.blockships.customships.EngineMenuGUI.isValidFuel(cursor.getType())) { + event.setCancelled(true); + } + } + } + // Block shift-clicks from player inventory that would move non-fuel items into engine GUI + if (event.isShiftClick() && slot >= 9) { + ItemStack clicked = event.getCurrentItem(); + if (clicked != null && !anon.def9a2a4.blockships.customships.EngineMenuGUI.isValidFuel(clicked.getType())) { + event.setCancelled(true); + } + } + } + + @EventHandler + public void onEngineMenuClose(InventoryCloseEvent event) { + if (event.getInventory().getHolder() instanceof anon.def9a2a4.blockships.customships.EngineMenuGUI.EngineMenuHolder holder) { + anon.def9a2a4.blockships.customships.EngineMenuGUI.saveFuelState(holder); + } else if (event.getInventory().getHolder() instanceof anon.def9a2a4.blockships.customships.EngineMenuGUI.EngineBlockMenuHolder blockHolder) { + anon.def9a2a4.blockships.customships.EngineMenuGUI.saveBlockFuelState(blockHolder); + } + } + + // ===== Ship Engine event handlers ===== + + /** + * Opens custom fuel GUI instead of vanilla blast furnace UI on placed ship engines. + */ + @EventHandler + public void onRightClickPlacedEngine(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK) return; + if (event.getClickedBlock() == null) return; + if (event.getClickedBlock().getType() != Material.BLAST_FURNACE) return; + if (!isShipEngine(event.getClickedBlock())) return; + + event.setCancelled(true); + anon.def9a2a4.blockships.customships.EngineMenuGUI.openForBlock(event.getPlayer(), event.getClickedBlock()); + } + + private static final String ENGINE_PDC_VALUE = "ship_engine"; + + /** + * Transfers PDC tag from a ship engine item to the placed block's TileState. + * Bukkit doesn't auto-transfer item PDC to blocks, so we do it manually. + */ + @EventHandler + public void onPlaceShipEngine(BlockPlaceEvent event) { + ItemStack item = event.getItemInHand(); + if (item.getType() != Material.BLAST_FURNACE || !item.hasItemMeta()) return; + + PersistentDataContainer itemPdc = item.getItemMeta().getPersistentDataContainer(); + NamespacedKey itemIdKey = new NamespacedKey(plugin, "custom_item_id"); + String itemId = itemPdc.get(itemIdKey, PersistentDataType.STRING); + if (!ENGINE_PDC_VALUE.equals(itemId)) return; + + // Transfer PDC to block TileState + Block block = event.getBlockPlaced(); + org.bukkit.block.BlockState state = block.getState(); + if (state instanceof org.bukkit.block.TileState tileState) { + tileState.getPersistentDataContainer().set(itemIdKey, PersistentDataType.STRING, ENGINE_PDC_VALUE); + tileState.update(); + } + } + + /** + * Prevents ship engines from burning fuel via vanilla smelting mechanics. + */ + @EventHandler + public void onEngineFurnaceBurn(FurnaceBurnEvent event) { + if (isShipEngine(event.getBlock())) { + event.setCancelled(true); + } + } + + /** + * Prevents ship engines from smelting items via vanilla mechanics. + */ + @EventHandler + public void onEngineFurnaceSmelt(FurnaceSmeltEvent event) { + if (isShipEngine(event.getBlock())) { + event.setCancelled(true); + } + } + + /** + * Drops the custom ship engine item when a player breaks an engine block. + * Without this, breaking drops a vanilla blast furnace (losing PDC tag and glint). + */ + @EventHandler + public void onBreakShipEngine(BlockBreakEvent event) { + Block block = event.getBlock(); + if (!isShipEngine(block)) return; + + event.setCancelled(true); + block.setType(Material.AIR); + block.getWorld().dropItemNaturally( + block.getLocation().add(0.5, 0.5, 0.5), + itemFactory.createItem("ship_engine", "_DEFAULT", null)); + } + + /** + * Checks if a block is a ship engine (blast furnace with engine PDC tag). + */ + private boolean isShipEngine(Block block) { + if (block.getType() != Material.BLAST_FURNACE) return false; + org.bukkit.block.BlockState state = block.getState(); + if (!(state instanceof org.bukkit.block.TileState tileState)) return false; + NamespacedKey itemIdKey = new NamespacedKey(plugin, "custom_item_id"); + return ENGINE_PDC_VALUE.equals( + tileState.getPersistentDataContainer().get(itemIdKey, PersistentDataType.STRING)); + } + /** * Records a shulker interaction timestamp for cooldown tracking. * Also cleans up stale entries to prevent memory leaks. diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/HelpBookContent.java b/blockships/src/main/java/anon/def9a2a4/blockships/HelpBookContent.java new file mode 100644 index 0000000..278a9f5 --- /dev/null +++ b/blockships/src/main/java/anon/def9a2a4/blockships/HelpBookContent.java @@ -0,0 +1,138 @@ +package anon.def9a2a4.blockships; + +import anon.def9a2a4.blockships.util.SteerPacketCompat; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; +import org.bukkit.plugin.Plugin; + +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Loads help book content from help_book.yml and creates written books. + * Shared by the ship wheel menu help button and the craftable Captain's Manual. + */ +public final class HelpBookContent { + + private static final int BOOK_LINES_PER_PAGE = 12; + private static final int BOOK_CHARS_PER_LINE = 20; + + private static String bookTitle = "Captain's Manual"; + private static String bookAuthor = "BlockShips"; + private static String[][] sections; + + private HelpBookContent() {} + + /** + * Loads help book content from the bundled help_book.yml resource. + * Must be called once during plugin startup. + */ + public static void load(Plugin plugin) { + var stream = plugin.getResource("help_book.yml"); + if (stream == null) { + plugin.getLogger().warning("help_book.yml not found in jar, using fallback help content"); + sections = new String[][] { + {"Controls", SteerPacketCompat.getAirshipControlsHelp()}, + {"Help", "Help book content could not be loaded."} + }; + return; + } + + YamlConfiguration config = YamlConfiguration.loadConfiguration(new InputStreamReader(stream)); + bookTitle = config.getString("title", "Captain's Manual"); + bookAuthor = config.getString("author", "BlockShips"); + + List sectionList = config.getList("sections"); + if (sectionList == null || sectionList.isEmpty()) { + sections = new String[][] {{"Help", "No sections defined."}}; + return; + } + + List parsed = new ArrayList<>(); + for (Object entry : sectionList) { + if (!(entry instanceof Map map)) continue; + String title = String.valueOf(map.get("title")); + Object contentObj = map.get("content"); + // null content = dynamic (controls text varies by server version) + String content = contentObj != null + ? String.valueOf(contentObj) + : SteerPacketCompat.getAirshipControlsHelp(); + parsed.add(new String[] {title, content}); + } + + sections = parsed.toArray(new String[0][]); + } + + /** + * Returns the loaded help sections as a String[][] array. + * Each entry is {title, content}. + */ + public static String[][] getSections() { + if (sections == null) { + // Fallback if load() wasn't called + return new String[][] { + {"Controls", SteerPacketCompat.getAirshipControlsHelp()}, + {"Help", "Help content not loaded."} + }; + } + return sections; + } + + /** + * Creates a written book ItemStack with all help content paginated. + */ + public static ItemStack createWrittenBook() { + ItemStack book = new ItemStack(Material.WRITTEN_BOOK); + BookMeta meta = (BookMeta) book.getItemMeta(); + if (meta == null) return book; + + meta.setTitle(bookTitle); + meta.setAuthor(bookAuthor); + + StringBuilder currentPage = new StringBuilder(); + int currentLines = 0; + + for (int i = 0; i < sections.length; i++) { + String title = sections[i][0]; + String content = sections[i][1]; + int sectionLines = estimateSectionLines(content); + + // Check if this section fits on current page + if (currentLines > 0 && currentLines + sectionLines > BOOK_LINES_PER_PAGE) { + currentPage.append(ChatColor.GRAY).append(ChatColor.ITALIC).append("(next page >>>)"); + meta.addPage(currentPage.toString()); + currentPage = new StringBuilder(); + currentLines = 0; + } + + currentPage.append(ChatColor.DARK_BLUE).append(ChatColor.BOLD).append(title).append("\n"); + currentPage.append(ChatColor.BLACK).append(content).append("\n\n"); + currentLines += sectionLines; + } + + if (currentPage.length() > 0) { + meta.addPage(currentPage.toString()); + } + + book.setItemMeta(meta); + return book; + } + + /** + * Opens the help book for the player (used by the ship wheel menu help button). + */ + public static void openBook(Player player) { + player.openBook(createWrittenBook()); + } + + private static int estimateSectionLines(String content) { + int contentLines = (int) Math.ceil((double) content.length() / BOOK_CHARS_PER_LINE); + return 1 + contentLines + 1; // title + content + blank line + } +} diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/ItemFactory.java b/blockships/src/main/java/anon/def9a2a4/blockships/ItemFactory.java index 6ebd7c4..4ce1e62 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/ItemFactory.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/ItemFactory.java @@ -92,8 +92,9 @@ private CustomItem loadCustomItem(String itemId) { Material baseMaterial = Material.valueOf(baseMaterialStr.toUpperCase()); String textureSet = plugin.getConfig().getString(configPath + ".texture-set"); String variantSource = plugin.getConfig().getString(configPath + ".variant-source"); + boolean enchantGlint = plugin.getConfig().getBoolean(configPath + ".enchant-glint", false); - return new CustomItem(itemId, displayName, baseMaterial, textureSet, variantSource, plugin, textureManager); + return new CustomItem(itemId, displayName, baseMaterial, textureSet, variantSource, enchantGlint, plugin, textureManager); } /** diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/ItemUtil.java b/blockships/src/main/java/anon/def9a2a4/blockships/ItemUtil.java index 55e593a..b9b062a 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/ItemUtil.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/ItemUtil.java @@ -4,6 +4,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.Recipe; import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.ShapelessRecipe; import org.bukkit.inventory.RecipeChoice; import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.entity.Player; @@ -81,20 +82,11 @@ public static boolean registerItemRecipe(Plugin plugin, String itemType, String String recipePath = configPath + ".recipe"; if (!plugin.getConfig().contains(recipePath)) return false; - // Get recipe pattern - List pattern = plugin.getConfig().getStringList(recipePath + ".pattern"); - if (pattern.isEmpty() || pattern.size() != 3) { - plugin.getLogger().warning("Invalid recipe pattern for " + itemType); - return false; - } + boolean shapeless = plugin.getConfig().getBoolean(recipePath + ".shapeless", false); // Create a placeholder kit item (actual customization happens in PrepareItemCraftEvent) ItemStack kit = itemFactory.createItemForRecipe(itemType); - - // Create recipe NamespacedKey recipeKey = new NamespacedKey(plugin, itemType + "_kit_recipe"); - ShapedRecipe recipe = new ShapedRecipe(recipeKey, kit); - recipe.shape(pattern.get(0), pattern.get(1), pattern.get(2)); // Get ingredients using RecipeIngredient system var ingredientsSection = plugin.getConfig().getConfigurationSection(recipePath + ".ingredients"); @@ -103,37 +95,55 @@ public static boolean registerItemRecipe(Plugin plugin, String itemType, String return false; } - for (String key : ingredientsSection.getKeys(false)) { - List ingredientStrings = plugin.getConfig().getStringList(recipePath + ".ingredients." + key); - - if (ingredientStrings.isEmpty()) { - plugin.getLogger().warning("No ingredients specified for key '" + key + "' in " + itemType); - continue; + // Get texture manager for custom item ingredients + ItemTextureManager textureManager = ((anon.def9a2a4.blockships.BlockShipsPlugin) plugin) + .getDisplayShip().getTextureManager(); + + if (shapeless) { + // Shapeless recipe: ingredients go in any slot + ShapelessRecipe recipe = new ShapelessRecipe(recipeKey, kit); + for (String key : ingredientsSection.getKeys(false)) { + List ingredientStrings = plugin.getConfig().getStringList(recipePath + ".ingredients." + key); + if (ingredientStrings.isEmpty()) continue; + try { + List ingredients = RecipeIngredient.parseList(ingredientStrings, plugin, textureManager); + if (!ingredients.isEmpty()) { + recipe.addIngredient(ingredients.get(0).getRecipeChoice()); + } + } catch (IllegalArgumentException e) { + plugin.getLogger().warning("Failed to parse ingredient '" + key + "' for " + itemType + ": " + e.getMessage()); + } + } + Bukkit.addRecipe(recipe, true); + } else { + // Shaped recipe: needs pattern + List pattern = plugin.getConfig().getStringList(recipePath + ".pattern"); + if (pattern.isEmpty() || pattern.size() != 3) { + plugin.getLogger().warning("Invalid recipe pattern for " + itemType); + return false; } - try { - // Get texture manager for custom item ingredients - ItemTextureManager textureManager = ((anon.def9a2a4.blockships.BlockShipsPlugin) plugin) - .getDisplayShip().getTextureManager(); - - // Parse ingredients using RecipeIngredient system - List ingredients = RecipeIngredient.parseList(ingredientStrings, plugin, textureManager); + ShapedRecipe recipe = new ShapedRecipe(recipeKey, kit); + recipe.shape(pattern.get(0), pattern.get(1), pattern.get(2)); - if (ingredients.isEmpty()) { - plugin.getLogger().warning("No valid ingredients for key '" + key + "' in " + itemType); + for (String key : ingredientsSection.getKeys(false)) { + List ingredientStrings = plugin.getConfig().getStringList(recipePath + ".ingredients." + key); + if (ingredientStrings.isEmpty()) { + plugin.getLogger().warning("No ingredients specified for key '" + key + "' in " + itemType); continue; } - - // Use the first ingredient's recipe choice for Bukkit registration - RecipeChoice choice = ingredients.get(0).getRecipeChoice(); - recipe.setIngredient(key.charAt(0), choice); - - } catch (IllegalArgumentException e) { - plugin.getLogger().warning("Failed to parse ingredient '" + key + "' for " + itemType + ": " + e.getMessage()); + try { + List ingredients = RecipeIngredient.parseList(ingredientStrings, plugin, textureManager); + if (!ingredients.isEmpty()) { + recipe.setIngredient(key.charAt(0), ingredients.get(0).getRecipeChoice()); + } + } catch (IllegalArgumentException e) { + plugin.getLogger().warning("Failed to parse ingredient '" + key + "' for " + itemType + ": " + e.getMessage()); + } } + Bukkit.addRecipe(recipe, true); } - Bukkit.addRecipe(recipe, true); registeredRecipes.add(recipeKey); return true; } diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/RecipeIngredient.java b/blockships/src/main/java/anon/def9a2a4/blockships/RecipeIngredient.java index edb2a1c..0bfafa8 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/RecipeIngredient.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/RecipeIngredient.java @@ -189,7 +189,7 @@ private CustomItem loadCustomItem() { String textureSet = plugin.getConfig().getString(configPath + ".texture-set"); String variantSource = plugin.getConfig().getString(configPath + ".variant-source"); - return new CustomItem(customItemId, displayName, baseMaterial, textureSet, variantSource, plugin, textureManager); + return new CustomItem(customItemId, displayName, baseMaterial, textureSet, variantSource, false, plugin, textureManager); } @Override diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/ShipConfig.java b/blockships/src/main/java/anon/def9a2a4/blockships/ShipConfig.java index 015ce2b..0f202d9 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/ShipConfig.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/ShipConfig.java @@ -44,6 +44,30 @@ public class ShipConfig { public final float verticalDrag; public final float verticalForwardNudge; + // Ship stats (power-to-mass ratio system, custom ships only) + public final int basePower; // Free power points every ship gets (default: 2) + public final int enginePower; // Power points per fueled engine (default: 30) + public final float sailCapRatio; // Sail contribution capped at this ratio (default: 0.8) + public final float defaultRatio; // Ratio that maps to current default stats (default: 0.7) + public final float maxRatioMultiplier; // Stats multiplier at ratio 1.0, relative to default (default: 1.5) + // Absolute floors (minimum stat values regardless of ratio) + public final float floorMaxSpeed; // 0.05 blocks/tick = 1 block/sec + public final float floorAcceleration; + public final float floorRotationSpeed; // 0.6 deg/tick = 30s per revolution + public final float floorRotationAcceleration; + // Absolute caps (maximum stat values regardless of ratio) + public final float capMaxSpeed; + public final float capAcceleration; + public final float capRotationSpeed; + public final float capRotationAcceleration; + // Airship vertical stats scaling + public final float verticalDensityScale; // How much density magnitude affects vertical ratio + public final float verticalEngineScale; // How much engine_points/mass affects vertical ratio + public final float floorMaxVerticalSpeed; + public final float floorVerticalAcceleration; + public final float capMaxVerticalSpeed; + public final float capVerticalAcceleration; + // Collision physics public final float shipMass; public final float collisionResponseStrength; @@ -105,6 +129,25 @@ private ShipConfig(Builder b) { this.maxVerticalSpeed = b.maxVerticalSpeed; this.verticalDrag = b.verticalDrag; this.verticalForwardNudge = b.verticalForwardNudge; + this.basePower = b.basePower; + this.enginePower = b.enginePower; + this.sailCapRatio = b.sailCapRatio; + this.defaultRatio = b.defaultRatio; + this.maxRatioMultiplier = b.maxRatioMultiplier; + this.floorMaxSpeed = b.floorMaxSpeed; + this.floorAcceleration = b.floorAcceleration; + this.floorRotationSpeed = b.floorRotationSpeed; + this.floorRotationAcceleration = b.floorRotationAcceleration; + this.capMaxSpeed = b.capMaxSpeed; + this.capAcceleration = b.capAcceleration; + this.capRotationSpeed = b.capRotationSpeed; + this.capRotationAcceleration = b.capRotationAcceleration; + this.verticalDensityScale = b.verticalDensityScale; + this.verticalEngineScale = b.verticalEngineScale; + this.floorMaxVerticalSpeed = b.floorMaxVerticalSpeed; + this.floorVerticalAcceleration = b.floorVerticalAcceleration; + this.capMaxVerticalSpeed = b.capMaxVerticalSpeed; + this.capVerticalAcceleration = b.capVerticalAcceleration; this.shipMass = b.shipMass; this.collisionResponseStrength = b.collisionResponseStrength; this.terrainCollisionStrength = b.terrainCollisionStrength; @@ -225,9 +268,51 @@ public static ShipConfig load(Plugin plugin, String shipType) { // Camera distance (for prefab ships; custom ships use per-ship value from ShipWheelData) .cameraDistance((float) cfg.getDouble(p + "camera-distance", 4.0)) .assemblyNudgeHeight((float) cfg.getDouble("custom-ships.assembly-nudge-height", 0.2)) + // Ship stats (power-to-mass ratio system) + .basePower(cfg.getInt("custom-ships.stats.base-power", 2)) + .enginePower(cfg.getInt("custom-ships.stats.engine-power", 30)) + .sailCapRatio((float) cfg.getDouble("custom-ships.stats.sail-cap-ratio", 0.8)) + .defaultRatio((float) cfg.getDouble("custom-ships.stats.default-ratio", 0.7)) + .maxRatioMultiplier((float) cfg.getDouble("custom-ships.stats.max-ratio-multiplier", 1.5)) + .floorMaxSpeed((float) cfg.getDouble("custom-ships.stats.floor-max-speed", 0.05)) + .floorAcceleration((float) cfg.getDouble("custom-ships.stats.floor-acceleration", 0.015)) + .floorRotationSpeed((float) cfg.getDouble("custom-ships.stats.floor-rotation-speed", 0.6)) + .floorRotationAcceleration((float) cfg.getDouble("custom-ships.stats.floor-rotation-acceleration", 0.05)) + .capMaxSpeed((float) cfg.getDouble("custom-ships.stats.cap-max-speed", -1)) + .capAcceleration((float) cfg.getDouble("custom-ships.stats.cap-acceleration", -1)) + .capRotationSpeed((float) cfg.getDouble("custom-ships.stats.cap-rotation-speed", -1)) + .capRotationAcceleration((float) cfg.getDouble("custom-ships.stats.cap-rotation-acceleration", -1)) + .verticalDensityScale((float) cfg.getDouble("custom-ships.stats.vertical-density-scale", 0.3)) + .verticalEngineScale((float) cfg.getDouble("custom-ships.stats.vertical-engine-scale", 0.01)) + .floorMaxVerticalSpeed((float) cfg.getDouble("custom-ships.stats.floor-max-vertical-speed", 0.03)) + .floorVerticalAcceleration((float) cfg.getDouble("custom-ships.stats.floor-vertical-acceleration", 0.01)) + .capMaxVerticalSpeed((float) cfg.getDouble("custom-ships.stats.cap-max-vertical-speed", 0.5)) + .capVerticalAcceleration((float) cfg.getDouble("custom-ships.stats.cap-vertical-acceleration", 0.1)) .build(); } + /** + * Computes an effective stat value from a power-to-mass ratio using linear interpolation. + * ratio 0.0 → floor, ratio defaultRatio → defaultVal, ratio 1.0 → cap. + * Result is clamped to [floor, cap]. + */ + public float computeStat(float ratio, float defaultVal, float floor, float configCap) { + float cap = configCap > 0 ? configCap : defaultVal * maxRatioMultiplier; + float stat; + if (ratio <= defaultRatio) { + // Interpolate floor → default over ratio 0.0 → defaultRatio + float t = defaultRatio > 0 ? ratio / defaultRatio : 0; + stat = floor + t * (defaultVal - floor); + } else if (defaultRatio < 1.0f) { + // Interpolate default → cap over ratio defaultRatio → 1.0 + float t = (ratio - defaultRatio) / (1.0f - defaultRatio); + stat = defaultVal + t * (cap - defaultVal); + } else { + stat = cap; + } + return Math.max(floor, Math.min(cap, stat)); + } + private static class Builder { boolean collisionDebugGlow = false; float maxSpeed = 0.5f; @@ -254,6 +339,27 @@ private static class Builder { float maxVerticalSpeed = 0.3f; float verticalDrag = 0.9f; float verticalForwardNudge = 0.011f; + // Ship stats defaults + int basePower = 2; + int enginePower = 30; + float sailCapRatio = 0.8f; + float defaultRatio = 0.7f; + float maxRatioMultiplier = 1.5f; + float floorMaxSpeed = 0.05f; // 1 block/sec + float floorAcceleration = 0.015f; + float floorRotationSpeed = 0.6f; // 30s per revolution + float floorRotationAcceleration = 0.05f; + float capMaxSpeed = -1f; // -1 = auto (maxRatioMultiplier * default) + float capAcceleration = -1f; + float capRotationSpeed = -1f; + float capRotationAcceleration = -1f; + float verticalDensityScale = 0.3f; + float verticalEngineScale = 0.01f; + float floorMaxVerticalSpeed = 0.03f; + float floorVerticalAcceleration = 0.01f; + float capMaxVerticalSpeed = 0.5f; + float capVerticalAcceleration = 0.1f; + float shipMass = 100.0f; float collisionResponseStrength = 0.3f; float terrainCollisionStrength = 1.0f; @@ -303,6 +409,25 @@ private static class Builder { Builder maxVerticalSpeed(float v) { maxVerticalSpeed = v; return this; } Builder verticalDrag(float v) { verticalDrag = v; return this; } Builder verticalForwardNudge(float v) { verticalForwardNudge = v; return this; } + Builder basePower(int v) { basePower = v; return this; } + Builder enginePower(int v) { enginePower = v; return this; } + Builder sailCapRatio(float v) { sailCapRatio = v; return this; } + Builder defaultRatio(float v) { defaultRatio = v; return this; } + Builder maxRatioMultiplier(float v) { maxRatioMultiplier = v; return this; } + Builder floorMaxSpeed(float v) { floorMaxSpeed = v; return this; } + Builder floorAcceleration(float v) { floorAcceleration = v; return this; } + Builder floorRotationSpeed(float v) { floorRotationSpeed = v; return this; } + Builder floorRotationAcceleration(float v) { floorRotationAcceleration = v; return this; } + Builder capMaxSpeed(float v) { capMaxSpeed = v; return this; } + Builder capAcceleration(float v) { capAcceleration = v; return this; } + Builder capRotationSpeed(float v) { capRotationSpeed = v; return this; } + Builder capRotationAcceleration(float v) { capRotationAcceleration = v; return this; } + Builder verticalDensityScale(float v) { verticalDensityScale = v; return this; } + Builder verticalEngineScale(float v) { verticalEngineScale = v; return this; } + Builder floorMaxVerticalSpeed(float v) { floorMaxVerticalSpeed = v; return this; } + Builder floorVerticalAcceleration(float v) { floorVerticalAcceleration = v; return this; } + Builder capMaxVerticalSpeed(float v) { capMaxVerticalSpeed = v; return this; } + Builder capVerticalAcceleration(float v) { capVerticalAcceleration = v; return this; } Builder shipMass(float v) { shipMass = v; return this; } Builder collisionResponseStrength(float v) { collisionResponseStrength = v; return this; } Builder terrainCollisionStrength(float v) { terrainCollisionStrength = v; return this; } diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/ShipModel.java b/blockships/src/main/java/anon/def9a2a4/blockships/ShipModel.java index b5eedce..3f3e46d 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/ShipModel.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/ShipModel.java @@ -37,7 +37,8 @@ private static BlockFace safeBlockFace(Map map, String key, BlockFace defa public final float waterFloatOffset; // Y-position offset from water surface where ship floats (for prefab ships) // Buoyancy system (for custom block ships) - public final int totalWeight; // Sum of all block weights (excluding null-weight blocks) + public final int totalWeight; // Sum of all block weights, including negative (for density/buoyancy) + public final int mass; // Sum of max(0, weight) per block (for health and power ratio) public final int blockCount; // Number of blocks with weight (for density calculation) public final Vector3f centerOfVolume; // Geometric center of all blocks (relative to wheel) public final float minY; // Bottom of ship (relative to origin) @@ -47,13 +48,23 @@ private static BlockFace safeBlockFace(Map map, String key, BlockFace defa public final double maxHealth; // Maximum health points for the ship public final double healthRegenPerSecond; // Health regeneration rate per second + // Ship stats (power-to-mass ratio system) + public final int woolCount; // Number of wool blocks + public final int bannerCount; // Number of banner blocks + public final int sailPower; // Sail power points (wool*3 + banner*7) + public final int engineCount; // Number of ship engine blocks detected + public final List engineBlockIndices; // Block indices that are engines (for click detection + fuel) + public final List engineLocalPositions; // Local positions of engines (for particles) + // Assembly rotation (for custom block ships disassembly) public final float assemblyYaw; // Yaw angle when assembled (0=S, 90=W, 180=N, 270=E), 0 for prefab ships public ShipModel(List parts, List items, Vector3f initialRotation, Vector3f positionOffset, Vector3f collisionOffset, Matrix3f rotationTransform, List seats, List cannons, float waterFloatOffset, double maxHealth, double healthRegenPerSecond, - int totalWeight, int blockCount, Vector3f centerOfVolume, float minY, float maxY, float assemblyYaw) { + int totalWeight, int mass, int blockCount, Vector3f centerOfVolume, float minY, float maxY, float assemblyYaw, + int woolCount, int bannerCount, int engineCount, + List engineBlockIndices, List engineLocalPositions) { this.parts = parts; this.items = items; this.initialRotation = initialRotation; @@ -66,11 +77,18 @@ public ShipModel(List parts, List items, Vector3f initialRo this.maxHealth = maxHealth; this.healthRegenPerSecond = healthRegenPerSecond; this.totalWeight = totalWeight; + this.mass = mass; this.blockCount = blockCount; this.centerOfVolume = centerOfVolume; this.minY = minY; this.maxY = maxY; this.assemblyYaw = assemblyYaw; + this.woolCount = woolCount; + this.bannerCount = bannerCount; + this.sailPower = woolCount * 3 + bannerCount * 7; + this.engineCount = engineCount; + this.engineBlockIndices = engineBlockIndices != null ? engineBlockIndices : Collections.emptyList(); + this.engineLocalPositions = engineLocalPositions != null ? engineLocalPositions : Collections.emptyList(); } /** @@ -81,6 +99,17 @@ public float getDensity() { return (float) totalWeight / blockCount; } + /** + * Calculates the ship's power-to-mass ratio for stat scaling. + * Uses sail power only (engines are dynamic and added at runtime). + * @param basePower Free power points every ship gets + * @return The ratio (0.0 to ~1.0+), before sail cap is applied + */ + public float getSailRatio(int basePower) { + if (mass <= 0) return 0; + return (float) (basePower + sailPower) / mass; + } + /** * Calculates the surface offset based on density compared to water. * @param waterDensity The reference density of water @@ -377,10 +406,11 @@ public static ShipModel fromFile(JavaPlugin plugin, String filePath, String ship // Prefab ships don't use weight-based buoyancy - they use waterFloatOffset from YAML // Set weight/blockCount to 0, centerOfVolume to origin, minY/maxY to 0, and assemblyYaw to 0 (prefab ships don't rotate on assembly) - // Prefab ships have no cannons (empty list) + // Prefab ships have no cannons (empty list) and no sail counting (stats come from config) return new ShipModel(out, items, initialRotation, positionOffset, collisionOffset, rotationTransform, seats, new ArrayList<>(), waterFloatOffset, maxHealth, healthRegenPerSecond, - 0, 0, new Vector3f(0, 0, 0), 0f, 0f, 0f); + 0, 0, 0, new Vector3f(0, 0, 0), 0f, 0f, 0f, + 0, 0, 0, null, null); } private static Matrix4f matrixFromMinecraftNbt(final float[] a) { @@ -584,9 +614,21 @@ public Map toMap() { // Buoyancy/physics data map.put("assembly_yaw", assemblyYaw); map.put("total_weight", totalWeight); + map.put("mass", mass); map.put("block_count", blockCount); map.put("min_y", minY); map.put("max_y", maxY); + map.put("wool_count", woolCount); + map.put("banner_count", bannerCount); + map.put("engine_count", engineCount); + if (!engineBlockIndices.isEmpty()) { + map.put("engine_block_indices", new ArrayList<>(engineBlockIndices)); + List> positions = new ArrayList<>(); + for (Vector3f pos : engineLocalPositions) { + positions.add(Arrays.asList(pos.x, pos.y, pos.z)); + } + map.put("engine_local_positions", positions); + } map.put("center_of_volume", Arrays.asList(centerOfVolume.x, centerOfVolume.y, centerOfVolume.z)); // Serialize parts - include transformation matrix (not in rawYaml for custom ships) @@ -738,10 +780,35 @@ public static ShipModel fromMap(Map map) { double maxHealth = 40.0; double healthRegenPerSecond = 2.0; + // Ship stats + int mass = map.containsKey("mass") ? ((Number) map.get("mass")).intValue() + : map.containsKey("total_positive_weight") ? ((Number) map.get("total_positive_weight")).intValue() : 0; + int woolCount = map.containsKey("wool_count") ? ((Number) map.get("wool_count")).intValue() : 0; + int bannerCount = map.containsKey("banner_count") ? ((Number) map.get("banner_count")).intValue() : 0; + int engineCount = map.containsKey("engine_count") ? ((Number) map.get("engine_count")).intValue() : 0; + + // Deserialize engine block indices and positions + List engineBlockIndices = new ArrayList<>(); + List engineLocalPositions = new ArrayList<>(); + if (map.containsKey("engine_block_indices")) { + @SuppressWarnings("unchecked") + List indices = (List) map.get("engine_block_indices"); + for (Number idx : indices) engineBlockIndices.add(idx.intValue()); + } + if (map.containsKey("engine_local_positions")) { + @SuppressWarnings("unchecked") + List> positions = (List>) map.get("engine_local_positions"); + for (List pos : positions) { + engineLocalPositions.add(new Vector3f( + pos.get(0).floatValue(), pos.get(1).floatValue(), pos.get(2).floatValue())); + } + } + return new ShipModel(parts, new ArrayList<>(), initialRotation, positionOffset, collisionOffset, rotationTransform, seats, cannons, waterFloatOffset, - maxHealth, healthRegenPerSecond, totalWeight, blockCount, - centerOfVolume, minY, maxY, assemblyYaw); + maxHealth, healthRegenPerSecond, totalWeight, mass, blockCount, + centerOfVolume, minY, maxY, assemblyYaw, + woolCount, bannerCount, engineCount, engineBlockIndices, engineLocalPositions); } } diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/customships/BlockStructureScanner.java b/blockships/src/main/java/anon/def9a2a4/blockships/customships/BlockStructureScanner.java index a0981ae..86932bf 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/customships/BlockStructureScanner.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/customships/BlockStructureScanner.java @@ -10,6 +10,7 @@ import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.Registry; +import org.bukkit.Tag; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.data.BlockData; @@ -307,10 +308,17 @@ public static ShipModel scanStructure(Location wheelLocation, BlockFace facing) // Track weight and center of volume (only for blocks with weight) int totalWeight = 0; - int totalPositiveWeight = 0; // For health calculation: sum of max(0, weight) + int totalMass = 0; // Sum of max(0, weight) per block — used for health and power ratio int weightedBlockCount = 0; float sumX = 0, sumY = 0, sumZ = 0; + // Track sail blocks and engines for ship stats (power-to-mass ratio) + int woolCount = 0; + int bannerCount = 0; + int engineCount = 0; + List engineBlockIndices = new ArrayList<>(); + List engineLocalPositions = new ArrayList<>(); + // Track ship bounds (for all blocks) float minY = Float.MAX_VALUE; float maxY = Float.MIN_VALUE; @@ -359,7 +367,7 @@ public static ShipModel scanStructure(Location wheelLocation, BlockFace facing) int weight = props.getWeight(); totalWeight += weight; if (weight > 0) { - totalPositiveWeight += weight; + totalMass += weight; } weightedBlockCount++; sumX += (float) dx; @@ -367,6 +375,29 @@ public static ShipModel scanStructure(Location wheelLocation, BlockFace facing) sumZ += (float) dz; } + // Count sail blocks and engines for ship stats + boolean isEngine = false; + Material blockMaterial = block.getType(); + if (Tag.WOOL.isTagged(blockMaterial)) { + woolCount++; + } else if (blockMaterial.name().contains("BANNER")) { + bannerCount++; + } else if (blockMaterial == Material.BLAST_FURNACE && plugin != null) { + // Check PDC for ship engine tag + org.bukkit.block.BlockState blockState = block.getState(); + if (blockState instanceof org.bukkit.block.TileState tileState) { + NamespacedKey engineKey = new NamespacedKey(plugin, "custom_item_id"); + String val = tileState.getPersistentDataContainer() + .get(engineKey, org.bukkit.persistence.PersistentDataType.STRING); + if ("ship_engine".equals(val)) { + isEngine = true; + engineCount++; + engineBlockIndices.add(blockIndex); + engineLocalPositions.add(new Vector3f((float) dx, (float) dy, (float) dz)); + } + } + } + // Store position to block index mapping (for finding driver seat block) String posKey = (int)dx + "," + (int)dy + "," + (int)dz; positionToBlockIndex.put(posKey, blockIndex); @@ -391,6 +422,9 @@ public static ShipModel scanStructure(Location wheelLocation, BlockFace facing) // Create raw YAML map (for compatibility) Map rawYaml = new HashMap<>(); + if (isEngine) { + rawYaml.put("is_engine", true); + } // Check for storage blocks (chests, furnaces, hoppers, etc.) ShipModel.StorageConfig storage = null; @@ -536,7 +570,7 @@ public static ShipModel scanStructure(Location wheelLocation, BlockFace facing) // Calculate health from positive block weights (heavier blocks = more health) // Blocks with negative/zero weight don't reduce health, just contribute nothing - double maxHealth = Math.min(1024.0, Math.max(1.0, totalPositiveWeight)); + double maxHealth = Math.min(1024.0, Math.max(1.0, totalMass)); // TODO: healthRegenPerSecond may be modified in the future by properties of the ship double healthRegenPerSecond = 1.0; @@ -580,11 +614,17 @@ public static ShipModel scanStructure(Location wheelLocation, BlockFace facing) maxHealth, healthRegenPerSecond, totalWeight, + totalMass, weightedBlockCount, // Only count blocks with weight for density centerOfVolume, minY, maxY, - assemblyYaw // Store for disassembly rotation calculation + assemblyYaw, // Store for disassembly rotation calculation + woolCount, + bannerCount, + engineCount, + engineBlockIndices, + engineLocalPositions ); } @@ -774,6 +814,20 @@ public static boolean placeBlocks(Location wheelLocation, ShipModel model, float } } + // Restore engine PDC tag on blast furnaces + if (Boolean.TRUE.equals(part.rawYaml.get("is_engine"))) { + org.bukkit.block.BlockState state = block.getState(); + if (state instanceof org.bukkit.block.TileState tileState) { + BlockShipsPlugin bsPlugin = (BlockShipsPlugin) org.bukkit.Bukkit.getPluginManager().getPlugin("BlockShips"); + if (bsPlugin != null) { + NamespacedKey engineKey = new NamespacedKey(bsPlugin, "custom_item_id"); + tileState.getPersistentDataContainer().set(engineKey, + org.bukkit.persistence.PersistentDataType.STRING, "ship_engine"); + tileState.update(); + } + } + } + // Restore container inventories // NOTE: Must get a fresh BlockState AFTER setBlockData, and set inventory contents // on the snapshot BEFORE calling update(), otherwise the inventory is cleared. diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/customships/EngineMenuGUI.java b/blockships/src/main/java/anon/def9a2a4/blockships/customships/EngineMenuGUI.java new file mode 100644 index 0000000..c2c02a2 --- /dev/null +++ b/blockships/src/main/java/anon/def9a2a4/blockships/customships/EngineMenuGUI.java @@ -0,0 +1,310 @@ +package anon.def9a2a4.blockships.customships; + +import anon.def9a2a4.blockships.ship.ShipInstance; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.Arrays; +import java.util.Set; + +/** + * Custom inventory GUI for managing ship engine fuel. + * Each engine has 3 fuel slots. Only valid furnace fuels are accepted. + */ +public class EngineMenuGUI { + + /** Materials that can be used as fuel in ship engines (matches vanilla furnace fuels). */ + private static final Set VALID_FUELS = Set.of( + Material.COAL, Material.CHARCOAL, Material.COAL_BLOCK, + Material.OAK_LOG, Material.SPRUCE_LOG, Material.BIRCH_LOG, Material.JUNGLE_LOG, + Material.ACACIA_LOG, Material.DARK_OAK_LOG, Material.MANGROVE_LOG, Material.CHERRY_LOG, + Material.OAK_PLANKS, Material.SPRUCE_PLANKS, Material.BIRCH_PLANKS, Material.JUNGLE_PLANKS, + Material.ACACIA_PLANKS, Material.DARK_OAK_PLANKS, Material.MANGROVE_PLANKS, Material.CHERRY_PLANKS, + Material.BAMBOO_PLANKS, + Material.STICK, Material.BAMBOO, + Material.LAVA_BUCKET, Material.BLAZE_ROD, + Material.DRIED_KELP_BLOCK, + Material.OAK_SLAB, Material.SPRUCE_SLAB, Material.BIRCH_SLAB, Material.JUNGLE_SLAB, + Material.ACACIA_SLAB, Material.DARK_OAK_SLAB, Material.MANGROVE_SLAB, Material.CHERRY_SLAB, + Material.BAMBOO_SLAB, + Material.BOOKSHELF, Material.CRAFTING_TABLE, Material.NOTE_BLOCK, Material.JUKEBOX, + Material.CHEST, Material.TRAPPED_CHEST, Material.BARREL, + Material.LECTERN, Material.COMPOSTER, Material.CARTOGRAPHY_TABLE, + Material.FLETCHING_TABLE, Material.SMITHING_TABLE, Material.LOOM, + Material.BOW, Material.CROSSBOW, Material.FISHING_ROD, + Material.WOODEN_SWORD, Material.WOODEN_PICKAXE, Material.WOODEN_AXE, Material.WOODEN_SHOVEL, Material.WOODEN_HOE, + Material.OAK_FENCE, Material.SPRUCE_FENCE, Material.BIRCH_FENCE, Material.JUNGLE_FENCE, + Material.ACACIA_FENCE, Material.DARK_OAK_FENCE, Material.MANGROVE_FENCE, Material.CHERRY_FENCE, + Material.OAK_FENCE_GATE, Material.SPRUCE_FENCE_GATE, Material.BIRCH_FENCE_GATE, Material.JUNGLE_FENCE_GATE, + Material.ACACIA_FENCE_GATE, Material.DARK_OAK_FENCE_GATE, Material.MANGROVE_FENCE_GATE, Material.CHERRY_FENCE_GATE + ); + + /** Fuel slot indices in the 9-slot GUI. */ + public static final int[] FUEL_SLOTS = {1, 2, 3}; + /** Status display item slot. */ + public static final int STATUS_SLOT = 5; + + /** + * Custom holder to store engine context. + */ + public static class EngineMenuHolder implements InventoryHolder { + private final ShipInstance ship; + private final int engineBlockIndex; + private Inventory inventory; + + public EngineMenuHolder(ShipInstance ship, int engineBlockIndex) { + this.ship = ship; + this.engineBlockIndex = engineBlockIndex; + } + + public ShipInstance getShip() { return ship; } + public int getEngineBlockIndex() { return engineBlockIndex; } + + @Override + public Inventory getInventory() { return inventory; } + public void setInventory(Inventory inv) { this.inventory = inv; } + } + + /** + * Opens the engine fuel GUI for a specific engine on a ship. + */ + public static void open(org.bukkit.entity.Player player, ShipInstance ship, int engineBlockIndex) { + EngineMenuHolder holder = new EngineMenuHolder(ship, engineBlockIndex); + Inventory gui = Bukkit.createInventory(holder, 9, ChatColor.DARK_GRAY + "Ship Engine"); + holder.setInventory(gui); + + // Fill non-fuel slots with glass panes + ItemStack filler = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); + ItemMeta fillerMeta = filler.getItemMeta(); + if (fillerMeta != null) { + fillerMeta.setDisplayName(" "); + filler.setItemMeta(fillerMeta); + } + for (int i = 0; i < 9; i++) { + gui.setItem(i, filler); + } + + // Load existing fuel from wheel data + if (ship.wheelData != null) { + ItemStack[] fuelSlots = ship.wheelData.getEngineFuelSlots(engineBlockIndex); + for (int i = 0; i < FUEL_SLOTS.length; i++) { + gui.setItem(FUEL_SLOTS[i], fuelSlots[i]); // null = empty slot + } + } else { + for (int slot : FUEL_SLOTS) { + gui.setItem(slot, null); + } + } + + // Status item + gui.setItem(STATUS_SLOT, createStatusItem(ship, engineBlockIndex)); + + player.openInventory(gui); + } + + /** + * Creates the status display item showing engine state. + */ + private static ItemStack createStatusItem(ShipInstance ship, int engineBlockIndex) { + int burnTicks = ship.wheelData != null ? ship.wheelData.getEngineBurnTicks(engineBlockIndex) : 0; + boolean isBurning = burnTicks > 0; + + // Check if fuel items are present in slots (even if not burning yet) + boolean hasFuelItems = false; + if (ship.wheelData != null) { + ItemStack[] slots = ship.wheelData.getAllEngineFuelSlots().get(engineBlockIndex); + if (slots != null) { + for (ItemStack item : slots) { + if (item != null && item.getType() != Material.AIR) { + hasFuelItems = true; + break; + } + } + } + } + + ItemStack status; + if (isBurning) { + status = new ItemStack(Material.FURNACE); + ItemMeta meta = status.getItemMeta(); + if (meta != null) { + int seconds = burnTicks / 20; + meta.setDisplayName(ChatColor.GREEN + "Engine Running"); + meta.setLore(Arrays.asList( + ChatColor.GRAY + "Fuel remaining: " + ChatColor.WHITE + seconds + "s" + )); + status.setItemMeta(meta); + } + } else if (hasFuelItems) { + status = new ItemStack(Material.FURNACE); + ItemMeta meta = status.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.YELLOW + "Engine Ready"); + meta.setLore(Arrays.asList( + ChatColor.GRAY + "Fuel loaded — will burn when sailing" + )); + status.setItemMeta(meta); + } + } else { + status = new ItemStack(Material.BLAST_FURNACE); + ItemMeta meta = status.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.GRAY + "Engine Idle"); + meta.setLore(Arrays.asList( + ChatColor.GRAY + "Add fuel to the left slots" + )); + status.setItemMeta(meta); + } + } + return status; + } + + /** + * Checks if a material is a valid furnace fuel. + */ + public static boolean isValidFuel(Material material) { + return VALID_FUELS.contains(material); + } + + /** + * Returns the burn time in ticks for a fuel material. + */ + public static int getBurnTime(Material material) { + return switch (material) { + case LAVA_BUCKET -> 20000; + case COAL_BLOCK -> 16000; + case BLAZE_ROD -> 2400; + case COAL, CHARCOAL -> 1600; + case OAK_LOG, SPRUCE_LOG, BIRCH_LOG, JUNGLE_LOG, ACACIA_LOG, DARK_OAK_LOG, MANGROVE_LOG, CHERRY_LOG -> 300; + case OAK_PLANKS, SPRUCE_PLANKS, BIRCH_PLANKS, JUNGLE_PLANKS, ACACIA_PLANKS, DARK_OAK_PLANKS, MANGROVE_PLANKS, CHERRY_PLANKS, BAMBOO_PLANKS -> 300; + case OAK_SLAB, SPRUCE_SLAB, BIRCH_SLAB, JUNGLE_SLAB, ACACIA_SLAB, DARK_OAK_SLAB, MANGROVE_SLAB, CHERRY_SLAB, BAMBOO_SLAB -> 150; + case STICK -> 100; + case BAMBOO -> 50; + case DRIED_KELP_BLOCK -> 4000; + case BOOKSHELF, LECTERN -> 300; + case CHEST, TRAPPED_CHEST, BARREL -> 300; + case CRAFTING_TABLE, NOTE_BLOCK, JUKEBOX -> 300; + case COMPOSTER, CARTOGRAPHY_TABLE, FLETCHING_TABLE, SMITHING_TABLE, LOOM -> 300; + case BOW, CROSSBOW, FISHING_ROD -> 300; + case WOODEN_SWORD, WOODEN_PICKAXE, WOODEN_AXE, WOODEN_SHOVEL, WOODEN_HOE -> 200; + case OAK_FENCE, SPRUCE_FENCE, BIRCH_FENCE, JUNGLE_FENCE, ACACIA_FENCE, DARK_OAK_FENCE, MANGROVE_FENCE, CHERRY_FENCE -> 300; + case OAK_FENCE_GATE, SPRUCE_FENCE_GATE, BIRCH_FENCE_GATE, JUNGLE_FENCE_GATE, ACACIA_FENCE_GATE, DARK_OAK_FENCE_GATE, MANGROVE_FENCE_GATE, CHERRY_FENCE_GATE -> 300; + default -> 0; + }; + } + + /** + * Checks if a slot index is a fuel slot. + */ + public static boolean isFuelSlot(int slot) { + for (int s : FUEL_SLOTS) { + if (s == slot) return true; + } + return false; + } + + /** + * Saves fuel slot contents from the GUI back to wheel data. + * Called when the GUI is closed. + */ + public static void saveFuelState(EngineMenuHolder holder) { + ShipInstance ship = holder.getShip(); + if (ship.wheelData == null) return; + + Inventory inv = holder.getInventory(); + ItemStack[] slots = new ItemStack[3]; + for (int i = 0; i < FUEL_SLOTS.length; i++) { + ItemStack item = inv.getItem(FUEL_SLOTS[i]); + slots[i] = (item != null && item.getType() != Material.AIR) ? item.clone() : null; + } + ship.wheelData.setEngineFuelSlots(holder.getEngineBlockIndex(), slots); + } + + // ===== Placed (unassembled) engine block GUI ===== + + /** + * Holder for a placed engine block's fuel GUI (no ShipInstance). + */ + public static class EngineBlockMenuHolder implements org.bukkit.inventory.InventoryHolder { + private final org.bukkit.block.Block block; + private Inventory inventory; + + public EngineBlockMenuHolder(org.bukkit.block.Block block) { + this.block = block; + } + + public org.bukkit.block.Block getBlock() { return block; } + + @Override + public Inventory getInventory() { return inventory; } + public void setInventory(Inventory inv) { this.inventory = inv; } + } + + /** + * Opens the custom fuel GUI for a placed (unassembled) engine block. + * Reads fuel from the blast furnace's container inventory, writes back on close. + */ + public static void openForBlock(org.bukkit.entity.Player player, org.bukkit.block.Block block) { + EngineBlockMenuHolder holder = new EngineBlockMenuHolder(block); + Inventory gui = Bukkit.createInventory(holder, 9, ChatColor.DARK_GRAY + "Ship Engine"); + holder.setInventory(gui); + + // Fill with glass panes + ItemStack filler = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); + ItemMeta fillerMeta = filler.getItemMeta(); + if (fillerMeta != null) { + fillerMeta.setDisplayName(" "); + filler.setItemMeta(fillerMeta); + } + for (int i = 0; i < 9; i++) { + gui.setItem(i, filler); + } + + // Load fuel from the blast furnace's container inventory + if (block.getState() instanceof org.bukkit.block.Container container) { + org.bukkit.inventory.Inventory blockInv = container.getSnapshotInventory(); + for (int i = 0; i < FUEL_SLOTS.length && i < blockInv.getSize(); i++) { + gui.setItem(FUEL_SLOTS[i], blockInv.getItem(i)); + } + } + + // Status item (always idle for placed blocks — no burn ticks) + ItemStack status = new ItemStack(Material.BLAST_FURNACE); + ItemMeta meta = status.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.GRAY + "Engine Idle"); + meta.setLore(Arrays.asList( + ChatColor.GRAY + "Add fuel, then assemble ship" + )); + status.setItemMeta(meta); + } + gui.setItem(STATUS_SLOT, status); + + player.openInventory(gui); + } + + /** + * Saves fuel from the GUI back to the placed blast furnace container. + */ + public static void saveBlockFuelState(EngineBlockMenuHolder holder) { + org.bukkit.block.Block block = holder.getBlock(); + if (!(block.getState() instanceof org.bukkit.block.Container)) return; + + org.bukkit.block.Container container = (org.bukkit.block.Container) block.getState(); + org.bukkit.inventory.Inventory blockInv = container.getSnapshotInventory(); + + // Clear existing contents and write fuel slots + blockInv.clear(); + Inventory gui = holder.getInventory(); + for (int i = 0; i < FUEL_SLOTS.length && i < blockInv.getSize(); i++) { + ItemStack item = gui.getItem(FUEL_SLOTS[i]); + blockInv.setItem(i, (item != null && item.getType() != Material.AIR) ? item.clone() : null); + } + container.update(); + } +} diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelData.java b/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelData.java index 9a46e66..c5e0748 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelData.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelData.java @@ -3,15 +3,14 @@ import anon.def9a2a4.blockships.ShipTags; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.BlockFace; import org.bukkit.entity.Shulker; +import org.bukkit.inventory.ItemStack; import org.bukkit.scheduler.BukkitTask; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import java.util.*;; /** * Tracks data for a placed ship wheel block. @@ -41,6 +40,15 @@ private static BlockFace safeBlockFace(Map map, String key, BlockFace defa private int lastDetectedBlockCount; // Block count from last detection private int lastDetectedWeight; // Total weight from last detection private int lastDetectedPositiveWeight; // Positive weight sum (for health calculation) + private int lastDetectedWoolCount; // Wool blocks (for ship stats) + private int lastDetectedBannerCount; // Banner blocks (for ship stats) + private int lastDetectedEngineCount; // Ship engine blocks + + // Engine fuel state (persisted across sessions) + // Key = engine block index, Value = array of 3 fuel ItemStacks (null = empty slot) + private final Map engineFuelSlots = new HashMap<>(); + // Key = engine block index, Value = remaining burn ticks on current fuel item + private final Map engineBurnTicks = new HashMap<>(); // Categorized blocks for colored particle visualization private Set lastDetectedRegularBlocks; // Non-seat blocks (white particles) @@ -129,10 +137,77 @@ public int getLastDetectedPositiveWeight() { return lastDetectedPositiveWeight; } - public void setLastDetectedStats(int blockCount, int totalWeight, int positiveWeight) { + public void setLastDetectedStats(int blockCount, int totalWeight, int positiveWeight, + int woolCount, int bannerCount, int engineCount) { this.lastDetectedBlockCount = blockCount; this.lastDetectedWeight = totalWeight; this.lastDetectedPositiveWeight = positiveWeight; + this.lastDetectedWoolCount = woolCount; + this.lastDetectedBannerCount = bannerCount; + this.lastDetectedEngineCount = engineCount; + } + + public int getLastDetectedWoolCount() { + return lastDetectedWoolCount; + } + + public int getLastDetectedBannerCount() { + return lastDetectedBannerCount; + } + + public int getLastDetectedEngineCount() { + return lastDetectedEngineCount; + } + + // ===== Engine fuel state ===== + + public ItemStack[] getEngineFuelSlots(int engineBlockIndex) { + ItemStack[] slots = engineFuelSlots.get(engineBlockIndex); + return slots != null ? slots : new ItemStack[3]; + } + + public void setEngineFuelSlots(int engineBlockIndex, ItemStack[] slots) { + engineFuelSlots.put(engineBlockIndex, slots); + } + + public int getEngineBurnTicks(int engineBlockIndex) { + return engineBurnTicks.getOrDefault(engineBlockIndex, 0); + } + + public void setEngineBurnTicks(int engineBlockIndex, int ticks) { + engineBurnTicks.put(engineBlockIndex, ticks); + } + + public Map getAllEngineFuelSlots() { + return engineFuelSlots; + } + + public Map getAllEngineBurnTicks() { + return engineBurnTicks; + } + + /** + * Returns the number of engines that currently have fuel (burn ticks > 0 or fuel items in slots). + * Takes the full list of engine block indices so engines without map entries are correctly counted as unfueled. + */ + public int countFueledEngines(List engineBlockIndices) { + int count = 0; + for (int idx : engineBlockIndices) { + if (engineBurnTicks.getOrDefault(idx, 0) > 0) { + count++; + continue; + } + ItemStack[] slots = engineFuelSlots.get(idx); + if (slots != null) { + for (ItemStack item : slots) { + if (item != null && item.getType() != Material.AIR) { + count++; + break; + } + } + } + } + return count; } public Set getLastDetectedRegularBlocks() { @@ -329,6 +404,28 @@ public Map toMap() { if (cameraDistance >= 0) { map.put("camera_distance", cameraDistance); } + // Serialize engine fuel state + if (!engineFuelSlots.isEmpty()) { + List> enginesList = new ArrayList<>(); + for (Map.Entry entry : engineFuelSlots.entrySet()) { + int idx = entry.getKey(); + ItemStack[] slots = entry.getValue(); + Map engineMap = new HashMap<>(); + engineMap.put("block_index", idx); + engineMap.put("burn_ticks", engineBurnTicks.getOrDefault(idx, 0)); + List serializedSlots = new ArrayList<>(); + for (ItemStack item : slots) { + if (item != null && item.getType() != Material.AIR) { + serializedSlots.add(Base64.getEncoder().encodeToString(item.serializeAsBytes())); + } else { + serializedSlots.add(""); + } + } + engineMap.put("fuel_slots", serializedSlots); + enginesList.add(engineMap); + } + map.put("engines", enginesList); + } return map; } @@ -361,6 +458,28 @@ public static ShipWheelData fromMap(Map map) { data.setCameraDistance(((Number) map.get("camera_distance")).floatValue()); } + // Load engine fuel state + if (map.containsKey("engines")) { + @SuppressWarnings("unchecked") + List> enginesList = (List>) map.get("engines"); + for (Map engineMap : enginesList) { + int idx = ((Number) engineMap.get("block_index")).intValue(); + int burnTicks = ((Number) engineMap.get("burn_ticks")).intValue(); + data.engineBurnTicks.put(idx, burnTicks); + + @SuppressWarnings("unchecked") + List serializedSlots = (List) engineMap.get("fuel_slots"); + ItemStack[] slots = new ItemStack[3]; + for (int i = 0; i < Math.min(serializedSlots.size(), 3); i++) { + String encoded = serializedSlots.get(i); + if (encoded != null && !encoded.isEmpty()) { + slots[i] = ItemStack.deserializeBytes(Base64.getDecoder().decode(encoded)); + } + } + data.engineFuelSlots.put(idx, slots); + } + } + return data; } } diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelManager.java b/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelManager.java index 5b31241..d7c1841 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelManager.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelManager.java @@ -16,6 +16,8 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; +import org.bukkit.NamespacedKey; +import org.bukkit.Tag; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.entity.Player; @@ -262,6 +264,15 @@ public boolean assembleShip(Player player, ShipWheelData wheelData) { // Link the wheel to the ship wheelData.setAssembledShipUUID(ship.id); + ship.wheelData = wheelData; + + // Update detection stats so the ship wheel menu shows correct data immediately + wheelData.setLastDetectedStats(model.parts.size(), model.totalWeight, model.mass, + model.woolCount, model.bannerCount, model.engineCount); + wheelData.setLastHealth(ship.vehicle.getHealth(), model.maxHealth); + wheelData.lastCenterOfVolumeY = model.centerOfVolume.y(); + wheelData.lastMinY = model.minY; + wheelData.lastSurfaceOffset = model.waterFloatOffset; // Tag the ship wheel collider (block at dx=0, dy=0, dz=0 relative to wheel origin) // This allows opening the menu by right-clicking the wheel collider @@ -676,7 +687,8 @@ public boolean detectShip(Player player, ShipWheelData wheelData, boolean showPa org.bukkit.attribute.Attribute maxHealthAttr = anon.def9a2a4.blockships.util.AttributeCompat.getMaxHealth(); org.bukkit.attribute.AttributeInstance maxHealthInstance = maxHealthAttr != null ? ship.vehicle.getAttribute(maxHealthAttr) : null; double maxHealth = maxHealthInstance != null ? maxHealthInstance.getBaseValue() : 100.0; - wheelData.setLastDetectedStats(blockCount, ship.model.totalWeight, (int)maxHealth); + wheelData.setLastDetectedStats(blockCount, ship.model.totalWeight, ship.model.mass, + ship.model.woolCount, ship.model.bannerCount, ship.model.engineCount); wheelData.setLastHealth(currentHealth, maxHealth); // Store buoyancy data from ship model wheelData.lastCenterOfVolumeY = ship.model.centerOfVolume.y(); @@ -718,6 +730,10 @@ public boolean detectShip(Player player, ShipWheelData wheelData, boolean showPa // Move one block behind the wheel (opposite of facing direction) driverSeat.add(facing.getOppositeFace().getModX(), 0, facing.getOppositeFace().getModZ()); + int woolCount = 0; + int bannerCount = 0; + int engineCount = 0; + NamespacedKey engineKey = new NamespacedKey(plugin, "custom_item_id"); for (Location loc : shipBlocks) { Block block = loc.getBlock(); BlockProperties props = configManager.getProperties(block.getType(), block.getBlockData()); @@ -728,6 +744,23 @@ public boolean detectShip(Player player, ShipWheelData wheelData, boolean showPa } else { regularBlocks.add(loc); } + + // Count sail blocks and engines for ship stats + Material blockMaterial = block.getType(); + if (Tag.WOOL.isTagged(blockMaterial)) { + woolCount++; + } else if (blockMaterial.name().contains("BANNER")) { + bannerCount++; + } else if (blockMaterial == Material.BLAST_FURNACE) { + org.bukkit.block.BlockState blockState = block.getState(); + if (blockState instanceof org.bukkit.block.TileState tileState) { + String val = tileState.getPersistentDataContainer() + .get(engineKey, org.bukkit.persistence.PersistentDataType.STRING); + if ("ship_engine".equals(val)) { + engineCount++; + } + } + } } // Calculate total weight and counts @@ -756,11 +789,24 @@ public boolean detectShip(Player player, ShipWheelData wheelData, boolean showPa } else { player.sendMessage("§7Seats: §c0 §7(default seat at wheel will be used)"); } + // Ship stats + int sailPower = woolCount * 3 + bannerCount * 7; + int shipMass = Math.max(1, calculateMass(shipBlocks)); + float sailRatio = (float) (config.basePower + sailPower) / shipMass; + float ratio = Math.min(sailRatio, config.sailCapRatio); + int speedPercent = Math.round(ratio / config.sailCapRatio * 100); + player.sendMessage("§7Sails: §f" + woolCount + " wool, " + bannerCount + " banners §7(" + sailPower + " power)"); + if (engineCount > 0) { + // Detection is pre-assembly, so engines aren't fueled yet — show as unfueled + player.sendMessage("§7Engines §c(unfueled)§7: §f" + engineCount + " §7(0 pts — fuel to activate)"); + } + String speedColor = speedPercent >= 125 ? "§b" : speedPercent >= 100 ? "§a" : speedPercent >= 75 ? "§e" : speedPercent >= 50 ? "§6" : "§c"; + player.sendMessage("§7Speed: " + speedColor + speedPercent + "%" + (speedPercent < 50 ? " §8(add banners or wool as sails!)" : "")); // Store detected blocks and stats for Ship Info display - int positiveWeight = calculatePositiveWeight(shipBlocks); + int positiveWeight = calculateMass(shipBlocks); wheelData.setLastDetectedBlocks(shipBlocks); - wheelData.setLastDetectedStats(blockCount, totalWeight, positiveWeight); + wheelData.setLastDetectedStats(blockCount, totalWeight, positiveWeight, woolCount, bannerCount, engineCount); wheelData.setLastDetectedBlockCategories(regularBlocks, seatBlocks, driverSeat); // Calculate and store buoyancy data for Ship Info display @@ -817,7 +863,7 @@ private int countWeightedBlocks(Set blocks) { * Calculate the sum of positive weights (used for health calculation). * Blocks with negative or zero weight contribute nothing to health. */ - private int calculatePositiveWeight(Set blocks) { + private int calculateMass(Set blocks) { BlockConfigManager configManager = BlockConfigManager.getInstance(); int positiveWeight = 0; diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelMenu.java b/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelMenu.java index 8ac4acd..ecb0af5 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelMenu.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/customships/ShipWheelMenu.java @@ -1,6 +1,7 @@ package anon.def9a2a4.blockships.customships; import anon.def9a2a4.blockships.BlockShipsPlugin; +import anon.def9a2a4.blockships.HelpBookContent; import anon.def9a2a4.blockships.ShipConfig; import anon.def9a2a4.blockships.ShipRegistry; import anon.def9a2a4.blockships.ship.ShipInstance; @@ -14,8 +15,6 @@ import org.bukkit.inventory.meta.ItemMeta; import anon.def9a2a4.blockships.ItemUtil; -import anon.def9a2a4.blockships.util.SteerPacketCompat; -import org.bukkit.inventory.meta.BookMeta; import org.bukkit.inventory.meta.SkullMeta; import java.util.ArrayList; @@ -34,23 +33,46 @@ public class ShipWheelMenu { public static class ShipInfo { public final int blockCount; public final int totalWeight; + public final int mass; // sum of positive block weights (for display + ratio) public final float density; public final int maxHealth; public final Integer currentHealth; // null if not assembled public final float surfaceOffset; public final float airDensity; public final float waterDensity; - - public ShipInfo(int blockCount, int totalWeight, float density, int maxHealth, - Integer currentHealth, float surfaceOffset, float airDensity, float waterDensity) { + // Ship stats + public final int woolCount; + public final int bannerCount; + public final int sailPower; + public final int engineCount; + public final int fueledEngines; + public final int enginePowerPerEngine; // power points per engine (from config) + public final float sailCapRatio; // sail cap threshold (from config, e.g. 0.8) + public final float sailRatio; // uncapped sail ratio (before sail cap applied) + public final float ratio; // final ratio (with sail cap + engines) + + public ShipInfo(int blockCount, int totalWeight, int mass, float density, int maxHealth, + Integer currentHealth, float surfaceOffset, float airDensity, float waterDensity, + int woolCount, int bannerCount, int sailPower, int engineCount, int fueledEngines, + int enginePowerPerEngine, float sailCapRatio, float sailRatio, float ratio) { this.blockCount = blockCount; this.totalWeight = totalWeight; + this.mass = mass; this.density = density; this.maxHealth = maxHealth; this.currentHealth = currentHealth; this.surfaceOffset = surfaceOffset; this.airDensity = airDensity; this.waterDensity = waterDensity; + this.woolCount = woolCount; + this.bannerCount = bannerCount; + this.sailPower = sailPower; + this.engineCount = engineCount; + this.fueledEngines = fueledEngines; + this.enginePowerPerEngine = enginePowerPerEngine; + this.sailCapRatio = sailCapRatio; + this.sailRatio = sailRatio; + this.ratio = ratio; } } @@ -87,19 +109,6 @@ public void setInventory(Inventory inventory) { // Help icon texture (question mark) private static final String HELP_TEXTURE = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZGE5OWIwNWI5YTFkYjRkMjliNWU2NzNkNzdhZTU0YTc3ZWFiNjY4MTg1ODYwMzVjOGEyMDA1YWViODEwNjAyYSJ9fX0="; - // Help content - used for both lore and book - // Controls text is dynamic based on server version (sprint vs S+Space for descent) - private static String[][] getHelpSections() { - return new String[][] { - {"Controls", SteerPacketCompat.getAirshipControlsHelp()}, - {"Getting Started", "Place wheel on your build, open this menu by right-clicking the wheel, click the boat to assemble."}, - {"Riding", "Right-click ship or seat to board. Sneak to exit."}, - {"Menu & Disassembly", "Right-click the ship's wheel, or sneak + right-click anywhere on the ship. Click the pickaxe to disassemble."}, - {"Cannons", "Dispenser + obsidian behind it. Right-click the obsidian to fire, or use the fireball in the menu."}, - {"Functionality", "Chests, barrels, and other containers work on ships. Attach leads to fences to bring mobs or boats along. Stairs work as extra seats for players."}, - {"Weight & Buoyancy", "Wood/wool = light, metals = heavy. Glowstone/end rods = lighter than air (airship!). Click the book to detect your ship and see more info."} - }; - } // Menu item slots - Left group: detect/info, Right group: assemble/align/disassemble private static final int HELP_SLOT = 0; @@ -113,6 +122,7 @@ private static String[][] getHelpSections() { private static final int DISASSEMBLE_SLOT = 16; private static final int FORCE_DISASSEMBLE_SLOT = 17; // Right of disassemble button private static final int HIGHLIGHT_SEATS_SLOT = 19; // Below detect slot (row 3) + private static final int STATS_SLOT = 20; // Below info slot (row 3) /** * Opens the ship wheel menu for a player. @@ -269,6 +279,7 @@ public static void openMenu(Player player, ShipWheelData wheelData) { // Ship Info button - shows weight, density, and buoyancy info from last detection menu.setItem(INFO_SLOT, createInfoItem(wheelData)); + menu.setItem(STATS_SLOT, createStatsItem(wheelData)); // Highlight Seats button - always shown menu.setItem(HIGHLIGHT_SEATS_SLOT, createHighlightSeatsItem(wheelData)); @@ -329,6 +340,8 @@ public static MenuAction getActionFromSlot(int slot) { return MenuAction.CAMERA_DISTANCE_DECREASE; } else if (slot == CAMERA_PLUS_SLOT) { return MenuAction.CAMERA_DISTANCE_INCREASE; + } else if (slot == STATS_SLOT) { + return MenuAction.INFO; // Clicking stats banner also refreshes ship info } return MenuAction.NONE; } @@ -364,12 +377,52 @@ private static ShipInfo getShipInfo(ShipWheelData wheelData) { currentHealth = (int) Math.ceil(wheelData.getLastCurrentHealth()); maxHealth = (int) wheelData.getLastMaxHealth(); } else { - int positiveWeight = wheelData.getLastDetectedPositiveWeight(); - maxHealth = Math.max(1, positiveWeight); + int shipMass = wheelData.getLastDetectedPositiveWeight(); + maxHealth = Math.max(1, shipMass); } - return new ShipInfo(blockCount, totalWeight, density, maxHealth, currentHealth, - surfaceOffset, airDensity, waterDensity); + // Ship stats — use live ShipInstance data when assembled + int woolCount, bannerCount, engineCount, fueledEngines, mass; + if (wheelData.isAssembled()) { + ShipInstance ship = ShipRegistry.byId(wheelData.getAssembledShipUUID()); + if (ship != null && ship.model != null) { + woolCount = ship.model.woolCount; + bannerCount = ship.model.bannerCount; + engineCount = ship.model.engineCount; + mass = Math.max(1, ship.model.mass); + anon.def9a2a4.blockships.customships.ShipWheelData wd = ship.resolveWheelData(); + fueledEngines = (wd != null) + ? wd.countFueledEngines(ship.model.engineBlockIndices) + : 0; + } else { + // Ship not found (destroyed?) — use detection data + woolCount = wheelData.getLastDetectedWoolCount(); + bannerCount = wheelData.getLastDetectedBannerCount(); + engineCount = wheelData.getLastDetectedEngineCount(); + mass = Math.max(1, wheelData.getLastDetectedPositiveWeight()); + fueledEngines = 0; + } + } else { + // Unassembled — use detection data, all engines unfueled + woolCount = wheelData.getLastDetectedWoolCount(); + bannerCount = wheelData.getLastDetectedBannerCount(); + engineCount = wheelData.getLastDetectedEngineCount(); + mass = Math.max(1, wheelData.getLastDetectedPositiveWeight()); + fueledEngines = 0; + } + + int sailPower = woolCount * 3 + bannerCount * 7; + + // Compute power ratio + float sailRatio = (float) (config.basePower + sailPower) / mass; + float nonEngineRatio = Math.min(sailRatio, config.sailCapRatio); + float engineBonus = (float) (fueledEngines * config.enginePower) / mass; + float ratio = Math.min(nonEngineRatio + engineBonus, 1.0f); + + return new ShipInfo(blockCount, totalWeight, mass, density, maxHealth, currentHealth, + surfaceOffset, airDensity, waterDensity, + woolCount, bannerCount, sailPower, engineCount, fueledEngines, + config.enginePower, config.sailCapRatio, sailRatio, ratio); } /** @@ -396,18 +449,32 @@ private static ItemStack createInfoItem(ShipWheelData wheelData) { lore.add(ChatColor.GRAY + "Max Health: " + ChatColor.RED + "❤ " + info.maxHealth); } - lore.add(ChatColor.GRAY + "Density: " + ChatColor.WHITE + String.format("%.2f", info.density)); - lore.add(ChatColor.GRAY + "Surface Offset: " + ChatColor.AQUA + String.format("%.2f", info.surfaceOffset) + " blocks"); - - // Float status + // Density with colored float status + ChatColor densityColor; + String floatStatus; if (info.density < info.airDensity) { - lore.add(ChatColor.BLUE + "Airship"); + densityColor = ChatColor.AQUA; + floatStatus = "Airship"; } else if (info.density < info.waterDensity) { - lore.add(ChatColor.GREEN + "Floats well"); + densityColor = ChatColor.GREEN; + floatStatus = "Floats well"; } else if (info.density < info.waterDensity + 0.5f) { - lore.add(ChatColor.YELLOW + "Sits low in water"); + densityColor = ChatColor.YELLOW; + floatStatus = "Sits low"; } else { - lore.add(ChatColor.RED + "Sits very low"); + densityColor = ChatColor.RED; + floatStatus = "Sits very low"; + } + lore.add(ChatColor.GRAY + "Density: " + densityColor + String.format("%.2f", info.density) + + ChatColor.GRAY + " (" + densityColor + floatStatus + ChatColor.GRAY + ")"); + + // Ship stats (simplified — detailed breakdown in stats item below) + lore.add(""); + int speedPercent = Math.round(info.ratio / info.sailCapRatio * 100); + String maxTag = info.ratio >= 1.0f ? ChatColor.AQUA + " (max)" : ""; + lore.add(ChatColor.GRAY + "Speed: " + speedColor(speedPercent) + speedPercent + "%" + maxTag); + if (speedPercent < 50) { + lore.add(ChatColor.DARK_PURPLE + "(add banners or wool as sails!)"); } } else { lore.add(ChatColor.GRAY + "No ship detected yet"); @@ -428,6 +495,83 @@ private static ItemStack createInfoItem(ShipWheelData wheelData) { */ public static void updateInfoItem(Inventory inventory, ShipWheelData wheelData) { inventory.setItem(INFO_SLOT, createInfoItem(wheelData)); + inventory.setItem(STATS_SLOT, createStatsItem(wheelData)); + } + + /** + * Creates the detailed Ship Stats item (banner) with full breakdown. + */ + private static ItemStack createStatsItem(ShipWheelData wheelData) { + ItemStack statsItem = new ItemStack(Material.WHITE_BANNER); + ItemMeta statsMeta = statsItem.getItemMeta(); + if (statsMeta != null) { + statsMeta.setDisplayName(ChatColor.GOLD + "Ship Stats"); + List lore = new ArrayList<>(); + + ShipInfo info = getShipInfo(wheelData); + if (info != null) { + // Sail breakdown + if (info.woolCount > 0) { + lore.add(ChatColor.GRAY + "Wool: " + ChatColor.WHITE + info.woolCount + + ChatColor.GRAY + " (" + (info.woolCount * 3) + " pts)"); + } + if (info.bannerCount > 0) { + lore.add(ChatColor.GRAY + "Banners: " + ChatColor.WHITE + info.bannerCount + + ChatColor.GRAY + " (" + (info.bannerCount * 7) + " pts)"); + } + + // Sail power with cap indicator + if (info.sailPower > 0) { + int sailCapPoints = Math.round(0.8f * info.mass); + int effectiveSailPts = 2 + info.sailPower; // base + sail + if (effectiveSailPts > sailCapPoints) { + lore.add(ChatColor.GRAY + "Sail Power: " + ChatColor.WHITE + info.sailPower + " pts" + + ChatColor.YELLOW + " (capped at " + sailCapPoints + " pts)"); + } else { + lore.add(ChatColor.GRAY + "Sail Power: " + ChatColor.WHITE + info.sailPower + " pts"); + } + } + + // Engines — always show fueled/unfueled breakdown + if (info.engineCount > 0) { + int unfueled = info.engineCount - info.fueledEngines; + if (info.fueledEngines > 0) { + int fueledPts = info.fueledEngines * info.enginePowerPerEngine; + lore.add(ChatColor.GRAY + "Engines: " + ChatColor.GREEN + info.fueledEngines + + ChatColor.GRAY + " (" + fueledPts + " pts)"); + } + if (unfueled > 0) { + lore.add(ChatColor.GRAY + "Engines " + ChatColor.RED + "(unfueled)" + + ChatColor.GRAY + ": " + ChatColor.WHITE + unfueled + + ChatColor.GRAY + " (0 pts)"); + } + } + + lore.add(""); + lore.add(ChatColor.GRAY + "Mass: " + ChatColor.WHITE + info.mass); + // Effective power after caps (matches physics formula): + // cappedSailPower = min(basePower + sailPower, 0.8 * mass) + // + enginePower, capped at 1.0 * mass total + int rawSailPower = 2 + info.sailPower; + int cappedSailPower = Math.min(rawSailPower, Math.round(0.8f * info.mass)); + int enginePts = info.fueledEngines * info.enginePowerPerEngine; + int effectivePower = Math.min(cappedSailPower + enginePts, info.mass); + lore.add(ChatColor.GRAY + "Effective Power: " + ChatColor.WHITE + effectivePower + + ChatColor.GRAY + " / " + info.mass + " pts"); + lore.add(ChatColor.GRAY + "Power Ratio: " + ChatColor.YELLOW + + String.format("%.2f", info.ratio) + ChatColor.GRAY + " / 1.00"); + + int speedPercent = Math.round(info.ratio / info.sailCapRatio * 100); + String maxTag = info.ratio >= 1.0f ? ChatColor.AQUA + " (max)" : ""; + lore.add(ChatColor.GRAY + "Speed: " + speedColor(speedPercent) + speedPercent + "%" + maxTag); + } else { + lore.add(ChatColor.GRAY + "Detect ship first"); + } + + statsMeta.setLore(lore); + statsItem.setItemMeta(statsMeta); + } + return statsItem; } /** @@ -555,70 +699,35 @@ private static ItemStack createHelpItem() { */ private static List createHelpLore() { List lore = new ArrayList<>(); - for (String[] section : getHelpSections()) { - lore.add(ChatColor.YELLOW + section[0]); - lore.add(ChatColor.GRAY + section[1]); - } + lore.add(ChatColor.GRAY + "WASD to move, Space is up, Sprint is down"); + lore.add(ChatColor.GRAY + "Place ship's wheel on ship and click 'Assemble' (boat)"); + lore.add(ChatColor.GRAY + "Right-click ship to board, Sneak to dismount"); + lore.add(ChatColor.GRAY + "Sails and engines make you go faster,"); + lore.add(ChatColor.GRAY + "glowstone and other glowing blocks make you float."); + lore.add(ChatColor.GRAY + "Enough floating blocks -> airship"); lore.add(""); - lore.add(ChatColor.DARK_GRAY + "Click to open help book"); + lore.add(ChatColor.DARK_GRAY + "Click for more info -- Captain's Manual"); return lore; } - private static final int BOOK_LINES_PER_PAGE = 12; - private static final int BOOK_CHARS_PER_LINE = 20; - - /** - * Estimates the number of lines a section will take in the book. - */ - private static int estimateSectionLines(String title, String content) { - // Title takes 1 line, content wraps based on chars per line, plus 1 blank line after - int contentLines = (int) Math.ceil((double) content.length() / BOOK_CHARS_PER_LINE); - return 1 + contentLines + 1; - } - /** * Opens a help book for the player with detailed ship information. * * @param player The player to show the book to */ public static void openHelpBook(Player player) { - ItemStack book = new ItemStack(Material.WRITTEN_BOOK); - BookMeta bookMeta = (BookMeta) book.getItemMeta(); - if (bookMeta != null) { - bookMeta.setTitle("Ship Wheel Help"); - bookMeta.setAuthor("BlockShips"); - - StringBuilder currentPage = new StringBuilder(); - int currentLines = 0; - String[][] helpSections = getHelpSections(); - - for (int i = 0; i < helpSections.length; i++) { - String title = helpSections[i][0]; - String content = helpSections[i][1]; - int sectionLines = estimateSectionLines(title, content); - - // Check if this section fits on current page - if (currentLines > 0 && currentLines + sectionLines > BOOK_LINES_PER_PAGE) { - // Add "next page" hint and start new page - currentPage.append(ChatColor.GRAY).append(ChatColor.ITALIC).append("(next page >>>)"); - bookMeta.addPage(currentPage.toString()); - currentPage = new StringBuilder(); - currentLines = 0; - } - - // Add section to current page - currentPage.append(ChatColor.DARK_BLUE).append(ChatColor.BOLD).append(title).append("\n"); - currentPage.append(ChatColor.BLACK).append(content).append("\n\n"); - currentLines += sectionLines; - } - - // Add final page if it has content - if (currentPage.length() > 0) { - bookMeta.addPage(currentPage.toString()); - } + HelpBookContent.openBook(player); + } - book.setItemMeta(bookMeta); - } - player.openBook(book); + /** + * Returns a ChatColor for speed percentage display. + * Red (<50%) → Gold (50-74%) → Yellow (75-99%) → Green (100-124%) → Blue (125%+) + */ + private static ChatColor speedColor(int speedPercent) { + if (speedPercent >= 125) return ChatColor.AQUA; + if (speedPercent >= 100) return ChatColor.GREEN; + if (speedPercent >= 75) return ChatColor.YELLOW; + if (speedPercent >= 50) return ChatColor.GOLD; + return ChatColor.RED; } } diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipInstance.java b/blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipInstance.java index d08d09f..5186d23 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipInstance.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipInstance.java @@ -82,6 +82,20 @@ static BlockFace safeBlockFace(Map yaml, String key, BlockFace defaultValu public final List seatShulkers = new ArrayList<>(); // Seat shulkers in order (index 0 = driver) private final Set occupiedSeatIndices = new HashSet<>(); // Track which seats are occupied public Shulker leadableShulker; // Designated lead attachment point (for prefab ships) + public anon.def9a2a4.blockships.customships.ShipWheelData wheelData; // Reference to wheel data (for engine fuel state, set during assembly) + + /** + * Lazily resolves wheelData if not set (e.g., after chunk recovery). + * Looks up via ShipWheelManager by ship UUID. + */ + public anon.def9a2a4.blockships.customships.ShipWheelData resolveWheelData() { + if (wheelData != null) return wheelData; + if (plugin instanceof anon.def9a2a4.blockships.BlockShipsPlugin bsp) { + wheelData = bsp.getShipWheelManager().getWheelByShipUUID(id); + } + return wheelData; + } + private BukkitRunnable task; private BukkitRunnable idleCheckTask; @@ -91,6 +105,7 @@ static BlockFace safeBlockFace(Map yaml, String key, BlockFace defaultValu private float previousPitch; private float spawnYaw; // Track spawn yaw for pre-1.21.9 display rotation fix private int ticksSinceLastMovement = 0; + private int engineSmokeTick = 0; // Counter for throttling engine smoke particles private boolean taskStopped = false; private boolean firstTick = true; // Force first tick to update positions @@ -1214,6 +1229,7 @@ void tick() { collision.detect(); // Detect collisions and accumulate forces physics.update(); // Apply physics (movement, rotation, buoyancy) collision.applyResponse(); // Apply collision response + spawnEngineSmoke(); // Visual feedback for running engines cachedVehicleLoc = vehicle.getLocation(); // Refresh after physics moved the vehicle // Set vehicle velocity from actual displacement (after physics + collision response) @@ -1359,6 +1375,29 @@ void tick() { * [2] Set relativeTo (empty = absolute) * [3] boolean onGround */ + /** + * Spawns smoke particles at fueled engine positions. Throttled to every 5 ticks. + */ + private void spawnEngineSmoke() { + if (!"custom".equals(shipType) || model.engineBlockIndices.isEmpty()) return; + if (resolveWheelData() == null || !hasDriver) return; + if (++engineSmokeTick % 5 != 0) return; + + for (int engineIdx : model.engineBlockIndices) { + if (wheelData.getEngineBurnTicks(engineIdx) <= 0) continue; + + // Find the engine's collision shulker by block index + for (CollisionBox box : colliders) { + if (box.blockIndex == engineIdx && box.entity != null && box.entity.isValid()) { + Location loc = box.entity.getLocation(); + loc.getWorld().spawnParticle(org.bukkit.Particle.CAMPFIRE_COSY_SMOKE, + loc.getX(), loc.getY() + 1.0, loc.getZ(), 0, 0.0, 0.05, 0.0, 1.0); + break; + } + } + } + } + private void sendVehiclePositionSync(Location loc, org.bukkit.util.Vector velocity) { if (!positionSyncInitialized) { positionSyncInitialized = true; diff --git a/blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipPhysics.java b/blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipPhysics.java index 4040e73..5c19a1c 100644 --- a/blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipPhysics.java +++ b/blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipPhysics.java @@ -34,6 +34,16 @@ public class ShipPhysics { // Track vertical movement state for carrier refresh on stop private boolean wasVerticallyMoving = false; + // Effective stats (computed from power-to-mass ratio for custom ships, or config defaults for prefab) + private float effectiveMaxSpeed; + private float effectiveAcceleration; + private float effectiveRotationSpeed; + private float effectiveRotationAcceleration; + private float effectiveMaxVerticalSpeed; + private float effectiveLiftAcceleration; + private float effectiveDescendAcceleration; + private boolean statsComputed = false; + // Sound cooldown (ticks until next sound can play) private int soundCooldown = 0; @@ -43,6 +53,115 @@ public class ShipPhysics { public ShipPhysics(ShipInstance ship) { this.ship = ship; + computeEffectiveStats(); + } + + /** + * Computes effective stats from the ship's power-to-mass ratio. + * For custom ships, uses linear interpolation between floor/default/cap. + * For prefab ships, uses config values directly. + */ + private void computeEffectiveStats() { + ShipConfig config = ship.config; + + if (!"custom".equals(ship.shipType)) { + // Prefab ships: use config values directly, no ratio system + effectiveMaxSpeed = config.maxSpeed; + effectiveAcceleration = config.acceleration; + effectiveRotationSpeed = config.rotationSpeed; + effectiveRotationAcceleration = config.rotationAcceleration; + effectiveMaxVerticalSpeed = config.maxVerticalSpeed; + effectiveLiftAcceleration = config.liftAcceleration; + effectiveDescendAcceleration = config.descendAcceleration; + statsComputed = true; + return; + } + + // Custom ships: compute ratio from sail power and mass + float sailRatio = ship.model.getSailRatio(config.basePower); + // Apply sail cap: non-engine contribution capped at sailCapRatio + float nonEngineRatio = Math.min(sailRatio, config.sailCapRatio); + // Count fueled engines from wheel data (engines with active fuel) + anon.def9a2a4.blockships.customships.ShipWheelData wd = ship.resolveWheelData(); + int fueledEngines = (wd != null) + ? wd.countFueledEngines(ship.model.engineBlockIndices) + : 0; // No wheel data = no fuel state = 0 fueled engines + float enginePower = fueledEngines * config.enginePower; + int mass = Math.max(1, ship.model.mass); + float ratio = Math.min(nonEngineRatio + enginePower / mass, 1.0f); + + // Compute horizontal stats + effectiveMaxSpeed = config.computeStat(ratio, config.maxSpeed, + config.floorMaxSpeed, config.capMaxSpeed); + effectiveAcceleration = config.computeStat(ratio, config.acceleration, + config.floorAcceleration, config.capAcceleration); + effectiveRotationSpeed = config.computeStat(ratio, config.rotationSpeed, + config.floorRotationSpeed, config.capRotationSpeed); + effectiveRotationAcceleration = config.computeStat(ratio, config.rotationAcceleration, + config.floorRotationAcceleration, config.capRotationAcceleration); + + // Compute vertical stats (airships only, density-based) + if (ship.isAirship) { + float density = ship.model.getDensity(); + float densityMag = Math.abs(density); + float engineVerticalBonus = (mass > 0) ? (enginePower / mass) * config.verticalEngineScale : 0; + float verticalRatio = Math.min(densityMag * config.verticalDensityScale + engineVerticalBonus, 1.0f); + + effectiveMaxVerticalSpeed = config.computeStat(verticalRatio, config.maxVerticalSpeed, + config.floorMaxVerticalSpeed, config.capMaxVerticalSpeed); + effectiveLiftAcceleration = config.computeStat(verticalRatio, config.liftAcceleration, + config.floorVerticalAcceleration, config.capVerticalAcceleration); + effectiveDescendAcceleration = config.computeStat(verticalRatio, config.descendAcceleration, + config.floorVerticalAcceleration, config.capVerticalAcceleration); + } else { + effectiveMaxVerticalSpeed = config.maxVerticalSpeed; + effectiveLiftAcceleration = config.liftAcceleration; + effectiveDescendAcceleration = config.descendAcceleration; + } + + statsComputed = true; + } + + /** + * Ticks fuel consumption for all engines. Called once per tick while W is held. + * When an engine's burn ticks reach 0, the next fuel item is consumed. + * Recomputes effective stats when fuel state changes. + */ + private void tickEngineFuel() { + boolean fuelChanged = false; + anon.def9a2a4.blockships.customships.ShipWheelData wd = ship.wheelData; + + for (int engineIdx : ship.model.engineBlockIndices) { + int burnTicks = wd.getEngineBurnTicks(engineIdx); + + if (burnTicks > 0) { + // Burn existing fuel + wd.setEngineBurnTicks(engineIdx, burnTicks - 1); + if (burnTicks - 1 == 0) fuelChanged = true; + } else { + // Try to consume next fuel item from slots + org.bukkit.inventory.ItemStack[] slots = wd.getAllEngineFuelSlots().get(engineIdx); + if (slots == null) continue; + for (int i = 0; i < slots.length; i++) { + if (slots[i] != null && slots[i].getType() != org.bukkit.Material.AIR) { + int newBurnTicks = anon.def9a2a4.blockships.customships.EngineMenuGUI.getBurnTime(slots[i].getType()); + if (newBurnTicks > 0) { + wd.setEngineBurnTicks(engineIdx, newBurnTicks); + slots[i].setAmount(slots[i].getAmount() - 1); + if (slots[i].getAmount() <= 0) { + slots[i] = null; + } + fuelChanged = true; + break; + } + } + } + } + } + + if (fuelChanged) { + computeEffectiveStats(); + } } /** @@ -82,14 +201,20 @@ public void update() { Location vehicleLoc = ship.vehicle.getLocation(); ShipConfig config = ship.config; + // Tick engine fuel (only for custom ships with engines, while W held) + if ("custom".equals(ship.shipType) && ship.isForwardPressed + && ship.model.engineCount > 0 && ship.resolveWheelData() != null) { + tickEngineFuel(); + } + // Apply acceleration/deceleration based on input state if (ship.isForwardPressed) { - currentSpeed = Math.min(currentSpeed + config.acceleration, config.maxSpeed); + currentSpeed = Math.min(currentSpeed + effectiveAcceleration, effectiveMaxSpeed); } else if (ship.isBackwardPressed) { if (currentSpeed > 0) { currentSpeed = Math.max(currentSpeed - config.activeDeceleration, 0.0f); } else { - currentSpeed = Math.max(currentSpeed - config.acceleration, -config.maxSpeed); + currentSpeed = Math.max(currentSpeed - effectiveAcceleration, -effectiveMaxSpeed); } } @@ -150,13 +275,13 @@ public void update() { // Update rotation based on input state if (ship.isLeftPressed) { currentRotationVelocity = Math.max( - currentRotationVelocity - config.rotationAcceleration, - -config.rotationSpeed + currentRotationVelocity - effectiveRotationAcceleration, + -effectiveRotationSpeed ); } else if (ship.isRightPressed) { currentRotationVelocity = Math.min( - currentRotationVelocity + config.rotationAcceleration, - config.rotationSpeed + currentRotationVelocity + effectiveRotationAcceleration, + effectiveRotationSpeed ); } else { // No input - apply momentum decay @@ -346,12 +471,12 @@ private void applyAirshipVerticalPhysics() { ShipConfig config = ship.config; if (ship.isSpacePressed) { - currentYVelocity = Math.min(currentYVelocity + config.liftAcceleration, config.maxVerticalSpeed); + currentYVelocity = Math.min(currentYVelocity + effectiveLiftAcceleration, effectiveMaxVerticalSpeed); if (Math.abs(currentSpeed) < config.verticalForwardNudge) { currentSpeed = config.verticalForwardNudge; } } else if (ship.isSprintPressed) { - currentYVelocity = Math.max(currentYVelocity - config.descendAcceleration, -config.maxVerticalSpeed); + currentYVelocity = Math.max(currentYVelocity - effectiveDescendAcceleration, -effectiveMaxVerticalSpeed); if (Math.abs(currentSpeed) < config.verticalForwardNudge) { currentSpeed = config.verticalForwardNudge; } diff --git a/blockships/src/main/resources/config.yml b/blockships/src/main/resources/config.yml index 6d9fdea..94230c6 100644 --- a/blockships/src/main/resources/config.yml +++ b/blockships/src/main/resources/config.yml @@ -2,6 +2,8 @@ # Set to false to disable config mismatch warnings on startup warn-config-mismatch: true +# Automatically add missing config keys from new plugin versions (preserves existing values) +auto-migrate-config: true # Whether ship blocks emit dynamic light (requires DynLight plugin) # https://github.com/def9a2a4/DynLight @@ -55,6 +57,38 @@ custom-ships: # Buoyancy damping: velocity damping to prevent oscillation (0.5 = stable) damping: 0.5 + # Ship stats (power-to-mass ratio system) + # Sails and engines provide power points; block weights provide mass. + # ratio = power / mass determines ship performance. + stats: + # Free power points every ship gets (ensures even tiny ships have some base ratio) + base-power: 2 + # Power points per fueled engine (unfueled engines contribute 0) + engine-power: 30 + # Sail contribution capped at this ratio (engines needed to push past it) + sail-cap-ratio: 0.8 + # Ratio that maps to current default stats (0.55 max speed, 1.5 rotation, etc.) + default-ratio: 0.7 + # Stats multiplier at ratio 1.0 relative to default (1.5 = 50% faster than default) + max-ratio-multiplier: 1.5 + # Absolute floors (minimum stat values regardless of ratio) + floor-max-speed: 0.05 # 1 block/sec + floor-acceleration: 0.015 + floor-rotation-speed: 0.6 # 30s per full revolution + floor-rotation-acceleration: 0.05 + # Absolute caps (-1 = auto: default * max-ratio-multiplier) + cap-max-speed: -1 + cap-acceleration: -1 + cap-rotation-speed: -1 + cap-rotation-acceleration: -1 + # Airship vertical stat scaling (density magnitude + engines) + vertical-density-scale: 0.3 + vertical-engine-scale: 0.01 + floor-max-vertical-speed: 0.03 + floor-vertical-acceleration: 0.01 + cap-max-vertical-speed: 0.5 + cap-vertical-acceleration: 0.1 + # Airship controls (for custom ships lighter than air) # Ships with density < air-density automatically become airships airship-controls: @@ -173,6 +207,34 @@ custom-items: result-item: PLAYER_HEAD result-texture-set: SHIP_WHEEL_SET + ship_engine: + display-name: "Ship Engine" + base-material: BLAST_FURNACE + enchant-glint: true + stackable: true + recipe: + pattern: + - "CCC" + - "CBC" + - "CCC" + ingredients: + C: [COPPER_INGOT] + B: [BLAST_FURNACE] + result-name: "Ship Engine" + result-item: BLAST_FURNACE + + captains_manual: + display-name: "Captain's Manual" + base-material: WRITTEN_BOOK + stackable: false + recipe: + shapeless: true + ingredients: + W: ["blockships:ship_wheel"] + B: [BOOK] + result-name: "Captain's Manual" + result-item: WRITTEN_BOOK + # list of ships ships: smallship: diff --git a/blockships/src/main/resources/help_book.yml b/blockships/src/main/resources/help_book.yml new file mode 100644 index 0000000..a49078b --- /dev/null +++ b/blockships/src/main/resources/help_book.yml @@ -0,0 +1,29 @@ +# Captain's Manual - Help book content +# Bundled in the jar, NOT copied to the user's data folder. +# Used for both the ship wheel menu help button and the craftable Captain's Manual. +# Set content to null for dynamic content (filled at runtime). + +title: "Captain's Manual" +author: "BlockShips" + +sections: + - title: "Controls" + content: null # Filled at runtime via SteerPacketCompat + + - title: "Getting Started" + content: "Place wheel on your build, open this menu by right-clicking the wheel, click the boat to assemble." + + - title: "Riding" + content: "Right-click ship or seat to board. Sneak to exit." + + - title: "Menu & Disassembly" + content: "Right-click the ship's wheel, or sneak + right-click anywhere on the ship. Click the pickaxe to disassemble." + + - title: "Cannons" + content: "Dispenser + obsidian behind it. Right-click the obsidian to fire, or use the fireball in the menu." + + - title: "Functionality" + content: "Chests, barrels, and other containers work on ships. Attach leads to fences to bring mobs or boats along. Stairs work as extra seats for players." + + - title: "Weight & Buoyancy" + content: "Wood/wool = light, metals = heavy. Glowstone/end rods = lighter than air (airship!). Click the book to detect your ship and see more info." diff --git a/docs/wip/TODO-minecart.md b/docs/wip/TODO-minecart.md new file mode 100644 index 0000000..76cb632 --- /dev/null +++ b/docs/wip/TODO-minecart.md @@ -0,0 +1,660 @@ +# Minecart Ship Feature - Design Document + +## Overview + +A special "ship minecart" that, when powered by an activator rail, scans the block structure above it and converts it into a passive display ship. The minecart rides on rails as normal; the ship follows along. A second activator rail signal disassembles the ship back into blocks. This enables train-like builds on rail networks. + +--- + +## User Flow + +1. Player crafts a **Ship Minecart** item (minecart + ship wheel + pistons) +2. Places it on a rail — spawns a minecart with a visible distinguishing block +3. Player builds a block structure above the placed minecart +4. Pushes the minecart onto a powered **activator rail** — blocks above are scanned and assembled into a display ship attached to the minecart +5. Minecart moves along rails; the ship display follows passively (no custom physics) +6. Minecart hits another powered activator rail — ship disassembles, blocks are placed back into the world +7. Breaking the minecart also disassembles the ship and drops the ship minecart item + +--- + +## Architecture + +### Key Insight: Reuse ShipInstance + +`ShipInstance` already contains all the display entity spawning, collision box positioning, display transform updating, and cleanup logic needed. The only ArmorStand-specific code is: + +- **Constructor** (lines 304-320): spawns an ArmorStand as the root vehicle entity +- **Health system** (~12 call sites across ShipInstance, DisplayShip, ShipWheelManager): calls `vehicle.getHealth()`, `vehicle.getAttribute()`, `vehicle.setHealth()` — these are `LivingEntity` methods that `Minecart` does not implement +- **Recovery** (lines 1980-1981, 2021): `instanceof ArmorStand` casts when recovering entity references after chunk reload + +Everything else — display entity spawning, collision shulker spawning, `updateCollisionPositions()`, display transform matrix computation, `destroy()`, passenger mounting — uses generic `Entity` methods (`getLocation`, `getYaw`, `isValid`, `addPassenger`, etc.). + +Rather than creating a parallel class hierarchy or extracting shared code, we widen `ShipInstance.vehicle` from `ArmorStand` to `Entity`, add an `isMinecartShip` flag, and short-circuit `tick()` for minecart mode. This gives us **zero code duplication** and **one new file** (the lifecycle manager). + +### New Components + +| Component | Location | Purpose | +|-----------|----------|---------| +| `MinecartShipManager` | `minecartships/MinecartShipManager.java` (NEW) | Lifecycle: placement, activator rail toggle, disassembly, events | +| `ship_minecart` item | `config.yml` custom-items section | Craftable item, PLAYER_HEAD with ship wheel texture | + +### Modified Components + +| Component | Change | +|-----------|--------| +| `ShipInstance` | Widen vehicle type, add minecart constructor + tick path | +| `DisplayShip` | Guard health call sites with `instanceof LivingEntity` | +| `ShipWheelManager` | Guard health call sites with `instanceof LivingEntity` | +| `ShipCollisionCoordinator` | Skip minecart ships in collision processing | +| `ShipPersistence` | Add `vehicleType` field to `ShipState` | +| `BlockShipsPlugin` | Register `MinecartShipManager` | + +--- + +## Detailed Implementation + +### 1. Ship Minecart Custom Item + +**File:** `blockships/src/main/resources/config.yml` + +Add to the `custom-items` section: + +```yaml +ship_minecart: + display-name: "Ship Minecart" + base-material: PLAYER_HEAD + texture-set: SHIP_WHEEL_SET # Reuse ship wheel texture; replace later + variant-source: null + stackable: false + recipe: + pattern: + - "P P" + - "PWP" + - " M " + ingredients: + P: [PISTON] + W: ["blockships:ship_wheel"] + M: [MINECART] + result-name: "Ship Minecart" + result-item: PLAYER_HEAD + result-texture-set: SHIP_WHEEL_SET +``` + +The existing `CustomItem` system creates PLAYER_HEAD items with PDC tag `custom_item_id: "ship_minecart"`. The existing `/blockships give ship_minecart` command, crafting recipe registration, and item detection all work automatically. + +--- + +### 2. ShipInstance: Widen Vehicle Type + +**File:** `blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipInstance.java` + +#### 2a. Change field type + +```java +// Line 72: was ArmorStand, now Entity +public Entity vehicle; // Root entity: ArmorStand (normal ships) or Minecart (minecart ships) + +// New field +public final boolean isMinecartShip; +``` + +#### 2b. Add minecart constructor + +A second constructor that receives an existing Minecart entity instead of spawning an ArmorStand: + +```java +public ShipInstance(JavaPlugin plugin, Minecart minecart, ShipModel model) { + this.plugin = plugin; + this.shipType = "custom"; // Minecart ships are always custom-scanned + this.model = model; + this.customization = ShipCustomization.empty(); + this.id = UUID.randomUUID(); + this.driverSeatIndex = 0; + this.isMinecartShip = true; + this.config = ShipConfig.load(plugin, "custom"); + this.isAirship = false; // Minecart ships don't fly + + this.vehicle = minecart; + minecart.addScoreboardTag(ShipTags.shipRootTag(id)); + + // No physics or collision delegates — minecart handles movement + this.physics = null; + this.collision = null; + + // Initialize rotation state + this.initialRotRadX = 0; + this.initialRotRadY = 0; + this.initialRotRadZ = 0; + cachedR_initial.identity(); + + this.previousVehicleLocation = minecart.getLocation().clone(); + this.previousYaw = minecart.getYaw(); + this.previousPitch = minecart.getPitch(); + this.spawnYaw = minecart.getYaw(); + + // Chunk tracking + this.currentChunkX = minecart.getLocation().getBlockX() >> 4; + this.currentChunkZ = minecart.getLocation().getBlockZ() >> 4; + + // === Display + collider spawning (lines 355-917) === + // This code is IDENTICAL to the existing constructor — it only uses: + // this.id, this.model, this.config, this.customization, this.shipType + // and the vehicle's location (via generic Entity.getLocation()) + // No ArmorStand-specific calls. + // ... (same display spawning, collision shulker spawning, inventory restoration) + + // Mount parent to minecart (same as mounting to ArmorStand) + // vehicle.addPassenger(parent); + + // Start tick task (same as existing, will dispatch to tickMinecart()) +} +``` + +#### 2c. Existing constructor + +Add `this.isMinecartShip = false;` to the existing ArmorStand-based constructor. + +--- + +### 3. Minecart Tick Path + +**File:** `blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipInstance.java` + +Add early dispatch at the top of `tick()`: + +```java +void tick() { + if (isMinecartShip) { + tickMinecart(); + return; + } + // ... existing full physics tick (health, steering, collision, physics, display) ... +} +``` + +New method — dramatically simpler than the full tick: + +```java +/** + * Simplified tick for minecart ships. + * The minecart handles all movement via rails. We just: + * 1. Update collision box positions to follow the minecart + * 2. Update display entity transforms if the minecart moved/rotated + * + * Skipped: health regen, steering input, collision detection, + * physics (acceleration/drag/buoyancy/rotation), collision response, + * velocity sync, idle-to-sleep transition. + */ +private void tickMinecart() { + cachedVehicleLoc = vehicle.getLocation(); + if (!cachedVehicleLoc.isChunkLoaded()) return; + if (vehicle.isDead() || !vehicle.isValid()) { + destroyWithPersistenceCleanup(); + return; + } + + // Sync collision boxes to follow the minecart + updateCollisionPositions(); + + // Check if minecart moved or rotated + Location currentLoc = cachedVehicleLoc; + float yaw = vehicle.getYaw(); + float pitch = vehicle.getPitch(); + + boolean hasMoved = hasMovedSinceLastTick(currentLoc, yaw, pitch); + if (!hasMoved && !firstTick) { + previousVehicleLocation = currentLoc.clone(); + previousYaw = yaw; + previousPitch = pitch; + return; + } + + firstTick = false; + ticksSinceLastMovement = 0; + + // Update chunk tracking + int newChunkX = currentLoc.getBlockX() >> 4; + int newChunkZ = currentLoc.getBlockZ() >> 4; + if (currentChunkX != newChunkX || currentChunkZ != newChunkZ) { + if (plugin instanceof BlockShipsPlugin bsp && bsp.getDisplayShip() != null) { + ShipWorldData worldData = bsp.getDisplayShip().getShipWorldData(); + worldData.updateChunkIndex(currentLoc.getWorld(), this.id, + currentChunkX, currentChunkZ, newChunkX, newChunkZ); + } + currentChunkX = newChunkX; + currentChunkZ = newChunkZ; + } + + // Update display transforms (same logic as lines 1300-1341) + previousVehicleLocation = currentLoc.clone(); + previousYaw = yaw; + previousPitch = pitch; + + // Build rotation + apply to displays + // (reuse existing buildRotationMatrix() and display transform code) +} +``` + +--- + +### 4. Guard Health Code + +All `vehicle.getHealth()`, `vehicle.getAttribute()`, `vehicle.setHealth()` calls must be guarded since `Minecart` is not a `LivingEntity`. + +**ShipInstance.java** — health regen in `tick()` (lines 1179-1210): +Already skipped for minecart ships because `tickMinecart()` returns before reaching this code. No change needed here. + +**DisplayShip.java** — damage handlers (~6 call sites at lines 1473, 1477, 1502, 1548, 1551, 1571, 1705, 1707): + +```java +// Before: +double currentHealth = inst.vehicle.getHealth(); + +// After: +if (!(inst.vehicle instanceof LivingEntity lv)) return; +double currentHealth = lv.getHealth(); +``` + +For minecart ships, damage events on collision shulkers are simply ignored (health system is deferred). + +**ShipWheelManager.java** — ship info display (~2 call sites at lines 675, 677): + +```java +// Before: +double currentHealth = ship.vehicle.getHealth(); + +// After: +if (ship.vehicle instanceof LivingEntity lv) { + double currentHealth = lv.getHealth(); + // ... show health info +} +``` + +--- + +### 5. Update Recovery Code + +**File:** `blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipInstance.java` + +`recoverVehicle()` (line 1976) — accept Minecart as well as ArmorStand: + +```java +for (Entity entity : chunk.getEntities()) { + if ((entity instanceof ArmorStand || entity instanceof Minecart) + && entity.getScoreboardTags().contains(rootTag)) { + this.vehicle = entity; + return true; + } +} +``` + +`recoverEntities()` (line 2003) — same change at line 2021: + +```java +if ((e instanceof ArmorStand || e instanceof Minecart) + && e.getScoreboardTags().contains(rootTag)) { + vehicle = e; + break; +} +``` + +--- + +### 6. Skip Minecart Ships in Collision Coordinator + +**File:** `blockships/src/main/java/anon/def9a2a4/blockships/ship/ShipCollisionCoordinator.java` + +At line 111, in the ship iteration loop: + +```java +for (ShipInstance ship : ShipRegistry.getAllShips()) { + if (ship.vehicle == null || ship.vehicle.isDead()) continue; + if (ship.isMinecartShip) continue; // <-- NEW: skip minecart ships + // ... existing collision processing +} +``` + +Minecart ships have collision boxes (for player walking), but don't generate or receive collision response forces. This is deferred to a future update. + +--- + +### 7. MinecartShipManager + +**New file:** `blockships/src/main/java/anon/def9a2a4/blockships/minecartships/MinecartShipManager.java` + +#### 7a. State tracking + +```java +public class MinecartShipManager implements Listener { + private final JavaPlugin plugin; + private final Map tracked = new HashMap<>(); + private BukkitRunnable tickTask; + + // Mutable state per tracked minecart + private static class MinecartState { + final Minecart minecart; + ShipInstance assembledShip; // null when unassembled + ShipModel sourceModel; // stored for disassembly block placement + boolean wasOnPoweredActivatorRail; // edge detection flag + + MinecartState(Minecart minecart) { + this.minecart = minecart; + this.wasOnPoweredActivatorRail = false; + } + } +} +``` + +#### 7b. Placement + +Listen for `PlayerInteractEvent` with `RIGHT_CLICK_BLOCK` action: + +```java +@EventHandler +public void onPlaceMinecart(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK) return; + ItemStack item = event.getItem(); + if (!isShipMinecartItem(item)) return; + + Block block = event.getClickedBlock(); + if (block == null) return; + Material type = block.getType(); + if (type != Material.RAIL && type != Material.POWERED_RAIL + && type != Material.DETECTOR_RAIL && type != Material.ACTIVATOR_RAIL) return; + + event.setCancelled(true); + + // Spawn minecart on the rail + Location spawnLoc = block.getLocation().add(0.5, 0, 0.5); + Minecart minecart = block.getWorld().spawn(spawnLoc, RideableMinecart.class, m -> { + m.addScoreboardTag("blockships:ship_minecart"); + m.setDisplayBlockData(Bukkit.createBlockData(Material.LODESTONE)); + m.getPersistentDataContainer().set( + new NamespacedKey(plugin, "ship_minecart"), PersistentDataType.BYTE, (byte) 1); + }); + + tracked.put(minecart.getUniqueId(), new MinecartState(minecart)); + + // Consume one item from hand + item.setAmount(item.getAmount() - 1); +} + +private boolean isShipMinecartItem(ItemStack stack) { + if (stack == null || !stack.hasItemMeta()) return false; + PersistentDataContainer pdc = stack.getItemMeta().getPersistentDataContainer(); + NamespacedKey key = new NamespacedKey(plugin, "custom_item_id"); + return "ship_minecart".equals(pdc.get(key, PersistentDataType.STRING)); +} +``` + +#### 7c. Activator rail toggle (tick task) + +A repeating task checks all tracked minecarts for activator rail contact: + +```java +public void startTickTask() { + tickTask = new BukkitRunnable() { + @Override + public void run() { + Iterator> it = tracked.entrySet().iterator(); + while (it.hasNext()) { + MinecartState state = it.next().getValue(); + if (!state.minecart.isValid() || state.minecart.isDead()) { + if (state.assembledShip != null) { + state.assembledShip.destroy(); + } + it.remove(); + continue; + } + + boolean onPowered = isOnPoweredActivatorRail(state.minecart); + + // Rising edge detection: only trigger on transition from off -> on + if (onPowered && !state.wasOnPoweredActivatorRail) { + if (state.assembledShip == null) { + assemble(state); + } else { + disassemble(state); + } + } + + state.wasOnPoweredActivatorRail = onPowered; + } + } + }; + tickTask.runTaskTimer(plugin, 0L, 1L); +} + +private boolean isOnPoweredActivatorRail(Minecart minecart) { + Block block = minecart.getLocation().getBlock(); + if (block.getType() != Material.ACTIVATOR_RAIL) return false; + org.bukkit.block.data.BlockData data = block.getBlockData(); + if (data instanceof org.bukkit.block.data.type.RedstoneRail rr) { + return rr.isPowered(); + } + return false; +} +``` + +The edge detection guarantees: +- One activator rail press = one toggle (assemble OR disassemble, not both) +- The minecart must **leave** the powered activator rail before it can trigger again +- Multiple ticks on the same powered rail do NOT re-trigger + +#### 7d. Assembly + +```java +private void assemble(MinecartState state) { + Location minecartLoc = state.minecart.getLocation(); + Location aboveMinecart = minecartLoc.clone().add(0, 1, 0); + + // Check there's actually a block to scan + if (aboveMinecart.getBlock().getType().isAir()) return; + + // Derive facing from minecart velocity or rail direction + BlockFace facing = deriveFacing(state.minecart); + + // Scan connected blocks (reuse existing flood-fill scanner) + ShipModel model; + try { + model = BlockStructureScanner.scanStructure(aboveMinecart, facing); + } catch (Exception e) { + plugin.getLogger().warning("Minecart ship scan failed: " + e.getMessage()); + return; + } + + if (model == null || model.parts.isEmpty()) return; + + // Create ship instance using the minecart as root vehicle + ShipInstance ship = new ShipInstance(plugin, state.minecart, model); + + // Remove scanned blocks from the world + BlockStructureScanner.removeBlocks(aboveMinecart, model); + + // Register with the ship system + ShipRegistry.register(ship); + + // Track for disassembly + state.assembledShip = ship; + state.sourceModel = model; +} +``` + +#### 7e. Disassembly + +```java +private void disassemble(MinecartState state) { + if (state.assembledShip == null) return; + + Location minecartLoc = state.minecart.getLocation(); + float currentYaw = state.minecart.getYaw(); + + // Place blocks back into the world + BlockStructureScanner.placeBlocks(minecartLoc.clone().add(0, 1, 0), + state.sourceModel, currentYaw, false); + + // Destroy ship entities (displays, colliders) and unregister + state.assembledShip.destroy(); + + // Minecart stays on the rail — revert to unassembled state + state.assembledShip = null; + state.sourceModel = null; +} +``` + +#### 7f. Event handlers + +```java +@EventHandler +public void onMinecartDestroyed(VehicleDestroyEvent event) { + if (!(event.getVehicle() instanceof Minecart minecart)) return; + MinecartState state = tracked.get(minecart.getUniqueId()); + if (state == null) return; + + // Disassemble ship before minecart is destroyed + if (state.assembledShip != null) { + disassemble(state); + } + + tracked.remove(minecart.getUniqueId()); + + // Drop ship minecart item at the location + Location loc = minecart.getLocation(); + ItemStack drop = createShipMinecartItem(); + loc.getWorld().dropItemNaturally(loc, drop); + + // Cancel the default minecart drop (we drop our custom item instead) + event.setCancelled(true); + minecart.remove(); +} + +@EventHandler +public void onPlayerEnterMinecart(VehicleEnterEvent event) { + if (!(event.getVehicle() instanceof Minecart minecart)) return; + if (!tracked.containsKey(minecart.getUniqueId())) return; + // Prevent players from riding the minecart directly + // They should use seat shulkers instead + event.setCancelled(true); +} +``` + +--- + +### 8. Persistence + +**File:** `blockships/src/main/java/anon/def9a2a4/blockships/ShipPersistence.java` + +#### 8a. Add vehicleType to ShipState + +```java +public static final class ShipState { + // ... existing fields ... + public final String vehicleType; // "armor_stand" or "minecart" + + // Update constructor to include vehicleType + // Default to "armor_stand" for backwards compatibility +} +``` + +#### 8b. Serialization + +`fromInstance()`: + +```java +String vehicleType = inst.isMinecartShip ? "minecart" : "armor_stand"; +``` + +`toMap()` / `fromMap()`: serialize/deserialize the `vehicleType` field. `fromMap()` defaults to `"armor_stand"` if the field is missing (backwards compat with existing save files). + +#### 8c. Loading + +In `loadAll()`, after deserializing the ShipState: + +```java +if ("minecart".equals(state.vehicleType)) { + // Spawn a fresh minecart at the saved location + Minecart minecart = world.spawn(loc, RideableMinecart.class, m -> { + m.addScoreboardTag("blockships:ship_minecart"); + m.setDisplayBlockData(Bukkit.createBlockData(Material.LODESTONE)); + }); + + // Create ship using minecart constructor + ShipInstance instance = new ShipInstance(plugin, minecart, model); + ShipRegistry.register(instance); + + // Also register with MinecartShipManager for rail toggle tracking + minecartShipManager.registerLoadedMinecart(minecart, instance, model); +} else { + // Existing ArmorStand-based loading path + ShipInstance instance = new ShipInstance(plugin, state.shipType, model, loc, customization); + // ... +} +``` + +#### 8d. Orphan cleanup + +In `cleanupOrphanedEntities()`, also remove orphaned ship minecarts: + +```java +if (entity instanceof Minecart + && entity.getScoreboardTags().contains("blockships:ship_minecart")) { + entity.remove(); + removedCount++; +} +``` + +--- + +### 9. Register Manager + +**File:** `blockships/src/main/java/anon/def9a2a4/blockships/BlockShipsPlugin.java` + +In `onEnable()`: + +```java +MinecartShipManager minecartShipManager = new MinecartShipManager(this); +getServer().getPluginManager().registerEvents(minecartShipManager, this); +minecartShipManager.startTickTask(); +``` + +Pass the manager reference to `ShipPersistence` for the load path (step 8c). + +--- + +## Summary + +| File | Change | Estimated Scope | +|------|--------|----------------| +| `ship/ShipInstance.java` | Widen vehicle to Entity, add minecart constructor + tick path, guard health | ~80 lines | +| `DisplayShip.java` | Guard ~6 health call sites with `instanceof LivingEntity` | ~12 lines | +| `customships/ShipWheelManager.java` | Guard ~2 health call sites with `instanceof LivingEntity` | ~4 lines | +| `ship/ShipCollisionCoordinator.java` | Skip `isMinecartShip` in collision loop | 1 line | +| `ShipPersistence.java` | Add `vehicleType` field, load path for minecart ships | ~30 lines | +| `BlockShipsPlugin.java` | Create and register `MinecartShipManager` | ~5 lines | +| `config.yml` | Add `ship_minecart` custom item + recipe | ~15 lines | +| **NEW** `minecartships/MinecartShipManager.java` | Placement, rail toggle, assembly, disassembly, events | ~250 lines | + +**Total: 1 new file, ~400 lines of changes. Zero code duplication.** + +--- + +## Deferred + +- [ ] Collision response — terrain/ship collision forces applied to minecart velocity +- [ ] Train coupling — linking multiple minecart ships together +- [ ] Minecart speed limits when carrying a ship +- [ ] Health/damage system for minecart ships +- [ ] Custom texture for ship_minecart item (currently reuses ship wheel) + +--- + +## Edge Cases + +- **Empty scan**: no valid blocks above minecart when activator rail fires — do nothing, stay unassembled +- **Minecart destroyed while assembled**: disassemble first (place blocks), then destroy +- **Player disconnects while seated**: existing `PlayerQuitEvent` handler in DisplayShip already dismounts players from ship shulkers +- **Chunk unload/reload**: existing chunk recovery system handles entity references via scoreboard tags; recovery code updated to accept Minecart in addition to ArmorStand +- **Large ships**: collision boxes may extend beyond the rail corridor; acceptable for first pass (no collision response); players can walk on the ship deck via shulkers +- **Minecart yaw on curves**: changes abruptly on corner rails; display interpolation (`setInterpolationDuration(2)`) smooths this over 2 ticks +- **Rail direction for scan facing**: derive from the rail shape at the activator rail block, or from minecart velocity if nonzero diff --git a/docs/wip/TODO-stats-fixes.md b/docs/wip/TODO-stats-fixes.md new file mode 100644 index 0000000..66d0591 --- /dev/null +++ b/docs/wip/TODO-stats-fixes.md @@ -0,0 +1,249 @@ +# Ship Stats System — Remaining Fixes + +## UI Math Issues + +### 1. Hardcoded sail cap ratio in stats lore +**File:** `ShipWheelMenu.java:525, 556` + +`sailCapPoints` and `cappedSailPower` use hardcoded `0.8f * info.mass` instead of `info.sailCapRatio * info.mass`. The speed% calculation (line 564) was already fixed to use `info.sailCapRatio`, but these two were missed. + +**Impact:** If the sail cap ratio is changed in config, the lore will show wrong cap points and wrong effective power, but actual physics will use the config value correctly. + +### 2. Density calculation mismatch between menu and physics +**File:** `ShipWheelMenu.java:362` + +```java +float density = blockCount > 0 ? (float) totalWeight / blockCount : 0; +``` + +`blockCount` here is `getLastDetectedBlockCount()` which is `shipBlocks.size()` — the **total number of blocks** including null-weight blocks. But `ShipModel.getDensity()` uses `blockCount` which is `weightedBlockCount` — only blocks that have a weight value in blocks.yml. + +The assembly path (ShipWheelManager:270) passes `model.parts.size()` (all blocks) as blockCount, while the model's internal `blockCount` is `weightedBlockCount`. + +**Impact:** Density shown in the menu is lower than the density used for actual buoyancy/airship detection, because it divides by a larger number. This means the menu might show a density that looks like a water ship, but the actual physics treats it as an airship. + +**Fix:** Store `weightedBlockCount` separately in ShipWheelData, or compute density from the ShipModel directly for assembled ships. + +--- + +## Engine Fuel GUI Issues + +### 3. Pre-assembly engine GUI reads wrong furnace slots +**File:** `EngineMenuGUI.java:270-273` + +```java +org.bukkit.inventory.Inventory blockInv = container.getSnapshotInventory(); +for (int i = 0; i < FUEL_SLOTS.length && i < blockInv.getSize(); i++) { + gui.setItem(FUEL_SLOTS[i], blockInv.getItem(i)); +} +``` + +Blast furnace container has 3 slots: [0]=smelt input, [1]=fuel, [2]=output. The code loads ALL three into the engine GUI's fuel slots. Items in the input/output slots from vanilla mechanics or hoppers would incorrectly appear as "fuel." + +**Fix:** Only read from the blast furnace's fuel slot (index 1), or use all 3 container slots exclusively for engine fuel (acceptable since vanilla smelting is suppressed on engine blocks). If using all 3, this should be documented. + +### 4. saveBlockFuelState clears entire furnace container +**File:** `EngineMenuGUI.java:302` + +`blockInv.clear()` wipes all 3 slots of the blast furnace before writing fuel back. If a hopper loaded items into the vanilla fuel slot while the GUI was open, those items are lost. + +**Fix:** Only clear/write the slots we actually use, or accept this behavior since smelting is suppressed and the custom GUI is the intended interface. + +### 5. Engine GUI status doesn't update live +**File:** `EngineMenuGUI.java:112-165` + +The status item (Running/Ready/Idle) is set once when the GUI opens and never updates. If the ship is sailing and fuel is burning while the GUI is open, the status item doesn't reflect changes until the GUI is reopened. + +**Impact:** Minor UX issue — the status is stale while the GUI is open. + +--- + +## Naming Inconsistencies + +### 6. `lastDetectedPositiveWeight` not renamed to match `mass` +**File:** `ShipWheelData.java:42, 137, 144` + +`ShipModel.totalPositiveWeight` was renamed to `mass`, but `ShipWheelData.lastDetectedPositiveWeight` and its getter `getLastDetectedPositiveWeight()` still use the old name. Functionally correct, but inconsistent. + +### 7. `blockCount` means different things in different contexts +- `ShipModel.blockCount` = weighted block count (blocks with non-null weight) +- `ShipWheelData.lastDetectedBlockCount` = total block count (all blocks) +- `model.parts.size()` = total block count + +This caused bug #2 above and is a source of confusion. + +--- + +## Edge Cases + +### 8. Pre-assembly fuel detection shows 0 for all engines +**File:** `ShipWheelMenu.java:411`, `ShipWheelManager.java` (detection) + +For unassembled ships, `fueledEngines = 0` is hardcoded. The blast furnace containers might have fuel loaded via hoppers or the custom placed-block GUI, but the code doesn't check. + +**Impact:** Misleading — player loads fuel via hoppers or the placed-engine GUI, but ship info still says all engines are unfueled until assembly. + +**Fix (planned):** During detection in `ShipWheelManager.detectShip()`, for each engine blast furnace, check its container inventory for fuel items. Count engines with fuel and pass `fueledEngines` to `setLastDetectedStats()` (needs a new parameter). `getShipInfo()` unassembled path should use the detected fueled count instead of hardcoded 0. + +### 9. Stale fuel entries not cleared on disassembly +**File:** `ShipWheelData.java` + +`engineFuelSlots` and `engineBurnTicks` maps are never cleared when a ship is disassembled. If the player rebuilds with different engine block indices, old entries for old indices remain in the maps. + +**Impact:** Mostly harmless (old indices won't match new engine indices), but wastes memory and could cause confusion if block indices happen to overlap. + +--- + +## Chat / Lore Mismatch + +### 18. Detection chat messages don't match stats lore +**File:** `ShipWheelManager.java:770-796` + +Stats banner lore shows: wool/banner breakdown, sail power with cap, fueled/unfueled engines, mass, effective power, power ratio, speed%. Chat detection messages show: sails (using "power" not "pts"), engines (always unfueled), speed% — but no mass, no effective power, no power ratio. Terminology inconsistent ("power" vs "pts"). + +### 19. Assembled ship detect produces no chat output +**File:** `ShipWheelManager.java:674-689` + +Clicking Detect on an assembled ship updates `lastDetected*` fields and returns `true` silently. No chat messages sent. User gets no feedback that detection ran. + +**Fix:** Add chat messages matching the lore format for the assembled path, including live fuel state. + +--- + +## Engine Fuel Behavior Issues + +### 10. Status item doesn't refresh when clicked +**File:** `DisplayShip.java` `onEngineMenuClick`, `EngineMenuGUI.java` + +The status slot (Running/Ready/Idle) click is cancelled as a non-fuel slot. Should detect STATUS_SLOT click, save current fuel state from GUI to wheelData, regenerate the status item, and update it in the inventory. All status lore variants should include "Click to refresh" hint. + +### 11. Fuel only burns when W (forward) is held +**File:** `ShipPhysics.java:204-208` + +`tickEngineFuel()` is guarded by `ship.isForwardPressed` only. Turning (A/D), reversing (S), ascending (Space), and descending (Sprint) don't consume fuel. Fuel should burn whenever any movement input is active. + +**Fix:** Change guard to `ship.isForwardPressed || ship.isBackwardPressed || ship.isLeftPressed || ship.isRightPressed || ship.isSpacePressed || ship.isSprintPressed`. + +### 12. Smoke appears even when ship is stationary +**File:** `ShipInstance.java` `spawnEngineSmoke()`, `ShipPhysics.java` + +Smoke checks `burnTicks > 0` which persists even when parked. Should only emit smoke when fuel is actively being consumed (i.e. movement keys are held). Add a `fuelBurningThisTick` flag on ShipPhysics, set in `tickEngineFuel()`, checked in `spawnEngineSmoke()`. + +### 13. Fuel burn times not configurable +**File:** `EngineMenuGUI.java:148-170` + +`VALID_FUELS` set and `getBurnTime()` switch are hardcoded. Server admins can't add/remove fuels or tweak burn times without recompiling. Consider moving to config.yml or at minimum using Bukkit's fuel API if available. + +### 14. Hopper fuel loading only reaches furnace slot 1 +**File:** `EngineMenuGUI.java` (pre-assembly GUI) + +Vanilla hoppers push items into the blast furnace's fuel slot (container index 1). But the engine GUI treats all 3 container slots as fuel. Items loaded by hoppers only go into slot 1 — slots 0 and 2 are unreachable by hoppers. This means only 1 of the 3 fuel slots can be hopper-fed pre-assembly. + +**Decision needed:** Either accept this (1 hopper-loadable slot + 2 manual slots), or use only the fuel slot for hopper compat and use the other 2 slots for something else (or remove them). + +### 15. GUI fuel slots should be {0,1,2} not {1,2,3} +**File:** `EngineMenuGUI.java` + +Current `FUEL_SLOTS = {1, 2, 3}`, `STATUS_SLOT = 5`. Changing to `{0, 1, 2}` and `STATUS_SLOT = 4`: +- Simplifies `openForBlock`/`saveBlockFuelState` — blast furnace container slots 0,1,2 map directly to GUI slots +- Cleaner layout: fuel leftmost, status in middle, filler on right +- Only fill slots 3-8 with glass panes (skip fuel slots 0-2) + +### 16. Pre-assembly fuel not transferred to wheelData on assembly +**File:** `ShipWheelManager.java` (assembly path, ~line 266) + +When a ship is assembled, blast furnace container inventories are serialized into `rawYaml.container_items` by BlockStructureScanner. But `wheelData.engineFuelSlots` is never populated from these containers. Fuel loaded pre-assembly (via hoppers or the placed-engine GUI) is trapped in rawYaml and inaccessible until disassembly. + +**Impact:** Player loads fuel into engines before assembly, assembles ship, opens engine GUI — fuel slots are empty. Fuel reappears only after disassembly. + +**Fix:** During assembly (after `ship.wheelData = wheelData`), iterate `model.engineBlockIndices`, read each engine's container inventory from the scanned model parts' rawYaml `container_items`, and populate `wheelData.engineFuelSlots` with those items. + +### 17. Engine block destroyed by explosion drops vanilla blast furnace +**File:** `DisplayShip.java` + +`onBreakShipEngine` only handles `BlockBreakEvent` (player-initiated). Explosions (`EntityExplodeEvent`, `BlockExplodeEvent`) bypass this handler and drop a vanilla blast furnace, losing the PDC tag and glint. + +**Fix:** Add `EntityExplodeEvent`/`BlockExplodeEvent` handlers that check for engine blocks in the exploded block list, remove them, and drop the custom engine item instead. + +### 18. Number-key hotbar swap bypasses fuel validation +**File:** `DisplayShip.java` `onEngineMenuClick` + +The click handler validates cursor placement and shift-clicks, but pressing number keys (1-9) while hovering a fuel slot swaps the hotbar item in without any fuel check. Double-click collect could also pull non-fuel items. + +**Fix:** Check `event.getAction()` for `HOTBAR_SWAP` / `HOTBAR_MOVE_AND_READD` and validate the hotbar item. For double-click, cancel `COLLECT_TO_CURSOR` actions when the top inventory is an engine GUI. + +### 18b. InventoryDragEvent not handled for engine GUI +**File:** `DisplayShip.java` + +`InventoryDragEvent` is a separate Bukkit event from `InventoryClickEvent`. Click-dragging to distribute items across multiple slots is not intercepted at all. A player can drag any item across fuel slots unchecked. + +**Fix:** Add an `InventoryDragEvent` handler that checks if any of the dragged-to slots are in an engine GUI. If the dragged item is not valid fuel, cancel. If any non-fuel slots are in the drag set, cancel. + +### 19. Stats not recomputed when fuel is added via GUI while stationary +**File:** `ShipPhysics.java` + +`computeEffectiveStats()` is called at construction and when `tickEngineFuel()` detects a fuel change. But `tickEngineFuel()` only runs while W is held. If a player adds fuel via the GUI while the ship is parked, the effective stats aren't recomputed until they start moving. The ship appears to still have the old (lower) stats until the first fuel tick fires. + +**Fix:** Call `computeEffectiveStats()` when the engine GUI closes (after `saveFuelState`), or add a dirty flag checked each tick. + +### 20. computeEffectiveStats() runs before wheelData is linked on construction/recovery +**File:** `ShipPhysics.java:56` + +`computeEffectiveStats()` is called in the ShipPhysics constructor, which runs inside the ShipInstance constructor — before `ship.wheelData` is set (ShipWheelManager:267). So the first stat computation always sees 0 fueled engines. After server restart, a ship with fuel and active burnTicks acts as unfueled until the player presses W (triggering `tickEngineFuel` → fuel change → recompute). + +Related to #19 but different trigger — #19 is about GUI close, this is about construction/recovery timing. + +**Fix:** Defer initial `computeEffectiveStats()` to first `update()` tick, or call it again after wheelData is linked. + +### 21. `engineLocalPositions` is dead data +**Files:** `ShipModel.java:57`, `BlockStructureScanner.java:320,396` + +`engineLocalPositions` (List) is populated during scan, serialized to YAML, and deserialized on load — but never read. The smoke particle code was rewritten to use collision shulker positions instead. This field, its constructor parameter, and its serialization/deserialization are all dead weight. + +**Fix:** Remove `engineLocalPositions` from ShipModel, BlockStructureScanner, and the YAML serialization. Keep `engineBlockIndices` (still used for click detection and fuel tracking). + +### 22. Fuel loaded while assembled lost on disassembly +**File:** `ShipWheelManager.java:375-398` (disassembly path) + +On disassembly, the code syncs `ship.storages` (chest/hopper inventories) back to `rawYaml.container_items` before placing blocks. But engine fuel stored in `wheelData.engineFuelSlots` is NOT synced back to the engine's container slots. The engine block is placed with whatever was in rawYaml from the original scan, losing any fuel loaded via the engine GUI while assembled. + +This is the reverse of #16: +- **#16:** fuel loaded pre-assembly → lost on assembly (container → wheelData gap) +- **#22:** fuel loaded while assembled → lost on disassembly (wheelData → container gap) + +**Fix:** Before `placeBlocks()`, iterate `model.engineBlockIndices`, read fuel from `wheelData.engineFuelSlots`, and write it into the engine part's `rawYaml.container_items`. + +### 23. Lava bucket consumed without returning empty bucket +**File:** `ShipPhysics.java:150-152` + +When a lava bucket is burned as fuel, its amount is decremented to 0 and the slot is set to null. Vanilla furnaces return an empty bucket. Players lose their bucket. + +**Fix:** After decrementing, if the consumed item was `LAVA_BUCKET`, set the slot to `new ItemStack(Material.BUCKET)` instead of null. + +### 24. Fuel ItemStack deserialization not crash-safe +**File:** `ShipWheelData.java` — `fromMap()` engine fuel deserialization + +`ItemStack.deserializeBytes()` is called without a try-catch. If the serialized data is corrupted or references a removed material (e.g., after a Minecraft version upgrade), this throws an exception and crashes the entire wheel data load, potentially losing all wheel data for that world. + +**Fix:** Wrap in try-catch per item, log a warning, and skip corrupted entries. + +### 25. Wool and banner power points hardcoded, not configurable +**Files:** `ShipModel.java`, `ShipWheelMenu.java`, `ShipWheelManager.java` + +`base-power` (2) and `engine-power` (30) are configurable in `config.yml`, but wool power (3) and banner power (7) are hardcoded as `woolCount * 3 + bannerCount * 7` in at least three places: ShipModel constructor, ShipWheelMenu.getShipInfo(), and ShipWheelManager detection chat. + +**Fix:** Add `wool-power: 3` and `banner-power: 7` to `config.yml` under `custom-ships.stats`. Load into ShipConfig. Pass to ShipModel or compute sailPower from config values everywhere. + +### 26. minMovementThreshold zeros speed while player is pressing W +**File:** `ShipPhysics.java:241` + +The threshold check runs unconditionally, even while the player is pressing W/S. We bumped `floorAcceleration` to 0.015 (above the 0.01 threshold) as a workaround, but if someone configures a lower floor they'd hit the same bug — acceleration gets zeroed every tick. + +**Fix:** Skip the threshold zero when `ship.isForwardPressed || ship.isBackwardPressed`. + +### 27. activeDeceleration and rotationDeceleration not scaled by ratio +**File:** `ShipPhysics.java:215, 290, 295` + +`activeDeceleration` (braking) and `rotationDeceleration` (rotation decay) use raw config values, not ratio-scaled effective values. A slow heavy ship brakes at the same rate as a fast light one. + +**Impact:** May be intentional (consistent braking), but inconsistent with acceleration/speed scaling. Low priority. diff --git a/docs/wip/TODO-stats.md b/docs/wip/TODO-stats.md index dd882e3..16599ba 100644 --- a/docs/wip/TODO-stats.md +++ b/docs/wip/TODO-stats.md @@ -135,9 +135,11 @@ Standard Minecraft furnace fuels: ### Engine GUI - Custom inventory menu (not the normal blast furnace smelting UI) -- **Multiple fuel slots** — load up fuel in advance -- Shows fuel remaining and estimated burn time -- **Hopper-compatible** if feasible (auto-fuel from adjacent hoppers) +- **3 fuel slots per engine** — queue up fuel in advance +- Shows fuel remaining and estimated burn time per engine +- Accessed by **right-clicking the engine block** on the assembled ship +- Multi-engine ships show all engines in one GUI view +- Crafting recipe: 8 copper ingots surrounding 1 blast furnace ### Engine Detection - Custom crafting recipe produces a blast furnace with PDC tag (`blockships:custom_item_id` = `"ship_engine"`)