跳转至

称号系统(Title)

给玩家搞一个称号(Title)——聊天前缀、头顶名字、Tab 列表里显示「⚔️ 萌新」、「🏆 百人斩」这种花活,是服务器最常见的玩法之一。这篇教程带你从零写一个完整的称号系统:

  • 存称号:用 persistentData 记录每个玩家当前挂的称号;
  • 显示称号:聊天前缀 + 头顶/Tab 名字两种显示方式;
  • 切换称号:聊天指令 !称号 使用 xxx / !称号 列表
  • 联动:击杀数解锁称号、签到次数解锁称号、花经验/物品购买称号、管理员发放,全部安排上。

💡 这篇是「可调用对象速查(23 篇)」+「玩家数据持久化(21 篇)」的实战应用,建议先看那两篇。


1. 称号是什么

称号本质上就是一段显示文本,挂到玩家名字前面。常见效果:

聊天:  [⚔️ 萌新] Steve: 大家好
头顶:  ⚔️ 萌新
       Steve
Tab:   [⚔️ 萌新] Steve

实现方式只有两步: 1. :把玩家当前称号 ID 存进 player.persistentData.title; 2. 显示:聊天时给消息加前缀;名字用 displayName 覆盖。


2. 第一步:存称号(基础数据结构)

先把「有哪些称号」和「每个玩家挂的是哪个」定义好:

// server_scripts/titles_config.js
// 称号清单:id 是内部代号(存存档用),name 是显示文字,color 是聊天颜色代码
const TITLES = [   // 一个数组,里面每个对象就是一个称号
  { id: 'none',     name: '',          color: '' },        // none = 无称号(默认)
  { id: 'newbie',   name: '⚔️ 萌新',   color: '§7' },      // 灰色
  { id: 'veteran',  name: '🔥 老玩家', color: '§6' },      // 金色
  { id: 'killer',   name: '🏆 百人斩', color: '§c' },      // 红色
  { id: 'signer',   name: '📅 签到王', color: '§a' },      // 绿色
  { id: 'vip',      name: '💎 VIP',    color: '§d' },      // 粉色
]

// 按 id 查称号:给个 id,返回称号对象;找不到就返回 none
function getTitle(id) {   // 定义一个函数,参数是称号 id
  for (let t of TITLES) {   // 遍历整个称号数组
    if (t.id === id) return t   // 找到 id 一样的就返回它
  }
  return TITLES[0]   // 找不到就返回第一个(none 无称号),防止报错
}

💡 颜色代码:§7 灰、§6 金、§c 红、§a 绿、§d 粉、§b 天蓝、§e 黄、§r 恢复默认。想要更多颜色去查「Minecraft 颜色代码表」。


3. 第二步:显示称号

3.1 聊天前缀(最常用)

// server_scripts/titles_chat.js
PlayerEvents.chat(event => {   // 监听"玩家发消息"事件
  let player = event.player   // 拿到发消息的玩家
  let title = getTitle(player.persistentData.title)   // 从玩家存档里读出称号 id,再查成称号对象

  if (title.id === 'none') return   // 没称号就不动消息,直接结束

  event.message = title.color + '[' + title.name + ']§r ' + event.message   // 给消息前面拼上称号:颜色 + [称号名] + 恢复默认色 + 原消息
})

效果:[⚔️ 萌新] Steve: 大家好

3.2 头顶 + Tab 列表名字(进阶)

聊天前缀要每句都带,头顶名字则更「显眼」。用 displayName 覆盖玩家显示名:

// server_scripts/titles_displayname.js
PlayerEvents.loggedIn(event => {   // 监听"玩家登录"事件(登录时刷新一次名字)
  applyTitleName(event.player)   // 调用下面定义的函数,把称号挂到名字上
})

function applyTitleName(player) {   // 定义函数:给玩家名字挂称号
  let title = getTitle(player.persistentData.title)   // 读出玩家当前称号
  if (title.id === 'none') {   // 如果没称号
    player.displayName = player.username   // 名字恢复成普通玩家名
    return   // 结束
  }
  player.displayName = title.color + '[' + title.name + ']§r ' + player.username   // 名字变成「[称号] 玩家名」
}

⚠️ 注意:displayName客户端显示层,服务器重启后要重新设置,所以放在 loggedIn 里每次登录都刷一遍。改称号后记得也调一次 applyTitleName(player) 刷新(后面完整案例会示范)。


