称号系统(Title)¶
给玩家搞一个称号(Title)——聊天前缀、头顶名字、Tab 列表里显示「⚔️ 萌新」、「🏆 百人斩」这种花活,是服务器最常见的玩法之一。这篇教程带你从零写一个完整的称号系统:
- 存称号:用
persistentData记录每个玩家当前挂的称号; - 显示称号:聊天前缀 + 头顶/Tab 名字两种显示方式;
- 切换称号:聊天指令
!称号 使用 xxx/!称号 列表; - 联动:击杀数解锁称号、签到次数解锁称号、花经验/物品购买称号、管理员发放,全部安排上。
💡 这篇是「可调用对象速查(23 篇)」+「玩家数据持久化(21 篇)」的实战应用,建议先看那两篇。
1. 称号是什么¶
称号本质上就是一段显示文本,挂到玩家名字前面。常见效果:
实现方式只有两步:
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 字段,解锁判断改成读配置:
// 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,就解锁了。