← Java 基础教程

第 38 / 40 课 · 免费教程

小项目 矿石冲刺赛

把命令、事件与计时组合成一场比赛

150–180 分钟环境:JDK 21、普通 Paper 1.21.11、同版本客户端。服务器插件由 Paper 加载;只有明确标注的规则测试使用 main。项目整合与交付

本课作品

报名、计时、计分与结算完整的一局

把前七课的零件连成一局矿石冲刺赛。规则类管状态与分数,插件接命令、事件和计时;比赛结束先关计分入口,再读取成绩和清理任务。 前置背包:第31-37课。独立项目名OreRush,包名de.teacherma.orerush,继续使用Java21和普通Paper1.21.11。本课适合分两次课完成。

开始前:建议先完成第 31–37 课

01 / 16

开始前:先认清运行位置

前30课打好了普通 Java 程序的基础。这一阶段把代码接到 Paper 服务器:Minecraft 客户端负责进入世界,服务器加载插件,事件和命令把玩家动作交给你的方法。

本书固定使用 JDK 21 与 Paper 1.21.11,客户端版本也保持一致。下载页面上的最新版本可能已经变了,按版本号选择。本书采用普通 plugin.yml 与 Bukkit API;本阶段没有声称支持 Folia。

图框中的短例是局部片段,先读本节的位置说明,不要各自保存成插件。完整文件会明确给出文件名;同一文件分成多段时,按编号连接,包名、import 与外层类只出现一次。

  • 预测:先写成功条件和预期消息。
  • 运行:构建成功后,在专用本机测试服安装并重启。
  • 核对:普通玩家、管理身份与控制台分别走对应路径。

02 / 16

先约好比赛规则

比赛还没开始,阿方已经挖出一袋煤,催裁判加分。裁判翻出状态牌:集合时只能报名,比赛时才记分,结算后再挥镐也不能修改这一局成绩。

先把规则写成状态变化:WAITING 等报名,RUNNING 接受有效计分,ENDING 冻结并结算。纯Java类管理规则,Paper主类把命令、事件和任务接进来,两边各有可检查的责任。

先约好比赛规则
  1. 玩家报名查分join与score
  2. 管理员启停start与stop
  3. 计分合同指定世界生存模式且事件未取消

玩家与老师的四条命令 · 位置见本节说明

/orerush join     # 玩家报名
/orerush score    # 查询本局分数
/orerush start    # 老师开始
/orerush stop     # 老师取消并清空报名
  • 三类矿石都包括普通矿石与深板岩矿石。按获准破坏的方块计分,不按掉落物数量。
  • 只计算指定世界、生存模式、已报名玩家的事件。
  • 一局结束后清空报名和分数;下一局必须重新报名。

03 / 16

状态决定现在能做什么

WAITING像集合区,允许报名;RUNNING像比赛中的矿洞,允许计分;ENDING先锁住计分,再整理成绩。状态不是写在墙上的标语,而是每次动作前都会被程序检查的值。

三种状态:开始、归零与回到等待
  1. WAITING允许报名,等待开始
  2. RUNNING只给已报名玩家计分
  3. ENDING停止计分,读取成绩
转换触发条件
WAITING → RUNNING开始
RUNNING → ENDING计时归零
ENDING → WAITING重置
RUNNING → WAITING停止 / 重置

只有比赛中才能加分 · 位置见本节说明

if (state != State.RUNNING) return;
核对表
动作WAITINGRUNNINGENDING
报名允许拒绝拒绝
开始有人才允许拒绝拒绝
加分拒绝已报名才允许拒绝
reset清空取消并清空结算后清空

04 / 16

报名名单和分数共享玩家键

UUID做键,0分表示已报名但还没有得分。没有这个键表示未报名,两者不能混淆。names另存报名时的名字,只用于结果展示;程序不会长期抓住一个Player对象不放。

报名名单和分数共享玩家键
  1. 同一个UUID名字与分数共用身份键
  2. 初始0分报名成功才创建
  3. 名字快照只用于成绩展示

避免重复报名清零 · 位置见本节说明

if (state != State.WAITING || scores.containsKey(id)) return false;
scores.put(id, 0);
return true;

区分未报名与零分 · 位置见本节说明

Integer score = round.snapshot().get(id);
String message = score == null ? "你尚未报名" : "本局得分: " + score;

05 / 16

先过滤事件 再计算矿石分值

一块石头也会产生破坏事件,但不必给它发钻石奖牌。监听器先筛选比赛状态、世界和生存模式;规则类再检查报名。材料名称交给纯Java方法,得到0、3、5或10。