4. 第三步:切换称号(聊天指令)

用聊天指令比写命令简单得多(不用 OP、不用 tab 补全):

// server_scripts/titles_command.js
PlayerEvents.chat(event => {   // 监听聊天
  let msg = event.message   // 玩家输入的内容
  let player = event.player   // 玩家本人

  if (msg === '!称号 列表') {   // 指令1:查看全部称号
    let lines = TITLES.filter(t => t.id !== 'none').map(t =>   // 过滤掉 none,把每个称号转成一行文字
      (t.id === player.persistentData.title ? '§a✔ ' : '§7  ') + t.color + t.name + '§r (' + t.id + ')'   // 当前挂着的打 ✔ 并标绿
    )
    player.tell('§6===== 称号列表 =====')   // 标题行
    lines.forEach(line => player.tell(line))   // 逐行发给玩家
    player.tell('§e输入 !称号 使用 <id> 来切换')   // 提示用法
    return   // 结束
  }

  if (msg.startsWith('!称号 使用 ')) {   // 指令2:切换称号,比如「!称号 使用 killer」
    let id = msg.slice('!称号 使用 '.length).trim()   // 截掉前缀,剩下就是玩家输入的 id
    let title = getTitle(id)   // 查这个 id 存不存在

    if (title.id === 'none' && id !== 'none') {   // 如果查出来是 none 且输入的不是 none,说明 id 不存在
      player.tell('§c没有这个称号!输入 !称号 列表 查看')   // 提示错误
      return
    }
    if (!player.persistentData.unlocked) player.persistentData.unlocked = []   // 初始化"已解锁称号"数组(第一次用)
    if (id !== 'none' && !player.persistentData.unlocked.includes(id)) {   // 如果这个称号没解锁
      player.tell('§c你还没解锁这个称号!')   // 拒绝
      return
    }

    player.persistentData.title = id   // 存档写入新称号 id
    applyTitleName(player)   // 刷新头顶名字(用第 3.2 节的函数)
    player.tell('§a称号已切换为:' + title.color + title.name)   // 告诉玩家成功
  }
})

💡 unlocked 数组就是「解锁记录」,后面的联动功能都是往这个数组里塞 id——称号系统和其他功能联动的关键就在这。


5. 联动 1:击杀数解锁称号

统计击杀(参照 21 篇的击杀计数器),到数量自动解锁:

// server_scripts/titles_kills.js
EntityEvents.death(event => {   // 监听"实体死亡"事件
  let player = event.source.player   // 拿到凶手玩家(不是玩家杀的则为 null)
  if (!player) return   // 不是玩家杀的就不管

  let data = player.persistentData   // 玩家存档
  data.kills = (data.kills || 0) + 1   // 击杀数 +1

  unlockTitle(player, 'killer', data.kills >= 100)   // 击杀满 100 解锁「百人斩」
  unlockTitle(player, 'veteran', data.kills >= 30)    // 击杀满 30 解锁「老玩家」
})

function unlockTitle(player, id, condition) {   // 通用解锁函数:满足条件就解锁称号
  if (!condition) return   // 条件不满足就不管
  if (!player.persistentData.unlocked) player.persistentData.unlocked = []   // 初始化解锁数组
  if (player.persistentData.unlocked.includes(id)) return   // 已经解锁过就不重复加
  player.persistentData.unlocked.push(id)   // 把称号 id 加进解锁列表
  let title = getTitle(id)   // 查称号显示名
  player.tell('§6🎉 解锁新称号:' + title.color + title.name + '§6!输入 !称号 使用 ' + id + ' 来佩戴')   // 全服可见的公告可以换成 server 广播
})

💡 想要「全服广播解锁」?把 player.tell(...) 换成 player.server.runCommandSilent('tellraw @a ...') 或遍历 player.server.players 逐个 tell。


6. 联动 2:签到次数解锁称号

和 21 篇的签到系统配合——签到满 7 次解锁「签到王」:

