Skip to content

实战:计分板系统 ​

经过前七节的学习,你已经掌握了事件、组件、自定义事件、通信和 UI 脚本交互。现在是时候把它们全部串起来,完成一个完整的实战功能——为番茄空岛模组实现一个绿宝石计分板,实时显示在 HUD 上。

你将学到

  • 用 InventoryItemChangedServerEvent 检测物品变化
  • 用 SetCommand 操作原版计分板
  • 用 ModAttr 将计分数据同步到客户端
  • 用 HUD 界面实时显示绿宝石数量
  • 完整的"事件 → 逻辑 → 通信 → UI"链路

功能目标 ​

在番茄空岛模组中,玩家的通关条件是攒够 10 颗绿宝石。我们要实现:

  1. 服务端:监听物品变化,实时统计每个玩家的绿宝石数量
  2. 计分板:用原版 scoreboard 命令记录分数,方便命令方块等系统读取
  3. 客户端 HUD:在屏幕上方常驻显示"绿宝石:3 / 10"
  4. 通关检测:达到 10 颗时发送祝贺消息

整体数据流如下:

物品变化事件               服务端逻辑             客户端 HUD
     │                       │                     │
     │ InventoryItemChanged  │                     │
     ├─────────────────────→ │ 遍历背包统计绿宝石   │
     │                       │ SetCommand 更新计分板│
     │                       │ ModAttr 设置分数     │
     │                       ├────────────────────→ │ RegisterUpdateFunc
     │                       │                     │ 更新 label 文本

第一步:设计 UI 布局 ​

首先在资源包中创建一个简单的 HUD 界面,用来显示绿宝石数量。

在 resource_pack_xxx/ui/ 目录下创建 scoreboard_hud.json:

json
{
  "namespace": "scoreboard_hud",
  "main": {
    "type": "screen",
    "controls": [
      { "score_panel@scoreboard_hud.score_panel": {} }
    ]
  },
  "score_panel": {
    "type": "panel",
    "size": [120, 24],
    "anchor_from": "top_middle",
    "anchor_to": "top_middle",
    "offset": [0, 8],
    "controls": [
      { "bg@scoreboard_hud.score_bg": {} },
      { "label@scoreboard_hud.score_label": {} }
    ]
  },
  "score_bg": {
    "type": "image",
    "texture": "textures/ui/Black",
    "alpha": 0.5,
    "size": ["100%", "100%"]
  },
  "score_label": {
    "type": "label",
    "text": "绿宝石:0 / 10",
    "color": [0.33, 1.0, 0.5],
    "font_size": "normal",
    "anchor_from": "center",
    "anchor_to": "center",
    "size": ["default", "default"]
  }
}

这个布局非常简单:屏幕顶部居中放一个带半透明黑底的文本标签。

控件路径

从 main 画布到 score_label 的控件路径是 /score_panel/label。注意 JSON 中的 "label@scoreboard_hud.score_label" 引用,@ 前面的 label 才是控件在父容器中的名称。

第二步:编写服务端逻辑 ​

服务端负责三件事:监听物品变化、统计绿宝石数量、同步数据。

ServerSystem.py——在 Scripts/server/ 目录下编写:

python
# -*- coding: utf-8 -*-
import mod.server.extraServerApi as serverApi

ServerSystem = serverApi.GetServerSystemCls()
CompFactory = serverApi.GetEngineCompFactory()

