Feat: paste images in server (#49)
* add gitignore for codex * add image as mapart support * refine codes * fix * adjust scaling algorithm * some fix * fix
This commit is contained in:
1 parent
f7661db224
commit
17c5fe964e
12 files changed
+1110
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<PlayerAFKCallback> PLAYER_AFK = EventFactory.createArrayBacked(PlayerAFKCallback.class,
|
||||
l -> (p, s) -> forEach(l, i -> i.onAFKStatus(p, s)));
|
||||
/** 铁砧 createResult 尾部触发:此时原版已算好结果槽,监听器可读取输入/命名并接管结果槽。 */
|
||||
Event<AnvilCreateResultCallback> 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);
|
||||
|
||||
@@ -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 秒
|
||||
|
||||
@@ -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 时接管结果槽。
|
||||
*
|
||||
* <p>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 = "<init>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<InteractionResult> 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<InteractionResult> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 调色板近似)。
|
||||
* <ul>
|
||||
* <li>铁砧:把(空)地图重命名为图片 URL 即可,结果槽直接产出地图画(防抖后才真正下载)。</li>
|
||||
* <li>{@code /mapfor <url>}:OP 直接获得成品。</li>
|
||||
* <li>把地图画放入墙上的物品框时,以该框为左下角自动铺满整幅图片(按原图宽高比在 4x4 格内
|
||||
* 取最贴合的 cols × rows 切片,详见 {@link #gridFor})。墙面铺不下、或框朝上/朝下时,
|
||||
* 这次放入会被直接拒绝——宁可放不进,也不留一张孤零零的低清种子图。</li>
|
||||
* <li>放进物品框的地图画一旦掉出,会连框一起消失(不掉落任何物品),见 {@code ItemFrameMixin}。</li>
|
||||
* </ul>
|
||||
* 玩家拿到的是单张"种子"地图(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<String> 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<AnvilMenu, AnvilSession> anvilSessions = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private final Cache<String, Rendered> 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<String, Long> 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<CommandSourceStack> dispatcher,
|
||||
CommandBuildContext registryAccess,
|
||||
Commands.CommandSelection env) {
|
||||
dispatcher.register(LiteralArgumentBuilder.<CommandSourceStack>literal("mapfor")
|
||||
.requires(source -> this.isEnabled() && source.isPlayer()
|
||||
&& Commands.LEVEL_GAMEMASTERS.check(source.permissions()))
|
||||
.then(RequiredArgumentBuilder.<CommandSourceStack, String>argument("url", StringArgumentType.string())
|
||||
.executes(this::onMapFor)));
|
||||
}
|
||||
|
||||
private int onMapFor(CommandContext<CommandSourceStack> 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<RenderOutcome> 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<RenderOutcome> future;
|
||||
private volatile ItemStack stack;
|
||||
private volatile boolean failed;
|
||||
|
||||
private AnvilSession(String url, UUID playerId) {
|
||||
this.url = url;
|
||||
this.playerId = playerId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "代偿图腾"
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
"common.bridge.ServerWorldBridge",
|
||||
"common.fixes.EndermanEntityMixin",
|
||||
"common.fixes.LivingEntityMixin",
|
||||
"gameplay.AnvilMenuMixin",
|
||||
"gameplay.ItemFrameMixin",
|
||||
"gameplay.MobCommanderMixin",
|
||||
"gameplay.NetherPortalBlockMixin",
|
||||
"gameplay.TotemProtectionMixin",
|
||||
|
||||
Reference in new issue
Block a user