// server_scripts/titles_signin.js
// 注意:这段要配合签到系统一起用(签到成功的地方调用 unlockSignTitle)
PlayerEvents.chat(event => {   // 监听聊天
  if (event.message !== '!签到') return   // 不是签到指令就跳过(这里是简化版签到,完整版见 21 篇)

  let player = event.player
  let data = player.persistentData

  let today = Math.floor(player.server.overworld.dayTime / 24000)   // 今天第几天
  if (data.lastSignDay === today) {   // 今天签过了
    player.tell('§c你今天已经签过到啦!')
    return
  }

  data.lastSignDay = today   // 记录今天
  data.signCount = (data.signCount || 0) + 1   // 签到次数 +1

  // ★ 联动点:签到满 7 次解锁「签到王」称号
  unlockTitle(player, 'signer', data.signCount >= 7)   // 用第 5 节定义的 unlockTitle 函数

  player.tell('§a签到成功!这是你第 ' + data.signCount + ' 次签到')
})

7. 联动 3:花经验/物品购买称号(商店型)

让玩家用经验等级或物品「买」称号:

// server_scripts/titles_shop.js
PlayerEvents.chat(event => {   // 监听聊天
  let msg = event.message
  let player = event.player
  if (!msg.startsWith('!买称号 ')) return   // 指令格式:!买称号 <id>,比如「!买称号 vip」

  let id = msg.slice('!买称号 '.length).trim()   // 取出称号 id
  let title = getTitle(id)
  if (title.id === 'none') {   // 查无此称号
    player.tell('§c没有这个称号!')
    return
  }

  // 例:VIP 称号 30 级经验才能买
  let PRICE = { vip: 30 }   // 价格表:称号 id -> 需要的经验等级
  let needLevel = PRICE[id]   // 查价格
  if (!needLevel) {   // 不在价格表里 = 不卖
    player.tell('§c这个称号不能购买,只能通过活动获得!')
    return
  }

  if (player.xpLevel < needLevel) {   // 经验等级不够
    player.tell('§c经验等级不够!需要 ' + needLevel + ' 级(你只有 ' + player.xpLevel + ' 级)')
    return
  }

  player.xpLevel -= needLevel   // 扣经验等级
  if (!player.persistentData.unlocked) player.persistentData.unlocked = []   // 初始化
  if (!player.persistentData.unlocked.includes(id)) player.persistentData.unlocked.push(id)   // 解锁
  player.persistentData.title = id   // 直接佩戴
  applyTitleName(player)   // 刷新名字
  player.tell('§a购买成功!已佩戴称号:' + title.color + title.name)
})

💡 想用物品当货币?把扣经验那段换成 player.inventory.clear(...) 或判断 player.mainHandItem,思路一样。


8. 联动 4:管理员发放/收回

OP 专用指令——直接给别人称号(活动奖励、特殊身份):

// server_scripts/titles_admin.js
PlayerEvents.chat(event => {   // 监听聊天
  let msg = event.message
  let player = event.player
  if (!player.isCreative()) return   // 只有创造模式(管理员)能用,防止普通玩家刷称号

  // 发放:!给称号 <玩家名> <称号id>
  if (msg.startsWith('!给称号 ')) {
    let parts = msg.slice('!给称号 '.length).split(' ')   // 按空格拆成 [玩家名, 称号id]
    let target = player.server.getPlayer(parts[0])   // 按名字找目标玩家
    if (!target) {   // 找不到人
      player.tell('§c找不到玩家:' + parts[0])
      return
    }
    let title = getTitle(parts[1])   // 查称号
    if (title.id === 'none' && parts[1] !== 'none') {   // 称号不存在
      player.tell('§c没有这个称号!')
      return
    }
    if (!target.persistentData.unlocked) target.persistentData.unlocked = []   // 初始化
    if (!target.persistentData.unlocked.includes(parts[1])) target.persistentData.unlocked.push(parts[1])   // 解锁
    target.persistentData.title = parts[1]   // 直接换上
    applyTitleName(target)   // 刷新对方名字
    player.tell('§a已给 ' + parts[0] + ' 称号:' + title.color + title.name)
    target.tell('§6管理员给你设置了称号:' + title.color + title.name)
  }

  // 收回:!收称号 <玩家名>
  if (msg.startsWith('!收称号 ')) {
    let name = msg.slice('!收称号 '.length).trim()   // 玩家名
    let target = player.server.getPlayer(name)
    if (!target) {   // 找不到
      player.tell('§c找不到玩家:' + name)
      return
    }
    target.persistentData.title = 'none'   // 称号清空
    applyTitleName(target)   // 刷新名字
    player.tell('§a已收回 ' + name + ' 的称号')
  }
})

