Get ready
选对下载包,第一步就会顺利。
准备一台 Windows 电脑、Minecraft Java 版和 IntelliJ IDEA。这个项目使用 Minecraft 1.20、JDK 21、Fabric Loader 0.16.14、Fabric API 0.83.0+1.20。请先在新的测试世界体验。
完整学习工程
只包含女巫项目需要的代码、Gradle 文件、模型和纹理。解压后用 IDEA 打开,可以运行、修改和构建。
下载源码 ZIP ↓原工程增量更新
新增女巫代码与资源,并提供两个已经合并的入口文件。附更新前核对说明和编译好的原工程 MOD。
下载更新 ZIP ↓独立试玩安装包
包含编译好的女巫 JAR 和中文安装说明。Minecraft 1.20 的 Fabric 游戏还需要相同版本的 Fabric API。
下载试玩 ZIP ↓学习工程用于从零制作;增量更新用于已有的 simon-minecraft-template-1.20;试玩包用于新建测试世界。学习工程与原工程的 MOD ID 都是 simon-minecraft,同一个游戏的 mods 文件夹中只能放其中一个版本。
建议节奏:每天完成 1–2 个阶段,做完验收再继续。不会 Java 时,可以回看 Java 基础课;不会运行 Fabric 时,回看 MOD 第 1 课。模型可以在 女巫模型页旋转观察,也可以下载到 Blockbench。
先玩成功:把伙伴带进世界
这一步的小目标:在测试世界看到女巫,切换步行和飞行模式。

先用完整学习工程做出第一次成功。暂时不用自己写所有代码,先确认电脑能运行它。
- 下载并解压。点击“完整学习工程”,把 ZIP 全部解压到一个新文件夹,例如
C:\MinecraftProjects\witch-companion。进入文件夹后应该直接看到build.gradle、gradlew.bat和src。 - 在 IDEA 中打开。选择 File → Open,选刚才的文件夹。首次打开需要联网下载 Gradle、Minecraft 和依赖,等待同步结束。
- 选择 JDK 21。File → Project Structure → Project SDK 选 21;Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JVM 也选 21。
- 先构建,再启动。打开 IDEA 底部 Terminal,依次运行下面两条命令。第一条出现
BUILD SUCCESSFUL后,再运行第二条。 - 建立测试世界。在游戏里选择 Singleplayer / 单人游戏 → Create New World / 创建新世界,选择创造模式,开启作弊,难度选普通。作弊用于后面的时间和僵尸测试。
- 看到女巫。进入世界后等约一秒,转头看看附近。向前走几步,观察她跟上来。右键女巫会依次切换自动、步行、飞行。
在工程根目录的 PowerShell / IDEA Terminal 运行
.\gradlew.bat build
.\gradlew.bat runClient游戏中按 T 打开聊天,输入下面的命令。伙伴命令用于控制你自己的女巫,不需要管理员权限。
第一次试玩的三个命令
/witch mode walk
/witch mode fly
/witch mode auto我已经有 simon-minecraft-template-1.20,该怎么加进去?
- 关闭正在运行的游戏,先备份工程的
src文件夹。 - 打开增量更新包,按照
UPLOAD-README.txt把新增的witchJava 包、模型和纹理复制到相同路径。 - 把下面两组注册代码加到原有入口方法中。原来其他方块、物品和实体的注册要保留。
- 如果你的入口文件与更新包对应的原版完全一致,可以使用包内已合并的入口;如果你自己改过它们,就只加入注册代码。
- 回到工程根目录运行
.\gradlew.bat build,成功后运行.\gradlew.bat runClient。
SimonMinecraft.java → onInitialize() 内新增
com.malaoshi.simon.witch.WitchContent.register();SimonMinecraftClient.java → onInitializeClient() 内新增
com.malaoshi.simon.witch.client.WitchCompanionRenderer.registerResources();
EntityRendererRegistry.register(
com.malaoshi.simon.witch.WitchContent.WITCH,
com.malaoshi.simon.witch.client.WitchCompanionRenderer::new
);在测试世界看到女巫,切换步行和飞行模式。 做到后,再进入下一阶段。
设计玩法:给伙伴一张任务卡
这一步的小目标:说清楚陪伴、夜战和召回的先后顺序。

NPC 是由程序控制的角色。我们的女巫有三份工作:陪伴你、在夜晚保护你、在掉队后找到你。先写清楚这些规则,写代码时就知道先做什么。
| 看到的情况 | 女巫要做什么 |
|---|---|
| 玩家刚进入世界 | 服务器为这个玩家准备一个伙伴。 |
| 平时没有敌人 | 按模式步行跟随,或骑扫帚在附近飞行。 |
| 晚上,附近有僵尸 | 优先看向并攻击僵尸,结束后继续陪伴。 |
| 距离玩家超过 28 格 | 在玩家附近寻找安全位置召回。 |
| 玩家去了另一个维度 | 旧伙伴退出,在新维度重建一个伙伴。 |
自动模式:约每 20 秒轮换一次步行和飞行。步行模式:一直在地面跟随。飞行模式:一直骑扫帚围绕玩家飞行。攻击优先级高于日常陪伴。
在纸上画一个玩家和女巫,再画三条箭头:走路、飞行、保护。每条箭头下面写一句“什么时候发生”。这张任务卡就是你的第一份游戏设计。
说清楚陪伴、夜战和召回的先后顺序。 做到后,再进入下一阶段。
连接模型:外形从哪里来
这一步的小目标:找到模型文件、6 张纹理和渲染器,知道它们如何合作。

