diff --git a/AGENTS.md b/AGENTS.md index cf77907..f0d366e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,12 @@ Agent 的主要工作范围**只有**这两个包: 新特性通常必然涉及 `registerFeature` 注册行、mixins.json 条目、lang 键、配方 JSON 这些"例行越界"——请在给出方案时一并列出这些改动点,**打包征得一次同意**后再动手,不要做完了才说。 +## 工具使用 + +- 探索、读写代码**优先使用 IDE 的 MCP 工具**:`list_directory_tree`(代替 `ls`/`find`)、`read_file`(代替 `cat`/`sed`)、`search_text`/`search_regex`/`search_symbol`(代替 `rg`/`grep`)、`get_symbol_info`、`analyze_calls`;编辑用 `apply_patch`/`create_new_file`。 +- 查 26.2 原版类签名**优先用 `read_file` 直接读 loom 缓存 jar 内的类**(会反编译,可读上下文):`~/.gradle/caches/fabric-loom/minecraftMaven/net/minecraft/minecraft-merged-deobf/26.2/minecraft-merged-deobf-26.2.jar!/net/minecraft/.../SomeClass.class`,`javap` 仅作回退。 +- shell 只留给 MCP 做不了的事:gradle 编译/运行、git、文件权限操作等。 + ## 构建与验证 仓库**没有** gradlew wrapper,使用系统 gradle + sdkman JDK: diff --git a/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java b/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java index d3f3a39..11ebeeb 100644 --- a/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java +++ b/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java @@ -21,6 +21,7 @@ import io.ib67.sfcraft.module.game.cauldron.CauldronModule; import io.ib67.sfcraft.module.game.commander.CommanderModule; import io.ib67.sfcraft.module.game.crystal.GravityCrystalModule; import io.ib67.sfcraft.module.game.lunchbox.LunchBoxModule; +import io.ib67.sfcraft.module.game.mapart.MapArtModule; import io.ib67.sfcraft.module.game.mount.MountModule; import io.ib67.sfcraft.module.game.portal.ElytraPortalModule; import io.ib67.sfcraft.module.game.token.TokenModule; @@ -105,6 +106,7 @@ public class SFCraftInitializer extends GuiceModInitializer { registerFeature(MountModule.class); registerFeature(TotemModule.class); registerFeature(ElytraPortalModule.class); + registerFeature(MapArtModule.class); // ItemGroupModule 必须最后注册:它冻结前面各模块被动贡献到 ItemGroupService 的物品,构建创意物品栏 registerFeature(ItemGroupModule.class); } diff --git a/src/main/java/io/ib67/sfcraft/callback/SFCallbacks.java b/src/main/java/io/ib67/sfcraft/callback/SFCallbacks.java index ab02142..e8930a8 100644 --- a/src/main/java/io/ib67/sfcraft/callback/SFCallbacks.java +++ b/src/main/java/io/ib67/sfcraft/callback/SFCallbacks.java @@ -12,6 +12,7 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.util.Unit; import net.minecraft.world.damagesource.DamageSource; import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AnvilMenu; import java.util.function.Consumer; import static io.ib67.sfcraft.callback.Utility.*; @@ -36,6 +37,13 @@ public interface SFCallbacks { l -> (p, t, f) -> forEach(l, i -> i.onFlyingTick(p, t, f))); Event PLAYER_AFK = EventFactory.createArrayBacked(PlayerAFKCallback.class, l -> (p, s) -> forEach(l, i -> i.onAFKStatus(p, s))); + /** 铁砧 createResult 尾部触发:此时原版已算好结果槽,监听器可读取输入/命名并接管结果槽。 */ + Event ANVIL_CREATE_RESULT = EventFactory.createArrayBacked(AnvilCreateResultCallback.class, + l -> (m, p) -> forEach(l, i -> i.onAnvilCreateResult(m, p))); + + interface AnvilCreateResultCallback { + void onAnvilCreateResult(AnvilMenu menu, Player player); + } interface PlayerAFKCallback { void onAFKStatus(ServerPlayer player, boolean inAFK); diff --git a/src/main/java/io/ib67/sfcraft/config/GameConfig.java b/src/main/java/io/ib67/sfcraft/config/GameConfig.java index a39e32f..971f23b 100644 --- a/src/main/java/io/ib67/sfcraft/config/GameConfig.java +++ b/src/main/java/io/ib67/sfcraft/config/GameConfig.java @@ -17,12 +17,28 @@ public class GameConfig { public Gps gps = new Gps(); public StandingFirmTotem standingFirmTotem = new StandingFirmTotem(); public ElytraPortal elytraPortal = new ElytraPortal(); + public MapArt mapArt = new MapArt(); public static class ElytraPortal { // 鞘翅滑翔冲进下界传送门时跳过等待(立即传送)的最低速度,单位米/秒;<= 0 关闭本特性 public double minSpeedBlocksPerSecond = 25.0; } + public static class MapArt { + // 单张图片的下载体积上限(字节) + public int maxDownloadBytes = 8388608; + // 全服同时进行中的图片下载+解码数上限,超出时新请求直接失败提示稍后重试(不排队) + public int maxConcurrentRenders = 2; + // HTTP 超时(秒) + public int httpTimeoutSeconds = 15; + // 输入图片单边像素上限(解码像素前按头部尺寸拦截,防爆内存);成品按宽高比在 4x4 格内自动选幅画 + public int maxImageDimension = 2048; + // 远端下载/解码失败的 URL 冷却秒数,期间同一地址不再发起请求 + public int failureCooldownSeconds = 60; + // 铁砧生成地图画的经验等级花费 + public int anvilXpCost = 1; + } + public static class StandingFirmTotem { // 可反复使用的图腾变种:触发时扣掉一部分总经验,并进入冷却 public int cooldownTicks = 600; // 30 秒 diff --git a/src/main/java/io/ib67/sfcraft/mixin/gameplay/AnvilMenuMixin.java b/src/main/java/io/ib67/sfcraft/mixin/gameplay/AnvilMenuMixin.java new file mode 100644 index 0000000..76febda --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/mixin/gameplay/AnvilMenuMixin.java @@ -0,0 +1,68 @@ +package io.ib67.sfcraft.mixin.gameplay; + +import io.ib67.sfcraft.callback.SFCallbacks; +import io.ib67.sfcraft.module.game.mapart.MapArtAnvilAccess; +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.AnvilMenu; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.inventory.DataSlot; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * 铁砧钩子:在 {@code createResult} 尾部发布 {@link SFCallbacks#ANVIL_CREATE_RESULT} 事件。 + * 附带实现 {@link MapArtAnvilAccess}(鸭接口),向监听器暴露输入/命名/结果槽的读写能力—— + * 地图画模块据此在重命名为图片 URL 时接管结果槽。 + * + *

