ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Godot 4游戏UI与敌人AI实战:HUD、字体渲染和状态机全解析

Godot 4游戏UI与敌人AI实战:HUD、字体渲染和状态机全解析 这一期其实是系列二里我拖得最久的一篇。前面几篇我们让角色动了起来、加了碰撞、做了基础关卡但游戏看起来还是很“素”——UI是临时凑的敌人只会傻站着挨打打死敌人后也没有任何分数反馈。这期专门补上这两块一套可复用的HUD方案加字体渲染再加一个能让敌人“看”到玩家并做出反应的简单AI。内容基于Godot 4.x不需要任何第三方插件最后还会聊一个发布阶段很多人忽略的问题导出时怎么保护你的场景和脚本资源。如果你已经掌握Godot的基本场景、节点和脚本写法这期可以放心跟。还没看过系列前几篇的新朋友也没关系我会把涉及到的关键节点和属性都解释清楚代码可以直接抄。这一期做完你的2D小游戏在“手感”之外至少会多一层“游戏感”这是从个人demo往正式小项目走很关键的一步。1. 先拆清楚这期要做的三件事1.1 HUD为什么必须用CanvasLayer Control很多初学者做HUD的时候直接把一个Label拖到主场景里跟着玩家角色走或者干脆把血量数字挂在角色节点下面。这样在单机测试时看着没什么问题但一旦摄像机移动起来就会露馅对白文字跟着世界一起晃血条飘到屏幕外。正确做法是把所有界面元素放在CanvasLayer节点下。CanvasLayer是一个独立渲染层它不受摄像机影响角色怎么跑HUD都稳稳停在屏幕上。它也不会打断2D世界的渲染顺序性能和实现都干净。Godot里UI控件的基类是ControlControl和Node2D的核心区别在于Node2D有transform跟随父节点和摄像机Control基于锚点Anchor和容器Container布局天然适配不同窗口尺寸。一句话总结世界里的东西用Node2D屏幕上的东西用Control两套体系别混着用。这里有个很容易踩的误区如果你把一个Control节点直接挂在主场景的根节点通常是Node2D下面它虽然还能显示但坐标和缩放都会跟着世界走。所以HUD的根节点一定要放在CanvasLayer下面这样它脱离世界坐标系完全是屏幕空间的东西。1.2 一套最小可用HUD的节点结构我直接给出一套能用的经典“左上角信息 屏幕中央提示”的结构你照着搭就行CanvasLayer (HUD) └── Control (FullRect) ├── MarginContainer │ └── VBoxContainer │ ├── HPBar (TextureProgressBar) │ ├── ScoreLabel (Label) │ └── ComboLabel (Label) └── CenterContainer └── MessageLabel (RichTextLabel)根节点Control把大小设为全屏锚点Preset选Full Rect这样无论窗口怎么变MarginContainer都能贴着安全边距。MarginContainer负责留边距VBoxContainer负责纵向自动排列你不需要手动计算坐标这是Godot UI体系最舒服的地方。HPBar我建议用TextureProgressBar而不是ProgressBar因为它支持三张图片组合底图、血条填充图、高亮over图比单纯画一个绿色矩形好看太多还支持填充方向。ScoreLabel和ComboLabel就是普通Label分别显示分数和连击数。CenterContainer用来放屏幕中央的提示文本比如“击杀敌人 100”、关卡Boss播报RichTextLabel做这个非常合适后面会细说。搭完节点后建议顺手建一个Theme统一管理样式。在FileSystem面板右键 - New Resource - Theme新建一个theme_main.tres把默认字体和字号配置好再拖到根Control的Theme属性上整个HUD的所有控件一起继承不用每个Label单独设置一遍字体颜色。如果你做的是像素风格游戏字体选择会直接影响观感这一块下面专门展开。2. 字体渲染原理与实操中文字体不糊也不方块2.1 Godot 4字体系统的工作方式做中文游戏字体问题早晚会碰上一次。Godot 4的字体系统底层用FreeType做字形光栅化也就是说无论你加载的是.ttf还是.otf引擎都会把用到的字符逐个渲染成位图然后缓存到一张纹理图集里。听起来抽象可以理解成一个印刷厂字形是铅字第一次用到某个字的时候现场铸造之后放进抽屉里下次直接取用速度就快了。这种机制带来的最大影响有两个。第一中文字符数量巨大几百个常用汉字第一次渲染时会有明显的额外开销游戏卡一下往往就是字体缓存还没建立。第二字形缓存命中之后绘制极快所以不要因为第一次的开销就放弃中文字体关键是配置好字体回退链。Godot 4里字体资源主要有三种SystemFont直接引用操作系统字体适合快速原型测试缺点是换一台设备字体可能就变了发布游戏不推荐。FontFile项目里导入的字体文件可以随包发布这是最常用的方式。FontVariation在已有字体基础上做变体比如调字距、调粗细最关键是它能配置fallbacks回退字体数组。为什么特别强调回退字体因为大多数设计字体和英文字体压根不含中文字形。你用一款好看的像素英文字体做主字体再挂一个思源黑体或Noto Sans SC做中文字体回退Godot在绘制时找不到中文字形会自动去回退字体里找就不会出现方块字了。2.2 项目级字体配置Theme与fallback下面是一套我在项目里实际用的中文字体配置流程。第一步准备字体文件。中文开源字体里思源黑体、阿里巴巴普惠体、得意黑都是能直接商用的选择注意看一下各自开源协议。放项目目录后Godot会自动导入成FontFile资源。第二步创建FontVariation回退链。在FileSystem面板右键 - New Resource - FontVariation把主字体Font设为你的英文字体或像素字体然后在Fallbacks数组里添加中文字体。这样英文数字用主字体中文自动走回退字体整体观感比较稳定。第三步建Theme统一挂载。把FontVariation拖进theme_main.tres的Default Font属性Default Font Size设成适合你项目的基准字号。如果某个Button或Label想单独用大字号直接在节点Inspector里展开Theme Overrides - Font Sizes - Font Size覆盖即可。字体描边和阴影这些细节也藏在Theme Overrides里。比如给字体加黑色描边需要设置三处$MyLabel.add_theme_color_override(font_outline_color, Color.BLACK) $MyLabel.add_theme_constant_override(outline_size, 4) $MyLabel.add_theme_color_override(font_shadow_color, Color(0, 0, 0, 0.5))如果你不想写代码也可以在Inspector里手动展开这些属性。描边在HUD文字上尤其好用不管背景多花文字始终能看清。2.3 动态文本打字机、滚动数字、倒计时字体配好之后最常用的几个动态文本场景我给你贴上现成代码。打字机效果用RichTextLabel的visible_characters属性配合Tween非常简单extends RichTextLabel export var type_speed : 25.0 # 每秒字符数 func play_type(full_text: String) - void: text full_text visible_characters 0 var duration : maxf(0.1, full_text.length() / type_speed) var tween : create_tween() tween.tween_property(self, visible_characters, full_text.length(), duration)注意一点如果文本里用了BBCode标签visible_characters计算的是实际可视字符数不受标签字符干扰这一点Godot处理得很靠谱。滚动数字是动作游戏里最出效果的一类。分数从0滚到1000比直接跳变有反馈感得多extends Label var _last_value : 0 func animate_value(new_value: int, duration : 0.25) - void: if _last_value new_value: return var tween : create_tween() tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) tween.tween_method(_on_value_tween, float(_last_value), float(new_value), duration) _last_value new_value func _on_value_tween(value: float) - void: text str(roundi(value))倒计时就更容易了但有一个隐藏优化点不要每帧去set_text。分钟级倒计时里数字通常每秒才变一次只在你真正需要更新时改textextends Label var countdown_time : 60.0 var _running : false var _last_display : -1 func start_countdown(seconds: float) - void: countdown_time seconds _running true _last_display -1 func _process(delta: float) - void: if not _running: return countdown_time - delta if countdown_time 0.0: countdown_time 0.0 _running false var rounded : ceili(countdown_time) if rounded ! _last_display: _last_display rounded text str(rounded)这里用_last_display做缓存避免每秒几十次的Label重建。这个优化在复杂UI上很关键RichTextLabel尤其敏感频繁set_text会反复解析文本敌人一多、提示一滚掉帧就来了。3. 敌人AI从“木头桩”到会看、会追、会攻击3.1 2D游戏里AI的“视觉”是怎么实现的很多新人一听到AI就想到机器学习和神经网络但在2D游戏里绝大多数敌人AI只是有限状态机。所谓“看见”玩家本质就是距离、角度、遮挡三个判断的组合。距离检测最简单用Area2D的圆形碰撞范围或者直接计算两点距离。角度检测要稍微想一下只有玩家位于敌人前方某个角度内敌人才会转头注意你背后偷袭应该能成立。判断方式不是用atan2硬算而是单位向量的点积。点积公式是a·b|a||b|cosθ向量都归一化之后结果就是cosθcos60°等于0.5所以只要正面朝向点乘指向玩家的方向大于等于0.5就表示夹角小于60度。遮挡检测用RayCast2D发一条射线如果中间有墙或者其他障碍物敌人就不应该隔着墙发现你。这是“2D视觉”里最像真实视觉的部分也是很多新手漏掉的一环。节点结构长这样Enemy (CharacterBody2D) ├── CollisionShape2D ├── Sprite2D ├── RayCast2D (VisionCast) ├── Area2D (AttackRange) └── AnimationPlayer设计上有个原则敌人永远不应该用“全知之眼”看到玩家哪怕AI再简单也要有距离和视线限制。这不仅为了真实感更重要的是给玩家留出策略空间——潜行绕背、卡墙角都能成立。3.2 一个够用的状态机框架直接给一个完整的敌人脚本状态机用枚举加match实现简单直观后续要加“警觉”“逃跑”状态也很容易扩展extends CharacterBody2D enum State { PATROL, CHASE, ATTACK, DEATH } signal killed(score_value: int) export var patrol_speed : 50.0 export var chase_speed : 120.0 export var view_distance : 240.0 export_range(0, 180, 1) var view_angle : 60.0 export var attack_range : 40.0 export var score_value : 100 var state: State State.PATROL var _direction : Vector2.RIGHT var _player: Node2D onready var vision_cast: RayCast2D $VisionCast onready var sprite: Sprite2D $Sprite2D onready var anim_player: AnimationPlayer $AnimationPlayer func _ready() - void: add_to_group(enemies) _player get_tree().get_first_node_in_group(player) vision_cast.enabled true vision_cast.exclude_parent true func _physics_process(delta: float) - void: if state State.DEATH: return update_state() match state: State.PATROL: _patrol(delta) State.CHASE: _chase(delta) State.ATTACK: _attack(delta) move_and_slide() func _patrol(delta: float) - void: if is_on_wall(): _direction -_direction velocity _direction * patrol_speed _update_flip(_direction.x) func _chase(delta: float) - void: if not _player: return var to_player : global_position.direction_to(_player.global_position) velocity to_player * chase_speed sprite.rotation lerp_angle(sprite.rotation, to_player.angle(), 8.0 * delta) func _attack(delta: float) - void: velocity Vector2.ZERO if _player: var to_player : global_position.direction_to(_player.global_position) sprite.rotation lerp_angle(sprite.rotation, to_player.angle(), 8.0 * delta) if anim_player.current_animation ! attack: anim_player.play(attack) func update_state() - void: if not _player: return var to_player : global_position.direction_to(_player.global_position) var dist : global_position.distance_to(_player.global_position) var can_see : dist view_distance and _is_in_angle(to_player) and _has_line_of_sight(to_player, dist) if state State.ATTACK and dist attack_range: # 继续攻击直到脱离攻击范围 return if can_see: state State.CHASE if dist attack_range else State.ATTACK else: state State.PATROL func _is_in_angle(to_player: Vector2) - bool: var forward : Vector2.RIGHT # 默认角色面朝右 return forward.dot(to_player) cos(deg_to_rad(view_angle / 2.0)) func _has_line_of_sight(to_player: Vector2, dist: float) - bool: vision_cast.target_position to_player * dist var collider : vision_cast.get_collider() return collider null or collider.is_in_group(player) func _update_flip(x: float) - void: sprite.flip_h x 0.0 func die() - void: if state State.DEATH: return state State.DEATH velocity Vector2.ZERO $CollisionShape2D.set_deferred(disabled, true) anim_player.play(death) killed.emit(score_value)几个容易踩的细节我单独说。RayCast2D的target_position是本地坐标不是全局坐标所以要用“指向玩家的方向向量乘以距离”来赋值。set_deferred(disabled, true)必须用set_deferred不能在物理回调里直接改碰撞状态否则会报错。另外如果美术素材本身是左右朝向的Sprite巡逻时用flip_h翻转就行如果素材是固定朝右追逐时直接旋转sprite更自然这两种做法不要混用一个节点很容易出现“翻过来又转过去”的视觉错乱。3.3 敌人死亡→加分→HUD更新的信号闭环现在把AI和HUD连起来。最简单的做法是在敌人die()里直接调用HUD的更新函数但我不建议这样。原因很简单敌人节点不需要知道HUD存在耦合越少越好。以后你要加击杀音效、任务进度、还是成就系统都去监听同一个killed信号就行改起来非常舒服。HUD端监听信号extends CanvasLayer onready var score_label: Label $Root/MarginContainer/VBoxContainer/ScoreLabel func _ready() - void: for enemy in get_tree().get_nodes_in_group(enemies): _connect_enemy(enemy) func _connect_enemy(enemy: Node) - void: if not enemy.killed.is_connected(_on_enemy_killed): enemy.killed.connect(_on_enemy_killed) func _on_enemy_killed(score_value: int) - void: var target : int(score_label.text) score_value var tween : create_tween() tween.tween_method(_set_score_text, int(score_label.text), target, 0.2) func _set_score_text(value: int) - void: score_label.text str(value)如果你的敌人是运行时动态生成的生成后调用一下_connect_enemy(enemy)就行。连接信号时务必用is_connected
返回列表