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
No files matched your search
@@ -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` 监听器按注册顺序执行
|
||||
@@ -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,随服务器材质包分发
|
||||
@@ -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,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 <x> <y> <z>:在玩家视线前方悬浮一只发光小史莱姆作为导航光标。
|
||||
* 视线与目标方向的偏差在容差角内时,光标锁定在准星上;超出容差则跳到真实目标方向,
|
||||
* 玩家把准星重新对准史莱姆即可回到正确航向。/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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 446 B |
|
After Width: | Height: | Size: 369 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 576 B |
|
After Width: | Height: | Size: 749 B |
|
After Width: | Height: | Size: 889 B |
|
After Width: | Height: | Size: 607 B |
|
After Width: | Height: | Size: 818 B |
|
After Width: | Height: | Size: 545 B |
|
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"
|
||||
}
|
||||
}
|
||||