模型告诉游戏“长什么样”,程序告诉游戏“做什么”。本项目沿用网站的女巫模型,保留了帽子上的猫、披风、扫帚、衣服和原始像素纹理。
- 打开 女巫 3D 模型页,拖动旋转,认出身体、帽子、披风和扫帚。
- 在工程中找到
src/main/resources/assets/simon-minecraft/witch/mesh.json。它保存每个贴图面的顶点、UV 和法线。 - 打开
textures/entity/witch,核对texture_0.png到texture_5.png共 6 张纹理。 - 打开
WitchCompanionRenderer.java。它加载网格,给每个面选择纹理,并把行走或飞行姿势画出来。
- 顶点
- 方块面的四个角,决定外形在哪里。
- UV
- 从纹理图片上取哪一小块像素,像给方块贴一张小贴纸。
- 法线
- 这个面朝向哪里,让光照能分清明暗。
网页模型不能直接放进 Java 物品 JSON:它有多个纹理和任意角度的部件。这里已经转换为 384 个贴图面的网格,由专门的实体渲染器绘制。你不需要先安装额外的动画模组。
查看完整文件:client/WitchCompanionRenderer.java
src/main/java/com/malaoshi/simon/witch/client/WitchCompanionRenderer.java
package com.malaoshi.simon.witch.client;
import com.google.gson.*;
import com.malaoshi.simon.SimonMinecraft;
import com.malaoshi.simon.witch.WitchCompanionEntity;
import net.fabricmc.fabric.api.resource.*;
import net.minecraft.client.render.*;
import net.minecraft.client.render.entity.*;
import net.minecraft.client.util.math.MatrixStack;
import net.minecraft.resource.*;
import net.minecraft.util.Identifier;
import net.minecraft.util.math.RotationAxis;
import org.joml.Vector3f;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
/** 网页原模型逐面导出,保留 64 个方块、原 UV、旋转及 6 张纹理。 */
public class WitchCompanionRenderer extends EntityRenderer<WitchCompanionEntity> {
private record Face(String part, int texture, float[][] points, float[][] uv, float[] normal) {}
private static volatile List<Face> faces = List.of();
private static final Identifier MESH = new Identifier(SimonMinecraft.MOD_ID, "witch/mesh.json");
private static final Identifier[] TEXTURES = new Identifier[6];
static { for (int i = 0; i < 6; i++) TEXTURES[i] = new Identifier(SimonMinecraft.MOD_ID, "textures/entity/witch/texture_" + i + ".png"); }
public WitchCompanionRenderer(EntityRendererFactory.Context context) { super(context); shadowRadius = 0.4f; }
public static void registerResources() {
ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES).registerReloadListener(new SimpleSynchronousResourceReloadListener() {
@Override public Identifier getFabricId() { return new Identifier(SimonMinecraft.MOD_ID, "witch_mesh"); }
@Override public void reload(ResourceManager manager) {
try (Reader reader = new InputStreamReader(manager.getResourceOrThrow(MESH).getInputStream(), StandardCharsets.UTF_8)) {
JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
List<Face> loaded = new ArrayList<>();
for (JsonElement item : json.getAsJsonArray("faces")) {
JsonObject face = item.getAsJsonObject();
loaded.add(new Face(face.get("part").getAsString(), face.get("texture").getAsInt(),
matrix(face.getAsJsonArray("points")), matrix(face.getAsJsonArray("uv")), array(face.getAsJsonArray("normal"))));
}
faces = List.copyOf(loaded);
SimonMinecraft.LOGGER.info("Witch mesh loaded: {} textured faces", faces.size());
} catch (IOException | RuntimeException error) {
throw new IllegalStateException("女巫模型加载失败: " + MESH, error);
}
}
});
}
private static float[] array(JsonArray values) {
float[] result = new float[values.size()];
for (int i = 0; i < values.size(); i++) result[i] = values.get(i).getAsFloat();
return result;
}
private static float[][] matrix(JsonArray values) {
float[][] result = new float[values.size()][];
for (int i = 0; i < values.size(); i++) result[i] = array(values.get(i).getAsJsonArray());
return result;
}
@Override public Identifier getTexture(WitchCompanionEntity entity) { return TEXTURES[0]; }
@Override public void render(WitchCompanionEntity entity, float yaw, float delta, MatrixStack matrices, VertexConsumerProvider buffers, int light) {
matrices.push();
matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(180 - yaw));
matrices.scale(0.0625f, 0.0625f, 0.0625f);
matrices.translate(0, 10, 0);
float time = entity.age + delta;
float step = (float)Math.sin(time * 0.65) * Math.min(1, (float)entity.getVelocity().horizontalLength() * 8) * 28;
for (Face face : faces) {
matrices.push();
if (face.part.equals("broom") && entity.isBroomFlying()) {
// 源扫帚已有 -57° 旋转,再转 -33° 即成为水平扫帚。
matrices.translate(-5.5, -3, 0);
pivot(matrices, 5.5f, 2.7f, .775f, -33, 0, 0);
} else if (face.part.equals("right_leg") || face.part.equals("left_leg")) {
float side = face.part.equals("right_leg") ? 1 : -1;
pivot(matrices, side * 1.9f, 2, 0, entity.isBroomFlying() ? -65 : step * side, 0, 0);
} else if (face.part.equals("right_arm") || face.part.equals("left_arm")) {
float side = face.part.equals("right_arm") ? 1 : -1;
pivot(matrices, side * 5, 13, 0, entity.isBroomFlying() ? -40 : -step * side * .6f, 0, side * 2);
} else if (face.part.equals("cape")) {
pivot(matrices, 0, 15, -6, entity.isBroomFlying() ? 15 : (float)Math.sin(time * .08) * 3, 0, 0);
}
MatrixStack.Entry entry = matrices.peek();
VertexConsumer consumer = buffers.getBuffer(RenderLayer.getEntityCutoutNoCull(TEXTURES[face.texture]));
Vector3f normal = new Vector3f(face.normal[0], face.normal[1], face.normal[2]).normalize();
for (int i = 0; i < 4; i++) {
float[] p = face.points[i], uv = face.uv[i];
consumer.vertex(entry.getPositionMatrix(), p[0], p[1], p[2])
.color(255, 255, 255, 255).texture(uv[0], uv[1])
.overlay(OverlayTexture.DEFAULT_UV).light(light)
.normal(entry.getNormalMatrix(), normal.x, normal.y, normal.z).next();
}
matrices.pop();
}
matrices.pop();
super.render(entity, yaw, delta, matrices, buffers, light);
}
private static void pivot(MatrixStack matrices, float x, float y, float z, float rx, float ry, float rz) {
matrices.translate(x, y, z);
matrices.multiply(RotationAxis.POSITIVE_Z.rotationDegrees(rz));
matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(ry));
matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(rx));
matrices.translate(-x, -y, -z);
}
}
找到模型文件、6 张纹理和渲染器,知道它们如何合作。 做到后,再进入下一阶段。
整理工程:找到每个文件的家
这一步的小目标:核对 Java 包名、资源路径和两个入口。