9. 联动 5:JSON 联动(称号清单外部化)

把称号清单从脚本里挪到 JSON 配置文件——服主想加称号、改颜色,直接改 JSON 文件就行,不用动代码(参照 28 篇的 JsonIO):

// kubejs/config/titles.json —— 称号清单配置(服主可改,不用动代码)
{
  "titles": [
    { "id": "none",     "name": "",          "color": "" },
    { "id": "newbie",   "name": "⚔️ 萌新",   "color": "§7" },
    { "id": "veteran",  "name": "🔥 老玩家", "color": "§6" },
    { "id": "killer",   "name": "🏆 百人斩", "color": "§c" },
    { "id": "signer",   "name": "📅 签到王", "color": "§a" },
    { "id": "vip",      "name": "💎 VIP",    "color": "§d" }
  ]
}
// server_scripts/titles_json.js —— 从 JSON 读取称号清单
let TITLES = []   // 全局变量:称号清单(启动时从 JSON 加载)

ServerEvents.loaded(event => {   // 服务器加载完成时触发
  let data = JsonIO.read('kubejs/config/titles.json')   // 读 JSON 配置文件(28 篇讲过)
  TITLES = (data && data.titles) ? data.titles : []   // 有配置就用配置,没有就空数组
  console.log('称号配置已加载:' + TITLES.length + ' 个')   // 控制台日志确认
})

function getTitle(id) {   // 按 id 查称号(和前面一样,但数据来自 JSON)
  for (let t of TITLES) { if (t.id === id) return t }   // 遍历查找
  return TITLES[0] || { id: 'none', name: '', color: '' }   // 找不到给个默认空称号
}

💡 这样改:加载 JSON 那版 getTitle 之后,前面所有功能(聊天前缀/切换/击杀解锁…)照常工作,因为大家读的都是同一个 TITLES。服主加称号 = 改 JSON + /reload,零代码。

还能把解锁条件也放进 JSON:给每个称号加 kills 字段,解锁判断改成读配置:

{ "id": "killer", "name": "🏆 百人斩", "color": "§c", "kills": 100 }
// server_scripts/titles_json_unlock.js —— 解锁条件也从 JSON 读
EntityEvents.death(event => {   // 监听实体死亡
  let player = event.source.player   // 凶手玩家
  if (!player) return   // 不是玩家杀的就跳过
  player.persistentData.kills = (player.persistentData.kills || 0) + 1   // 击杀数 +1

  TITLES.forEach(t => {   // 遍历所有称号
    if (t.kills && player.persistentData.kills >= t.kills) {   // 有 kills 条件且达标
      unlockTitle(player, t.id, true)   // 解锁(用第 5 节的通用函数)
    }
  })
})

10. 联动 6:ChestGUI 联动(图形化称号选择界面)

聊天指令太「文字」,做个图形化称号选择界面(参照 27 篇的 openChestGUI)——点格子就切换,已解锁亮绿宝石、未解锁灰屏障:

// server_scripts/titles_gui.js —— 图形化称号选择界面
PlayerEvents.chat(event => {   // 监听聊天
  if (event.message !== '!称号界面') return   // 指令:!称号界面 打开图形界面
  openTitleGUI(event.player)   // 调用下面定义的函数
})

function openTitleGUI(player) {   // 定义函数:打开称号选择界面
  player.openChestGUI('称号选择', 2, gui => {   // 2 行 = 18 格,标题「称号选择」
    let unlocked = player.persistentData.unlocked || []   // 已解锁称号列表
    let current = player.persistentData.title   // 当前佩戴的称号

    let x = 0, y = 0   // 格子坐标(从 0,0 开始摆放)
    TITLES.forEach(t => {   // 遍历所有称号
      if (t.id === 'none') return   // 跳过「无称号」
      if (x > 8) { x = 0; y++ }   // 一行放满 9 个就换到下一行

      let isUnlocked = unlocked.includes(t.id)   // 这个称号解锁了没
      let isCurrent = (current === t.id)   // 是不是当前佩戴的

      // 放一个按钮:已解锁放绿宝石,没解锁放屏障;名字带上状态
      gui.button(x, y,
        isUnlocked ? 'minecraft:emerald' : 'minecraft:barrier',   // 格子里显示的物品
        (isCurrent ? '§a✔ ' : '') + t.color + t.name + (isUnlocked ? '' : ' §c(未解锁)'),   // 按钮名称
        click => {   // 点击回调
          let p = click.slot.gui.player   // 从点击事件里拿玩家
          if (!isUnlocked) {   // 没解锁
            p.tell('§c你还没解锁这个称号!')   // 拒绝并提示
            return
          }
          p.persistentData.title = t.id   // 切换称号
          applyTitleName(p)   // 刷新头顶名字
          p.tell('§a称号已切换为:' + t.color + t.name)   // 成功提示
          p.closeMenu()   // 关闭界面
        })
      x++   // 下一个格子
    })

    gui.closed = () => player.tell('§7称号界面已关闭')   // 关闭时提示
  })
}