class TomatoServerSystem(ServerSystem):

    def __init__(self, namespace, systemName):
        ServerSystem.__init__(self, namespace, systemName)
        self.emeraldCounts = {}  # {playerId: int} 缓存每个玩家的绿宝石数
        self._ListenEvents()
        self._InitScoreboard()

    def _ListenEvents(self):
        # 监听背包物品变化
        self.ListenForEvent(
            serverApi.GetEngineNamespace(),
            serverApi.GetEngineSystemName(),
            "InventoryItemChangedServerEvent",
            self, self.OnInventoryChanged
        )
        # 监听玩家加入
        self.ListenForEvent(
            serverApi.GetEngineNamespace(),
            serverApi.GetEngineSystemName(),
            "AddServerPlayerEvent",
            self, self.OnPlayerJoin
        )

    def _InitScoreboard(self):
        """创建计分板记分项(如果不存在)"""
        levelId = serverApi.GetLevelId()
        cmdComp = CompFactory.CreateCommand(levelId)
        cmdComp.SetCommand("/scoreboard objectives add emeralds dummy 绿宝石")

    # ---- 事件回调 ----

    def OnPlayerJoin(self, args):
        playerId = args["id"]
        self.emeraldCounts[playerId] = 0
        # 初始化该玩家的计分板分数
        levelId = serverApi.GetLevelId()
        cmdComp = CompFactory.CreateCommand(levelId)
        cmdComp.SetCommand(
            "/scoreboard players set @s emeralds 0",
            playerId
        )
        # 初始同步到客户端
        self._SyncToClient(playerId, 0)

    def OnInventoryChanged(self, args):
        playerId = args["playerId"]
        newItem = args["newItemDict"]
        oldItem = args["oldItemDict"]
        # 快速过滤:只关心绿宝石相关的槽位变化
        isEmeraldNew = newItem and newItem.get("itemName") == "minecraft:emerald"
        isEmeraldOld = oldItem and oldItem.get("itemName") == "minecraft:emerald"
        if not isEmeraldNew and not isEmeraldOld:
            return
        # 重新统计该玩家所有绿宝石
        count = self._CountEmeralds(playerId)
        oldCount = self.emeraldCounts.get(playerId, 0)
        if count == oldCount:
            return  # 数量没变,不重复同步
        self.emeraldCounts[playerId] = count
        self._UpdateScoreboard(playerId, count)
        self._SyncToClient(playerId, count)
        # 通关检测
        if count >= 10 and oldCount < 10:
            self._OnWin(playerId)

    # ---- 辅助方法 ----

    def _CountEmeralds(self, playerId):
        """遍历背包统计绿宝石总数"""
        itemComp = CompFactory.CreateItem(playerId)
        items = itemComp.GetPlayerAllItems(
            serverApi.GetMinecraftEnum().ItemPosType.INVENTORY
        )
        total = 0
        if items:
            for item in items:
                if item and item.get("itemName") == "minecraft:emerald":
                    total += item.get("count", 0)
        return total

    def _UpdateScoreboard(self, playerId, count):
        """用命令更新原版计分板"""
        levelId = serverApi.GetLevelId()
        cmdComp = CompFactory.CreateCommand(levelId)
        cmdComp.SetCommand(
            "/scoreboard players set @s emeralds {}".format(count),
            playerId
        )

    def _SyncToClient(self, playerId, count):
        """通过 ModAttr 同步到客户端"""
        attrComp = CompFactory.CreateModAttr(playerId)
        attrComp.SetAttr("emeraldCount", count)

    def _OnWin(self, playerId):
        """玩家达成通关条件"""
        levelId = serverApi.GetLevelId()
        cmdComp = CompFactory.CreateCommand(levelId)
        cmdComp.SetCommand(
            '/title @s title §a§l恭喜通关!',
            playerId
        )
        cmdComp.SetCommand(
            '/title @s subtitle §e你收集了 10 颗绿宝石',
            playerId
        )

InventoryItemChangedServerEvent 的触发时机

这个事件在物品即将变化时触发,此时槽位中仍然是旧物品。但因为我们用 GetPlayerAllItems 重新遍历整个背包来统计,所以需要注意:单次移动/堆叠操作可能触发多次事件(顺序不保证)。

我们的解决方案是:用 emeraldCounts 字典缓存上一次的数量,只在数量真正变化时才同步,避免重复推送。

关键设计解读 ​

设计原因
用 InventoryItemChangedServerEvent 而非 ActorAcquiredItemServerEvent前者覆盖所有来源(捡起、交易、合成、命令给予等),后者对交易只触发客户端事件
用 GetPlayerAllItems 全量统计而非增量计算物品堆叠/拆分会触发多次事件且顺序不确定,全量统计更可靠
用 SetCommand 写计分板ModSDK 没有直接设置计分板分数的组件 API,只能通过命令
用 ModAttr 同步而非自定义事件ModAttr 自动同步,玩家断线重连后也能恢复数据

第三步:编写客户端 HUD 逻辑 ​

客户端需要做两件事:创建 HUD 界面、接收 ModAttr 更新文本。

ScoreboardHud.py——在 Scripts/client/ 目录下创建 UI 类:

