Lua实现可扩展行为树:游戏AI模块化与热更新实战

发布时间:2026/7/31 5:14:48

Lua实现可扩展行为树:游戏AI模块化与热更新实战 1. 项目概述为什么游戏AI需要可扩展的行为树在游戏开发尤其是独立游戏或中小型团队项目中我们常常面临一个矛盾既希望AI逻辑足够复杂、智能能够应对多样的游戏场景又受限于紧张的开发周期和有限的人力。传统的状态机FSM在逻辑简单时很好用但当AI行为超过十几个状态状态间的转换关系就会变得像一团乱麻难以维护和扩展。这时行为树Behavior Tree就成了一个更优雅的解决方案。行为树将AI决策过程抽象为一棵树形结构通过节点Node的组合来定义行为逻辑。其核心优势在于模块化和可读性。每个节点如条件节点、动作节点、序列节点、选择节点职责单一通过树的结构清晰地表达了“在什么条件下按什么顺序执行什么动作”的逻辑。这对于团队协作和后期迭代至关重要。然而很多开发者尤其是初次接触行为树的同学在兴奋地搭建起第一个AI后很快就会遇到新的瓶颈当游戏需求快速变化需要频繁添加新行为、调整优先级或者需要让AI具备学习、记忆等更高级能力时最初设计的行为树框架往往变得僵化难以扩展。代码里开始出现大量的“硬编码”添加一个新怪物类型可能意味着要复制粘贴一整棵树并手动修改几十个地方这显然是不可持续的。这正是“可扩展性”要解决的问题。一个可扩展的行为树框架应该能让开发者像搭积木一样快速组合出新的AI行为并且能方便地注入游戏特有的上下文如玩家位置、自身血量、道具信息等甚至支持运行时动态修改树的结构。而Lua凭借其轻量、嵌入容易、热更新灵活的特性成为实现这一目标的绝佳语言。它允许我们将行为树的逻辑定义从C等底层引擎中解耦出来用脚本快速迭代AI逻辑无需重启游戏。接下来我将结合自己在一个横版动作游戏项目中重构AI系统的实战经验分享三种用Lua实现高度可扩展行为树的高级技巧。这些技巧不仅关乎代码怎么写更关乎如何设计框架让你在面对策划频繁的需求变更时依然能从容不迫。2. 技巧一基于数据驱动的行为树构建与动态加载第一个技巧也是实现可扩展性的基石就是将行为树的结构定义数据化并与逻辑执行分离。最糟糕的做法是把树的结构和节点的执行逻辑全部写死在Lua代码里混杂在一起。2.1 设计可序列化的节点与树结构我们首先要定义一套描述行为树的数据结构。一个常见的做法是使用Lua表Table来定义整棵树。每个节点都是一个表包含其类型、参数、子节点等信息。-- 行为树节点数据结构的定义示例 local node_definitions { -- 一个选择节点Selector从左到右执行子节点直到一个成功。 { id root_selector, type selector, children { { id seq_attack, type sequence, children { ... } }, { id seq_patrol, type sequence, children { ... } }, { id action_idle, type action, task idle } } } } -- 一个更具体的攻击序列节点 local attack_sequence { id seq_attack, type sequence, -- 顺序节点所有子节点成功才算成功任一失败则中断。 children { { type condition, evaluator isPlayerInRange, -- 条件评估函数名 args { radius 5.0 } }, { type action, task chargeAttack, -- 动作执行函数名 args { speed 2.0, damage 10 } } } }这里的关键是type、evaluator、task这些字段都是字符串。它们不是直接指向Lua函数而是函数在某个注册表中的键Key。这样做的好处是这整棵树的结构可以被轻易地序列化成JSON、Lua文件甚至存储在数据库中。策划或设计师可以在不接触代码的情况下通过编辑JSON文件来调整AI的行为逻辑。实操心得在项目初期我就吃过亏。最初我把函数直接写在节点定义里如action function(blackboard) ... end。这确实直观但导致树结构无法序列化保存也无法实现热重载。后来重构为“函数名注册”模式灵活性大增。记得为每个节点设计一个唯一的id字段这在调试和动态修改时会非常有用。2.2 实现树的加载器与节点工厂有了数据结构我们需要一个“加载器”Loader和“节点工厂”Node Factory来将数据变成可执行的行为树对象。-- 节点工厂根据类型字符串创建对应的节点对象 local NodeFactory {} NodeFactory.registry {} -- 注册表存放类型名到节点类的映射 function NodeFactory.register(nodeType, nodeClass) NodeFactory.registry[nodeType] nodeClass end function NodeFactory.create(nodeData, parent) local nodeClass NodeFactory.registry[nodeData.type] if not nodeClass then error(Unknown node type: .. tostring(nodeData.type)) end -- 传入节点数据和父节点引用创建实例 local node nodeClass.new(nodeData, parent) -- 递归创建子节点 if nodeData.children then for _, childData in ipairs(nodeData.children) do local childNode NodeFactory.create(childData, node) node:addChild(childNode) end end return node end -- 示例注册一个动作节点类 local ActionNode {} ActionNode.__index ActionNode function ActionNode.new(data, parent) local self setmetatable({}, ActionNode) self.id data.id self.taskName data.task -- 任务名如 chargeAttack self.args data.args or {} self.parent parent self.status fresh -- fresh, running, success, failure return self end function ActionNode:execute(blackboard) self.status running -- 从全局任务注册表中查找并执行函数 local taskFunc TaskRegistry.get(self.taskName) if taskFunc then local result taskFunc(blackboard, self.args) self.status result and success or failure else print(Warning: Task not found - .. self.taskName) self.status failure end return self.status end -- 注册到工厂 NodeFactory.register(action, ActionNode)加载器的工作就是读取JSON/Lua数据文件调用NodeFactory.create生成整棵树的根节点。这样当我们需要为一种新的敌人配置AI时只需要新增一个数据文件然后在游戏初始化时加载它即可。2.3 支持动态加载与热更新基于数据驱动的最大优势——热更新。由于树结构是数据节点逻辑函数是通过名字从注册表动态查找的我们可以在游戏运行时替换这些函数。-- 假设我们有一个管理所有行为树的BrainManager function BrainManager:loadTree(entityId, treeConfigPath) local treeData loadJsonFile(treeConfigPath) -- 加载数据 local rootNode NodeFactory.create(treeData) self.trees[entityId] { root rootNode, blackboard Blackboard.new() -- 每个AI独立的黑板数据 } end -- 热更新当检测到行为树数据文件变化时 function BrainManager:hotReloadTree(entityId, treeConfigPath) local oldBrain self.trees[entityId] if oldBrain then -- 保留原有的黑板数据维持AI的运行状态记忆 local oldBlackboard oldBrain.blackboard -- 重新加载树结构 local newTreeData loadJsonFile(treeConfigPath) local newRootNode NodeFactory.create(newTreeData) self.trees[entityId] { root newRootNode, blackboard oldBlackboard -- 关键复用黑板 } print(Behavior tree hot-reloaded for entity: .. entityId) end end这意味着策划调整了Boss的攻击频率或巡逻路径你只需要让他修改JSON文件并保存游戏内无需重启Boss的AI行为立刻就会改变。这对于调试和迭代的效率提升是巨大的。3. 技巧二利用“黑板”Blackboard实现节点间高效数据共享与解耦行为树中的节点需要根据游戏世界的信息做决策比如“玩家是否在视野内”、“自身血量是否低于20%”。同时一个节点产生的数据如“计算出的逃跑目标点”可能需要被另一个节点使用。如果让节点之间直接互相引用或访问全局变量会造成严重的耦合难以维护。“黑板”Blackboard模式是解决这个问题的标准答案。你可以把它想象成一个AI实体私有的、键值对形式的共享内存区域。所有节点都通过同一个黑板对象来读写数据彼此不知道对方的存在实现了完全解耦。3.1 设计一个灵活的Lua黑板系统一个基础的黑板实现很简单就是一个Lua表。但我们需要考虑线程安全如果在多线程环境下、数据类型和访问控制。local Blackboard {} Blackboard.__index Blackboard function Blackboard.new() local self setmetatable({}, Blackboard) self.data {} -- 存储所有数据 self.listeners {} -- 监听器用于数据变化时触发回调 return self end -- 设置数据可触发监听器 function Blackboard:set(key, value, forceNotify) local oldValue self.data[key] self.data[key] value -- 如果值发生变化或者强制通知则触发监听 if forceNotify or oldValue ~ value then self:notifyChange(key, value, oldValue) end end function Blackboard:get(key, defaultValue) local value self.data[key] if value nil then return defaultValue end return value end -- 监听数据变化可用于调试或触发复杂逻辑 function Blackboard:watch(key, callback) if not self.listeners[key] then self.listeners[key] {} end table.insert(self.listeners[key], callback) end function Blackboard:notifyChange(key, newValue, oldValue) local listeners self.listeners[key] if listeners then for _, cb in ipairs(listeners) do cb(newValue, oldValue) end end end3.2 在条件与动作节点中应用黑板现在我们的条件评估函数和动作执行函数都可以接收黑板作为参数。-- 在任务注册表中注册函数 TaskRegistry.register(isPlayerInRange, function(blackboard, args) local selfPos blackboard:get(self.position) local playerPos blackboard:get(world.player.position) if not selfPos or not playerPos then return false end local radius args.radius or 10.0 local distance calculateDistance(selfPos, playerPos) return distance radius end) TaskRegistry.register(chargeAttack, function(blackboard, args) local entity blackboard:get(self.entity) -- 获取关联的游戏实体对象 local targetPos blackboard:get(attack.targetPosition) if not entity or not targetPos then return false -- 动作失败 end -- 执行冲锋逻辑... local speed args.speed or 1.0 chargeTowards(entity, targetPos, speed) -- 动作完成后可以在黑板上设置状态 blackboard:set(lastAction, chargeAttack) blackboard:set(isCooldown.charge, true) -- 假设冲锋总是成功实际应根据游戏逻辑判断 return true end)关键点在于是谁在更新黑板上的world.player.position或self.position这些原始数据这通常由一个独立的“感知系统”Perception System来完成。这个系统每帧或每隔几帧收集游戏世界的信息并更新到每个AI实体的黑板上。这样行为树节点只需要关心从黑板上消费数据完全不知道数据来源实现了感知与决策的分离。3.3 实现带作用域的分层黑板对于复杂AI比如一个包含多个子任务如“战斗”-“寻找掩体”-“射击”的复合行为我们可能希望某些数据只在特定子树内共享而不是全局可见。这就需要分层黑板。我们可以设计一个支持作用域链的黑板。当在一个子树中查找某个键时先在自己的局部作用域找找不到再向上级父作用域查找直到根黑板。local ScopedBlackboard {} ScopedBlackboard.__index ScopedBlackboard function ScopedBlackboard.new(parentBlackboard) local self setmetatable({}, ScopedBlackboard) self.localData {} self.parent parentBlackboard -- 父级黑板形成链 return self end function ScopedBlackboard:setLocal(key, value) self.localData[key] value end function ScopedBlackboard:get(key, defaultValue) -- 先在本地查找 local value self.localData[key] if value ~ nil then return value end -- 本地没有且存在父级则向父级查找 if self.parent then return self.parent:get(key, defaultValue) end -- 链上都没有返回默认值 return defaultValue end在创建行为树节点时可以为某些复合节点如SequenceSelector创建一个新的ScopedBlackboard并将其传递给子节点。这样子节点之间可以共享一些临时数据而不会污染全局黑板空间。例如一个“寻找路径”的节点可以将计算出的路径点列表存储在局部黑板上供后续的“沿路径移动”节点使用其他不相关的节点则看不到这个数据。注意事项黑板虽然强大但也要避免滥用。不要把所有数据都塞进黑板只存放节点间需要共享的决策状态和上下文信息。像敌人的基础属性攻击力、血量上限这类静态数据更适合直接从游戏实体组件中读取。同时要为黑板键名制定清晰的命名规范如使用点分隔的命名空间perception.playerVisible,navigation.targetPoint防止键名冲突。4. 技巧三通过装饰器与自定义组合节点应对复杂逻辑标准的行为树节点类型Sequence, Selector, Condition, Action有时不足以优雅地表达一些复杂逻辑。比如“在5秒内尝试攻击最多3次”或者“只有当某个全局事件发生时才执行某个子树”。这时我们就需要用到装饰器Decorator和自定义组合节点。4.1 装饰器节点的威力装饰器是一种特殊节点它只有一个子节点。它的作用是修改或增强这个子节点的行为。常见的装饰器有重复Repeat重复执行子节点N次或直到失败。取反Inverter将子节点的执行结果取反成功变失败失败变成功。强制返回Force Success/Failure无论子节点结果如何都返回成功或失败。冷却Cooldown子节点执行后在一段时间内不能再被执行。条件中断Conditional Abort在子节点运行期间持续监控某个条件若条件变化则中断子节点。用Lua实现一个通用的装饰器基类很容易关键在于设计好它的execute方法使其能够包装子节点的执行。local DecoratorNode {} DecoratorNode.__index DecoratorNode function DecoratorNode.new(data, parent) local self setmetatable({}, DecoratorNode) self.id data.id self.child nil -- 装饰器只有一个子节点 self.parent parent self.status fresh self.decoratorType data.decoratorType self.args data.args or {} return self end function DecoratorNode:addChild(node) if self.child then error(Decorator node can only have one child!) end self.child node end -- 具体装饰器重复节点 local RepeatDecorator setmetatable({}, {__index DecoratorNode}) RepeatDecorator.__index RepeatDecorator function RepeatDecorator.new(data, parent) local self DecoratorNode.new(data, parent) setmetatable(self, RepeatDecorator) self.currentCount 0 self.maxCount data.args.times or 1 -- 默认重复1次即无效果 return self end function RepeatDecorator:execute(blackboard) self.status running while self.currentCount self.maxCount do local childStatus self.child:execute(blackboard) if childStatus running then return running -- 子节点还在运行直接返回 elseif childStatus failure then self.status failure return failure -- 子节点失败中断重复 end -- 子节点成功继续下一次循环 self.currentCount self.currentCount 1 end self.status success return success end function RepeatDecorator:reset() self.currentCount 0 self.status fresh if self.child then self.child:reset() end end -- 注册到工厂 NodeFactory.register(decorator_repeat, RepeatDecorator)在数据定义中我们可以这样使用{ id: try_attack_three_times, type: decorator_repeat, args: { times: 3 }, children: [ { type: sequence, children: [ { type: condition, evaluator: canAttack }, { type: action, task: performAttack } ] } ] }4.2 构建自定义组合节点应对特定场景除了装饰器我们还可以创造全新的组合节点类型。标准行为树库可能不提供但你的游戏特定需要的节点。例如一个“并行节点Parallel”它同时执行所有子节点并根据成功/失败的数量来决定自己的返回状态常用于“一边移动一边播放动画”。local ParallelNode {} ParallelNode.__index ParallelNode -- 假设继承自某个基础节点类 function ParallelNode.new(data, parent) local self setmetatable({}, ParallelNode) self.id data.id self.children {} self.parent parent self.status fresh self.policy data.args.policy or requireAll -- requireAll, requireOne return self end function ParallelNode:execute(blackboard) self.status running local successCount 0 local failureCount 0 for _, child in ipairs(self.children) do if child.status fresh or child.status running then local childStatus child:execute(blackboard) if childStatus success then successCount successCount 1 elseif childStatus failure then failureCount failureCount 1 end -- 如果子节点是running则继续留在running状态 else -- 子节点已经结束计入结果 if child.status success then successCount successCount 1 elseif child.status failure then failureCount failureCount 1 end end end -- 根据策略判断并行节点自身状态 local total #self.children if self.policy requireAll then if successCount total then self.status success return success elseif failureCount 0 then self.status failure return failure end elseif self.policy requireOne then if successCount 0 then self.status success return success elseif failureCount total then self.status failure return failure end end -- 否则继续运行 return running end另一个有用的自定义节点是“动态选择器Dynamic Selector”。普通选择器Selector是按固定顺序评估子节点。而动态选择器可以在每次执行前根据黑板上的动态权重或优先级对子节点进行重新排序让AI的行为选择更具适应性和变化。4.3 将游戏事件作为触发器集成到行为树中有时AI需要响应外部事件比如“被击中时触发格挡”、“听到声音后转向”。我们可以设计一种“事件监听装饰器”或一个特殊的“事件条件节点”。思路是在游戏的事件系统中允许行为树节点注册监听。当特定事件发生时事件系统会通知所有监听该事件的AI实体并在其黑板上设置一个标志或数据。-- 事件条件节点 local EventConditionNode {} EventConditionNode.__index EventConditionNode function EventConditionNode.new(data, parent) local self setmetatable({}, EventConditionNode) self.id data.id self.eventType data.args.eventType -- 如 onDamaged, onSoundHeard self.status fresh return self end function EventConditionNode:execute(blackboard) -- 检查黑板上是否有该事件触发的标记 local eventFlag blackboard:get(event. .. self.eventType, false) if eventFlag then -- 消费掉这个事件标记防止同一事件重复触发 blackboard:set(event. .. self.eventType, false) return success end return failure end -- 在游戏的事件派发器中 function GameEventDispatcher:onEntityDamaged(entityId, damageInfo) -- ... 处理伤害逻辑 ... -- 通知该实体的行为树 local brain BrainManager:getBrain(entityId) if brain then brain.blackboard:set(event.onDamaged, true) brain.blackboard:set(event.lastDamageSource, damageInfo.source) end end这样我们就可以在行为树中创建这样的分支“当‘被攻击’事件触发时执行‘格挡’或‘逃跑’序列”。这极大地增强了AI与游戏世界的交互能力。常见问题过度使用装饰器和自定义节点会让行为树变得难以理解。我的经验法则是优先使用标准的Sequence和Selector进行组合。只有当标准节点组合出的逻辑非常晦涩或低效时才考虑引入装饰器或自定义节点。并且一定要为这些非标节点编写清晰的注释说明其用途和行为。5. 实战构建一个可扩展的怪物AI并处理常见问题让我们综合运用以上三种技巧为一个简单的游戏怪物构建AI。假设怪物有“闲逛”、“追击玩家”、“攻击”三种主要行为优先级从高到低是攻击 追击 闲逛。5.1 AI行为树结构设计首先我们用数据定义这棵树local monsterAITree { id monster_root, type selector, -- 根节点是选择器按优先级选择分支 children { { -- 攻击分支 (最高优先级) id attack_branch, type sequence, children { { type condition, evaluator isPlayerInAttackRange, args { range 1.5 } }, { type decorator_cooldown, -- 添加攻击冷却装饰器 args { cooldownKey attackCD, duration 2.0 }, children { { type action, task meleeAttack, args { damage 15 } } } } } }, { -- 追击分支 id chase_branch, type sequence, children { { type condition, evaluator isPlayerInSight, args { sightRange 10.0 } }, { type action, task moveToPlayer, args { speed 3.0 } } } }, { -- 闲逛分支 (最低优先级兜底行为) id wander_branch, type action, task wanderRandomly, args { radius 5.0, interval 3.0 } } } }5.2 关键节点的Lua实现与黑板交互我们需要实现上述用到的条件评估器和动作任务并展示它们如何与黑板交互。-- 感知系统每帧更新黑板数据简化示例 function PerceptionSystem:updateEntity(entityId, blackboard) local entity World:getEntity(entityId) local player World:getPlayer() -- 更新自身位置 blackboard:set(self.position, entity:getPosition()) -- 更新玩家位置 blackboard:set(world.player.position, player:getPosition()) -- 计算并更新玩家是否在视野内 local distance vector.distance(entity:getPosition(), player:getPosition()) blackboard:set(perception.playerDistance, distance) blackboard:set(perception.playerInSight, distance 10.0 and hasLineOfSight(entity, player)) -- 更新血量状态 blackboard:set(self.health, entity.health) blackboard:set(self.isLowHealth, entity.health entity.maxHealth * 0.3) end -- 条件评估函数 TaskRegistry.register(isPlayerInAttackRange, function(blackboard, args) local distance blackboard:get(perception.playerDistance, math.huge) return distance (args.range or 1.0) end) TaskRegistry.register(isPlayerInSight, function(blackboard, args) return blackboard:get(perception.playerInSight, false) end) -- 动作任务函数 TaskRegistry.register(meleeAttack, function(blackboard, args) local entity blackboard:get(self.entity) local player World:getPlayer() if not entity or not player then return false end -- 执行攻击动画、伤害计算等 entity:playAnimation(attack) player:takeDamage(args.damage) -- 在黑板记录上次攻击时间用于冷却判断 blackboard:set(lastAttackTime, os.clock()) return true end) TaskRegistry.register(moveToPlayer, function(blackboard, args) local entity blackboard:get(self.entity) local targetPos blackboard:get(world.player.position) if not entity or not targetPos then return false end local currentPos entity:getPosition() local direction vector.normalize(vector.sub(targetPos, currentPos)) local velocity vector.mul(direction, args.speed or 2.0) entity:setVelocity(velocity) -- 这是一个“持续”动作需要返回running直到到达目标 local distance vector.distance(currentPos, targetPos) if distance 0.5 then -- 到达阈值 entity:setVelocity({x0, y0}) return true -- 成功到达 end return running -- 仍在移动中 end)注意moveToPlayer动作返回的是running。行为树每帧或每个更新周期都会从根节点重新执行Tick对于返回running的节点下一帧会直接继续执行它而不是重新评估。这保证了动作的连续性。5.3 调试与性能优化技巧一个复杂的行为树系统调试是必不可少的。1. 可视化调试在游戏中绘制当前AI的行为树状态是终极调试手段。可以为每个节点在execute方法中记录其本次执行的结果成功、失败、运行中并将这些状态信息与节点ID关联。然后在游戏调试界面上根据ID将树绘制出来并用不同颜色如绿/红/黄高亮节点状态。这能让你一眼看出AI当前卡在哪一步。2. 黑板数据监视为黑板提供一个调试查看接口。在游戏内按某个键可以显示当前选中AI的黑板所有键值对。这对于验证感知系统是否正确更新数据、动作节点是否设置了正确的状态标志至关重要。3. 性能优化条件节点优化行为树每帧都从根节点执行高频率的条件检查如距离计算可能成为性能瓶颈。可以采用“分层更新”策略将条件分为“高频”每帧和“低频”每0.5秒或更长。对于低频条件可以在节点内记录上次检查时间未到时间则直接返回缓存结果。避免过深的树过于复杂庞大的树会增加遍历开销。尽量保持树的扁平化将复杂的子树封装成“宏”或“子行为树”通过一个特殊的“子树节点”来引用。这样主树结构清晰也便于复用。节点状态缓存标准行为树每次Tick都会重新遍历。对于已经完成成功/失败且其前提条件未改变的子树可以缓存其结果在本帧内跳过执行。这需要更精细的状态管理和脏标记机制实现复杂但对性能提升显著。4. 一个常见的坑状态重置行为树节点在返回success或failure后其状态会保持。下一帧如果从根节点重新执行需要将非running的节点状态重置为fresh否则它们将不会被执行。重置的时机通常是在父节点开始执行时或者整棵树每帧Tick开始时。务必在你的框架中处理好状态重置逻辑否则会出现AI“卡住”不动的情况。6. 扩展思路从行为树到实用工具链当你拥有一个稳定可扩展的Lua行为树框架后可以围绕它构建一系列提升生产力的工具。1. 可视化编辑器这是最大的生产力倍增器。你可以使用诸如LÖVE、Defold甚至网页技术配合Lua解释器开发一个简单的编辑器。让策划或设计师通过拖拽节点、连线的方式来构建行为树编辑器负责生成我们定义好的JSON或Lua表数据文件。这能极大降低行为树的使用门槛。2. 行为树片段库将一些经过验证的、通用的行为模式如“巡逻-警戒-追击”循环、“远程攻击-寻找掩体”策略封装成行为树片段保存到库中。在设计新AI时可以直接从库中拖拽这些片段进行组合快速搭建原型。3. 与技能系统、对话系统集成行为树不仅可以控制移动和战斗也可以用来驱动NPC的对话流程、触发场景交互、管理BOSS的阶段技能。将技能释放、对话选项也抽象为行为树上的动作节点可以让游戏的所有逻辑驱动统一到行为树框架下保持架构的一致性。4. 机器学习实验接口虽然本文不涉及复杂的AI但可扩展的行为树框架为未来集成机器学习如强化学习提供了可能。你可以将行为树中的某些决策节点如选择攻击方式暴露为一个可学习的“策略”让机器学习模型来输出决策结果而行为树负责执行。黑板则成为机器学习Agent观察环境State的窗口。实现一个健壮、可扩展的Lua行为树系统前期需要一定的设计和开发投入但一旦建成它将为你的游戏AI开发带来持久的敏捷性和强大的表现力。记住框架的目标不是追求理论上最完美的AI而是用可维护的代码快速实现符合游戏设计需求的、有趣的AI行为。从一个小而核心的版本开始逐步迭代让它随着你的项目一起成长。

相关新闻