从 IDEA 左侧 Project 面板展开 src → main。先找到 Java,再找到 resources。文件夹路径和 Java 包名必须对应。
女巫项目的主要文件
src/main/
├─ java/com/malaoshi/simon/
│ ├─ SimonMinecraft.java
│ ├─ SimonMinecraftClient.java
│ └─ witch/
│ ├─ WitchContent.java
│ ├─ WitchCompanionEntity.java
│ ├─ WitchCompanions.java
│ ├─ WitchCompanionState.java
│ └─ client/WitchCompanionRenderer.java
└─ resources/
├─ fabric.mod.json
└─ assets/simon-minecraft/
├─ witch/mesh.json
└─ textures/entity/witch/texture_0.png ... texture_5.png右键 com.malaoshi.simon → New → Package,创建 witch;再在里面创建 client。Java 文件第一行的 package 应与它所在的位置一致。
SimonMinecraft.MOD_ID 的值是 simon-minecraft,资源文件夹也叫 assets/simon-minecraft。拼写、大小写和连字符必须一致。
核对 Java 包名、资源路径和两个入口。 做到后,再进入下一阶段。
注册 NPC:向游戏介绍新伙伴
这一步的小目标:理解实体类型、基础属性和客户端渲染的三次注册。

注册就像给新同学办入学手续。游戏需要知道她的名字、身体大小和基础属性;客户端还需要知道用哪个渲染器画她。
- 实体类型。
WitchContent注册simon-minecraft:witch_companion,并设置实体的碰撞体积。 - 基础属性。用
FabricDefaultAttributeRegistry注册 40 点生命值、0.30 行走速度和 32 格跟随搜索属性。 - 公共入口。
SimonMinecraft.onInitialize()调用WitchContent.register()。 - 客户端入口。
SimonMinecraftClient注册资源加载和实体渲染器。
查看完整文件:WitchContent.java
src/main/java/com/malaoshi/simon/witch/WitchContent.java
package com.malaoshi.simon.witch;
import com.malaoshi.simon.SimonMinecraft;
import net.fabricmc.fabric.api.object.builder.v1.entity.FabricDefaultAttributeRegistry;
import net.fabricmc.fabric.api.object.builder.v1.entity.FabricEntityTypeBuilder;
import net.minecraft.entity.*;
import net.minecraft.registry.*;
import net.minecraft.util.Identifier;
public final class WitchContent {
public static final EntityType<WitchCompanionEntity> WITCH = Registry.register(
Registries.ENTITY_TYPE, new Identifier(SimonMinecraft.MOD_ID, "witch_companion"),
FabricEntityTypeBuilder.create(SpawnGroup.CREATURE, WitchCompanionEntity::new)
.dimensions(EntityDimensions.fixed(0.65f, 1.95f))
.trackRangeBlocks(80).trackedUpdateRate(2).build());
public static void register() {
FabricDefaultAttributeRegistry.register(WITCH, WitchCompanionEntity.attributes());
WitchCompanions.register();
SimonMinecraft.LOGGER.info("Witch companion ready: /witch help");
}
}
实体行为在服务器上决定,外形在客户端画出。单人游戏里也同时存在这两部分。
碰撞体高度是 1.95 格,可以通过普通两格高通道。尖帽属于外形装饰,在低矮室内可能被天花板遮住。
如果一生成就崩溃,先找属性注册;如果代码在运行却看不见模型,先找客户端渲染注册和资源路径。
理解实体类型、基础属性和客户端渲染的三次注册。 做到后,再进入下一阶段。
步行跟随:找到玩家,开始寻路
这一步的小目标:移动十格,观察伙伴追上来并在附近停下。

