独立项目 01 · Fabric 1.20免费公开项目

扫帚女巫:
我的守护伙伴

给 3D 模型一个会陪伴、会保护你的新身份。

她在你附近走路,也会骑着扫帚飞行。晚上遇到僵尸,她就发出魔法保护你。先运行一个成功的版本,再用清楚的小步骤理解并改造它。

11 个学习阶段14 张游戏实景截图10 张思维导图完整 Java 代码
Minecraft 1.20 游戏里的玩家、扫帚女巫、树林与木屋
游戏内实拍:网站原女巫模型已经成为玩家身边的伙伴。场景、树木、方块与玩家均由 Minecraft 1.20 渲染。

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 ↓
已经有 Simon 工程

原工程增量更新

新增女巫代码与资源,并提供两个已经合并的入口文件。附更新前核对说明和编译好的原工程 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。

从游戏设计、3D 模型、Java 行为到测试交付的项目总览
从游戏设计、3D 模型、Java 行为到测试交付的项目总览 · 点击可放大
阶段 01 · 15 分钟

先玩成功:把伙伴带进世界

这一步的小目标:在测试世界看到女巫,切换步行和飞行模式。

游戏内实拍:进入世界后,玩家在林间营地见到自己的女巫伙伴。
游戏内实拍:进入世界后,玩家在林间营地见到自己的女巫伙伴。 · 点击可放大

先用完整学习工程做出第一次成功。暂时不用自己写所有代码,先确认电脑能运行它。

  1. 下载并解压。点击“完整学习工程”,把 ZIP 全部解压到一个新文件夹,例如 C:\MinecraftProjects\witch-companion。进入文件夹后应该直接看到 build.gradle、gradlew.bat 和 src。
  2. 在 IDEA 中打开。选择 File → Open,选刚才的文件夹。首次打开需要联网下载 Gradle、Minecraft 和依赖,等待同步结束。
  3. 选择 JDK 21。File → Project Structure → Project SDK 选 21;Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JVM 也选 21。
  4. 先构建,再启动。打开 IDEA 底部 Terminal,依次运行下面两条命令。第一条出现 BUILD SUCCESSFUL 后,再运行第二条。
  5. 建立测试世界。在游戏里选择 Singleplayer / 单人游戏 → Create New World / 创建新世界,选择创造模式,开启作弊,难度选普通。作弊用于后面的时间和僵尸测试。
  6. 看到女巫。进入世界后等约一秒,转头看看附近。向前走几步,观察她跟上来。右键女巫会依次切换自动、步行、飞行。

在工程根目录的 PowerShell / IDEA Terminal 运行

.\gradlew.bat build
.\gradlew.bat runClient

游戏中按 T 打开聊天,输入下面的命令。伙伴命令用于控制你自己的女巫,不需要管理员权限。

第一次试玩的三个命令

/witch mode walk
/witch mode fly
/witch mode auto
我已经有 simon-minecraft-template-1.20,该怎么加进去?
  1. 关闭正在运行的游戏,先备份工程的 src 文件夹。
  2. 打开增量更新包,按照 UPLOAD-README.txt 把新增的 witch Java 包、模型和纹理复制到相同路径。
  3. 把下面两组注册代码加到原有入口方法中。原来其他方块、物品和实体的注册要保留。
  4. 如果你的入口文件与更新包对应的原版完全一致,可以使用包内已合并的入口;如果你自己改过它们,就只加入注册代码。
  5. 回到工程根目录运行 .\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
);
先玩成功的步骤和检查点
先玩成功的步骤和检查点 · 点击可放大
阶段验收

在测试世界看到女巫,切换步行和飞行模式。 做到后,再进入下一阶段。

阶段 02 · 15 分钟

设计玩法:给伙伴一张任务卡

这一步的小目标:说清楚陪伴、夜战和召回的先后顺序。

游戏内实拍:玩家、女巫与友好动物。先分清要陪伴谁、要保护谁。
游戏内实拍:玩家、女巫与友好动物。先分清要陪伴谁、要保护谁。 · 点击可放大

NPC 是由程序控制的角色。我们的女巫有三份工作:陪伴你、在夜晚保护你、在掉队后找到你。先写清楚这些规则,写代码时就知道先做什么。

看到的情况女巫要做什么
玩家刚进入世界服务器为这个玩家准备一个伙伴。
平时没有敌人按模式步行跟随,或骑扫帚在附近飞行。
晚上,附近有僵尸优先看向并攻击僵尸,结束后继续陪伴。
距离玩家超过 28 格在玩家附近寻找安全位置召回。
玩家去了另一个维度旧伙伴退出,在新维度重建一个伙伴。

自动模式:约每 20 秒轮换一次步行和飞行。步行模式:一直在地面跟随。飞行模式:一直骑扫帚围绕玩家飞行。攻击优先级高于日常陪伴。

画出你的任务卡

在纸上画一个玩家和女巫,再画三条箭头:走路、飞行、保护。每条箭头下面写一句“什么时候发生”。这张任务卡就是你的第一份游戏设计。

