sfcraft 2.0 (#47)

* play with polymer

* new features checkpoint 1

* squash! new features and our own art assets!

* refactor && agents.md

* new command gps

* add new variant of undying totem

* refactor cauldron recipe handling to use stable IDs instead of indices

* add recipes

* remove irrelavant parent from blaze_bomb

* clean up

* add: anti spam module

* implement i18n for remaining texts
This commit is contained in:
iceBear67 authored and GitHub committed 2026-07-20 18:33:15 +08:00
1 parent 19e16a575e
commit 1c4c64a63d
105 files changed
+5343 -69

No files matched your search

@@ -4,6 +4,7 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.inject.*;
import io.ib67.sfcraft.module.*;
import io.ib67.sfcraft.module.chat.AntiSpamModule;
import io.ib67.sfcraft.module.chat.ChatPrefixModule;
import io.ib67.sfcraft.module.chat.decorator.ChatPingModule;
import io.ib67.sfcraft.module.chat.decorator.ChatSendLocModule;
@@ -12,7 +13,19 @@ import io.ib67.sfcraft.module.compat.ModCompatModule;
import io.ib67.sfcraft.module.room.CreativeRoomModule;
import io.ib67.sfcraft.module.supervisor.WebModule;
import io.ib67.sfcraft.module.supervisor.web.SchematicUploader;
import io.ib67.sfcraft.module.game.GameExtensionModule;
import io.ib67.sfcraft.module.game.beheading.BeheadingModule;
import io.ib67.sfcraft.module.game.bomb.BombModule;
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.mount.MountModule;
import io.ib67.sfcraft.module.game.token.TokenModule;
import io.ib67.sfcraft.module.game.totem.TotemModule;
import io.ib67.sfcraft.registry.CauldronRecipeRegistry;
import io.ib67.sfcraft.registry.RoomRegistry;
import io.ib67.sfcraft.registry.cauldron.SimpleCauldronRecipeRegistry;
import io.ib67.sfcraft.registry.chat.SimpleMessageDecorator;
import io.ib67.sfcraft.registry.event.SFRandomEventRegistry;
import io.ib67.sfcraft.init.GuiceModInitializer;
@@ -21,7 +34,9 @@ import io.ib67.sfcraft.geoip.MaxMindGeoIPService;
import io.ib67.sfcraft.inject.MinecraftServerSupplier;
import io.ib67.sfcraft.registry.RandomEventRegistry;
import io.ib67.sfcraft.module.command.BackModule;
import io.ib67.sfcraft.module.command.GpsModule;
import io.ib67.sfcraft.module.command.ManagementModule;
import io.ib67.sfcraft.module.command.ReloadCommandModule;
import io.ib67.sfcraft.registry.room.SimpleRoomRegistry;
import io.ib67.sfcraft.room.RoomTeleporter;
import lombok.extern.log4j.Log4j2;
@@ -45,6 +60,7 @@ public class SFCraftInitializer extends GuiceModInitializer {
binder().bind(SignatureService.class).in(Singleton.class);
binder().bind(RoomTeleporter.class).in(Singleton.class);
binder().bind(RoomRegistry.class).to(SimpleRoomRegistry.class).in(Singleton.class);
binder().bind(CauldronRecipeRegistry.class).to(SimpleCauldronRecipeRegistry.class).in(Singleton.class);
}
private void registerFeatures() {
@@ -64,9 +80,27 @@ public class SFCraftInitializer extends GuiceModInitializer {
registerFeature(ChatPrefixModule.class);
registerFeature(SoundModule.class);
registerFeature(ChatShowoffModule.class);
registerFeature(AntiSpamModule.class);
registerFeature(ReloadCommandModule.class);
registerFeature(GpsModule.class);
registerGameFeatures();
registerWebModules();
}
private void registerGameFeatures() {
// GameExtensionModule 必须最先注册:它加载 gameplay.json,后续游戏模块注册内容时会读取配置
registerFeature(GameExtensionModule.class);
registerFeature(CauldronModule.class);
registerFeature(GravityCrystalModule.class);
registerFeature(BombModule.class);
registerFeature(TokenModule.class);
registerFeature(LunchBoxModule.class);
registerFeature(BeheadingModule.class);
registerFeature(CommanderModule.class);
registerFeature(MountModule.class);
registerFeature(TotemModule.class);
}
private void registerWebModules() {
registerFeature(WebModule.class);
registerFeature(SchematicUploader.class);
@@ -1,15 +0,0 @@
package io.ib67.sfcraft;
import net.fabricmc.fabric.api.event.registry.FabricRegistryBuilder;
import net.fabricmc.fabric.api.event.registry.RegistryAttribute;
import net.minecraft.core.Registry;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.Item;
public class SFRegistries {
public static final Registry<Item> ITEMS = FabricRegistryBuilder
.<Item>create(ResourceKey.createRegistryKey(Identifier.fromNamespaceAndPath(SFCraft.MOD_ID, "item")))
.attribute(RegistryAttribute.MODDED)
.buildAndRegister();
}
@@ -0,0 +1,37 @@
package io.ib67.sfcraft.callback;
import net.fabricmc.fabric.api.event.Event;
import net.fabricmc.fabric.api.event.EventFactory;
import java.util.ArrayList;
import java.util.List;
/**
* Fired by {@code /sfcraft reload}. Modules that load configuration resources
* subscribe to this event and re-read their files when it fires.
* Listeners should throw an unchecked exception on failure so the command can report it.
*
* <p>Listeners are isolated: one failing listener does not stop the others from reloading.
* Every listener runs, then the failures are aggregated and rethrown so the command still
* reports the error instead of silently leaving a half-reloaded state.
*/
public interface SFConfigReload {
Event<SFConfigReload> EVENT = EventFactory.createArrayBacked(SFConfigReload.class,
listeners -> () -> {
List<Exception> errors = new ArrayList<>();
for (SFConfigReload listener : listeners) {
try {
listener.onConfigReload();
} catch (Exception e) {
errors.add(e);
}
}
if (!errors.isEmpty()) {
var aggregate = new RuntimeException(errors.size() + " config listener(s) failed to reload");
errors.forEach(aggregate::addSuppressed);
throw aggregate;
}
});
void onConfigReload();
}
@@ -0,0 +1,117 @@
package io.ib67.sfcraft.config;
/**
* sfcraft 游戏内容的数值配置,位于 sfcraft/gameplay.json(与 config.json 同目录)。
* 加载/重载由 {@link GameConfigService} 负责;/sfcraft reload 可热重载。
* 注意:战利品注入(loot)的数值在数据包加载时取样,改完还需再执行原版 /reload 才生效。
*/
public class GameConfig {
public Bomb bomb = new Bomb();
public ReverseToken reverseToken = new ReverseToken();
public Cauldron cauldron = new Cauldron();
public GravityCrystal gravityCrystal = new GravityCrystal();
public Loot loot = new Loot();
public Beheading beheading = new Beheading();
public Commander commander = new Commander();
public Mount mount = new Mount();
public Gps gps = new Gps();
public StandingFirmTotem standingFirmTotem = new StandingFirmTotem();
public static class StandingFirmTotem {
// 可反复使用的图腾变种:触发时扣掉一部分总经验,并进入冷却
public int cooldownTicks = 600; // 30 秒
// 总经验点数低于此值时放弃触发(直接让玩家死亡);160 = 等级 10
public int minExperiencePoints = 160;
public double experienceDrainRatio = 0.5;
}
public static class Gps {
// 视线与目标方向的夹角小于该值(度)时,光标锁定在准星上;超过则跳到真实目标方向
public double toleranceDegrees = 12.0;
// 光标悬浮在视线前方的距离;放远一点航向偏差在画面上更明显,也更不容易糊在脸上
public double cursorDistance = 8.0;
public double arriveRadius = 4.0;
// 水平(X-Z)距离大于该值时进入远距离巡航:光标锁在眼睛所在水平面,只随左右转向(yaw)偏移、
// 忽略俯仰,这样用鞘翅高速飞行低头看地面时光标不会跟着往下沉;近于此值则恢复全向指示
public double planarDistance = 50.0;
}
public static class Mount {
// 驯服食物(需怪物处于虚弱、周围无掠夺者、非袭击时喂食);默认金苹果,呼应虚弱+金苹果治疗语义
public String ravagerTameFood = "minecraft:golden_apple";
public String hoglinTameFood = "minecraft:golden_apple";
public float tameSuccessChance = 0.2F;
public boolean requireSaddleToRide = true;
public double tameNearbyRaiderRadius = 12.0;
public double rideSpeedFactor = 1.15;
public RavagerDash ravagerDash = new RavagerDash();
public static class RavagerDash {
public int boostTicks = 12;
public double speedMultiplier = 2.2;
public int cooldownTicks = 40;
public int durabilityCost = 1;
public double collisionDamage = 6.0;
}
}
public static class Commander {
// 新生成的敌怪(Enemy)自然刷新时携带「统帅」效果的概率
public double spawnChance = 0.02;
public double radius = 12.0;
public int maxRecipients = 2;
public int buffDurationTicks = 60;
public int glowRefreshTicks = 40;
public int scanIntervalTicks = 10;
public int particleIntervalTicks = 5;
public double cohesionMaxDistance = 6.0;
public double cohesionSpeed = 1.2;
}
public static class Beheading {
// 掉头概率 = baseChance + perLevelChance * 附魔等级(玩家头颅单独用 playerHeadChance)
public float baseChance = 0.05F;
public float perLevelChance = 0.05F;
public float playerHeadChance = 0.05F;
}
public static class Bomb {
public int fuseTicks = 80;
public float explosionPower = 3F;
public int throwCooldownTicks = 10;
public double slowCircleRadius = 2.5;
public int slownessAmplifier = 2;
public int blazeIgniteTicks = 100;
public double blazeIgniteRadius = 3.0;
public int blazeFireSpreadMin = 2;
public int blazeFireSpreadMax = 3;
// 烟雾:飞行时朝速度反方向、落地后朝落地面法线喷黑色粒子
public int smokeColor = 0x000000;
public int smokeParticlesPerTick = 3;
public float smokeParticleScale = 1.4F;
public double smokeSpeed = 0.05;
}
public static class ReverseToken {
public int maxUses = 8;
public int cooldownSeconds = 300;
}
public static class Cauldron {
public int maxItems = 4;
public int reactionTicks = 100;
}
public static class GravityCrystal {
public double range = 8.0;
public int boostIntervalTicks = 2;
public double[] boostPerCharge = {0.0, 0.15, 0.20, 0.25, 0.30};
public double maxSpeedMetersPerSecond = 8.0;
}
public static class Loot {
public float dungeonBombChance = 0.3F;
public float dungeonObsidianBombChance = 0.15F;
public float creeperBombChance = 0.1F;
}
}
@@ -0,0 +1,49 @@
package io.ib67.sfcraft.config;
import com.google.gson.Gson;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import io.ib67.sfcraft.inject.ConfigRoot;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Loads and holds {@link GameConfig} (sfcraft/gameplay.json). The initial load happens in
* {@link io.ib67.sfcraft.module.game.GameExtensionModule}, which also re-reads it on
* {@link io.ib67.sfcraft.callback.SFConfigReload}.
*/
@Singleton
public class GameConfigService {
private final Path root;
private final Gson gson;
private volatile GameConfig config = new GameConfig();
@Inject
public GameConfigService(@ConfigRoot Path root, Gson gson) {
this.root = root;
this.gson = gson;
}
public GameConfig get() {
return config;
}
public synchronized void reload() throws IOException {
if (Files.notExists(root)) {
Files.createDirectories(root);
}
var file = root.resolve("gameplay.json");
if (Files.notExists(file)) {
Files.writeString(file, gson.toJson(new GameConfig()));
config = new GameConfig();
return;
}
var loaded = gson.fromJson(Files.readString(file), GameConfig.class);
if (loaded == null) {
throw new IOException("gameplay.json is empty");
}
config = loaded;
}
}
@@ -4,6 +4,10 @@ import net.minecraft.world.entity.Mob;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
/**
* 仅承载 shadow 供子 mixin(如 fixes.EndermanEntityMixin)复用;
* gameplay 相关的注入见 io.ib67.sfcraft.mixin.gameplay。
*/
@Mixin(Mob.class)
public abstract class MobEntityMixin {
@Shadow
@@ -0,0 +1,27 @@
package io.ib67.sfcraft.mixin.gameplay;
import io.ib67.sfcraft.SFCraft;
import io.ib67.sfcraft.module.game.commander.CommanderModule;
import net.minecraft.world.DifficultyInstance;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.SpawnGroupData;
import net.minecraft.world.level.ServerLevelAccessor;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* 新生成的敌怪有 config 概率(默认 2%)携带「统帅」效果,概率与行为均由 {@link CommanderModule} 驱动。
*/
@Mixin(Mob.class)
public abstract class MobCommanderMixin {
@Inject(method = "finalizeSpawn", at = @At("TAIL"))
private void sfcraft$maybeCommander(ServerLevelAccessor world, DifficultyInstance difficulty,
EntitySpawnReason reason, SpawnGroupData data,
CallbackInfoReturnable<SpawnGroupData> cir) {
SFCraft.getInjector().getInstance(CommanderModule.class)
.onMobSpawned((Mob) (Object) this, reason);
}
}
@@ -0,0 +1,29 @@
package io.ib67.sfcraft.mixin.gameplay;
import io.ib67.sfcraft.SFCraft;
import io.ib67.sfcraft.module.game.totem.TotemModule;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.LivingEntity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* 让「屹立不倒」图腾在死亡保护判定里插一脚。仅当本图腾**成功触发**时才接管并返回 true;
* 冷却/经验不足(DECLINED)或玩家没拿本图腾(NOT_APPLICABLE)一律**放行原版**——本图腾不挂
* DEATH_PROTECTION 组件,原版不认它,于是既不会误消耗本图腾,也不会连带取消掉玩家另一只手里
* 的原版不死图腾。逻辑见 {@link TotemModule#tryProtect}。
*/
@Mixin(LivingEntity.class)
public abstract class TotemProtectionMixin {
@Inject(method = "checkTotemDeathProtection", at = @At("HEAD"), cancellable = true)
private void sfcraft$standingFirm(DamageSource source, CallbackInfoReturnable<Boolean> cir) {
if (!((Object) this instanceof ServerPlayer player)) return;
// 只有触发成功才拦截返回 true;其余情形不动 cir,让原版逻辑继续(原版图腾等照常兜底)
if (SFCraft.getInjector().getInstance(TotemModule.class).tryProtect(player, source) == TotemModule.Result.PROTECTED) {
cir.setReturnValue(true);
}
}
}
@@ -1,4 +1,4 @@
package io.ib67.sfcraft.mixin.common;
package io.ib67.sfcraft.mixin.gameplay;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.item.ItemStack;
@@ -12,12 +12,14 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
public abstract class ZombieMixin {
@Inject(method = "canHoldItem", at = @At("HEAD"), cancellable = true)
public void canHoldItem(final ItemStack itemStack, final CallbackInfoReturnable<Boolean> cir) {
cir.setReturnValue(!(itemStack.is(Items.EGG)
// 命中黑名单才禁止拾取(返回 false),其余物品交回原版判定
if (itemStack.is(Items.EGG)
|| itemStack.is(Items.ROTTEN_FLESH)
|| itemStack.is(Items.STRING))
|| itemStack.is(Items.STRING)
|| itemStack.is(Items.BONE)
|| itemStack.is(Items.ARROW)
|| itemStack.is(Items.WHEAT_SEEDS)
);
|| itemStack.is(Items.WHEAT_SEEDS)) {
cir.setReturnValue(false);
}
}
}
@@ -0,0 +1,116 @@
package io.ib67.sfcraft.mixin.gameplay.mount;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.animal.Animal;
import net.minecraft.world.entity.monster.hoglin.Hoglin;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
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;
import io.ib67.sfcraft.module.game.mount.MountAccess;
import io.ib67.sfcraft.module.game.mount.MountLogic;
import java.util.UUID;
@Mixin(Hoglin.class)
public abstract class HoglinMountMixin extends Animal implements MountAccess {
@Unique
private UUID sfcraft$owner;
@Unique
private boolean sfcraft$saddled;
@Unique
private int sfcraft$dashTicks;
protected HoglinMountMixin(EntityType<? extends Animal> type, Level level) {
super(type, level);
}
@Override
public UUID sfcraft$getOwner() {
return sfcraft$owner;
}
@Override
public void sfcraft$setOwner(UUID owner) {
sfcraft$owner = owner;
}
@Override
public boolean sfcraft$isSaddled() {
return sfcraft$saddled;
}
@Override
public void sfcraft$setSaddled(boolean saddled) {
sfcraft$saddled = saddled;
}
@Override
public int sfcraft$getDashTicks() {
return sfcraft$dashTicks;
}
@Override
public void sfcraft$setDashTicks(int ticks) {
sfcraft$dashTicks = ticks;
}
@Override
public LivingEntity getControllingPassenger() {
LivingEntity rider = MountLogic.controllingPassenger((Mob) (Object) this, this);
return rider != null ? rider : super.getControllingPassenger();
}
@Override
protected Vec3 getRiddenInput(Player player, Vec3 travelVector) {
return MountLogic.riddenInput(this, player);
}
@Override
public boolean canSimulateMovement() {
// 骑手是 Player 时会被判为客户端权威,但纯服务端坐骑客户端不会模拟 → 强制服务端模拟
if (!level().isClientSide() && getControllingPassenger() != null) return true;
return super.canSimulateMovement();
}
@Override
protected float getRiddenSpeed(Player player) {
return MountLogic.riddenSpeed((Mob) (Object) this, this);
}
@Override
protected void tickRidden(Player player, Vec3 travelVector) {
MountLogic.tickRidden((Mob) (Object) this, this, player);
}
@Inject(method = "aiStep", at = @At("HEAD"))
private void sfcraft$suppressAiWhenRidden(CallbackInfo ci) {
MountLogic.tickAiSuppression((Mob) (Object) this, this);
}
@Inject(method = "doHurtTarget", at = @At("HEAD"), cancellable = true)
private void sfcraft$noAttackWhenRidden(ServerLevel level, Entity target, CallbackInfoReturnable<Boolean> cir) {
if (MountLogic.blocksAttack((Mob) (Object) this, this, target)) cir.setReturnValue(false);
}
@Inject(method = "addAdditionalSaveData", at = @At("TAIL"))
private void sfcraft$saveMount(ValueOutput out, CallbackInfo ci) {
MountLogic.save(out, this);
}
@Inject(method = "readAdditionalSaveData", at = @At("TAIL"))
private void sfcraft$loadMount(ValueInput in, CallbackInfo ci) {
MountLogic.load(in, this);
}
}
@@ -0,0 +1,121 @@
package io.ib67.sfcraft.mixin.gameplay.mount;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.monster.Ravager;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.raid.Raider;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
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;
import io.ib67.sfcraft.module.game.mount.MountAccess;
import io.ib67.sfcraft.module.game.mount.MountLogic;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Mixin(Ravager.class)
public abstract class RavagerMountMixin extends Raider implements MountAccess {
@Unique
private UUID sfcraft$owner;
@Unique
private boolean sfcraft$saddled;
@Unique
private int sfcraft$dashTicks;
@Unique
private final Set<UUID> sfcraft$dashHitEntities = new HashSet<>();
protected RavagerMountMixin(EntityType<? extends Raider> type, Level level) {
super(type, level);
}
@Override
public UUID sfcraft$getOwner() {
return sfcraft$owner;
}
@Override
public void sfcraft$setOwner(UUID owner) {
sfcraft$owner = owner;
}
@Override
public boolean sfcraft$isSaddled() {
return sfcraft$saddled;
}
@Override
public void sfcraft$setSaddled(boolean saddled) {
sfcraft$saddled = saddled;
}
@Override
public int sfcraft$getDashTicks() {
return sfcraft$dashTicks;
}
@Override
public void sfcraft$setDashTicks(int ticks) {
sfcraft$dashTicks = ticks;
}
@Override
public LivingEntity getControllingPassenger() {
LivingEntity rider = MountLogic.controllingPassenger((Mob) (Object) this, this);
return rider != null ? rider : super.getControllingPassenger();
}
@Override
protected Vec3 getRiddenInput(Player player, Vec3 travelVector) {
return MountLogic.riddenInput(this, player);
}
@Override
public boolean canSimulateMovement() {
// 骑手是 Player 时会被判为客户端权威,但纯服务端坐骑客户端不会模拟 → 强制服务端模拟
if (!level().isClientSide() && getControllingPassenger() != null) return true;
return super.canSimulateMovement();
}
@Override
protected float getRiddenSpeed(Player player) {
return MountLogic.riddenSpeed((Mob) (Object) this, this);
}
@Override
protected void tickRidden(Player player, Vec3 travelVector) {
MountLogic.tickRidden((Mob) (Object) this, this, player);
}
@Inject(method = "aiStep", at = @At("HEAD"))
private void sfcraft$suppressAiWhenRidden(CallbackInfo ci) {
MountLogic.tickAiSuppression((Mob) (Object) this, this);
MountLogic.tickRavagerDashCollision((Ravager) (Object) this, this, sfcraft$dashHitEntities);
}
@Inject(method = "doHurtTarget", at = @At("HEAD"), cancellable = true)
private void sfcraft$noAttackWhenRidden(ServerLevel level, Entity target, CallbackInfoReturnable<Boolean> cir) {
if (MountLogic.blocksAttack((Mob) (Object) this, this, target)) cir.setReturnValue(false);
}
@Inject(method = "addAdditionalSaveData", at = @At("TAIL"))
private void sfcraft$saveMount(ValueOutput out, CallbackInfo ci) {
MountLogic.save(out, this);
}
@Inject(method = "readAdditionalSaveData", at = @At("TAIL"))
private void sfcraft$loadMount(ValueInput in, CallbackInfo ci) {
MountLogic.load(in, this);
}
}
@@ -21,7 +21,7 @@ public class AFKModule extends ServerModule {
public void onInitialize() {
SFCallbacks.PLAYER_IDLE.register(this::onAFK);
prefix = new ChatPrefix(
Component.literal("[挂机] ").withColor(CommonColors.LIGHT_GRAY),
Component.translatable("message.sfcraft.afk.prefix").withColor(CommonColors.LIGHT_GRAY),
"afk",
true,
10
@@ -40,7 +40,7 @@ public class AFKModule extends ServerModule {
private void enAFK(ServerPlayer player) {
chatPrefixModule.applyPrefix(player, prefix);
player.level().getServer().getPlayerList().broadcastSystemMessage(
Component.literal(" * " + player.getName().tryCollapseToString() + " 正在挂机.").withColor(CommonColors.LIGHT_GRAY),
Component.translatable("message.sfcraft.afk.now_afk", player.getName().tryCollapseToString()).withColor(CommonColors.LIGHT_GRAY),
false
);
SFCallbacks.PLAYER_AFK.invoker().onAFKStatus(player, true);
@@ -50,7 +50,7 @@ public class AFKModule extends ServerModule {
String playerName = player.getName().tryCollapseToString();
chatPrefixModule.removePrefix(player, prefix);
player.level().getServer().getPlayerList().broadcastSystemMessage(
Component.literal(" * " + playerName + " 回来了.").withColor(CommonColors.LIGHT_GRAY),
Component.translatable("message.sfcraft.afk.back", playerName).withColor(CommonColors.LIGHT_GRAY),
false
);
SFCallbacks.PLAYER_AFK.invoker().onAFKStatus(player, false);
@@ -22,7 +22,7 @@ public class ElytraSpeedMeterModule extends ServerModule {
static {
for (int i = 1; i <= 100; i++) {
ELYTRA_DURABILITY[i - 1] = Component.literal("ELYTRA: " + i + "%").withColor(getDamageColor(i));
ELYTRA_DURABILITY[i - 1] = Component.translatable("message.sfcraft.elytra.durability", i).withColor(getDamageColor(i));
}
}
@@ -54,7 +54,7 @@ public class ElytraSpeedMeterModule extends ServerModule {
} else {
clean((ServerPlayer) player);
if (f > THRESHLD_OF_ELYTRA_FLY * 20) {
player.sendOverlayMessage(Component.literal("!! LANDED !!").withColor(CommonColors.GREEN));
player.sendOverlayMessage(Component.translatable("message.sfcraft.elytra.landed").withColor(CommonColors.GREEN));
} else {
player.sendOverlayMessage(Component.empty());
@@ -83,7 +83,7 @@ public class ElytraSpeedMeterModule extends ServerModule {
var percent = (int) Math.ceil(((double) remaining / stack.getMaxDamage()) * 100);
return ELYTRA_DURABILITY[Math.max(percent - 1, 0)];
}
return Component.literal("ELYTRA: INFINITY").withColor(CommonColors.BLUE);
return Component.translatable("message.sfcraft.elytra.infinity").withColor(CommonColors.BLUE);
}
private static int getDamageColor(int percent) {
@@ -3,6 +3,7 @@ package io.ib67.sfcraft.module;
import com.google.inject.Inject;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import io.ib67.sfcraft.callback.SFConfigReload;
import io.ib67.sfcraft.inject.MinecraftServerSupplier;
import io.ib67.sfcraft.util.Helper;
import io.ib67.sfcraft.ServerModule;
@@ -24,6 +25,12 @@ public class MotdModule extends ServerModule {
@Override
public void onInitialize() {
motd = getMotd(configRoot);
SFConfigReload.EVENT.register(() -> {
motd = getMotd(configRoot);
if (isEnabled()) {
minecraftServer.get().setMotd(motd);
}
});
}
@Override
@@ -60,14 +60,14 @@ public class OfflineExemptModule extends ServerModule {
var pm = source.getServer().getPlayerList();
var wl = pm.getWhiteList();
if (List.of(wl.getUserList()).contains(player)) {
source.sendFailure(Component.nullToEmpty(player + " is already whitelisted or this id is conflict with a online user."));
source.sendFailure(Component.translatable("message.sfcraft.offline.already_whitelisted", player));
return 0;
}
var profile = NameAndId.createOffline(player);
wl.add(new UserWhiteListEntry(profile));
var uc = source.getServer().services().nameToIdCache();
if (uc != null) uc.add(profile);
source.sendSystemMessage(Component.nullToEmpty("[" + profile.name() + "/" + profile.id() + "]" + " is added!"));
source.sendSystemMessage(Component.translatable("message.sfcraft.offline.added", profile.name(), profile.id().toString()));
return 0;
}
@@ -76,9 +76,9 @@ public class OfflineExemptModule extends ServerModule {
var server = source.getServer();
var wl = server.getPlayerList().getWhiteList();
var names = wl.getUserList();
source.sendSystemMessage(Component.nullToEmpty("Offline Users:").copy().withColor(CommonColors.GREEN));
source.sendSystemMessage(Component.translatable("message.sfcraft.offline.list_header").withColor(CommonColors.GREEN));
var r = Stream.of(names).filter(name -> wl.isWhiteListed(NameAndId.createOffline(name))).collect(Collectors.joining(", "));
source.sendSystemMessage(Component.nullToEmpty(r.isEmpty() ? "No offline users found in whitelist." : r));
source.sendSystemMessage(r.isEmpty() ? Component.translatable("message.sfcraft.offline.list_empty") : Component.nullToEmpty(r));
return 0;
}
@@ -79,7 +79,7 @@ public class RoomModule extends ServerModule {
return 0;
}
if (!isVirtual(player.getUUID())) {
player.sendSystemMessage(Component.literal("你不在任何 \"房间\" 内。"));
player.sendSystemMessage(Component.translatable("message.sfcraft.room.not_in_room"));
return 0;
}
var networkHandler = player.connection;
@@ -0,0 +1,107 @@
package io.ib67.sfcraft.module.chat;
import io.ib67.sfcraft.ServerModule;
import net.fabricmc.fabric.api.message.v1.ServerMessageEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.network.chat.ChatType;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.PlayerChatMessage;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.CommonColors;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 简单反刷屏:限制单个玩家的公屏发言频率,并拦截连续的重复消息。
* <p>
* 命中以下任意一条即拦截(消息不会广播,只给发言者一个 overlay 提示):
* <ul>
* <li><b>频率限制</b>:{@link #WINDOW_MILLIS} 毫秒内最多放行 {@link #MAX_MESSAGES} 条。</li>
* <li><b>重复拦截</b>:与上一条内容相同(忽略大小写与首尾空白)且间隔不足
* {@link #DUPLICATE_WINDOW_MILLIS} 毫秒的消息被拦截。</li>
* </ul>
* 判定完全在服务端进行,走 Fabric {@code ALLOW_CHAT_MESSAGE} 钩子——返回 false 即取消该条消息。
* 只影响公屏聊天;命令(如 /msg)走的是 {@code ALLOW_COMMAND_MESSAGE},不受影响。
*/
public class AntiSpamModule extends ServerModule {
/** 频率窗口长度(毫秒)。 */
private static final long WINDOW_MILLIS = 3_000L;
/** 频率窗口内允许放行的最大消息数,超出即拦截。 */
private static final int MAX_MESSAGES = 4;
/** 判定「重复消息」的时间窗口(毫秒)。 */
private static final long DUPLICATE_WINDOW_MILLIS = 10_000L;
/** 两次 overlay 提示之间的最短间隔,避免提示本身也刷屏。 */
private static final long WARN_COOLDOWN_MILLIS = 1_500L;
// 仅在服务端主线程访问(聊天与断线回调都在主线程),无需并发容器。
private final Map<UUID, Tracker> trackers = new HashMap<>();
@Override
public void onInitialize() {
ServerMessageEvents.ALLOW_CHAT_MESSAGE.register(this::allowChat);
ServerPlayConnectionEvents.DISCONNECT.register(
(handler, server) -> trackers.remove(handler.getPlayer().getUUID()));
}
private boolean allowChat(PlayerChatMessage message, ServerPlayer sender, ChatType.Bound params) {
if (!isEnabled()) return true;
long now = System.currentTimeMillis();
var tracker = trackers.computeIfAbsent(sender.getUUID(), ignored -> new Tracker());
var content = message.signedContent().strip();
var reasonKey = tracker.check(content, now);
if (reasonKey != null) {
// 被拦截的消息不计入放行窗口、也不刷新「上一条」,以免刷屏者借此绕过判定。
warn(sender, tracker, now, reasonKey);
return false;
}
tracker.accept(content, now);
return true;
}
private void warn(ServerPlayer sender, Tracker tracker, long now, String reasonKey) {
if (!tracker.canWarn(now)) return;
sender.sendOverlayMessage(Component.translatable(reasonKey).withColor(CommonColors.RED));
}
/** 单个玩家的发言节流状态。 */
private static final class Tracker {
/** 近期已放行消息的时间戳,构成滑动窗口。 */
private final Deque<Long> recent = new ArrayDeque<>();
private String lastContent = "";
private long lastContentTime;
private long lastWarnTime;
/** @return 拦截原因的翻译键,{@code null} 表示放行。 */
String check(String content, long now) {
if (!content.isEmpty()
&& content.equalsIgnoreCase(lastContent)
&& now - lastContentTime < DUPLICATE_WINDOW_MILLIS) {
return "message.sfcraft.antispam.duplicate";
}
while (!recent.isEmpty() && now - recent.peekFirst() >= WINDOW_MILLIS) {
recent.pollFirst();
}
if (recent.size() >= MAX_MESSAGES) {
return "message.sfcraft.antispam.too_fast";
}
return null;
}
void accept(String content, long now) {
recent.addLast(now);
lastContent = content;
lastContentTime = now;
}
boolean canWarn(long now) {
if (now - lastWarnTime < WARN_COOLDOWN_MILLIS) return false;
lastWarnTime = now;
return true;
}
}
}
@@ -53,7 +53,7 @@ public class ChatPingModule extends ServerModule implements ChatDecorator {
var r = Component.literal(match.replaceAll(it -> this.matchPlayer(it, names, foundPlayers)));
for (Player foundPlayer : foundPlayers) {
if (sender != null) {
foundPlayer.sendOverlayMessage(Component.literal(sender.getName().tryCollapseToString() + " 正在叫你。").withColor(CommonColors.LIGHT_GRAY));
foundPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.chat.calling_you", sender.getName().tryCollapseToString()).withColor(CommonColors.LIGHT_GRAY));
}
Helper.playNotifySound(
(ServerPlayer) foundPlayer,
@@ -39,30 +39,31 @@ public class ChatSendLocModule extends ServerModule implements ChatDecorator {
}
private static Component generateLocText(ServerPlayer sender) {
if (sender == null) return Component.nullToEmpty(" (invalid position) ");
if (sender == null) return Component.translatable("message.sfcraft.chat.invalid_position");
sender.addEffect(new MobEffectInstance(MobEffects.GLOWING, 15 * 20));
var x = sender.blockPosition().getX();
var y = sender.blockPosition().getY();
var z = sender.blockPosition().getZ();
var key = sender.level().dimension();
var world = translate(key);
return Component
.literal(" " + x + ", " + y + ", " + z + world + " ")
.literal(" " + x + ", " + y + ", " + z)
.append(dimensionLabel(key))
.append(" ")
.withColor(Helper.fromRgb(63, 254, 254))
.withStyle(style -> style.withClickEvent(new ClickEvent.OpenUrl(URI.create(
"https://mc.sfclub.cc/map/?world=" + key.identifier().toString().replace(':', '_') + "&zoom=5&x=" + x + "&z=" + z
))));
}
private static String translate(ResourceKey<Level> registryKey) {
private static Component dimensionLabel(ResourceKey<Level> registryKey) {
if (registryKey == Level.END) {
return " (末地)";
return Component.translatable("message.sfcraft.dimension.end");
} else if (registryKey == Level.NETHER) {
return " (地狱)";
return Component.translatable("message.sfcraft.dimension.nether");
} else if (registryKey == Level.OVERWORLD) {
return "";
return Component.empty();
} else {
return " (未知)";
return Component.translatable("message.sfcraft.dimension.unknown");
}
}
}
@@ -48,14 +48,14 @@ public class BackModule extends ServerModule {
Helper.teleportSafely(player, wld, _pos.getX(), _pos.getY(), _pos.getZ(),0,0);
return 0;
}
player.sendSystemMessage(Component.nullToEmpty("周围没有玩家。").copy().withColor(CommonColors.RED));
player.sendSystemMessage(Component.translatable("message.sfcraft.back.no_players").withColor(CommonColors.RED));
}
return 0;
}
public void onPlayerDeath(Player player, DamageSource damageSource) {
if (this.isEnabled() && Helper.canBack((ServerPlayer) player)) {
player.sendSystemMessage(Component.nullToEmpty("Tip: 死亡地点附近有玩家,可以使用 /back 传送到他们那里。(即使在对方也死亡的状态下)"));
player.sendSystemMessage(Component.translatable("message.sfcraft.back.tip"));
}
}
}
@@ -0,0 +1,214 @@
package io.ib67.sfcraft.module.command;
import com.google.inject.Inject;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import eu.pb4.polymer.virtualentity.api.ElementHolder;
import eu.pb4.polymer.virtualentity.api.attachment.ManualAttachment;
import eu.pb4.polymer.virtualentity.api.elements.SimpleEntityElement;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.config.GameConfig;
import io.ib67.sfcraft.config.GameConfigService;
import io.ib67.sfcraft.util.Helper;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.commands.CommandBuildContext;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.arguments.coordinates.Coordinates;
import net.minecraft.commands.arguments.coordinates.Vec3Argument;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* /gps &lt;x&gt; &lt;y&gt; &lt;z&gt;:在玩家视线前方悬浮一只发光小史莱姆作为导航光标。
* 视线与目标方向的偏差在容差角内时,光标锁定在准星上;超出容差则跳到真实目标方向,
* 玩家把准星重新对准史莱姆即可回到正确航向。/gps stop 取消导航。
*
* <p>光标是 polymer 虚拟实体(纯数据包,只对导航者本人可见):服务端不存在真实实体,
* 天然无 AI、不可伤害、不随区块保存;位置每 tick 由服务端按最新玩家位置重算,
* 鞘翅高速飞行时也保持稳定。一名玩家同时只有一个导航目标,重复 /gps 直接替换。
*/
public class GpsModule extends ServerModule {
// 史莱姆(size=1)半高,用于把光标中心对齐到视线,而不是让史莱姆"脚踩"在视线上
private static final double CURSOR_HALF_HEIGHT = 0.26;
@Inject
private GameConfigService config;
private final Map<UUID, GpsSession> sessions = new HashMap<>();
@Override
public void onInitialize() {
CommandRegistrationCallback.EVENT.register(this::registerCommands);
ServerTickEvents.END_SERVER_TICK.register(this::onTick);
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> {
var player = handler.getPlayer();
if (player == null) return;
var session = sessions.remove(player.getUUID());
if (session != null) session.destroy();
});
// 重生会让客户端重建世界,虚拟实体需要向新实例重发生成包
ServerPlayerEvents.AFTER_RESPAWN.register((oldPlayer, newPlayer, alive) -> {
var session = sessions.get(newPlayer.getUUID());
if (session != null) session.rewatch(oldPlayer, newPlayer);
});
}
@Override
public void onDisable() {
sessions.values().forEach(GpsSession::destroy);
sessions.clear();
}
private void registerCommands(CommandDispatcher<CommandSourceStack> dispatcher,
CommandBuildContext registryAccess,
Commands.CommandSelection env) {
dispatcher.register(LiteralArgumentBuilder.<CommandSourceStack>literal("gps")
.requires(source -> this.isEnabled() && source.getPlayer() != null)
.then(LiteralArgumentBuilder.<CommandSourceStack>literal("stop")
.executes(this::stop))
.then(RequiredArgumentBuilder.<CommandSourceStack, Coordinates>argument("target", Vec3Argument.vec3())
.executes(this::start)));
}
private int start(CommandContext<CommandSourceStack> context) {
ServerPlayer player = context.getSource().getPlayer();
Vec3 target = Vec3Argument.getVec3(context, "target");
var old = sessions.remove(player.getUUID());
if (old != null) old.destroy();
sessions.put(player.getUUID(), new GpsSession(player, target));
context.getSource().sendSuccess(() -> Component.translatable("command.sfcraft.gps.started",
Math.round(target.x), Math.round(target.y), Math.round(target.z)), false);
return 1;
}
private int stop(CommandContext<CommandSourceStack> context) {
var session = sessions.remove(context.getSource().getPlayer().getUUID());
if (session == null) {
context.getSource().sendFailure(Component.translatable("command.sfcraft.gps.not_active"));
return 0;
}
session.destroy();
context.getSource().sendSuccess(() -> Component.translatable("command.sfcraft.gps.stopped"), false);
return 1;
}
private void onTick(MinecraftServer server) {
if (sessions.isEmpty()) return;
var gps = config.get().gps;
var iterator = sessions.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
var session = entry.getValue();
ServerPlayer player = server.getPlayerList().getPlayer(entry.getKey());
if (player == null || player.isRemoved()) {
session.destroy();
iterator.remove();
continue;
}
if (player.level().dimension() != session.dimension) {
session.destroy();
iterator.remove();
player.sendSystemMessage(Component.translatable("command.sfcraft.gps.dimension_changed"));
continue;
}
if (player.position().distanceToSqr(session.target) <= gps.arriveRadius * gps.arriveRadius) {
session.destroy();
iterator.remove();
player.sendSystemMessage(Component.translatable("command.sfcraft.gps.arrived"));
Helper.playNotifySound(player, SoundEvents.PLAYER_LEVELUP, SoundSource.PLAYERS, 0.6f, 1.5f);
continue;
}
session.tick(player, gps);
}
}
private static final class GpsSession {
private final Vec3 target;
private final ResourceKey<Level> dimension;
private final ElementHolder holder;
// ManualAttachment 的位置供给器读取该字段;每 tick 按最新玩家位置重算后再 holder.tick()
private volatile Vec3 cursorPos;
GpsSession(ServerPlayer player, Vec3 target) {
this.target = target;
this.dimension = player.level().dimension();
this.cursorPos = player.getEyePosition().add(player.getLookAngle()).subtract(0, CURSOR_HALF_HEIGHT, 0);
this.holder = new ElementHolder();
var cursor = new SimpleEntityElement(EntityTypes.SLIME); // 默认 size=1,即小史莱姆
// 本体隐身、只留发光轮廓:发光轮廓穿墙渲染,即使光标位置进了地形玩家也能看到航向
cursor.setInvisible(true);
cursor.setGlowing(true);
cursor.setSilent(true);
cursor.setNoGravity(true);
holder.addElement(cursor);
new ManualAttachment(holder, (ServerLevel) player.level(), () -> this.cursorPos);
holder.startWatching(player);
}
void tick(ServerPlayer player, GameConfig.Gps gps) {
Vec3 eye = player.getEyePosition();
double dx = target.x - eye.x;
double dz = target.z - eye.z;
double horizontalDistance = Math.sqrt(dx * dx + dz * dz);
if (horizontalDistance > gps.planarDistance) {
// 远距离巡航:只在眼睛所在水平面上按左右转向(yaw)偏转,完全忽略俯仰。
// 用 yaw 直接算水平朝向而非取视线的水平分量——鞘翅看正下方时视线水平分量趋近 0,
// 归一化会得到 NaN;yaw 始终有定义,天然规避退化也天然"忽略俯仰"。
Vec3 lookHoriz = horizontalDirection(player.getYRot());
Vec3 targetHoriz = new Vec3(dx, 0, dz).normalize();
double angle = Math.toDegrees(Math.acos(Mth.clamp(lookHoriz.dot(targetHoriz), -1.0, 1.0)));
Vec3 horiz = angle <= gps.toleranceDegrees ? lookHoriz : targetHoriz;
this.cursorPos = new Vec3(
eye.x + horiz.x * gps.cursorDistance,
eye.y - CURSOR_HALF_HEIGHT,
eye.z + horiz.z * gps.cursorDistance);
} else {
// 近距离:恢复全向(含俯仰)指示,便于精确对准竖直方向上的目标。
// 容差内粘在准星方向(画面上纹丝不动),超出则跳到真实目标方向提示玩家回正。
Vec3 toTarget = target.subtract(eye);
Vec3 targetDir = toTarget.normalize();
Vec3 look = player.getLookAngle();
double angle = Math.toDegrees(Math.acos(Mth.clamp(look.dot(targetDir), -1.0, 1.0)));
Vec3 direction = angle <= gps.toleranceDegrees ? look : targetDir;
double distance = Math.min(gps.cursorDistance, toTarget.length());
this.cursorPos = eye.add(direction.scale(distance)).subtract(0, CURSOR_HALF_HEIGHT, 0);
}
holder.tick();
}
// Minecraft yaw(度)→ 水平单位朝向向量,与俯仰无关
private static Vec3 horizontalDirection(float yawDegrees) {
double yaw = Math.toRadians(yawDegrees);
return new Vec3(-Math.sin(yaw), 0.0, Math.cos(yaw));
}
void rewatch(ServerPlayer oldPlayer, ServerPlayer newPlayer) {
holder.stopWatching(oldPlayer);
holder.startWatching(newPlayer);
}
void destroy() {
holder.destroy();
}
}
}
@@ -65,7 +65,7 @@ public class ManagementModule extends ServerModule {
private int listGeo(CommandContext<CommandSourceStack> ctx) {
var src = ctx.getSource();
var server = src.getServer();
src.sendSystemMessage(Component.nullToEmpty("List of player with cities:"));
src.sendSystemMessage(Component.translatable("message.sfcraft.management.cities_header"));
for (ServerPlayer serverPlayerEntity : server.getPlayerList().getPlayers()) {
try {
var addr = InetAddress.getByName(serverPlayerEntity.getIpAddress());
@@ -77,7 +77,7 @@ public class ManagementModule extends ServerModule {
.append(Component.literal(" (" + Helper.hideIp(addr) + ")").withColor(CommonColors.LIGHT_GRAY))
);
} catch (GeoIp2Exception | UnknownHostException e) {
src.sendSystemMessage(Component.literal(" - [FAILED TO FETCH] " + serverPlayerEntity.getName().tryCollapseToString()).withColor(CommonColors.SOFT_RED));
src.sendSystemMessage(Component.translatable("message.sfcraft.management.fetch_failed", serverPlayerEntity.getName().tryCollapseToString()).withColor(CommonColors.SOFT_RED));
}
}
return 0;
@@ -87,10 +87,10 @@ public class ManagementModule extends ServerModule {
var player = ctx.getSource().getPlayer();
if (player.entityTags().contains(SFConsts.SPECIAL_SUDO)) {
player.removeTag(SFConsts.SPECIAL_SUDO);
player.sendSystemMessage(Component.nullToEmpty("Sudo is off.").copy().withColor(CommonColors.GREEN));
player.sendSystemMessage(Component.translatable("message.sfcraft.management.sudo_off").withColor(CommonColors.GREEN));
} else {
player.addTag(SFConsts.SPECIAL_SUDO);
player.sendSystemMessage(Component.nullToEmpty("Sudo is on.").copy().withColor(CommonColors.GREEN));
player.sendSystemMessage(Component.translatable("message.sfcraft.management.sudo_on").withColor(CommonColors.GREEN));
}
return 0;
}
@@ -98,11 +98,12 @@ public class ManagementModule extends ServerModule {
@SneakyThrows
public int listPerms(CommandContext<CommandSourceStack> ctx) {
var src = ctx.getSource();
src.sendSystemMessage(Component.nullToEmpty("Permissions: ").copy().withColor(CommonColors.GREEN));
src.sendSystemMessage(Component.translatable("message.sfcraft.management.perms_header").withColor(CommonColors.GREEN));
for (Field declaredField : SFConsts.class.getDeclaredFields()) {
if (Permission.class.isAssignableFrom(declaredField.getType())) {
var permission = (Permission<?>) declaredField.get(null);
src.sendSystemMessage(Component.nullToEmpty(" - " + permission.key() + " " + (permission.byDefault() ? "(default)" : "")));
src.sendSystemMessage(Component.literal(" - " + permission.key() + " ")
.append(permission.byDefault() ? Component.translatable("message.sfcraft.management.perm_default") : Component.empty()));
}
}
return 0;
@@ -0,0 +1,47 @@
package io.ib67.sfcraft.module.command;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.callback.SFConfigReload;
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.network.chat.Component;
/**
* /sfcraft reload:触发 {@link SFConfigReload},由各配置相关模块自行重读文件。
*/
@Log4j2
public class ReloadCommandModule extends ServerModule {
@Override
public void onInitialize() {
CommandRegistrationCallback.EVENT.register(this::registerCommands);
}
private void registerCommands(CommandDispatcher<CommandSourceStack> dispatcher,
CommandBuildContext registryAccess,
Commands.CommandSelection env) {
dispatcher.register(LiteralArgumentBuilder.<CommandSourceStack>literal("sfcraft")
.then(LiteralArgumentBuilder.<CommandSourceStack>literal("reload")
.requires(source -> this.isEnabled() && Commands.LEVEL_GAMEMASTERS.check(source.permissions()))
.executes(this::reload)));
}
private int reload(CommandContext<CommandSourceStack> context) {
try {
SFConfigReload.EVENT.invoker().onConfigReload();
context.getSource().sendSuccess(
() -> Component.translatable("command.sfcraft.reload.success"), true);
return 1;
} catch (Exception e) {
log.error("Failed to reload sfcraft configs", e);
context.getSource().sendFailure(
Component.translatable("command.sfcraft.reload.failure", e.getMessage()));
return 0;
}
}
}
@@ -0,0 +1,39 @@
package io.ib67.sfcraft.module.game;
import com.google.inject.Inject;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.callback.SFConfigReload;
import io.ib67.sfcraft.config.GameConfigService;
import lombok.extern.log4j.Log4j2;
import java.io.IOException;
/**
* Owns the lifecycle of gameplay.json ({@link GameConfigService}): performs the initial load
* before the other game modules register their content, and re-reads it on
* {@link SFConfigReload} (fired by /sfcraft reload).
* <p>
* Must be registered before all other {@code module.game} features, some of which bake
* config values into registry content (e.g. reverse pearl token durability).
*/
@Log4j2
public class GameExtensionModule extends ServerModule {
@Inject
private GameConfigService gameConfig;
@Override
public void onInitialize() {
try {
gameConfig.reload();
} catch (Exception e) {
log.error("Cannot load gameplay.json, falling back to defaults", e);
}
SFConfigReload.EVENT.register(() -> {
try {
gameConfig.reload();
} catch (IOException e) {
throw new IllegalStateException("Cannot reload gameplay.json: " + e.getMessage(), e);
}
});
}
}
@@ -0,0 +1,58 @@
package io.ib67.sfcraft.module.game;
import eu.pb4.polymer.core.api.block.PolymerBlockUtils;
import eu.pb4.polymer.core.api.entity.PolymerEntityUtils;
import io.ib67.sfcraft.SFCraft;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import java.util.function.Function;
/**
* Registration shorthands for game content. Only call these from a module's
* {@code onInitialize()} — static registries are frozen after ModInit.
*/
public final class RegistryHelper {
private RegistryHelper() {
}
public static <B extends Block> B registerBlock(String name, Function<BlockBehaviour.Properties, B> factory, BlockBehaviour.Properties properties) {
var key = ResourceKey.create(Registries.BLOCK, Identifier.fromNamespaceAndPath(SFCraft.MOD_ID, name));
return Registry.register(BuiltInRegistries.BLOCK, key, factory.apply(properties.setId(key)));
}
public static <I extends Item> I registerItem(String name, Function<Item.Properties, I> factory, Item.Properties properties) {
var key = ResourceKey.create(Registries.ITEM, Identifier.fromNamespaceAndPath(SFCraft.MOD_ID, name));
return Registry.register(BuiltInRegistries.ITEM, key, factory.apply(properties.setId(key)));
}
public static <T extends BlockEntity> BlockEntityType<T> registerBlockEntity(
String name,
FabricBlockEntityTypeBuilder.Factory<? extends T> factory,
Block... blocks
) {
var id = Identifier.fromNamespaceAndPath(SFCraft.MOD_ID, name);
var type = Registry.register(BuiltInRegistries.BLOCK_ENTITY_TYPE, id,
FabricBlockEntityTypeBuilder.<T>create(factory, blocks).build());
PolymerBlockUtils.registerBlockEntity(type);
return type;
}
public static <T extends Entity> EntityType<T> registerEntity(String name, EntityType.Builder<T> builder) {
var key = ResourceKey.create(Registries.ENTITY_TYPE, Identifier.fromNamespaceAndPath(SFCraft.MOD_ID, name));
var type = Registry.register(BuiltInRegistries.ENTITY_TYPE, key, builder.build(key));
PolymerEntityUtils.registerType(type);
return type;
}
}
@@ -0,0 +1,94 @@
package io.ib67.sfcraft.module.game.beheading;
import com.google.inject.Inject;
import io.ib67.sfcraft.SFCraft;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.config.GameConfigService;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.minecraft.core.Holder;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.ResolvableProfile;
import net.minecraft.world.item.enchantment.Enchantment;
import net.minecraft.world.item.enchantment.EnchantmentHelper;
import java.util.Map;
import java.util.Optional;
/**
* 斩首附魔:持有该附魔的武器击杀敌怪(含玩家)时有概率掉落对应头颅。
* 掉头逻辑纯服务端,附魔本身为数据驱动内容(data/sfcraft/enchantment/beheading.json),
* 无需代码注册,仅作等级标记。
*/
public class BeheadingModule extends ServerModule {
public static final ResourceKey<Enchantment> BEHEADING = ResourceKey.create(
Registries.ENCHANTMENT, Identifier.fromNamespaceAndPath(SFCraft.MOD_ID, "beheading"));
// 有原版对应头颅的实体 → 头颅物品;其余实体不掉头
private static final Map<EntityType<?>, Item> HEADS = Map.of(
EntityTypes.ZOMBIE, Items.ZOMBIE_HEAD,
EntityTypes.SKELETON, Items.SKELETON_SKULL,
EntityTypes.WITHER_SKELETON, Items.WITHER_SKELETON_SKULL,
EntityTypes.CREEPER, Items.CREEPER_HEAD,
EntityTypes.PIGLIN, Items.PIGLIN_HEAD,
EntityTypes.PIGLIN_BRUTE, Items.PIGLIN_HEAD,
EntityTypes.ZOMBIFIED_PIGLIN, Items.PIGLIN_HEAD,
EntityTypes.ENDER_DRAGON, Items.DRAGON_HEAD);
@Inject
private GameConfigService config;
@Override
public void onInitialize() {
ServerLivingEntityEvents.AFTER_DEATH.register(this::onDeath);
}
private void onDeath(LivingEntity victim, DamageSource source) {
if (!(victim.level() instanceof ServerLevel serverLevel)) return;
var killer = source.getEntity() instanceof LivingEntity le ? le : victim.getKillCredit();
if (killer == null) return;
var head = headFor(victim);
if (head == null) return;
var holder = getBeheading(serverLevel.registryAccess());
if (holder.isEmpty()) return;
int level = EnchantmentHelper.getItemEnchantmentLevel(holder.get(), killer.getMainHandItem());
if (level <= 0) return;
var beheading = config.get().beheading;
float chance = victim instanceof Player
? beheading.playerHeadChance
: beheading.baseChance + beheading.perLevelChance * level;
if (serverLevel.getRandom().nextFloat() >= chance) return;
victim.spawnAtLocation(serverLevel, head);
}
private static ItemStack headFor(LivingEntity victim) {
if (victim instanceof Player player) {
var head = new ItemStack(Items.PLAYER_HEAD);
head.set(DataComponents.PROFILE, ResolvableProfile.createResolved(player.getGameProfile()));
return head;
}
var item = HEADS.get(victim.getType());
return item == null ? null : new ItemStack(item);
}
public static Optional<Holder.Reference<Enchantment>> getBeheading(RegistryAccess registryAccess) {
return registryAccess.lookup(Registries.ENCHANTMENT).flatMap(registry -> registry.get(BEHEADING));
}
}
@@ -0,0 +1,218 @@
package io.ib67.sfcraft.module.game.bomb;
import eu.pb4.polymer.core.api.entity.PolymerEntity;
import io.ib67.sfcraft.config.GameConfigService;
import net.fabricmc.fabric.api.networking.v1.context.PacketContext;
import net.minecraft.core.Direction;
import net.minecraft.core.particles.DustParticleOptions;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.projectile.throwableitemprojectile.ThrowableItemProjectile;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseFireBlock;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.Vec3;
import java.util.function.Supplier;
public class BombEntity extends ThrowableItemProjectile implements PolymerEntity {
// 纯视觉的粒子密度;数值类参数见 GameConfig.bomb
private static final int SLOW_CIRCLE_POINTS = 16;
private final GameConfigService config;
private final Supplier<Item> defaultItem;
private BombType type = BombType.NORMAL;
private boolean landed;
private int fuseTicks;
// 落地后烟雾喷射方向:落地面朝外法线(默认向上)
private Direction landNormal = Direction.UP;
public BombEntity(EntityType<? extends BombEntity> entityType, Level level,
GameConfigService config, Supplier<Item> defaultItem) {
super(entityType, level);
this.config = config;
this.defaultItem = defaultItem;
}
public BombEntity(EntityType<? extends BombEntity> entityType, LivingEntity shooter, Level level, ItemStack stack,
GameConfigService config, Supplier<Item> defaultItem) {
super(entityType, shooter, level, stack);
this.config = config;
this.defaultItem = defaultItem;
}
public void setBombType(BombType type) {
this.type = type;
}
@Override
protected Item getDefaultItem() {
return defaultItem.get();
}
@Override
public EntityType<?> getPolymerEntityType(PacketContext context) {
return EntityTypes.SNOWBALL;
}
@Override
protected boolean canHitEntity(net.minecraft.world.entity.Entity entity) {
// 炸弹只在碰到方块时触发,直接穿过实体
return false;
}
@Override
protected void onHitBlock(BlockHitResult result) {
super.onHitBlock(result);
if (level().isClientSide() || landed) return;
landNormal = result.getDirection();
land(result.getLocation().add(Vec3.atLowerCornerOf(result.getDirection().getUnitVec3i()).scale(0.05)));
}
private void land(Vec3 restPos) {
landed = true;
setDeltaMovement(Vec3.ZERO);
setNoGravity(true);
setPos(restPos);
if (type == BombType.OBSIDIAN) {
level().playSound(null, getX(), getY(), getZ(),
SoundEvents.ELDER_GUARDIAN_CURSE, SoundSource.HOSTILE, 1.0F, 1.0F);
}
}
@Override
public void tick() {
if (!landed) {
super.tick();
}
if (!(level() instanceof ServerLevel serverLevel)) return;
fuseTicks++;
emitSmoke(serverLevel);
if (landed && type == BombType.OBSIDIAN) {
if (fuseTicks % 5 == 0) drawSlowCircle(serverLevel);
if (fuseTicks % 10 == 0) applySlowness(serverLevel);
}
if (fuseTicks >= config.get().bomb.fuseTicks) explode();
}
private void emitSmoke(ServerLevel serverLevel) {
var bombConfig = config.get().bomb;
// 飞行时朝速度反方向;落地后朝落地面朝外法线("从表面冒出")
Vec3 direction;
if (landed) {
direction = Vec3.atLowerCornerOf(landNormal.getUnitVec3i());
} else {
var velocity = getDeltaMovement();
if (velocity.lengthSqr() < 1.0e-6) return;
direction = velocity.normalize().scale(-1);
}
// 黑色粉尘沿方向单侧铺开形成拖尾/升腾烟柱(dust 不吃粒子速度,故靠位置偏移造型)
var dust = new DustParticleOptions(bombConfig.smokeColor, bombConfig.smokeParticleScale);
var random = getRandom();
for (int i = 0; i < bombConfig.smokeParticlesPerTick; i++) {
double t = random.nextDouble() * 0.6;
double jitter = 0.08;
double px = getX() + direction.x * t + (random.nextDouble() - 0.5) * jitter;
double py = getY() + direction.y * t + (random.nextDouble() - 0.5) * jitter;
double pz = getZ() + direction.z * t + (random.nextDouble() - 0.5) * jitter;
serverLevel.sendParticles(dust, px, py, pz, 1, 0.0, 0.0, 0.0, bombConfig.smokeSpeed);
}
}
private void drawSlowCircle(ServerLevel serverLevel) {
double radius = config.get().bomb.slowCircleRadius;
for (int i = 0; i < SLOW_CIRCLE_POINTS; i++) {
double angle = (Math.PI * 2 / SLOW_CIRCLE_POINTS) * i;
serverLevel.sendParticles(ParticleTypes.SOUL_FIRE_FLAME,
getX() + Math.cos(angle) * radius,
getY() + 0.1,
getZ() + Math.sin(angle) * radius,
1, 0, 0, 0, 0);
}
}
private void applySlowness(ServerLevel serverLevel) {
var bombConfig = config.get().bomb;
double radius = bombConfig.slowCircleRadius;
var box = getBoundingBox().inflate(radius, 1.0, radius);
for (var entity : serverLevel.getEntitiesOfClass(LivingEntity.class, box)) {
double dx = entity.getX() - getX();
double dz = entity.getZ() - getZ();
if (dx * dx + dz * dz > radius * radius) continue;
entity.addEffect(new MobEffectInstance(MobEffects.SLOWNESS, 40, bombConfig.slownessAmplifier));
}
}
private void explode() {
if (!(level() instanceof ServerLevel serverLevel)) return;
var bombConfig = config.get().bomb;
serverLevel.explode(this, getX(), getY(), getZ(), bombConfig.explosionPower, Level.ExplosionInteraction.NONE);
if (type == BombType.BLAZE) {
// 灰色浓烟为主、少量橙色火花,从爆炸中心向外爆发
serverLevel.sendParticles(ParticleTypes.LARGE_SMOKE, getX(), getY(), getZ(), 40, 0, 0, 0, 0.25);
serverLevel.sendParticles(ParticleTypes.FLAME, getX(), getY(), getZ(), 12, 0, 0, 0, 0.3);
for (var entity : serverLevel.getEntitiesOfClass(LivingEntity.class, getBoundingBox().inflate(bombConfig.blazeIgniteRadius))) {
entity.igniteForTicks(bombConfig.blazeIgniteTicks);
}
spreadFire(serverLevel);
}
discard();
}
private void spreadFire(ServerLevel serverLevel) {
var bombConfig = config.get().bomb;
var random = getRandom();
int target = bombConfig.blazeFireSpreadMin
+ random.nextInt(Math.max(1, bombConfig.blazeFireSpreadMax - bombConfig.blazeFireSpreadMin + 1));
int placed = 0;
for (int attempt = 0; attempt < 12 && placed < target; attempt++) {
var pos = blockPosition().offset(random.nextInt(5) - 2, random.nextInt(3) - 1, random.nextInt(5) - 2);
var fireState = BaseFireBlock.getState(serverLevel, pos);
if (serverLevel.getBlockState(pos).isAir() && fireState.canSurvive(serverLevel, pos)) {
serverLevel.setBlockAndUpdate(pos, fireState);
placed++;
}
}
}
@Override
protected void addAdditionalSaveData(ValueOutput out) {
super.addAdditionalSaveData(out);
out.putString("bomb_type", type.name());
out.putBoolean("landed", landed);
out.putInt("fuse_ticks", fuseTicks);
out.putInt("land_normal", landNormal.get3DDataValue());
}
@Override
protected void readAdditionalSaveData(ValueInput in) {
super.readAdditionalSaveData(in);
type = BombType.byName(in.getStringOr("bomb_type", BombType.NORMAL.name()));
landed = in.getBooleanOr("landed", false);
fuseTicks = in.getIntOr("fuse_ticks", 0);
landNormal = Direction.from3DDataValue(in.getIntOr("land_normal", Direction.UP.get3DDataValue()));
if (landed) setNoGravity(true);
}
public enum BombType {
NORMAL, OBSIDIAN, BLAZE;
static BombType byName(String name) {
for (var value : values()) {
if (value.name().equals(name)) return value;
}
return NORMAL;
}
}
}
@@ -0,0 +1,54 @@
package io.ib67.sfcraft.module.game.bomb;
import eu.pb4.polymer.core.api.item.PolymerItem;
import io.ib67.sfcraft.config.GameConfigService;
import net.fabricmc.fabric.api.networking.v1.context.PacketContext;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.projectile.Projectile;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
public class BombItem extends Item implements PolymerItem {
private final GameConfigService config;
private final EntityType<BombEntity> bombType;
private final BombEntity.BombType type;
private final Item visualItem;
public BombItem(Properties properties, GameConfigService config, EntityType<BombEntity> bombType,
BombEntity.BombType type, Item visualItem) {
super(properties);
this.config = config;
this.bombType = bombType;
this.type = type;
this.visualItem = visualItem;
}
@Override
public Item getPolymerItem(ItemStack stack, PacketContext context) {
return visualItem;
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
var stack = player.getItemInHand(hand);
level.playSound(null, player.getX(), player.getY(), player.getZ(),
SoundEvents.TNT_PRIMED, SoundSource.PLAYERS, 0.8F, 1.2F);
if (level instanceof ServerLevel serverLevel) {
Projectile.spawnProjectileFromRotation((spawnLevel, shooter, spawnStack) -> {
var bomb = new BombEntity(bombType, shooter, spawnLevel, spawnStack, config, () -> this);
bomb.setBombType(type);
return bomb;
}, serverLevel, stack, player, 0.0F, 1.5F, 1.0F);
}
player.getCooldowns().addCooldown(stack, config.get().bomb.throwCooldownTicks);
stack.consume(1, player);
return InteractionResult.SUCCESS_SERVER;
}
}
@@ -0,0 +1,88 @@
package io.ib67.sfcraft.module.game.bomb;
import com.google.inject.Inject;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.config.GameConfigService;
import io.ib67.sfcraft.module.game.RegistryHelper;
import lombok.Getter;
import net.fabricmc.fabric.api.loot.v3.LootTableEvents;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.MobCategory;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
import net.minecraft.world.level.storage.loot.LootPool;
import net.minecraft.world.level.storage.loot.entries.LootItem;
import net.minecraft.world.level.storage.loot.functions.SetItemCountFunction;
import net.minecraft.world.level.storage.loot.predicates.LootItemRandomChanceCondition;
import net.minecraft.world.level.storage.loot.providers.number.UniformGenerator;
/**
* 三种投掷炸弹(普通/黑曜石/烈焰)与它们的战利品注入(地牢箱、苦力怕掉落)。
*/
@Getter
public class BombModule extends ServerModule {
@Inject
private GameConfigService config;
private EntityType<BombEntity> bombEntityType;
private Item bomb;
private Item obsidianBomb;
private Item blazeBomb;
@Override
public void onInitialize() {
bombEntityType = RegistryHelper.registerEntity(
"bomb",
EntityType.Builder.<BombEntity>of(
(type, level) -> new BombEntity(type, level, config, () -> bomb),
MobCategory.MISC)
.sized(0.25F, 0.25F)
.clientTrackingRange(4)
.updateInterval(10)
);
bomb = registerBomb("bomb", BombEntity.BombType.NORMAL);
obsidianBomb = registerBomb("obsidian_bomb", BombEntity.BombType.OBSIDIAN);
blazeBomb = registerBomb("blaze_bomb", BombEntity.BombType.BLAZE);
registerLootModifiers();
}
private Item registerBomb(String name, BombEntity.BombType type) {
return RegistryHelper.registerItem(
name,
properties -> new BombItem(properties, config, bombEntityType, type, Items.WARPED_FUNGUS_ON_A_STICK),
new Item.Properties()
.modelId(Identifier.fromNamespaceAndPath("sfcraft", "item/" + name))
.stacksTo(16)
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft." + name))
);
}
private void registerLootModifiers() {
// 概率在战利品表构建时(数据包加载)取样:改完配置需 /sfcraft reload + 原版 /reload 才生效
LootTableEvents.MODIFY.register((key, tableBuilder, source, registries) -> {
var loot = config.get().loot;
if (BuiltInLootTables.SIMPLE_DUNGEON.equals(key)) {
tableBuilder.withPool(LootPool.lootPool()
.when(LootItemRandomChanceCondition.randomChance(loot.dungeonBombChance))
.add(LootItem.lootTableItem(bomb)
.apply(SetItemCountFunction.setCount(UniformGenerator.between(1.0F, 3.0F)))));
tableBuilder.withPool(LootPool.lootPool()
.when(LootItemRandomChanceCondition.randomChance(loot.dungeonObsidianBombChance))
.add(LootItem.lootTableItem(obsidianBomb)
.apply(SetItemCountFunction.setCount(UniformGenerator.between(1.0F, 2.0F)))));
}
EntityTypes.CREEPER.getDefaultLootTable().ifPresent(creeperTable -> {
if (creeperTable.equals(key)) {
tableBuilder.withPool(LootPool.lootPool()
.when(LootItemRandomChanceCondition.randomChance(loot.creeperBombChance))
.add(LootItem.lootTableItem(bomb)));
}
});
});
}
}
@@ -0,0 +1,136 @@
package io.ib67.sfcraft.module.game.cauldron;
import com.mojang.serialization.MapCodec;
import eu.pb4.polymer.core.api.block.PolymerBlock;
import eu.pb4.polymer.virtualentity.api.BlockWithElementHolder;
import eu.pb4.polymer.virtualentity.api.ElementHolder;
import net.fabricmc.fabric.api.networking.v1.context.PacketContext;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LayeredCauldronBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
import org.jetbrains.annotations.Nullable;
import org.jspecify.annotations.NonNull;
import java.util.function.BiFunction;
public class AmethystCauldronBlock extends BaseEntityBlock implements PolymerBlock, BlockWithElementHolder {
public static final BooleanProperty HAS_WATER = BooleanProperty.create("has_water");
public static final MapCodec<AmethystCauldronBlock> CODEC = simpleCodec(AmethystCauldronBlock::new);
// Polymer 会把该偏移加到方块中心(Vec3.atCenterOf), 因此这里是相对中心的偏移:
// 结果约为 (x+0.5, y+0.55, z+0.5), 即锅内水中、锅沿以下、水平居中。
private static final Vec3 ELEMENT_HOLDER_OFFSET = new Vec3(0.0, 0.05, 0.0);
// CauldronModule 注册方块实体类型后回填(方块与其方块实体类型互相引用,无法在构造器闭合)
private BlockEntityType<AmethystCauldronBlockEntity> blockEntityType;
private BiFunction<BlockPos, BlockState, AmethystCauldronBlockEntity> blockEntityFactory;
public AmethystCauldronBlock(Properties properties) {
super(properties);
registerDefaultState(getStateDefinition().any().setValue(HAS_WATER, false));
}
void wireBlockEntity(BlockEntityType<AmethystCauldronBlockEntity> type,
BiFunction<BlockPos, BlockState, AmethystCauldronBlockEntity> factory) {
this.blockEntityType = type;
this.blockEntityFactory = factory;
}
@Override
protected @NonNull MapCodec<? extends BaseEntityBlock> codec() {
return CODEC;
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(HAS_WATER);
}
@Override
public BlockState getPolymerBlockState(BlockState state, PacketContext context) {
return state.getValue(HAS_WATER)
? Blocks.WATER_CAULDRON.defaultBlockState().setValue(LayeredCauldronBlock.LEVEL, 3)
: Blocks.CAULDRON.defaultBlockState();
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return Blocks.CAULDRON.defaultBlockState().getShape(level, pos, context);
}
@Override
public @Nullable BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return blockEntityFactory.apply(pos, state);
}
@Override
public @Nullable <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
if (level.isClientSide()) return null;
return createTickerHelper(type, blockEntityType, AmethystCauldronBlockEntity::tick);
}
@Override
protected @NonNull InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (stack.is(Items.WATER_BUCKET)) {
if (state.getValue(HAS_WATER)) return InteractionResult.FAIL;
level.setBlock(pos, state.setValue(HAS_WATER, true), Block.UPDATE_ALL);
if (!player.hasInfiniteMaterials()) {
player.setItemInHand(hand, new ItemStack(Items.BUCKET));
}
level.playSound(null, pos, SoundEvents.BUCKET_EMPTY, SoundSource.BLOCKS, 1.0F, 1.0F);
return InteractionResult.SUCCESS_SERVER;
}
// 其余物品不再手持触发反应: 把配方所需物品(含回响碎片催化剂)丢进锅里即可自动开始。
// 必须返回 TRY_WITH_EMPTY_HAND 而非 PASS,否则 26.2 交互流程不会继续调用 useWithoutItem,
// 空手右键取回原料(retrieveLast)就永远触发不了。
return InteractionResult.TRY_WITH_EMPTY_HAND;
}
@Override
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (level.getBlockEntity(pos) instanceof AmethystCauldronBlockEntity cauldron) {
return cauldron.retrieveLast(player);
}
return InteractionResult.PASS;
}
@Override
public @Nullable ElementHolder createElementHolder(ServerLevel world, BlockPos pos, BlockState initialBlockState) {
return new CauldronDisplayHolder(world, pos);
}
@Override
public Vec3 getElementHolderOffset(ServerLevel world, BlockPos pos, BlockState initialBlockState) {
return ELEMENT_HOLDER_OFFSET;
}
@Override
public boolean tickElementHolder(ServerLevel world, BlockPos pos, BlockState initialBlockState) {
return true;
}
}
@@ -0,0 +1,194 @@
package io.ib67.sfcraft.module.game.cauldron;
import io.ib67.sfcraft.config.GameConfigService;
import io.ib67.sfcraft.registry.CauldronRecipeRegistry;
import io.ib67.sfcraft.registry.cauldron.CauldronRecipe;
import lombok.Getter;
import net.minecraft.core.BlockPos;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.AABB;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
public class AmethystCauldronBlockEntity extends BlockEntity {
@Getter
private final List<ItemStack> contents = new ArrayList<>();
private final CauldronRecipeRegistry recipes;
private final GameConfigService config;
@Nullable
private CauldronRecipe activeRecipe;
private int reactionTicksLeft;
public AmethystCauldronBlockEntity(BlockEntityType<AmethystCauldronBlockEntity> type,
CauldronRecipeRegistry recipes,
GameConfigService config,
BlockPos pos, BlockState state) {
super(type, pos, state);
this.recipes = recipes;
this.config = config;
}
public boolean isReacting() {
return activeRecipe != null;
}
public static void tick(Level level, BlockPos pos, BlockState state, AmethystCauldronBlockEntity cauldron) {
if (!(level instanceof ServerLevel serverLevel)) return;
long time = level.getGameTime();
if (time % 8 == 0) {
serverLevel.sendParticles(ParticleTypes.FIREFLY,
pos.getX() + 0.5, pos.getY() + 1.0, pos.getZ() + 0.5,
1, 0.35, 0.25, 0.35, 0.02);
}
if (time % 4 == 0) {
cauldron.absorbItems(serverLevel, pos);
}
if (cauldron.activeRecipe != null) {
serverLevel.sendParticles(cauldron.activeRecipe.reactionParticle(),
pos.getX() + 0.5, pos.getY() + 1.2, pos.getZ() + 0.5,
3, 0.25, 0.15, 0.25, 0.0);
if (--cauldron.reactionTicksLeft <= 0) {
cauldron.finishReaction(serverLevel, pos, state);
}
} else {
if (time % 4 == 0) cauldron.tryAutoStart(serverLevel, pos, state);
}
}
private void absorbItems(ServerLevel level, BlockPos pos) {
int maxItems = config.get().cauldron.maxItems;
if (activeRecipe != null || contents.size() >= maxItems) return;
for (var itemEntity : level.getEntitiesOfClass(ItemEntity.class, new AABB(pos))) {
if (contents.size() >= maxItems) break;
var stack = itemEntity.getItem();
if (stack.isEmpty()) continue;
contents.add(stack.copy());
itemEntity.discard();
level.playSound(null, pos, SoundEvents.ITEM_PICKUP, SoundSource.BLOCKS, 0.6F, 0.7F);
setChanged();
}
}
/**
* 每 tick 检查: 锅内物品凑齐某配方且满足水/加热条件时自动开始反应。
* 凑齐但缺条件时冒烟提示"就差水或加热"。
*/
private void tryAutoStart(ServerLevel level, BlockPos pos, BlockState state) {
if (contents.isEmpty()) return;
var match = recipes.match(contents);
if (match.isEmpty()) return;
var recipe = match.get();
boolean ready = (!recipe.needsWater() || state.getValue(AmethystCauldronBlock.HAS_WATER))
&& (!recipe.needsHeat() || isHeated(level, pos));
if (!ready) {
if (level.getGameTime() % 16 == 0) {
level.sendParticles(ParticleTypes.SMOKE,
pos.getX() + 0.5, pos.getY() + 1.0, pos.getZ() + 0.5,
2, 0.2, 0.1, 0.2, 0.01);
}
return;
}
activeRecipe = recipe;
reactionTicksLeft = recipe.reactionTicks();
level.playSound(null, pos, SoundEvents.AMETHYST_BLOCK_RESONATE, SoundSource.BLOCKS, 1.0F, 0.8F);
setChanged();
}
/** 空手右键: 取回最后放入的一个原料(后进先出);反应进行中禁止取出。 */
public InteractionResult retrieveLast(Player player) {
if (level == null) return InteractionResult.PASS;
if (activeRecipe != null) {
sendHint(player, Component.translatable("message.sfcraft.cauldron.reacting"));
return InteractionResult.FAIL;
}
if (contents.isEmpty()) return InteractionResult.PASS;
var stack = contents.removeLast();
if (!player.getInventory().add(stack)) {
player.drop(stack, false);
}
level.playSound(null, getBlockPos(), SoundEvents.ITEM_FRAME_REMOVE_ITEM, SoundSource.BLOCKS, 0.6F, 1.0F);
setChanged();
return InteractionResult.SUCCESS_SERVER;
}
private void finishReaction(ServerLevel level, BlockPos pos, BlockState state) {
var recipe = activeRecipe;
activeRecipe = null;
reactionTicksLeft = 0;
if (recipe == null) return;
var result = recipe.assembler().apply(contents);
contents.clear();
if (!result.isEmpty()) {
Block.popResource(level, pos.above(), result);
}
if (recipe.needsWater()) {
level.setBlock(pos, state.setValue(AmethystCauldronBlock.HAS_WATER, false), Block.UPDATE_ALL);
}
level.playSound(null, pos, SoundEvents.AMETHYST_BLOCK_CHIME, SoundSource.BLOCKS, 1.0F, 1.0F);
setChanged();
}
private static boolean isHeated(Level level, BlockPos pos) {
var below = pos.below();
var belowState = level.getBlockState(below);
return belowState.is(Blocks.FIRE)
|| belowState.is(Blocks.SOUL_FIRE)
|| belowState.is(Blocks.MAGMA_BLOCK)
|| level.getFluidState(below).is(FluidTags.LAVA);
}
@Override
public void preRemoveSideEffects(BlockPos pos, BlockState state) {
if (level != null) {
for (var stack : contents) {
Block.popResource(level, pos.above(), stack);
}
contents.clear();
}
super.preRemoveSideEffects(pos, state);
}
private static void sendHint(Player player, Component message) {
if (player instanceof ServerPlayer serverPlayer) {
serverPlayer.sendOverlayMessage(message);
}
}
@Override
protected void saveAdditional(ValueOutput out) {
super.saveAdditional(out);
var list = out.list("items", ItemStack.CODEC);
contents.forEach(list::add);
out.putInt("reaction_ticks", reactionTicksLeft);
out.putString("recipe", activeRecipe == null ? "" : activeRecipe.id());
}
@Override
protected void loadAdditional(ValueInput in) {
super.loadAdditional(in);
contents.clear();
in.listOrEmpty("items", ItemStack.CODEC).forEach(contents::add);
reactionTicksLeft = in.getIntOr("reaction_ticks", 0);
activeRecipe = recipes.byId(in.getStringOr("recipe", ""));
}
}
@@ -0,0 +1,66 @@
package io.ib67.sfcraft.module.game.cauldron;
import eu.pb4.polymer.virtualentity.api.ElementHolder;
import eu.pb4.polymer.virtualentity.api.elements.ItemDisplayElement;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
import org.joml.Vector3f;
import java.util.List;
/**
* Floating item displays showing what's inside the amethyst cauldron.
*/
public class CauldronDisplayHolder extends ElementHolder {
private static final Vec3[] OFFSETS = {
new Vec3(0.0, 0.0, 0.0),
new Vec3(0.18, 0.03, 0.0),
new Vec3(-0.09, 0.06, 0.16),
new Vec3(-0.09, 0.09, -0.16),
};
private static final int SYNC_INTERVAL = 10;
private final ServerLevel level;
private final BlockPos pos;
private final ItemDisplayElement[] elements = new ItemDisplayElement[OFFSETS.length];
private final ItemStack[] shownStacks = new ItemStack[OFFSETS.length];
public CauldronDisplayHolder(ServerLevel level, BlockPos pos) {
this.level = level;
this.pos = pos;
}
@Override
protected void onTick() {
super.onTick();
if (level.getGameTime() % SYNC_INTERVAL != 0) return;
List<ItemStack> contents = level.getBlockEntity(pos) instanceof AmethystCauldronBlockEntity cauldron
? cauldron.getContents()
: List.of();
for (int i = 0; i < elements.length; i++) {
var stack = i < contents.size() ? contents.get(i) : ItemStack.EMPTY;
if (stack.isEmpty()) {
if (elements[i] != null) {
removeElement(elements[i]);
elements[i] = null;
shownStacks[i] = null;
}
continue;
}
if (elements[i] == null) {
var element = new ItemDisplayElement();
element.setOffset(OFFSETS[i]);
element.setScale(new Vector3f(0.35f, 0.35f, 0.35f));
element.setItemDisplayContext(ItemDisplayContext.GROUND);
elements[i] = addElement(element);
}
if (shownStacks[i] == null || !ItemStack.matches(shownStacks[i], stack)) {
shownStacks[i] = stack.copy();
elements[i].setItem(stack.copyWithCount(1));
}
}
}
}
@@ -0,0 +1,71 @@
package io.ib67.sfcraft.module.game.cauldron;
import com.google.inject.Inject;
import eu.pb4.polymer.core.api.item.PolymerBlockItem;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.config.GameConfigService;
import io.ib67.sfcraft.module.game.RegistryHelper;
import io.ib67.sfcraft.module.game.item.SimplePolymerItem;
import io.ib67.sfcraft.registry.CauldronRecipeRegistry;
import lombok.Getter;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
/**
* 紫水晶炼药锅:吸入锅内的物品凑齐配方并满足水/加热条件后自动反应。
* 配方由其它特性模块注册到 {@link CauldronRecipeRegistry}。
*/
@Getter
public class CauldronModule extends ServerModule {
@Inject
private GameConfigService config;
@Inject
private CauldronRecipeRegistry recipes;
private AmethystCauldronBlock cauldronBlock;
private BlockEntityType<AmethystCauldronBlockEntity> cauldronBlockEntity;
private Item cauldronItem;
private Item cauldronBlankItem;
@Override
public void onInitialize() {
cauldronBlock = RegistryHelper.registerBlock(
"amethyst_cauldron",
AmethystCauldronBlock::new,
BlockBehaviour.Properties.of()
.strength(2.0F)
.sound(SoundType.METAL)
.noOcclusion()
.lightLevel(state -> 10)
);
cauldronBlockEntity = RegistryHelper.registerBlockEntity(
"amethyst_cauldron",
(pos, state) -> new AmethystCauldronBlockEntity(cauldronBlockEntity, recipes, config, pos, state),
cauldronBlock
);
cauldronBlock.wireBlockEntity(cauldronBlockEntity,
(pos, state) -> new AmethystCauldronBlockEntity(cauldronBlockEntity, recipes, config, pos, state));
cauldronBlankItem = RegistryHelper.registerItem(
"amethyst_cauldron_blank",
properties -> new SimplePolymerItem(properties, Items.RAW_IRON),
new Item.Properties()
.modelId(Identifier.fromNamespaceAndPath("sfcraft", "item/amethyst_cauldron"))
.stacksTo(16)
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.amethyst_cauldron_blank"))
);
cauldronItem = RegistryHelper.registerItem(
"amethyst_cauldron",
properties -> new PolymerBlockItem(cauldronBlock, properties, Items.CAULDRON),
new Item.Properties()
.component(DataComponents.ENCHANTMENT_GLINT_OVERRIDE, true)
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.amethyst_cauldron"))
);
}
}
@@ -0,0 +1,26 @@
package io.ib67.sfcraft.module.game.commander;
import eu.pb4.polymer.core.api.other.PolymerMobEffect;
import net.fabricmc.fabric.api.networking.v1.context.PacketContext;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectCategory;
import net.minecraft.world.effect.MobEffects;
/**
* 「统帅」药水效果。行为(紫光、纽带、为周围怪物供 buff、死亡即失效)由
* {@link CommanderModule} 驱动;本类只承载注册与 Polymer 客户端替身。
*
* <p>MobEffect 是静态非同步注册表,自定义效果必须实现 {@link PolymerMobEffect}
* 并在 {@link #getPolymerReplacement} 返回原版效果,否则原版客户端收到未知 id 会错乱。
*/
public class CommanderMobEffect extends MobEffect implements PolymerMobEffect {
public CommanderMobEffect() {
super(MobEffectCategory.NEUTRAL, 0xAA00FF); // 紫色
}
@Override
public MobEffect getPolymerReplacement(MobEffect effect, PacketContext context) {
// 原版客户端见到的替身:发光(无害,且与我们用 GLOWING+队伍颜色实现的紫光一致)
return MobEffects.GLOWING.value();
}
}
@@ -0,0 +1,293 @@
package io.ib67.sfcraft.module.game.commander;
import com.google.inject.Inject;
import io.ib67.sfcraft.SFCraft;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.config.GameConfig;
import io.ib67.sfcraft.config.GameConfigService;
import io.ib67.sfcraft.inject.MinecraftServerSupplier;
import lombok.Getter;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.core.Holder;
import net.minecraft.core.Registry;
import net.minecraft.core.particles.DustParticleOptions;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.monster.Enemy;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.scores.PlayerTeam;
import net.minecraft.world.scores.Scoreboard;
import net.minecraft.world.scores.TeamColor;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
/**
* 「统帅」效果的注册与行为驱动:
* <ul>
* <li>新生成的敌怪有 config 概率携带该效果(入口在 MobCommanderMixin → {@link #onMobSpawned})</li>
* <li>携带者发紫光(GLOWING + 紫色队伍颜色)</li>
* <li>为半径内至多 N 个「同攻击目标、且不带统帅效果」的怪物提供 2 种 buff</li>
* <li>提供者↔受益者之间有紫色粒子纽带</li>
* <li>受益者走太远则被寻路拉回</li>
* <li>提供者切换仇恨目标 → 已连接的受益者同步覆写为新目标(整队跟随)</li>
* <li>提供者死亡/失去效果 → 立即撤销其 buff、去光、退队</li>
* </ul>
*/
public class CommanderModule extends ServerModule {
private static final String TEAM_NAME = "sfcraft_commander";
private static final int PURPLE = 0xAA00FF;
private static final List<Holder<MobEffect>> BUFF_POOL =
List.of(MobEffects.STRENGTH, MobEffects.RESISTANCE, MobEffects.SPEED);
@Inject
private GameConfigService config;
@Inject
private MinecraftServerSupplier server;
/** 「统帅」效果本体,在 ModInit 注册(此时静态注册表尚未冻结)。 */
@Getter
private Holder<MobEffect> commanderEffect;
// 提供者 UUID -> 当前纽带
private final Map<UUID, Bond> active = new HashMap<>();
// 事件维护的「当前已加载、携带统帅效果的怪」集合;rescan 只遍历它,避免全实体扫描,
// 且完全不依赖玩家位置——统帅 buff 是给怪群用的,与玩家远近无关。
private final Set<Mob> providers = new HashSet<>();
private int tickCounter;
// lastTargetId:上一轮扫描时提供者的仇恨目标 UUID(null 表示无目标),用于检测目标切换
private record Bond(ResourceKey<Level> dimension, UUID lastTargetId,
Set<UUID> recipients, List<Holder<MobEffect>> buffs) {
}
@Override
public void onInitialize() {
commanderEffect = Registry.registerForHolder(
BuiltInRegistries.MOB_EFFECT,
Identifier.fromNamespaceAndPath(SFCraft.MOD_ID, "commander"),
new CommanderMobEffect());
ServerTickEvents.END_SERVER_TICK.register(this::onTick);
// 靠实体加载/卸载事件维护提供者集合:区块加载(含新刷怪 addFreshEntity、重启后 spawn 区块、
// 玩家探索加载)时若怪携带统帅效果就登记,卸载/死亡时移除。集合始终等于「当前加载的提供者」。
ServerEntityEvents.ENTITY_LOAD.register((entity, level) -> {
if (entity instanceof Mob mob && mob.hasEffect(commanderEffect)) {
providers.add(mob);
}
});
ServerEntityEvents.ENTITY_UNLOAD.register((entity, level) -> {
if (entity instanceof Mob mob) {
providers.remove(mob);
}
});
// 提供者死亡即时清理,保证「死亡后 buff 立即消失」
ServerLivingEntityEvents.AFTER_DEATH.register((entity, source) -> {
if (active.containsKey(entity.getUUID()) && entity.level() instanceof ServerLevel level) {
clearBond(entity.getUUID(), level.getServer());
}
});
}
@Override
public void onEnable() {
// 清理上次运行残留在队伍里的成员(实体已消失、记分板条目未随之清除);此刻 active 必为空
Scoreboard scoreboard = server.get().getScoreboard();
PlayerTeam team = scoreboard.getPlayerTeam(TEAM_NAME);
if (team != null) {
for (String member : new ArrayList<>(team.getPlayers())) {
scoreboard.removePlayerFromTeam(member, team);
}
}
}
/** MobCommanderMixin 在 finalizeSpawn 尾部调用:自然刷新的敌怪按概率获得统帅效果。 */
public void onMobSpawned(Mob mob, EntitySpawnReason reason) {
if (reason != EntitySpawnReason.NATURAL || !(mob instanceof Enemy)) return;
double chance = config.get().commander.spawnChance;
if (chance <= 0 || mob.getRandom().nextDouble() >= chance) return;
mob.addEffect(new MobEffectInstance(commanderEffect,
MobEffectInstance.INFINITE_DURATION, 0, false, false, false));
providers.add(mob); // 直接登记,不依赖 ENTITY_LOAD 的时序
}
private void onTick(MinecraftServer server) {
var commander = config.get().commander;
tickCounter++;
if (tickCounter % Math.max(1, commander.scanIntervalTicks) == 0) {
rescan(server, commander);
}
if (tickCounter % Math.max(1, commander.particleIntervalTicks) == 0) {
drawTethers(server);
}
}
private void rescan(MinecraftServer server, GameConfig.Commander config) {
Scoreboard scoreboard = server.getScoreboard();
PlayerTeam team = getOrCreateTeam(scoreboard);
Set<UUID> seen = new HashSet<>();
// 遍历事件维护的提供者集合(携带统帅效果的已加载怪),不做全实体扫描、也不看玩家位置
for (Mob provider : new ArrayList<>(providers)) {
// 已死亡 / 失去效果(如被 /effect clear)/ 不在服务端世界 → 移出集合,由下方 stale 清理撤销影响
if (!provider.isAlive() || !provider.hasEffect(commanderEffect)
|| !(provider.level() instanceof ServerLevel level)) {
providers.remove(provider);
continue;
}
seen.add(provider.getUUID());
// 紫光:GLOWING(无粒子)+ 紫色队伍
provider.addEffect(new MobEffectInstance(MobEffects.GLOWING, config.glowRefreshTicks, 0, true, false, false));
if (!team.equals(scoreboard.getPlayersTeam(provider.getScoreboardName()))) {
scoreboard.addPlayerToTeam(provider.getScoreboardName(), team);
}
Bond previous = active.get(provider.getUUID());
LivingEntity target = provider.getTarget();
UUID targetId = target == null ? null : target.getUUID();
// 提供者切换仇恨目标 → 立即把新目标覆写给上一轮已连接的成员,让整队跟随
if (previous != null && target != null && !Objects.equals(previous.lastTargetId(), targetId)) {
overrideRecipientTargets(level, previous.recipients(), target);
}
List<Holder<MobEffect>> buffs = pickBuffs(provider.getUUID());
Set<UUID> priorRecipients = previous == null ? Set.of() : previous.recipients();
Set<UUID> recipients = collectAndBuff(level, provider, buffs, config, priorRecipients);
active.put(provider.getUUID(), new Bond(level.dimension(), targetId, recipients, buffs));
}
// 已不再是提供者(失去效果/离开)→ 清理
List<UUID> stale = new ArrayList<>();
for (UUID id : active.keySet()) {
if (!seen.contains(id)) stale.add(id);
}
for (UUID id : stale) {
clearBond(id, server);
}
}
private Set<UUID> collectAndBuff(ServerLevel level, Mob provider, List<Holder<MobEffect>> buffs,
GameConfig.Commander config, Set<UUID> priorRecipients) {
Set<UUID> recipients = new HashSet<>();
LivingEntity target = provider.getTarget();
if (target == null) return recipients; // 无共同目标则只发光,不供 buff
double r2 = config.radius * config.radius;
double cohesion2 = config.cohesionMaxDistance * config.cohesionMaxDistance;
var box = provider.getBoundingBox().inflate(config.radius);
var candidates = level.getEntitiesOfClass(Mob.class, box, m ->
m != provider && m.isAlive() && !m.hasEffect(commanderEffect) && m.getTarget() == target);
// 超过 maxRecipients 时,让上一轮已连接的成员优先留队,避免被新候选挤出队伍
candidates.sort(Comparator.comparingInt(m -> priorRecipients.contains(m.getUUID()) ? 0 : 1));
for (Mob candidate : candidates) {
if (recipients.size() >= config.maxRecipients) break;
double d2 = candidate.distanceToSqr(provider);
if (d2 > r2) continue;
for (Holder<MobEffect> buff : buffs) {
candidate.addEffect(new MobEffectInstance(buff, config.buffDurationTicks, 0, false, true, true));
}
if (d2 > cohesion2) {
candidate.getNavigation().moveTo(provider, config.cohesionSpeed);
}
recipients.add(candidate.getUUID());
}
return recipients;
}
// 把提供者的新仇恨目标覆写给这些已连接的成员,使其停止各自为战、转而跟随统帅
private void overrideRecipientTargets(ServerLevel level, Set<UUID> recipients, LivingEntity target) {
for (UUID rid : recipients) {
if (level.getEntityInAnyDimension(rid) instanceof Mob mob
&& mob != target && mob.isAlive() && !mob.hasEffect(commanderEffect)) {
mob.setTarget(target);
}
}
}
private void clearBond(UUID providerId, MinecraftServer server) {
Bond bond = active.remove(providerId);
if (bond == null) return;
ServerLevel level = server.getLevel(bond.dimension());
if (level != null) {
for (UUID rid : bond.recipients()) {
if (level.getEntityInAnyDimension(rid) instanceof LivingEntity recipient) {
for (Holder<MobEffect> buff : bond.buffs()) {
recipient.removeEffect(buff);
}
}
}
if (level.getEntityInAnyDimension(providerId) instanceof LivingEntity provider) {
provider.removeEffect(MobEffects.GLOWING);
}
}
// 非玩家实体的记分板名即 UUID 串,无需实体在场即可退队
Scoreboard scoreboard = server.getScoreboard();
String name = providerId.toString();
PlayerTeam team = scoreboard.getPlayerTeam(TEAM_NAME);
if (team != null && team.equals(scoreboard.getPlayersTeam(name))) {
scoreboard.removePlayerFromTeam(name, team);
}
}
private void drawTethers(MinecraftServer server) {
if (active.isEmpty()) return;
var dust = new DustParticleOptions(PURPLE, 1.0F);
for (var entry : active.entrySet()) {
Bond bond = entry.getValue();
ServerLevel level = server.getLevel(bond.dimension());
if (level == null) continue;
if (!(level.getEntityInAnyDimension(entry.getKey()) instanceof LivingEntity provider)) continue;
Vec3 from = provider.position().add(0, provider.getBbHeight() * 0.5, 0);
for (UUID rid : bond.recipients()) {
if (level.getEntityInAnyDimension(rid) instanceof LivingEntity recipient) {
Vec3 to = recipient.position().add(0, recipient.getBbHeight() * 0.5, 0);
drawLine(level, dust, from, to);
}
}
}
}
private static void drawLine(ServerLevel level, DustParticleOptions dust, Vec3 a, Vec3 b) {
Vec3 delta = b.subtract(a);
int points = Math.max(2, (int) (delta.length() * 3));
for (int i = 0; i <= points; i++) {
Vec3 p = a.add(delta.scale((double) i / points));
level.sendParticles(dust, p.x, p.y, p.z, 1, 0.0, 0.0, 0.0, 0.0);
}
}
private static List<Holder<MobEffect>> pickBuffs(UUID id) {
// 按提供者 UUID 稳定地从 3 种里去掉 1 种,保留 2 种
List<Holder<MobEffect>> chosen = new ArrayList<>(BUFF_POOL);
chosen.remove(Math.floorMod(id.hashCode(), BUFF_POOL.size()));
return chosen;
}
private static PlayerTeam getOrCreateTeam(Scoreboard scoreboard) {
PlayerTeam team = scoreboard.getPlayerTeam(TEAM_NAME);
if (team == null) {
team = scoreboard.addPlayerTeam(TEAM_NAME);
team.setColor(Optional.of(TeamColor.LIGHT_PURPLE));
}
return team;
}
}
@@ -0,0 +1,90 @@
package io.ib67.sfcraft.module.game.crystal;
import com.mojang.serialization.MapCodec;
import eu.pb4.polymer.core.api.block.PolymerBlock;
import net.fabricmc.fabric.api.networking.v1.context.PacketContext;
import net.minecraft.core.BlockPos;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.RespawnAnchorBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.IntegerProperty;
import net.minecraft.world.phys.BlockHitResult;
import org.jetbrains.annotations.Nullable;
import java.util.function.BiFunction;
public class GravityCrystalBlock extends BaseEntityBlock implements PolymerBlock {
public static final IntegerProperty CHARGE = RespawnAnchorBlock.CHARGE;
public static final MapCodec<GravityCrystalBlock> CODEC = simpleCodec(GravityCrystalBlock::new);
// GravityCrystalModule 注册方块实体类型后回填(方块与其方块实体类型互相引用,无法在构造器闭合)
private BlockEntityType<GravityCrystalBlockEntity> blockEntityType;
private BiFunction<BlockPos, BlockState, GravityCrystalBlockEntity> blockEntityFactory;
public GravityCrystalBlock(Properties properties) {
super(properties);
registerDefaultState(getStateDefinition().any().setValue(CHARGE, 0));
}
void wireBlockEntity(BlockEntityType<GravityCrystalBlockEntity> type,
BiFunction<BlockPos, BlockState, GravityCrystalBlockEntity> factory) {
this.blockEntityType = type;
this.blockEntityFactory = factory;
}
@Override
protected MapCodec<? extends BaseEntityBlock> codec() {
return CODEC;
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(CHARGE);
}
@Override
public BlockState getPolymerBlockState(BlockState state, PacketContext context) {
return Blocks.RESPAWN_ANCHOR.defaultBlockState()
.setValue(RespawnAnchorBlock.CHARGE, state.getValue(CHARGE));
}
@Override
public @Nullable BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return blockEntityFactory.apply(pos, state);
}
@Override
public @Nullable <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
if (level.isClientSide()) return null;
return createTickerHelper(type, blockEntityType, GravityCrystalBlockEntity::tick);
}
@Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (!stack.is(Items.AMETHYST_SHARD)) return InteractionResult.PASS;
int charge = state.getValue(CHARGE);
if (charge >= RespawnAnchorBlock.MAX_CHARGES) return InteractionResult.FAIL;
if (level.isClientSide()) return InteractionResult.SUCCESS;
level.setBlock(pos, state.setValue(CHARGE, charge + 1), Block.UPDATE_ALL);
if (!player.hasInfiniteMaterials()) {
stack.shrink(1);
}
level.playSound(null, pos, SoundEvents.RESPAWN_ANCHOR_CHARGE, SoundSource.BLOCKS, 1.0F, 1.0F);
return InteractionResult.SUCCESS_SERVER;
}
}
@@ -0,0 +1,78 @@
package io.ib67.sfcraft.module.game.crystal;
import io.ib67.sfcraft.config.GameConfig;
import io.ib67.sfcraft.config.GameConfigService;
import net.minecraft.core.BlockPos;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
public class GravityCrystalBlockEntity extends BlockEntity {
// 静止判定阈值(纯实现细节);数值类参数见 GameConfig.gravityCrystal
private static final double MIN_SPEED_SQ = 1.0e-4;
private static final int PARTICLE_INTERVAL = 5;
private final GameConfigService config;
public GravityCrystalBlockEntity(BlockEntityType<GravityCrystalBlockEntity> type,
GameConfigService config,
BlockPos pos, BlockState state) {
super(type, pos, state);
this.config = config;
}
public static void tick(Level level, BlockPos pos, BlockState state, GravityCrystalBlockEntity crystal) {
if (!(level instanceof ServerLevel serverLevel)) return;
int charge = state.getValue(GravityCrystalBlock.CHARGE);
if (charge <= 0) return;
long time = level.getGameTime();
var config = crystal.config.get().gravityCrystal;
if (time % Math.max(1, config.boostIntervalTicks) == 0) {
boostEntities(serverLevel, pos, charge, config);
}
if (time % PARTICLE_INTERVAL == 0) {
sendConvergingParticles(serverLevel, pos, config);
}
}
private static void boostEntities(ServerLevel level, BlockPos pos, int charge, GameConfig.GravityCrystal config) {
if (config.boostPerCharge.length == 0) return; // 配置为空数组时不加速,避免下方索引越界
int index = Math.min(charge, config.boostPerCharge.length - 1);
double factor = 1.0 + config.boostPerCharge[index];
double maxSpeed = config.maxSpeedMetersPerSecond / 20.0;
double maxSpeedSq = maxSpeed * maxSpeed;
for (var entity : level.getEntitiesOfClass(LivingEntity.class, new AABB(pos).inflate(config.range))) {
var velocity = entity.getDeltaMovement();
double speedSq = velocity.lengthSqr();
if (speedSq < MIN_SPEED_SQ || speedSq >= maxSpeedSq) continue;
var boosted = velocity.scale(factor);
if (boosted.lengthSqr() > maxSpeedSq) {
boosted = boosted.normalize().scale(maxSpeed);
}
entity.setDeltaMovement(boosted);
entity.hurtMarked = true;
}
}
private static void sendConvergingParticles(ServerLevel level, BlockPos pos, GameConfig.GravityCrystal config) {
var center = Vec3.atCenterOf(pos);
var random = level.getRandom();
for (int i = 0; i < 3; i++) {
double angle = random.nextDouble() * Math.PI * 2;
double distance = 3.0 + random.nextDouble() * Math.max(0.5, config.range - 3.0);
double x = center.x + Math.cos(angle) * distance;
double y = center.y + (random.nextDouble() - 0.3) * 3.0;
double z = center.z + Math.sin(angle) * distance;
var direction = center.subtract(x, y, z).normalize();
// count=0 时 dx/dy/dz 为粒子速度方向,粒子朝水晶本体飞行
level.sendParticles(ParticleTypes.ENCHANT, x, y, z, 0,
direction.x, direction.y, direction.z, 2.0);
}
}
}
@@ -0,0 +1,95 @@
package io.ib67.sfcraft.module.game.crystal;
import com.google.inject.Inject;
import eu.pb4.polymer.core.api.item.PolymerBlockItem;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.callback.SFConfigReload;
import io.ib67.sfcraft.config.GameConfigService;
import io.ib67.sfcraft.module.game.RegistryHelper;
import io.ib67.sfcraft.registry.CauldronRecipeRegistry;
import io.ib67.sfcraft.registry.cauldron.CauldronRecipe;
import lombok.Getter;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import java.util.ArrayList;
import java.util.List;
/**
* 重力水晶:用紫水晶碎片充能,加速范围内生物的移动。
*/
@Getter
public class GravityCrystalModule extends ServerModule {
@Inject
private GameConfigService config;
@Inject
private CauldronRecipeRegistry cauldronRecipes;
private GravityCrystalBlock crystalBlock;
private BlockEntityType<GravityCrystalBlockEntity> crystalBlockEntity;
private Item crystalItem;
private final List<CauldronRecipe> registeredRecipes = new ArrayList<>();
@Override
public void onInitialize() {
crystalBlock = RegistryHelper.registerBlock(
"gravity_crystal",
GravityCrystalBlock::new,
BlockBehaviour.Properties.of()
.strength(50.0F, 1200.0F)
.sound(SoundType.AMETHYST)
.lightLevel(state -> Math.min(15, state.getValue(GravityCrystalBlock.CHARGE) * 4))
);
crystalBlockEntity = RegistryHelper.registerBlockEntity(
"gravity_crystal",
(pos, state) -> new GravityCrystalBlockEntity(crystalBlockEntity, config, pos, state),
crystalBlock
);
crystalBlock.wireBlockEntity(crystalBlockEntity,
(pos, state) -> new GravityCrystalBlockEntity(crystalBlockEntity, config, pos, state));
crystalItem = RegistryHelper.registerItem(
"gravity_crystal",
properties -> new PolymerBlockItem(crystalBlock, properties, Items.RESPAWN_ANCHOR),
new Item.Properties()
.component(DataComponents.ENCHANTMENT_GLINT_OVERRIDE, true)
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.gravity_crystal"))
);
registerRecipes();
// /sfcraft reload 后重建配方,应用最新的 reactionTicks
SFConfigReload.EVENT.register(() -> {
registeredRecipes.forEach(cauldronRecipes::unregister);
registeredRecipes.clear();
registerRecipes();
});
}
private void registerRecipes() {
// 紫水晶炼药锅里丢入: 羽毛 + 重生锚 + 回响碎片(催化剂),加热熔炼 → 重力水晶
register(new CauldronRecipe(
"gravity_crystal",
List.of(
stack -> stack.is(Items.FEATHER),
stack -> stack.is(Items.RESPAWN_ANCHOR),
stack -> stack.is(Items.ECHO_SHARD)),
false,
true,
ParticleTypes.REVERSE_PORTAL,
config.get().cauldron.reactionTicks,
contents -> new ItemStack(crystalItem)));
}
private void register(CauldronRecipe recipe) {
registeredRecipes.add(recipe);
cauldronRecipes.register(recipe);
}
}
@@ -0,0 +1,20 @@
package io.ib67.sfcraft.module.game.item;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.util.CommonColors;
/**
* custom item 的 lore 行工厂。lore 渲染的基样式是紫色斜体(ItemLore.LORE_STYLE),
* 不覆盖颜色/斜体会很难看,统一从这里出灰色非斜体的行。
*/
public final class ItemLores {
private ItemLores() {
}
public static MutableComponent line(String key, Object... args) {
return Component.translatable(key, args)
.withColor(CommonColors.GRAY)
.withStyle(style -> style.withItalic(false));
}
}
@@ -0,0 +1,20 @@
package io.ib67.sfcraft.module.game.item;
import eu.pb4.polymer.core.api.item.PolymerItem;
import net.fabricmc.fabric.api.networking.v1.context.PacketContext;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
public class SimplePolymerItem extends Item implements PolymerItem {
private final Item polymerItem;
public SimplePolymerItem(Properties properties, Item polymerItem) {
super(properties);
this.polymerItem = polymerItem;
}
@Override
public Item getPolymerItem(ItemStack stack, PacketContext context) {
return polymerItem;
}
}
@@ -0,0 +1,105 @@
package io.ib67.sfcraft.module.game.lunchbox;
import eu.pb4.polymer.core.api.item.PolymerItem;
import net.fabricmc.fabric.api.networking.v1.context.PacketContext;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.ItemContainerContents;
import net.minecraft.world.level.Level;
public class LunchBoxItem extends Item implements PolymerItem {
private static final int NO_FOOD = -1;
private static final int NOT_HUNGRY = -2;
public LunchBoxItem(Properties properties) {
super(properties);
}
@Override
public Item getPolymerItem(ItemStack stack, PacketContext context) {
// 碗:非方块(不会出现放置鬼影),且没有自定义 use 行为,
// 客户端右键会走 consumable 组件路径,进食动画预测完整保留
return Items.BOWL;
}
@Override
public ItemStack getPolymerItemStack(ItemStack stack, TooltipFlag tooltipFlag, PacketContext context, HolderLookup.Provider lookup) {
var clientStack = PolymerItem.super.getPolymerItemStack(stack, tooltipFlag, context, lookup);
// 把 food/consumable 组件带给原版客户端,右键时就会播放原版进食动画
var food = stack.get(DataComponents.FOOD);
var consumable = stack.get(DataComponents.CONSUMABLE);
if (food != null) clientStack.set(DataComponents.FOOD, food);
if (consumable != null) clientStack.set(DataComponents.CONSUMABLE, consumable);
return clientStack;
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
var stack = player.getItemInHand(hand);
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResult.SUCCESS_SERVER;
if (player.isShiftKeyDown()) {
serverPlayer.openMenu(new SimpleMenuProvider(
(containerId, inventory, p) -> new LunchBoxMenu(containerId, inventory, stack),
Component.translatable("container.sfcraft.lunch_box")));
return InteractionResult.SUCCESS_SERVER;
}
int slot = findEdibleSlot(serverPlayer, loadContents(stack));
if (slot == NO_FOOD) {
serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.lunch_box.empty"));
return InteractionResult.FAIL;
}
if (slot == NOT_HUNGRY) {
serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.lunch_box.not_hungry"));
return InteractionResult.FAIL;
}
var consumable = stack.get(DataComponents.CONSUMABLE);
if (consumable == null) return InteractionResult.FAIL;
// 走原版进食流程(动画+咀嚼音效),结束后进入下方 finishUsingItem
return consumable.startConsuming(player, stack, hand);
}
@Override
public ItemStack finishUsingItem(ItemStack stack, Level level, LivingEntity entity) {
if (!(entity instanceof ServerPlayer player)) return stack;
var items = loadContents(stack);
int slot = findEdibleSlot(player, items);
if (slot >= 0) {
// finishUsingItem 应用盒内那份食物的营养/饱和/效果/余留物(碗、瓶留回格子)
var remainder = items.get(slot).finishUsingItem(level, player);
items.set(slot, remainder);
stack.set(DataComponents.CONTAINER, ItemContainerContents.fromItems(items));
}
return stack;
}
private static NonNullList<ItemStack> loadContents(ItemStack lunchBox) {
NonNullList<ItemStack> items = NonNullList.withSize(LunchBoxMenu.SIZE, ItemStack.EMPTY);
lunchBox.getOrDefault(DataComponents.CONTAINER, ItemContainerContents.EMPTY).copyInto(items);
return items;
}
private static int findEdibleSlot(ServerPlayer player, NonNullList<ItemStack> items) {
boolean hasFood = false;
for (int i = 0; i < items.size(); i++) {
var food = items.get(i);
if (food.isEmpty() || food.getItem() instanceof LunchBoxItem) continue;
var foodProperties = food.get(DataComponents.FOOD);
if (foodProperties == null) continue;
hasFood = true;
if (player.canEat(foodProperties.canAlwaysEat())) return i;
}
return hasFood ? NOT_HUNGRY : NO_FOOD;
}
}
@@ -0,0 +1,87 @@
package io.ib67.sfcraft.module.game.lunchbox;
import net.minecraft.core.NonNullList;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.ItemContainerContents;
/**
* 午餐盒的 9 格容器菜单(GENERIC_9x1,原版客户端原生支持)。
* 只允许放入带 food 组件的物品;每次变动立即回写到物品的 container 组件,防止刷物品。
*/
public class LunchBoxMenu extends AbstractContainerMenu {
public static final int SIZE = 9;
private final ItemStack lunchBox;
private final SimpleContainer container;
public LunchBoxMenu(int containerId, Inventory playerInventory, ItemStack lunchBox) {
super(MenuType.GENERIC_9x1, containerId);
this.lunchBox = lunchBox;
this.container = new SimpleContainer(SIZE) {
@Override
public void setChanged() {
super.setChanged();
lunchBox.set(DataComponents.CONTAINER, ItemContainerContents.fromItems(getItems()));
}
};
NonNullList<ItemStack> loaded = NonNullList.withSize(SIZE, ItemStack.EMPTY);
lunchBox.getOrDefault(DataComponents.CONTAINER, ItemContainerContents.EMPTY).copyInto(loaded);
for (int i = 0; i < SIZE; i++) {
container.setItem(i, loaded.get(i));
}
for (int col = 0; col < SIZE; col++) {
addSlot(new Slot(container, col, 8 + col * 18, 18) {
@Override
public boolean mayPlace(ItemStack stack) {
return isFood(stack);
}
});
}
for (int row = 0; row < 3; row++) {
for (int col = 0; col < 9; col++) {
addSlot(new Slot(playerInventory, col + row * 9 + 9, 8 + col * 18, 49 + row * 18));
}
}
for (int col = 0; col < 9; col++) {
addSlot(new Slot(playerInventory, col, 8 + col * 18, 107));
}
}
@Override
public ItemStack quickMoveStack(Player player, int index) {
var slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
var stackInSlot = slot.getItem();
var original = stackInSlot.copy();
if (index < SIZE) {
if (!moveItemStackTo(stackInSlot, SIZE, slots.size(), true)) return ItemStack.EMPTY;
} else {
if (!isFood(stackInSlot)) return ItemStack.EMPTY;
if (!moveItemStackTo(stackInSlot, 0, SIZE, false)) return ItemStack.EMPTY;
}
if (stackInSlot.isEmpty()) {
slot.setByPlayer(ItemStack.EMPTY);
} else {
slot.setChanged();
}
return original;
}
@Override
public boolean stillValid(Player player) {
return player.getMainHandItem() == lunchBox || player.getOffhandItem() == lunchBox;
}
// 午餐盒自身也带 food 组件(为了进食动画),要排除掉,防止套娃
private static boolean isFood(ItemStack stack) {
return stack.has(DataComponents.FOOD) && !(stack.getItem() instanceof LunchBoxItem);
}
}
@@ -0,0 +1,34 @@
package io.ib67.sfcraft.module.game.lunchbox;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.module.game.RegistryHelper;
import lombok.Getter;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.food.FoodProperties;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.component.Consumables;
/**
* 午餐盒:随身 9 格食物容器,右键直接吃盒内的食物,Shift+右键打开盒子。
*/
@Getter
public class LunchBoxModule extends ServerModule {
private Item lunchBox;
@Override
public void onInitialize() {
lunchBox = RegistryHelper.registerItem(
"lunch_box",
LunchBoxItem::new,
new Item.Properties()
.stacksTo(1)
.modelId(Identifier.fromNamespaceAndPath("sfcraft", "item/lunch_box"))
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.lunch_box"))
// 0 营养 + canAlwaysEat:仅用于触发原版进食流程,真正的营养来自盒内食物
.component(DataComponents.FOOD, new FoodProperties(0, 0.0F, true))
.component(DataComponents.CONSUMABLE, Consumables.DEFAULT_FOOD)
);
}
}
@@ -0,0 +1,20 @@
package io.ib67.sfcraft.module.game.mount;
import java.util.UUID;
/**
* 由 Ravager / Hoglin 的 mixin 实现的鸭子接口,承载驯服坐骑的持久状态。
*/
public interface MountAccess {
UUID sfcraft$getOwner();
void sfcraft$setOwner(UUID owner);
boolean sfcraft$isSaddled();
void sfcraft$setSaddled(boolean saddled);
int sfcraft$getDashTicks();
void sfcraft$setDashTicks(int ticks);
}
@@ -0,0 +1,182 @@
package io.ib67.sfcraft.module.game.mount;
import io.ib67.sfcraft.SFCraft;
import io.ib67.sfcraft.config.GameConfig;
import io.ib67.sfcraft.config.GameConfigService;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.monster.Ravager;
import net.minecraft.world.entity.player.Input;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.raid.Raider;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.Vec3;
import java.util.Set;
import java.util.UUID;
/**
* 驯服坐骑的共享逻辑,供 RavagerMountMixin / HoglinMountMixin 与 {@link MountModule} 复用。
* mixin 无法参与依赖注入,故本类保持静态工具形式,配置经 {@link SFCraft#getInjector()} 惰性获取。
*/
public final class MountLogic {
private static GameConfigService configService;
private MountLogic() {
}
private static GameConfig.Mount config() {
var service = configService;
if (service == null) {
configService = service = SFCraft.getInjector().getInstance(GameConfigService.class);
}
return service.get().mount;
}
// ---- 骑乘移动(mixin 覆写 LivingEntity 骑乘方法后委托到此) ----
public static LivingEntity controllingPassenger(Mob mob, MountAccess mount) {
if (mount.sfcraft$getOwner() == null) return null;
var config = config();
if (config.requireSaddleToRide && !mount.sfcraft$isSaddled()) return null;
if (mob.getFirstPassenger() instanceof Player player
&& player.getUUID().equals(mount.sfcraft$getOwner())) {
return player;
}
return null;
}
public static Vec3 riddenInput(MountAccess mount, Player player) {
// 纯服务端坐骑:玩家 xxa/zza 在服务端恒为 0,须从输入包(getLastClientInput)读转向
float strafe = 0.0F;
float forward = 0.0F;
if (player instanceof ServerPlayer serverPlayer) {
Input input = serverPlayer.getLastClientInput();
strafe = (input.left() ? 1.0F : 0.0F) - (input.right() ? 1.0F : 0.0F);
forward = (input.forward() ? 1.0F : 0.0F) - (input.backward() ? 1.0F : 0.0F);
}
if (mount.sfcraft$getDashTicks() > 0) forward = 1.0F; // 冲刺:强制全速前进
strafe *= 0.5F;
if (forward <= 0.0F) forward *= 0.25F;
return new Vec3(strafe, 0.0, forward);
}
public static float riddenSpeed(Mob mob, MountAccess mount) {
var config = config();
double speed = mob.getAttributeValue(Attributes.MOVEMENT_SPEED) * config.rideSpeedFactor;
if (mount.sfcraft$getDashTicks() > 0) speed *= config.ravagerDash.speedMultiplier;
return (float) speed;
}
public static void tickRidden(Mob mob, MountAccess mount, Player player) {
// 朝向对齐骑手视线
mob.setYRot(player.getYRot());
mob.yRotO = mob.getYRot();
mob.setXRot(player.getXRot() * 0.5F);
mob.setYBodyRot(mob.getYRot());
mob.setYHeadRot(mob.getYRot());
if (mount.sfcraft$getDashTicks() > 0) {
mount.sfcraft$setDashTicks(mount.sfcraft$getDashTicks() - 1);
}
mob.setTarget(null);
}
/** 每 tick 的 AI 抑制:骑乘时清目标;已驯服则不再以任何玩家为目标(不止是主人)。 */
public static void tickAiSuppression(Mob mob, MountAccess mount) {
if (mob.getControllingPassenger() instanceof Player) {
mob.setTarget(null);
return;
}
if (mount.sfcraft$getOwner() != null && mob.getTarget() instanceof Player) {
mob.setTarget(null);
}
}
/** 是否应拦截这次攻击:骑乘中一律拦截;已驯服则永不攻击任何玩家。 */
public static boolean blocksAttack(Mob mob, MountAccess mount, Entity target) {
if (mob.hasControllingPassenger()) return true;
return mount.sfcraft$getOwner() != null && target instanceof Player;
}
/** 劫掠兽冲刺期间撞到的生物受到一次性撞击伤害,同一次冲刺内每个生物只命中一次。 */
public static void tickRavagerDashCollision(Ravager ravager, MountAccess mount, Set<UUID> hitThisDash) {
if (mount.sfcraft$getDashTicks() <= 0) {
hitThisDash.clear();
return;
}
if (!(ravager.level() instanceof ServerLevel serverLevel)) return;
double damage = config().ravagerDash.collisionDamage;
var box = ravager.getBoundingBox().inflate(0.2);
for (LivingEntity victim : serverLevel.getEntitiesOfClass(LivingEntity.class, box,
e -> e != ravager && !ravager.hasPassenger(e))) {
if (!hitThisDash.add(victim.getUUID())) continue;
victim.hurtServer(serverLevel, ravager.damageSources().mobAttack(ravager), (float) damage);
}
}
// ---- 持久化 ----
public static void save(ValueOutput out, MountAccess mount) {
if (mount.sfcraft$getOwner() != null) {
out.putString("sfcraft_owner", mount.sfcraft$getOwner().toString());
}
out.putBoolean("sfcraft_saddled", mount.sfcraft$isSaddled());
}
public static void load(ValueInput in, MountAccess mount) {
String owner = in.getStringOr("sfcraft_owner", "");
mount.sfcraft$setOwner(owner.isEmpty() ? null : parseUuid(owner));
mount.sfcraft$setSaddled(in.getBooleanOr("sfcraft_saddled", false));
}
private static UUID parseUuid(String s) {
try {
return UUID.fromString(s);
} catch (IllegalArgumentException e) {
return null;
}
}
// ---- 驯服前置条件 ----
/** 返回 null 表示可驯服,否则返回失败原因(用于反馈)。 */
public static Component tameBlockReason(Mob mob) {
if (!mob.hasEffect(MobEffects.WEAKNESS)) {
return Component.translatable("message.sfcraft.mount.need_weakness");
}
if (!(mob.level() instanceof ServerLevel level)) {
return Component.translatable("message.sfcraft.mount.cannot_here");
}
if (level.getRaidAt(mob.blockPosition()) != null) {
return Component.translatable("message.sfcraft.mount.raid_active");
}
if (mob instanceof Raider raider && raider.hasActiveRaid()) {
return Component.translatable("message.sfcraft.mount.raid_active");
}
var box = mob.getBoundingBox().inflate(config().tameNearbyRaiderRadius);
for (Raider other : level.getEntitiesOfClass(Raider.class, box)) {
if (other != mob) return Component.translatable("message.sfcraft.mount.raider_nearby");
}
return null;
}
public static Item resolveItem(String id, Item fallback) {
try {
return BuiltInRegistries.ITEM.get(Identifier.parse(id))
.map(Holder.Reference::value).orElse(fallback);
} catch (RuntimeException e) {
return fallback;
}
}
}
@@ -0,0 +1,141 @@
package io.ib67.sfcraft.module.game.mount;
import com.google.inject.Inject;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.config.GameConfigService;
import net.fabricmc.fabric.api.event.player.UseEntityCallback;
import net.fabricmc.fabric.api.event.player.UseItemCallback;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.monster.Ravager;
import net.minecraft.world.entity.monster.hoglin.Hoglin;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.EntityHitResult;
/**
* 驯服坐骑的交互入口:
* <ul>
* <li>{@link UseEntityCallback}:喂食驯服 / 上鞍 / 骑乘</li>
* <li>{@link UseItemCallback}:骑乘劫掠兽时用胡萝卜钓竿向前冲刺</li>
* </ul>
*/
public class MountModule extends ServerModule {
@Inject
private GameConfigService config;
@Override
public void onInitialize() {
UseEntityCallback.EVENT.register(this::onUseEntity);
UseItemCallback.EVENT.register(this::onUseItem);
}
private InteractionResult onUseEntity(Player player, Level level, InteractionHand hand,
Entity entity, EntityHitResult hit) {
if (level.isClientSide() || hand != InteractionHand.MAIN_HAND) return InteractionResult.PASS;
if (!(entity instanceof MountAccess mount) || !(entity instanceof Mob mob)) return InteractionResult.PASS;
var mountConfig = config.get().mount;
ItemStack held = player.getItemInHand(hand);
// 未驯服:喂食驯服(需满足虚弱/无掠夺者/非袭击)
if (mount.sfcraft$getOwner() == null) {
Item food = MountLogic.resolveItem(
mob instanceof Hoglin ? mountConfig.hoglinTameFood : mountConfig.ravagerTameFood, Items.GOLDEN_APPLE);
if (!held.is(food)) return InteractionResult.PASS;
Component reason = MountLogic.tameBlockReason(mob);
if (reason != null) {
if (player instanceof ServerPlayer serverPlayer) serverPlayer.sendOverlayMessage(reason);
return InteractionResult.FAIL;
}
// 喂食消耗;仅有一定概率(默认 20%)驯服成功
if (!player.getAbilities().instabuild) held.consume(1, player);
if (mob.getRandom().nextFloat() < mountConfig.tameSuccessChance) {
mount.sfcraft$setOwner(player.getUUID());
mob.setPersistenceRequired();
celebrate(mob);
} else {
tameFailed(mob);
if (player instanceof ServerPlayer serverPlayer) {
serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.mount.tame_failed"));
}
}
return InteractionResult.SUCCESS_SERVER;
}
// 已驯服:仅主人可交互
if (!player.getUUID().equals(mount.sfcraft$getOwner())) return InteractionResult.PASS;
// 上鞍
if (!mount.sfcraft$isSaddled() && held.is(Items.SADDLE)) {
mount.sfcraft$setSaddled(true);
if (!player.getAbilities().instabuild) held.consume(1, player);
level.playSound(null, mob.getX(), mob.getY(), mob.getZ(),
SoundEvents.HORSE_SADDLE, SoundSource.NEUTRAL, 1.0F, 1.0F);
return InteractionResult.SUCCESS_SERVER;
}
// 骑乘:空手右键(需已上鞍,若配置要求)
boolean canRide = !mountConfig.requireSaddleToRide || mount.sfcraft$isSaddled();
if (canRide && held.isEmpty() && !player.isSecondaryUseActive()) {
player.startRiding(mob);
return InteractionResult.SUCCESS_SERVER;
}
return InteractionResult.PASS;
}
private InteractionResult onUseItem(Player player, Level level, InteractionHand hand) {
if (level.isClientSide()) return InteractionResult.PASS;
ItemStack held = player.getItemInHand(hand);
if (!held.is(Items.CARROT_ON_A_STICK)) return InteractionResult.PASS;
// 仅骑乘已驯服劫掠兽时冲刺
if (!(player.getVehicle() instanceof Ravager ravager) || !(player.getVehicle() instanceof MountAccess mount)) {
return InteractionResult.PASS;
}
if (!player.getUUID().equals(mount.sfcraft$getOwner())) return InteractionResult.PASS;
if (player.getCooldowns().isOnCooldown(held)) return InteractionResult.PASS;
var dash = config.get().mount.ravagerDash;
// 冲刺 = 冲刺窗口内强制全速前进 + 提速(见 MountLogic.riddenInput / riddenSpeed);
// 不能用一次性速度脉冲,因为骑乘 travel() 每 tick 会覆盖 deltaMovement
mount.sfcraft$setDashTicks(dash.boostTicks);
if (level instanceof ServerLevel serverLevel) {
serverLevel.sendParticles(ParticleTypes.CLOUD,
ravager.getX(), ravager.getY() + 0.3, ravager.getZ(), 12, 0.3, 0.1, 0.3, 0.1);
serverLevel.playSound(null, ravager.getX(), ravager.getY(), ravager.getZ(),
SoundEvents.RAVAGER_ROAR, SoundSource.NEUTRAL, 0.6F, 1.4F);
}
player.getCooldowns().addCooldown(held, dash.cooldownTicks);
if (dash.durabilityCost > 0) held.hurtAndBreak(dash.durabilityCost, player, hand);
return InteractionResult.SUCCESS_SERVER;
}
private static void celebrate(Mob mob) {
if (mob.level() instanceof ServerLevel serverLevel) {
serverLevel.sendParticles(ParticleTypes.HEART,
mob.getX(), mob.getY() + mob.getBbHeight(), mob.getZ(), 7, 0.4, 0.4, 0.4, 0.1);
serverLevel.playSound(null, mob.getX(), mob.getY(), mob.getZ(),
SoundEvents.PLAYER_LEVELUP, SoundSource.NEUTRAL, 0.7F, 1.4F);
}
}
private static void tameFailed(Mob mob) {
if (mob.level() instanceof ServerLevel serverLevel) {
serverLevel.sendParticles(ParticleTypes.SMOKE,
mob.getX(), mob.getY() + mob.getBbHeight(), mob.getZ(), 7, 0.3, 0.3, 0.3, 0.02);
serverLevel.playSound(null, mob.getX(), mob.getY(), mob.getZ(),
SoundEvents.VILLAGER_NO, SoundSource.NEUTRAL, 0.8F, 1.0F);
}
}
}
@@ -0,0 +1,110 @@
package io.ib67.sfcraft.module.game.token;
import eu.pb4.polymer.core.api.item.PolymerItem;
import io.ib67.sfcraft.module.game.item.ItemLores;
import net.fabricmc.fabric.api.networking.v1.context.PacketContext;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
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.ItemLore;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Set;
import java.util.UUID;
public class PearlTokenItem extends Item implements PolymerItem {
public static final String OWNER_KEY = "sfcraft_owner";
public static final String OWNER_NAME_KEY = "sfcraft_owner_name";
public PearlTokenItem(Properties properties) {
super(properties);
}
@Override
public Item getPolymerItem(ItemStack stack, PacketContext context) {
return Items.WARPED_FUNGUS_ON_A_STICK;
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
var stack = player.getItemInHand(hand);
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResult.SUCCESS_SERVER;
var ownerId = getOwnerId(stack);
if (ownerId == null) {
bindOne(serverPlayer, hand, stack);
return InteractionResult.SUCCESS_SERVER;
}
var server = ((ServerLevel) level).getServer();
var owner = server.getPlayerList().getPlayer(ownerId);
if (owner == null) {
serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.token.owner_offline", getOwnerName(stack)));
return InteractionResult.FAIL;
}
var targetLevel = (ServerLevel) serverPlayer.level();
owner.teleportTo(targetLevel, serverPlayer.getX(), serverPlayer.getY(), serverPlayer.getZ(),
Set.of(), owner.getYRot(), owner.getXRot(), false);
targetLevel.sendParticles(ParticleTypes.PORTAL,
serverPlayer.getX(), serverPlayer.getY() + 1.0, serverPlayer.getZ(), 32, 0.5, 1.0, 0.5, 0.2);
targetLevel.playSound(null, serverPlayer.getX(), serverPlayer.getY(), serverPlayer.getZ(),
SoundEvents.ENDERMAN_TELEPORT, SoundSource.PLAYERS, 1.0F, 1.0F);
owner.sendSystemMessage(Component.translatable("message.sfcraft.token.summoned_owner",
serverPlayer.getGameProfile().name()));
serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.token.summon_success", getOwnerName(stack)));
stack.consume(1, serverPlayer);
return InteractionResult.SUCCESS_SERVER;
}
private static void bindOne(ServerPlayer player, InteractionHand hand, ItemStack stack) {
var bound = stack.copyWithCount(1);
CustomData.update(DataComponents.CUSTOM_DATA, bound, tag -> {
tag.putString(OWNER_KEY, player.getUUID().toString());
tag.putString(OWNER_NAME_KEY, player.getGameProfile().name());
});
bound.set(DataComponents.LORE, new ItemLore(List.of(
ItemLores.line("lore.sfcraft.token.owner", player.getGameProfile().name()))));
if (stack.getCount() == 1) {
player.setItemInHand(hand, bound);
} else {
stack.shrink(1);
if (!player.getInventory().add(bound)) {
player.drop(bound, false);
}
}
player.sendOverlayMessage(Component.translatable("message.sfcraft.token.bound"));
}
@Nullable
public static UUID getOwnerId(ItemStack stack) {
var data = stack.get(DataComponents.CUSTOM_DATA);
if (data == null) return null;
var raw = data.copyTag().getStringOr(OWNER_KEY, "");
if (raw.isEmpty()) return null;
try {
return UUID.fromString(raw);
} catch (IllegalArgumentException e) {
return null;
}
}
public static String getOwnerName(ItemStack stack) {
var data = stack.get(DataComponents.CUSTOM_DATA);
if (data == null) return "?";
return data.copyTag().getStringOr(OWNER_NAME_KEY, "?");
}
}
@@ -0,0 +1,118 @@
package io.ib67.sfcraft.module.game.token;
import eu.pb4.polymer.core.api.item.PolymerItem;
import io.ib67.sfcraft.config.GameConfigService;
import io.ib67.sfcraft.module.game.item.ItemLores;
import net.fabricmc.fabric.api.networking.v1.context.PacketContext;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
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.ItemLore;
import net.minecraft.world.level.Level;
import java.util.List;
import java.util.Set;
import java.util.UUID;
public class ReversePearlTokenItem extends Item implements PolymerItem {
// 冷却由本类自行实现:在 CUSTOM_DATA 内记录上次使用的时间戳
public static final String LAST_USE_KEY = "sfcraft_last_use";
private final GameConfigService config;
public ReversePearlTokenItem(Properties properties, GameConfigService config) {
super(properties);
this.config = config;
}
@Override
public Item getPolymerItem(ItemStack stack, PacketContext context) {
return Items.FISHING_ROD;
}
public ItemStack createBound(UUID ownerId, String ownerName) {
// 满耐久出品(damage 默认为 0),使用次数完全由耐久承载
var stack = new ItemStack(this);
CustomData.update(DataComponents.CUSTOM_DATA, stack, tag -> {
tag.putString(PearlTokenItem.OWNER_KEY, ownerId.toString());
tag.putString(PearlTokenItem.OWNER_NAME_KEY, ownerName);
});
updateLore(stack, ownerName);
return stack;
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
var stack = player.getItemInHand(hand);
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResult.SUCCESS_SERVER;
var ownerId = PearlTokenItem.getOwnerId(stack);
if (ownerId == null) {
serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.token.not_bound"));
return InteractionResult.FAIL;
}
var tokenConfig = config.get().reverseToken;
// 自实现冷却:比对上次使用时间戳,未到冷却时间则拒绝
var data = stack.get(DataComponents.CUSTOM_DATA);
var tag = data == null ? null : data.copyTag();
long now = System.currentTimeMillis();
long lastUse = tag == null ? 0 : tag.getLongOr(LAST_USE_KEY, 0L);
long remainingMs = tokenConfig.cooldownSeconds * 1000L - (now - lastUse);
if (remainingMs > 0) {
serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.token.cooldown",
remainingMs / 1000 + 1));
return InteractionResult.FAIL;
}
var server = ((ServerLevel) level).getServer();
var owner = server.getPlayerList().getPlayer(ownerId);
var ownerName = PearlTokenItem.getOwnerName(stack);
if (owner == null) {
serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.token.owner_offline", ownerName));
return InteractionResult.FAIL;
}
var targetLevel = (ServerLevel) owner.level();
serverPlayer.teleportTo(targetLevel, owner.getX(), owner.getY(), owner.getZ(),
Set.of(), serverPlayer.getYRot(), serverPlayer.getXRot(), false);
targetLevel.sendParticles(ParticleTypes.PORTAL,
owner.getX(), owner.getY() + 1.0, owner.getZ(), 32, 0.5, 1.0, 0.5, 0.2);
targetLevel.playSound(null, owner.getX(), owner.getY(), owner.getZ(),
SoundEvents.ENDERMAN_TELEPORT, SoundSource.PLAYERS, 1.0F, 1.0F);
owner.sendSystemMessage(Component.translatable("message.sfcraft.token.arrived",
serverPlayer.getGameProfile().name()));
// 记录本次使用时间戳(冷却),再扣除耐久(使用次数)
CustomData.update(DataComponents.CUSTOM_DATA, stack, t -> t.putLong(LAST_USE_KEY, now));
stack.hurtAndBreak(1, serverPlayer, hand);
if (stack.isEmpty()) {
serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.token.depleted"));
} else {
updateLore(stack, ownerName);
int remaining = stack.getMaxDamage() - stack.getDamageValue();
serverPlayer.sendOverlayMessage(Component.translatable("message.sfcraft.token.uses_left",
remaining, stack.getMaxDamage()));
}
return InteractionResult.SUCCESS_SERVER;
}
private static void updateLore(ItemStack stack, String ownerName) {
int max = stack.getMaxDamage();
int remaining = max - stack.getDamageValue();
stack.set(DataComponents.LORE, new ItemLore(List.of(
ItemLores.line("lore.sfcraft.token.owner", ownerName),
ItemLores.line("lore.sfcraft.token.uses", remaining, max))));
}
}
@@ -0,0 +1,92 @@
package io.ib67.sfcraft.module.game.token;
import com.google.inject.Inject;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.callback.SFConfigReload;
import io.ib67.sfcraft.config.GameConfigService;
import io.ib67.sfcraft.module.game.RegistryHelper;
import io.ib67.sfcraft.registry.CauldronRecipeRegistry;
import io.ib67.sfcraft.registry.cauldron.CauldronRecipe;
import lombok.Getter;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.particles.DustColorTransitionOptions;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import java.util.ArrayList;
import java.util.List;
/**
* 珍珠信物(召唤绑定者)与反向珍珠信物(传送到绑定者)。
* 反向信物由紫水晶炼药锅炼成,配方注册到 {@link CauldronRecipeRegistry},
* 并在 /sfcraft reload 时重建以应用最新的 reactionTicks。
*/
@Getter
public class TokenModule extends ServerModule {
@Inject
private GameConfigService config;
@Inject
private CauldronRecipeRegistry cauldronRecipes;
private Item pearlToken;
private ReversePearlTokenItem reversePearlToken;
private final List<CauldronRecipe> registeredRecipes = new ArrayList<>();
@Override
public void onInitialize() {
pearlToken = RegistryHelper.registerItem(
"pearl_token",
PearlTokenItem::new,
new Item.Properties()
.modelId(Identifier.fromNamespaceAndPath("sfcraft", "item/pearl_token"))
.stacksTo(16)
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.pearl_token"))
);
reversePearlToken = RegistryHelper.registerItem(
"reverse_pearl_token",
properties -> new ReversePearlTokenItem(properties, config),
new Item.Properties()
.modelId(Identifier.fromNamespaceAndPath("sfcraft", "item/pearl_token"))
.component(DataComponents.ENCHANTMENT_GLINT_OVERRIDE, true)
.durability(config.get().reverseToken.maxUses)
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.reverse_pearl_token"))
);
registerRecipes();
SFConfigReload.EVENT.register(() -> {
registeredRecipes.forEach(cauldronRecipes::unregister);
registeredRecipes.clear();
registerRecipes();
});
}
private void registerRecipes() {
// 锅里丢入: 已绑定的珍珠信物 + 回响碎片(催化剂) + 水 + 加热 → 反向珍珠信物
register(new CauldronRecipe(
"reverse_pearl_token",
List.of(
stack -> stack.is(pearlToken) && PearlTokenItem.getOwnerId(stack) != null,
stack -> stack.is(Items.ECHO_SHARD)),
true,
true,
new DustColorTransitionOptions(0x006D6D, 0x000000, 1.0F),
config.get().cauldron.reactionTicks,
contents -> {
var token = contents.stream()
.filter(stack -> stack.is(pearlToken))
.findFirst()
.orElse(ItemStack.EMPTY);
var ownerId = PearlTokenItem.getOwnerId(token);
if (ownerId == null) return ItemStack.EMPTY;
return reversePearlToken.createBound(ownerId, PearlTokenItem.getOwnerName(token));
}));
}
private void register(CauldronRecipe recipe) {
registeredRecipes.add(recipe);
cauldronRecipes.register(recipe);
}
}
@@ -0,0 +1,28 @@
package io.ib67.sfcraft.module.game.totem;
import net.minecraft.world.entity.player.Player;
/**
* 计算玩家的总经验点数。玩家的 {@code totalExperience} 字段有已知的不同步问题(如附魔消耗后不减),
* 这里改用 等级 + 进度条 精确重算,与原版经验条显示一致。
*/
public final class ExperienceHelper {
private ExperienceHelper() {
}
public static int total(Player player) {
return xpAtLevelStart(player.experienceLevel)
+ Math.round(player.experienceProgress * player.getXpNeededForNextLevel());
}
// 从 0 级累积到 level 级所需的总经验点数(原版分段公式)
private static int xpAtLevelStart(int level) {
if (level <= 16) {
return level * level + 6 * level;
}
if (level <= 31) {
return (int) (2.5 * level * level - 40.5 * level + 360);
}
return (int) (4.5 * level * level - 162.5 * level + 2220);
}
}
@@ -0,0 +1,146 @@
package io.ib67.sfcraft.module.game.totem;
import com.google.inject.Inject;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.callback.SFConfigReload;
import io.ib67.sfcraft.config.GameConfigService;
import io.ib67.sfcraft.module.game.RegistryHelper;
import io.ib67.sfcraft.module.game.item.SimplePolymerItem;
import io.ib67.sfcraft.registry.CauldronRecipeRegistry;
import io.ib67.sfcraft.registry.cauldron.CauldronRecipe;
import lombok.Getter;
import net.minecraft.advancements.triggers.CriteriaTriggers;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.tags.DamageTypeTags;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.DeathProtection;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.level.gameevent.GameEvent;
import java.util.ArrayList;
import java.util.List;
/**
* 「屹立不倒」——可反复使用的不死图腾变种。物品自带原版死亡保护组件
* ({@link DeathProtection#TOTEM_OF_UNDYING}),因此复用原版的清负面/回血/图腾动画/成就统计;
* 差异行为(可反复用、30 秒冷却、触发扣一半经验、经验不足则放弃)由 {@link #tryProtect}
* 在 {@code TotemProtectionMixin} 里接管原版判定后实现。
*/
@Getter
public class TotemModule extends ServerModule {
@Inject
private GameConfigService config;
@Inject
private CauldronRecipeRegistry cauldronRecipes;
private Item standingFirmTotem;
private final List<CauldronRecipe> registeredRecipes = new ArrayList<>();
/** 死亡保护判定结果,回传给 mixin 决定原版方法的返回值。 */
public enum Result {
/** 玩家没拿本图腾,交回原版逻辑。 */
NOT_APPLICABLE,
/** 已触发保护,玩家存活。 */
PROTECTED,
/** 拿了本图腾但条件不满足(冷却/经验不足),放弃治疗、玩家死亡且不消耗物品。 */
DECLINED
}
@Override
public void onInitialize() {
standingFirmTotem = RegistryHelper.registerItem(
"standing_firm_totem",
properties -> new SimplePolymerItem(properties, Items.TOTEM_OF_UNDYING),
new Item.Properties()
.stacksTo(1)
.modelId(Identifier.fromNamespaceAndPath("sfcraft", "item/standing_firm_totem"))
// 刻意不挂 DEATH_PROTECTION 组件:否则原版死亡保护会识别它、在本图腾失效(冷却/
// 经验不足)时把它当普通图腾消耗,或连带取消掉玩家另一只手里的原版图腾。
// 效果改由 mixin 完全接管,复用 DeathProtection.TOTEM_OF_UNDYING 常量(见 tryProtect)。
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.standing_firm_totem"))
);
registerRecipes();
// /sfcraft reload 后重建配方,应用最新的 reactionTicks
SFConfigReload.EVENT.register(() -> {
registeredRecipes.forEach(cauldronRecipes::unregister);
registeredRecipes.clear();
registerRecipes();
});
}
private void registerRecipes() {
// 紫水晶炼药锅里丢入: 不死图腾 + 附魔之瓶 + 绿宝石(催化剂),加热 → 屹立不倒
register(new CauldronRecipe(
"standing_firm_totem",
List.of(
stack -> stack.is(Items.TOTEM_OF_UNDYING),
stack -> stack.is(Items.EXPERIENCE_BOTTLE),
stack -> stack.is(Items.EMERALD)),
false,
true,
ParticleTypes.TOTEM_OF_UNDYING,
config.get().cauldron.reactionTicks,
contents -> new ItemStack(standingFirmTotem)));
}
private void register(CauldronRecipe recipe) {
registeredRecipes.add(recipe);
cauldronRecipes.register(recipe);
}
/**
* mixin 在 {@code checkTotemDeathProtection} 头部调用。玩家持有本图腾时完全接管本次判定,
* 阻止原版把它当普通图腾消耗。
*/
public Result tryProtect(ServerPlayer player, DamageSource source) {
ItemStack totem = findTotem(player);
if (totem == null) {
return Result.NOT_APPLICABLE;
}
// 穿透无敌的伤害(虚空、/kill 等)不救:交回原版,原版对此既不保护也不消耗
if (source.is(DamageTypeTags.BYPASSES_INVULNERABILITY)) {
return Result.NOT_APPLICABLE;
}
var settings = config.get().standingFirmTotem;
if (player.getCooldowns().isOnCooldown(totem)) {
return Result.DECLINED;
}
int totalXp = ExperienceHelper.total(player);
if (totalXp < settings.minExperiencePoints) {
return Result.DECLINED;
}
// 触发:扣经验 → 冷却 → 复用原版图腾效果(回血在下方,清负面/加 buff 由组件负责)
player.giveExperiencePoints(-(int) (totalXp * settings.experienceDrainRatio));
player.getCooldowns().addCooldown(totem, settings.cooldownTicks);
player.setHealth(1.0F);
// 复用原版图腾的死亡效果(清负面 + 再生/伤害吸收/抗火);物品本身不挂组件,直接用常量应用
DeathProtection.TOTEM_OF_UNDYING.applyEffects(totem, player);
// 事件 35 = 图腾使用动画+粒子+音效,由客户端按手持物品外观播放
player.level().broadcastEntityEvent(player, (byte) 35);
// 复刻原版图腾的统计/成就副作用:物品使用统计、「向死而生」成就、交互结束游戏事件
player.awardStat(Stats.ITEM_USED.get(standingFirmTotem));
CriteriaTriggers.USED_TOTEM.trigger(player, totem);
player.gameEvent(GameEvent.ITEM_INTERACT_FINISH);
return Result.PROTECTED;
}
private ItemStack findTotem(ServerPlayer player) {
for (InteractionHand hand : InteractionHand.values()) {
ItemStack held = player.getItemInHand(hand);
if (held.is(standingFirmTotem)) {
return held;
}
}
return null;
}
}
@@ -139,7 +139,7 @@ public class CreativeRoomModule extends ServerModule {
return Command.SINGLE_SUCCESS;
}
}
p.sendSystemMessage(Component.nullToEmpty("You and target must be in playground!"));
p.sendSystemMessage(Component.translatable("message.sfcraft.playground.must_be_in"));
}
return Command.SINGLE_SUCCESS;
}
@@ -151,9 +151,9 @@ public class CreativeRoomModule extends ServerModule {
var mode = ctx.getArgument("mode", GameType.class);
if (p.level().dimension().equals(CreativeSpaceRoom.WORLD)) {
p.setGameMode(mode);
p.sendSystemMessage(Component.nullToEmpty("Your gamemode has been changed to " + mode.getSerializedName()));
p.sendSystemMessage(Component.translatable("message.sfcraft.playground.gamemode_changed", mode.getSerializedName()));
} else {
p.sendSystemMessage(Component.nullToEmpty("You can only use this in playground!"));
p.sendSystemMessage(Component.translatable("message.sfcraft.playground.only_here"));
}
}
return Command.SINGLE_SUCCESS;
@@ -171,14 +171,14 @@ public class CreativeRoomModule extends ServerModule {
}
public void onPlayerJoin(ServerPlayer player) {
player.sendSystemMessage(Component.nullToEmpty("你现在正在创造游乐园中,使用 //schem list 查看可用投影。"));
player.sendSystemMessage(Component.literal("以下功能现在可用:").withColor(CommonColors.WHITE)
.append(Component.literal("/player /track /summon /setblock /gamerule 及 WorldEdit 的所有命令").withColor(Color.MAGENTA.getRGB()).append("。")));
player.sendSystemMessage(Component.translatable("message.sfcraft.playground.welcome"));
player.sendSystemMessage(Component.translatable("message.sfcraft.playground.features_available").withColor(CommonColors.WHITE)
.append(Component.translatable("message.sfcraft.playground.features_list").withColor(Color.MAGENTA.getRGB())));
player.sendSystemMessage(Component.nullToEmpty(" "));
player.sendSystemMessage(Component.literal("如何上传投影到游乐场: ").withColor(CommonColors.GRAY)
player.sendSystemMessage(Component.translatable("message.sfcraft.playground.upload_hint").withColor(CommonColors.GRAY)
.append(Component.literal("https://github.com/saltedfishclub/sfcraft-schematics").withColor(CommonColors.GRAY).withStyle(it -> it.withUnderlined(true))));
player.sendSystemMessage(Component.literal("游乐园中的生物不能逃逸到其他维度。").withColor(CommonColors.GRAY));
player.sendSystemMessage(Component.literal("使用 /reco 或重新加入游戏即可离开。").withColor(CommonColors.GRAY));
player.sendSystemMessage(Component.translatable("message.sfcraft.playground.mobs_no_escape").withColor(CommonColors.GRAY));
player.sendSystemMessage(Component.translatable("message.sfcraft.playground.how_to_leave").withColor(CommonColors.GRAY));
player.setGameMode(GameType.CREATIVE);
chatPrefixModule.applyPrefix(player, prefix);
@@ -64,11 +64,11 @@ public class SchematicUploader extends WebHandler {
private int onRequestSchematic(CommandContext<CommandSourceStack> context) {
var source = context.getSource();
if (source.isPlayer() && !SFConsts.COMMAND_UPLOAD_SCHEMATIC.hasPermission(source.getPlayer())) {
source.sendSystemMessage(Component.literal("你没有使用上传功能的权限!请联系管理员申请"));
source.sendSystemMessage(Component.translatable("message.sfcraft.schem.no_permission"));
return Command.SINGLE_SUCCESS;
}
var url = generateSchematicUrl(source.isPlayer() ? source.getPlayer().getName().tryCollapseToString() : "CONSOLE");
source.sendSystemMessage(Component.literal("Click this URL to upload schematic files.").withColor(Color.GREEN.getRGB()));
source.sendSystemMessage(Component.translatable("message.sfcraft.schem.click_to_upload").withColor(Color.GREEN.getRGB()));
source.sendSystemMessage(Component.literal(url).withStyle(it -> it.withClickEvent(new ClickEvent.OpenUrl(URI.create(url)))));
return Command.SINGLE_SUCCESS;
}
@@ -172,8 +172,8 @@ public class SchematicUploader extends WebHandler {
if (player == null) {
return;
}
player.sendSystemMessage(Component.literal("Schematic " + fileName + " has been saved! Use //schem list to find it").withColor(Color.GREEN.getRGB()));
player.sendSystemMessage(Component.literal("Or you can click this").withStyle(it -> it.withUnderlined(true).withClickEvent(
player.sendSystemMessage(Component.translatable("message.sfcraft.schem.saved", fileName).withColor(Color.GREEN.getRGB()));
player.sendSystemMessage(Component.translatable("message.sfcraft.schem.or_click").withStyle(it -> it.withUnderlined(true).withClickEvent(
new ClickEvent.SuggestCommand("//schem load " + fileName)
)));
}
@@ -0,0 +1,24 @@
package io.ib67.sfcraft.registry;
import io.ib67.sfcraft.registry.cauldron.CauldronRecipe;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Optional;
/**
* Registry of amethyst cauldron reactions. Feature modules contribute their recipes in
* {@code onInitialize()} and re-register them on {@link io.ib67.sfcraft.callback.SFConfigReload}.
*/
public interface CauldronRecipeRegistry {
void register(CauldronRecipe recipe);
void unregister(CauldronRecipe recipe);
Optional<CauldronRecipe> match(List<ItemStack> contents);
/** Look up by the recipe's stable id, used to restore an in-progress reaction from the block entity. */
@Nullable
CauldronRecipe byId(String id);
}
@@ -0,0 +1,44 @@
package io.ib67.sfcraft.registry.cauldron;
import net.minecraft.core.particles.ParticleOptions;
import net.minecraft.world.item.ItemStack;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
/**
* A reaction inside the amethyst cauldron: all {@code inputs} (ingredients and the
* catalyst alike) must be thrown into the cauldron. Once the contents match and the
* water/heat conditions are met, the reaction starts automatically.
*
* <p>{@code id} is a stable identifier used to persist an in-progress reaction across
* save/reload — it must be unique and stable, unlike list position, so that
* re-registering recipes (e.g. on {@code /sfcraft reload}) or changing the recipe set
* never makes a saved cauldron resume the wrong reaction.
*/
public record CauldronRecipe(
String id,
List<Predicate<ItemStack>> inputs,
boolean needsWater,
boolean needsHeat,
ParticleOptions reactionParticle,
int reactionTicks,
Function<List<ItemStack>, ItemStack> assembler
) {
public boolean matches(List<ItemStack> contents) {
if (contents.size() != inputs.size()) return false;
return matchRemaining(contents, new boolean[contents.size()], 0);
}
private boolean matchRemaining(List<ItemStack> contents, boolean[] used, int index) {
if (index == inputs.size()) return true;
for (int i = 0; i < contents.size(); i++) {
if (used[i] || !inputs.get(index).test(contents.get(i))) continue;
used[i] = true;
if (matchRemaining(contents, used, index + 1)) return true;
used[i] = false;
}
return false;
}
}
@@ -0,0 +1,38 @@
package io.ib67.sfcraft.registry.cauldron;
import io.ib67.sfcraft.registry.CauldronRecipeRegistry;
import net.minecraft.world.item.ItemStack;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;
public class SimpleCauldronRecipeRegistry implements CauldronRecipeRegistry {
private final List<CauldronRecipe> recipes = new CopyOnWriteArrayList<>();
@Override
public void register(CauldronRecipe recipe) {
recipes.add(recipe);
}
@Override
public void unregister(CauldronRecipe recipe) {
recipes.remove(recipe);
}
@Override
public Optional<CauldronRecipe> match(List<ItemStack> contents) {
return recipes.stream().filter(recipe -> recipe.matches(contents)).findFirst();
}
@Override
@Nullable
public CauldronRecipe byId(String id) {
if (id == null || id.isEmpty()) return null;
for (CauldronRecipe recipe : recipes) {
if (recipe.id().equals(id)) return recipe;
}
return null;
}
}
@@ -33,7 +33,7 @@ public class RoomTeleporter {
public void teleportTo(Room room, ServerPlayer player) {
if (roomModule.isVirtual(player.getUUID())) {
player.sendSystemMessage(Component.literal("您已经在某个房间里了").withColor(CommonColors.SOFT_RED));
player.sendSystemMessage(Component.translatable("message.sfcraft.room.already_in").withColor(CommonColors.SOFT_RED));
return;
}
var finalUuid = roomModule.generateIdForRoom(player.getGameProfile(), player.getName().tryCollapseToString(), room.getServerIdentifier());
@@ -27,7 +27,7 @@ public class CreativeSpaceRoom implements Room {
@Override
public void shutdown() {
for (ServerPlayer joinedPlayer : playerManager.getJoinedPlayers()) {
joinedPlayer.connection.disconnect(Component.nullToEmpty("Room is shutting down"));
joinedPlayer.connection.disconnect(Component.translatable("message.sfcraft.room.shutting_down"));
}
}
@@ -8,6 +8,7 @@ import io.ib67.sfcraft.SFCraft;
import io.ib67.sfcraft.config.SFConfig;
import io.ib67.sfcraft.geoip.GeoIPService;
import lombok.SneakyThrows;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
@@ -65,7 +66,7 @@ public class Helper {
// check block type
var fluid = stand.getFluidState().getType();
if (!(fluid instanceof EmptyFluid) && !(fluid instanceof WaterFluid)) {
player.sendSystemMessage(Component.literal("§c传送目的地具有非水流体, 因此拒绝传送。"));
player.sendSystemMessage(Component.translatable("message.sfcraft.teleport.non_water").withStyle(ChatFormatting.RED));
return false;
}
}
@@ -73,7 +74,7 @@ public class Helper {
var ground = world.getBlockState(groundPos);
if (!ground.entityCanStandOn(world, groundPos, player)
&& !stand.entityCanStandOnFace(world, pos, player, Direction.DOWN)) {
player.sendSystemMessage(Component.literal("§c传送目的地没有可靠落地点,且为非空气方块, 因此拒绝传送。"));
player.sendSystemMessage(Component.translatable("message.sfcraft.teleport.no_landing").withStyle(ChatFormatting.RED));
return false;
} else {
var deltaY = 0.0;
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sfcraft:item/amethyst_cauldron_blank"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sfcraft:item/blaze_bomb"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sfcraft:item/bomb"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sfcraft:item/lunch_box"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sfcraft:item/obsidian_bomb"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sfcraft:item/pearl_token"
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sfcraft:item/standing_firm_totem"
}
}
@@ -0,0 +1,85 @@
{
"block.sfcraft.amethyst_cauldron": "Amethyst Cauldron",
"block.sfcraft.gravity_crystal": "Gravity Crystal",
"item.sfcraft.amethyst_cauldron_blank": "Amethyst Cauldron Shell",
"item.sfcraft.amethyst_cauldron": "Amethyst Cauldron",
"item.sfcraft.gravity_crystal": "Gravity Crystal",
"item.sfcraft.bomb": "Bomb",
"item.sfcraft.obsidian_bomb": "Obsidian Bomb",
"item.sfcraft.blaze_bomb": "Blaze Bomb",
"item.sfcraft.pearl_token": "Pearl Token",
"item.sfcraft.reverse_pearl_token": "Reverse Pearl Token",
"item.sfcraft.lunch_box": "Lunch Box",
"container.sfcraft.lunch_box": "Lunch Box",
"enchantment.sfcraft.beheading": "Beheading",
"message.sfcraft.cauldron.reacting": "The reaction is in progress, ingredients cannot be retrieved",
"message.sfcraft.antispam.too_fast": "You're chatting too fast, slow down",
"message.sfcraft.antispam.duplicate": "Please don't repeat yourself",
"message.sfcraft.teleport.non_water": "Teleport destination contains a non-water fluid; teleport denied.",
"message.sfcraft.teleport.no_landing": "Teleport destination has no reliable landing spot and is a non-air block; teleport denied.",
"message.sfcraft.room.already_in": "You are already in a room.",
"message.sfcraft.room.shutting_down": "Room is shutting down",
"message.sfcraft.room.not_in_room": "You are not in any \"room\".",
"message.sfcraft.offline.already_whitelisted": "%s is already whitelisted, or this id conflicts with an online user.",
"message.sfcraft.offline.added": "[%s/%s] is added!",
"message.sfcraft.offline.list_header": "Offline Users:",
"message.sfcraft.offline.list_empty": "No offline users found in whitelist.",
"message.sfcraft.management.cities_header": "List of players with cities:",
"message.sfcraft.management.fetch_failed": " - [FAILED TO FETCH] %s",
"message.sfcraft.management.sudo_off": "Sudo is off.",
"message.sfcraft.management.sudo_on": "Sudo is on.",
"message.sfcraft.management.perms_header": "Permissions: ",
"message.sfcraft.management.perm_default": "(default)",
"message.sfcraft.back.no_players": "There are no players nearby.",
"message.sfcraft.back.tip": "Tip: There are players near your death location; use /back to teleport to them (even if they are also dead).",
"message.sfcraft.afk.prefix": "[AFK] ",
"message.sfcraft.afk.now_afk": " * %s is now AFK.",
"message.sfcraft.afk.back": " * %s is back.",
"message.sfcraft.elytra.durability": "ELYTRA: %s%%",
"message.sfcraft.elytra.landed": "!! LANDED !!",
"message.sfcraft.elytra.infinity": "ELYTRA: INFINITY",
"message.sfcraft.chat.calling_you": "%s is calling you.",
"message.sfcraft.chat.invalid_position": " (invalid position) ",
"message.sfcraft.dimension.end": " (The End)",
"message.sfcraft.dimension.nether": " (The Nether)",
"message.sfcraft.dimension.unknown": " (Unknown)",
"message.sfcraft.playground.must_be_in": "You and target must be in playground!",
"message.sfcraft.playground.gamemode_changed": "Your gamemode has been changed to %s",
"message.sfcraft.playground.only_here": "You can only use this in playground!",
"message.sfcraft.playground.welcome": "You are now in the creative playground. Use //schem list to view available schematics.",
"message.sfcraft.playground.features_available": "The following features are now available: ",
"message.sfcraft.playground.features_list": "/player /track /summon /setblock /gamerule and all WorldEdit commands.",
"message.sfcraft.playground.upload_hint": "How to upload schematics to the playground: ",
"message.sfcraft.playground.mobs_no_escape": "Mobs in the playground cannot escape to other dimensions.",
"message.sfcraft.playground.how_to_leave": "Use /reco or rejoin the game to leave.",
"message.sfcraft.schem.no_permission": "You don't have permission to use the upload feature. Please contact an administrator.",
"message.sfcraft.schem.click_to_upload": "Click this URL to upload schematic files.",
"message.sfcraft.schem.saved": "Schematic %s has been saved! Use //schem list to find it",
"message.sfcraft.schem.or_click": "Or you can click this",
"message.sfcraft.lunch_box.empty": "The lunch box is empty",
"message.sfcraft.lunch_box.not_hungry": "You are not hungry right now",
"message.sfcraft.mount.need_weakness": "It must be weakened before it can be tamed",
"message.sfcraft.mount.cannot_here": "It cannot be tamed here",
"message.sfcraft.mount.raid_active": "Cannot tame during a raid",
"message.sfcraft.mount.raider_nearby": "Cannot tame while raiders are nearby",
"message.sfcraft.mount.tame_failed": "Taming failed... try again",
"message.sfcraft.token.owner_offline": "The token's owner %s is offline",
"message.sfcraft.token.summoned_owner": "%s used a pearl token to summon you to their side",
"message.sfcraft.token.summon_success": "Summoned %s",
"message.sfcraft.token.bound": "Pearl token bound; whoever uses it will summon you",
"message.sfcraft.token.not_bound": "This token is not bound to an owner",
"message.sfcraft.token.cooldown": "Token on cooldown, %s seconds remaining",
"message.sfcraft.token.arrived": "%s used a reverse pearl token to come to your side",
"message.sfcraft.token.depleted": "The token is depleted and crumbles to ash",
"message.sfcraft.token.uses_left": "Uses remaining: %s/%s",
"lore.sfcraft.token.owner": "Owner: %s",
"lore.sfcraft.token.uses": "Remaining uses: %s/%s",
"command.sfcraft.reload.success": "sfcraft configs reloaded (loot chances require a vanilla /reload to apply)",
"command.sfcraft.reload.failure": "Reload failed: %s",
"command.sfcraft.gps.started": "Navigation started: target (%s, %s, %s), follow the glowing little slime",
"command.sfcraft.gps.stopped": "Navigation cancelled",
"command.sfcraft.gps.not_active": "No navigation in progress",
"command.sfcraft.gps.arrived": "You have arrived, navigation ended",
"command.sfcraft.gps.dimension_changed": "You left the target's dimension, navigation ended",
"item.sfcraft.standing_firm_totem": "Totem of Restitution"
}
@@ -0,0 +1,85 @@
{
"block.sfcraft.amethyst_cauldron": "紫水晶炼药锅",
"block.sfcraft.gravity_crystal": "重力水晶",
"item.sfcraft.amethyst_cauldron_blank": "紫水晶炼药锅胚体",
"item.sfcraft.amethyst_cauldron": "紫水晶炼药锅",
"item.sfcraft.gravity_crystal": "重力水晶",
"item.sfcraft.bomb": "炸弹",
"item.sfcraft.obsidian_bomb": "黑曜石炸弹",
"item.sfcraft.blaze_bomb": "烈焰炸弹",
"item.sfcraft.pearl_token": "珍珠信物",
"item.sfcraft.reverse_pearl_token": "反向珍珠信物",
"item.sfcraft.lunch_box": "午餐盒",
"container.sfcraft.lunch_box": "午餐盒",
"enchantment.sfcraft.beheading": "斩首",
"message.sfcraft.cauldron.reacting": "反应进行中,无法取出原料",
"message.sfcraft.antispam.too_fast": "发言太快了,请慢一点",
"message.sfcraft.antispam.duplicate": "请勿重复刷屏",
"message.sfcraft.teleport.non_water": "传送目的地具有非水流体, 因此拒绝传送。",
"message.sfcraft.teleport.no_landing": "传送目的地没有可靠落地点,且为非空气方块, 因此拒绝传送。",
"message.sfcraft.room.already_in": "您已经在某个房间里了",
"message.sfcraft.room.shutting_down": "房间正在关闭",
"message.sfcraft.room.not_in_room": "你不在任何 \"房间\" 内。",
"message.sfcraft.offline.already_whitelisted": "%s 已在白名单中,或此 ID 与某个在线用户冲突。",
"message.sfcraft.offline.added": "[%s/%s] 已添加!",
"message.sfcraft.offline.list_header": "离线用户:",
"message.sfcraft.offline.list_empty": "白名单中没有离线用户。",
"message.sfcraft.management.cities_header": "玩家城市列表:",
"message.sfcraft.management.fetch_failed": " - [获取失败] %s",
"message.sfcraft.management.sudo_off": "Sudo 已关闭。",
"message.sfcraft.management.sudo_on": "Sudo 已开启。",
"message.sfcraft.management.perms_header": "权限:",
"message.sfcraft.management.perm_default": "(默认)",
"message.sfcraft.back.no_players": "周围没有玩家。",
"message.sfcraft.back.tip": "Tip: 死亡地点附近有玩家,可以使用 /back 传送到他们那里。(即使在对方也死亡的状态下)",
"message.sfcraft.afk.prefix": "[挂机] ",
"message.sfcraft.afk.now_afk": " * %s 正在挂机.",
"message.sfcraft.afk.back": " * %s 回来了.",
"message.sfcraft.elytra.durability": "鞘翅: %s%%",
"message.sfcraft.elytra.landed": "!! 已落地 !!",
"message.sfcraft.elytra.infinity": "鞘翅: 无限",
"message.sfcraft.chat.calling_you": "%s 正在叫你。",
"message.sfcraft.chat.invalid_position": " (无效坐标) ",
"message.sfcraft.dimension.end": " (末地)",
"message.sfcraft.dimension.nether": " (地狱)",
"message.sfcraft.dimension.unknown": " (未知)",
"message.sfcraft.playground.must_be_in": "你和目标必须都在游乐园中!",
"message.sfcraft.playground.gamemode_changed": "你的游戏模式已更改为 %s",
"message.sfcraft.playground.only_here": "只能在游乐园中使用此功能!",
"message.sfcraft.playground.welcome": "你现在正在创造游乐园中,使用 //schem list 查看可用投影。",
"message.sfcraft.playground.features_available": "以下功能现在可用:",
"message.sfcraft.playground.features_list": "/player /track /summon /setblock /gamerule 及 WorldEdit 的所有命令。",
"message.sfcraft.playground.upload_hint": "如何上传投影到游乐场: ",
"message.sfcraft.playground.mobs_no_escape": "游乐园中的生物不能逃逸到其他维度。",
"message.sfcraft.playground.how_to_leave": "使用 /reco 或重新加入游戏即可离开。",
"message.sfcraft.schem.no_permission": "你没有使用上传功能的权限!请联系管理员申请",
"message.sfcraft.schem.click_to_upload": "点击此链接以上传投影文件。",
"message.sfcraft.schem.saved": "投影 %s 已保存!使用 //schem list 查找它",
"message.sfcraft.schem.or_click": "或者点击这里",
"message.sfcraft.lunch_box.empty": "午餐盒是空的",
"message.sfcraft.lunch_box.not_hungry": "你现在不饿",
"message.sfcraft.mount.need_weakness": "它必须处于虚弱状态才能被驯服",
"message.sfcraft.mount.cannot_here": "无法在此驯服",
"message.sfcraft.mount.raid_active": "袭击进行中,无法驯服",
"message.sfcraft.mount.raider_nearby": "附近有掠夺者时无法驯服",
"message.sfcraft.mount.tame_failed": "驯服失败了…再试试",
"message.sfcraft.token.owner_offline": "信物的主人 %s 不在线",
"message.sfcraft.token.summoned_owner": "%s 使用珍珠信物将你召唤到了他们身边",
"message.sfcraft.token.summon_success": "已召唤 %s",
"message.sfcraft.token.bound": "珍珠信物已绑定,他人使用时会把你召唤过去",
"message.sfcraft.token.not_bound": "这个信物没有绑定主人",
"message.sfcraft.token.cooldown": "信物冷却中,还需 %s 秒",
"message.sfcraft.token.arrived": "%s 使用反向珍珠信物来到了你身边",
"message.sfcraft.token.depleted": "信物已耗尽,化为了灰烬",
"message.sfcraft.token.uses_left": "剩余使用次数: %s/%s",
"lore.sfcraft.token.owner": "主人: %s",
"lore.sfcraft.token.uses": "剩余次数: %s/%s",
"command.sfcraft.reload.success": "sfcraft 配置已重载(战利品概率需再执行 /reload 生效)",
"command.sfcraft.reload.failure": "重载失败: %s",
"command.sfcraft.gps.started": "导航已开始: 目标 (%s, %s, %s),跟随发光的小史莱姆前进",
"command.sfcraft.gps.stopped": "导航已取消",
"command.sfcraft.gps.not_active": "当前没有正在进行的导航",
"command.sfcraft.gps.arrived": "已到达目的地,导航结束",
"command.sfcraft.gps.dimension_changed": "已离开目标所在维度,导航结束",
"item.sfcraft.standing_firm_totem": "代偿图腾"
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "sfcraft:item/amethyst_cauldron_blank"
}
}
@@ -0,0 +1,226 @@
{
"format_version": "1.21.11",
"credit": "Made with Blockbench",
"textures": {
"0": "sfcraft:item/coal_block",
"3": "sfcraft:item/redstone_block",
"8": "sfcraft:item/bomb_corner",
"9": "sfcraft:item/flame_bomb_surface",
"particle": "sfcraft:item/coal_block"
},
"elements": [
{
"from": [0, 0, 0],
"to": [16, 16, 16],
"rotation": {"angle": 0, "axis": "y", "origin": [7, -2, 6]},
"faces": {
"north": {"uv": [0, 0, 16, 16], "texture": "#9"},
"east": {"uv": [0, 0, 16, 16], "texture": "#9"},
"south": {"uv": [0, 0, 16, 16], "texture": "#9"},
"west": {"uv": [0, 0, 16, 16], "texture": "#9"},
"up": {"uv": [0, 0, 16, 16], "texture": "#0"},
"down": {"uv": [0, 0, 16, 16], "texture": "#0"}
}
},
{
"from": [13.5, -0.5, -0.5],
"to": [16.5, 3, 2.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, -1, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [-0.5, 13, 13.5],
"to": [2.5, 16.5, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, 12.5, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [13.5, 13, 13.5],
"to": [16.5, 16.5, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, 12.5, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [-0.5, 13, -0.5],
"to": [3, 16.5, 3],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, 12.5, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [13.5, 13, -0.5],
"to": [16.5, 16.5, 2.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, 12.5, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [-0.5, -0.5, -0.5],
"to": [3, 3, 3],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, -1, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [13.5, -0.5, 13.5],
"to": [16.5, 3, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, -1, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [-0.5, -0.5, 13.5],
"to": [2.5, 3, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, -1, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [4.75, 16, 4.75],
"to": [10.75, 16.5, 10.75],
"rotation": {"angle": 0, "axis": "y", "origin": [7, 16, 7]},
"faces": {
"north": {"uv": [6, 0, 12, 1], "texture": "#8"},
"east": {"uv": [6, 1, 12, 2], "texture": "#8"},
"south": {"uv": [6, 2, 12, 3], "texture": "#8"},
"west": {"uv": [6, 3, 12, 4], "texture": "#8"},
"up": {"uv": [6, 6, 0, 0], "texture": "#8"},
"down": {"uv": [6, 6, 0, 12], "texture": "#8"}
}
},
{
"from": [7, 16, 7],
"to": [9, 19, 9],
"rotation": {"angle": 0, "axis": "y", "origin": [7, 16, 7]},
"faces": {
"north": {"uv": [0, 0, 2, 3], "texture": "#3"},
"east": {"uv": [0, 0, 2, 3], "texture": "#3"},
"south": {"uv": [0, 0, 2, 3], "texture": "#3"},
"west": {"uv": [0, 0, 2, 3], "texture": "#3"},
"up": {"uv": [0, 0, 2, 3], "texture": "#3"},
"down": {"uv": [0, 0, 2, 3], "texture": "#3"}
}
},
{
"from": [6, 18, 6],
"to": [8, 21, 8],
"rotation": {"angle": 0, "axis": "y", "origin": [6, 18, 6]},
"faces": {
"north": {"uv": [0, 0, 2, 3], "texture": "#3"},
"east": {"uv": [0, 0, 2, 3], "texture": "#3"},
"south": {"uv": [0, 0, 2, 3], "texture": "#3"},
"west": {"uv": [0, 0, 2, 3], "texture": "#3"},
"up": {"uv": [0, 0, 2, 3], "texture": "#3"},
"down": {"uv": [0, 0, 2, 3], "texture": "#3"}
}
}
],
"display": {
"thirdperson_righthand": {
"rotation": [33, 0, 0],
"translation": [0, 1, 0],
"scale": [0.14062, 0.14062, 0.14062]
},
"thirdperson_lefthand": {
"rotation": [33, 0, 0],
"translation": [0, -0.25, 1],
"scale": [0.14062, 0.14062, 0.14062]
},
"firstperson_righthand": {
"rotation": [33, 0, 0],
"translation": [0, 2.25, 0],
"scale": [0.31055, 0.31055, 0.31055]
},
"firstperson_lefthand": {
"rotation": [33, 0, 0],
"translation": [0, 2.25, 0],
"scale": [0.31055, 0.31055, 0.31055]
},
"ground": {
"scale": [0.3, 0.3, 0.3]
},
"gui": {
"rotation": [30.5, -45.25, 0],
"scale": [0.42, 0.42, 0.42]
},
"head": {
"translation": [0, 10.75, 0],
"scale": [0.31641, 0.31641, 0.31641]
},
"fixed": {
"scale": [0.3, 0.3, 0.3]
},
"on_shelf": {
"scale": [0.60938, 0.60938, 0.60938]
}
},
"groups": [
0,
{
"name": "corner",
"origin": [13, -1, -1],
"scope": 0,
"color": 0,
"children": [1, 2, 3, 4, 5, 6, 7, 8]
},
9,
{
"name": "fuse",
"origin": [7, 16, 7],
"scope": 0,
"color": 0,
"children": [10, 11]
}
]
}
@@ -0,0 +1,226 @@
{
"format_version": "1.21.11",
"credit": "Made with Blockbench",
"textures": {
"0": "sfcraft:item/coal_block",
"3": "sfcraft:item/redstone_block",
"9": "sfcraft:item/bomb_corner",
"10": "sfcraft:item/normal_bomb_surface",
"particle": "sfcraft:item/coal_block"
},
"elements": [
{
"from": [0, 0, 0],
"to": [16, 16, 16],
"rotation": {"angle": 0, "axis": "y", "origin": [7, -2, 6]},
"faces": {
"north": {"uv": [0, 0, 16, 16], "texture": "#10"},
"east": {"uv": [0, 0, 16, 16], "texture": "#10"},
"south": {"uv": [0, 0, 16, 16], "texture": "#10"},
"west": {"uv": [0, 0, 16, 16], "texture": "#10"},
"up": {"uv": [0, 0, 16, 16], "texture": "#0"},
"down": {"uv": [0, 0, 16, 16], "texture": "#0"}
}
},
{
"from": [13.5, -0.5, -0.5],
"to": [16.5, 3, 2.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, -1, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#9"}
}
},
{
"from": [-0.5, 13, 13.5],
"to": [2.5, 16.5, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, 12.5, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#9"}
}
},
{
"from": [13.5, 13, 13.5],
"to": [16.5, 16.5, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, 12.5, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#9"}
}
},
{
"from": [-0.5, 13, -0.5],
"to": [3, 16.5, 3],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, 12.5, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#9"}
}
},
{
"from": [13.5, 13, -0.5],
"to": [16.5, 16.5, 2.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, 12.5, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#9"}
}
},
{
"from": [-0.5, -0.5, -0.5],
"to": [3, 3, 3],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, -1, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#9"}
}
},
{
"from": [13.5, -0.5, 13.5],
"to": [16.5, 3, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, -1, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#9"}
}
},
{
"from": [-0.5, -0.5, 13.5],
"to": [2.5, 3, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, -1, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#9"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#9"}
}
},
{
"from": [4.75, 16, 4.75],
"to": [10.75, 16.5, 10.75],
"rotation": {"angle": 0, "axis": "y", "origin": [7, 16, 7]},
"faces": {
"north": {"uv": [6, 0, 12, 1], "texture": "#9"},
"east": {"uv": [6, 1, 12, 2], "texture": "#9"},
"south": {"uv": [6, 2, 12, 3], "texture": "#9"},
"west": {"uv": [6, 3, 12, 4], "texture": "#9"},
"up": {"uv": [6, 6, 0, 0], "texture": "#9"},
"down": {"uv": [6, 6, 0, 12], "texture": "#9"}
}
},
{
"from": [7, 16, 7],
"to": [9, 19, 9],
"rotation": {"angle": 0, "axis": "y", "origin": [7, 16, 7]},
"faces": {
"north": {"uv": [0, 0, 2, 3], "texture": "#3"},
"east": {"uv": [0, 0, 2, 3], "texture": "#3"},
"south": {"uv": [0, 0, 2, 3], "texture": "#3"},
"west": {"uv": [0, 0, 2, 3], "texture": "#3"},
"up": {"uv": [0, 0, 2, 3], "texture": "#3"},
"down": {"uv": [0, 0, 2, 3], "texture": "#3"}
}
},
{
"from": [6, 18, 6],
"to": [8, 21, 8],
"rotation": {"angle": 0, "axis": "y", "origin": [6, 18, 6]},
"faces": {
"north": {"uv": [0, 0, 2, 3], "texture": "#3"},
"east": {"uv": [0, 0, 2, 3], "texture": "#3"},
"south": {"uv": [0, 0, 2, 3], "texture": "#3"},
"west": {"uv": [0, 0, 2, 3], "texture": "#3"},
"up": {"uv": [0, 0, 2, 3], "texture": "#3"},
"down": {"uv": [0, 0, 2, 3], "texture": "#3"}
}
}
],
"display": {
"thirdperson_righthand": {
"rotation": [33, 0, 0],
"translation": [0, 1, 0],
"scale": [0.14062, 0.14062, 0.14062]
},
"thirdperson_lefthand": {
"rotation": [33, 0, 0],
"translation": [0, 0.25, 0.25],
"scale": [0.14062, 0.14062, 0.14062]
},
"firstperson_righthand": {
"rotation": [33, 0, 0],
"translation": [0, 2.25, 0],
"scale": [0.31055, 0.31055, 0.31055]
},
"firstperson_lefthand": {
"rotation": [33, 0, 0],
"translation": [0, 2.25, 0],
"scale": [0.31055, 0.31055, 0.31055]
},
"ground": {
"scale": [0.3, 0.3, 0.3]
},
"gui": {
"rotation": [30.5, -45.25, 0],
"scale": [0.42, 0.42, 0.42]
},
"head": {
"translation": [0, 10.75, 0],
"scale": [0.31641, 0.31641, 0.31641]
},
"fixed": {
"scale": [0.3, 0.3, 0.3]
},
"on_shelf": {
"scale": [0.60938, 0.60938, 0.60938]
}
},
"groups": [
0,
{
"name": "corner",
"origin": [13, -1, -1],
"scope": 0,
"color": 0,
"children": [1, 2, 3, 4, 5, 6, 7, 8]
},
9,
{
"name": "fuse",
"origin": [7, 16, 7],
"scope": 0,
"color": 0,
"children": [10, 11]
}
]
}
@@ -0,0 +1,239 @@
{
"format_version": "1.21.11",
"credit": "Made with Blockbench",
"texture_size": [32, 32],
"textures": {
"0": "sfcraft:item/lunch_box",
"particle": "sfcraft:item/lunch_box"
},
"elements": [
{
"from": [2, 0, 9],
"to": [3, 1, 13],
"faces": {
"north": {"uv": [14.5, 0, 15, 0.5], "texture": "#0"},
"east": {"uv": [9.5, 0.5, 11.5, 1], "texture": "#0"},
"south": {"uv": [9, 1, 9.5, 1.5], "texture": "#0"},
"west": {"uv": [12.5, 1.5, 14.5, 2], "texture": "#0"},
"up": {"uv": [7, 3.5, 6.5, 1.5], "texture": "#0"},
"down": {"uv": [7, 9.5, 6.5, 11.5], "texture": "#0"}
}
},
{
"from": [4, 0, 3],
"to": [5, 1, 6],
"faces": {
"north": {"uv": [13.5, 0, 14, 0.5], "texture": "#0"},
"east": {"uv": [13, 0.5, 14.5, 1], "texture": "#0"},
"south": {"uv": [10, 1, 10.5, 1.5], "texture": "#0"},
"west": {"uv": [9.5, 1.5, 11, 2], "texture": "#0"},
"up": {"uv": [6, 6.5, 5.5, 5], "texture": "#0"},
"down": {"uv": [6, 13, 5.5, 14.5], "texture": "#0"}
}
},
{
"from": [5, 0, 2],
"to": [6, 1, 6],
"faces": {
"north": {"uv": [13, 0, 13.5, 0.5], "texture": "#0"},
"east": {"uv": [13, 0.5, 15, 1], "texture": "#0"},
"south": {"uv": [10.5, 1, 11, 1.5], "texture": "#0"},
"west": {"uv": [9, 1.5, 11, 2], "texture": "#0"},
"up": {"uv": [5.5, 7, 5, 5], "texture": "#0"},
"down": {"uv": [5.5, 13, 5, 15], "texture": "#0"}
}
},
{
"from": [6, 0, 1],
"to": [8, 1, 6],
"faces": {
"north": {"uv": [12, 0, 13, 0.5], "texture": "#0"},
"east": {"uv": [13, 0.5, 15.5, 1], "texture": "#0"},
"south": {"uv": [11, 1, 12, 1.5], "texture": "#0"},
"west": {"uv": [8.5, 1.5, 11, 2], "texture": "#0"},
"up": {"uv": [5, 7.5, 4, 5], "texture": "#0"},
"down": {"uv": [5, 13, 4, 15.5], "texture": "#0"}
}
},
{
"from": [4, 0, 13],
"to": [8, 1, 14],
"faces": {
"north": {"uv": [12, 0, 14, 0.5], "texture": "#0"},
"east": {"uv": [9, 0.5, 9.5, 1], "texture": "#0"},
"south": {"uv": [10, 1, 12, 1.5], "texture": "#0"},
"west": {"uv": [14.5, 1.5, 15, 2], "texture": "#0"},
"up": {"uv": [6, 1.5, 4, 1], "texture": "#0"},
"down": {"uv": [6, 9, 4, 9.5], "texture": "#0"}
}
},
{
"from": [5, 0, 14],
"to": [8, 1, 15],
"faces": {
"north": {"uv": [12, 0, 13.5, 0.5], "texture": "#0"},
"east": {"uv": [8.5, 0.5, 9, 1], "texture": "#0"},
"south": {"uv": [10.5, 1, 12, 1.5], "texture": "#0"},
"west": {"uv": [15, 1.5, 15.5, 2], "texture": "#0"},
"up": {"uv": [5.5, 1, 4, 0.5], "texture": "#0"},
"down": {"uv": [5.5, 8.5, 4, 9], "texture": "#0"}
}
},
{
"from": [6, 0, 15],
"to": [8, 1, 16],
"faces": {
"north": {"uv": [12, 0, 13, 0.5], "texture": "#0"},
"east": {"uv": [8, 0.5, 8.5, 1], "texture": "#0"},
"south": {"uv": [11, 1, 12, 1.5], "texture": "#0"},
"west": {"uv": [15.5, 1.5, 16, 2], "texture": "#0"},
"up": {"uv": [5, 0.5, 4, 0], "texture": "#0"},
"down": {"uv": [5, 8, 4, 8.5], "texture": "#0"}
}
},
{
"from": [8, 0, 1],
"to": [9, 1, 15],
"faces": {
"north": {"uv": [11.5, 0, 12, 0.5], "texture": "#0"},
"east": {"uv": [8.5, 0.5, 15.5, 1], "texture": "#0"},
"south": {"uv": [12, 1, 12.5, 1.5], "texture": "#0"},
"west": {"uv": [8.5, 1.5, 15.5, 2], "texture": "#0"},
"up": {"uv": [4, 7.5, 3.5, 0.5], "texture": "#0"},
"down": {"uv": [4, 8.5, 3.5, 15.5], "texture": "#0"}
}
},
{
"from": [9, 0, 0],
"to": [10, 1, 14],
"faces": {
"north": {"uv": [11, 0, 11.5, 0.5], "texture": "#0"},
"east": {"uv": [9, 0.5, 16, 1], "texture": "#0"},
"south": {"uv": [12.5, 1, 13, 1.5], "texture": "#0"},
"west": {"uv": [8, 1.5, 15, 2], "texture": "#0"},
"up": {"uv": [3.5, 8, 3, 1], "texture": "#0"},
"down": {"uv": [3.5, 9, 3, 16], "texture": "#0"}
}
},
{
"from": [10, 0, 0],
"to": [11, 1, 14],
"faces": {
"north": {"uv": [10.5, 0, 11, 0.5], "texture": "#0"},
"east": {"uv": [9, 0.5, 16, 1], "texture": "#0"},
"south": {"uv": [13, 1, 13.5, 1.5], "texture": "#0"},
"west": {"uv": [8, 1.5, 15, 2], "texture": "#0"},
"up": {"uv": [3, 8, 2.5, 1], "texture": "#0"},
"down": {"uv": [3, 9, 2.5, 16], "texture": "#0"}
}
},
{
"from": [11, 0, 0],
"to": [12, 1, 13],
"faces": {
"north": {"uv": [10, 0, 10.5, 0.5], "texture": "#0"},
"east": {"uv": [9.5, 0.5, 16, 1], "texture": "#0"},
"south": {"uv": [13.5, 1, 14, 1.5], "texture": "#0"},
"west": {"uv": [8, 1.5, 14.5, 2], "texture": "#0"},
"up": {"uv": [2.5, 8, 2, 1.5], "texture": "#0"},
"down": {"uv": [2.5, 9.5, 2, 16], "texture": "#0"}
}
},
{
"from": [12, 0, 1],
"to": [13, 1, 12],
"faces": {
"north": {"uv": [9.5, 0, 10, 0.5], "texture": "#0"},
"east": {"uv": [10, 0.5, 15.5, 1], "texture": "#0"},
"south": {"uv": [14, 1, 14.5, 1.5], "texture": "#0"},
"west": {"uv": [8.5, 1.5, 14, 2], "texture": "#0"},
"up": {"uv": [2, 7.5, 1.5, 2], "texture": "#0"},
"down": {"uv": [2, 10, 1.5, 15.5], "texture": "#0"}
}
},
{
"from": [13, 0, 2],
"to": [14, 1, 9],
"faces": {
"north": {"uv": [9, 0, 9.5, 0.5], "texture": "#0"},
"east": {"uv": [11.5, 0.5, 15, 1], "texture": "#0"},
"south": {"uv": [14.5, 1, 15, 1.5], "texture": "#0"},
"west": {"uv": [9, 1.5, 12.5, 2], "texture": "#0"},
"up": {"uv": [1.5, 7, 1, 3.5], "texture": "#0"},
"down": {"uv": [1.5, 11.5, 1, 15], "texture": "#0"}
}
},
{
"from": [14, 0, 3],
"to": [15, 1, 6],
"faces": {
"north": {"uv": [8.5, 0, 9, 0.5], "texture": "#0"},
"east": {"uv": [13, 0.5, 14.5, 1], "texture": "#0"},
"south": {"uv": [15, 1, 15.5, 1.5], "texture": "#0"},
"west": {"uv": [9.5, 1.5, 11, 2], "texture": "#0"},
"up": {"uv": [1, 6.5, 0.5, 5], "texture": "#0"},
"down": {"uv": [1, 13, 0.5, 14.5], "texture": "#0"}
}
},
{
"from": [3, 0, 6],
"to": [8, 1, 13],
"faces": {
"north": {"uv": [12, 0, 14.5, 0.5], "texture": "#0"},
"east": {"uv": [9.5, 0.5, 13, 1], "texture": "#0"},
"south": {"uv": [9.5, 1, 12, 1.5], "texture": "#0"},
"west": {"uv": [11, 1.5, 14.5, 2], "texture": "#0"},
"up": {"uv": [6.5, 5, 4, 1.5], "texture": "#0"},
"down": {"uv": [6.5, 9.5, 4, 13], "texture": "#0"}
}
}
],
"display": {
"thirdperson_righthand": {
"rotation": [59.26, -12.02, 115.05],
"translation": [-3, 0, -1.25],
"scale": [0.35547, 0.35547, 0.35547]
},
"thirdperson_lefthand": {
"rotation": [-83.24, -12.02, 115.05],
"translation": [-3, 0.25, 1.5],
"scale": [0.35547, 0.35547, 0.35547]
},
"firstperson_righthand": {
"rotation": [21.59, 29.56, 67.84],
"translation": [2.25, 1.75, -1.25]
},
"firstperson_lefthand": {
"rotation": [-10.25, 47.06, -105.98],
"translation": [4.75, 1.25, -1.25],
"scale": [0.62695, 0.62695, 0.62695]
},
"ground": {
"translation": [0, 8.75, 0]
},
"gui": {
"rotation": [-84.93, 79.56, 178.36],
"translation": [0.5, 0, 0]
},
"head": {
"translation": [0, 15.75, 0]
},
"fixed": {
"rotation": [-70.17, -81.22, 11.15],
"translation": [0, 0, -8]
},
"on_shelf": {
"rotation": [-104.18, 70.19, -166.68],
"translation": [1.25, 0, 4.25]
}
},
"groups": [
{
"name": "group",
"origin": [8, 8, 8],
"scope": 0,
"color": 0,
"children": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
}
]
}
@@ -0,0 +1,226 @@
{
"format_version": "1.21.11",
"credit": "Made with Blockbench",
"textures": {
"0": "sfcraft:item/coal_block",
"3": "sfcraft:item/redstone_block",
"8": "sfcraft:item/bomb_corner",
"9": "sfcraft:item/obsidian_bomb_surface",
"particle": "sfcraft:item/coal_block"
},
"elements": [
{
"from": [0, 0, 0],
"to": [16, 16, 16],
"rotation": {"angle": 0, "axis": "y", "origin": [7, -2, 6]},
"faces": {
"north": {"uv": [0, 0, 16, 16], "texture": "#9"},
"east": {"uv": [0, 0, 16, 16], "texture": "#9"},
"south": {"uv": [0, 0, 16, 16], "texture": "#9"},
"west": {"uv": [0, 0, 16, 16], "texture": "#9"},
"up": {"uv": [0, 0, 16, 16], "texture": "#0"},
"down": {"uv": [0, 0, 16, 16], "texture": "#0"}
}
},
{
"from": [13.5, -0.5, -0.5],
"to": [16.5, 3, 2.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, -1, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [-0.5, 13, 13.5],
"to": [2.5, 16.5, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, 12.5, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [13.5, 13, 13.5],
"to": [16.5, 16.5, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, 12.5, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [-0.5, 13, -0.5],
"to": [3, 16.5, 3],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, 12.5, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [13.5, 13, -0.5],
"to": [16.5, 16.5, 2.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, 12.5, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [-0.5, -0.5, -0.5],
"to": [3, 3, 3],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, -1, -1]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [13.5, -0.5, 13.5],
"to": [16.5, 3, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [13, -1, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [-0.5, -0.5, 13.5],
"to": [2.5, 3, 16.5],
"rotation": {"angle": 0, "axis": "y", "origin": [-1, -1, 13]},
"faces": {
"north": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"east": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"south": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"west": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"up": {"uv": [0, 0, 5, 4.25], "texture": "#8"},
"down": {"uv": [0, 0, 5, 4.25], "texture": "#8"}
}
},
{
"from": [4.75, 16, 4.75],
"to": [10.75, 16.5, 10.75],
"rotation": {"angle": 0, "axis": "y", "origin": [7, 16, 7]},
"faces": {
"north": {"uv": [6, 0, 12, 1], "texture": "#8"},
"east": {"uv": [6, 1, 12, 2], "texture": "#8"},
"south": {"uv": [6, 2, 12, 3], "texture": "#8"},
"west": {"uv": [6, 3, 12, 4], "texture": "#8"},
"up": {"uv": [6, 6, 0, 0], "texture": "#8"},
"down": {"uv": [6, 6, 0, 12], "texture": "#8"}
}
},
{
"from": [7, 16, 7],
"to": [9, 19, 9],
"rotation": {"angle": 0, "axis": "y", "origin": [7, 16, 7]},
"faces": {
"north": {"uv": [0, 0, 2, 3], "texture": "#3"},
"east": {"uv": [0, 0, 2, 3], "texture": "#3"},
"south": {"uv": [0, 0, 2, 3], "texture": "#3"},
"west": {"uv": [0, 0, 2, 3], "texture": "#3"},
"up": {"uv": [0, 0, 2, 3], "texture": "#3"},
"down": {"uv": [0, 0, 2, 3], "texture": "#3"}
}
},
{
"from": [6, 18, 6],
"to": [8, 21, 8],
"rotation": {"angle": 0, "axis": "y", "origin": [6, 18, 6]},
"faces": {
"north": {"uv": [0, 0, 2, 3], "texture": "#3"},
"east": {"uv": [0, 0, 2, 3], "texture": "#3"},
"south": {"uv": [0, 0, 2, 3], "texture": "#3"},
"west": {"uv": [0, 0, 2, 3], "texture": "#3"},
"up": {"uv": [0, 0, 2, 3], "texture": "#3"},
"down": {"uv": [0, 0, 2, 3], "texture": "#3"}
}
}
],
"display": {
"thirdperson_righthand": {
"rotation": [33, 0, 0],
"translation": [0, 1, 0],
"scale": [0.14062, 0.14062, 0.14062]
},
"thirdperson_lefthand": {
"rotation": [33, 0, 0],
"translation": [0, 0.25, 0.25],
"scale": [0.14062, 0.14062, 0.14062]
},
"firstperson_righthand": {
"rotation": [33, 0, 0],
"translation": [0, 2.25, 0],
"scale": [0.31055, 0.31055, 0.31055]
},
"firstperson_lefthand": {
"rotation": [33, 0, 0],
"translation": [0, 2.25, 0],
"scale": [0.31055, 0.31055, 0.31055]
},
"ground": {
"scale": [0.3, 0.3, 0.3]
},
"gui": {
"rotation": [30.5, -45.25, 0],
"scale": [0.42, 0.42, 0.42]
},
"head": {
"translation": [0, 10.75, 0],
"scale": [0.31641, 0.31641, 0.31641]
},
"fixed": {
"scale": [0.3, 0.3, 0.3]
},
"on_shelf": {
"scale": [0.60938, 0.60938, 0.60938]
}
},
"groups": [
0,
{
"name": "corner",
"origin": [13, -1, -1],
"scope": 0,
"color": 0,
"children": [1, 2, 3, 4, 5, 6, 7, 8]
},
9,
{
"name": "fuse",
"origin": [7, 16, 7],
"scope": 0,
"color": 0,
"children": [10, 11]
}
]
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "sfcraft:item/pearl_token"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "sfcraft:item/exp_totem"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"sfcraft:amethyst_cauldron",
"sfcraft:gravity_crystal"
]
}
@@ -0,0 +1,5 @@
{
"values": [
"sfcraft:beheading"
]
}
@@ -0,0 +1,5 @@
{
"values": [
"sfcraft:beheading"
]
}
@@ -0,0 +1,5 @@
{
"values": [
"sfcraft:beheading"
]
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"sfcraft:weaponsmith/4/fire_charge_emerald_bomb"
]
}
@@ -0,0 +1,22 @@
{
"anvil_cost": 4,
"description": {
"translate": "enchantment.sfcraft.beheading",
"fallback": "斩首"
},
"max_cost": {
"base": 65,
"per_level_above_first": 9
},
"max_level": 3,
"min_cost": {
"base": 15,
"per_level_above_first": 9
},
"primary_items": "#minecraft:enchantable/sweeping",
"slots": [
"mainhand"
],
"supported_items": "#minecraft:enchantable/sweeping",
"weight": 2
}
@@ -0,0 +1,49 @@
{
"type": "minecraft:block",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:alternatives",
"children": [
{
"type": "minecraft:item",
"name": "sfcraft:amethyst_cauldron",
"conditions": [
{
"condition": "minecraft:match_tool",
"predicate": {
"predicates": {
"minecraft:enchantments": [
{
"enchantments": "minecraft:silk_touch",
"levels": {
"min": 1
}
}
]
}
}
}
]
},
{
"type": "minecraft:group",
"children": [
{
"type": "minecraft:item",
"name": "minecraft:iron_ingot"
},
{
"type": "minecraft:item",
"name": "minecraft:amethyst_shard"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,19 @@
{
"type": "minecraft:block",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "sfcraft:gravity_crystal"
}
],
"conditions": [
{
"condition": "minecraft:survives_explosion"
}
]
}
]
}
@@ -0,0 +1,9 @@
{
"type": "minecraft:smelting",
"ingredient": "sfcraft:amethyst_cauldron_blank",
"result": {
"id": "sfcraft:amethyst_cauldron"
},
"experience": 0.5,
"cookingtime": 200
}
@@ -0,0 +1,15 @@
{
"type": "minecraft:crafting_shaped",
"pattern": [
"I I",
"IBI",
"III"
],
"key": {
"I": "minecraft:iron_ingot",
"B": "minecraft:amethyst_block"
},
"result": {
"id": "sfcraft:amethyst_cauldron_blank"
}
}
@@ -0,0 +1,10 @@
{
"type": "minecraft:crafting_shapeless",
"ingredients": [
"sfcraft:bomb",
"minecraft:blaze_powder"
],
"result": {
"id": "sfcraft:blaze_bomb"
}
}
@@ -0,0 +1,15 @@
{
"type": "minecraft:crafting_shaped",
"pattern": [
" I ",
"IBI",
" I "
],
"key": {
"I": "minecraft:iron_ingot",
"B": "minecraft:bundle"
},
"result": {
"id": "sfcraft:lunch_box"
}
}
@@ -0,0 +1,18 @@
{
"type": "minecraft:crafting_shapeless",
"ingredients": [
"sfcraft:bomb",
"sfcraft:bomb",
"sfcraft:bomb",
"sfcraft:bomb",
"sfcraft:bomb",
"sfcraft:bomb",
"sfcraft:bomb",
"sfcraft:bomb",
"minecraft:crying_obsidian"
],
"result": {
"id": "sfcraft:obsidian_bomb",
"count": 8
}
}
@@ -0,0 +1,10 @@
{
"type": "minecraft:crafting_shapeless",
"ingredients": [
"minecraft:ender_pearl",
"minecraft:amethyst_shard"
],
"result": {
"id": "sfcraft:pearl_token"
}
}
@@ -0,0 +1,20 @@
{
"wants": {
"id": "minecraft:fire_charge",
"count": 4
},
"additional_wants": {
"id": "minecraft:emerald",
"count": {
"type": "minecraft:uniform",
"min": 1.0,
"max": 8.0
}
},
"gives": {
"id": "sfcraft:bomb",
"count": 4
},
"max_uses": 12,
"reputation_discount": 0.2
}
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more