python
# -*- coding: utf-8 -*-
import mod.client.extraClientApi as clientApi

ScreenNode = clientApi.GetScreenNodeCls()

class ScoreboardHudScreen(ScreenNode):

    def __init__(self, namespace, name, param):
        ScreenNode.__init__(self, namespace, name, param)

    def Create(self):
        """界面创建时获取控件引用"""
        self.scoreLabel = self.GetBaseUIControl(
            "/score_panel/label"
        ).asLabel()
        # 首次显示默认文本
        self.scoreLabel.SetText("绿宝石:0 / 10")

    def UpdateScore(self, count):
        """外部调用:更新分数文本"""
        if self.scoreLabel:
            self.scoreLabel.SetText(
                "绿宝石:{} / 10".format(count)
            )

ClientSystem.py——在客户端系统中注册 UI 并监听数据更新:

python
# -*- coding: utf-8 -*-
import mod.client.extraClientApi as clientApi

ClientSystem = clientApi.GetClientSystemCls()
CompFactory = clientApi.GetEngineCompFactory()

class TomatoClientSystem(ClientSystem):

    def __init__(self, namespace, systemName):
        ClientSystem.__init__(self, namespace, systemName)
        self.hudScreen = None
        self._ListenEvents()
        self._RegisterUI()

    def _ListenEvents(self):
        self.ListenForEvent(
            clientApi.GetEngineNamespace(),
            clientApi.GetEngineSystemName(),
            "UiInitFinished",
            self, self.OnUiReady
        )

    def _RegisterUI(self):
        """注册 UI(可在 UiInitFinished 之前调用)"""
        clientApi.RegisterUI(
            "tomato_island",
            "scoreboardHud",
            "Scripts.client.ScoreboardHud.ScoreboardHudScreen"
        )

    def OnUiReady(self, args):
        """UI 系统就绪后创建 HUD"""
        self.hudScreen = clientApi.CreateUI(
            "tomato_island",
            "scoreboardHud",
            {"isHud": 1}
        )
        if self.hudScreen:
            # 注册 ModAttr 更新回调
            localPlayerId = clientApi.GetLocalPlayerId()
            attrComp = CompFactory.CreateModAttr(localPlayerId)
            attrComp.RegisterUpdateFunc(
                "emeraldCount",
                self.OnEmeraldCountUpdate
            )

    def OnEmeraldCountUpdate(self):
        """ModAttr 变化时自动调用"""
        localPlayerId = clientApi.GetLocalPlayerId()
        attrComp = CompFactory.CreateModAttr(localPlayerId)
        count = attrComp.GetAttr("emeraldCount", 0)
        if self.hudScreen:
            self.hudScreen.UpdateScore(count)

    def Destroy(self):
        self.UnListenForEvent(
            clientApi.GetEngineNamespace(),
            clientApi.GetEngineSystemName(),
            "UiInitFinished",
            self, self.OnUiReady
        )

CreateUI 参数说明

CreateUI 的第三个参数传 {"isHud": 1} 表示创建 HUD 类型界面。HUD 常驻屏幕,不阻挡玩家操作,适合计分板这类持续显示的信息。与之对应的 PushScreen 会打开全屏交互界面,阻挡操作。详见 S7. UI 脚本交互。

第四步:串联完整数据流 ​

现在来回顾从物品变化到 UI 更新的完整链路:

1. 玩家捡起/交易获得绿宝石
   │
   ▼
2. InventoryItemChangedServerEvent 触发
   │
   ▼
3. OnInventoryChanged 过滤非绿宝石变化
   │
   ▼
4. _CountEmeralds 遍历背包统计总数
   │
   ▼
5. _UpdateScoreboard 用 SetCommand 写入原版计分板
   │
   ▼
6. _SyncToClient 用 ModAttr.SetAttr 设置 emeraldCount
   │
   ▼  (引擎自动同步)
   ▼
7. OnEmeraldCountUpdate 被自动调用
   │
   ▼
8. hudScreen.UpdateScore(count) 更新 HUD 文本

为什么用两套系统? ​

你可能会问:既然已经有 ModAttr 同步数据了,为什么还要用原版计分板?

系统用途优势
原版计分板命令方块 / 目标选择器可以读取可用 @a[scores={emeralds=10..}] 做条件判断
ModAttr同步到客户端 UI自动同步、断线重连恢复、客户端可直接读取

