← Java 基础教程

第 34 / 40 课 · 免费教程

每个玩家的数据

用 UUID 为每位玩家保管独立冷却

100–120 分钟环境:JDK 21、普通 Paper 1.21.11、同版本客户端。服务器插件由 Paper 加载;只有明确标注的规则测试使用 main。Paper 插件实践

本课作品

互不串号的 /dash 冷却

大家共用一张 Map,每位玩家拥有自己的 UUID 标签。把冷却写成截止时间,用可控制的测试时钟验证边界,再接到真正的冲刺命令。 前置背包:HashMap、命令、权限和简单算术。新项目BlockAcademyL34沿用第31课构建脚本,使用Java21与Paper1.21.11。

开始前:建议先完成第 26、31–33 课

01 / 17

开始前:先认清运行位置

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

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

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

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

02 / 17

玩家名字会变 身份键要稳

两位玩家轮流冲刺,第二位刚出发就被告知“你还在冷却”。原来程序把全村人塞进同一只秒表。每位玩家用自己的 UUID 找自己的记录,冷却才不会串门。

Map 的键是玩家身份,值是下一次允许行动的时刻。比较当前时间和这个时刻即可,不必每秒给每人都建任务。测试时把时间作为参数传入,就能不用真的等三十秒。

玩家名字会变 身份键要稳
  1. UUID服务器提供的身份键
  2. 显示名给读者和玩家看
  3. 数据记录按键找到自己的状态

读取身份键 · 位置见本节说明

UUID id = player.getUniqueId();
String displayName = player.getName();

合适的字段类型 · 位置见本节说明

private final Map<UUID, Integer> mined = new HashMap<>();

03 / 17

每位玩家有自己的格子

若只写一个全局次数变量,Alex挖一次,Steve查看时也会增加。Map用UUID分隔记录:大家共用一只箱子,每人拥有自己的标签。

每位玩家有自己的格子
  1. Alex的记录只更新Alex
  2. Steve的记录不跟着增加
  3. 缺失记录getOrDefault提供初值

累计开采次数 · 位置见本节说明

UUID id = player.getUniqueId();
mined.merge(id, 1, Integer::sum);
int count = mined.getOrDefault(id, 0);

新玩家默认零 · 位置见本节说明

UUID other = UUID.randomUUID(); // 只用于本地练习
System.out.println(mined.getOrDefault(other, 0));

运行结果

0

04 / 17

记录可以再次行动的时刻

假设当前时间为1000毫秒,冷却持续30000毫秒,那么可再次使用的时间是31000。每位玩家都存自己的 readyAt,比较当前时间就知道是否还在冷却。

停下来想一想:离冷却结束还差1毫秒,应该允许吗?记录在玩家退出时直接删掉,会带来什么绕过方式?

记录可以再次行动的时刻
  1. 现在1000ms开始30秒冷却
  2. 结束前30999ms还剩1ms,拒绝
  3. 截止31000ms剩余0,允许

计算截止时间 · 位置见本节说明

long now = System.currentTimeMillis();
long readyAt = now + 30_000L;
boolean cooling = now < readyAt;

统一时间单位 · 位置见本节说明

long durationMs = 30_000L; // 30秒,不是30毫秒
long threeSeconds = 3L * 1000L;

05 / 17

剩一毫秒也还没结束

玩家不喜欢看到“再等0秒”,却仍然不能行动。判断冷却使用毫秒,显示给玩家时向上取整。只要还有1毫秒,界面就显示1秒。

剩一毫秒也还没结束
  1. 逻辑判断使用剩余毫秒
  2. 玩家显示向上取整为秒
  3. 边界证据1ms也显示1秒

剩余时间不小于零 · 位置见本节说明

long remainingMs = Math.max(0L, readyAt - now);

整数向上取整 · 位置见本节说明

long seconds = (remainingMs + 999L) / 1000L;
核对表
剩余毫秒显示秒数
00
11
9991
10001
10012

06 / 17

冷却规则独立成一个类

新建 src/main/java/de/teacherma/lesson34/Cooldowns.java。方法接收now,不在内部偷偷读取时钟,这样测试时可以直接把时间推到边界。

冷却规则独立成一个类
  1. remaining计算剩余时间
  2. start记录截止时刻
  3. prune与clear按策略清理记录

完整 Cooldowns 类 · 完整文件

package de.teacherma.lesson34;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

public final class Cooldowns {
    private final Map<UUID, Long> readyAt = new HashMap<>();
    public long remaining(UUID id, long now) {
        return Math.max(0L, readyAt.getOrDefault(id, now) - now);
    }
    public void start(UUID id, long now, long durationMs) {
        if (durationMs < 0) throw new IllegalArgumentException("冷却不能为负");
        readyAt.put(id, now + durationMs);
    }
    public long seconds(UUID id, long now) {
        long ms = remaining(id, now);
        return (ms + 999L) / 1000L;
    }
    public void prune(long now) {
        readyAt.entrySet().removeIf(entry -> entry.getValue() <= now);
    }
    public void clear() { readyAt.clear(); }
}

