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

+78
View File
@@ -0,0 +1,78 @@
# sfcraft Agent 工作指南
Fabric **纯服务端** mod,Minecraft **26.2**,Java 25,官方 Mojang 映射,自定义内容经 [Polymer](https://polymer.pb4.eu) 伪装成原版内容。整体结构见 [ARCHITECTURE.md](./ARCHITECTURE.md),先读它再动代码。
## ⚠️ 工作范围(最高优先级规则)
Agent 的主要工作范围**只有**这两个包:
- `io.ib67.sfcraft.module.game` — 游戏玩法模块
- `io.ib67.sfcraft.mixin.gameplay` — 玩法相关 mixin
**其它任何位置在修改之前必须先向用户说明并获得明确许可**,包括但不限于:
- `io.ib67.sfcraft` 其余包(基础设施、DI、callback、registry、其他 module、其他 mixin)
- `SFCraftInitializer`(即使只是给新模块加一行 `registerFeature`)
- `sfcraft.mixins.json`(即使只是加一条 gameplay mixin 条目)
- `src/main/resources/` 下的 assets/data(lang、配方、战利品表、tag…)
- 构建脚本、accesswidener、fabric.mod.json、文档
新特性通常必然涉及 `registerFeature` 注册行、mixins.json 条目、lang 键、配方 JSON 这些"例行越界"——请在给出方案时一并列出这些改动点,**打包征得一次同意**后再动手,不要做完了才说。
## 构建与验证
仓库**没有** gradlew wrapper,使用系统 gradle + sdkman JDK:
```bash
export JAVA_HOME=~/.sdkman/candidates/java/25.0.1-amzn
~/.sdkman/candidates/gradle/9.5.1/bin/gradle compileJava --console=plain # 编译(mixin AP 会校验注入目标)
~/.sdkman/candidates/gradle/9.5.1/bin/gradle runServer --console=plain # dev 服务器(run/ 目录,EULA 已接受)
```
查 26.2 真实签名(解决 cannot find symbol 的最快办法):
```bash
javap -cp ~/.gradle/caches/fabric-loom/minecraftMaven/net/minecraft/minecraft-merged-deobf/26.2/minecraft-merged-deobf-26.2.jar <类全名>
```
## 新增/修改玩法特性的套路
1. 在 `module.game.<feature>` 建包:一个 `ServerModule` 子类 + 该特性的方块/物品/实体类。
2. 注册表内容在模块 `onInitialize()` 里用 `RegistryHelper` 注册(此阶段注册表未冻结、`MinecraftServer` 不可用),实例存模块字段(`@Getter`)。**不要写 `public static void register()` 或静态注册表持有类。**
3. 依赖一律 `@Inject`(如 `GameConfigService`、`CauldronRecipeRegistry`);方块实体/实体拿不到注入,由模块在注册时用工厂闭包把依赖塞进构造器(参考 `CauldronModule`)。
4. mixin 放 `mixin.gameplay`,保持轻薄:逻辑写在模块的公开方法里,mixin 用 `SFCraft.getInjector().getInstance(XxxModule.class)` 一行委托(参考 `MobCommanderMixin`);高频调用路径可参考 `MountLogic` 的惰性缓存。能用 Fabric API 事件或 `SFCallbacks` 回调就不要写 mixin。
5. 需要注册模块时在 `SFCraftInitializer#registerGameFeatures` 加一行(⚠️ 越界,需许可)。**`GameExtensionModule` 必须保持在所有 game 模块之前**——它加载 gameplay.json,后续模块注册物品时会读配置。
6. 编译验证,必要时 runServer 冒烟。
## 编码约定
- **配置**:数值参数进 `GameConfig`(`io.ib67.sfcraft.config`)对应特性的嵌套类,运行期通过注入的 `GameConfigService#get()` **每次现读,不要缓存**(否则 `/sfcraft reload` 不生效)。注册时烘焙进组件的值(如耐久)天然不可热重载,属已知限制。需要在重载时重建的东西(如炼药锅配方)监听 `callback.SFConfigReload`。
- **i18n**:玩家可见文本一律 `Component.translatable`,键加进 `assets/sfcraft/lang/zh_cn.json` **和** `en_us.json`(⚠️ 越界,需许可)。**禁止**硬编码中文/英文 literal。不要引入 server-translations 依赖——玩家装服务器材质包,lang 由客户端解析。物品显示名 = `ITEM_NAME` 组件挂 translatable。
- **纯服务端**:假设代码总在服务端运行;玩家移动是客户端权威,服务端改玩家速度矢量要置 `entity.hurtMarked = true`。
## 踩过的坑
**26.2 Mojmap 重命名**(相对 1.21.x,写代码前先 javap 确认):
- `ResourceLocation` → `net.minecraft.resources.Identifier`(`Identifier.fromNamespaceAndPath`)
- EntityType 常量在 `net.minecraft.world.entity.EntityTypes`(`EntityTypes.SNOWBALL`)
- `Level.isClientSide` 字段私有化 → 用 `isClientSide()`;`Level.random` → `getRandom()`
- BE 存档:`saveAdditional(ValueOutput)` / `loadAdditional(ValueInput)`,列表用 `out.list(name, Codec)` / `in.listOrEmpty(...)`
- `BlockBehaviour.onRemove` 已删 → 覆写 `BlockEntity.preRemoveSideEffects`
- 类移包:`Zombie` → `entity.monster.zombie`,`ThrowableItemProjectile` → `entity.projectile.throwableitemprojectile`,`VillagerProfession` → `entity.npc.villager`
- `MobEffects.SLOWNESS`(不是 MOVEMENT_SLOWDOWN);`SimpleContainer` 没有 addListener 了(覆写 `setChanged()` 持久化)
- `GameProfile` 是 record(`name()`/`id()`);`sendOverlayMessage`/`sendSystemMessage` 取代 `displayClientMessage`;命令权限 `Commands.LEVEL_GAMEMASTERS.check(source.permissions())`
**Polymer(0.17.3+26.2)**:
- `PacketContext` 在 `net.fabricmc.fabric.api.networking.v1.context.PacketContext`
- BE 类型必须 `PolymerBlockUtils.registerBlockEntity`,实体类型必须 `PolymerEntityUtils.registerType`(`RegistryHelper` 已封装)
- 伪装物品尽量选**非方块、无自定义 use 行为**的原版物品:方块物品有放置鬼影;有 use 覆写的物品会吃掉客户端 consumable 预测(进食动画不显示)
- 要让客户端拿到额外组件(如 `food`/`consumable`),覆写 `getPolymerItemStack` 显式复制
- 自定义 MobEffect 必须实现 `PolymerMobEffect` 并返回原版替身(静态非同步注册表)
**加载时序**:
- 初始数据包加载**早于物品组件初始化**,自定义 Codec 里不能用 `ItemStack.CODEC`(报 "Item ... does not have components yet")——只存物品 id(`BuiltInRegistries.ITEM.holderByNameCodec()`),用时再 `new ItemStack(holder)`
- 注册必须在 `onInitialize()`(ModInit);`onEnable` 阶段注册表已冻结
- 模块按 `registerFeature` 顺序初始化;`SFConfigReload` 监听器按注册顺序执行
+100
View File
@@ -0,0 +1,100 @@
# SFCraft 架构
Fabric **纯服务端** mod(`fabric.mod.json` 中 `environment: "server"`),Minecraft **26.2**,Java 25,**官方 Mojang 映射**。所有自定义内容通过 [Polymer](https://polymer.pb4.eu) 伪装成原版内容;玩家使用原版客户端 + 服务器发布的材质包即可游玩。
## 分层
代码全部位于 `io.ib67.sfcraft`,按职责分为三层:
```
io.ib67.sfcraft
├── (根) SFCraft / SFCraftInitializer / ServerModule / Lifecycle
├── init/ GuiceModInitializer(Fabric 入口基类)、PreInitHandler
├── inject/ DI 注解与供给器(@ConfigRoot、@ConfigResource、MinecraftServerSupplier)
├── config/ SFConfig(config.json)、GameConfig + GameConfigService(gameplay.json)
├── callback/ SFCallbacks(玩家事件)、SFConfigReload(配置重载)
├── registry/ 注册表接口(RoomRegistry、RandomEventRegistry、CauldronRecipeRegistry…)
│ └── <name>/ 对应实现(SimpleRoomRegistry、SimpleCauldronRecipeRegistry…)
├── module/ 服务器功能模块(chat/、command/、room/、supervisor/、compat/…)
│ └── game/ ★ 游戏玩法内容,按特性分包(见下)
├── mixin/ 所有 mixin(配置 sfcraft.mixins.json)
└── util/、room/、subserver/、geoip/、entity/ 支撑代码
```
## 启动与生命周期
入口是 `SFCraftInitializer`(继承 `GuiceModInitializer`,同时本身是一个 Guice `AbstractModule`):
1. **ModInit**:创建 Injector(`SFCraft` + initializer 自身两个 Guice module)→ 按 `registerFeature()` 的**注册顺序**逐个实例化 `ServerModule` 并调用 `onInitialize()`。此阶段静态注册表未冻结,**所有注册表写入必须在这里完成**;但 `MinecraftServer` 尚不可用。
2. **SERVER_STARTED**:全部模块切换到 `Lifecycle.State.ENABLED`(触发 `onEnable()`),此时可通过注入的 `MinecraftServerSupplier` 拿到 server。
3. **SERVER_STOPPING**:切换到 `DISABLED`(触发 `onDisable()`)。模块状态变更抛异常则进入 `ERROR`。
DI 约定:
- 服务在 `SFCraftInitializer#registerServices` 绑定(接口 → 实现,单例);功能模块用 `registerFeature()`(自动单例 + 进入初始化列表)。
- 模块内依赖一律 `@Inject` 字段注入。
- **mixin 无法注入**,按既有先例用 `SFCraft.getInjector().getInstance(...)`(见 `EntityMixin`、`MobCommanderMixin`)。
- **方块实体/实体无法注入**,由所属模块在注册时用工厂闭包传入依赖(见 `CauldronModule`:`(pos, state) -> new AmethystCauldronBlockEntity(type, recipes, config, pos, state)`)。
## 配置体系
| 文件 | 载体 | 加载 | 热重载 |
|---|---|---|---|
| `sfcraft/config.json` | `SFConfig`(POJO 单例,直接注入) | `SFCraft#loadConfig` @Provides | 否(重启生效) |
| `sfcraft/gameplay.json` | `GameConfig`(POJO),经 `GameConfigService` 持有 | `GameExtensionModule.onInitialize()` 首次加载 | 是(`/sfcraft reload`) |
| `sfcraft/motd.txt` 等资源 | `@ConfigResource` 字符串 | 各模块 @Provides | MotdModule 监听重载 |
**重载机制**:`/sfcraft reload`(`module.command.ReloadCommandModule`,权限 LEVEL_GAMEMASTERS)只做一件事——触发 `callback.SFConfigReload` 事件。需要热重载的模块自行监听:
- `GameExtensionModule` → 重读 gameplay.json
- `TokenModule` → 重建自己注册的炼药锅配方(应用新 reactionTicks)
- `MotdModule` → 重读 motd.txt 并即时生效
监听器按注册顺序执行;`GameExtensionModule` 注册最早,保证其它监听器执行时读到的已是新配置。战利品概率在数据包加载时取样,改完还需再执行原版 `/reload`。
## 游戏内容层(module.game)
每个玩法特性一个包,包内含一个 `ServerModule`(在 `onInitialize()` 注册该特性的全部注册表内容并持有实例)及其方块/物品/实体类。注册用 `RegistryHelper` 静态工具(自动处理 `setId`、Polymer 的 `registerBlockEntity`/`registerType`)。
| 包 | 模块 | 内容 |
|---|---|---|
| `game/` | `GameExtensionModule` | gameplay.json 生命周期(**必须在其它 game 模块之前注册**——部分模块注册物品时会把配置值烘焙进组件,如反向信物耐久) |
| `game/cauldron/` | `CauldronModule` | 紫水晶炼药锅方块 + BE(吸入掉落物、按配方自动反应)+ 两个物品;配方来自 `registry.CauldronRecipeRegistry`,本模块不写死配方 |
| `game/crystal/` | `GravityCrystalModule` | 重力水晶(伪装重生锚,紫水晶碎片充能,范围内速度矢量加速) |
| `game/bomb/` | `BombModule` | 三种炸弹物品 + 共用 `BombEntity`(伪装雪球)+ 战利品注入(地牢箱、苦力怕) |
| `game/token/` | `TokenModule` | 珍珠信物/反向信物(owner 存 `custom_data`)+ 向炼药锅注册"信物+回响碎片→反向信物"配方 |
| `game/lunchbox/` | `LunchBoxModule` | 午餐盒(`food`+`consumable` 组件驱动原版进食动画,内容存 `container` 组件,GENERIC_9x1 菜单) |
| `game/beheading/` | `BeheadingModule` | 斩首附魔掉头逻辑(附魔本体数据驱动:`data/sfcraft/enchantment/beheading.json`) |
| `game/commander/` | `CommanderModule` | 「统帅」效果注册 + 光环/纽带/整队跟随行为(实例状态);刷新概率入口在 `MobCommanderMixin` |
| `game/mount/` | `MountModule` | 劫掠兽/疣猪兽驯服坐骑交互;`MountLogic` 为静态共享逻辑供 mixin 复用,`MountAccess` 为 mixin 鸭子接口 |
| `game/item/` | — | 跨特性共享的物品基类(`SimplePolymerItem`) |
跨特性协作靠注册表接口解耦:炼药锅系统只消费 `CauldronRecipeRegistry`,信物特性向其贡献配方,二者互不引用。
## Mixin 组织
`io.ib67.sfcraft.mixin`,按用途分包(`sfcraft.mixins.json` 同步维护):
| 包 | 用途 |
|---|---|
| `common/` | 基础设施注入:发布 `SFCallbacks` 事件(EntityMixin、PlayerEntityMixin)、供子 mixin 复用的 shadow(MobEntityMixin) |
| `common/bridge/` | 暴露原版内部状态的 accessor/bridge |
| `common/fixes/` | 原版行为修正(蜜蜂挤压、末影人不消失等) |
| `gameplay/` | ★ 玩法特性 mixin(MobCommanderMixin、ZombieMixin、`mount/` 骑乘覆写);保持轻薄,逻辑委托给 `module.game` 的模块或 `MountLogic` |
| `server/` | 服务端网络/世界层(登录、MOTD、睡眠、furnace ticker…) |
| `server/subserver/` | 多"房间"子服体系(数据隔离、transfer) |
约定:mixin 里尽量不直接调业务方法——要么发布 `SFCallbacks` 回调由模块监听,要么(gameplay)一行委托给模块公开方法。
## i18n
玩家可见文本一律 `Component.translatable(key)`,键值在 `assets/sfcraft/lang/{zh_cn,en_us}.json`。**不引入 server-translations 之类的服务端翻译库**——玩家会安装服务器材质包,lang 文件随包分发,由客户端解析。键名约定:`item.sfcraft.*`、`block.sfcraft.*`、`message.sfcraft.<feature>.*`、`lore.sfcraft.*`、`container.sfcraft.*`、`command.sfcraft.*`、`enchantment.sfcraft.*`。物品显示名用 `ITEM_NAME` 组件挂 translatable(Polymer 会把组件带给客户端,否则客户端显示伪装物品的原版名)。
## 数据驱动内容(resources)
- `data/sfcraft/recipe/` — 合成/熔炼配方(26.2 单数目录名)
- `data/sfcraft/loot_table/blocks/` — 方块战利品表(精准采集用 `alternatives` + `match_tool`)
- `data/sfcraft/enchantment/beheading.json` — 数据驱动附魔(描述用 translate + fallback)
- `data/sfcraft/villager_trade/` + `data/minecraft/tags/villager_trade/` — 26.2 数据驱动村民交易
- `data/minecraft/tags/` — 挖掘工具、附魔池等 tag 合并
- `assets/sfcraft/` — 物品模型/贴图/lang,随服务器材质包分发
+6 -3
View File
@@ -1,4 +1,4 @@
MIT License
MIT License and ARR for assets
Copyright (c) 2024 SaltedFish Club
@@ -10,7 +10,10 @@ copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
copies or substantial portions of the Software. The above copyright notice DOES
NOT APPLY TO artworks and assets included in the Software (./src/main/resources/assets),
which are subject to ALL RIGHT RESERVED by iceBear67.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
@@ -18,4 +21,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.
+19
View File
@@ -18,6 +18,7 @@ repositories {
name "kalculosSnapshots"
url "https://repo.sfclub.cc/snapshots"
}
maven { url 'https://maven.nucleoid.xyz' } // You should have it
}
dependencies {
@@ -35,6 +36,11 @@ dependencies {
}
shadow("io.ib67.kiwi:lang:1.2.3-SNAPSHOT")
shadow('com.github.houbb:pinyin:0.4.0')
def polymerVersion = "0.17.3+26.2"
implementation include("eu.pb4:polymer-core:$polymerVersion")
implementation include("eu.pb4:polymer-virtual-entity:$polymerVersion")
implementation include("eu.pb4:polymer-resource-pack:$polymerVersion")
implementation include("eu.pb4:polymer-blocks:$polymerVersion")
shadow('com.google.inject:guice:7.0.0') {
exclude group: 'com.google.guava', module: 'guava'
exclude group: 'com.google.guava', module: 'listenablefuture'
@@ -85,6 +91,19 @@ java {
withSourcesJar()
}
tasks.register("packageTestResources", Zip) {
archiveFileName = "resources.zip"
destinationDirectory = layout.projectDirectory.dir("run/world")
from("src/main/resources/assets/sfcraft") {
into("assets/sfcraft")
}
}
runServer {
dependsOn packageTestResources
}
loom {
accessWidenerPath = file("src/main/resources/sfcraft.accesswidener")
}
@@ -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"
}
}
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more