尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

游戏组合检测器实战:Python实现规则匹配与事件触发

游戏组合检测器实战:Python实现规则匹配与事件触发 在合成类、消除类或放置类游戏中经常会在日志或弹窗里看到“检测到双果冻双红宝石”。这句提示表面上是在告诉玩家当前场景同时出现了两个果冻和两个红宝石系统因此触发了合成、奖励或成就事件。从客户端或服务端开发的角度看这句话背后是一个典型的状态检测问题。处理这类问题不是只写一个if而是要把游戏场景建模、道具统计、规则匹配、事件触发、测试验证串成一条完整链路。本文以“检测到双果冻双红宝石”为最小示例用 Python 实现一个可复用的组合检测器并说明进入生产环境后应该如何设计规则、控制性能、记录日志和排查问题。1. 先理解“检测到双果冻双红宝石”到底属于什么问题1.1 一句话定义状态检测 规则匹配 事件触发“检测到双果冻双红宝石”本质上是三个动作的组合读取当前游戏场景得到一份状态快照。对快照执行规则判断检查果冻数量和红宝石数量是否满足条件。条件满足后触发后续逻辑比如合成道具、发放奖励、更新成就进度或打印日志。这三个动作可以统称为组合检测。组合检测不限于双果冻双红宝石也可以扩展到“检测到三个红宝石”“检测到连续 5 个果冻”“检测到 L 形道具组”等更复杂规则。因此代码结构不能写死应该让规则可以被新增、替换和配置。1.2 为什么不能直接写死一个 if 判断对一个固定需求直接写下面的代码也能跑if jelly_count 2 and ruby_count 2: print(检测到双果冻双红宝石)问题在于这种写法把所有逻辑揉进了业务代码。项目一旦加入新组合、新道具或新触发条件这段代码就会越堆越长。更常见的问题还有道具类型名散落各处写错大小写或空格后很难察觉。扫描逻辑和规则判断混在一起后面需要复用扫描结果时无从下手。没有事件抽象测试时只能靠打印观察。无法从日志和监控判断某条规则为什么命中或没有命中。所以在工程上更推荐把“扫描场景”和“判断规则”分开再通过事件机制对外通知结果。这样同一个场景快照可以同时喂给多条规则不会每条规则都遍历一遍整个网格。1.3 检测器需要回答的三个问题组合检测器要回答的问题其实非常固定当前场景里有哪些道具每个道具在哪个坐标这些数量关系是否满足某条规则如果满足应该通知哪些业务模块围绕这三个问题本文会实现一个最小可运行检测器。它接收一个二维网格输出每种道具的数量和坐标再通过规则列表判断是否触发“双果冻双红宝石”事件。注意本文以合成类游戏的通用场景为例不绑定任何具体游戏产品和版本。你在落地时需要把道具类型、规则阈值、触发方式按自己的项目业务重新定义。2. 环境准备与项目结构先把运行基础搭好2.1 学习环境要求本文示例使用 Python 3不需要安装第三方依赖标准库即可运行。推荐环境项目要求Python3.8 或更高版本推荐 3.10操作系统Windows / macOS / Linux 均可第三方依赖无代码编辑器任意推荐 VS Code 或 PyCharm在命令行确认版本python --version如果输出类似Python 3.10.12就可以继续。2.2 项目目录设计先规划一个最小项目结构避免把所有代码都写在一个文件里。目录如下combo_detector/ ├── main.py # 入口演示完整检测流程 ├── models.py # 道具常量、场景快照、事件对象 ├── rules.py # 规则抽象与具体规则 ├── listeners.py # 事件监听器 ├── detector.py # 组合检测器主类 ├── loader.py # 场景数据加载与校验 ├── scene.json # 测试场景数据 └── test_detector.py # 自动化测试这个结构在真实项目里可以继续拆但作为演示已经足够。核心思路是模型层负责数据规则层负责判断监听器层负责业务响应检测器层负责串联流程。2.3 道具类型定义道具类型不要散落成魔法字符串。在models.py中统一定义# models.py EMPTY empty JELLY jelly RUBY ruby这样后续代码里不会出现“某个地方写Jelly另一个地方写jelly”的差异问题。即使业务中还有更多道具类型也统一维护在这一处。3. 场景建模用二维网格表达游戏地图3.1 为什么选择二维网格而不是对象列表游戏场景最常见的表达方式是二维网格。每个格子有一个坐标(row, col)row表示第几行col表示第几列。对于组合检测来说二维网格有两个明显优势天然支持位置分析比如判断两个道具是否相邻、是否在同一行。数据来源清晰可以直接来自地图配置、后台接口或 JSON 文件。如果只用“道具对象列表”虽然也能统计数量但后期做位置匹配时会缺少年份坐标信息还得回过头改数据模型。3.2 手工构造地图快照在入门阶段可以直接构造一个 3x3 网格作为测试场景scene [ [ruby, jelly, empty], [jelly, ruby, empty], [empty, empty, empty], ]这个场景中ruby出现在(0,0)和(1,1)共 2 个。jelly出现在(0,1)和(1,0)共 2 个。空位置用empty表示。结果很明显满足双果冻双红宝石条件。3.3 从 JSON 加载场景数据实际项目中场景数据往往来自接口或配置。可以准备一个scene.json{ grid: [ [ruby, jelly, empty], [jelly, ruby, empty], [empty, empty, empty] ] }在loader.py中实现加载与校验# loader.py import json def load_grid_from_json(path): with open(path, r, encodingutf-8) as f: data json.load(f) grid data[grid] validate_grid(grid) return grid def validate_grid(grid): if not grid or not isinstance(grid, list): raise ValueError(grid 必须是非空二维数组) width len(grid[0]) for row in grid: if not isinstance(row, list): raise ValueError(每一行必须是数组) if len(row) ! width: raise ValueError(grid 每一行列数必须一致)这是最容易忽略的一步。很多检测结果异常不是因为规则写错而是因为传入的网格行长度不一致导致坐标错位。注意行长度校验必须在进入检测器之前完成否则后续扫描结果没有参考价值。4. 核心检测逻辑扫描、计数、坐标聚合4.1 扫描一次网格收集数量与位置GameSnapshot负责在初始化时扫描一次网格输出两类核心信息每种道具的数量以及每种道具出现的坐标列表。# models.py from collections import defaultdict from dataclasses import dataclass, field from typing import List, Tuple EMPTY empty JELLY jelly RUBY ruby class GameSnapshot: 一次扫描得到的游戏状态快照 def __init__(self, grid): self.grid grid self.counts defaultdict(int) self.positions defaultdict(list) self._scan() def _scan(self): for row_index, row in enumerate(self.grid): for col_index, item in enumerate(row): if not item or item EMPTY: continue self.counts[item] 1 self.positions[item].append((row_index, col_index))这段代码遍历整个二维数组遇到空位置就跳过遇到道具就累加数量并记录坐标。时间复杂度是O(row * col)。在实际项目中这个扫描结果应该被多条规则复用而不是每条规则都重新遍历一次。4.2 组合规则的抽象接口规则层要面向抽象编程。先定义ComboRule接口# rules.py from abc import ABC, abstractmethod from models import ComboEvent class ComboRule(ABC): abstractmethod def match(self, snapshot) - bool: 判断当前快照是否满足规则 abstractmethod def build_event(self, snapshot, matched) - ComboEvent: 构建事件对象便于业务监听器使用这样后续增加“检测到三个红宝石”“检测到连续四个果冻”时只需要新增一个规则类不需要修改检测器主流程。4.3 实现“双果冻双红宝石”规则针对本文主题实现具体规则# rules.py from models import JELLY, RUBY, ComboEvent class DoubleJellyDoubleRubyRule(ComboRule): RULE_NAME 检测到双果冻双红宝石 def match(self, snapshot) - bool: jelly_count snapshot.counts.get(JELLY, 0) ruby_count snapshot.counts.get(RUBY, 0) return jelly_count 2 and ruby_count 2 def build_event(self, snapshot, matched) - ComboEvent: return ComboEvent( rule_nameself.RULE_NAME, matchedmatched, jelly_countsnapshot.counts.get(JELLY, 0), ruby_countsnapshot.counts.get(RUBY, 0), jelly_positionslist(snapshot.positions.get(JELLY, [])), ruby_positionslist(snapshot.positions.get(RUBY, [])), )这里有一个关键取舍使用 2而不是 2。因为“检测到双果冻双红宝石”在大多数游戏场景里表示“至少两个果冻和至少两个红宝石”此时应该触发合成或奖励。如果场上出现了 3 个果冻仍然可以用其中的 2 个参与合成所以继续命中规则更合理。如果你的业务定义为“恰好只有两个果冻和两个红宝石时才触发”可以改成 2但需要额外考虑是否还有第三个果冻存在。4.4 检测结果对象设计事件对象用于在不同模块之间传递检测信息。定义如下# models.py dataclass class ComboEvent: rule_name: str matched: bool jelly_count: int 0 ruby_count: int 0 jelly_positions: List[Tuple[int, int]] field(default_factorylist) ruby_positions: List[Tuple[int, int]] field(default_factorylist)这个对象包含三个层面的信息规则层面哪条规则命中。数量层面当前果冻和红宝石各多少个。位置层面这些道具都在哪些坐标方便后续做位置展示或连击判断。5. 触发事件检测到之后如何告知业务层5.1 事件回调而不是直接写业务“检测到双果冻双红宝石”之后要做什么不同项目差异很大。有的是合成新道具有的是触发奖励弹窗有的是推进任务进度。因此检测器不应该关心具体业务它只负责通知某条规则命中了。这种设计可以避免检测器与业务代码强耦合。新增业务逻辑时只需要新增监听器不需要改动检测器。5.2 组合事件与监听器定义监听器接口# listeners.py from abc import ABC, abstractmethod class ComboListener(ABC): abstractmethod def on_combo_detected(self, event): pass实现一个奖励监听器作为示例# listeners.py class RewardListener(ComboListener): def on_combo_detected(self, event): if not event.matched: return print(f[事件] {event.rule_name}) print(f[事件] 果冻数量{event.jelly_count}, 红宝石数量{event.ruby_count}) print(f[事件] 果冻坐标{event.jelly_positions}) print(f[事件] 红宝石坐标{event.ruby_positions})实际项目中这里可以调用合成服务、发送消息队列、更新数据库或写入埋点日志。监听器内部可以做异步化、幂等控制和失败重试。5.3 完整的检测流程检测器主类负责串联扫描快照 - 逐条规则匹配 - 命中后通知监听器。# detector.py from models import GameSnapshot class ComboDetector: def __init__(self, rulesNone, listenersNone): self.rules rules or [] self.listeners listeners or [] def add_rule(self, rule): self.rules.append(rule) def add_listener(self, listener): self.listeners.append(listener) def check(self, grid): snapshot GameSnapshot(grid) for rule in self.rules: matched rule.match(snapshot) event rule.build_event(snapshot, matched) print(f[扫描] {rule.RULE_NAME} {命中 if matched else 未命中}) if matched: for listener in self.listeners: listener.on_combo_detected(event) return snapshot入口可以这样写# main.py from detector import ComboDetector from listeners import RewardListener from rules import DoubleJellyDoubleRubyRule def main(): grid [ [ruby, jelly, empty], [jelly, ruby, empty], [empty, empty, empty], ] detector ComboDetector( rules[DoubleJellyDoubleRubyRule()], listeners[RewardListener()], ) snapshot detector.check(grid) print(果冻数量:, snapshot.counts[jelly]) print(红宝石数量:, snapshot.counts[ruby]) if __name__ __main__: main()预期输出[扫描] 检测到双果冻双红宝石 命中 [事件] 检测到双果冻双红宝石 [事件] 果冻数量2, 红宝石数量2 [事件] 果冻坐标[(0, 1), (1, 0)] [事件] 红宝石坐标[(0, 0), (1, 1)] 果冻数量: 2 红宝石数量: 2从输出可以看到检测器不仅告诉业务方“命中了哪条规则”还提供了数量和坐标细节方便后续业务使用。6. 测试用例证明检测器真的可靠6.1 用例设计检测器是否符合预期不能只靠肉眼观察。至少需要覆盖以下场景场景同时包含 2 个果冻和 2 个红宝石应该命中。场景只有 2 个果冻没有红宝石不应该命中。场景只有 2 个红宝石没有果冻不应该命中。场景为空不应该命中。场景包含超过 2 个果冻或红宝石仍然应该命中。6.2 典型场景表格场景果冻数量红宝石数量是否命中2 果冻 2 红宝石22命中只有 2 果冻20不命中只有 2 红宝石02不命中全空场景00不命中3 果冻 3 红宝石33命中1 果冻 2 红宝石12不命中这张表既是测试用例也是验收标准。团队在评审需求时应该把这类表格写到需求文档里避免“到底要不要触发”产生歧义。6.3 自动化测试代码使用标准库unittest或pytest都可以。下面是使用pytest风格编写的测试# test_detector.py from detector import ComboDetector from rules import DoubleJellyDoubleRubyRule from models import JELLY, RUBY def build_scene_that_hits(): return [ [ruby, jelly, empty], [jelly, ruby, empty], [empty, empty, empty], ] def build_scene_only_two_jellies(): return [ [jelly, empty, empty], [jelly, empty, empty], [empty, empty, empty], ] def build_scene_only_two_rubies(): return [ [ruby, empty, empty], [ruby, empty, empty], [empty, empty, empty], ] def build_empty_scene(): return [ [empty, empty, empty], [empty, empty, empty], [empty, empty, empty], ] def test_double_jelly_double_ruby_hit(): rule DoubleJellyDoubleRubyRule() detector ComboDetector(rules[rule]) snapshot detector.check(build_scene_that_hits()) assert rule.match(snapshot) is True assert snapshot.counts[JELLY] 2 assert snapshot.counts[RUBY] 2 def test_only_two_jellies_not_hit(): rule DoubleJellyDoubleRubyRule() detector ComboDetector(rules[rule]) snapshot detector.check(build_scene_only_two_jellies()) assert rule.match(snapshot) is False def test_only_two_rubies_not_hit(): rule DoubleJellyDoubleRubyRule() detector ComboDetector(rules[rule]) snapshot detector.check(build_scene_only_two_rubies()) assert rule.match(snapshot) is False def test_empty_scene_not_hit(): rule DoubleJellyDoubleRubyRule() detector ComboDetector(rules[rule]) snapshot detector.check(build_empty_scene()) assert rule.match(snapshot) is False def test_more_items_still_hit(): grid [ [ruby, jelly, ruby], [jelly, ruby, empty], [empty, jelly, empty], ] rule DoubleJellyDoubleRubyRule() detector ComboDetector(rules[rule]) snapshot detector.check(grid) assert rule.match(snapshot) is True assert snapshot.counts[JELLY] 3 assert snapshot.counts[RUBY] 3运行测试pytest test_detector.py如果没有安装pytest也可以运行python -m pytest test_detector.py。7. 生产环境扩展性能、配置与监控7.1 学习环境与生产环境差异学习环境中一个 3x3 场景足够演示逻辑但生产环境通常要处理两类差异场景规模更大可能是几百乘几百的地图。规则数量更多可能同时存在几十条组合规则。因此扫描结果要尽量复用规则判断要尽量减少重复遍历。GameSnapshot设计为“扫描一次多处使用”就是为这一点服务的。7.2 大场景扫描优化当网格达到 1000x1000 时单次扫描需要遍历 100 万个格子。在 Python 中这个耗时虽不至于无法接受但如果每帧都执行就会带来持续性能压力。常见的优化方向引入脏标记只在场景发生变化时重新扫描而不是每帧全量扫描。增量更新当一个格子的道具从jelly变成ruby时只更新统计数和位置列表不重扫全图。在需要性能的关键路径上可以用 Cython、Numba 或 Go 重写扫描模块。如果规则只关心数量不需要位置可以只维护Counter放弃坐标列表减少内存占用。7.3 规则配置化业务提出“增加一条规则”时开发同学最不想做的是改代码、发版、上线。更合理的做法是把规则外置到配置中心或配置文件。可以用 YAML 配置规则rules: - name: 检测到双果冻双红宝石 items: jelly: 2 ruby: 2 comparator: gte - name: 检测到三个红宝石 items: ruby: 3 comparator: gte然后实现一个配置驱动的规则类class ConfigurableRule(ComboRule): def __init__(self, name, requirements, comparatorgte): self.name name self.requirements requirements self.comparator comparator def match(self, snapshot) - bool: for item, required_count in self.requirements.items(): actual snapshot.counts.get(item, 0) if self.comparator gte and actual required_count: return False if self.comparator eq and actual ! required_count: return False return True def build_event(self, snapshot, matched): from models import ComboEvent return ComboEvent( rule_nameself.name, matchedmatched, jelly_countsnapshot.counts.get(jelly, 0), ruby_countsnapshot.counts.get(ruby, 0), jelly_positionslist(snapshot.positions.get(jelly, [])), ruby_positionslist(snapshot.positions.get(ruby, [])), )这样新增规则时只需要改配置不需要改代码。需要注意的是配置驱动的规则在类型安全、复杂度上都有限制适合简单数量规则复杂位置规则仍然需要专门代码。7.4 日志、指标与告警生产环境不能只看 print 输出。至少要做到每次检测记录规则名、命中状态、消耗时间。监听器执行时打点统计命中次数方便观察新规则上线后的触发频率。对耗时超过阈值的检测操作记录慢日志。在监听器内增加 try except避免业务异常拖垮检测主流程。如果检测逻辑运行在服务端可以把这些指标接到 Prometheus、Grafana 或云监控平台。如果运行在客户端则通过日志上报或埋点 SDK 汇总。8. 常见问题排查从现象到根因8.1 排查清单遇到“该触发却没触发”或“不该触发却触发了”的问题按顺序检查原始 grid 数据是否正确。道具类型是否使用了统一常量。GameSnapshot的 counts 和 positions 是否符合预期。单条规则的 match 结果是否正确。监听器是否注册成功。是否重复触发是否缺少去重机制。8.2 常见问题表格问题现象常见原因检查方式处理建议检测不到组合道具类型字符串大小写或空格不一致打印 counts 和 positions统一使用常量定义道具类型规则一直不触发网格行长度不一致导致坐标错位用 validate_grid 校验加载场景前统一校验明明有 3 个却提示没有规则用了 2而不是 2查看规则阈值按业务决定使用 gte 还是 eq事件被多次触发状态变化时重复调用 check打印调用栈或日志时间增加去重只在状态变化后触发监听器不执行监听器未注册或注册位置错误检查 detector.listeners确认 add_listener 被调用大场景卡顿每帧全量扫描查看耗时日志引入增量更新或脏标记坐标越界行长度不一致或场景数据异常打印原始 grid入口处校验矩阵形状新增规则后原有逻辑受影响规则之间耦合检查规则依赖规则独立实现互不调用8.3 推荐排查顺序先验证输入再验证处理最后验证输出。具体流程是打印grid确认数据来源正确。调用GameSnapshot(grid)确认counts和positions里的值符合手算结果。单独执行每个规则的match(snapshot)确认规则逻辑独立正确。打印ComboEvent确认事件对象里的数据与 match 结果一致。检查监听器注册确认事件是否被真正消费。如果重复触发检查调用频率和去重逻辑。9. 最佳实践与扩展方向9.1 设计时要坚持的原则组合检测器的代码结构不复杂但很容易在后续扩展中腐化。几个原则值得长期坚持道具类型用常量或枚举不用魔法字符串。扫描、规则、触发三个环节相互解耦。一个快照可以被多条规则复用。事件对象携带数量与位置而不是只携带一个 bool。检测器不感知具体业务只负责通知。所有边界场景都要写测试至少覆盖数量不足、数量刚好、数量溢出、全空场景。9.2 扩展方向位置匹配、连通域、规则引擎“双果冻双红宝石”只是数量型规则的起点。很多游戏还会要求位置条件例如两个果冻必须相邻。两个红宝石必须在同一行或同一列。果冻和红宝石必须组成 2x2 形状。这些需求需要在GameSnapshot的positions基础上增加空间分析逻辑。比如判断两个坐标是否相邻def is_adjacent(pos1, pos2): return abs(pos1[0] - pos2[0]) abs(pos1[1] - pos2[1]) 1更进一步可以用连通域分析把相邻道具聚成块再判断块的数量和形状。这样就能支持更复杂的消除、合成和连击逻辑。如果业务规则多且频繁变化可以考虑引入规则引擎。在 Java 生态中Drools 是常见选择在 Python 项目中可以先用配置驱动规则或者引入简单的条件表达式解析库。规则引擎能降低新增规则的成本但也会引入性能开销和学习成本是否需要引入要根据团队规模和规则复杂度决定。9.3 上手建议如果要在自己的项目里落地一个组合检测器建议从本文的“数量型规则”开始跑通再逐步加入位置匹配、规则配置化和性能优化。对新手来说最有价值的练习不是直接接游戏引擎而是先把这个纯 Python 的最小模型跑起来尝试新增一条“检测到三个红宝石”的规则然后观察测试、事件和日志如何跟着变化。完成这一步之后再思考场景数据如何从 JSON、数据库或接口进入检测器以及命中规则后如何调用真实业务接口。这个阶段你已经把“检测到双果冻双红宝石”从一个固定需求变成了一类可复用、可测试、可扩展的工程能力。
返回列表