停下来想一想:结束时应先广播排行榜,还是先让状态离开 RUNNING?如果结算期间又收到挖矿事件呢?

先过滤事件 再计算矿石分值
  1. 过滤事件比赛状态、世界、模式
  2. 取得矿石分值铁3、金5、钻石10
  3. 写入已报名者未报名或0分不写入

沿箭头进入下一步前,必须满足前一步条件;被拒绝的分支提前返回。

统一分值入口 · 位置见本节说明

int points = GameRound.points(event.getBlock().getType().name());

两类深板岩矿石也要认 · 位置见本节说明

GameRound.points("DEEPSLATE_DIAMOND_ORE"); // 10
GameRound.points("DEEPSLATE_IRON_ORE");    // 3
GameRound.points("STONE");                 // 0

06 / 16

一场比赛只领一张任务票

开始成功后才创建重复任务。GameRound.start拒绝重复开始,因此连按两次start也不会冒出两个裁判,同时把秒数减两遍。第一次回调等20tick,再减1。

一场比赛只领一张任务票
  1. 开始成功状态检查通过才安排
  2. 每20tick回调一次更新
  3. 任务唯一拒绝再次start

先检查再创建任务 · 位置见本节说明

if (!round.start(duration)) {
    sender.sendMessage("现在不能开始");
    return true;

}
// 通过后才调用runTaskTimer

回调中的顺序 · 位置见本节说明

if (round.tick()) { finish(); return; }
int left = round.remaining();

07 / 16

先关计分 再宣布成绩

最后一次tick先把状态变为ENDING,才调用finish。结算时取消任务,读取成绩快照,按分数从高到低显示,最后清理。否则裁判念到第二个人时,第一个人还可能偷偷再挖一块。

先关计分 再宣布成绩
  1. 关闭计分归零进入ENDING
  2. 读取快照按分数与UUID排序
  3. 统一重置取消任务,清空本局

状态在规则类里改变 · 位置见本节说明

if (--remaining > 0) return false;
state = State.ENDING;
return true;

成绩快照不怕原表清空 · 位置见本节说明

var result = round.snapshot();
round.reset();
// result仍保留刚才的成绩,不能修改。
  • 同分者分数相同,展示顺序用UUID字符串稳定排列,不宣称先显示的人赢了。
  • 本局结果只广播,不保存历史;stop属于取消,不颁奖。
  • 插件停用也取消任务并清空内存状态。

08 / 16

准备两个类和两个资源文件

创建Gradle项目OreRush,使用第31课的build.gradle.kts。Java文件放src/main/java/de/teacherma/orerush。以下两份yml都放src/main/resources。第一次启动后修改plugins/OreRush/config.yml,停服再启动生效。

准备两个类和两个资源文件
  1. GameRound.java数字规则与状态
  2. OreRushPlugin.java命令事件与调度
  3. 两个yml身份权限与玩法配置

完整plugin.yml · 完整文件

name: OreRush
version: 1.0.0
main: de.teacherma.orerush.OreRushPlugin
api-version: '1.21.11'
author: TeacherMa
website: https://www.teacher-ma.de
commands:
  orerush:
    description: 报名和管理矿石冲刺
    usage: /orerush <join|score|start|stop>
permissions:
  blockacademy.orerush.admin:
    default: op

完整config.yml · 完整文件

world: "world"
duration-seconds: 60

09 / 16

完整比赛规则类

把下面完整保存为GameRound.java。这个类不导入Paper,只处理状态、UUID和数字;第39课会直接测试它,不必每次都启动游戏。

完整比赛规则类
  1. 报名合同只在WAITING且不重复
  2. 开始合同有人、合法时长、空闲
  3. 计分合同比赛中已报名且正分

完整GameRound.java · 本段1/2,顺序连接

package de.teacherma.orerush;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public final class GameRound {
    public enum State { WAITING, RUNNING, ENDING }
    private State state = State.WAITING;
    private int remaining;
    private final Map<UUID, Integer> scores = new HashMap<>();
    public State state() { return state; }
    public int remaining() { return remaining; }
    public boolean join(UUID id) {
        if (state != State.WAITING || scores.containsKey(id)) return false;
        scores.put(id, 0); return true;
    }
    public boolean start(int seconds) {
        if (state != State.WAITING || scores.isEmpty() || seconds < 1 || seconds > 3600)
            return false;
        scores.replaceAll((id, old) -> 0);
        remaining = seconds; state = State.RUNNING; return true;
    }
    public void add(UUID id, int points) {
        if (state == State.RUNNING && scores.containsKey(id) && points > 0)
            scores.computeIfPresent(id, (key, old) -> old + points);
    }

完整GameRound.java · 本段2/2,顺序连接

    public boolean tick() {
        if (state != State.RUNNING) return false;
        if (--remaining > 0) return false;
        state = State.ENDING; return true;
    }
    public Map<UUID, Integer> snapshot() { return Map.copyOf(scores); }
    public void reset() {
        state = State.WAITING; remaining = 0; scores.clear();
    }
    public static int points(String material) {
        if (material == null) return 0;
        return switch (material) {
            case "DIAMOND_ORE", "DEEPSLATE_DIAMOND_ORE" -> 10;
            case "GOLD_ORE", "DEEPSLATE_GOLD_ORE" -> 5;
            case "IRON_ORE", "DEEPSLATE_IRON_ORE" -> 3;
            default -> 0;
        };
    }
}