两者配合使用:计分板给原版命令系统用,ModAttr 给 UI 脚本用。

计分板 API 速查 ​

ModSDK 的计分板相关 API 非常有限,这里做一个完整整理:

读取 API(组件方式) ​

API端说明
CreateGame(levelId).GetAllScoreboardObjects()服务端 / 客户端获取所有记分项列表
CreateGame(levelId).GetAllPlayerScoreboardObjects()服务端 / 客户端获取所有玩家的计分板分数

写入操作(只能用命令) ​

操作命令
创建记分项/scoreboard objectives add <名称> dummy [显示名]
删除记分项/scoreboard objectives remove <名称>
设置分数/scoreboard players set <玩家> <记分项> <分数>
增加分数/scoreboard players add <玩家> <记分项> <分数>
减少分数/scoreboard players remove <玩家> <记分项> <分数>
重置分数/scoreboard players reset <玩家> [记分项]
侧边栏显示/scoreboard objectives setdisplay sidebar <记分项>

所有写入操作都通过 CompFactory.CreateCommand(levelId).SetCommand(cmd) 执行。

客户端读取限制

客户端的 GetAllScoreboardObjects() 和 GetAllPlayerScoreboardObjects() 只能读取到已设置侧边栏显示的记分项。如果需要读取未显示在侧边栏的记分项,必须在服务端操作。

没有计分板变化事件

ModSDK 没有提供计分板分数变化的事件。如果你需要响应分数变化,有两种方案:

  1. 推荐:像本教程一样,自己管理数据源,所有分数变更都经过你的代码
  2. 轮询 GetAllPlayerScoreboardObjects() 定时检查(不推荐,性能差)

文件结构总结 ​

behavior_pack_xxx/
├── Scripts/
│   ├── server/
│   │   └── ServerSystem.py      # 监听物品变化 + 计分板 + ModAttr
│   └── client/
│       ├── ClientSystem.py      # 注册/创建 HUD + ModAttr 回调
│       └── ScoreboardHud.py     # ScreenNode 子类,更新文本

resource_pack_xxx/
└── ui/
    └── scoreboard_hud.json      # HUD 布局:顶部居中文本标签

扩展思路 ​

掌握了核心链路后,你可以轻松扩展更多功能:

扩展思路
多记分项在 ModAttr 中用字典存储 {"emeralds": 3, "kills": 5},HUD 显示多行
排行榜服务端用 GetAllPlayerScoreboardObjects() 排序,用 BroadcastToAllClient 广播
进度条将 score_label 替换为进度条控件(fill 类型 image),根据比例设置宽度
击杀计分监听 MobDieEvent,检查 attacker 是否为玩家,累加击杀数
交易计分监听 InventoryItemChangedServerEvent 已经覆盖——交易获得的物品也会触发此事件

击杀计分示例

python
def OnMobDie(self, args):
    attackerId = args.get("attacker", "")
    dieEntityId = args["id"]
    if not attackerId:
        return
    # 检查死亡实体是否为番茄鸡
    engineComp = CompFactory.CreateEngineType(dieEntityId)
    engineType = engineComp.GetEngineTypeStr()
    if engineType == "tomato_island:tomato_chicken":
        # 给击杀者加分
        self.killCounts[attackerId] = self.killCounts.get(attackerId, 0) + 1

小结 ​

要点说明
物品检测InventoryItemChangedServerEvent 覆盖所有物品来源(捡起、交易、合成等)
全量统计用 GetPlayerAllItems 遍历背包统计,避免增量计算被多次事件干扰
计分板写入ModSDK 无组件 API,必须用 SetCommand 执行 /scoreboard 命令
计分板读取GetAllScoreboardObjects() / GetAllPlayerScoreboardObjects(),客户端有侧边栏限制
数据同步ModAttr.SetAttr → 自动同步 → 客户端 RegisterUpdateFunc 被动接收
HUD 创建CreateUI + isHud=1,在 UiInitFinished 之后调用
双系统配合原版计分板给命令系统用,ModAttr 给 UI 脚本用
通关检测服务端权威判定,只在分数首次达标时触发

接下来我们将学习调试与热更新——掌握日志打印、测试窗口和热更新技巧,让脚本开发效率倍增。