设计玩法的步骤和检查点
设计玩法的步骤和检查点 · 点击可放大
阶段验收

说清楚陪伴、夜战和召回的先后顺序。 做到后,再进入下一阶段。

阶段 03 · 20 分钟

连接模型:外形从哪里来

这一步的小目标:找到模型文件、6 张纹理和渲染器,知道它们如何合作。

游戏内实拍:近距离观察帽子、衣服、披风和扫帚,核对模型与纹理。
游戏内实拍:近距离观察帽子、衣服、披风和扫帚,核对模型与纹理。 · 点击可放大

模型告诉游戏“长什么样”,程序告诉游戏“做什么”。本项目沿用网站的女巫模型,保留了帽子上的猫、披风、扫帚、衣服和原始像素纹理。

  1. 打开 女巫 3D 模型页,拖动旋转,认出身体、帽子、披风和扫帚。
  2. 在工程中找到 src/main/resources/assets/simon-minecraft/witch/mesh.json。它保存每个贴图面的顶点、UV 和法线。
  3. 打开 textures/entity/witch,核对 texture_0.png 到 texture_5.png 共 6 张纹理。
  4. 打开 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 张纹理和渲染器,知道它们如何合作。 做到后,再进入下一阶段。

阶段 04 · 15 分钟

整理工程:找到每个文件的家

这一步的小目标:核对 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 包名、资源路径和两个入口。 做到后,再进入下一阶段。

阶段 05 · 20 分钟

注册 NPC:向游戏介绍新伙伴

这一步的小目标:理解实体类型、基础属性和客户端渲染的三次注册。

游戏内实拍:游戏识别并显示新伙伴后,再检查注册代码的三个环节。
游戏内实拍:游戏识别并显示新伙伴后,再检查注册代码的三个环节。 · 点击可放大

注册就像给新同学办入学手续。游戏需要知道她的名字、身体大小和基础属性;客户端还需要知道用哪个渲染器画她。

  1. 实体类型。WitchContent 注册 simon-minecraft:witch_companion,并设置实体的碰撞体积。
  2. 基础属性。用 FabricDefaultAttributeRegistry 注册 40 点生命值、0.30 行走速度和 32 格跟随搜索属性。
  3. 公共入口。SimonMinecraft.onInitialize() 调用 WitchContent.register()。
  4. 客户端入口。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 格,可以通过普通两格高通道。尖帽属于外形装饰,在低矮室内可能被天花板遮住。

先检查注册,再检查行为

如果一生成就崩溃,先找属性注册;如果代码在运行却看不见模型,先找客户端渲染注册和资源路径。

注册 NPC的步骤和检查点
注册 NPC的步骤和检查点 · 点击可放大
阶段验收

理解实体类型、基础属性和客户端渲染的三次注册。 做到后,再进入下一阶段。

阶段 06 · 20 分钟

步行跟随:找到玩家,开始寻路

这一步的小目标:移动十格,观察伙伴追上来并在附近停下。

游戏内实拍:女巫站在玩家附近。切换 walk 后,向前走十格观察她跟上。
游戏内实拍:女巫站在玩家附近。切换 walk 后,向前走十格观察她跟上。 · 点击可放大

女巫不能随便认一个最近的人当主人。她保存玩家的 UUID,用这个唯一编号找到真正的主人。

  1. 在游戏输入 /witch mode walk。
  2. 向前走约十格,然后停下。
  3. 观察她向你的侧后方移动,接近后停下。
  4. 在路上放几个方块,看看她能否绕过去。被挡住约五秒时,她会尝试在你附近安全召回。

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"));
    }
}
步行跟随的步骤和检查点
步行跟随的步骤和检查点 · 点击可放大
阶段验收

移动十格,观察伙伴追上来并在附近停下。 做到后,再进入下一阶段。

阶段 07 · 25 分钟

扫帚飞行:让位置缓缓变化

这一步的小目标:观察水平扫帚、弯曲双腿和环绕飞行。

游戏内实拍:女巫离开地面,骑着水平扫帚在营地上空飞行。
游戏内实拍:女巫离开地面,骑着水平扫帚在营地上空飞行。 · 点击可放大

飞行有两件事:实体真的在空中移动,同时模型真的换成骑扫帚的姿势。只有改变外形,女巫仍会站在地面。

  1. 输入 /witch mode fly,等待两三秒。
  2. 抬头观察:女巫在玩家身边约三格的圆周上飞行,目标高度约为玩家脚下上方 2.5 格。
  3. 看看扫帚变成水平,双腿和手臂弯曲。
  4. 走到树旁,观察她的移动。她会碰撞,不会穿墙;碰到障碍时尝试抬高,卡住后尝试召回。

飞行位置与速度的核心思路

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 让速度逐渐靠近新速度,转向会更柔和。上面的代码是思路摘录,完整变量和分支在实体文件中。

