diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 3c0465a..a950f0b 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1 +1,6 @@
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
+#Wed Jul 22 12:25:27 GMT+08:00 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java b/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java
index 1c3895e..2703d14 100644
--- a/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java
+++ b/src/main/java/io/ib67/sfcraft/SFCraftInitializer.java
@@ -14,6 +14,7 @@ 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.ItemGroupModule;
import io.ib67.sfcraft.module.game.beheading.BeheadingModule;
import io.ib67.sfcraft.module.game.bomb.BombModule;
import io.ib67.sfcraft.module.game.cauldron.CauldronModule;
@@ -24,7 +25,9 @@ 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.ItemGroupService;
import io.ib67.sfcraft.registry.RoomRegistry;
+import io.ib67.sfcraft.registry.itemgroup.PolymerItemGroupService;
import io.ib67.sfcraft.registry.cauldron.SimpleCauldronRecipeRegistry;
import io.ib67.sfcraft.registry.chat.SimpleMessageDecorator;
import io.ib67.sfcraft.registry.event.SFRandomEventRegistry;
@@ -61,6 +64,7 @@ public class SFCraftInitializer extends GuiceModInitializer {
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);
+ binder().bind(ItemGroupService.class).to(PolymerItemGroupService.class).in(Singleton.class);
}
private void registerFeatures() {
@@ -99,6 +103,8 @@ public class SFCraftInitializer extends GuiceModInitializer {
registerFeature(CommanderModule.class);
registerFeature(MountModule.class);
registerFeature(TotemModule.class);
+ // ItemGroupModule 必须最后注册:它冻结前面各模块被动贡献到 ItemGroupService 的物品,构建创意物品栏
+ registerFeature(ItemGroupModule.class);
}
private void registerWebModules() {
diff --git a/src/main/java/io/ib67/sfcraft/mixin/server/ServerWorldMixin.java b/src/main/java/io/ib67/sfcraft/mixin/server/ServerWorldMixin.java
index 165bc9d..4f110a5 100644
--- a/src/main/java/io/ib67/sfcraft/mixin/server/ServerWorldMixin.java
+++ b/src/main/java/io/ib67/sfcraft/mixin/server/ServerWorldMixin.java
@@ -9,7 +9,6 @@ import org.spongepowered.asm.mixin.injection.*;
@Mixin(ServerLevel.class)
public abstract class ServerWorldMixin {
-
@ModifyArg(method = "tickTime", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/storage/ServerLevelData;setGameTime(J)V"))
public long setTimeOfDay(long timeOfDay) {
var _this = ((ServerLevel) (Object) this);
@@ -18,7 +17,7 @@ public abstract class ServerWorldMixin {
var dt = _this.dimensionTypeRegistration();
var clock = dt.value().defaultClock();
if(clock.isEmpty()) return timeOfDay;
- _this.clockManager().addTicks(clock.get(), 10);
+ _this.clockManager().addTicks(clock.get(), 20);
MixinHelper.spedUp = true;
return timeOfDay + 10;
}
diff --git a/src/main/java/io/ib67/sfcraft/module/game/ItemGroupModule.java b/src/main/java/io/ib67/sfcraft/module/game/ItemGroupModule.java
new file mode 100644
index 0000000..d944f63
--- /dev/null
+++ b/src/main/java/io/ib67/sfcraft/module/game/ItemGroupModule.java
@@ -0,0 +1,27 @@
+package io.ib67.sfcraft.module.game;
+
+import com.google.inject.Inject;
+import io.ib67.sfcraft.ServerModule;
+import io.ib67.sfcraft.module.game.cauldron.CauldronModule;
+import io.ib67.sfcraft.registry.ItemGroupService;
+
+/**
+ * Finalizes the mod's creative inventory tab. Must be the LAST feature registered in
+ * {@code registerGameFeatures()} so that, by the time its {@link #onInitialize()} runs, every
+ * other feature module has already contributed its items to {@link ItemGroupService}. This module
+ * only freezes the passively-collected set — it never registers items itself.
+ *
+ * The tab icon is the amethyst cauldron, pulled from {@link CauldronModule} (registered earlier,
+ * so its item is available here) to keep the icon type-safe rather than looked up by string id.
+ */
+public class ItemGroupModule extends ServerModule {
+ @Inject
+ private ItemGroupService itemGroups;
+ @Inject
+ private CauldronModule cauldron;
+
+ @Override
+ public void onInitialize() {
+ itemGroups.freeze(cauldron.getCauldronItem());
+ }
+}
diff --git a/src/main/java/io/ib67/sfcraft/module/game/bomb/BombModule.java b/src/main/java/io/ib67/sfcraft/module/game/bomb/BombModule.java
index d0b4f38..9c11ebb 100644
--- a/src/main/java/io/ib67/sfcraft/module/game/bomb/BombModule.java
+++ b/src/main/java/io/ib67/sfcraft/module/game/bomb/BombModule.java
@@ -4,6 +4,7 @@ import com.google.inject.Inject;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.config.GameConfigService;
import io.ib67.sfcraft.module.game.RegistryHelper;
+import io.ib67.sfcraft.registry.ItemGroupService;
import lombok.Getter;
import net.fabricmc.fabric.api.loot.v3.LootTableEvents;
import net.minecraft.core.component.DataComponents;
@@ -28,6 +29,8 @@ import net.minecraft.world.level.storage.loot.providers.number.UniformGenerator;
public class BombModule extends ServerModule {
@Inject
private GameConfigService config;
+ @Inject
+ private ItemGroupService itemGroups;
private EntityType bombEntityType;
private Item bomb;
@@ -48,6 +51,9 @@ public class BombModule extends ServerModule {
bomb = registerBomb("bomb", BombEntity.BombType.NORMAL);
obsidianBomb = registerBomb("obsidian_bomb", BombEntity.BombType.OBSIDIAN);
blazeBomb = registerBomb("blaze_bomb", BombEntity.BombType.BLAZE);
+ itemGroups.add(bomb);
+ itemGroups.add(obsidianBomb);
+ itemGroups.add(blazeBomb);
registerLootModifiers();
}
diff --git a/src/main/java/io/ib67/sfcraft/module/game/cauldron/CauldronModule.java b/src/main/java/io/ib67/sfcraft/module/game/cauldron/CauldronModule.java
index d4e5a97..7fd40b0 100644
--- a/src/main/java/io/ib67/sfcraft/module/game/cauldron/CauldronModule.java
+++ b/src/main/java/io/ib67/sfcraft/module/game/cauldron/CauldronModule.java
@@ -7,6 +7,7 @@ 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.ItemGroupService;
import lombok.Getter;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
@@ -27,6 +28,8 @@ public class CauldronModule extends ServerModule {
private GameConfigService config;
@Inject
private CauldronRecipeRegistry recipes;
+ @Inject
+ private ItemGroupService itemGroups;
private AmethystCauldronBlock cauldronBlock;
private BlockEntityType cauldronBlockEntity;
@@ -67,5 +70,7 @@ public class CauldronModule extends ServerModule {
.component(DataComponents.ENCHANTMENT_GLINT_OVERRIDE, true)
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.amethyst_cauldron"))
);
+ itemGroups.add(cauldronItem);
+ itemGroups.add(cauldronBlankItem);
}
}
diff --git a/src/main/java/io/ib67/sfcraft/module/game/crystal/GravityCrystalModule.java b/src/main/java/io/ib67/sfcraft/module/game/crystal/GravityCrystalModule.java
index 0b9f291..238051e 100644
--- a/src/main/java/io/ib67/sfcraft/module/game/crystal/GravityCrystalModule.java
+++ b/src/main/java/io/ib67/sfcraft/module/game/crystal/GravityCrystalModule.java
@@ -7,6 +7,7 @@ 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.ItemGroupService;
import io.ib67.sfcraft.registry.cauldron.CauldronRecipe;
import lombok.Getter;
import net.minecraft.core.component.DataComponents;
@@ -31,6 +32,8 @@ public class GravityCrystalModule extends ServerModule {
private GameConfigService config;
@Inject
private CauldronRecipeRegistry cauldronRecipes;
+ @Inject
+ private ItemGroupService itemGroups;
private GravityCrystalBlock crystalBlock;
private BlockEntityType crystalBlockEntity;
@@ -63,6 +66,7 @@ public class GravityCrystalModule extends ServerModule {
.component(DataComponents.ENCHANTMENT_GLINT_OVERRIDE, true)
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.gravity_crystal"))
);
+ itemGroups.add(crystalItem);
registerRecipes();
// /sfcraft reload 后重建配方,应用最新的 reactionTicks
diff --git a/src/main/java/io/ib67/sfcraft/module/game/lunchbox/LunchBoxModule.java b/src/main/java/io/ib67/sfcraft/module/game/lunchbox/LunchBoxModule.java
index 5c77c25..ae3a46d 100644
--- a/src/main/java/io/ib67/sfcraft/module/game/lunchbox/LunchBoxModule.java
+++ b/src/main/java/io/ib67/sfcraft/module/game/lunchbox/LunchBoxModule.java
@@ -1,7 +1,9 @@
package io.ib67.sfcraft.module.game.lunchbox;
+import com.google.inject.Inject;
import io.ib67.sfcraft.ServerModule;
import io.ib67.sfcraft.module.game.RegistryHelper;
+import io.ib67.sfcraft.registry.ItemGroupService;
import lombok.Getter;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
@@ -15,6 +17,9 @@ import net.minecraft.world.item.component.Consumables;
*/
@Getter
public class LunchBoxModule extends ServerModule {
+ @Inject
+ private ItemGroupService itemGroups;
+
private Item lunchBox;
@Override
@@ -30,5 +35,6 @@ public class LunchBoxModule extends ServerModule {
.component(DataComponents.FOOD, new FoodProperties(0, 0.0F, true))
.component(DataComponents.CONSUMABLE, Consumables.DEFAULT_FOOD)
);
+ itemGroups.add(lunchBox);
}
}
diff --git a/src/main/java/io/ib67/sfcraft/module/game/token/TokenModule.java b/src/main/java/io/ib67/sfcraft/module/game/token/TokenModule.java
index 38c4fc8..0ea39c3 100644
--- a/src/main/java/io/ib67/sfcraft/module/game/token/TokenModule.java
+++ b/src/main/java/io/ib67/sfcraft/module/game/token/TokenModule.java
@@ -6,6 +6,7 @@ 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.ItemGroupService;
import io.ib67.sfcraft.registry.cauldron.CauldronRecipe;
import lombok.Getter;
import net.minecraft.core.component.DataComponents;
@@ -30,6 +31,8 @@ public class TokenModule extends ServerModule {
private GameConfigService config;
@Inject
private CauldronRecipeRegistry cauldronRecipes;
+ @Inject
+ private ItemGroupService itemGroups;
private Item pearlToken;
private ReversePearlTokenItem reversePearlToken;
@@ -55,6 +58,8 @@ public class TokenModule extends ServerModule {
.durability(config.get().reverseToken.maxUses)
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.reverse_pearl_token"))
);
+ itemGroups.add(pearlToken);
+ itemGroups.add(reversePearlToken);
registerRecipes();
SFConfigReload.EVENT.register(() -> {
registeredRecipes.forEach(cauldronRecipes::unregister);
diff --git a/src/main/java/io/ib67/sfcraft/module/game/totem/TotemModule.java b/src/main/java/io/ib67/sfcraft/module/game/totem/TotemModule.java
index 005dfde..be1601c 100644
--- a/src/main/java/io/ib67/sfcraft/module/game/totem/TotemModule.java
+++ b/src/main/java/io/ib67/sfcraft/module/game/totem/TotemModule.java
@@ -7,6 +7,7 @@ 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.ItemGroupService;
import io.ib67.sfcraft.registry.cauldron.CauldronRecipe;
import lombok.Getter;
import net.minecraft.advancements.triggers.CriteriaTriggers;
@@ -40,6 +41,8 @@ public class TotemModule extends ServerModule {
private GameConfigService config;
@Inject
private CauldronRecipeRegistry cauldronRecipes;
+ @Inject
+ private ItemGroupService itemGroups;
private Item standingFirmTotem;
@@ -68,6 +71,7 @@ public class TotemModule extends ServerModule {
// 效果改由 mixin 完全接管,复用 DeathProtection.TOTEM_OF_UNDYING 常量(见 tryProtect)。
.component(DataComponents.ITEM_NAME, Component.translatable("item.sfcraft.standing_firm_totem"))
);
+ itemGroups.add(standingFirmTotem);
registerRecipes();
// /sfcraft reload 后重建配方,应用最新的 reactionTicks
SFConfigReload.EVENT.register(() -> {
diff --git a/src/main/java/io/ib67/sfcraft/registry/ItemGroupService.java b/src/main/java/io/ib67/sfcraft/registry/ItemGroupService.java
new file mode 100644
index 0000000..4ff8d96
--- /dev/null
+++ b/src/main/java/io/ib67/sfcraft/registry/ItemGroupService.java
@@ -0,0 +1,23 @@
+package io.ib67.sfcraft.registry;
+
+import net.minecraft.world.level.ItemLike;
+
+/**
+ * Collects the mod's items into a single server-side creative inventory tab (later synced to
+ * Polymer-compatible clients). Feature modules contribute their items via {@link #add(ItemLike)}
+ * in {@code onInitialize()}; a single finalizer module calls {@link #freeze(ItemLike)} last —
+ * after every other module has registered — to build and register the tab from what was collected.
+ *
+ * Collection is passive: the service only records what modules push to it and does not itself
+ * know about any concrete feature. Adding after {@link #freeze(ItemLike)} is an error.
+ */
+public interface ItemGroupService {
+ /** Contribute an item to the creative tab. Must be called before {@link #freeze(ItemLike)}. */
+ void add(ItemLike item);
+
+ /**
+ * Build and register the creative tab from everything collected so far, using {@code icon}
+ * as the tab icon. Must be called exactly once, after all {@link #add(ItemLike)} calls.
+ */
+ void freeze(ItemLike icon);
+}
diff --git a/src/main/java/io/ib67/sfcraft/registry/itemgroup/PolymerItemGroupService.java b/src/main/java/io/ib67/sfcraft/registry/itemgroup/PolymerItemGroupService.java
new file mode 100644
index 0000000..d5d05a9
--- /dev/null
+++ b/src/main/java/io/ib67/sfcraft/registry/itemgroup/PolymerItemGroupService.java
@@ -0,0 +1,53 @@
+package io.ib67.sfcraft.registry.itemgroup;
+
+import eu.pb4.polymer.core.api.item.PolymerCreativeModeTabUtils;
+import io.ib67.sfcraft.SFCraft;
+import io.ib67.sfcraft.registry.ItemGroupService;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.Identifier;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.ItemLike;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Default {@link ItemGroupService} backed by Polymer's server-side creative tabs
+ * ({@link PolymerCreativeModeTabUtils}). Items are kept in insertion order and de-duplicated;
+ * {@link #freeze(ItemLike)} builds the tab and registers it under {@code sfcraft:general},
+ * titled by the {@code itemGroup.sfcraft.general} translation key (resolved client-side via the
+ * server resource pack).
+ */
+public class PolymerItemGroupService implements ItemGroupService {
+ private static final Identifier ID = Identifier.fromNamespaceAndPath(SFCraft.MOD_ID, "general");
+
+ // LinkedHashSet: stable tab order (module registration order) with de-duplication.
+ private final Set items = new LinkedHashSet<>();
+ private boolean frozen;
+
+ @Override
+ public void add(ItemLike item) {
+ if (frozen) {
+ throw new IllegalStateException("Item group is already frozen; cannot add " + item);
+ }
+ items.add(item);
+ }
+
+ @Override
+ public void freeze(ItemLike icon) {
+ if (frozen) {
+ throw new IllegalStateException("Item group is already frozen");
+ }
+ frozen = true;
+ var tab = PolymerCreativeModeTabUtils.builder()
+ .title(Component.translatable("itemGroup.sfcraft.general"))
+ .icon(() -> new ItemStack(icon))
+ .displayItems((parameters, output) -> {
+ for (ItemLike item : items) {
+ output.accept(item);
+ }
+ })
+ .build();
+ PolymerCreativeModeTabUtils.registerPolymerCreativeModeTab(ID, tab);
+ }
+}
diff --git a/src/main/resources/assets/sfcraft/lang/en_us.json b/src/main/resources/assets/sfcraft/lang/en_us.json
index 4410127..013fb38 100644
--- a/src/main/resources/assets/sfcraft/lang/en_us.json
+++ b/src/main/resources/assets/sfcraft/lang/en_us.json
@@ -1,4 +1,5 @@
{
+ "itemGroup.sfcraft.general": "SFCraft",
"block.sfcraft.amethyst_cauldron": "Amethyst Cauldron",
"block.sfcraft.gravity_crystal": "Gravity Crystal",
"item.sfcraft.amethyst_cauldron_blank": "Amethyst Cauldron Shell",
diff --git a/src/main/resources/assets/sfcraft/lang/zh_cn.json b/src/main/resources/assets/sfcraft/lang/zh_cn.json
index c36e7fc..3dc2c98 100644
--- a/src/main/resources/assets/sfcraft/lang/zh_cn.json
+++ b/src/main/resources/assets/sfcraft/lang/zh_cn.json
@@ -1,4 +1,5 @@
{
+ "itemGroup.sfcraft.general": "SFCraft",
"block.sfcraft.amethyst_cauldron": "紫水晶炼药锅",
"block.sfcraft.gravity_crystal": "重力水晶",
"item.sfcraft.amethyst_cauldron_blank": "紫水晶炼药锅胚体",