26.2 中 {@code inputSlots}/{@code resultSlots}/{@code player} 已上移至 + * {@link net.minecraft.world.inventory.ItemCombinerMenu},@Shadow 无法解析父类字段, + * 故输入/结果槽改走公开的槽位表访问,player 在构造时从 {@link Inventory} 捕获。 + */ +@Mixin(AnvilMenu.class) +public abstract class AnvilMenuMixin implements MapArtAnvilAccess { + @Shadow + private @Nullable String itemName; + @Shadow + @Final + private DataSlot cost; + + @Unique + private Player sfcraft$player; + + @Inject(method = "(ILnet/minecraft/world/entity/player/Inventory;Lnet/minecraft/world/inventory/ContainerLevelAccess;)V", at = @At("TAIL")) + private void sfcraft$capturePlayer(int syncId, Inventory inventory, ContainerLevelAccess access, CallbackInfo ci) { + this.sfcraft$player = inventory.player; + } + + @Override + public ItemStack sfcraft$getInput() { + return ((AbstractContainerMenu) (Object) this).slots.get(AnvilMenu.INPUT_SLOT).getItem(); + } + + @Override + public @Nullable String sfcraft$getItemName() { + return this.itemName; + } + + @Override + public void sfcraft$setResult(ItemStack stack, int xpCost) { + ((AbstractContainerMenu) (Object) this).slots.get(AnvilMenu.RESULT_SLOT).container.setItem(0, stack); + this.cost.set(xpCost); + ((AbstractContainerMenu) (Object) this).broadcastChanges(); + } + + @Inject(method = "createResult", at = @At("TAIL")) + private void sfcraft$mapArtResult(CallbackInfo ci) { + SFCallbacks.ANVIL_CREATE_RESULT.invoker() + .onAnvilCreateResult((AnvilMenu) (Object) this, this.sfcraft$player); + } +} diff --git a/src/main/java/io/ib67/sfcraft/mixin/gameplay/ItemFrameMixin.java b/src/main/java/io/ib67/sfcraft/mixin/gameplay/ItemFrameMixin.java new file mode 100644 index 0000000..1cf5236 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/mixin/gameplay/ItemFrameMixin.java @@ -0,0 +1,89 @@ +package io.ib67.sfcraft.mixin.gameplay; + +import io.ib67.sfcraft.SFCraft; +import io.ib67.sfcraft.module.game.mapart.MapArtModule; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.decoration.ItemFrame; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * 地图画离开物品框即连框消失(不掉落画也不掉落框),见 {@link MapArtModule#onItemFrameDrop}。 + * 私有方法 {@code dropItem(ServerLevel, Entity, boolean)} 是内容物与框本体掉落物的唯一出口。 + * 玩家把地图画放入空框时以该框为左下角自动铺满整幅墙画,见 {@link MapArtModule#onMapArtPlaced}; + * 墙面铺不下(或框朝上/朝下)则在放入前就否掉这次交互,见 {@link MapArtModule#canPlaceMapArt}。 + */ +@Mixin(ItemFrame.class) +public abstract class ItemFrameMixin { + @Unique + private boolean sfcraft$wasEmptyBeforeInteract; + /** interact HEAD 阶段剪出、RETURN 阶段粘贴的"是不是在放地图画"判定结果,避免同一 tick 里二次解析组件。 */ + @Unique + private boolean sfcraft$placingMapArt; + + @Inject(method = "dropItem(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;Z)V", + at = @At("HEAD"), cancellable = true) + private void sfcraft$vanishMapArt(ServerLevel level, Entity causedBy, boolean withFrame, CallbackInfo ci) { + if (SFCraft.getInjector().getInstance(MapArtModule.class) + .onItemFrameDrop((ItemFrame) (Object) this)) { + ci.cancel(); + } + } + + /** + * 记下交互前框是否为空(TAIL 处判断"这次是放入"用),顺带在原版 {@code setItem}/{@code consume} + * 之前否掉铺不下的地图画:此时什么都还没发生,拒绝是干净的,不必事后回滚。 + */ + @Inject(method = "interact", at = @At("HEAD"), cancellable = true) + private void sfcraft$beforeInteract(Player player, InteractionHand hand, Vec3 pos, + CallbackInfoReturnable cir) { + var frame = (ItemFrame) (Object) this; + this.sfcraft$wasEmptyBeforeInteract = frame.getItem().isEmpty(); + this.sfcraft$placingMapArt = false; // RETURN 总会回到这里,不复用上一跳的结果 + if (frame.level().isClientSide() || !this.sfcraft$wasEmptyBeforeInteract + || !(player instanceof ServerPlayer serverPlayer)) { + return; + } + var held = player.getItemInHand(hand); + if (!MapArtModule.isMapArt(held)) { + return; // 非地图画:不打扰原版,也不留 placingMapArt 痕迹 + } + if (!SFCraft.getInjector().getInstance(MapArtModule.class) + .canPlaceMapArt(frame, held, serverPlayer)) { + cir.setReturnValue(InteractionResult.FAIL); + return; + } + this.sfcraft$placingMapArt = true; + } + + // RETURN 而非 TAIL:interact 有多个 return,放画走的是"框空+手上有物品"那条早退分支, + // TAIL 只会绑到方法末尾的旋转分支(那里 wasEmpty 必为 false,守卫永不成立)。 + @Inject(method = "interact", at = @At("RETURN")) + private void sfcraft$onMapArtPlaced(Player player, InteractionHand hand, Vec3 pos, + CallbackInfoReturnable cir) { + try { + if (!this.sfcraft$placingMapArt || ((ItemFrame) (Object) this).level().isClientSide() + || !(player instanceof ServerPlayer serverPlayer)) { + return; + } + // HEAD 里已确认是地图画,这里不再二次解析组件。 + if (this.sfcraft$wasEmptyBeforeInteract && !((ItemFrame) (Object) this).getItem().isEmpty()) { + SFCraft.getInjector().getInstance(MapArtModule.class) + .onMapArtPlaced((ItemFrame) (Object) this, serverPlayer); + } + } finally { + // 单条 interact 调用结束就要擦掉,避免堆栈帧复用到下一跳。 + this.sfcraft$placingMapArt = false; + } + } +} diff --git a/src/main/java/io/ib67/sfcraft/module/game/mapart/MapArtAnvilAccess.java b/src/main/java/io/ib67/sfcraft/module/game/mapart/MapArtAnvilAccess.java new file mode 100644 index 0000000..ab56b90 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/module/game/mapart/MapArtAnvilAccess.java @@ -0,0 +1,18 @@ +package io.ib67.sfcraft.module.game.mapart; + +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * {@code AnvilMenuMixin} 实现的鸭子接口,把铁砧菜单的内部输入/名称/结果槽暴露给 {@link MapArtModule}。 + */ +public interface MapArtAnvilAccess { + /** 输入槽 0 的物品。 */ + ItemStack sfcraft$getInput(); + + /** 当前输入的重命名文本(null = 未输入)。 */ + @Nullable String sfcraft$getItemName(); + + /** 覆写结果槽与所需经验等级,并同步客户端。 */ + void sfcraft$setResult(ItemStack stack, int xpCost); +} diff --git a/src/main/java/io/ib67/sfcraft/module/game/mapart/MapArtModule.java b/src/main/java/io/ib67/sfcraft/module/game/mapart/MapArtModule.java new file mode 100644 index 0000000..9b7b9ed --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/module/game/mapart/MapArtModule.java @@ -0,0 +1,762 @@ +package io.ib67.sfcraft.module.game.mapart; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.inject.Inject; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import io.ib67.sfcraft.ServerModule; +import io.ib67.sfcraft.callback.SFCallbacks; +import io.ib67.sfcraft.config.GameConfigService; +import io.ib67.sfcraft.inject.MinecraftServerSupplier; +import lombok.extern.log4j.Log4j2; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.commands.CommandBuildContext; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.component.DataComponents; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.decoration.GlowItemFrame; +import net.minecraft.world.entity.decoration.HangingEntity; +import net.minecraft.world.entity.decoration.ItemFrame; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AnvilMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.component.CustomData; +import net.minecraft.world.item.component.TooltipDisplay; +import net.minecraft.world.level.saveddata.maps.MapItemSavedData; +import org.jspecify.annotations.Nullable; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Collections; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.WeakHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; + +/** + * 地图画:把 http(s) 图片下载后映射为原版填充地图画(MapColor 调色板近似)。 + *

+ * 玩家拿到的是单张"种子"地图(128x128 整图预览);上墙后每格替换为 128x128 的局部清晰切片。 + * 输入单边像素上限见 + * {@code GameConfig.MapArt#maxImageDimension},在解码像素前先读头部尺寸拦截,防止解压炸弹。 + * 生成物是普通 {@code FILLED_MAP}+{@code MAP_ID}/{@code CUSTOM_DATA} 组件,纯原版物品,无需 Polymer。 + * 名称走 {@code ITEM_NAME}(玩家不再看到每格"地图画"名牌,也不能再被铁砧重命名); + * 上墙的切片用 {@code TOOLTIP_DISPLAY} 隐藏 {@code MAP_ID},工具提示只剩名称一行。 + */ +@Log4j2 +public class MapArtModule extends ServerModule { + /** + * 地图画标记(custom_data 里的布尔键),玩家在铁砧放入地图画、以及移除 {@link #MAP_ART_URL} 等 + * 读取路径的轻量判定;堆栈本身比较、掉落时避免深拷贝都用它。 + */ + public static final String MAP_ART_MARKER = "sfcraft_map_art"; + /** + * 上墙地图画所在物品框的实体 tag,随框存档持久化,是"这个框掉了要连画一起消失"的判定依据。 + * 铺墙时就打在框本身,掉落路径上不再深拷贝内容物的 NBT。 + */ + public static final String MAP_ART_FRAME_TAG = "sfcraft_map_art"; + /** + * 图片来源 URL(custom_data 里的字符串键),上墙展开时凭它从渲染缓存取整幅像素。 + */ + public static final String MAP_ART_URL = "sfcraft_map_art_url"; + /** + * 铺墙格数(custom_data 里的整数键),在种子图上随 URL 一起写入。 + * 有了它,放置前的空间校验无需等下载/渲染就能同步做完。 + */ + public static final String MAP_ART_COLS = "sfcraft_map_art_cols"; + public static final String MAP_ART_ROWS = "sfcraft_map_art_rows"; + /** + * 铺墙单边格数上限(gridFor 与种子图格数校验共用,防恶意伪造的种子图铺出超大实体墙)。 + */ + private static final int MAP_GRID_LIMIT = 4; + /** + * 渲染信号量的静态容量上限。运行时真正的并发配额每次 render 都从配置读, + * 这里只是给信号量建一个够大的池子,配置里调大也够用。 + */ + private static final int MAX_RENDER_SLOTS = 32; + private static final int ANVIL_DEBOUNCE_MS = 400; + private static final int RENDER_CACHE_SIZE = 16; + /** + * 仅含 {@link #MAP_ART_MARKER} 一个键的探测 tag,配合 {@link CustomData#matchedBy} 做布尔判定, + * 不付出 {@link CustomData#copyTag()} 的整树深拷贝成本。 + */ + private static final CompoundTag MARKER_PROBE = new CompoundTag(); + + static { + MARKER_PROBE.putBoolean(MAP_ART_MARKER, true); + } + + /** + * JDK ImageIO 默认可栅格化的扩展名;webp 不在其中。 + */ + private static final Set SUPPORTED_EXTENSIONS = Set.of(".png", ".jpg", ".jpeg", ".gif", ".bmp"); + + @Inject + private GameConfigService configService; + @Inject + private MinecraftServerSupplier serverSupplier; + + private final HttpClient httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + private final Map anvilSessions = Collections.synchronizedMap(new WeakHashMap<>()); + private final Cache renderCache = CacheBuilder.newBuilder() + .maximumSize(RENDER_CACHE_SIZE) + .build(); + private final Executor debouncedExecutor = CompletableFuture.delayedExecutor(ANVIL_DEBOUNCE_MS, TimeUnit.MILLISECONDS); + /** + * 远端下载/解码失败的地址黑名单:url -> 允许重试的 epoch millis。 + */ + private final Map failedUrls = new ConcurrentHashMap<>(); + /** + * 全服同时进行的远端下载+解码数上限,超出直接拒绝(不排队,挂起等待的玩家拿不到反馈)。 + * 默认从 {@link GameConfig.MapArt#maxConcurrentRenders} 读;运行时改该配置只影响新起的请求。 + */ + private final java.util.concurrent.Semaphore renderSlots = new java.util.concurrent.Semaphore(MAX_RENDER_SLOTS); + + @Override + public void onInitialize() { + CommandRegistrationCallback.EVENT.register(this::registerCommand); + SFCallbacks.ANVIL_CREATE_RESULT.register(this::onAnvilCreateResult); + } + + private void registerCommand(CommandDispatcher dispatcher, + CommandBuildContext registryAccess, + Commands.CommandSelection env) { + dispatcher.register(LiteralArgumentBuilder.literal("mapfor") + .requires(source -> this.isEnabled() && source.isPlayer() + && Commands.LEVEL_GAMEMASTERS.check(source.permissions())) + .then(RequiredArgumentBuilder.argument("url", StringArgumentType.string()) + .executes(this::onMapFor))); + } + + private int onMapFor(CommandContext context) { + var source = context.getSource(); + var player = source.getPlayer(); + if (player == null) { + return 0; + } + // 异步回调只带 UUID:下载期间玩家可能下线,强引用 ServerPlayer 会滞留整个下载周期 + var playerId = player.getUUID(); + var url = StringArgumentType.getString(context, "url"); + source.sendSuccess(() -> Component.translatable("command.sfcraft.mapart.started", url), false); + render(url).whenComplete((outcome, error) -> scheduleOnServer(() -> { + var target = serverSupplier.get().getPlayerList().getPlayer(playerId); + if (target == null) { + return; // 已下线;渲染结果仍在缓存里,重新执行命令即可 + } + if (outcome == null || outcome.rendered() == null) { + target.sendSystemMessage(Component.translatable("message.sfcraft.mapart.failed", + describe(outcome, error))); + return; + } + give(target, buildMap(target.level(), outcome.rendered(), url, + target.getX(), target.getZ())); + target.sendSystemMessage(Component.translatable("command.sfcraft.mapart.success")); + })); + return 1; + } + + // ============================== 铁砧 ============================== + + /** + * 监听 {@link SFCallbacks#ANVIL_CREATE_RESULT}:URL 命名的地图接管结果槽。 + */ + public void onAnvilCreateResult(AnvilMenu menu, Player player) { + var access = (MapArtAnvilAccess) menu; + var input = access.sfcraft$getInput(); + var name = access.sfcraft$getItemName(); + if ((!input.is(Items.MAP) && !input.is(Items.FILLED_MAP)) || name == null || !isUrlLike(name)) { + var removed = anvilSessions.remove(menu); + if (removed != null && removed.future != null) { + removed.future.cancel(false); + } + return; + } + var session = anvilSessions.get(menu); + if (session != null && session.url.equals(name)) { + // 同名重入(createResult 会因槽位/输入变动反复触发):失败过或未完成则保持压制,完成则补上结果 + access.sfcraft$setResult(session.stack == null ? ItemStack.EMPTY : session.stack, + session.stack == null ? 0 : anvilXpCost()); + return; + } + if (session != null && session.future != null) { + session.future.cancel(false); + } + // 先压掉原版"重命名为 URL"的结果,下载渲染完成前玩家什么也拿不走 + access.sfcraft$setResult(ItemStack.EMPTY, 0); + var newSession = new AnvilSession(name, player.getUUID()); + anvilSessions.put(menu, newSession); + var future = CompletableFuture + .runAsync(() -> { + }, debouncedExecutor) + .thenCompose(ignored -> render(name)); + newSession.future = future; + future.whenComplete((outcome, error) -> + scheduleOnServer(() -> completeAnvilSession(menu, newSession, outcome, error))); + } + + private void completeAnvilSession(AnvilMenu menu, AnvilSession session, RenderOutcome outcome, Throwable error) { + if (anvilSessions.get(menu) != session) { + return; // 玩家已经改动输入/改名,丢弃过期结果 + } + var player = serverSupplier.get().getPlayerList().getPlayer(session.playerId); + if (player == null || player.isRemoved()) { + anvilSessions.remove(menu); // 已下线,界面也没了,无需再发 + return; + } + if (outcome == null || outcome.rendered() == null) { + session.failed = true; + player.sendSystemMessage(Component.translatable("message.sfcraft.mapart.failed", + describe(outcome, error))); + return; + } + var stack = buildMap(player.level(), outcome.rendered(), session.url, + player.getX(), player.getZ()); + if (player.containerMenu != menu) { + // 完成时砧子界面已关:直接发进背包 + anvilSessions.remove(menu); + give(player, stack); + player.sendSystemMessage(Component.translatable("message.sfcraft.mapart.success_inventory")); + return; + } + session.stack = stack; + ((MapArtAnvilAccess) menu).sfcraft$setResult(stack, anvilXpCost()); + } + + // ============================== 物品框 ============================== + + /** + * {@code ItemFrameMixin} 在掉落前调用。返回 true 表示已接管:画连框消失,什么都不掉。 + * 判定靠实体 tag,不再深拷贝内容物的 NBT——上墙的地图画在铺墙时已打上 {@link #MAP_ART_FRAME_TAG}。 + */ + public boolean onItemFrameDrop(ItemFrame frame) { + if (!frame.entityTags().contains(MAP_ART_FRAME_TAG)) { + return false; + } + frame.setItem(ItemStack.EMPTY); + frame.discard(); + return true; + } + + /** + * 仅判定"是不是地图画堆栈"(布尔),供铁砧放入校验等无需读取具体键值的场合: + * 伪造一个只含标记键的 1-entry CompoundTag,避开 {@link CustomData#copyTag()} 的整树深拷贝。 + */ + public static boolean isMapArt(ItemStack stack) { + if (!stack.is(Items.FILLED_MAP)) { + return false; + } + var data = stack.get(DataComponents.CUSTOM_DATA); + return data != null && data.matchedBy(MARKER_PROBE); + } + + /** + * 地图画的图片来源 URL(无此键时无法重建整幅,例如旧版本制作的地图画)。 + */ + public static @Nullable String mapArtUrl(ItemStack stack) { + if (!stack.is(Items.FILLED_MAP)) { + return null; + } + var data = stack.get(DataComponents.CUSTOM_DATA); + if (data == null || !data.matchedBy(MARKER_PROBE)) { + return null; + } + // 深拷贝整树只为读一个字符串,太贵;matchedBy 之后这里走的是 1-entry probe 比对路径。 + return data.copyTag().getString(MAP_ART_URL).filter(MapArtModule::isUrlLike).orElse(null); + } + + /** + * 种子图记下的铺墙格数 {cols, rows};旧版本制作的地图画没有这两个键,返回 null。 + */ + private static int @Nullable [] mapArtGrid(ItemStack stack) { + if (!stack.is(Items.FILLED_MAP)) { + return null; + } + var data = stack.get(DataComponents.CUSTOM_DATA); + if (data == null || !data.matchedBy(MARKER_PROBE)) { + return null; // 非地图画/切片的路由:matchedBy 用 1-entry probe 指控,比 copyTag 便宜几个数量级 + } + var tag = data.copyTag(); // 进入这条分支概率很小,承担得起一次深拷贝 + int cols = tag.getIntOr(MAP_ART_COLS, 0); + int rows = tag.getIntOr(MAP_ART_ROWS, 0); + if (cols < 1 || rows < 1 || cols > MAP_GRID_LIMIT || rows > MAP_GRID_LIMIT) { + return null; + } + return new int[]{cols, rows}; + } + + /** + * {@code ItemFrameMixin} 在放入前调用(服务端主线程):返回 false 表示这面墙容不下整幅画, + * 直接否掉这次交互——宁可不让放,也不留一张孤零零的低清种子图挂在墙上。 + */ + public boolean canPlaceMapArt(ItemFrame frame, ItemStack stack, @Nullable ServerPlayer player) { + var grid = mapArtGrid(stack); + if (grid == null || (grid[0] == 1 && grid[1] == 1)) { + return true; // 非地图画/切片/旧版无格数信息/本就单格:照常放 + } + if (!(frame.level() instanceof ServerLevel level)) { + return true; + } + var facing = frame.getDirection(); + if (facing.getAxis().isVertical()) { + sendKeyed(player, "message.sfcraft.mapart.expand.wall_only"); + return false; + } + if (!hasRoom(frame, level, facing, grid[0], grid[1])) { + sendKeyed(player, "message.sfcraft.mapart.expand.no_space", grid[0], grid[1]); + return false; + } + return true; + } + + /** + * {@code ItemFrameMixin} 在玩家把地图画放入空框后调用(服务端主线程): + * 以该框为左下角,把整幅图片的其余格自动铺满墙面。 + */ + public void onMapArtPlaced(ItemFrame frame, ServerPlayer player) { + var url = mapArtUrl(frame.getItem()); + if (url == null) { + return; + } + var rendered = renderCache.getIfPresent(url); + if (rendered != null) { + expand(frame, rendered, player); // 渲染完就挂墙的常见路径:缓存必中,同步铺开 + return; + } + // 缓存被挤出或服务器重启过:按 URL 重新拉取,完成后回主线程再铺 + var expectedMapId = frame.getItem().get(DataComponents.MAP_ID); + var playerId = player.getUUID(); + render(url).whenComplete((outcome, error) -> scheduleOnServer(() -> { + if (frame.isRemoved() || !url.equals(mapArtUrl(frame.getItem()))) { + return; // 框已消失或内容物已被换掉 + } + if (!Objects.equals(expectedMapId, frame.getItem().get(DataComponents.MAP_ID))) { + return; // 内容物虽仍像地图画,但已不是当初那张,放弃 + } + var target = serverSupplier.get().getPlayerList().getPlayer(playerId); + if (outcome == null || outcome.rendered() == null) { + sendKeyed(target, "message.sfcraft.mapart.failed", describe(outcome, error)); + return; + } + expand(frame, outcome.rendered(), target); + })); + } + + /** + * 主线程:把 frame(作为左下角)所在墙面铺满成 cols x rows 的整幅墙画。 + * 放置前已校验过空间,这里再兜一次底:下载期间墙面被占的话把种子图退还玩家。 + */ + private void expand(ItemFrame frame, Rendered art, @Nullable ServerPlayer player) { + if (art.cols() == 1 && art.rows() == 1) { + // 单格:种子图本身即成品,也要打上 tag 才能走掉落消失的路径(后续可能是旧种子图、或玩家手动放进空框)。 + frame.addTag(MAP_ART_FRAME_TAG); + return; + } + if (!(frame.level() instanceof ServerLevel level)) { + return; + } + var facing = frame.getDirection(); + if (facing.getAxis().isVertical()) { + sendKeyed(player, "message.sfcraft.mapart.expand.wall_only"); + revert(frame, player); + return; + } + if (!hasRoom(frame, level, facing, art.cols(), art.rows())) { + sendKeyed(player, "message.sfcraft.mapart.expand.no_space", art.cols(), art.rows()); + revert(frame, player); + return; + } + // 观察者面对框时的右手方向:面朝向绕 Y 轴逆时针转 90 度(如框朝南,右侧为东) + var right = facing.getCounterClockWise(); + var origin = frame.blockPosition(); + frame.setItem(buildTile(level, art, 0, 0, frame.getX(), frame.getZ())); + frame.addTag(MAP_ART_FRAME_TAG); + for (int tx = 0; tx < art.cols(); tx++) { + for (int ty = 0; ty < art.rows(); ty++) { + if (tx == 0 && ty == 0) { + continue; + } + var tile = newFrameLike(frame, level, origin.relative(right, tx).above(ty), facing); + tile.setItem(buildTile(level, art, tx, ty, frame.getX(), frame.getZ())); + tile.addTag(MAP_ART_FRAME_TAG); + level.addFreshEntity(tile); + } + } + } + + /** + * 以 frame 为左下角,cols x rows 的墙面是否都能挂得下框(左下角那格是 frame 自己,跳过)。 + */ + private static boolean hasRoom(ItemFrame frame, ServerLevel level, Direction facing, int cols, int rows) { + var right = facing.getCounterClockWise(); + var origin = frame.blockPosition(); + for (int tx = 0; tx < cols; tx++) { + for (int ty = 0; ty < rows; ty++) { + if (tx == 0 && ty == 0) { + continue; + } + var probe = newFrameLike(frame, level, origin.relative(right, tx).above(ty), facing); + var occupied = !level.getEntitiesOfClass(HangingEntity.class, probe.getBoundingBox()).isEmpty(); + if (!probe.survives() || occupied) { + return false; + } + } + } + return true; + } + + /** + * 展开不成:把框腾空、种子图退回玩家。玩家已下线就只能维持现状(退无可退)。 + * 创造模式下原版 {@code consume} 不扣手上那份,退回等于复制,故只腾空不补发。 + */ + private static void revert(ItemFrame frame, @Nullable ServerPlayer player) { + if (player == null) { + return; + } + var seed = frame.getItem().copy(); + frame.setItem(ItemStack.EMPTY); + if (!player.hasInfiniteMaterials()) { + give(player, seed); + } + } + + private static ItemFrame newFrameLike(ItemFrame template, ServerLevel level, BlockPos pos, Direction facing) { + return template instanceof GlowItemFrame + ? new GlowItemFrame(level, pos, facing) + : new ItemFrame(level, pos, facing); + } + + private static void sendKeyed(@Nullable ServerPlayer player, String key, Object... args) { + if (player != null) { + player.sendSystemMessage(Component.translatable(key, args)); + } + } + + // ============================== 生成 ============================== + + /** + * 在主线程调用:产出"种子"地图画——整幅 128x128 预览,记下来源 URL 供上墙展开。 + */ + private ItemStack buildMap(ServerLevel level, Rendered art, String url, double originX, double originZ) { + var data = MapItemSavedData.createFresh(originX, originZ, (byte) 0, false, false, level.dimension()); + MapArtRenderer.copyToMapColors(art.canvas(), art.cols(), art.rows(), data.colors); + return finishMap(level, data, url, art.cols(), art.rows()); + } + + /** + * 在主线程调用:产出整幅墙画的一格切片(tx/ty 以左下角为原点),不带来源 URL(不再二次展开)。 + */ + private ItemStack buildTile(ServerLevel level, Rendered art, int tx, int ty, double originX, double originZ) { + var data = MapItemSavedData.createFresh(originX, originZ, (byte) 0, false, false, level.dimension()); + int canvasWidth = art.cols() * MapArtRenderer.MAP_SIZE; + int rowFromTop = art.rows() - 1 - ty; // 画布像素第 0 行在图像顶部,墙上第 0 格在最底部 + for (int y = 0; y < MapArtRenderer.MAP_SIZE; y++) { + System.arraycopy(art.canvas(), ((rowFromTop * MapArtRenderer.MAP_SIZE + y) * canvasWidth) + + tx * MapArtRenderer.MAP_SIZE, data.colors, + y * MapArtRenderer.MAP_SIZE, MapArtRenderer.MAP_SIZE); + } + var stack = finishMap(level, data, null, 0, 0); + stack.set(DataComponents.TOOLTIP_DISPLAY, + TooltipDisplay.DEFAULT.withHidden(DataComponents.MAP_ID, true)); + return stack; + } + + /** + * 锁定画布、申请 map id 并打包成带地图画标记的填充地图。url 为 null 时不写来源/格数(切片不再展开)。 + */ + private static ItemStack finishMap(ServerLevel level, MapItemSavedData data, @Nullable String url, + int cols, int rows) { + var locked = data.locked(); // 锁定:不可再被制图台改图/缩放 + locked.setDirty(); + var mapId = level.getFreeMapId(); + level.setMapData(mapId, locked); + var stack = new ItemStack(Items.FILLED_MAP); + stack.set(DataComponents.MAP_ID, mapId); + stack.set(DataComponents.ITEM_NAME, + Component.translatable("block.sfcraft.map_art").withStyle(style -> style.withItalic(false))); + var marker = new CompoundTag(); + marker.putBoolean(MAP_ART_MARKER, true); + if (url != null) { + marker.putString(MAP_ART_URL, url); + marker.putInt(MAP_ART_COLS, cols); + marker.putInt(MAP_ART_ROWS, rows); + } + CustomData.set(DataComponents.CUSTOM_DATA, stack, marker); + return stack; + } + + private static void give(ServerPlayer player, ItemStack stack) { + if (!player.getInventory().add(stack)) { + player.drop(stack, false); + } + } + + // ============================== 下载与渲染 ============================== + + private CompletableFuture render(String url) { + var settings = configService.get().mapArt; + var cached = renderCache.getIfPresent(url); + if (cached != null) { + return CompletableFuture.completedFuture(RenderOutcome.ok(cached)); + } + var retryAfter = failedUrls.get(url); + if (retryAfter != null) { + long remainingMillis = retryAfter - System.currentTimeMillis(); + if (remainingMillis > 0) { + return CompletableFuture.completedFuture(RenderOutcome.fail( + RenderFailure.COOLDOWN, Long.toString(TimeUnit.MILLISECONDS.toSeconds(remainingMillis) + 1))); + } + failedUrls.remove(url); + } + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + return CompletableFuture.completedFuture(RenderOutcome.fail(RenderFailure.INVALID_URL, null)); + } + var scheme = uri.getScheme(); + if (uri.getHost() == null || scheme == null + || !(scheme.equalsIgnoreCase("https") || scheme.equalsIgnoreCase("http"))) { + return CompletableFuture.completedFuture(RenderOutcome.fail(RenderFailure.INVALID_URL, null)); + } + // 防 SSRF:不允许 user:pass@host 这类内嵌验证信息 + if (uri.getUserInfo() != null) { + return CompletableFuture.completedFuture(RenderOutcome.fail(RenderFailure.USERINFO, null)); + } + var path = uri.getPath() == null ? "" : uri.getPath().toLowerCase(Locale.ROOT); + if (SUPPORTED_EXTENSIONS.stream().noneMatch(path::endsWith)) { + return CompletableFuture.completedFuture(RenderOutcome.fail(RenderFailure.UNSUPPORTED_EXTENSION, null)); + } + // 并发上限:每次 render 都从配置读当前上限,已持有信号量的数量超过它就直接拒绝。 + // 信号量实际容量是 MAX_RENDER_SLOTS(静态给足 32),运行时真正的配额靠 + // "已持有数 >= allowed 就 tryAcquire 失败"这个不变式维护。 + int allowed = Math.max(1, settings.maxConcurrentRenders); + int inFlight = MAX_RENDER_SLOTS - renderSlots.availablePermits(); + if (inFlight >= allowed || !renderSlots.tryAcquire()) { + return CompletableFuture.completedFuture(RenderOutcome.fail(RenderFailure.BUSY, null)); + } + var request = HttpRequest.newBuilder(uri) + .timeout(Duration.ofSeconds(Math.max(1, settings.httpTimeoutSeconds))) + .GET() + .build(); + int maxBytes = Math.max(1024, settings.maxDownloadBytes); + try { + return httpClient.sendAsync(request, + HttpResponse.BodyHandlers.buffering(HttpResponse.BodyHandlers.ofByteArray(), maxBytes)) + .thenApply(response -> { + if (response.statusCode() / 100 != 2) { + markFailed(url); + return RenderOutcome.fail(RenderFailure.DOWNLOAD_FAILED, "HTTP " + response.statusCode()); + } + var contentType = response.headers().firstValue("content-type").orElse(""); + if (!contentType.isBlank() && !contentType.regionMatches(true, 0, "image/", 0, 6)) { + markFailed(url); + return RenderOutcome.fail(RenderFailure.NOT_IMAGE, null); + } + return decode(url, response.body()); + }) + .whenComplete((outcome, error) -> renderSlots.release()); + } catch (Exception e) { + // 构造链里抛出来的(极少数:URI 类已在外层查过);确保信号量能归还,并把异常变成正常失败 + renderSlots.release(); + markFailed(url); + return CompletableFuture.completedFuture(RenderOutcome.fail(RenderFailure.DOWNLOAD_FAILED, rootMessage(e))); + } + } + + /** + * 先读图片头部尺寸(不解码像素)拦截超大图,通过后再栅格化到整幅墙画画布。 + */ + private RenderOutcome decode(String url, byte[] body) { + int maxDimension = configService.get().mapArt.maxImageDimension; + ImageReader reader = null; + try (var imageStream = ImageIO.createImageInputStream(new ByteArrayInputStream(body))) { + if (imageStream == null) { + markFailed(url); + return RenderOutcome.fail(RenderFailure.NOT_IMAGE, null); + } + var readers = ImageIO.getImageReaders(imageStream); + if (!readers.hasNext()) { + markFailed(url); + return RenderOutcome.fail(RenderFailure.NOT_IMAGE, null); + } + reader = readers.next(); + reader.setInput(imageStream); + int width = reader.getWidth(0); + int height = reader.getHeight(0); + if (width <= 0 || height <= 0) { + markFailed(url); + return RenderOutcome.fail(RenderFailure.NOT_IMAGE, null); + } + if (width > maxDimension || height > maxDimension) { + markFailed(url); + return RenderOutcome.fail(RenderFailure.TOO_LARGE, width + "x" + height); + } + var image = reader.read(0); + var grid = gridFor(width, height); + int canvasWidth = grid[0] * MapArtRenderer.MAP_SIZE; + int canvasHeight = grid[1] * MapArtRenderer.MAP_SIZE; + var canvas = grid[0] == 1 && grid[1] == 1 + ? MapArtRenderer.render(image) + : MapArtRenderer.render(image, canvasWidth, canvasHeight); + var rendered = new Rendered(grid[0], grid[1], canvas); + renderCache.put(url, rendered); + return RenderOutcome.ok(rendered); + } catch (IOException e) { + log.warn("Failed to decode map art image from {}", url, e); + markFailed(url); + return RenderOutcome.fail(RenderFailure.NOT_IMAGE, null); + } finally { + if (reader != null) { + reader.dispose(); + } + } + } + + /** + * 远端失败(网络/非图片/超大)后进入短时黑名单,避免玩家反复触发下载。 + */ + private void markFailed(String url) { + long cooldownMillis = Math.max(0, configService.get().mapArt.failureCooldownSeconds) * 1000L; + failedUrls.put(url, System.currentTimeMillis() + cooldownMillis); + } + + private Component describe(RenderOutcome outcome, Throwable error) { + if (outcome != null && outcome.failure() != null) { + return switch (outcome.failure()) { + case INVALID_URL -> Component.translatable("message.sfcraft.mapart.error.invalid_url"); + case USERINFO -> Component.translatable("message.sfcraft.mapart.error.userinfo"); + case UNSUPPORTED_EXTENSION -> + Component.translatable("message.sfcraft.mapart.error.unsupported_extension"); + case COOLDOWN -> Component.translatable("message.sfcraft.mapart.error.cooldown", outcome.detail()); + case TOO_LARGE -> Component.translatable("message.sfcraft.mapart.error.too_large", + outcome.detail(), configService.get().mapArt.maxImageDimension); + case NOT_IMAGE -> Component.translatable("message.sfcraft.mapart.error.not_image"); + case DOWNLOAD_FAILED -> Component.translatable("message.sfcraft.mapart.error.download_failed", + outcome.detail() == null ? "?" : outcome.detail()); + case BUSY -> Component.translatable("message.sfcraft.mapart.error.busy", + configService.get().mapArt.maxConcurrentRenders); + }; + } + return Component.translatable("message.sfcraft.mapart.error.download_failed", rootMessage(error)); + } + + private static String rootMessage(Throwable error) { + Throwable cause = error; + while ((cause instanceof java.util.concurrent.CompletionException + || cause instanceof java.util.concurrent.ExecutionException) && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause == null ? "?" : String.valueOf(cause.getMessage()); + } + + private void scheduleOnServer(Runnable task) { + var server = serverSupplier.get(); + if (server == null || server.isStopped()) { + return; + } + server.execute(task); + } + + private int anvilXpCost() { + return Math.max(1, configService.get().mapArt.anvilXpCost); + } + + /** + * 按原图宽高比决定铺墙格数 {cols, rows}。 + * 在 {@code MAP_GRID_LIMIT} 内枚举所有 cols × rows 组合,取 {@code |ln((cols/rows) / 图片宽高比)|} + * 最小的——让目标画布比例在原图比例的最近对数邻域里落位。整数离散化后常见输入的落位: + * 1:1 → 1x1;3:2 / 16:9 → 3x2;用户那张 1.34 ≈ 4:3 → 4x3;明显长条(≥ ~2.4)封顶到 4x1~4x4 里的最贴比。 + * 装得下过去的旧版 3xN 种子图(格数源自 3 上限)依然能铺。 + */ + private static int[] gridFor(int imageWidth, int imageHeight) { + double logAspect = Math.log((double) imageWidth / imageHeight); + int bestCols = 1, bestRows = 1; + double bestDistortion = Double.MAX_VALUE; + for (int cols = 1; cols <= MAP_GRID_LIMIT; cols++) { + for (int rows = 1; rows <= MAP_GRID_LIMIT; rows++) { + double distortion = Math.abs(Math.log((double) cols / rows) - logAspect); + if (distortion < bestDistortion - 1e-9) { // eps:失真度并列时保持靠前的组合,结果稳定 + bestDistortion = distortion; + bestCols = cols; + bestRows = rows; + } + } + } + return new int[]{bestCols, bestRows}; + } + + private static boolean isUrlLike(String text) { + var lower = text.toLowerCase(Locale.ROOT); + return (lower.startsWith("https://") || lower.startsWith("http://")) + && text.chars().noneMatch(Character::isWhitespace); + } + + /** + * 一次渲染的产物:cols*128 x rows*128 的整幅调色板像素(行优先铺开,第 0 行为图像顶部)。1x1 时 canvas=preview。 + */ + private record Rendered(int cols, int rows, byte[] canvas) { + } + + private record RenderOutcome(@Nullable Rendered rendered, @Nullable RenderFailure failure, + @Nullable String detail) { + private static RenderOutcome ok(Rendered rendered) { + return new RenderOutcome(rendered, null, null); + } + + private static RenderOutcome fail(RenderFailure failure, @Nullable String detail) { + return new RenderOutcome(null, failure, detail); + } + } + + private enum RenderFailure { + INVALID_URL, USERINFO, UNSUPPORTED_EXTENSION, COOLDOWN, DOWNLOAD_FAILED, NOT_IMAGE, TOO_LARGE, BUSY + } + + private static final class AnvilSession { + private final String url; + private final UUID playerId; + private volatile CompletableFuture future; + private volatile ItemStack stack; + private volatile boolean failed; + + private AnvilSession(String url, UUID playerId) { + this.url = url; + this.playerId = playerId; + } + } +} diff --git a/src/main/java/io/ib67/sfcraft/module/game/mapart/MapArtRenderer.java b/src/main/java/io/ib67/sfcraft/module/game/mapart/MapArtRenderer.java new file mode 100644 index 0000000..89353d8 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/module/game/mapart/MapArtRenderer.java @@ -0,0 +1,109 @@ +package io.ib67.sfcraft.module.game.mapart; + +import net.minecraft.world.level.material.MapColor; + +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.util.Arrays; + +/** + * 把任意尺寸的图片来源伸缩为指定尺寸画布(单张地图 128x128,多格墙画画布为 128 的整数倍)的像素缓冲(MapColor 打包 id)。 + * 每像素取 RGB 距离最近的 MapColor x Brightness 组合;alpha < 128 视为透明(打包 id 0)。 + */ +public final class MapArtRenderer { + public static final int MAP_SIZE = 128; + + private static final int[] PALETTE_RGB; + private static final byte[] PALETTE_PACKED; + + static { + var rgbs = new int[63 * 4]; + var packed = new byte[63 * 4]; + int size = 0; + for (int id = 1; id <= 63; id++) { + MapColor color = MapColor.byId(id); + if (color == MapColor.NONE) { + continue; + } + for (MapColor.Brightness brightness : MapColor.Brightness.values()) { + rgbs[size] = color.calculateARGBColor(brightness); + packed[size] = color.getPackedId(brightness); + size++; + } + } + PALETTE_RGB = new int[size]; + PALETTE_PACKED = new byte[size]; + System.arraycopy(rgbs, 0, PALETTE_RGB, 0, size); + System.arraycopy(packed, 0, PALETTE_PACKED, 0, size); + } + + private MapArtRenderer() { + } + + /** 拉伸至 {@value #MAP_SIZE}x{@value #MAP_SIZE} 并映射为地图画像素。 */ + public static byte[] render(BufferedImage source) { + return render(source, MAP_SIZE, MAP_SIZE); + } + + /** + * 把 cols*128 x rows*128 的整幅画布等比缩到 {@value #MAP_SIZE}x{@value #MAP_SIZE} 地图像素 + * (保持比例、两侧填透明黑,即打包 id 0)。供"种子"地图作整图 preview 用——宽图画布是 256x128, + * 原样铺进 128x128 会丢一半。 + */ + public static void copyToMapColors(byte[] canvas, int cols, int rows, byte[] target) { + int canvasWidth = cols * MAP_SIZE; + int canvasHeight = rows * MAP_SIZE; + double scale = Math.min((double) MAP_SIZE / canvasWidth, (double) MAP_SIZE / canvasHeight); + int fitWidth = (int) Math.round(canvasWidth * scale); + int fitHeight = (int) Math.round(canvasHeight * scale); + int offsetX = (MAP_SIZE - fitWidth) / 2; + int offsetY = (MAP_SIZE - fitHeight) / 2; + Arrays.fill(target, (byte) 0); + for (int y = 0; y < fitHeight; y++) { + for (int x = 0; x < fitWidth; x++) { + int sx = Math.min((int) (x / scale), canvasWidth - 1); + int sy = Math.min((int) (y / scale), canvasHeight - 1); + target[(offsetY + y) * MAP_SIZE + offsetX + x] = canvas[sy * canvasWidth + sx]; + } + } + } + + /** 拉伸至 width x height(行优先铺平,第 0 行为图像顶部)并映射为地图画像素。 */ + public static byte[] render(BufferedImage source, int width, int height) { + var canvas = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = canvas.createGraphics(); + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + graphics.drawImage(source, 0, 0, width, height, null); + graphics.dispose(); + + var pixels = new byte[width * height]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int argb = canvas.getRGB(x, y); + pixels[x + y * width] = (argb >>> 24) < 128 ? 0 : closestColor(argb); + } + } + return pixels; + } + + private static byte closestColor(int argb) { + int red = argb >> 16 & 0xFF; + int green = argb >> 8 & 0xFF; + int blue = argb & 0xFF; + int best = 0; + long bestDistance = Long.MAX_VALUE; + for (int i = 0; i < PALETTE_RGB.length; i++) { + int paletteColor = PALETTE_RGB[i]; + long dr = red - (paletteColor >> 16 & 0xFF); + long dg = green - (paletteColor >> 8 & 0xFF); + long db = blue - (paletteColor & 0xFF); + long distance = dr * dr + dg * dg + db * db; + if (distance < bestDistance) { + bestDistance = distance; + best = i; + } + } + return PALETTE_PACKED[best]; + } +} diff --git a/src/main/resources/assets/sfcraft/lang/en_us.json b/src/main/resources/assets/sfcraft/lang/en_us.json index 3d8b11e..aa137dd 100644 --- a/src/main/resources/assets/sfcraft/lang/en_us.json +++ b/src/main/resources/assets/sfcraft/lang/en_us.json @@ -87,5 +87,20 @@ "command.sfcraft.gps.dimension_changed": "You left the target's dimension, navigation ended", "command.sfcraft.gps.wrong_world": "You are not in the target world (%s); navigation refused", "command.sfcraft.gps.preview": "Click to preview this location on the web map", + "command.sfcraft.mapart.started": "Downloading image from %s...", + "command.sfcraft.mapart.success": "Map art created", + "message.sfcraft.mapart.failed": "Failed to create map art: %s", + "message.sfcraft.mapart.success_inventory": "Map art created; the anvil was closed, so it went straight to your inventory", + "message.sfcraft.mapart.error.invalid_url": "Invalid URL; it must start with http:// or https://", + "message.sfcraft.mapart.error.userinfo": "URLs with embedded credentials (user:pass@) are not allowed", + "message.sfcraft.mapart.error.unsupported_extension": "The URL must end with a supported image extension (png/jpg/jpeg/gif/bmp)", + "message.sfcraft.mapart.error.cooldown": "This URL failed recently; retry in %s seconds", + "message.sfcraft.mapart.error.too_large": "Image too large (%s; max %s pixels per side)", + "message.sfcraft.mapart.error.not_image": "The downloaded content is not a recognizable image", + "message.sfcraft.mapart.error.download_failed": "Download failed: %s", + "message.sfcraft.mapart.error.busy": "The server is already rendering %s map art(s) concurrently; please try again later", + "message.sfcraft.mapart.expand.wall_only": "Map art can only be hung on a vertical wall", + "message.sfcraft.mapart.expand.no_space": "Not enough room; this art needs a clear %s x %s wall", + "block.sfcraft.map_art": "Map Art", "item.sfcraft.standing_firm_totem": "Totem of Restitution" } diff --git a/src/main/resources/assets/sfcraft/lang/zh_cn.json b/src/main/resources/assets/sfcraft/lang/zh_cn.json index 118a166..afffba5 100644 --- a/src/main/resources/assets/sfcraft/lang/zh_cn.json +++ b/src/main/resources/assets/sfcraft/lang/zh_cn.json @@ -87,5 +87,20 @@ "command.sfcraft.gps.dimension_changed": "已离开目标所在维度,导航结束", "command.sfcraft.gps.wrong_world": "你不在目标世界(%s),已拒绝导航", "command.sfcraft.gps.preview": "点击在网页地图上预览该位置", + "command.sfcraft.mapart.started": "正在从 %s 下载图片", + "command.sfcraft.mapart.success": "地图画已生成", + "message.sfcraft.mapart.failed": "生成地图画失败: %s", + "message.sfcraft.mapart.success_inventory": "地图画已生成,由于铁砧界面已关闭,已放入你的背包", + "message.sfcraft.mapart.error.invalid_url": "无效的 URL,需以 http:// 或 https:// 开头", + "message.sfcraft.mapart.error.userinfo": "URL 不允许携带 user:pass 等验证信息", + "message.sfcraft.mapart.error.unsupported_extension": "URL 需以受支持的图片扩展名结尾(png/jpg/jpeg/gif/bmp)", + "message.sfcraft.mapart.error.cooldown": "该地址刚请求失败过,%s 秒后可重试", + "message.sfcraft.mapart.error.too_large": "图片尺寸过大(%s,单边上限 %s 像素)", + "message.sfcraft.mapart.error.not_image": "下载的内容不是可识别的图片", + "message.sfcraft.mapart.error.download_failed": "下载失败: %s", + "message.sfcraft.mapart.error.busy": "当前全服并发渲染已达上限(%s),请稍后再试", + "message.sfcraft.mapart.expand.wall_only": "地图画只能挂在竖直墙面上", + "message.sfcraft.mapart.expand.no_space": "墙面空间不足,这幅画需要 %s x %s 格空墙", + "block.sfcraft.map_art": "地图画", "item.sfcraft.standing_firm_totem": "代偿图腾" } diff --git a/src/main/resources/sfcraft.mixins.json b/src/main/resources/sfcraft.mixins.json index ffe6f49..2d0356b 100644 --- a/src/main/resources/sfcraft.mixins.json +++ b/src/main/resources/sfcraft.mixins.json @@ -18,6 +18,8 @@ "common.bridge.ServerWorldBridge", "common.fixes.EndermanEntityMixin", "common.fixes.LivingEntityMixin", + "gameplay.AnvilMenuMixin", + "gameplay.ItemFrameMixin", "gameplay.MobCommanderMixin", "gameplay.NetherPortalBlockMixin", "gameplay.TotemProtectionMixin",