10 / 16

比赛主类第一部分

接下来四页按顺序合成OreRushPlugin.java。本页导入依赖、声明共享状态并读取配置;如果YAML本身读不通,就停用插件并保留日志。

比赛主类第一部分
  1. 插件字段一份规则与一个任务
  2. 候选配置类型、时长与世界验证
  3. 登记入口配置合格才接命令事件

主类第1部分 · 本段1/2,顺序连接

package de.teacherma.orerush;

import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import org.bukkit.GameMode;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;

public final class OreRushPlugin extends JavaPlugin implements Listener {
    private final GameRound round = new GameRound();
    private final Map<UUID, String> names = new HashMap<>();

主类第1部分 · 本段2/2,顺序连接

    private BukkitTask task;
    private String worldName;
    private int duration;
    @Override
    public void onEnable() {
        saveDefaultConfig();
        var config = new YamlConfiguration();
        try { config.load(new File(getDataFolder(), "config.yml")); }
        catch (IOException | InvalidConfigurationException ex) {
            getLogger().log(java.util.logging.Level.SEVERE, "配置读取失败", ex);
            getServer().getPluginManager().disablePlugin(this); return;
        }

11 / 16

比赛主类第二部分

继续同一个文件。检查配置的类型和范围,注册命令及事件。join和score必须由玩家执行;报名还要确认玩家所在世界和生存模式。

比赛主类第二部分
  1. join玩家在指定世界报名
  2. score查询自己的分数
  3. start与stop管理权限保护

主类第2部分 · 顺序接在同一源文件

        worldName = config.getString("world", "");
        duration = config.getInt("duration-seconds", 0);
        if (!config.isString("world") || !config.isInt("duration-seconds")
                || getServer().getWorld(worldName) == null || duration < 1 || duration > 3600) {
            getLogger().severe("配置无效:world需已加载,duration-seconds需为1到3600的整数");
            getServer().getPluginManager().disablePlugin(this); return;
        }
        Objects.requireNonNull(getCommand("orerush")).setExecutor(this);
        getServer().getPluginManager().registerEvents(this, this);
    }
    @Override
    public boolean onCommand(CommandSender sender, Command command,
                             String label, String[] args) {
        if (args.length != 1) { sender.sendMessage("/orerush join|score|start|stop"); return true; }
        String action = args[0];
        if (action.equals("join") || action.equals("score")) {
            if (!(sender instanceof Player p)) {
                sender.sendMessage("报名和查分请在游戏中操作"); return true;
            }
            UUID id = p.getUniqueId();
            if (action.equals("score")) {
                Integer score = round.snapshot().get(id);
                p.sendMessage(score == null ? "你尚未报名" : "本局得分: " + score);
            } else if (!p.getWorld().getName().equals(worldName)
                    || p.getGameMode() != GameMode.SURVIVAL) {
                p.sendMessage("请在比赛世界使用生存模式报名");
            } else if (round.join(id)) {
                names.put(id, p.getName()); p.sendMessage("报名成功,等待老师开始");
            } else p.sendMessage("已经报名,或者比赛正在进行");
            return true;
        }

12 / 16

比赛主类第三部分

本段处理老师的start和stop。start被拒绝时不创建任何新任务;stop取消本局并清空报名。定时回调只做一次减数和少量消息判断。

比赛主类第三部分
  1. 开始成功后创建一个任务
  2. 倒计时首次与间隔都20tick
  3. 事件只把合格材料送入规则

主类第3部分 · 顺序接在同一源文件

        if (!action.equals("start") && !action.equals("stop")) {
            sender.sendMessage("参数只能是 join、score、start、stop"); return true;
        }
        if (!sender.hasPermission("blockacademy.orerush.admin")) {
            sender.sendMessage("只有老师可以开始或停止比赛"); return true;
        }
        if (action.equals("stop")) {
            reset(); announce("本局已取消,报名已清空"); return true;
        }
        if (getServer().getWorld(worldName) == null || !round.start(duration)) {
            sender.sendMessage("无法开始:检查世界、报名人数和比赛状态"); return true;
        }
        announce("矿石冲刺开始,共 " + duration + " 秒!");
        task = getServer().getScheduler().runTaskTimer(this, () -> {
            if (round.tick()) { finish(); return; }
            int left = round.remaining();
            if (left % 10 == 0 || left <= 5) announce("剩余 " + left + " 秒");
        }, 20L, 20L);
        return true;
    }

13 / 16

比赛主类第四部分

补齐方块事件、成绩排序和清理方法,最后关闭主类。finish只由归零的tick调用,所有收尾路径都能取消任务;Map快照用于读分,原表仍由规则类管理。

比赛主类第四部分
  1. 结算先读独立成绩快照
  2. 广播同分也有确定顺序
  3. 清理任务、报名、名字统一收尾

主类第4部分 · 顺序接在同一源文件

