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:
iceBear67 authored and GitHub committed 2026-08-04 02:22:17 +08:00
1 parent f7661db224
commit 17c5fe964e
12 files changed
+1110

No files matched your search

+6
View File
@@ -19,6 +19,12 @@ Agent 的主要工作范围**只有**这两个包:
新特性通常必然涉及 `registerFeature` 注册行、mixins.json 条目、lang 键、配方 JSON 这些"例行越界"——请在给出方案时一并列出这些改动点,**打包征得一次同意**后再动手,不要做完了才说。 新特性通常必然涉及 `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: 仓库**没有** 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.commander.CommanderModule;
import io.ib67.sfcraft.module.game.crystal.GravityCrystalModule; import io.ib67.sfcraft.module.game.crystal.GravityCrystalModule;
import io.ib67.sfcraft.module.game.lunchbox.LunchBoxModule; 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.mount.MountModule;
import io.ib67.sfcraft.module.game.portal.ElytraPortalModule; import io.ib67.sfcraft.module.game.portal.ElytraPortalModule;
import io.ib67.sfcraft.module.game.token.TokenModule; import io.ib67.sfcraft.module.game.token.TokenModule;
@@ -105,6 +106,7 @@ public class SFCraftInitializer extends GuiceModInitializer {
registerFeature(MountModule.class); registerFeature(MountModule.class);
registerFeature(TotemModule.class); registerFeature(TotemModule.class);
registerFeature(ElytraPortalModule.class); registerFeature(ElytraPortalModule.class);
registerFeature(MapArtModule.class);
// ItemGroupModule 必须最后注册:它冻结前面各模块被动贡献到 ItemGroupService 的物品,构建创意物品栏 // ItemGroupModule 必须最后注册:它冻结前面各模块被动贡献到 ItemGroupService 的物品,构建创意物品栏
registerFeature(ItemGroupModule.class); registerFeature(ItemGroupModule.class);
} }
@@ -12,6 +12,7 @@ import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Unit; import net.minecraft.util.Unit;
import net.minecraft.world.damagesource.DamageSource; import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AnvilMenu;
import java.util.function.Consumer; import java.util.function.Consumer;
import static io.ib67.sfcraft.callback.Utility.*; 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))); l -> (p, t, f) -> forEach(l, i -> i.onFlyingTick(p, t, f)));
Event<PlayerAFKCallback> PLAYER_AFK = EventFactory.createArrayBacked(PlayerAFKCallback.class, Event<PlayerAFKCallback> PLAYER_AFK = EventFactory.createArrayBacked(PlayerAFKCallback.class,
l -> (p, s) -> forEach(l, i -> i.onAFKStatus(p, s))); 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 { interface PlayerAFKCallback {
void onAFKStatus(ServerPlayer player, boolean inAFK); void onAFKStatus(ServerPlayer player, boolean inAFK);
@@ -17,12 +17,28 @@ public class GameConfig {
public Gps gps = new Gps(); public Gps gps = new Gps();
public StandingFirmTotem standingFirmTotem = new StandingFirmTotem(); public StandingFirmTotem standingFirmTotem = new StandingFirmTotem();
public ElytraPortal elytraPortal = new ElytraPortal(); public ElytraPortal elytraPortal = new ElytraPortal();
public MapArt mapArt = new MapArt();
public static class ElytraPortal { public static class ElytraPortal {
// 鞘翅滑翔冲进下界传送门时跳过等待(立即传送)的最低速度,单位米/秒;<= 0 关闭本特性 // 鞘翅滑翔冲进下界传送门时跳过等待(立即传送)的最低速度,单位米/秒;<= 0 关闭本特性
public double minSpeedBlocksPerSecond = 25.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 static class StandingFirmTotem {
// 可反复使用的图腾变种:触发时扣掉一部分总经验,并进入冷却 // 可反复使用的图腾变种:触发时扣掉一部分总经验,并进入冷却
public int cooldownTicks = 600; // 30 秒 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.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.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.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" "item.sfcraft.standing_firm_totem": "Totem of Restitution"
} }
@@ -87,5 +87,20 @@
"command.sfcraft.gps.dimension_changed": "已离开目标所在维度,导航结束", "command.sfcraft.gps.dimension_changed": "已离开目标所在维度,导航结束",
"command.sfcraft.gps.wrong_world": "你不在目标世界(%s),已拒绝导航", "command.sfcraft.gps.wrong_world": "你不在目标世界(%s),已拒绝导航",
"command.sfcraft.gps.preview": "点击在网页地图上预览该位置", "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": "代偿图腾" "item.sfcraft.standing_firm_totem": "代偿图腾"
} }
+2
View File
@@ -18,6 +18,8 @@
"common.bridge.ServerWorldBridge", "common.bridge.ServerWorldBridge",
"common.fixes.EndermanEntityMixin", "common.fixes.EndermanEntityMixin",
"common.fixes.LivingEntityMixin", "common.fixes.LivingEntityMixin",
"gameplay.AnvilMenuMixin",
"gameplay.ItemFrameMixin",
"gameplay.MobCommanderMixin", "gameplay.MobCommanderMixin",
"gameplay.NetherPortalBlockMixin", "gameplay.NetherPortalBlockMixin",
"gameplay.TotemProtectionMixin", "gameplay.TotemProtectionMixin",