女巫不能随便认一个最近的人当主人。她保存玩家的 UUID,用这个唯一编号找到真正的主人。
- 在游戏输入
/witch mode walk。 - 向前走约十格,然后停下。
- 观察她向你的侧后方移动,接近后停下。
- 在路上放几个方块,看看她能否绕过去。被挡住约五秒时,她会尝试在你附近安全召回。
WitchCompanionEntity.java 中的寻路核心
navigation.startMovingTo(
destination.x, destination.y, destination.z, 1.1
);destination 是目的地,最后的 1.1 是寻路速度倍率。目的地平时在玩家身边约三格处。每十个游戏刻更新一次位置,靠近时停止寻路。
正常速度下,一秒约有 20 个游戏刻。每 10 刻检查一次,大约每半秒检查一次;机器卡顿时,现实中的等待时间会更长。
查看完整文件:WitchCompanionEntity.java
src/main/java/com/malaoshi/simon/witch/WitchCompanionEntity.java
package com.malaoshi.simon.witch;
import net.minecraft.entity.*;
import net.minecraft.entity.attribute.*;
import net.minecraft.entity.data.*;
import net.minecraft.entity.mob.PathAwareEntity;
import net.minecraft.entity.mob.ZombieEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.entity.ai.goal.SwimGoal;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.particle.ParticleTypes;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.sound.SoundEvents;
import net.minecraft.text.Text;
import net.minecraft.util.*;
import net.minecraft.util.math.*;
import net.minecraft.world.World;
import java.util.*;
public class WitchCompanionEntity extends PathAwareEntity {
public static final double GUARD_RADIUS = 16.0;
public static final float MAGIC_DAMAGE = 6.0f;
public static final int ATTACK_INTERVAL = 20; // 20 游戏刻约等于 1 秒
private static final TrackedData<Boolean> FLYING = DataTracker.registerData(WitchCompanionEntity.class, TrackedDataHandlerRegistry.BOOLEAN);
private static final TrackedData<Integer> MODE = DataTracker.registerData(WitchCompanionEntity.class, TrackedDataHandlerRegistry.INTEGER);
private UUID ownerUuid;
private int attackCooldown;
private int stuckTicks;
private Vec3d lastPosition = Vec3d.ZERO;
public WitchCompanionEntity(EntityType<? extends PathAwareEntity> type, World world) {
super(type, world);
setPersistent(); setCustomName(Text.literal("扫帚女巫 · 守护伙伴"));
setCustomNameVisible(true);
}
public static DefaultAttributeContainer.Builder attributes() {
return createMobAttributes().add(EntityAttributes.GENERIC_MAX_HEALTH, 40)
.add(EntityAttributes.GENERIC_MOVEMENT_SPEED, 0.30)
.add(EntityAttributes.GENERIC_FOLLOW_RANGE, 32);
}
@Override protected void initGoals() { goalSelector.add(0, new SwimGoal(this)); }
@Override protected void initDataTracker() {
super.initDataTracker(); dataTracker.startTracking(FLYING, false); dataTracker.startTracking(MODE, 0);
}
public UUID getOwnerUuid() { return ownerUuid; }
public void setOwnerUuid(UUID owner) { ownerUuid = owner; }
public boolean isBroomFlying() { return dataTracker.get(FLYING); }
public int getMode() { return dataTracker.get(MODE); }
public void setMode(int mode) { dataTracker.set(MODE, MathHelper.clamp(mode, 0, 2)); }
public static String modeName(int mode) { return new String[]{"自动陪伴", "步行跟随", "扫帚飞行"}[MathHelper.clamp(mode, 0, 2)]; }
/** 找有地板、无液体、身体放得下的位置;若没有就悬停,避免卡进墙。 */
public void recall(PlayerEntity owner) {
navigation.stop(); setVelocity(Vec3d.ZERO);
BlockPos center = owner.getBlockPos();
for (int dy = 1; dy >= -2; dy--) for (int dx = -3; dx <= 3; dx++) for (int dz = -3; dz <= 3; dz++) {
if (Math.abs(dx) + Math.abs(dz) < 2) continue;
BlockPos feet = center.add(dx, dy, dz);
if (!getWorld().getBlockState(feet.down()).isSolidBlock(getWorld(), feet.down())) continue;
Vec3d pos = Vec3d.ofBottomCenter(feet);
Box box = getDimensions(getPose()).getBoxAt(pos);
if (getWorld().isSpaceEmpty(this, box) && !getWorld().containsFluid(box)) {
refreshPositionAndAngles(pos.x, pos.y, pos.z, owner.getYaw(), 0);
fallDistance = 0; stuckTicks = 0; return;
}
}
Box box = getDimensions(getPose()).getBoxAt(owner.getPos());
if (getWorld().isSpaceEmpty(this, box)) refreshPositionAndAngles(owner.getX(), owner.getY(), owner.getZ(), owner.getYaw(), 0);
dataTracker.set(FLYING, true); setNoGravity(true); fallDistance = 0;
}
@Override public void tick() {
super.tick();
if (!(getWorld() instanceof ServerWorld world) || ownerUuid == null) return;
var state = WitchCompanionState.get(world.getServer());
// 被卸载的旧伙伴重新加载时退出,避免远距离传送后出现重复伙伴。
if (state.disabled.contains(ownerUuid) || !getUuid().equals(state.companions.get(ownerUuid))) { discard(); return; }
ServerPlayerEntity owner = world.getServer().getPlayerManager().getPlayer(ownerUuid);
if (owner == null || owner.getWorld() != world || !owner.isAlive() || owner.isSpectator()) {
navigation.stop(); setVelocity(Vec3d.ZERO); setTarget(null); return;
}
if (squaredDistanceTo(owner) > 28 * 28) recall(owner);
if (attackCooldown > 0) attackCooldown--;
if (age % 10 == 0) {
ZombieEntity nearest = null; double best = Double.MAX_VALUE;
if (world.isNight()) for (ZombieEntity zombie : world.getEntitiesByClass(ZombieEntity.class,
owner.getBoundingBox().expand(GUARD_RADIUS), z -> z.isAlive() && z.squaredDistanceTo(owner) <= GUARD_RADIUS * GUARD_RADIUS)) {
double d = squaredDistanceTo(zombie);
if (d < best) { nearest = zombie; best = d; }
}
setTarget(nearest);
}
LivingEntity enemy = getTarget();
if (enemy != null && (!world.isNight() || !enemy.isAlive() || enemy.squaredDistanceTo(owner) > GUARD_RADIUS * GUARD_RADIUS)) {
setTarget(null); enemy = null;
}
boolean fly = getMode() == 2 || (getMode() == 0 && (age / 400) % 2 == 1);
dataTracker.set(FLYING, fly); setNoGravity(fly); fallDistance = 0;
Vec3d destination;
if (enemy != null) {
destination = enemy.getPos().add(0, fly ? 2.5 : 0, 0);
getLookControl().lookAt(enemy, 30, 30);
if (squaredDistanceTo(enemy) <= 12 * 12 && canSee(enemy) && attackCooldown == 0) {
attackCooldown = ATTACK_INTERVAL;
enemy.damage(getDamageSources().indirectMagic(this, this), MAGIC_DAMAGE);
Vec3d from = getPos().add(0, 1.5, 0), to = enemy.getPos().add(0, 1, 0);
for (int i = 0; i <= 16; i++) {
Vec3d p = from.lerp(to, i / 16.0);
world.spawnParticles(ParticleTypes.WITCH, p.x, p.y, p.z, 1, 0.03, 0.03, 0.03, 0.01);
}
playSound(SoundEvents.ENTITY_EVOKER_CAST_SPELL, 0.55f, 1.3f);
}
} else {
double angle = fly ? age * 0.018 : Math.toRadians(owner.getYaw() + 110);
destination = owner.getPos().add(Math.cos(angle) * 3, fly ? 2.5 + Math.sin(age * 0.08) * 0.25 : 0, Math.sin(angle) * 3);
getLookControl().lookAt(owner, 30, 30);
}
if (fly) {
navigation.stop();
Vec3d delta = destination.subtract(getPos());
Vec3d desired = delta.length() < 0.3 ? Vec3d.ZERO : delta.normalize().multiply(Math.min(0.32, delta.length() * 0.15));
if (horizontalCollision) desired = desired.add(0, 0.18, 0);
setVelocity(getVelocity().lerp(desired, 0.25)); velocityDirty = true;
if (desired.horizontalLengthSquared() > 0.002) {
setYaw((float)(Math.toDegrees(Math.atan2(desired.z, desired.x)) - 90)); bodyYaw = getYaw();
}
if (age % 6 == 0) world.spawnParticles(ParticleTypes.END_ROD, getX(), getY() + 0.2, getZ(), 1, 0.1, 0.05, 0.1, 0);
} else if (age % 10 == 0) {
if (getPos().squaredDistanceTo(destination) > 2.5 && (enemy == null || squaredDistanceTo(enemy) > 7 * 7 || !canSee(enemy)))
navigation.startMovingTo(destination.x, destination.y, destination.z, 1.1);
else navigation.stop();
}
if (age % 20 == 0) {
boolean wantsMove = getPos().squaredDistanceTo(destination) > 16;
stuckTicks = wantsMove && getPos().squaredDistanceTo(lastPosition) < 0.04 ? stuckTicks + 20 : 0;
lastPosition = getPos();
if (stuckTicks >= 100) recall(owner);
if (getHealth() < getMaxHealth()) heal(1);
}
}
@Override public void travel(Vec3d input) {
if (isBroomFlying()) { move(MovementType.SELF, getVelocity()); setVelocity(getVelocity().multiply(0.95)); }
else super.travel(input);
}
@Override public boolean handleFallDamage(float distance, float multiplier, net.minecraft.entity.damage.DamageSource source) { return false; }
@Override protected ActionResult interactMob(PlayerEntity player, Hand hand) {
if (!player.getUuid().equals(ownerUuid)) return ActionResult.PASS;
if (player instanceof ServerPlayerEntity serverPlayer) {
WitchCompanions.setMode(serverPlayer, (getMode() + 1) % 3);
player.sendMessage(Text.literal("伙伴模式:" + modeName(getMode())), true);
}
return ActionResult.success(getWorld().isClient);
}
@Override public void writeCustomDataToNbt(NbtCompound nbt) {
super.writeCustomDataToNbt(nbt);
if (ownerUuid != null) nbt.putUuid("WitchOwner", ownerUuid);
nbt.putInt("WitchMode", getMode());
}
@Override public void readCustomDataFromNbt(NbtCompound nbt) {
super.readCustomDataFromNbt(nbt);
if (nbt.containsUuid("WitchOwner")) ownerUuid = nbt.getUuid("WitchOwner");
setMode(nbt.getInt("WitchMode"));
}
}
移动十格,观察伙伴追上来并在附近停下。 做到后,再进入下一阶段。
扫帚飞行:让位置缓缓变化
这一步的小目标:观察水平扫帚、弯曲双腿和环绕飞行。