    @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
    public void onBreak(BlockBreakEvent event) {
        if (round.state() != GameRound.State.RUNNING) return;
        if (!event.getBlock().getWorld().getName().equals(worldName)) return;
        if (event.getPlayer().getGameMode() != GameMode.SURVIVAL) return;
        round.add(event.getPlayer().getUniqueId(),
                GameRound.points(event.getBlock().getType().name()));
    }
    private void finish() {
        if (task != null) task.cancel();
        task = null;
        announce("时间到,结算本局得分!");
        round.snapshot().entrySet().stream().sorted((a, b) -> {
            int order = Integer.compare(b.getValue(), a.getValue());
            return order != 0 ? order : a.getKey().toString().compareTo(b.getKey().toString());
        }).forEach(entry -> announce(names.getOrDefault(entry.getKey(), "未知玩家")
                + ":" + entry.getValue() + " 分"));
        reset();
    }
    private void announce(String text) { getServer().broadcast(Component.text(text)); }
    private void reset() {
        if (task != null) task.cancel();
        task = null; round.reset(); names.clear();
    }
    @Override
    public void onDisable() { reset(); }
}

14 / 16

从报名走完整整一局

邀请两位同学分别报名,让老师开始。Alex挖一块钻石,Steve挖两块铁矿,结束时应该分别是10分和6分。再做故障路线:未报名、重复开始、结束后继续挖,看看分数是否被挡住。

从报名走完整整一局
  1. Alex钻石10分
  2. Steve铁矿两块6分
  3. 结束后再挖不再写入本局
核对表
操作预期
无人报名时start拒绝
比赛中再start或join拒绝,不叠加任务
生存模式挖两类钻石矿每块10分
未报名者或其他世界挖矿不计分
破坏被保护插件取消不计分
自然结束后再挖不计分,需重新报名
比赛中stop或停服任务清理,成绩不保留

15 / 16

拓展挑战与自己的验收记录

留下源代码、实际运行结果和一条问题记录。先用自己的话解释,再运行验证;结果与预测不同的地方,往往最值得讨论。

拓展任务与通关复盘
  1. 木镐完整完成一次对局
  2. 铁镐修改分值并重测
  3. 钻石镐增加只读状态查询
  • 木镐:为三种矿石及它们的深板岩版本列测试表,确认普通石头为0分。
  • 铁镐:完成双人一局、重复start、stop和重启测试,记录十条验收结果。
  • 钻石镐:设计队伍或连击,只选一个新增功能;写清何时加分、何时清理,再实现。
  • 验收要求:完成双人一整局,记录报名、重复开始、未报名挖矿、取消事件、结束后挖矿和第二局重置。
核对表
我能独立完成已掌握再练一次
能解释三个比赛状态□□
会连接命令事件与调度□□
能走通正常与失败路径□□

16 / 16

把证据留给下一次冒险

把“能编译”“数字规则通过”和“游戏实际表现正确”分成三栏。没有运行的场景写待测;发现 Bug 时记录触发条件、预期、实际、最小修复与复测结果。

你的验证记录
类别你选择的操作留下的证据
正常本课的一次成功路径版本与实际输出
边界结束前一刻、最大值或满背包与合同逐项比较
拒绝错误参数、无权限或不相关事件反馈清楚,状态未误改
  • 解释本课每条箭头为什么从这里指向那里;并列卡片没有先后含义。
  • 先改一个规则,再同时修改说明和预期,用同一输入复测。

TAKE IT WITH YOU

下载本课 PDF

完整课文、代码、图解与练习,选择你需要的语言。