Compare commits

...
6 Commits
Author SHA1 Message Date
Alex 4fdea65537 feat(middle-click): Add in GUI and in-world middle-click pick to set and reset channels (#74) 2026-09-21 10:36:57 +08:00
Chitak 985 89057ebb2d Clean up empty NBT when clearing projector channels (#75) 2026-09-20 09:13:19 +08:00
Eldrinn Elantey d829ae466b Localize and unify missing block messages in autoplace (#70)
StructureLib built the missing block name on the server, where only
en_US is loaded, so players on dedicated servers saw English names in
chat. Send the item's lang key and let the client translate it. Stacks
whose name is not a plain key, GT meta items among them, keep the old
server side name.

Players also saw two shapes of the same message: a bracketed name with
rarity color and an item tooltip from two of the survivalPlaceBlocks
branches, a bare yellow name from the stack list branch. All three now
build the name the same way.
2026-09-09 09:21:27 +08:00
Martin Robertz 137f651486 update deps 2026-07-03 19:22:22 +02:00
attempt0andgithub-actions[bot] ff88cbf2d7 Read channel in getBlocksToPlace (#68)
## Summary
This causes inconsistency in the API and should be addressed. The
primary consumer of the API is NEI preview and the preview does show
wrong block with glass tier set, not sure if this is the cause
<img width="1088" height="491" alt="image"
src="https://github.com/user-attachments/assets/fff146d8-6d8e-44d9-842e-1677691271d2"
/>

## Checklist
- [ ] I have tested this PR in DevEnv
- [ ] I have tested this PR in Fullpack
- [x] This PR is in compliance with the [GTNH AI
Policy](https://github.com/GTNewHorizons/GTNH-Dev-Doc/blob/master/AI_POLICY.md)
- [ ] This PR requires another PR in order to merge

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-03 23:33:08 +08:00
DylanTaylor1 4922760b46 Change subchannel color to match tooltips (#67)
Changes subchannel color from orange to aqua to match tooltips from the
Construction Tooltips Overhaul
(https://github.com/GTNewHorizons/GT5-Unofficial/pull/7299)
2026-07-02 21:12:20 -04:00
14 changed files with 294 additions and 23 deletions

No files matched your search

+2 -3
View File
@@ -1,9 +1,8 @@
// Add your dependencies here
dependencies {
api("com.github.GTNewHorizons:GTNHLib:0.11.18:dev")
implementation("com.github.GTNewHorizons:NotEnoughItems:2.8.104-GTNH:dev")
api("com.github.GTNewHorizons:GTNHLib:0.11.21:dev")
implementation("com.github.GTNewHorizons:NotEnoughItems:2.8.110-GTNH:dev")
testImplementation(platform('org.junit:junit-bom:5.9.2'))
testImplementation('org.junit.jupiter:junit-jupiter')
testRuntimeOnly ('org.junit.platform:junit-platform-launcher')
@@ -0,0 +1,88 @@
package com.gtnewhorizon.structurelib;
import java.util.Collection;
import java.util.Map;
import javax.annotation.Nullable;
import net.minecraft.block.Block;
import net.minecraft.entity.player.EntityPlayer;
import net.minecraft.entity.player.EntityPlayerMP;
import net.minecraft.item.ItemStack;
import net.minecraft.util.ChatComponentTranslation;
import net.minecraft.util.MovingObjectPosition;
import net.minecraft.world.World;
import com.gtnewhorizon.structurelib.alignment.constructable.ChannelDataAccessor;
import com.gtnewhorizon.structurelib.net.SetChannelDataMessage;
/**
* Handler for middle-click channel picking. When the player holds a hologram projector on the cursor and middle-clicks
* a registered indicator item, the corresponding channel is set on the projector. The same applies in world when the
* projector is held in hand and a registered indicator block is middle-clicked. Shift+middle-click wipes all channels.
* Was made side neutral to support server-side validation at some point if necessary.
*/
public final class ChannelPickHandler {
private ChannelPickHandler() {}
/**
* Handle a middle-click event while holding a hologram projector on the cursor.
*/
public static boolean handleMiddleClick(EntityPlayer player, ItemStack holoStack, @Nullable ItemStack targetStack,
final boolean isSneaking) {
return handlePick(player, holoStack, targetStack, isSneaking, true);
}
public static boolean handleWorldPick(World world, EntityPlayer player, ItemStack holoStack,
@Nullable MovingObjectPosition hit, final boolean isSneaking) {
ItemStack targetStack = null;
if (hit != null) {
Block block = world.getBlock(hit.blockX, hit.blockY, hit.blockZ);
if (!block.isAir(world, hit.blockX, hit.blockY, hit.blockZ)) {
targetStack = block.getPickBlock(hit, world, hit.blockX, hit.blockY, hit.blockZ, player);
}
}
// A null target still goes through so that shift+middle-click wipes even when aiming at nothing
return handlePick(player, holoStack, targetStack, isSneaking, false);
}
private static boolean handlePick(EntityPlayer player, ItemStack holoStack, @Nullable ItemStack targetStack,
final boolean isSneaking, final boolean onCursor) {
// Clear channels on shift+middle-click. The "press again to confirm" gesture is gated client-side
// (see ChannelWipeConfirmation) before this method is ever called.
if (isSneaking) {
ChannelDataAccessor.wipeChannelData(holoStack);
sendChannelData(player, holoStack, onCursor);
player.addChatMessage(new ChatComponentTranslation("structurelib.pickchannel.cleared"));
return true;
}
if (targetStack == null) return false; // nothing hovered, cancel silently
Collection<Map.Entry<String, Integer>> channels = ChannelDescription.iterate(targetStack);
if (channels.isEmpty()) {
player.addChatMessage(new ChatComponentTranslation("structurelib.pickchannel.nomapping"));
return false;
}
// Assuming there's only 1 channel per item for now (same behavior as drag and drop)
Map.Entry<String, Integer> first = channels.iterator().next();
ChannelDataAccessor.setChannelData(holoStack, first.getKey(), first.getValue());
sendChannelData(player, holoStack, onCursor);
player.addChatMessage(
new ChatComponentTranslation("structurelib.pickchannel.success", first.getKey(), first.getValue()));
return true;
}
/**
* Player is the one triggering this update, so should already be in-sync.
*/
private static void sendChannelData(EntityPlayer player, ItemStack holoStack, boolean onCursor) {
if (!(player instanceof EntityPlayerMP)) {
StructureLib.net.sendToServer(new SetChannelDataMessage(holoStack, onCursor));
}
}
}
@@ -0,0 +1,33 @@
package com.gtnewhorizon.structurelib;
import net.minecraft.client.Minecraft;
import net.minecraft.entity.player.EntityPlayer;
import net.minecraft.util.ChatComponentTranslation;
/**
* Client-side-only confirmation gate for "wipe all channels" shift+middle-click gesture.
*/
public final class ChannelWipeConfirmation {
private static final long CONFIRM_WINDOW_MS = 5000;
private static long armedAtMs = -1;
private ChannelWipeConfirmation() {}
/**
* Call this before invoking {@link ChannelPickHandler} whenever the wipe gesture (shift+middle-click) is detected.
*
* @return true if this is a confirming second press within the window and the wipe should proceed now; false if
* this press only armed the confirmation.
*/
public static boolean confirm(EntityPlayer player) {
long now = Minecraft.getSystemTime();
if (now - armedAtMs > CONFIRM_WINDOW_MS) {
armedAtMs = now;
player.addChatMessage(new ChatComponentTranslation("structurelib.pickchannel.confirmwipe"));
return false;
}
armedAtMs = -1;
return true;
}
}
@@ -30,6 +30,7 @@ import net.minecraft.util.IChatComponent;
import net.minecraft.util.IIcon;
import net.minecraft.util.Vec3;
import net.minecraft.world.World;
import net.minecraftforge.client.event.MouseEvent;
import net.minecraftforge.client.event.RenderWorldLastEvent;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.config.ConfigElement;
@@ -40,6 +41,7 @@ import org.apache.commons.lang3.tuple.Pair;
import org.lwjgl.opengl.GL11;
import com.gtnewhorizon.structurelib.entity.fx.WeightlessParticleFX;
import com.gtnewhorizon.structurelib.item.ItemConstructableTrigger;
import com.gtnewhorizon.structurelib.net.RegistryOrderSyncMessage;
import com.gtnewhorizon.structurelib.net.SetChannelDataMessage;
@@ -53,6 +55,8 @@ import cpw.mods.fml.common.eventhandler.SubscribeEvent;
import cpw.mods.fml.common.gameevent.TickEvent.ClientTickEvent;
import cpw.mods.fml.common.gameevent.TickEvent.Phase;
import cpw.mods.fml.common.network.FMLNetworkEvent;
import cpw.mods.fml.relauncher.Side;
import cpw.mods.fml.relauncher.SideOnly;
public class ClientProxy extends CommonProxy {
@@ -560,6 +564,35 @@ public class ClientProxy extends CommonProxy {
public static class ForgeEventHandler {
@SubscribeEvent
@SideOnly(Side.CLIENT)
public void onMouseEvent(MouseEvent event) {
final EntityPlayer player = Minecraft.getMinecraft().thePlayer;
if (player == null || player.isDead) {
return;
}
final ItemStack heldItem = player.getHeldItem();
if (heldItem == null || !(heldItem.getItem() instanceof ItemConstructableTrigger)) return;
if (event.button != 2 || !event.buttonstate) return;
// Sink the middle click as soon as a projector is held, so vanilla pick block does not run
event.setCanceled(true);
final boolean isSneaking = player.isSneaking();
if (isSneaking && !ChannelWipeConfirmation.confirm(player)) return;
ChannelPickHandler.handleWorldPick(
player.worldObj,
player,
heldItem,
Minecraft.getMinecraft().objectMouseOver,
isSneaking);
}
@SubscribeEvent
public void onEntityJoinWorld(EntityJoinWorldEvent e) {
if (e.world.isRemote && e.entity instanceof EntityClientPlayerMP) {
@@ -17,6 +17,7 @@ import net.minecraftforge.common.MinecraftForge;
import com.gtnewhorizon.structurelib.net.ErrorHintParticleMessage;
import com.gtnewhorizon.structurelib.net.UpdateHintParticleMessage;
import cpw.mods.fml.common.event.FMLInitializationEvent;
import cpw.mods.fml.common.event.FMLLoadCompleteEvent;
import cpw.mods.fml.common.event.FMLPreInitializationEvent;
import cpw.mods.fml.common.eventhandler.SubscribeEvent;
@@ -64,6 +65,8 @@ public class CommonProxy {
public void preInit(FMLPreInitializationEvent e) {}
public void init(FMLInitializationEvent e) {}
public long getOverworldTime() {
return MinecraftServer.getServer().getEntityWorld().getTotalWorldTime();
}
@@ -33,6 +33,7 @@ import com.gtnewhorizon.structurelib.util.XSTR;
import cpw.mods.fml.common.Mod;
import cpw.mods.fml.common.SidedProxy;
import cpw.mods.fml.common.event.FMLInitializationEvent;
import cpw.mods.fml.common.event.FMLInterModComms;
import cpw.mods.fml.common.event.FMLLoadCompleteEvent;
import cpw.mods.fml.common.event.FMLPreInitializationEvent;
@@ -128,6 +129,11 @@ public class StructureLib {
DevelopHelper.onPreInit();
}
@Mod.EventHandler
public void init(FMLInitializationEvent e) {
proxy.init(e);
}
@Mod.EventHandler
public void loadComplete(FMLLoadCompleteEvent e) {
proxy.loadComplete(e);
@@ -146,7 +146,12 @@ public class ChannelDataAccessor {
*/
public static void wipeChannelData(ItemStack masterStack) {
if (masterStack == null) throw new IllegalArgumentException();
if (masterStack.stackTagCompound != null) masterStack.stackTagCompound.removeTag(SECONDARY_HINT_TAG);
if (masterStack.stackTagCompound != null) {
masterStack.stackTagCompound.removeTag(SECONDARY_HINT_TAG);
if (masterStack.stackTagCompound.hasNoTags()) {
masterStack.stackTagCompound = null;
}
}
}
/**
@@ -1,5 +1,6 @@
package com.gtnewhorizon.structurelib.core;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@@ -7,7 +8,9 @@ import java.util.Set;
import com.gtnewhorizon.gtnhmixins.IEarlyMixinLoader;
import cpw.mods.fml.relauncher.FMLLaunchHandler;
import cpw.mods.fml.relauncher.IFMLLoadingPlugin;
import cpw.mods.fml.relauncher.Side;
@IFMLLoadingPlugin.MCVersion("1.7.10")
public class StructureLibCore implements IFMLLoadingPlugin, IEarlyMixinLoader {
@@ -44,6 +47,11 @@ public class StructureLibCore implements IFMLLoadingPlugin, IEarlyMixinLoader {
@Override
public List<String> getMixins(Set<String> loadedCoreMods) {
return Arrays.asList("blockChangeNotifier.MixinForgeHooks", "blockChangeNotifier.MixinWorld");
List<String> mixins = new ArrayList<>(
Arrays.asList("blockChangeNotifier.MixinForgeHooks", "blockChangeNotifier.MixinWorld"));
if (FMLLaunchHandler.side() == Side.CLIENT) {
mixins.add("MixinGuiContainer_PickChannel");
}
return mixins;
}
}
@@ -0,0 +1,64 @@
package com.gtnewhorizon.structurelib.mixins.early;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiScreen;
import net.minecraft.client.gui.inventory.GuiContainer;
import net.minecraft.entity.player.EntityPlayer;
import net.minecraft.inventory.Slot;
import net.minecraft.item.ItemStack;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Invoker;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import com.gtnewhorizon.structurelib.ChannelPickHandler;
import com.gtnewhorizon.structurelib.ChannelWipeConfirmation;
import com.gtnewhorizon.structurelib.StructureLib;
import com.gtnewhorizon.structurelib.item.ItemConstructableTrigger;
import codechicken.nei.NEIClientConfig;
import codechicken.nei.guihook.GuiContainerManager;
import cpw.mods.fml.common.Loader;
@Mixin(value = GuiContainer.class, priority = 100)
public abstract class MixinGuiContainer_PickChannel {
@Inject(method = "mouseClicked(III)V", at = @At("HEAD"), cancellable = true)
private void slib$onMouseClicked(int mouseX, int mouseY, int mouseButton, CallbackInfo ci) {
if (mouseButton != 2) return;
EntityPlayer player = Minecraft.getMinecraft().thePlayer;
if (player == null) return;
ItemStack cursorStack = player.inventory.getItemStack();
if (cursorStack == null || !(cursorStack.getItem() instanceof ItemConstructableTrigger)) return;
// Sink middle click as soon as projector is picked up
ci.cancel();
ItemStack hoveredStack = null;
try {
Slot slot = slib$invokeGetSlotAtPosition(mouseX, mouseY);
if (slot != null) {
hoveredStack = slot.getStack();
}
// try NEI
if (hoveredStack == null && Loader.isModLoaded("NotEnoughItems") && !NEIClientConfig.isHidden()) {
hoveredStack = GuiContainerManager.getStackMouseOver((GuiContainer) (Object) this);
}
} catch (Exception e) {
StructureLib.LOGGER.error("Error while resolving hovered slot for channel pick", e);
}
boolean isSneaking = GuiScreen.isShiftKeyDown();
if (isSneaking && !ChannelWipeConfirmation.confirm(player)) return;
ChannelPickHandler.handleMiddleClick(player, cursorStack, hoveredStack, isSneaking);
}
@Invoker("getSlotAtPosition")
protected abstract Slot slib$invokeGetSlotAtPosition(int x, int y);
}
@@ -23,15 +23,22 @@ import io.netty.buffer.ByteBuf;
public class SetChannelDataMessage implements IMessage {
private final List<Map.Entry<String, Integer>> data = new ArrayList<>();
private boolean onCursor;
public SetChannelDataMessage() {}
public SetChannelDataMessage(ItemStack trigger) {
this(trigger, false);
}
public SetChannelDataMessage(ItemStack trigger, final boolean onCursor) {
this.onCursor = onCursor;
ChannelDataAccessor.iterateChannelData(trigger).forEach(data::add);
}
@Override
public void fromBytes(ByteBuf buf) {
onCursor = buf.readBoolean();
data.clear();
int size = ByteBufUtils.readVarShort(buf);
for (int i = 0; i < size; i++) {
@@ -41,6 +48,7 @@ public class SetChannelDataMessage implements IMessage {
@Override
public void toBytes(ByteBuf buf) {
buf.writeBoolean(onCursor);
ByteBufUtils.writeVarShort(buf, data.size());
for (Entry<String, Integer> e : data) {
ByteBufUtils.writeUTF8String(buf, e.getKey());
@@ -53,11 +61,11 @@ public class SetChannelDataMessage implements IMessage {
@Override
public IMessage onMessage(SetChannelDataMessage message, MessageContext ctx) {
EntityPlayerMP player = ctx.getServerHandler().playerEntity;
ItemStack heldItem = player.getHeldItem();
if (heldItem != null && heldItem.getItem() instanceof ItemConstructableTrigger) {
ChannelDataAccessor.wipeChannelData(heldItem);
ItemStack target = message.onCursor ? player.inventory.getItemStack() : player.getHeldItem();
if (target != null && target.getItem() instanceof ItemConstructableTrigger) {
ChannelDataAccessor.wipeChannelData(target);
for (Entry<String, Integer> e : message.data) {
ChannelDataAccessor.setChannelData(heldItem, e.getKey(), e.getValue());
ChannelDataAccessor.setChannelData(target, e.getKey(), e.getValue());
}
// since this is a set all channel request from the client, we would assume client already know
// what this would look like on the client, so no sync
@@ -65,7 +73,7 @@ public class SetChannelDataMessage implements IMessage {
StructureLib.LOGGER.warn(
"{} trying to set channel data on {}, which is not a hologram projector!",
player.getUniqueID(),
heldItem);
target);
}
return null;
}
@@ -30,6 +30,7 @@ import javax.annotation.Nullable;
import net.minecraft.block.Block;
import net.minecraft.entity.player.EntityPlayer;
import net.minecraft.entity.player.EntityPlayerMP;
import net.minecraft.event.HoverEvent;
import net.minecraft.init.Blocks;
import net.minecraft.item.Item;
import net.minecraft.item.ItemBlock;
@@ -38,10 +39,9 @@ import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.tileentity.TileEntity;
import net.minecraft.util.ChatComponentText;
import net.minecraft.util.ChatComponentTranslation;
import net.minecraft.util.ChatStyle;
import net.minecraft.util.EnumChatFormatting;
import net.minecraft.util.IChatComponent;
import net.minecraft.util.IIcon;
import net.minecraft.util.StatCollector;
import net.minecraft.world.World;
import net.minecraftforge.common.util.ForgeDirection;
@@ -317,6 +317,25 @@ public class StructureUtility {
return survivalPlaceBlock(block, meta, world, x, y, z, s, (EntityPlayer) actor, chatter);
}
/**
* Build a chat component naming the given stack, so that the receiving client localizes it.
* <p>
* Falls back to the server side display name for stacks whose name is not a plain translation key, e.g. items that
* assemble their name in {@link Item#getItemStackDisplayName(ItemStack)}.
*/
private static IChatComponent getStackChatName(ItemStack stack) {
String key = stack.getUnlocalizedName() + ".name";
if (!StatCollector.canTranslate(key)) return stack.func_151000_E();
IChatComponent name = new ChatComponentText("[").appendSibling(new ChatComponentTranslation(key))
.appendText("]");
NBTTagCompound tag = new NBTTagCompound();
stack.writeToNBT(tag);
name.getChatStyle()
.setChatHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_ITEM, new ChatComponentText(tag.toString())))
.setColor(stack.getRarity().rarityColor);
return name;
}
/**
* This is a helper method for implementing
* {@link IStructureElement#survivalPlaceBlock(Object, World, int, int, int, ItemStack, IItemSource, EntityPlayerMP, Consumer)}
@@ -341,7 +360,7 @@ public class StructureUtility {
ItemStack stack = new ItemStack(itemBlock, 1, itemMeta);
if (!s.takeOne(stack, true)) {
if (chatter != null) chatter.accept(
new ChatComponentTranslation("structurelib.autoplace.missing_block", stack.func_151000_E()));
new ChatComponentTranslation("structurelib.autoplace.missing_block", getStackChatName(stack)));
return PlaceResult.REJECT;
}
if (block instanceof ICustomBlockSetting blockCustom) {
@@ -447,7 +466,7 @@ public class StructureUtility {
if (!StructureLibAPI.isBlockTriviallyReplaceable(world, x, y, z, actor)) return PlaceResult.REJECT;
if (!assumeStackPresent && !s.takeOne(stack, true)) {
if (chatter != null) chatter.accept(
new ChatComponentTranslation("structurelib.autoplace.missing_block", stack.func_151000_E()));
new ChatComponentTranslation("structurelib.autoplace.missing_block", getStackChatName(stack)));
return PlaceResult.REJECT;
}
if (!stack.copy().tryPlaceItemIntoWorld(actor, world, x, y, z, ForgeDirection.UP.ordinal(), 0.5f, 0.5f, 0.5f))
@@ -1484,10 +1503,10 @@ public class StructureUtility {
}
for (ItemStack stack : e.getStacks()) {
if (!source.takeOne(stack, true)) {
IChatComponent name = new ChatComponentText(stack.getDisplayName());
name.setChatStyle(new ChatStyle().setColor(EnumChatFormatting.YELLOW));
env.getChatter()
.accept(new ChatComponentTranslation("structurelib.autoplace.missing_block", name));
env.getChatter().accept(
new ChatComponentTranslation(
"structurelib.autoplace.missing_block",
getStackChatName(stack)));
continue;
}
return StructureUtility.survivalPlaceBlock(
@@ -3013,7 +3032,8 @@ public class StructureUtility {
@Override
public BlocksToPlace getBlocksToPlace(T t, World world, int x, int y, int z, ItemStack trigger,
AutoPlaceEnvironment env) {
return backing.getBlocksToPlace(t, world, x, y, z, trigger, env);
return backing
.getBlocksToPlace(t, world, x, y, z, ChannelDataAccessor.withChannel(trigger, channel), env);
}
@Deprecated
@@ -43,8 +43,12 @@ item.structurelib.constructableTrigger.gui.info=Many multiblocks support some de
channels.structurelib.show_errors=When set, will highlight parts of the multiblock that is definitely wrong
structurelib.tooltip.channelvalue=§7Indicates value §6%d§r§7 for sub channel §6%s
structurelib.tooltip.indicator_dnd=Drop into GUI to select this value for this channel
structurelib.tooltip.channelvalue=§7Value §b%d§r§7 for subchannel §b%s
structurelib.tooltip.indicator_dnd=Drop into GUI or middle-click to select this value for this channel
structurelib.pickchannel.success=§aSet §b%s§r§a to §b%d§r
structurelib.pickchannel.cleared=§aAll channels cleared§r
structurelib.pickchannel.confirmwipe=§6Sneak+Middle-click again to clear all channels
structurelib.pickchannel.nomapping=§cNo channel mapping found for this item
structurelib.autoplace.built_stat=Built %s blocks
structurelib.autoplace.complete=§aThis multiblock has completed!
@@ -43,7 +43,7 @@ item.structurelib.constructableTrigger.gui.info=Многие мультибло
channels.structurelib.show_errors=Если включено, подсвечивает элементы мультиблока с явными ошибками
structurelib.tooltip.channelvalue=§7Указывает значение §6%d§r§7 для подканала §6%s
structurelib.tooltip.channelvalue=§7Указывает значение §b%d§r§7 для подканала §b%s
structurelib.tooltip.indicator_dnd=Перейдите в GUI, чтобы выбрать это значение для этого канала.
structurelib.autoplace.built_stat=Построено %s блоков
@@ -42,7 +42,7 @@ item.structurelib.constructableTrigger.gui.info=许多多方块的结构都是
channels.structurelib.show_errors=添加后,在投影时会高亮标出必然错误的部分
structurelib.tooltip.channelvalue=§7表示子信道 §6%2$s§r§7 为 §6%d§r
structurelib.tooltip.channelvalue=§7表示子信道 §b%2$s§r§7 为 §b%d§r
structurelib.tooltip.indicator_dnd=拖入GUI中来选择该值
structurelib.autoplace.built_stat=已放置 %s 个方块