飞行有两件事:实体真的在空中移动,同时模型真的换成骑扫帚的姿势。只有改变外形,女巫仍会站在地面。
- 输入
/witch mode fly,等待两三秒。 - 抬头观察:女巫在玩家身边约三格的圆周上飞行,目标高度约为玩家脚下上方 2.5 格。
- 看看扫帚变成水平,双腿和手臂弯曲。
- 走到树旁,观察她的移动。她会碰撞,不会穿墙;碰到障碍时尝试抬高,卡住后尝试召回。
飞行位置与速度的核心思路
double angle = age * 0.018;
Vec3d destination = owner.getPos().add(
Math.cos(angle) * 3,
2.5 + Math.sin(age * 0.08) * 0.25,
Math.sin(angle) * 3
);
setNoGravity(true);
setVelocity(getVelocity().lerp(desired, 0.25));cos 和 sin 配合画一个圆;2.5 是目标高度;lerp 让速度逐渐靠近新速度,转向会更柔和。上面的代码是思路摘录,完整变量和分支在实体文件中。

观察水平扫帚、弯曲双腿和环绕飞行。 做到后,再进入下一阶段。
夜间守护:只攻击附近的僵尸
这一步的小目标:白天不主动攻击,夜间发出魔法,友好动物不受伤。