💡 想显示「当前佩戴」更明显?把当前称号的按钮物品换成 minecraft:nether_star(下界之星),一眼就能找到自己戴的哪个。


11. 完整案例:一键全装

把上面的文件整合成一个(复制到 server_scripts/titles.js 就能用):

// server_scripts/titles.js —— 完整称号系统(含击杀/签到/购买/管理全部联动)
const TITLES = [
  { id: 'none',     name: '',          color: '' },
  { id: 'newbie',   name: '⚔️ 萌新',   color: '§7' },
  { id: 'veteran',  name: '🔥 老玩家', color: '§6' },
  { id: 'killer',   name: '🏆 百人斩', color: '§c' },
  { id: 'signer',   name: '📅 签到王', color: '§a' },
  { id: 'vip',      name: '💎 VIP',    color: '§d' },
]

function getTitle(id) {
  for (let t of TITLES) { if (t.id === id) return t }
  return TITLES[0]
}

function applyTitleName(player) {
  let title = getTitle(player.persistentData.title)
  if (title.id === 'none') { player.displayName = player.username; return }
  player.displayName = title.color + '[' + title.name + ']§r ' + player.username
}

function unlockTitle(player, id, condition) {
  if (!condition) return
  if (!player.persistentData.unlocked) player.persistentData.unlocked = []
  if (player.persistentData.unlocked.includes(id)) return
  player.persistentData.unlocked.push(id)
  let title = getTitle(id)
  player.tell('§6🎉 解锁新称号:' + title.color + title.name + '§6!输入 !称号 使用 ' + id + ' 来佩戴')
}

// —— 显示:聊天前缀 + 头顶名字 ——
PlayerEvents.chat(event => {
  let title = getTitle(event.player.persistentData.title)
  if (title.id !== 'none') event.message = title.color + '[' + title.name + ']§r ' + event.message
})

PlayerEvents.loggedIn(event => applyTitleName(event.player))

// —— 指令:列表 / 切换 ——
PlayerEvents.chat(event => {
  let msg = event.message
  let player = event.player

  if (msg === '!称号 列表') {
    let lines = TITLES.filter(t => t.id !== 'none').map(t =>
      (t.id === player.persistentData.title ? '§a✔ ' : '§7  ') + t.color + t.name + '§r (' + t.id + ')')
    player.tell('§6===== 称号列表 =====')
    lines.forEach(line => player.tell(line))
    player.tell('§e输入 !称号 使用 <id> 来切换')
    return
  }

  if (msg.startsWith('!称号 使用 ')) {
    let id = msg.slice('!称号 使用 '.length).trim()
    let title = getTitle(id)
    if (title.id === 'none' && id !== 'none') { player.tell('§c没有这个称号!'); return }
    if (!player.persistentData.unlocked) player.persistentData.unlocked = []
    if (id !== 'none' && !player.persistentData.unlocked.includes(id)) { player.tell('§c你还没解锁这个称号!'); return }
    player.persistentData.title = id
    applyTitleName(player)
    player.tell('§a称号已切换为:' + title.color + title.name)
  }
})

// —— 联动 1:击杀解锁 ——
EntityEvents.death(event => {
  let player = event.source.player
  if (!player) return
  let data = player.persistentData
  data.kills = (data.kills || 0) + 1
  unlockTitle(player, 'killer', data.kills >= 100)
  unlockTitle(player, 'veteran', data.kills >= 30)
})