07 / 17

让假时钟替你等三十秒

不必真的站在屏幕前数秒。新建普通测试类,在main里调用下面代码。所有数字都是测试时钟;这样边界测试运行很快,也容易重现。

让假时钟替你等三十秒
  1. 控制测试时间不用真的等30秒
  2. 两个UUID证明状态独立
  3. 两个边界结束前1ms与恰好结束

两个玩家的边界测试 · 位置见本节说明

Cooldowns c = new Cooldowns();
UUID alex = UUID.randomUUID();
UUID steve = UUID.randomUUID();
c.start(alex, 1000L, 30_000L);
System.out.println(c.remaining(alex, 1000L));
System.out.println(c.remaining(alex, 30999L));
System.out.println(c.seconds(alex, 30999L));
System.out.println(c.remaining(alex, 31000L));
System.out.println(c.remaining(steve, 2000L));

运行结果

30000
1
1
0
0

08 / 17

冲刺方向是一支箭头

getDirection 返回玩家视线方向。把Y设为0,得到水平箭头,再归一化并乘以0.9,最后给一个0.2的轻微向上速度。玩家几乎竖直看天时,水平箭头可能接近零,要先拒绝。

冲刺方向是一支箭头
  1. 水平分量先把Y设为0
  2. 长度检查接近0先拒绝
  3. 提交速度归一化后给水平和向上分量

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

防止归一化零向量 · 位置见本节说明

Vector direction = player.getLocation().getDirection().setY(0);
if (direction.lengthSquared() < 0.0001) {
    player.sendMessage(Component.text("请平视前方再冲刺"));
    return true;
}

给玩家一次速度 · 位置见本节说明

player.setVelocity(direction.normalize().multiply(0.9).setY(0.2));

09 / 17

只有成功执行后才开始冷却

把扣冷却放得太早,玩家抬头输错一次也要等30秒,像自动售货机先吞钱再检查有没有饮料。按顺序验证,真正调用setVelocity后才记录冷却。

只有成功执行后才开始冷却
  1. 合法请求类型、权限、参数通过
  2. 提交速度方向有效才执行
  3. 记录冷却成功路径末尾再开始

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

使用同一份现在时间 · 位置见本节说明

long now = System.currentTimeMillis();
if (cooldowns.remaining(id, now) > 0) {
    player.sendMessage(Component.text(
            "再等 " + cooldowns.seconds(id, now) + " 秒"));
    return true;
}

成功路径末尾 · 位置见本节说明

player.setVelocity(direction.normalize().multiply(0.9).setY(0.2));
cooldowns.start(id, now, 30_000L);

10 / 17

退出游戏不等于冷却消失

如果玩家一退出就删除他的冷却,重新登录立刻又能冲刺。本课保留尚未过期记录,下次有人执行命令时清理过期项;停用插件时统一清空。

退出游戏不等于冷却消失
  1. 退出重进未结束记录保留
  2. 记录过期有人执行命令时清理
  3. 服务器重启本章内存冷却清空

懒清理 · 位置见本节说明

long now = System.currentTimeMillis();
cooldowns.prune(now);

停用时清空 · 位置见本节说明

@Override
public void onDisable() {
    cooldowns.clear();
}
核对表
动作本课行为
玩家退出再进未到期冷却继续
插件停用或服务器重启内存数据清空
记录过期后有人执行命令删除过期项

11 / 17

冲刺项目需要两份 Java 文件

src/main/java/de/teacherma/lesson34 下放 Cooldowns.java 和 DashPlugin.java。它们在同一个包里,所以主类能直接使用 Cooldowns。资源文件只声明主类,不用登记普通辅助类。

冲刺项目需要两份 Java 文件
  1. Cooldowns.java只管数字与时间
  2. DashPlugin.java连接命令和玩家
  3. plugin.yml只登记插件主类

完整 plugin.yml · 完整文件

name: BlockAcademyL34
version: 1.0.0
main: de.teacherma.lesson34.DashPlugin
api-version: '1.21.11'
author: TeacherMa
website: https://www.teacher-ma.de
commands:
  dash:
    description: 向前冲刺
    usage: /dash
permissions:
  blockacademy.dash:
    default: true

12 / 17

冲刺主类上半部

新建 DashPlugin.java,先复制本页,再接下一页。主类持有一个Cooldowns对象,这个对象内部按UUID给每名玩家保管记录。

冲刺主类上半部
  1. 一个插件字段持有一份Cooldowns
  2. Map多个键每名玩家一条记录
  3. 一个请求查询与更新用同一个now

DashPlugin 第1部分 · 顺序接在同一源文件

package de.teacherma.lesson34;