保护玩家要有明确的边界。女巫只寻找玩家附近 16 格内的僵尸家族,包括普通僵尸以及继承僵尸行为的变种。她不会把猪、村民或玩家当成魔法目标。
- 在新建的创造模式测试世界,确认难度为普通,开启作弊。
- 先输入下面的白天测试命令。僵尸带着头盔,避免因为阳光着火影响观察。
- 等待五秒,观察女巫没有主动施法。然后把时间改成夜晚。
- 女巫在 12 格内且能看见僵尸时发出紫色魔法,每 20 刻最多一次,造成 6 点伤害。
- 再召唤一只猪,确认魔法不会锁定它。测试结束后改回白天。
在游戏聊天中一条一条输入
/difficulty normal
/time set day
/summon minecraft:zombie ~4 ~ ~ {NoAI:1b,ArmorItems:[{},{},{},{id:"minecraft:netherite_helmet",Count:1b}]}
/time set night
/summon minecraft:pig ~2 ~ ~
/time set day施法前必须同时满足的条件(思路摘录)
world.isNight()
&& enemy.isAlive()
&& squaredDistanceTo(enemy) <= 12 * 12
&& canSee(enemy)
&& attackCooldown == 0冷却就像沙漏:施法后先把沙漏设为 20,每刻减一,减到零才允许再次施法。视线检查会阻止隔着实心墙造成伤害。

白天不主动攻击,夜间发出魔法,友好动物不受伤。 做到后,再进入下一阶段。
保存关系:换世界也能跟着你
这一步的小目标:退出重进后保留模式,跨维度保持一个伙伴。

