From f2ecea463d2236d2bad3cebd3c4ba0bcaea56626 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Thu, 5 Nov 2026 10:25:02 +0200 Subject: [PATCH] Add a Market Automaton that trades from its own slots. Hoppers can fill and empty it without turning the original market block into a machine. --- CHANGELOG.md | 5 + .../fabric/VillagerMarketFabric.java | 22 +- .../client/VillagerMarketFabricClient.java | 14 +- .../gametest/FabricMarketGameTests.java | 20 + .../fabric/platform/FabricPlatformHelper.java | 42 ++ .../neoforge/VillagerMarketNeoForge.java | 36 +- .../client/VillagerMarketNeoForgeClient.java | 11 +- .../platform/NeoForgePlatformHelper.java | 36 ++ .../minecraft/villagermarket/ModContent.java | 52 ++- .../block/MarketAutomatonBlock.java | 74 ++++ .../entity/MarketAutomatonBlockEntity.java | 362 ++++++++++++++++++ .../block/entity/RedstoneMode.java | 30 ++ .../client/MarketAutomatonScreen.java | 292 ++++++++++++++ .../client/MarketScreenLogic.java | 44 ++- .../client/VillagerMarketScreen.java | 4 +- .../gametest/MarketGameTests.java | 124 +++++- .../villagermarket/menu/AutomatonOpening.java | 39 ++ .../villagermarket/menu/MachineSlots.java | 31 ++ .../menu/MarketAutomatonMenu.java | 163 ++++++++ .../network/AutomatonPayloads.java | 138 +++++++ .../platform/services/PlatformHelper.java | 9 + .../villagermarket/trade/ArmedTrade.java | 30 ++ .../villagermarket/trade/TradeExecutor.java | 7 +- .../blockstates/market_automaton.json | 5 + .../items/market_automaton.json | 6 + .../assets/villagermarket/lang/en_us.json | 10 +- .../models/block/market_automaton.json | 145 +++++++ .../models/item/market_automaton.json | 3 + .../textures/gui/sprites/similar.png | Bin 0 -> 230 bytes .../minecraft/tags/block/mineable/axe.json | 2 +- .../recipes/misc/market_automaton.json | 43 +++ .../loot_table/blocks/market_automaton.json | 10 + .../recipe/market_automaton.json | 13 + .../client/MarketAutomatonScreen.java | 280 ++++++++++++++ .../client/VillagerMarketScreen.java | 4 +- .../recipes/misc/market_automaton.json | 43 +++ .../recipe/market_automaton.json | 13 + 37 files changed, 2126 insertions(+), 36 deletions(-) create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/block/MarketAutomatonBlock.java create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/MarketAutomatonBlockEntity.java create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/RedstoneMode.java create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/menu/AutomatonOpening.java create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/menu/MachineSlots.java create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/menu/MarketAutomatonMenu.java create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/network/AutomatonPayloads.java create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/trade/ArmedTrade.java create mode 100644 src/main/resources/assets/villagermarket/blockstates/market_automaton.json create mode 100644 src/main/resources/assets/villagermarket/items/market_automaton.json create mode 100644 src/main/resources/assets/villagermarket/models/block/market_automaton.json create mode 100644 src/main/resources/assets/villagermarket/models/item/market_automaton.json create mode 100644 src/main/resources/assets/villagermarket/textures/gui/sprites/similar.png create mode 100644 src/main/resources/data/villagermarket/advancement/recipes/misc/market_automaton.json create mode 100644 src/main/resources/data/villagermarket/loot_table/blocks/market_automaton.json create mode 100644 src/main/resources/data/villagermarket/recipe/market_automaton.json create mode 100644 versions/1.21.1/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java create mode 100644 versions/1.21.1/src/main/resources/data/villagermarket/advancement/recipes/misc/market_automaton.json create mode 100644 versions/1.21.1/src/main/resources/data/villagermarket/recipe/market_automaton.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 674d371..66de2e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ After publishing, the action prepares the next development version and archives ## Unreleased +- A Market Automaton trades on its own, at the same speed as a hopper. Choose an offer, put the payment in the two slots or let a hopper fill them, and take the result from the bottom. The choice stays when the trade locks, and you can take the payment back out of the window. +- The furnace arrow sits between the payment and the result. A button on the right side of the window chooses whether the block ignores redstone, runs only while powered, or runs only while unpowered. +- Each automaton offer shows `[max: uses left]`, or `[locked]` once none remain. That label updates while the window stays open. +- Similar is the icon beside the redstone button. It also buys other villagers' copies of the selected trade and picks the cheapest one the slots can pay. + ## 2.0.0-alpha - Under the trades, [available / max until lock] explains the two numbers on each offer. diff --git a/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/VillagerMarketFabric.java b/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/VillagerMarketFabric.java index b5a6b7e..d450d4f 100644 --- a/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/VillagerMarketFabric.java +++ b/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/VillagerMarketFabric.java @@ -3,6 +3,7 @@ import net.modcrafters.minecraft.villagermarket.Constants; import net.modcrafters.minecraft.villagermarket.ModContent; import net.modcrafters.minecraft.villagermarket.VillagerMarket; +import net.modcrafters.minecraft.villagermarket.network.AutomatonPayloads; import net.modcrafters.minecraft.villagermarket.network.MarketPayloads; import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.creativetab.v1.FabricCreativeModeTab; @@ -18,14 +19,33 @@ public void onInitialize() { Registry.register(BuiltInRegistries.BLOCK, ModContent.MARKET_ID.identifier(), ModContent.marketBlock()); Registry.register(BuiltInRegistries.ITEM, ModContent.MARKET_ITEM_ID.identifier(), ModContent.marketItem()); Registry.register(BuiltInRegistries.MENU, ModContent.MENU_ID.identifier(), ModContent.menu()); + Registry.register(BuiltInRegistries.BLOCK, ModContent.AUTOMATON_ID.identifier(), ModContent.automatonBlock()); + Registry.register(BuiltInRegistries.ITEM, ModContent.AUTOMATON_ITEM_ID.identifier(), ModContent.automatonItem()); + Registry.register(BuiltInRegistries.BLOCK_ENTITY_TYPE, ModContent.AUTOMATON_ID.identifier(), ModContent.automatonType()); + Registry.register(BuiltInRegistries.MENU, ModContent.AUTOMATON_MENU_ID.identifier(), ModContent.automatonMenu()); Registry.register(BuiltInRegistries.CREATIVE_MODE_TAB, ModContent.TAB_ID.identifier(), ModContent.titled(FabricCreativeModeTab.builder()) - .displayItems((parameters, output) -> output.accept(ModContent.MARKET_ITEM)) + .displayItems((parameters, output) -> { + output.accept(ModContent.MARKET_ITEM); + output.accept(ModContent.AUTOMATON_ITEM); + }) .build()); PayloadTypeRegistry.clientboundPlay().register(MarketPayloads.Offers.TYPE, MarketPayloads.Offers.STREAM_CODEC); PayloadTypeRegistry.serverboundPlay().register(MarketPayloads.Trade.TYPE, MarketPayloads.Trade.STREAM_CODEC); + PayloadTypeRegistry.clientboundPlay().register(AutomatonPayloads.Recipe.TYPE, AutomatonPayloads.Recipe.STREAM_CODEC); + PayloadTypeRegistry.serverboundPlay().register(AutomatonPayloads.Select.TYPE, AutomatonPayloads.Select.STREAM_CODEC); + PayloadTypeRegistry.clientboundPlay().register(AutomatonPayloads.Mode.TYPE, AutomatonPayloads.Mode.STREAM_CODEC); + PayloadTypeRegistry.serverboundPlay().register(AutomatonPayloads.Redstone.TYPE, AutomatonPayloads.Redstone.STREAM_CODEC); + PayloadTypeRegistry.clientboundPlay().register(AutomatonPayloads.SimilarState.TYPE, AutomatonPayloads.SimilarState.STREAM_CODEC); + PayloadTypeRegistry.serverboundPlay().register(AutomatonPayloads.Similar.TYPE, AutomatonPayloads.Similar.STREAM_CODEC); ServerPlayNetworking.registerGlobalReceiver(MarketPayloads.Trade.TYPE, (payload, context) -> context.server().execute(() -> MarketPayloads.handleTrade(context.player(), payload))); + ServerPlayNetworking.registerGlobalReceiver(AutomatonPayloads.Select.TYPE, + (payload, context) -> context.server().execute(() -> AutomatonPayloads.handleSelect(context.player(), payload))); + ServerPlayNetworking.registerGlobalReceiver(AutomatonPayloads.Redstone.TYPE, + (payload, context) -> context.server().execute(() -> AutomatonPayloads.handleRedstone(context.player(), payload))); + ServerPlayNetworking.registerGlobalReceiver(AutomatonPayloads.Similar.TYPE, + (payload, context) -> context.server().execute(() -> AutomatonPayloads.handleSimilar(context.player(), payload))); Constants.LOG.info("Villager Market Fabric init"); } } diff --git a/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/client/VillagerMarketFabricClient.java b/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/client/VillagerMarketFabricClient.java index 72c0a38..f2b95c2 100644 --- a/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/client/VillagerMarketFabricClient.java +++ b/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/client/VillagerMarketFabricClient.java @@ -1,7 +1,9 @@ package net.modcrafters.minecraft.villagermarket.fabric.client; import net.modcrafters.minecraft.villagermarket.ModContent; +import net.modcrafters.minecraft.villagermarket.client.MarketAutomatonScreen; import net.modcrafters.minecraft.villagermarket.client.VillagerMarketScreen; +import net.modcrafters.minecraft.villagermarket.network.AutomatonPayloads; import net.modcrafters.minecraft.villagermarket.network.MarketPayloads; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; @@ -11,7 +13,17 @@ public class VillagerMarketFabricClient implements ClientModInitializer { @Override public void onInitializeClient() { MenuScreens.register(ModContent.menu(), VillagerMarketScreen::new); + MenuScreens.register(ModContent.automatonMenu(), MarketAutomatonScreen::new); ClientPlayNetworking.registerGlobalReceiver(MarketPayloads.Offers.TYPE, - (payload, context) -> context.client().execute(() -> VillagerMarketScreen.apply(payload))); + (payload, context) -> context.client().execute(() -> { + VillagerMarketScreen.apply(payload); + MarketAutomatonScreen.applyOffers(payload); + })); + ClientPlayNetworking.registerGlobalReceiver(AutomatonPayloads.Recipe.TYPE, + (payload, context) -> context.client().execute(() -> MarketAutomatonScreen.apply(payload))); + ClientPlayNetworking.registerGlobalReceiver(AutomatonPayloads.Mode.TYPE, + (payload, context) -> context.client().execute(() -> MarketAutomatonScreen.applyMode(payload))); + ClientPlayNetworking.registerGlobalReceiver(AutomatonPayloads.SimilarState.TYPE, + (payload, context) -> context.client().execute(() -> MarketAutomatonScreen.applySimilar(payload))); } } diff --git a/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/gametest/FabricMarketGameTests.java b/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/gametest/FabricMarketGameTests.java index f184667..3f4ddc2 100644 --- a/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/gametest/FabricMarketGameTests.java +++ b/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/gametest/FabricMarketGameTests.java @@ -29,4 +29,24 @@ public void adjustedPriceIsWhatIsCharged(GameTestHelper helper) { public void experienceLandsOnThePlayer(GameTestHelper helper) { MarketGameTests.experienceLandsOnThePlayer(helper); } + + @GameTest(structure = "minecraft:empty") + public void automatonTradesFromItsSlots(GameTestHelper helper) { + MarketGameTests.automatonTradesFromItsSlots(helper); + } + + @GameTest(structure = "minecraft:empty") + public void automatonRejectsADifferentBook(GameTestHelper helper) { + MarketGameTests.automatonRejectsADifferentBook(helper); + } + + @GameTest(structure = "minecraft:empty") + public void automatonStopsWithoutRedstone(GameTestHelper helper) { + MarketGameTests.automatonStopsWithoutRedstone(helper); + } + + @GameTest(structure = "minecraft:empty") + public void automatonBuysTheCheapestSimilar(GameTestHelper helper) { + MarketGameTests.automatonBuysTheCheapestSimilar(helper); + } } diff --git a/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/platform/FabricPlatformHelper.java b/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/platform/FabricPlatformHelper.java index 4f07da3..e83e180 100644 --- a/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/platform/FabricPlatformHelper.java +++ b/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/platform/FabricPlatformHelper.java @@ -1,5 +1,9 @@ package net.modcrafters.minecraft.villagermarket.fabric.platform; +import net.modcrafters.minecraft.villagermarket.ModContent; +import net.modcrafters.minecraft.villagermarket.block.entity.MarketAutomatonBlockEntity; +import net.modcrafters.minecraft.villagermarket.menu.AutomatonOpening; +import net.modcrafters.minecraft.villagermarket.menu.MarketAutomatonMenu; import net.modcrafters.minecraft.villagermarket.menu.OpeningData; import net.modcrafters.minecraft.villagermarket.menu.VillagerMarketMenu; import net.modcrafters.minecraft.villagermarket.platform.services.PlatformHelper; @@ -7,6 +11,8 @@ import net.fabricmc.fabric.api.menu.v1.ExtendedMenuProvider; import net.fabricmc.fabric.api.menu.v1.ExtendedMenuType; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +//? if <26.1 +/*import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;*/ import net.minecraft.core.BlockPos; import net.minecraft.network.chat.Component; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; @@ -16,6 +22,8 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.level.block.entity.BlockEntityType; +import java.util.Set; public class FabricPlatformHelper implements PlatformHelper { @Override @@ -28,6 +36,19 @@ public MenuType marketMenu() { return new ExtendedMenuType<>(VillagerMarketMenu::new, OpeningData.STREAM_CODEC); } + @Override + public MenuType automatonMenu() { + return new ExtendedMenuType<>(MarketAutomatonMenu::new, AutomatonOpening.STREAM_CODEC); + } + + @Override + public BlockEntityType automatonType() { + //? if >=26.1 { + return new BlockEntityType<>(MarketAutomatonBlockEntity::new, Set.of(ModContent.automatonBlock())); + //?} else + //return FabricBlockEntityTypeBuilder.create(MarketAutomatonBlockEntity::new, ModContent.automatonBlock()).build(); + } + @Override public void openMarket(ServerPlayer player, BlockPos pos) { OpeningData data = VillagerMarketMenu.opening((ServerLevel) player.level(), pos); @@ -49,6 +70,27 @@ public AbstractContainerMenu createMenu(int containerId, Inventory inventory, Pl }); } + @Override + public void openAutomaton(ServerPlayer player, BlockPos pos) { + AutomatonOpening data = MarketAutomatonMenu.opening((ServerLevel) player.level(), pos); + player.openMenu(new ExtendedMenuProvider() { + @Override + public AutomatonOpening getScreenOpeningData(ServerPlayer player) { + return data; + } + + @Override + public Component getDisplayName() { + return Component.translatable("block.villagermarket.market_automaton"); + } + + @Override + public AbstractContainerMenu createMenu(int containerId, Inventory inventory, Player player) { + return new MarketAutomatonMenu(containerId, inventory, data); + } + }); + } + @Override public void sendToPlayer(ServerPlayer player, CustomPacketPayload payload) { ServerPlayNetworking.send(player, payload); diff --git a/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/VillagerMarketNeoForge.java b/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/VillagerMarketNeoForge.java index b4fe65d..ad0726b 100644 --- a/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/VillagerMarketNeoForge.java +++ b/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/VillagerMarketNeoForge.java @@ -5,6 +5,7 @@ import net.modcrafters.minecraft.villagermarket.VillagerMarket; import net.modcrafters.minecraft.villagermarket.neoforge.client.VillagerMarketNeoForgeClient; import net.modcrafters.minecraft.villagermarket.neoforge.gametest.NeoForgeMarketGameTests; +import net.modcrafters.minecraft.villagermarket.network.AutomatonPayloads; import net.modcrafters.minecraft.villagermarket.network.MarketPayloads; import net.minecraft.core.registries.Registries; import net.minecraft.world.item.CreativeModeTab; @@ -35,9 +36,16 @@ private static void registerContent(RegisterEvent event) { event.register(Registries.BLOCK, ModContent.MARKET_ID.identifier(), ModContent::marketBlock); event.register(Registries.ITEM, ModContent.MARKET_ITEM_ID.identifier(), ModContent::marketItem); event.register(Registries.MENU, ModContent.MENU_ID.identifier(), ModContent::menu); + event.register(Registries.BLOCK, ModContent.AUTOMATON_ID.identifier(), ModContent::automatonBlock); + event.register(Registries.ITEM, ModContent.AUTOMATON_ITEM_ID.identifier(), ModContent::automatonItem); + event.register(Registries.BLOCK_ENTITY_TYPE, ModContent.AUTOMATON_ID.identifier(), ModContent::automatonType); + event.register(Registries.MENU, ModContent.AUTOMATON_MENU_ID.identifier(), ModContent::automatonMenu); event.register(Registries.CREATIVE_MODE_TAB, ModContent.TAB_ID.identifier(), () -> ModContent.titled(CreativeModeTab.builder()) - .displayItems((parameters, output) -> output.accept(ModContent.MARKET_ITEM)) + .displayItems((parameters, output) -> { + output.accept(ModContent.MARKET_ITEM); + output.accept(ModContent.AUTOMATON_ITEM); + }) .build()); } @@ -45,9 +53,31 @@ private static void registerPayloads(RegisterPayloadHandlersEvent event) { PayloadRegistrar registrar = event.registrar("1"); //? if >=1.21.6 { registrar.playToClient(MarketPayloads.Offers.TYPE, MarketPayloads.Offers.STREAM_CODEC); - //?} else - //registrar.playToClient(MarketPayloads.Offers.TYPE, MarketPayloads.Offers.STREAM_CODEC, (payload, context) -> net.modcrafters.minecraft.villagermarket.client.VillagerMarketScreen.apply(payload)); + //?} else { + /*registrar.playToClient(MarketPayloads.Offers.TYPE, MarketPayloads.Offers.STREAM_CODEC, (payload, context) -> { + net.modcrafters.minecraft.villagermarket.client.VillagerMarketScreen.apply(payload); + net.modcrafters.minecraft.villagermarket.client.MarketAutomatonScreen.applyOffers(payload); + }); + *///?} registrar.playToServer(MarketPayloads.Trade.TYPE, MarketPayloads.Trade.STREAM_CODEC, (payload, context) -> context.enqueueWork(() -> MarketPayloads.handleTrade((net.minecraft.server.level.ServerPlayer) context.player(), payload))); + //? if >=1.21.6 { + registrar.playToClient(AutomatonPayloads.Recipe.TYPE, AutomatonPayloads.Recipe.STREAM_CODEC); + //?} else + //registrar.playToClient(AutomatonPayloads.Recipe.TYPE, AutomatonPayloads.Recipe.STREAM_CODEC, (payload, context) -> net.modcrafters.minecraft.villagermarket.client.MarketAutomatonScreen.apply(payload)); + registrar.playToServer(AutomatonPayloads.Select.TYPE, AutomatonPayloads.Select.STREAM_CODEC, + (payload, context) -> context.enqueueWork(() -> AutomatonPayloads.handleSelect((net.minecraft.server.level.ServerPlayer) context.player(), payload))); + //? if >=1.21.6 { + registrar.playToClient(AutomatonPayloads.Mode.TYPE, AutomatonPayloads.Mode.STREAM_CODEC); + //?} else + //registrar.playToClient(AutomatonPayloads.Mode.TYPE, AutomatonPayloads.Mode.STREAM_CODEC, (payload, context) -> net.modcrafters.minecraft.villagermarket.client.MarketAutomatonScreen.applyMode(payload)); + registrar.playToServer(AutomatonPayloads.Redstone.TYPE, AutomatonPayloads.Redstone.STREAM_CODEC, + (payload, context) -> context.enqueueWork(() -> AutomatonPayloads.handleRedstone((net.minecraft.server.level.ServerPlayer) context.player(), payload))); + //? if >=1.21.6 { + registrar.playToClient(AutomatonPayloads.SimilarState.TYPE, AutomatonPayloads.SimilarState.STREAM_CODEC); + //?} else + //registrar.playToClient(AutomatonPayloads.SimilarState.TYPE, AutomatonPayloads.SimilarState.STREAM_CODEC, (payload, context) -> net.modcrafters.minecraft.villagermarket.client.MarketAutomatonScreen.applySimilar(payload)); + registrar.playToServer(AutomatonPayloads.Similar.TYPE, AutomatonPayloads.Similar.STREAM_CODEC, + (payload, context) -> context.enqueueWork(() -> AutomatonPayloads.handleSimilar((net.minecraft.server.level.ServerPlayer) context.player(), payload))); } } diff --git a/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/client/VillagerMarketNeoForgeClient.java b/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/client/VillagerMarketNeoForgeClient.java index 0e9169f..a7ac07a 100644 --- a/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/client/VillagerMarketNeoForgeClient.java +++ b/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/client/VillagerMarketNeoForgeClient.java @@ -1,7 +1,9 @@ package net.modcrafters.minecraft.villagermarket.neoforge.client; import net.modcrafters.minecraft.villagermarket.ModContent; +import net.modcrafters.minecraft.villagermarket.client.MarketAutomatonScreen; import net.modcrafters.minecraft.villagermarket.client.VillagerMarketScreen; +import net.modcrafters.minecraft.villagermarket.network.AutomatonPayloads; import net.modcrafters.minecraft.villagermarket.network.MarketPayloads; import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent; @@ -20,11 +22,18 @@ public static void register(IEventBus modBus) { private static void registerScreens(RegisterMenuScreensEvent event) { event.register(ModContent.menu(), VillagerMarketScreen::new); + event.register(ModContent.automatonMenu(), MarketAutomatonScreen::new); } //? if >=1.21.6 { private static void registerPayloads(RegisterClientPayloadHandlersEvent event) { - event.register(MarketPayloads.Offers.TYPE, (payload, context) -> VillagerMarketScreen.apply(payload)); + event.register(MarketPayloads.Offers.TYPE, (payload, context) -> { + VillagerMarketScreen.apply(payload); + MarketAutomatonScreen.applyOffers(payload); + }); + event.register(AutomatonPayloads.Recipe.TYPE, (payload, context) -> MarketAutomatonScreen.apply(payload)); + event.register(AutomatonPayloads.Mode.TYPE, (payload, context) -> MarketAutomatonScreen.applyMode(payload)); + event.register(AutomatonPayloads.SimilarState.TYPE, (payload, context) -> MarketAutomatonScreen.applySimilar(payload)); } //?} } diff --git a/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/platform/NeoForgePlatformHelper.java b/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/platform/NeoForgePlatformHelper.java index 4058435..4f3bd05 100644 --- a/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/platform/NeoForgePlatformHelper.java +++ b/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/platform/NeoForgePlatformHelper.java @@ -1,5 +1,9 @@ package net.modcrafters.minecraft.villagermarket.neoforge.platform; +import net.modcrafters.minecraft.villagermarket.ModContent; +import net.modcrafters.minecraft.villagermarket.block.entity.MarketAutomatonBlockEntity; +import net.modcrafters.minecraft.villagermarket.menu.AutomatonOpening; +import net.modcrafters.minecraft.villagermarket.menu.MarketAutomatonMenu; import net.modcrafters.minecraft.villagermarket.menu.OpeningData; import net.modcrafters.minecraft.villagermarket.menu.VillagerMarketMenu; import net.modcrafters.minecraft.villagermarket.platform.services.PlatformHelper; @@ -12,6 +16,8 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.level.block.entity.BlockEntityType; +import java.util.Set; import net.neoforged.neoforge.common.extensions.IMenuTypeExtension; import net.neoforged.neoforge.network.PacketDistributor; //? if >=1.21.6 @@ -29,6 +35,20 @@ public MenuType marketMenu() { new VillagerMarketMenu(containerId, inventory, OpeningData.STREAM_CODEC.decode(buf))); } + @Override + public MenuType automatonMenu() { + return IMenuTypeExtension.create((containerId, inventory, buf) -> + new MarketAutomatonMenu(containerId, inventory, AutomatonOpening.STREAM_CODEC.decode(buf))); + } + + @Override + public BlockEntityType automatonType() { + //? if >=26.1 { + return new BlockEntityType<>(MarketAutomatonBlockEntity::new, Set.of(ModContent.automatonBlock())); + //?} else + //return BlockEntityType.Builder.of(MarketAutomatonBlockEntity::new, ModContent.automatonBlock()).build(null); + } + @Override public void openMarket(ServerPlayer player, BlockPos pos) { OpeningData data = VillagerMarketMenu.opening((ServerLevel) player.level(), pos); @@ -45,6 +65,22 @@ public AbstractContainerMenu createMenu(int containerId, Inventory inventory, Pl }, buf -> OpeningData.STREAM_CODEC.encode(buf, data)); } + @Override + public void openAutomaton(ServerPlayer player, BlockPos pos) { + AutomatonOpening data = MarketAutomatonMenu.opening((ServerLevel) player.level(), pos); + player.openMenu(new net.minecraft.world.MenuProvider() { + @Override + public Component getDisplayName() { + return Component.translatable("block.villagermarket.market_automaton"); + } + + @Override + public AbstractContainerMenu createMenu(int containerId, Inventory inventory, Player player) { + return new MarketAutomatonMenu(containerId, inventory, data); + } + }, buf -> AutomatonOpening.STREAM_CODEC.encode(buf, data)); + } + @Override public void sendToPlayer(ServerPlayer player, CustomPacketPayload payload) { PacketDistributor.sendToPlayer(player, payload); diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/ModContent.java b/src/main/java/net/modcrafters/minecraft/villagermarket/ModContent.java index c6c3fff..748b6db 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/ModContent.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/ModContent.java @@ -1,6 +1,9 @@ package net.modcrafters.minecraft.villagermarket; +import net.modcrafters.minecraft.villagermarket.block.MarketAutomatonBlock; import net.modcrafters.minecraft.villagermarket.block.MarketBlock; +import net.modcrafters.minecraft.villagermarket.block.entity.MarketAutomatonBlockEntity; +import net.modcrafters.minecraft.villagermarket.menu.MarketAutomatonMenu; import net.modcrafters.minecraft.villagermarket.menu.VillagerMarketMenu; import net.modcrafters.minecraft.villagermarket.platform.Services; import net.minecraft.core.registries.Registries; @@ -12,6 +15,7 @@ import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.BlockBehaviour; import net.minecraft.world.level.material.MapColor; import net.minecraft.world.inventory.MenuType; @@ -23,19 +27,30 @@ public final class ModContent { Registries.ITEM, Identifier.fromNamespaceAndPath(Constants.MOD_ID, "villager_market")); public static final ResourceKey> MENU_ID = ResourceKey.create( Registries.MENU, Identifier.fromNamespaceAndPath(Constants.MOD_ID, "villager_market")); + public static final ResourceKey AUTOMATON_ID = ResourceKey.create( + Registries.BLOCK, Identifier.fromNamespaceAndPath(Constants.MOD_ID, "market_automaton")); + public static final ResourceKey AUTOMATON_ITEM_ID = ResourceKey.create( + Registries.ITEM, Identifier.fromNamespaceAndPath(Constants.MOD_ID, "market_automaton")); + public static final ResourceKey> AUTOMATON_MENU_ID = ResourceKey.create( + Registries.MENU, Identifier.fromNamespaceAndPath(Constants.MOD_ID, "market_automaton")); public static final ResourceKey TAB_ID = ResourceKey.create( Registries.CREATIVE_MODE_TAB, Identifier.fromNamespaceAndPath(Constants.MOD_ID, "villagermarket")); public static MarketBlock MARKET; public static BlockItem MARKET_ITEM; + public static MarketAutomatonBlock AUTOMATON; + public static BlockItem AUTOMATON_ITEM; + public static BlockEntityType AUTOMATON_TYPE; private static MenuType menu; + private static MenuType automatonMenu; private ModContent() { } public static void init() { menu = Services.PLATFORM.marketMenu(); + automatonMenu = Services.PLATFORM.automatonMenu(); Constants.LOG.info("{} common init", Constants.MOD_NAME); } @@ -43,40 +58,65 @@ public static MenuType menu() { return menu; } + public static MenuType automatonMenu() { + return automatonMenu; + } + public static MarketBlock marketBlock() { if (MARKET == null) { - MARKET = new MarketBlock(blockProperties()); + MARKET = new MarketBlock(wood(MARKET_ID)); } return MARKET; } public static BlockItem marketItem() { if (MARKET_ITEM == null) { - MARKET_ITEM = new BlockItem(marketBlock(), itemProperties()); + MARKET_ITEM = new BlockItem(marketBlock(), item(MARKET_ITEM_ID)); } return MARKET_ITEM; } + public static MarketAutomatonBlock automatonBlock() { + if (AUTOMATON == null) { + AUTOMATON = new MarketAutomatonBlock(wood(AUTOMATON_ID)); + } + return AUTOMATON; + } + + public static BlockItem automatonItem() { + if (AUTOMATON_ITEM == null) { + AUTOMATON_ITEM = new BlockItem(automatonBlock(), item(AUTOMATON_ITEM_ID)); + } + return AUTOMATON_ITEM; + } + + public static BlockEntityType automatonType() { + if (AUTOMATON_TYPE == null) { + AUTOMATON_TYPE = Services.PLATFORM.automatonType(); + } + return AUTOMATON_TYPE; + } + public static CreativeModeTab.Builder titled(CreativeModeTab.Builder builder) { return builder.title(Component.translatable("itemGroup.villagermarket")).icon(() -> new ItemStack(MARKET_ITEM)); } - private static BlockBehaviour.Properties blockProperties() { + private static BlockBehaviour.Properties wood(ResourceKey id) { var properties = BlockBehaviour.Properties.of() .mapColor(MapColor.COLOR_BROWN) .sound(SoundType.WOOD) .strength(3.0F); //? if >=1.21.2 - properties = properties.setId(MARKET_ID); + properties = properties.setId(id); return properties; } - private static Item.Properties itemProperties() { + private static Item.Properties item(ResourceKey id) { var properties = new Item.Properties(); //? if >=1.21.2 properties = properties.useBlockDescriptionPrefix(); //? if >=1.21.2 - properties = properties.setId(MARKET_ITEM_ID); + properties = properties.setId(id); return properties; } } diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/block/MarketAutomatonBlock.java b/src/main/java/net/modcrafters/minecraft/villagermarket/block/MarketAutomatonBlock.java new file mode 100644 index 0000000..f1b053c --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/block/MarketAutomatonBlock.java @@ -0,0 +1,74 @@ +package net.modcrafters.minecraft.villagermarket.block; + +import net.modcrafters.minecraft.villagermarket.ModContent; +import net.modcrafters.minecraft.villagermarket.block.entity.MarketAutomatonBlockEntity; +import net.modcrafters.minecraft.villagermarket.platform.Services; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; +//? if <26.1 { +/*import com.mojang.serialization.MapCodec; +import net.minecraft.world.Containers; +import net.minecraft.world.level.block.RenderShape; +*///?} + +public class MarketAutomatonBlock extends BaseEntityBlock { + //? if <26.1 { + /*private static final MapCodec CODEC = simpleCodec(MarketAutomatonBlock::new); + + @Override + protected MapCodec codec() { + return CODEC; + } + *///?} + + public MarketAutomatonBlock(Properties properties) { + super(properties); + } + + //? if <26.1 { + /*@Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.MODEL; + } + *///?} + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new MarketAutomatonBlockEntity(pos, state); + } + + @Override + public BlockEntityTicker getTicker(Level level, BlockState state, BlockEntityType type) { + if (level.isClientSide()) { + return null; + } + return createTickerHelper(type, ModContent.automatonType(), MarketAutomatonBlockEntity::serverTick); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { + if (player instanceof ServerPlayer serverPlayer) { + Services.PLATFORM.openAutomaton(serverPlayer, pos); + } + return InteractionResult.SUCCESS; + } + + //? if <26.1 { + /*@Override + protected void onRemove(BlockState state, Level level, BlockPos pos, BlockState newState, boolean movedByPiston) { + if (!state.is(newState.getBlock()) && level.getBlockEntity(pos) instanceof MarketAutomatonBlockEntity machine) { + Containers.dropContents(level, pos, machine); + } + super.onRemove(state, level, pos, newState, movedByPiston); + } + *///?} +} diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/MarketAutomatonBlockEntity.java b/src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/MarketAutomatonBlockEntity.java new file mode 100644 index 0000000..81d3edc --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/MarketAutomatonBlockEntity.java @@ -0,0 +1,362 @@ +package net.modcrafters.minecraft.villagermarket.block.entity; + +import net.modcrafters.minecraft.villagermarket.ModContent; +import net.modcrafters.minecraft.villagermarket.menu.MachineSlots; +import net.modcrafters.minecraft.villagermarket.menu.MarketAutomatonMenu; +import net.modcrafters.minecraft.villagermarket.menu.VillagerMarketMenu; +import net.modcrafters.minecraft.villagermarket.network.MarketPayloads; +import net.modcrafters.minecraft.villagermarket.platform.Services; +import net.modcrafters.minecraft.villagermarket.search.MarketSearch; +import net.modcrafters.minecraft.villagermarket.trade.ArmedTrade; +import net.modcrafters.minecraft.villagermarket.trade.ListedOffer; +import net.modcrafters.minecraft.villagermarket.trade.TradeExecutor; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.NonNullList; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.WorldlyContainer; +import net.minecraft.world.entity.npc.villager.Villager; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.trading.MerchantOffer; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.Vec3; + +import java.util.List; +//? if >=26.1 { +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +//?} else { +/*import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +*///?} + +public class MarketAutomatonBlockEntity extends BlockEntity implements WorldlyContainer, MenuProvider { + private static final int[] INPUTS = {MachineSlots.INPUT_A, MachineSlots.INPUT_B}; + private static final int[] OUTPUTS = {MachineSlots.OUTPUT}; + private final NonNullList items = NonNullList.withSize(MachineSlots.COUNT, ItemStack.EMPTY); + private final NonNullList recipe = NonNullList.withSize(MachineSlots.COUNT, ItemStack.EMPTY); + private RedstoneMode redstone = RedstoneMode.IGNORED; + private boolean similar; + private int ticks; + + public MarketAutomatonBlockEntity(BlockPos pos, BlockState state) { + super(ModContent.automatonType(), pos, state); + } + + public static void serverTick(Level level, BlockPos pos, BlockState state, MarketAutomatonBlockEntity machine) { + if (!(level instanceof ServerLevel server) || !machine.hasRecipe() || !machine.allowsWork(level)) { + return; + } + if (machine.ticks > 0) { + machine.ticks--; + } + if (machine.ticks > 0) { + return; + } + if (machine.trade(server, pos)) { + machine.ticks = MachineSlots.COOLDOWN; + } + } + + public void arm(ItemStack costA, ItemStack costB, ItemStack result) { + recipe.set(MachineSlots.INPUT_A, costA.copy()); + recipe.set(MachineSlots.INPUT_B, costB.copy()); + recipe.set(MachineSlots.OUTPUT, result.copy()); + setChanged(); + } + + public boolean hasRecipe() { + return !costA().isEmpty() && !result().isEmpty(); + } + + public RedstoneMode redstone() { + return redstone; + } + + public void cycleRedstone() { + redstone = redstone.next(); + setChanged(); + } + + public boolean similar() { + return similar; + } + + public void setSimilar(boolean similar) { + this.similar = similar; + setChanged(); + } + + public void toggleSimilar() { + similar = !similar; + setChanged(); + } + + public boolean allowsWork(Level level) { + boolean powered = level.hasNeighborSignal(this.worldPosition); + return switch (redstone) { + case IGNORED -> true; + case POWERED -> powered; + case UNPOWERED -> !powered; + }; + } + + public ItemStack costA() { + return recipe.get(MachineSlots.INPUT_A); + } + + public ItemStack costB() { + return recipe.get(MachineSlots.INPUT_B); + } + + public ItemStack result() { + return recipe.get(MachineSlots.OUTPUT); + } + + private boolean trade(ServerLevel server, BlockPos pos) { + Villager chosenVillager = null; + MerchantOffer chosen = null; + int bestA = Integer.MAX_VALUE; + int bestB = Integer.MAX_VALUE; + for (Villager villager : MarketSearch.find(server, pos)) { + var offers = villager.getOffers(); + for (int index = 0; index < offers.size(); index++) { + MerchantOffer offer = offers.get(index); + if (!payable(offer)) { + continue; + } + if (!similar) { + perform(villager, offer); + publishOffers(server); + return true; + } + int priceA = offer.getCostA().getCount(); + int priceB = offer.getCostB().isEmpty() ? 0 : offer.getCostB().getCount(); + if (chosen == null || priceA < bestA || (priceA == bestA && priceB < bestB)) { + bestA = priceA; + bestB = priceB; + chosenVillager = villager; + chosen = offer; + } + } + } + if (chosen == null) { + return false; + } + perform(chosenVillager, chosen); + publishOffers(server); + return true; + } + + private void publishOffers(ServerLevel server) { + List fresh = VillagerMarketMenu.scan(server, this.worldPosition); + for (ServerPlayer player : server.players()) { + if (player.containerMenu instanceof MarketAutomatonMenu menu && menu.pos().equals(this.worldPosition)) { + menu.replace(fresh); + Services.PLATFORM.sendToPlayer(player, new MarketPayloads.Offers(fresh)); + } + } + } + + private boolean payable(MerchantOffer offer) { + if (!ArmedTrade.matches(offer, costA(), costB(), result())) { + return false; + } + if (Math.max(0, offer.getMaxUses() - offer.getUses()) <= 0) { + return false; + } + ItemStack payA = offer.getCostA(); + ItemStack payB = offer.getCostB(); + ItemStack give = offer.getResult(); + if (payA.isEmpty() || give.isEmpty() || !canPay(MachineSlots.INPUT_A, payA)) { + return false; + } + if (!payB.isEmpty() && !canPay(MachineSlots.INPUT_B, payB)) { + return false; + } + return roomFor(give); + } + + private void perform(Villager villager, MerchantOffer offer) { + ItemStack payA = offer.getCostA(); + ItemStack payB = offer.getCostB(); + ItemStack give = offer.getResult(); + shrink(MachineSlots.INPUT_A, payA.getCount()); + if (!payB.isEmpty()) { + shrink(MachineSlots.INPUT_B, payB.getCount()); + } + ItemStack out = getItem(MachineSlots.OUTPUT); + if (out.isEmpty()) { + setItem(MachineSlots.OUTPUT, give.copy()); + } else { + out.grow(give.getCount()); + setChanged(); + } + TradeExecutor.reward(villager, offer, Vec3.atCenterOf(this.worldPosition)); + } + + private boolean canPay(int slot, ItemStack cost) { + ItemStack stack = getItem(slot); + return ArmedTrade.sameKind(stack, cost) && stack.getCount() >= cost.getCount(); + } + + private boolean roomFor(ItemStack give) { + ItemStack out = getItem(MachineSlots.OUTPUT); + if (out.isEmpty()) { + return true; + } + return ArmedTrade.sameKind(out, give) && out.getCount() + give.getCount() <= out.getMaxStackSize(); + } + + private void shrink(int slot, int count) { + ItemStack stack = getItem(slot); + stack.shrink(count); + if (stack.isEmpty()) { + setItem(slot, ItemStack.EMPTY); + } else { + setChanged(); + } + } + + //? if >=26.1 { + @Override + protected void loadAdditional(ValueInput input) { + super.loadAdditional(input); + ContainerHelper.loadAllItems(input, items); + ContainerHelper.loadAllItems(input.childOrEmpty("Recipe"), recipe); + redstone = RedstoneMode.byId(input.getByteOr("Redstone", (byte) 0)); + similar = input.getByteOr("Similar", (byte) 0) != 0; + } + + @Override + protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); + ContainerHelper.saveAllItems(output, items); + ContainerHelper.saveAllItems(output.child("Recipe"), recipe); + output.putByte("Redstone", (byte) redstone.ordinal()); + output.putByte("Similar", (byte) (similar ? 1 : 0)); + } + //?} else { + /*@Override + protected void loadAdditional(CompoundTag tag, HolderLookup.Provider provider) { + super.loadAdditional(tag, provider); + ContainerHelper.loadAllItems(tag, items, provider); + if (tag.contains("Recipe")) { + ContainerHelper.loadAllItems(tag.getCompound("Recipe"), recipe, provider); + } + redstone = RedstoneMode.byId(tag.getByte("Redstone")); + similar = tag.getByte("Similar") != 0; + } + + @Override + protected void saveAdditional(CompoundTag tag, HolderLookup.Provider provider) { + super.saveAdditional(tag, provider); + ContainerHelper.saveAllItems(tag, items, provider); + CompoundTag recipeTag = new CompoundTag(); + ContainerHelper.saveAllItems(recipeTag, recipe, provider); + tag.put("Recipe", recipeTag); + tag.putByte("Redstone", (byte) redstone.ordinal()); + tag.putByte("Similar", (byte) (similar ? 1 : 0)); + } + *///?} + + @Override + public int getContainerSize() { + return MachineSlots.COUNT; + } + + @Override + public boolean isEmpty() { + for (ItemStack stack : items) { + if (!stack.isEmpty()) { + return false; + } + } + return true; + } + + @Override + public ItemStack getItem(int slot) { + return items.get(slot); + } + + @Override + public ItemStack removeItem(int slot, int count) { + ItemStack removed = ContainerHelper.removeItem(items, slot, count); + if (!removed.isEmpty()) { + setChanged(); + } + return removed; + } + + @Override + public ItemStack removeItemNoUpdate(int slot) { + return ContainerHelper.takeItem(items, slot); + } + + @Override + public void setItem(int slot, ItemStack stack) { + items.set(slot, stack); + setChanged(); + } + + @Override + public boolean stillValid(Player player) { + return player.level().getBlockEntity(this.worldPosition) == this + && player.distanceToSqr(Vec3.atCenterOf(this.worldPosition)) < 64.0; + } + + @Override + public void clearContent() { + items.clear(); + setChanged(); + } + + @Override + public boolean canPlaceItem(int slot, ItemStack stack) { + if (slot == MachineSlots.INPUT_A) { + return !costA().isEmpty() && ArmedTrade.sameKind(stack, costA()); + } + if (slot == MachineSlots.INPUT_B) { + return !costB().isEmpty() && ArmedTrade.sameKind(stack, costB()); + } + return false; + } + + @Override + public int[] getSlotsForFace(Direction direction) { + return direction == Direction.DOWN ? OUTPUTS : INPUTS; + } + + @Override + public boolean canPlaceItemThroughFace(int slot, ItemStack stack, Direction direction) { + return direction != Direction.DOWN && canPlaceItem(slot, stack); + } + + @Override + public boolean canTakeItemThroughFace(int slot, ItemStack stack, Direction direction) { + return slot == MachineSlots.OUTPUT && direction == Direction.DOWN; + } + + @Override + public Component getDisplayName() { + return Component.translatable("block.villagermarket.market_automaton"); + } + + @Override + public AbstractContainerMenu createMenu(int containerId, Inventory inventory, Player player) { + if (!(player.level() instanceof ServerLevel server)) { + return null; + } + return new MarketAutomatonMenu(containerId, inventory, MarketAutomatonMenu.opening(server, this.worldPosition)); + } +} diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/RedstoneMode.java b/src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/RedstoneMode.java new file mode 100644 index 0000000..436f032 --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/RedstoneMode.java @@ -0,0 +1,30 @@ +package net.modcrafters.minecraft.villagermarket.block.entity; + +import net.minecraft.network.chat.Component; + +public enum RedstoneMode { + IGNORED, + POWERED, + UNPOWERED; + + public RedstoneMode next() { + RedstoneMode[] modes = values(); + return modes[(ordinal() + 1) % modes.length]; + } + + public static RedstoneMode byId(int id) { + RedstoneMode[] modes = values(); + if (id < 0 || id >= modes.length) { + return IGNORED; + } + return modes[id]; + } + + public Component label() { + return switch (this) { + case IGNORED -> Component.translatable("gui.villagermarket.redstone.ignored"); + case POWERED -> Component.translatable("gui.villagermarket.redstone.powered"); + case UNPOWERED -> Component.translatable("gui.villagermarket.redstone.unpowered"); + }; + } +} diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java b/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java new file mode 100644 index 0000000..3a5f42e --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java @@ -0,0 +1,292 @@ +package net.modcrafters.minecraft.villagermarket.client; + +import net.modcrafters.minecraft.villagermarket.block.entity.RedstoneMode; +import net.modcrafters.minecraft.villagermarket.menu.MachineSlots; +import net.modcrafters.minecraft.villagermarket.menu.MarketAutomatonMenu; +import net.modcrafters.minecraft.villagermarket.network.AutomatonPayloads; +import net.modcrafters.minecraft.villagermarket.network.MarketPayloads; +import net.modcrafters.minecraft.villagermarket.platform.Services; +import net.modcrafters.minecraft.villagermarket.trade.ListedOffer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +import java.util.List; + +public class MarketAutomatonScreen extends AbstractContainerScreen { + private static final Identifier BUTTON = Identifier.withDefaultNamespace("widget/button"); + private static final Identifier BUTTON_HIGHLIGHTED = Identifier.withDefaultNamespace("widget/button_highlighted"); + private static final Identifier SLOT = Identifier.withDefaultNamespace("container/slot"); + private static final Identifier TRADE_ARROW = Identifier.withDefaultNamespace("container/villager/trade_arrow"); + private static final Identifier FLOW_ARROW = Identifier.withDefaultNamespace("container/furnace/burn_progress"); + private static final Identifier SIMILAR = Identifier.fromNamespaceAndPath("villagermarket", "similar"); + private static final Identifier TRADE_ARROW_OUT = Identifier.withDefaultNamespace("container/villager/trade_arrow_out_of_stock"); + private static final Identifier SCROLLER = Identifier.withDefaultNamespace("widget/scroller"); + private static final Identifier SCROLLER_BACKGROUND = Identifier.withDefaultNamespace("widget/scroller_background"); + private static final int PANEL = 0xFFC6C6C6; + private static final int HIGHLIGHT = 0xFFFFFFFF; + private static final int SHADOW = 0xFF555555; + private static final int BORDER = 0xFF000000; + private static final int LABEL = 0xFF404040; + private static final int BUTTON_TEXT = 0xFFFFFFFF; + private static final ItemStack REDSTONE_DUST = new ItemStack(Items.REDSTONE); + private static final ItemStack REDSTONE_TORCH = new ItemStack(Items.REDSTONE_TORCH); + private static final int SLASH = 0xFFCC3333; + private final MarketScreenLogic logic = new MarketScreenLogic(); + private ItemStack hovered = ItemStack.EMPTY; + private Component tip = Component.empty(); + + public MarketAutomatonScreen(MarketAutomatonMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title, MarketScreenLogic.WIDTH, MachineSlots.HEIGHT); + logic.listHeight = MachineSlots.LIST_H; + logic.showY = MachineSlots.SHOW_Y; + } + + public static void apply(AutomatonPayloads.Recipe payload) { + if (Minecraft.getInstance().gui.screen() instanceof MarketAutomatonScreen screen) { + screen.getMenu().arm(payload.costA(), payload.costB(), payload.result()); + } + } + + public static void applyMode(AutomatonPayloads.Mode payload) { + if (Minecraft.getInstance().gui.screen() instanceof MarketAutomatonScreen screen) { + screen.getMenu().redstone(RedstoneMode.byId(payload.mode())); + } + } + + public static void applySimilar(AutomatonPayloads.SimilarState payload) { + if (Minecraft.getInstance().gui.screen() instanceof MarketAutomatonScreen screen) { + screen.getMenu().similar(payload.similar()); + } + } + + public static void applyOffers(MarketPayloads.Offers payload) { + if (Minecraft.getInstance().gui.screen() instanceof MarketAutomatonScreen screen) { + screen.getMenu().replace(payload.offers()); + } + } + + @Override + public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + super.extractBackground(graphics, mouseX, mouseY, partialTick); + int left = this.leftPos; + int top = this.topPos; + drawPanel(graphics, left, top); + graphics.text(this.font, this.title, left + 8, top + 6, LABEL, false); + List groups = logic.groups(this.menu.offers()); + graphics.enableScissor(left + MarketScreenLogic.GROUP_X, top + MarketScreenLogic.GROUP_Y, + left + MarketScreenLogic.GROUP_X + MarketScreenLogic.GROUP_W, top + MarketScreenLogic.GROUP_Y + logic.listHeight); + drawGroup(graphics, left, top, mouseX, mouseY, 0, logic.selectedGroup.isEmpty(), Component.translatable("gui.villagermarket.all")); + for (int i = 0; i < groups.size(); i++) { + drawGroup(graphics, left, top, mouseX, mouseY, i + 1, groups.get(i).equals(logic.selectedGroup), VillagerMarketScreen.groupLabel(groups.get(i))); + } + graphics.disableScissor(); + hovered = ItemStack.EMPTY; + tip = Component.empty(); + Inventory inventory = this.minecraft.player.getInventory(); + List shown = logic.visible(this.menu.offers(), inventory); + logic.fitOffer(shown.size()); + int offerW = logic.offerWidth(shown.size()); + graphics.enableScissor(left + MarketScreenLogic.OFFER_X, top + MarketScreenLogic.OFFER_Y, + left + MarketScreenLogic.OFFER_X + offerW, top + MarketScreenLogic.OFFER_Y + logic.listHeight); + for (int i = logic.offerScroll; i < shown.size(); i++) { + int y = top + MarketScreenLogic.OFFER_Y + (i - logic.offerScroll) * MarketScreenLogic.OFFER_ROW; + if (y + MarketScreenLogic.OFFER_ROW > top + MarketScreenLogic.OFFER_Y + logic.listHeight) { + break; + } + drawOffer(graphics, shown.get(i), left + MarketScreenLogic.OFFER_X, y, offerW, mouseX, mouseY, inventory); + } + graphics.disableScissor(); + drawOfferBar(graphics, left, top, shown.size()); + Component show = Component.translatable(logic.showAll ? "gui.villagermarket.show_all" : "gui.villagermarket.available"); + drawButton(graphics, left + MarketScreenLogic.SHOW_X, top + MachineSlots.SHOW_Y, MarketScreenLogic.SHOW_W, MarketScreenLogic.SHOW_H, + show, logic.showAll || inside(mouseX, mouseY, left + MarketScreenLogic.SHOW_X, top + MachineSlots.SHOW_Y, MarketScreenLogic.SHOW_W, MarketScreenLogic.SHOW_H)); + drawSimilar(graphics, left, top, mouseX, mouseY); + drawSlot(graphics, left + MachineSlots.INPUT_X - 1, top + MachineSlots.INPUT_Y - 1); + drawSlot(graphics, left + MachineSlots.INPUT_X + 17, top + MachineSlots.INPUT_Y - 1); + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, FLOW_ARROW, left + MachineSlots.ARROW_X, top + MachineSlots.INPUT_Y, MachineSlots.ARROW_W, MachineSlots.ARROW_H); + drawSlot(graphics, left + MachineSlots.OUTPUT_X - 1, top + MachineSlots.OUTPUT_Y - 1); + drawRedstone(graphics, left, top, mouseX, mouseY); + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + drawSlot(graphics, left + MachineSlots.INV_X + column * 18 - 1, top + MachineSlots.INV_Y + row * 18 - 1); + } + } + for (int column = 0; column < 9; column++) { + drawSlot(graphics, left + MachineSlots.INV_X + column * 18 - 1, top + MachineSlots.HOTBAR_Y - 1); + } + } + + private void drawPanel(GuiGraphicsExtractor graphics, int x, int y) { + int right = x + MarketScreenLogic.WIDTH; + int bottom = y + MachineSlots.HEIGHT; + graphics.fill(x, y, right, bottom, PANEL); + graphics.fill(x + 1, y + 1, right - 1, y + 3, HIGHLIGHT); + graphics.fill(x + 1, y + 1, x + 3, bottom - 1, HIGHLIGHT); + graphics.fill(x + 3, bottom - 3, right - 1, bottom - 1, SHADOW); + graphics.fill(right - 3, y + 3, right - 1, bottom - 1, SHADOW); + graphics.fill(x, y, right, y + 1, BORDER); + graphics.fill(x, bottom - 1, right, bottom, BORDER); + graphics.fill(x, y, x + 1, bottom, BORDER); + graphics.fill(right - 1, y, right, bottom, BORDER); + } + + private void drawGroup(GuiGraphicsExtractor graphics, int left, int top, int mouseX, int mouseY, int index, boolean selected, Component label) { + int x = left + MarketScreenLogic.GROUP_X; + int y = top + MarketScreenLogic.GROUP_Y + (index - logic.groupScroll) * MarketScreenLogic.GROUP_ROW; + if (y + MarketScreenLogic.GROUP_ROW <= top + MarketScreenLogic.GROUP_Y || y >= top + MarketScreenLogic.GROUP_Y + logic.listHeight) { + return; + } + boolean hot = selected || inside(mouseX, mouseY, x, y, MarketScreenLogic.GROUP_W, MarketScreenLogic.GROUP_ROW); + blitButton(graphics, x, y, MarketScreenLogic.GROUP_W, MarketScreenLogic.GROUP_ROW, hot); + graphics.text(this.font, label, x + 4, y + 6, BUTTON_TEXT, true); + } + + private void drawOffer(GuiGraphicsExtractor graphics, ListedOffer offer, int x, int y, int width, int mouseX, int mouseY, Inventory inventory) { + boolean selected = this.menu.armed(offer); + boolean hot = selected || inside(mouseX, mouseY, x, y, width, MarketScreenLogic.OFFER_ROW); + blitButton(graphics, x, y, width, MarketScreenLogic.OFFER_ROW, hot); + drawStack(graphics, offer.costA(), x + 3, y + 2, mouseX, mouseY); + drawStack(graphics, offer.costB(), x + 22, y + 2, mouseX, mouseY); + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, offer.usesLeft() > 0 ? TRADE_ARROW : TRADE_ARROW_OUT, x + 41, y + 5, 10, 9); + drawStack(graphics, offer.result(), x + 54, y + 2, mouseX, mouseY); + Component uses = offer.usesLeft() > 0 + ? Component.translatable("gui.villagermarket.max_uses", offer.usesLeft()) + : Component.translatable("gui.villagermarket.locked"); + graphics.text(this.font, uses, x + 74, y + 6, BUTTON_TEXT, true); + } + + private void drawOfferBar(GuiGraphicsExtractor graphics, int left, int top, int count) { + if (!logic.offerBar(count)) { + return; + } + int x = left + MarketScreenLogic.BAR_X; + int y = top + MarketScreenLogic.OFFER_Y; + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, SCROLLER_BACKGROUND, x, y, MarketScreenLogic.BAR_W, logic.listHeight); + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, SCROLLER, x, top + logic.offerThumbY(count), MarketScreenLogic.BAR_W, logic.offerThumbH(count)); + } + + private void drawButton(GuiGraphicsExtractor graphics, int x, int y, int width, int height, Component label, boolean hot) { + blitButton(graphics, x, y, width, height, hot); + int textX = x + (width - this.font.width(label)) / 2; + graphics.text(this.font, label, textX, y + (height - 8) / 2, BUTTON_TEXT, true); + } + + private void blitButton(GuiGraphicsExtractor graphics, int x, int y, int width, int height, boolean hot) { + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, hot ? BUTTON_HIGHLIGHTED : BUTTON, x, y, width, height); + } + + private void drawSimilar(GuiGraphicsExtractor graphics, int left, int top, int mouseX, int mouseY) { + int x = left + MachineSlots.SIMILAR_X; + int y = top + MachineSlots.SIMILAR_Y; + boolean hot = this.menu.similar() || inside(mouseX, mouseY, x, y, MachineSlots.SIMILAR_SIZE, MachineSlots.SIMILAR_SIZE); + blitButton(graphics, x, y, MachineSlots.SIMILAR_SIZE, MachineSlots.SIMILAR_SIZE, hot); + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, SIMILAR, x + 2, y + 2, 16, 16); + if (inside(mouseX, mouseY, x, y, MachineSlots.SIMILAR_SIZE, MachineSlots.SIMILAR_SIZE)) { + tip = Component.translatable(this.menu.similar() ? "gui.villagermarket.similar.on" : "gui.villagermarket.similar.off"); + } + } + + private void drawRedstone(GuiGraphicsExtractor graphics, int left, int top, int mouseX, int mouseY) { + int x = left + MachineSlots.REDSTONE_X; + int y = top + MachineSlots.REDSTONE_Y; + int size = MachineSlots.REDSTONE_SIZE; + boolean hot = inside(mouseX, mouseY, x, y, size, size); + blitButton(graphics, x, y, size, size, hot); + RedstoneMode mode = this.menu.redstone(); + ItemStack icon = mode == RedstoneMode.UNPOWERED ? REDSTONE_TORCH : REDSTONE_DUST; + graphics.item(icon, x + 2, y + 2); + if (mode == RedstoneMode.IGNORED) { + for (int step = 0; step < 12; step++) { + graphics.fill(x + 3 + step, y + 4 + step, x + 6 + step, y + 7 + step, SLASH); + } + } + if (hot) { + tip = mode.label(); + } + } + + private void drawSlot(GuiGraphicsExtractor graphics, int x, int y) { + graphics.blitSprite(RenderPipelines.GUI_TEXTURED, SLOT, x, y, 18, 18); + } + + private void drawStack(GuiGraphicsExtractor graphics, ItemStack stack, int x, int y, int mouseX, int mouseY) { + if (stack.isEmpty()) { + return; + } + graphics.item(stack, x, y); + graphics.itemDecorations(this.font, stack, x, y); + if (mouseX >= x && mouseX < x + 16 && mouseY >= y && mouseY < y + 16) { + hovered = stack; + } + } + + private boolean similarHit(double mouseX, double mouseY) { + return inside((int) mouseX, (int) mouseY, this.leftPos + MachineSlots.SIMILAR_X, this.topPos + MachineSlots.SIMILAR_Y, MachineSlots.SIMILAR_SIZE, MachineSlots.SIMILAR_SIZE); + } + + private boolean redstoneHit(double mouseX, double mouseY) { + return inside((int) mouseX, (int) mouseY, this.leftPos + MachineSlots.REDSTONE_X, this.topPos + MachineSlots.REDSTONE_Y, MachineSlots.REDSTONE_SIZE, MachineSlots.REDSTONE_SIZE); + } + + private static boolean inside(int mouseX, int mouseY, int x, int y, int width, int height) { + return mouseX >= x && mouseY >= y && mouseX < x + width && mouseY < y + height; + } + + @Override + protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + super.extractRenderState(graphics, mouseX, mouseY, partialTick); + if (!hovered.isEmpty()) { + graphics.setTooltipForNextFrame(this.font, hovered, mouseX, mouseY); + } else if (!tip.getString().isEmpty()) { + graphics.setTooltipForNextFrame(this.font, tip, mouseX, mouseY); + } + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (similarHit(event.x(), event.y())) { + Services.PLATFORM.sendToServer(new AutomatonPayloads.Similar()); + return true; + } + if (redstoneHit(event.x(), event.y())) { + Services.PLATFORM.sendToServer(new AutomatonPayloads.Redstone()); + return true; + } + MarketScreenLogic.Click click = logic.browse(event.x(), event.y(), this.leftPos, this.topPos, this.menu.offers(), this.minecraft.player.getInventory()); + if (click == MarketScreenLogic.Click.OFFER && logic.selected != null) { + Services.PLATFORM.sendToServer(new AutomatonPayloads.Select(logic.selected.villagerId(), logic.selected.offerIndex())); + } + return click != MarketScreenLogic.Click.NONE || super.mouseClicked(event, doubleClick); + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double dragX, double dragY) { + logic.dragBar((int) event.y() - this.topPos, logic.visible(this.menu.offers(), this.minecraft.player.getInventory()).size()); + return super.mouseDragged(event, dragX, dragY); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + logic.releaseBar(); + return super.mouseReleased(event); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + List shown = logic.visible(this.menu.offers(), this.minecraft.player.getInventory()); + logic.scroll(mouseX, mouseY, this.leftPos, this.topPos, scrollY, shown.size(), logic.groups(this.menu.offers()).size()); + return true; + } +} diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketScreenLogic.java b/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketScreenLogic.java index 2659806..146c042 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketScreenLogic.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketScreenLogic.java @@ -39,6 +39,8 @@ public final class MarketScreenLogic { public static final int BAR_W = 6; public static final int BAR_X = WIDTH - 4 - BAR_W; + public int listHeight = OFFER_H; + public int showY = SHOW_Y; public boolean showAll = true; public String selectedGroup = ""; public int groupScroll; @@ -74,15 +76,15 @@ public static int costSlotX(int index, boolean second) { return ONCE_X - 4 - slots * COST_SLOT + index * COST_SLOT; } - public static int offerRows() { - return Math.max(1, OFFER_H / OFFER_ROW); + public int offerRows() { + return Math.max(1, listHeight / OFFER_ROW); } - public static boolean offerBar(int count) { + public boolean offerBar(int count) { return count > offerRows(); } - public static int offerWidth(int count) { + public int offerWidth(int count) { return offerBar(count) ? BAR_X - OFFER_X - 2 : OFFER_W; } @@ -92,17 +94,17 @@ public void fitOffer(int count) { public int offerThumbY(int count) { int thumb = offerThumbH(count); - int travel = Math.max(1, OFFER_H - thumb); + int travel = Math.max(1, listHeight - thumb); int max = Math.max(1, count - offerRows()); return OFFER_Y + offerScroll * travel / max; } public int offerThumbH(int count) { - return Math.max(BAR_W * 2, OFFER_H * offerRows() / Math.max(count, 1)); + return Math.max(BAR_W * 2, listHeight * offerRows() / Math.max(count, 1)); } public boolean pressBar(int x, int y, int count) { - if (!offerBar(count) || !inside(x, y, BAR_X, OFFER_Y, BAR_W, OFFER_H)) { + if (!offerBar(count) || !inside(x, y, BAR_X, OFFER_Y, BAR_W, listHeight)) { return false; } draggingBar = true; @@ -118,7 +120,7 @@ public void dragBar(int y, int count) { return; } int thumb = offerThumbH(count); - int travel = Math.max(1, OFFER_H - thumb); + int travel = Math.max(1, listHeight - thumb); int max = Math.max(1, count - offerRows()); offerScroll = clamp(y - barGrab - OFFER_Y, travel); offerScroll = offerScroll * max / travel; @@ -156,7 +158,7 @@ public void onOffers(List offers) { public Click click(double mouseX, double mouseY, int left, int top, List offers, Inventory inventory) { int x = (int) mouseX - left; int y = (int) mouseY - top; - if (inside(x, y, SHOW_X, SHOW_Y, SHOW_W, SHOW_H)) { + if (inside(x, y, SHOW_X, showY, SHOW_W, SHOW_H)) { showAll = !showAll; return Click.TOGGLE; } @@ -168,7 +170,21 @@ public Click click(double mouseX, double mouseY, int left, int top, List offers, Inventory inventory) { + int x = (int) mouseX - left; + int y = (int) mouseY - top; + if (inside(x, y, SHOW_X, showY, SHOW_W, SHOW_H)) { + showAll = !showAll; + return Click.TOGGLE; + } + return browseAt(x, y, offers, inventory); + } + + private Click browseAt(int x, int y, List offers, Inventory inventory) { + if (inside(x, y, GROUP_X, GROUP_Y, GROUP_W, listHeight)) { int index = groupScroll + (y - GROUP_Y) / GROUP_ROW; List groups = groups(offers); if (index == 0) { @@ -183,7 +199,7 @@ public Click click(double mouseX, double mouseY, int left, int top, List= 0 && index < shown.size() ? shown.get(index) : null; return Click.OFFER; @@ -195,10 +211,10 @@ public void scroll(double mouseX, double mouseY, int left, int top, double delta int x = (int) mouseX - left; int y = (int) mouseY - top; int step = delta > 0 ? -1 : 1; - if (inside(x, y, GROUP_X, GROUP_Y, GROUP_W, GROUP_H)) { - int visible = Math.max(1, GROUP_H / GROUP_ROW); + if (inside(x, y, GROUP_X, GROUP_Y, GROUP_W, listHeight)) { + int visible = Math.max(1, listHeight / GROUP_ROW); groupScroll = clamp(groupScroll + step, groupCount + 1 - visible); - } else if (inside(x, y, OFFER_X, OFFER_Y, BAR_X + BAR_W - OFFER_X, OFFER_H)) { + } else if (inside(x, y, OFFER_X, OFFER_Y, BAR_X + BAR_W - OFFER_X, listHeight)) { offerScroll = clamp(offerScroll + step, offerCount - offerRows()); } } diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/client/VillagerMarketScreen.java b/src/main/java/net/modcrafters/minecraft/villagermarket/client/VillagerMarketScreen.java index b11fe9f..b81d623 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/client/VillagerMarketScreen.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/client/VillagerMarketScreen.java @@ -67,7 +67,7 @@ public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mou hovered = ItemStack.EMPTY; List shown = logic.visible(this.menu.offers(), this.minecraft.player.getInventory()); logic.fitOffer(shown.size()); - int offerW = MarketScreenLogic.offerWidth(shown.size()); + int offerW = logic.offerWidth(shown.size()); graphics.enableScissor(left + MarketScreenLogic.OFFER_X, top + MarketScreenLogic.OFFER_Y, left + MarketScreenLogic.OFFER_X + offerW, top + MarketScreenLogic.OFFER_Y + MarketScreenLogic.OFFER_H); for (int i = logic.offerScroll; i < shown.size(); i++) { @@ -139,7 +139,7 @@ private void drawOffer(GuiGraphicsExtractor graphics, ListedOffer offer, int x, } private void drawOfferBar(GuiGraphicsExtractor graphics, int left, int top, int count) { - if (!MarketScreenLogic.offerBar(count)) { + if (!logic.offerBar(count)) { return; } int x = left + MarketScreenLogic.BAR_X; diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/gametest/MarketGameTests.java b/src/main/java/net/modcrafters/minecraft/villagermarket/gametest/MarketGameTests.java index e6df5bb..61f9bc7 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/gametest/MarketGameTests.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/gametest/MarketGameTests.java @@ -1,9 +1,14 @@ package net.modcrafters.minecraft.villagermarket.gametest; +import net.modcrafters.minecraft.villagermarket.ModContent; +import net.modcrafters.minecraft.villagermarket.block.entity.MarketAutomatonBlockEntity; +import net.modcrafters.minecraft.villagermarket.block.entity.RedstoneMode; +import net.modcrafters.minecraft.villagermarket.menu.MachineSlots; import net.modcrafters.minecraft.villagermarket.search.MarketSearch; import net.modcrafters.minecraft.villagermarket.trade.MainSlots; import net.modcrafters.minecraft.villagermarket.trade.TradeExecutor; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.core.component.DataComponents; import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.network.chat.Component; @@ -12,10 +17,12 @@ import net.minecraft.world.entity.ExperienceOrb; import net.minecraft.world.entity.npc.villager.Villager; import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.item.trading.ItemCost; +import net.minecraft.world.level.block.Blocks; //? if >=26.1 import net.minecraft.world.level.GameType; import net.minecraft.world.item.trading.MerchantOffer; @@ -34,7 +41,11 @@ public record Case(String name, Consumer body) { new Case("components_must_match", MarketGameTests::componentsMustMatch), new Case("villager_outside_the_box_is_not_traded", MarketGameTests::villagerOutsideTheBoxIsNotTraded), new Case("adjusted_price_is_what_is_charged", MarketGameTests::adjustedPriceIsWhatIsCharged), - new Case("experience_lands_on_the_player", MarketGameTests::experienceLandsOnThePlayer) + new Case("experience_lands_on_the_player", MarketGameTests::experienceLandsOnThePlayer), + new Case("automaton_trades_from_its_slots", MarketGameTests::automatonTradesFromItsSlots), + new Case("automaton_rejects_a_different_book", MarketGameTests::automatonRejectsADifferentBook), + new Case("automaton_stops_without_redstone", MarketGameTests::automatonStopsWithoutRedstone), + new Case("automaton_buys_the_cheapest_similar", MarketGameTests::automatonBuysTheCheapestSimilar) ); private MarketGameTests() { @@ -123,6 +134,117 @@ public static void experienceLandsOnThePlayer(GameTestHelper helper) { helper.succeed(); } + public static void automatonTradesFromItsSlots(GameTestHelper helper) { + BlockPos at = new BlockPos(6, 1, 1); + helper.setBlock(at, ModContent.automatonBlock()); + // The game tests share one world, and the search box is 48 blocks, so this trade is diamonds rather than emeralds. + Villager villager = villager(helper, offer(new ItemCost(Items.DIAMOND), new ItemStack(Items.BREAD), 2, 0.0f, 0)); + MarketAutomatonBlockEntity machine = machine(helper, at); + MerchantOffer deal = villager.getOffers().get(0); + machine.arm(deal.getCostA(), ItemStack.EMPTY, deal.getResult()); + machine.setItem(MachineSlots.INPUT_A, new ItemStack(Items.DIAMOND, 3)); + BlockPos absolute = helper.absolutePos(at); + tick(helper, machine, absolute); + helper.assertTrue(machine.getItem(MachineSlots.OUTPUT).is(Items.BREAD) && machine.getItem(MachineSlots.OUTPUT).getCount() == 1, "first bread"); + helper.assertTrue(machine.getItem(MachineSlots.INPUT_A).getCount() == 2, "diamonds after one trade"); + helper.assertTrue(!machine.canTakeItemThroughFace(MachineSlots.INPUT_A, machine.getItem(MachineSlots.INPUT_A), Direction.NORTH), "a side hopper pulled the payment"); + helper.assertTrue(machine.canTakeItemThroughFace(MachineSlots.OUTPUT, machine.getItem(MachineSlots.OUTPUT), Direction.DOWN), "the bottom could not take the result"); + AABB area = new AABB(absolute).inflate(8); + boolean onBlock = false; + boolean onVillager = false; + for (ExperienceOrb orb : helper.getLevel().getEntitiesOfClass(ExperienceOrb.class, area)) { + if (orb.distanceToSqr(Vec3.atCenterOf(absolute)) < 4) { + onBlock = true; + } + if (orb.distanceToSqr(villager) < 4) { + onVillager = true; + } + } + helper.assertTrue(onBlock, "the experience orb was not on the automaton"); + helper.assertTrue(!onVillager, "the experience orb stayed on the villager"); + tick(helper, machine, absolute); + helper.assertTrue(machine.getItem(MachineSlots.OUTPUT).getCount() == 2, "second bread"); + helper.assertTrue(machine.getItem(MachineSlots.INPUT_A).getCount() == 1, "the locked trade kept spending"); + tick(helper, machine, absolute); + helper.assertTrue(machine.getItem(MachineSlots.OUTPUT).getCount() == 2, "a locked trade produced more bread"); + helper.assertTrue(machine.getItem(MachineSlots.INPUT_A).getCount() == 1, "a locked trade took the last diamond"); + helper.succeed(); + } + + public static void automatonRejectsADifferentBook(GameTestHelper helper) { + BlockPos at = new BlockPos(6, 1, 1); + helper.setBlock(at, ModContent.automatonBlock()); + ItemCost named = new ItemCost(Items.BOOK).withComponents(builder -> builder.expect(DataComponents.CUSTOM_NAME, Component.literal("ledger"))); + MerchantOffer deal = new MerchantOffer(new ItemCost(Items.EMERALD), Optional.of(named), new ItemStack(Items.BREAD), 0, 4, 1, 0.0f, 0); + villager(helper, deal); + MarketAutomatonBlockEntity machine = machine(helper, at); + machine.arm(deal.getCostA(), deal.getCostB(), deal.getResult()); + machine.setItem(MachineSlots.INPUT_A, new ItemStack(Items.EMERALD)); + machine.setItem(MachineSlots.INPUT_B, new ItemStack(Items.BOOK)); + tick(helper, machine, helper.absolutePos(at)); + helper.assertTrue(machine.getItem(MachineSlots.OUTPUT).isEmpty(), "a plain book paid for a named book"); + helper.assertTrue(machine.getItem(MachineSlots.INPUT_B).is(Items.BOOK), "the plain book was taken"); + helper.succeed(); + } + + public static void automatonStopsWithoutRedstone(GameTestHelper helper) { + BlockPos at = new BlockPos(6, 1, 1); + helper.setBlock(at, ModContent.automatonBlock()); + Villager villager = villager(helper, offer(new ItemCost(Items.AMETHYST_SHARD), new ItemStack(Items.BREAD), 4, 0.0f, 0)); + MarketAutomatonBlockEntity machine = machine(helper, at); + MerchantOffer deal = villager.getOffers().get(0); + machine.arm(deal.getCostA(), ItemStack.EMPTY, deal.getResult()); + machine.setItem(MachineSlots.INPUT_A, new ItemStack(Items.AMETHYST_SHARD, 2)); + machine.cycleRedstone(); + BlockPos absolute = helper.absolutePos(at); + tick(helper, machine, absolute); + helper.assertTrue(machine.redstone() == RedstoneMode.POWERED, "mode did not move to powered"); + helper.assertTrue(machine.getItem(MachineSlots.OUTPUT).isEmpty(), "traded with no redstone signal"); + helper.setBlock(at.east(), Blocks.REDSTONE_BLOCK); + tick(helper, machine, absolute); + helper.assertTrue(machine.getItem(MachineSlots.OUTPUT).is(Items.BREAD), "did not trade while powered"); + machine.cycleRedstone(); + machine.setItem(MachineSlots.OUTPUT, ItemStack.EMPTY); + tick(helper, machine, absolute); + helper.assertTrue(machine.redstone() == RedstoneMode.UNPOWERED, "mode did not move to unpowered"); + helper.assertTrue(machine.getItem(MachineSlots.OUTPUT).isEmpty(), "traded while a signal blocked the unpowered mode"); + helper.succeed(); + } + + public static void automatonBuysTheCheapestSimilar(GameTestHelper helper) { + BlockPos at = new BlockPos(6, 1, 1); + helper.setBlock(at, ModContent.automatonBlock()); + MerchantOffer cheapDeal = offer(new ItemCost(Items.ECHO_SHARD), new ItemStack(Items.BREAD), 4, 0.0f, 0); + MerchantOffer priceyDeal = offer(new ItemCost(Items.ECHO_SHARD), new ItemStack(Items.BREAD), 4, 0.2f, 100); + int cheapCount = cheapDeal.getCostA().getCount(); + int priceyCount = priceyDeal.getCostA().getCount(); + helper.assertTrue(priceyCount > cheapCount, "the expensive offer was not more expensive"); + Villager cheap = villager(helper, cheapDeal); + Villager pricey = villager(helper, priceyDeal); + MarketAutomatonBlockEntity machine = machine(helper, at); + machine.arm(priceyDeal.getCostA(), ItemStack.EMPTY, priceyDeal.getResult()); + machine.setSimilar(true); + machine.setItem(MachineSlots.INPUT_A, new ItemStack(Items.ECHO_SHARD, priceyCount)); + tick(helper, machine, helper.absolutePos(at)); + helper.assertTrue(cheap.getOffers().get(0).getUses() == 1, "did not buy the cheaper offer"); + helper.assertTrue(pricey.getOffers().get(0).getUses() == 0, "bought the expensive offer"); + helper.assertTrue(machine.getItem(MachineSlots.INPUT_A).getCount() == priceyCount - cheapCount, "did not pay the cheaper price"); + helper.succeed(); + } + + private static MarketAutomatonBlockEntity machine(GameTestHelper helper, BlockPos at) { + //? if >=26.1 { + return helper.getBlockEntity(at, MarketAutomatonBlockEntity.class); + //?} else + //return (MarketAutomatonBlockEntity) helper.getBlockEntity(at); + } + + private static void tick(GameTestHelper helper, MarketAutomatonBlockEntity machine, BlockPos absolute) { + for (int i = 0; i < MachineSlots.COOLDOWN; i++) { + MarketAutomatonBlockEntity.serverTick(helper.getLevel(), absolute, machine.getBlockState(), machine); + } + } + private static MerchantOffer offer(ItemCost cost, ItemStack result, int maxUses, float multiplier, int demand) { return new MerchantOffer(cost, Optional.empty(), result, 0, maxUses, 1, multiplier, demand); } diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/menu/AutomatonOpening.java b/src/main/java/net/modcrafters/minecraft/villagermarket/menu/AutomatonOpening.java new file mode 100644 index 0000000..5641f92 --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/menu/AutomatonOpening.java @@ -0,0 +1,39 @@ +package net.modcrafters.minecraft.villagermarket.menu; + +import net.modcrafters.minecraft.villagermarket.trade.ListedOffer; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.item.ItemStack; + +import java.util.List; + +public record AutomatonOpening( + BlockPos pos, + List offers, + ItemStack costA, + ItemStack costB, + ItemStack result, + int redstone, + boolean similar +) { + public static final StreamCodec STREAM_CODEC = StreamCodec.of( + (buf, opening) -> { + BlockPos.STREAM_CODEC.encode(buf, opening.pos()); + ListedOffer.LIST_CODEC.encode(buf, opening.offers()); + ItemStack.OPTIONAL_STREAM_CODEC.encode(buf, opening.costA()); + ItemStack.OPTIONAL_STREAM_CODEC.encode(buf, opening.costB()); + ItemStack.OPTIONAL_STREAM_CODEC.encode(buf, opening.result()); + ByteBufCodecs.INT.encode(buf, opening.redstone()); + ByteBufCodecs.BOOL.encode(buf, opening.similar()); + }, + buf -> new AutomatonOpening( + BlockPos.STREAM_CODEC.decode(buf), + ListedOffer.LIST_CODEC.decode(buf), + ItemStack.OPTIONAL_STREAM_CODEC.decode(buf), + ItemStack.OPTIONAL_STREAM_CODEC.decode(buf), + ItemStack.OPTIONAL_STREAM_CODEC.decode(buf), + ByteBufCodecs.INT.decode(buf), + ByteBufCodecs.BOOL.decode(buf))); +} diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/menu/MachineSlots.java b/src/main/java/net/modcrafters/minecraft/villagermarket/menu/MachineSlots.java new file mode 100644 index 0000000..32eeda2 --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/menu/MachineSlots.java @@ -0,0 +1,31 @@ +package net.modcrafters.minecraft.villagermarket.menu; + +public final class MachineSlots { + public static final int INPUT_A = 0; + public static final int INPUT_B = 1; + public static final int OUTPUT = 2; + public static final int COUNT = 3; + public static final int COOLDOWN = 8; + public static final int LIST_H = 80; + public static final int SHOW_Y = 100; + public static final int INPUT_X = 111; + public static final int INPUT_Y = SHOW_Y + 2; + public static final int ARROW_X = 148; + public static final int ARROW_W = 24; + public static final int ARROW_H = 16; + public static final int OUTPUT_X = 176; + public static final int OUTPUT_Y = INPUT_Y; + public static final int REDSTONE_X = 228; + public static final int REDSTONE_Y = SHOW_Y; + public static final int REDSTONE_SIZE = 20; + public static final int SIMILAR_SIZE = 20; + public static final int SIMILAR_X = REDSTONE_X - 4 - SIMILAR_SIZE; + public static final int SIMILAR_Y = REDSTONE_Y; + public static final int INV_X = 48; + public static final int INV_Y = 128; + public static final int HOTBAR_Y = 186; + public static final int HEIGHT = 210; + + private MachineSlots() { + } +} diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/menu/MarketAutomatonMenu.java b/src/main/java/net/modcrafters/minecraft/villagermarket/menu/MarketAutomatonMenu.java new file mode 100644 index 0000000..29d16de --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/menu/MarketAutomatonMenu.java @@ -0,0 +1,163 @@ +package net.modcrafters.minecraft.villagermarket.menu; + +import net.modcrafters.minecraft.villagermarket.ModContent; +import net.modcrafters.minecraft.villagermarket.block.entity.MarketAutomatonBlockEntity; +import net.modcrafters.minecraft.villagermarket.block.entity.RedstoneMode; +import net.modcrafters.minecraft.villagermarket.trade.ArmedTrade; +import net.modcrafters.minecraft.villagermarket.trade.ListedOffer; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; + +import java.util.List; + +public class MarketAutomatonMenu extends AbstractContainerMenu { + private final BlockPos pos; + private final net.minecraft.world.Container container; + private List offers; + private ItemStack costA; + private ItemStack costB; + private ItemStack result; + private RedstoneMode redstone; + private boolean similar; + + public MarketAutomatonMenu(int containerId, Inventory inventory, AutomatonOpening data) { + super(ModContent.automatonMenu(), containerId); + this.pos = data.pos(); + this.offers = List.copyOf(data.offers()); + BlockEntity found = inventory.player.level().getBlockEntity(data.pos()); + this.container = found instanceof MarketAutomatonBlockEntity machine ? machine : new SimpleContainer(MachineSlots.COUNT); + arm(data.costA(), data.costB(), data.result()); + this.redstone = RedstoneMode.byId(data.redstone()); + this.similar = data.similar(); + addSlot(new Slot(container, MachineSlots.INPUT_A, MachineSlots.INPUT_X, MachineSlots.INPUT_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return !costA.isEmpty() && ArmedTrade.sameKind(stack, costA); + } + }); + addSlot(new Slot(container, MachineSlots.INPUT_B, MachineSlots.INPUT_X + 18, MachineSlots.INPUT_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return !costB.isEmpty() && ArmedTrade.sameKind(stack, costB); + } + }); + addSlot(new Slot(container, MachineSlots.OUTPUT, MachineSlots.OUTPUT_X, MachineSlots.OUTPUT_Y) { + @Override + public boolean mayPlace(ItemStack stack) { + return false; + } + }); + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column + row * 9 + 9, MachineSlots.INV_X + column * 18, MachineSlots.INV_Y + row * 18)); + } + } + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column, MachineSlots.INV_X + column * 18, MachineSlots.HOTBAR_Y)); + } + } + + public static AutomatonOpening opening(ServerLevel level, BlockPos pos) { + ItemStack costA = ItemStack.EMPTY; + ItemStack costB = ItemStack.EMPTY; + ItemStack result = ItemStack.EMPTY; + int redstone = 0; + boolean similar = false; + if (level.getBlockEntity(pos) instanceof MarketAutomatonBlockEntity machine) { + costA = machine.costA().copy(); + costB = machine.costB().copy(); + result = machine.result().copy(); + redstone = machine.redstone().ordinal(); + similar = machine.similar(); + } + return new AutomatonOpening(pos, VillagerMarketMenu.scan(level, pos), costA, costB, result, redstone, similar); + } + + public BlockPos pos() { + return pos; + } + + public List offers() { + return offers; + } + + public void replace(List offers) { + this.offers = List.copyOf(offers); + } + + public ItemStack costA() { + return costA; + } + + public ItemStack costB() { + return costB; + } + + public ItemStack result() { + return result; + } + + public RedstoneMode redstone() { + return redstone; + } + + public void redstone(RedstoneMode redstone) { + this.redstone = redstone; + } + + public boolean similar() { + return similar; + } + + public void similar(boolean similar) { + this.similar = similar; + } + + public void arm(ItemStack costA, ItemStack costB, ItemStack result) { + this.costA = costA.copy(); + this.costB = costB.copy(); + this.result = result.copy(); + } + + public boolean armed(ListedOffer offer) { + return ArmedTrade.matches(offer.costA(), offer.costB(), offer.result(), costA, costB, result); + } + + @Override + public boolean stillValid(Player player) { + if (container instanceof MarketAutomatonBlockEntity machine) { + return machine.stillValid(player); + } + return false; + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + Slot slot = this.slots.get(index); + if (!slot.hasItem()) { + return ItemStack.EMPTY; + } + ItemStack stack = slot.getItem(); + ItemStack copy = stack.copy(); + if (index < MachineSlots.COUNT) { + if (!moveItemStackTo(stack, MachineSlots.COUNT, this.slots.size(), true)) { + return ItemStack.EMPTY; + } + } else if (!moveItemStackTo(stack, MachineSlots.INPUT_A, MachineSlots.OUTPUT, false)) { + return ItemStack.EMPTY; + } + if (stack.isEmpty()) { + slot.set(ItemStack.EMPTY); + } else { + slot.setChanged(); + } + return copy; + } +} diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/network/AutomatonPayloads.java b/src/main/java/net/modcrafters/minecraft/villagermarket/network/AutomatonPayloads.java new file mode 100644 index 0000000..a426413 --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/network/AutomatonPayloads.java @@ -0,0 +1,138 @@ +package net.modcrafters.minecraft.villagermarket.network; + +import net.modcrafters.minecraft.villagermarket.Constants; +import net.modcrafters.minecraft.villagermarket.block.entity.MarketAutomatonBlockEntity; +import net.modcrafters.minecraft.villagermarket.menu.MarketAutomatonMenu; +import net.modcrafters.minecraft.villagermarket.platform.Services; +import net.modcrafters.minecraft.villagermarket.trade.ListedOffer; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; + +public final class AutomatonPayloads { + private AutomatonPayloads() { + } + + public record Select(int villagerId, int offerIndex) implements CustomPacketPayload { + public static final Type