Minecraft 1.20 实际运行中女巫骑水平扫帚飞行
Minecraft 游戏内实拍 · 在林间营地观察水平扫帚与骑乘姿势。
扫帚飞行的步骤和检查点
扫帚飞行的步骤和检查点 · 点击可放大
阶段验收

观察水平扫帚、弯曲双腿和环绕飞行。 做到后,再进入下一阶段。

阶段 08 · 25 分钟

夜间守护:只攻击附近的僵尸

这一步的小目标:白天不主动攻击,夜间发出魔法,友好动物不受伤。

游戏内实拍:夜晚的营地出现僵尸,女巫向目标施法;观察玩家开启了夜视。
游戏内实拍:夜晚的营地出现僵尸,女巫向目标施法;观察玩家开启了夜视。 · 点击可放大

保护玩家要有明确的边界。女巫只寻找玩家附近 16 格内的僵尸家族,包括普通僵尸以及继承僵尸行为的变种。她不会把猪、村民或玩家当成魔法目标。

  1. 在新建的创造模式测试世界,确认难度为普通,开启作弊。
  2. 先输入下面的白天测试命令。僵尸带着头盔,避免因为阳光着火影响观察。
  3. 等待五秒,观察女巫没有主动施法。然后把时间改成夜晚。
  4. 女巫在 12 格内且能看见僵尸时发出紫色魔法,每 20 刻最多一次,造成 6 点伤害。
  5. 再召唤一只猪,确认魔法不会锁定它。测试结束后改回白天。

在游戏聊天中一条一条输入

/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,每刻减一,减到零才允许再次施法。视线检查会阻止隔着实心墙造成伤害。

Minecraft 实际游戏中女巫用紫色魔法攻击夜间僵尸
真实游戏截图 · 为看清模型,测试观察玩家带有夜视效果;世界时间仍是夜晚。
夜间守护的步骤和检查点
夜间守护的步骤和检查点 · 点击可放大
阶段验收

白天不主动攻击,夜间发出魔法,友好动物不受伤。 做到后,再进入下一阶段。

阶段 09 · 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;
    }
}
  1. 切到飞行模式,退出世界,再进入,确认模式保留。
  2. 输入 /witch remove,退出重进,确认不会再次自动生成。
  3. 输入 /witch summon,确认女巫重新出现。
  4. 有条件时进入下界,检查新维度里仍只有一个自己的伙伴。
阶段验收

退出重进后保留模式,跨维度保持一个伙伴。 做到后,再进入下一阶段。

阶段 10 · 20 分钟

逐项验收:把错误缩小到一步

这一步的小目标:完成步行、飞行、夜战、存档和召回的验收清单。

游戏内实拍:女巫与猪同处营地。友好动物不受攻击也是必须检查的一项。
游戏内实拍:女巫与猪同处营地。友好动物不受攻击也是必须检查的一项。 · 点击可放大

先判断错误发生在哪一层。一次只测试一个效果,会更容易找到原因。

我的验收清单

这是你亲手测试后的完成记录。每完成并确认一项,就勾选左边的方框;没有成功的项目,先按下面的排错表检查。

已完成 0 / 10 项

勾选只保存在当前浏览器,方便下次继续学习。

遇到的问题先检查这一处
找不到 gradlew.batTerminal 是否在能看到 build.gradle 的工程根目录?ZIP 是否已全部解压?
Java 编译版本错误Project SDK 与 Gradle JVM 是否都是 JDK 21?
依赖下载失败检查网络,重新同步 Gradle;首次运行需要下载 Minecraft 资源。
进世界没有女巫等待一秒,输入 /witch summon;检查公共入口是否调用 WitchContent.register()。
模型不可见或出现紫黑纹理检查客户端的两行注册和 assets/simon-minecraft 下的网格、6 张 PNG。
晚上不打僵尸难度不能是和平;僵尸要在玩家附近 16 格,并在女巫 12 格内且视线畅通。
有两个同名 MODmods 文件夹只放一个 simon-minecraft 版本。不要同时装试玩版与原工程版。
伙伴掉队或卡住先走到空旷地面,再输入 /witch recall。低矮房间可能放不下模型。

IDEA 运行报错时,看底部输出里第一条与 witch 相关的错误;游戏日志在工程的 run/logs/latest.log。先记录报错文本与刚才的操作,再修改对应文件。

逐项验收的步骤和检查点
逐项验收的步骤和检查点 · 点击可放大
阶段验收

完成步行、飞行、夜战、存档和召回的验收清单。 做到后,再进入下一阶段。

阶段 11 · 20 分钟

做自己的版本:一次只改一个数

这一步的小目标:修改伤害或守护半径,记录修改前后的差别。

游戏内实拍:傍晚营地里的扫帚女巫。修改一个数值,再回到游戏观察差别。
游戏内实拍:傍晚营地里的扫帚女巫。修改一个数值,再回到游戏观察差别。 · 点击可放大

先完成验收,再改成自己的作品。不要一次改变很多数值:每改一次,运行一次,记录一次。

小实验修改哪里观察什么
更轻的魔法实体文件的 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