如果只把主人编号放在一个临时变量里,游戏关闭后就忘记了。NBT 是游戏保存数据的格式;PersistentState 是随世界保存的小记事本。
- 实体的小记事本
WitchOwner保存主人 UUID;WitchMode保存自动、步行或飞行。- 世界的小记事本
- 记录每个玩家对应哪一个伙伴,以及是否关闭伙伴。它保存在世界的
simon_witch_companions数据中。 - 跨维度
- 发现主人去了另一个维度,旧伙伴退出,新伙伴使用原来的模式重新生成。
- 旧伙伴重新加载
- 世界记录会检查伙伴 UUID;不再属于当前记录的旧伙伴会退出,避免出现两个伙伴。
每秒检查一次在线玩家。伙伴死亡后会自动重新生成;跨维度重建会恢复生命值,保留模式。输入 /witch remove 后,关闭状态会保存;输入 /witch summon 可以重新开启。
查看完整文件:WitchCompanionState.java
src/main/java/com/malaoshi/simon/witch/WitchCompanionState.java
package com.malaoshi.simon.witch;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.nbt.NbtList;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import java.util.*;
/** 每个玩家只保留一个伙伴编号;关闭和模式选择也会随世界保存。 */
public final class WitchCompanionState extends PersistentState {
public final Map<UUID, UUID> companions = new HashMap<>();
public final Map<UUID, Integer> modes = new HashMap<>();
public final Set<UUID> disabled = new HashSet<>();
public static WitchCompanionState get(MinecraftServer server) {
return server.getOverworld().getPersistentStateManager().getOrCreate(
WitchCompanionState::read, WitchCompanionState::new, "simon_witch_companions");
}
private static WitchCompanionState read(NbtCompound nbt) {
WitchCompanionState state = new WitchCompanionState();
NbtList list = nbt.getList("Players", 10);
for (int i = 0; i < list.size(); i++) {
NbtCompound row = list.getCompound(i);
if (!row.containsUuid("Owner")) continue;
UUID owner = row.getUuid("Owner");
if (row.containsUuid("Companion")) state.companions.put(owner, row.getUuid("Companion"));
state.modes.put(owner, Math.max(0, Math.min(2, row.getInt("Mode"))));
if (row.getBoolean("Disabled")) state.disabled.add(owner);
}
return state;
}
@Override public NbtCompound writeNbt(NbtCompound nbt) {
NbtList list = new NbtList();
Set<UUID> owners = new HashSet<>(companions.keySet());
owners.addAll(disabled); owners.addAll(modes.keySet());
for (UUID owner : owners) {
NbtCompound row = new NbtCompound();
row.putUuid("Owner", owner);
if (companions.containsKey(owner)) row.putUuid("Companion", companions.get(owner));
row.putInt("Mode", modes.getOrDefault(owner, 0));
row.putBoolean("Disabled", disabled.contains(owner));
list.add(row);
}
nbt.put("Players", list);
return nbt;
}
}
查看完整文件:WitchCompanions.java
src/main/java/com/malaoshi/simon/witch/WitchCompanions.java
package com.malaoshi.simon.witch;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.entity.Entity;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.text.Text;
import java.util.UUID;
import static net.minecraft.server.command.CommandManager.literal;
/** 服务器负责生成、跨维度重建和指令;客户端只负责显示。 */
public final class WitchCompanions {
public static void register() {
ServerTickEvents.END_SERVER_TICK.register(server -> {
if (server.getTicks() % 20 != 0) return;
for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) {
if (player.isAlive() && !player.isSpectator()) ensure(player);
}
});
CommandRegistrationCallback.EVENT.register((dispatcher, registry, environment) -> {
var root = literal("witch").requires(source -> source.getEntity() instanceof ServerPlayerEntity)
.executes(ctx -> help(ctx.getSource().getPlayerOrThrow()));
root.then(literal("help").executes(ctx -> help(ctx.getSource().getPlayerOrThrow())));
root.then(literal("summon").executes(ctx -> {
var player = ctx.getSource().getPlayerOrThrow();
var state = WitchCompanionState.get(player.getServer());
state.disabled.remove(player.getUuid()); state.markDirty();
ensure(player); tell(player, "女巫伙伴已开启。右键她切换模式。"); return 1;
}));
root.then(literal("recall").executes(ctx -> {
var player = ctx.getSource().getPlayerOrThrow();
var witch = ensure(player);
if (witch != null) witch.recall(player);
tell(player, witch == null ? "伙伴已关闭,请先输入 /witch summon。" : "伙伴回到你身边了。"); return 1;
}));
root.then(literal("remove").executes(ctx -> {
var player = ctx.getSource().getPlayerOrThrow();
var state = WitchCompanionState.get(player.getServer());
state.disabled.add(player.getUuid());
var witch = find(player.getServer(), state.companions.remove(player.getUuid()));
if (witch != null) witch.discard();
state.markDirty(); tell(player, "伙伴已关闭;/witch summon 可以重新开启。"); return 1;
}));
var mode = literal("mode");
String[] names = {"auto", "walk", "fly"};
for (int i = 0; i < names.length; i++) {
final int selected = i;
mode.then(literal(names[i]).executes(ctx -> {
var player = ctx.getSource().getPlayerOrThrow();
setMode(player, selected);
tell(player, "伙伴模式:" + WitchCompanionEntity.modeName(selected)); return 1;
}));
}
root.then(mode); dispatcher.register(root);
});
}
private static int help(ServerPlayerEntity player) {
tell(player, "女巫伙伴:/witch mode auto|walk|fly;/witch recall;/witch remove;/witch summon。夜晚会攻击你附近的僵尸。");
return 1;
}
private static void tell(ServerPlayerEntity player, String message) {
player.sendMessage(Text.literal(message), false);
}
public static void setMode(ServerPlayerEntity player, int mode) {
var state = WitchCompanionState.get(player.getServer());
state.modes.put(player.getUuid(), mode); state.markDirty();
var witch = ensure(player);
if (witch != null) witch.setMode(mode);
}
public static WitchCompanionEntity find(MinecraftServer server, UUID uuid) {
if (uuid == null) return null;
for (ServerWorld world : server.getWorlds()) {
Entity entity = world.getEntity(uuid);
if (entity instanceof WitchCompanionEntity witch && witch.isAlive()) return witch;
}
return null;
}
public static WitchCompanionEntity ensure(ServerPlayerEntity player) {
var state = WitchCompanionState.get(player.getServer());
UUID owner = player.getUuid();
if (state.disabled.contains(owner)) return null;
var witch = find(player.getServer(), state.companions.get(owner));
if (witch != null && witch.getWorld() != player.getWorld()) {
witch.discard(); witch = null;
}
if (witch == null) {
witch = WitchContent.WITCH.create(player.getServerWorld());
if (witch == null) return null;
witch.setOwnerUuid(owner);
witch.setMode(state.modes.getOrDefault(owner, 0));
// 初始位置必须在主人身边;召回找不到空位时也不会留在 (0,0,0)。
witch.refreshPositionAndAngles(player.getX(), player.getY(), player.getZ(), player.getYaw(), 0);
witch.recall(player);
if (!player.getServerWorld().spawnEntity(witch)) return null;
state.companions.put(owner, witch.getUuid()); state.markDirty();
}
return witch;
}
}
- 切到飞行模式,退出世界,再进入,确认模式保留。
- 输入
/witch remove,退出重进,确认不会再次自动生成。 - 输入
/witch summon,确认女巫重新出现。 - 有条件时进入下界,检查新维度里仍只有一个自己的伙伴。
退出重进后保留模式,跨维度保持一个伙伴。 做到后,再进入下一阶段。
逐项验收:把错误缩小到一步
这一步的小目标:完成步行、飞行、夜战、存档和召回的验收清单。