import java.util.Objects;
import net.kyori.adventure.text.Component;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.util.Vector;

public final class DashPlugin extends JavaPlugin {
    private final Cooldowns cooldowns = new Cooldowns();
    @Override
    public void onEnable() {
        Objects.requireNonNull(getCommand("dash")).setExecutor(this);
    }
    @Override
    public boolean onCommand(CommandSender sender, Command command,
                             String label, String[] args) {
        if (!(sender instanceof Player player)) {
            sender.sendMessage("冲刺需要游戏中的身体");
            return true;
        }
        if (!player.hasPermission("blockacademy.dash")) {
            player.sendMessage(Component.text("你还没有冲刺权限"));
            return true;
        }
        if (args.length != 0) {
            player.sendMessage(Component.text("用法:/dash"));
            return true;
        }

13 / 17

冲刺主类下半部

继续上一页。查询、提示、方向验证、执行和记录都发生在一次命令调用中。未通过检查的分支提前返回,方便从上到下阅读。

冲刺主类下半部
  1. 查询剩余时间决定分支
  2. 方向零向量不执行
  3. 动作完成记录冷却并反馈

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

        long now = System.currentTimeMillis();
        cooldowns.prune(now);
        var id = player.getUniqueId();
        if (cooldowns.remaining(id, now) > 0) {
            player.sendMessage(Component.text(
                    "再等 " + cooldowns.seconds(id, now) + " 秒"));
            return true;
        }
        Vector direction = player.getLocation().getDirection().setY(0);
        if (direction.lengthSquared() < 0.0001) {
            player.sendMessage(Component.text("请平视前方再冲刺"));
            return true;
        }
        player.setVelocity(direction.normalize().multiply(0.9).setY(0.2));
        cooldowns.start(id, now, 30_000L);
        player.sendMessage(Component.text("冲刺!下次需要等30秒"));
        return true;
    }
    @Override
    public void onDisable() { cooldowns.clear(); }
}

14 / 17

两名玩家才能揭穿共享冷却

单人测试看不出“所有人共用同一个截止时间”的错误。请两名同学分别连接测试服,按下表走一遍,再检查退出重进和时间边界。

两名玩家才能揭穿共享冷却
  1. Alex先用Alex进入冷却
  2. Steve马上用Steve仍可使用
  3. Alex再用只收到剩余时间提示
核对表
操作预期
Alex使用后Steve立即使用两人都成功
Alex立刻再次使用提示剩余秒数
冷却中退出并立即重进仍受剩余冷却限制
近乎竖直看天后使用提示平视,不新增冷却
带额外参数 /dash now显示用法,不新增冷却
控制台输入 dash清楚提示,无类型转换错误
停服重启后使用内存冷却已清空

15 / 17

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

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

拓展任务与通关复盘
  1. 木镐独立记录开采数
  2. 铁镐测试1ms与0ms
  3. 钻石镐玩家和技能两层独立键
  • 木镐:用Map记录每位玩家获准开采的次数,未出现过的玩家显示0。
  • 铁镐:把冷却设为10秒,验证剩余1毫秒显示1秒,截止时显示0秒。
  • 钻石镐:为冲刺、治疗、工具包各设独立冷却,设计玩家与技能两层键,并测互不影响。
  • 验收要求:用两个UUID和可控制的测试时间,验证互不影响、结束前1毫秒、恰好结束、退出后重新加入。
核对表
我能独立完成已掌握再练一次
用UUID关联玩家记录□□
能算剩余时间并取整□□
能解释退出与重启策略□□

16 / 17

可独立运行的规则验证

在项目内新建 checks/L34RuleCheck.java,完整保存下面代码。新建 checks/sources.txt,写入下面两条路径,然后从项目根目录执行命令。这是普通 Java 测试,不是插件主类。

L34RuleCheck.java · 完整文件

import de.teacherma.lesson34.Cooldowns;
import java.util.UUID;

public class L34RuleCheck {
    public static void main(String[] args) {
        Cooldowns c = new Cooldowns();
        UUID alex = new UUID(0, 1), steve = new UUID(0, 2);
        c.start(alex, 1000L, 30_000L);
        System.out.println(c.remaining(alex, 1000L));
        System.out.println(c.remaining(alex, 30999L));
        System.out.println(c.seconds(alex, 30999L));
        System.out.println(c.remaining(alex, 31000L));
        System.out.println(c.remaining(steve, 2000L));
    }
}

运行结果

30000
1
1
0
0

checks/sources.txt · 完整内容

src/main/java/de/teacherma/lesson34/Cooldowns.java
checks/L34RuleCheck.java

项目根目录终端命令 · 分别执行两行

javac -encoding UTF-8 -d checks/out @checks/sources.txt
java -cp checks/out L34RuleCheck

17 / 17

把证据留给下一次冒险

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

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

TAKE IT WITH YOU

下载本课 PDF

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