// —— 联动 2:签到解锁 ——
PlayerEvents.chat(event => {
  if (event.message !== '!签到') return
  let player = event.player
  let data = player.persistentData
  let today = Math.floor(player.server.overworld.dayTime / 24000)
  if (data.lastSignDay === today) { player.tell('§c你今天已经签过到啦!'); return }
  data.lastSignDay = today
  data.signCount = (data.signCount || 0) + 1
  unlockTitle(player, 'signer', data.signCount >= 7)
  player.tell('§a签到成功!这是你第 ' + data.signCount + ' 次签到')
})

// —— 联动 3:经验购买 ——
PlayerEvents.chat(event => {
  let msg = event.message
  let player = event.player
  if (!msg.startsWith('!买称号 ')) return
  let id = msg.slice('!买称号 '.length).trim()
  let title = getTitle(id)
  if (title.id === 'none') { player.tell('§c没有这个称号!'); return }
  let PRICE = { vip: 30 }
  let needLevel = PRICE[id]
  if (!needLevel) { player.tell('§c这个称号不能购买,只能通过活动获得!'); return }
  if (player.xpLevel < needLevel) { player.tell('§c经验等级不够!需要 ' + needLevel + ' 级'); return }
  player.xpLevel -= needLevel
  if (!player.persistentData.unlocked) player.persistentData.unlocked = []
  if (!player.persistentData.unlocked.includes(id)) player.persistentData.unlocked.push(id)
  player.persistentData.title = id
  applyTitleName(player)
  player.tell('§a购买成功!已佩戴称号:' + title.color + title.name)
})

// —— 联动 4:管理员发放/收回 ——
PlayerEvents.chat(event => {
  let msg = event.message
  let player = event.player
  if (!player.isCreative()) return

  if (msg.startsWith('!给称号 ')) {
    let parts = msg.slice('!给称号 '.length).split(' ')
    let target = player.server.getPlayer(parts[0])
    if (!target) { player.tell('§c找不到玩家:' + parts[0]); return }
    let title = getTitle(parts[1])
    if (title.id === 'none' && parts[1] !== 'none') { player.tell('§c没有这个称号!'); return }
    if (!target.persistentData.unlocked) target.persistentData.unlocked = []
    if (!target.persistentData.unlocked.includes(parts[1])) target.persistentData.unlocked.push(parts[1])
    target.persistentData.title = parts[1]
    applyTitleName(target)
    player.tell('§a已给 ' + parts[0] + ' 称号:' + title.color + title.name)
    target.tell('§6管理员给你设置了称号:' + title.color + title.name)
  }

  if (msg.startsWith('!收称号 ')) {
    let name = msg.slice('!收称号 '.length).trim()
    let target = player.server.getPlayer(name)
    if (!target) { player.tell('§c找不到玩家:' + name); return }
    target.persistentData.title = 'none'
    applyTitleName(target)
    player.tell('§a已收回 ' + name + ' 的称号')
  }
})

12. 速查表

功能 关键代码 说明
存称号 player.persistentData.title = 'killer' 存档写入称号 id,重启不丢
解锁记录 player.persistentData.unlocked = [] 数组存已解锁称号 id
查称号 getTitle(id) 按 id 查称号对象(含名字和颜色)
聊天前缀 event.message = color + '[名]§r ' + event.message 在 PlayerEvents.chat 里拼消息
头顶名字 player.displayName = ... 覆盖显示名(登录/改称号时刷新)
切换指令 !称号 使用 <id> 需要已解锁
列表指令 !称号 列表 显示全部称号,当前打 ✔
购买指令 !买称号 <id> 花经验等级购买
管理指令 !给称号 <玩家> <id> / !收称号 <玩家> 创造模式限定
击杀解锁 EntityEvents.death + data.kills 击杀数达标自动解锁
签到解锁 签到次数达标自动解锁 配合 21 篇签到系统
JSON 配置 JsonIO.read('kubejs/config/titles.json') 称号清单外部化,服主改 JSON 加称号
JSON 解锁条件 称号对象加 kills 字段 解锁条件也从配置读
图形界面 player.openChestGUI('称号选择', 2, gui => {...}) 点格子切换称号,!称号界面 打开
颜色代码 §7§6§c§a绿 §d §r 恢复默认

还能联动什么? 在线时长解锁(PlayerEvents.tick 计时)、活动限时称号(定时任务 + 过期检查)、排行榜称号(配合 21 篇的商店/排行榜案例)、甚至接 FPSMatch 的击杀事件——思路都一样:unlocked 里塞 id,就解锁了