先判断错误发生在哪一层。一次只测试一个效果,会更容易找到原因。
勾选只保存在当前浏览器,方便下次继续学习。
| 遇到的问题 | 先检查这一处 |
|---|---|
| 找不到 gradlew.bat | Terminal 是否在能看到 build.gradle 的工程根目录?ZIP 是否已全部解压? |
| Java 编译版本错误 | Project SDK 与 Gradle JVM 是否都是 JDK 21? |
| 依赖下载失败 | 检查网络,重新同步 Gradle;首次运行需要下载 Minecraft 资源。 |
| 进世界没有女巫 | 等待一秒,输入 /witch summon;检查公共入口是否调用 WitchContent.register()。 |
| 模型不可见或出现紫黑纹理 | 检查客户端的两行注册和 assets/simon-minecraft 下的网格、6 张 PNG。 |
| 晚上不打僵尸 | 难度不能是和平;僵尸要在玩家附近 16 格,并在女巫 12 格内且视线畅通。 |
| 有两个同名 MOD | mods 文件夹只放一个 simon-minecraft 版本。不要同时装试玩版与原工程版。 |
| 伙伴掉队或卡住 | 先走到空旷地面,再输入 /witch recall。低矮房间可能放不下模型。 |
IDEA 运行报错时,看底部输出里第一条与 witch 相关的错误;游戏日志在工程的 run/logs/latest.log。先记录报错文本与刚才的操作,再修改对应文件。
完成步行、飞行、夜战、存档和召回的验收清单。 做到后,再进入下一阶段。
做自己的版本:一次只改一个数
这一步的小目标:修改伤害或守护半径,记录修改前后的差别。

先完成验收,再改成自己的作品。不要一次改变很多数值:每改一次,运行一次,记录一次。
| 小实验 | 修改哪里 | 观察什么 |
|---|---|---|
| 更轻的魔法 | 实体文件的 MAGIC_DAMAGE:6.0f 改为 4.0f | 相同生命值的僵尸需要更多次施法。 |
| 更慢的冷却 | ATTACK_INTERVAL:20 改为 30 | 施法间隔从约一秒变成约 1.5 秒。 |
| 更小的守护圈 | GUARD_RADIUS:16.0 改为 10.0 | 只主动寻找更靠近玩家的僵尸。 |
| 更低的飞行高度 | destination 中的 2.5 改为 1.8 | 观察扫帚离地更低,是否更容易碰到障碍。 |
今天修改了 ______。修改前的现象是 ______。修改后的现象是 ______。我决定保留 / 撤回这次修改,因为 ______。
完成后运行 .\gradlew.bat build。可安装的 JAR 在 build/libs,选择普通版本;带 -sources 的 JAR 是源代码包。安装到对应的 Fabric 1.20 游戏的 mods 文件夹,同时放入匹配的 Fabric API。
编译好的 JAR、可编辑源工程、玩法与版本说明。自己的存档、账户信息和日志不用放进分享包。
修改伤害或守护半径,记录修改前后的差别。 做到后,再进入下一阶段。
Project notes
把思路带到下一个项目。
你已经完成从玩法设计、模型连接、注册、行为到调试和发布的一条完整路线。下一次制作机器人、小精灵或宠物伙伴时,也可以沿用这张项目地图。
项目版本:2026-10-04。实际游戏检查覆盖自动生成、去重、步行、飞行、昼夜攻击、友好动物、存档关系、远距离召回、跨维度、关闭与重启伙伴,并补充实心墙遮挡检查。本页所有场景图均在 Minecraft 1.20 的独立测试世界实拍。为了看清动作,部分镜头固定了角色位置;夜战镜头给观察玩家加了夜视效果。思维导图用于解释程序步骤。
原工程里的其他模型资源提示属于既有素材;本项目的学习工程只加载女巫需要的资源。测试与运行步骤以本页指定的 1.20 版本为准。
进一步查阅:Fabric 的 Minecraft 1.20 版本说明 · 1.20 寻路实体 API · 1.20 世界存档状态 API
