游戏开发编程技术
综合
project.godot配置项:config_version=4则识别为Godot 3.x;config_version=5则识别为Godot 4.x 分辨率使用IDE默认的1152×648 见源码 Godot .NET导出apk大小为22MiB起,仅通过apksigner压缩签名(Android SDK Build-Tools 位于/build-tools/),并不执行Android SDK编译; 可使用 com.example.$genname 变量符来命名唯一名字,$genname取值项目名并自动转为小写。 勾选Gradle构建后*.aab则44MiB+,*.apk则92MB+,且必须进行完整的Android SDK编译。 清理Godot IDE的数据目录:rm -r ~/AppData/Local/Godot/,~/AppData/Roaming/Godot/ 已知 - Godot 4.2不支持Web导出;Godot 4.x起控件文本直接支持中文显示。 Android设备已有8成支持Vulkan 1.1了,故首选Forward Mobile;如果用来做桌面软件,应启用application/run/low_processor_mode。 游戏制作 游戏动画&骨骼蒙皮 游戏引擎IDE 手机游戏开发
核心概念
基础: C#脚本无GDS的全局函数,故将其分散到了Godot.Mathf等类中。 Godot 3D的1个单位等于1米(m); 红色X指两边,绿色Y指上/下,蓝色Z指前/后。 归一化的向量表示向量的方向,不归一则表示位置。 3D节点首选Transform控制平移、旋转、缩放等,尽量少用2D属性Rotation。 Node只描述树形层次,不考虑视觉呈现,Node2D和Node3D则增加了位置(中心点为准)和缩放比(整体缩放/尽量少用),但宽高大小均交由其子类处理,比如sprite2D.get_rect()。 资源模型采用开放标准的glTF(.glb、.gltf/字母为L)格式,次选Blender(.blend)格式。 获取程序名 - ProjectSettings.GetSetting("application/config/name").ToString(); 手机: 获取挖孔屏去除孔洞区域的矩形范围 - DisplayServer.GetDisplaySafeArea(); 引擎: 物理模拟关键点是时间恒定,故此类操作应在_PhysicsProcess中处理,而_Process则处理时间不敏感的行为。 物理帧率 - Physics -> Common -> Physics Ticks per Second(物理 -> 通用 -> 每秒物理周期数): 60 物理处理(Physics processing)函数 - _PhysicsProcess(double delta) 固定帧率:只有固定帧率才能正确且平滑的模拟移动和撞击等物理行为,不至于时快时慢的鬼畜。 _PhysicsProcess函数执行时delta值(帧间时间增量)通常为0.016666666666666666 = 1秒 ÷ 60FPS,大于该值就相当于上一帧执行超时了,故与delta相乘(比如位移)是用来弥补其上一帧超时的亏欠。 空闲处理(Idle processing)函数(尽速帧率/默认30FPS?) - _Process(double delta) 尽速帧率:由于_PhysicsProcess维持了定速的物理行为,故其他快慢无所谓的行为可以放入该函数。 节点分类: 单独使用 - Button、MeshInstance3D等 组合使用 - CharacterBody3D需要子节点CollisionShape3D来提供碰撞,需要MeshInstance3D等提供可见性,后两者为同级; 因MeshInstance3D无IsOnFloor()碰撞判断,且只提供可见性,故无法单独与CollisionShape3D搭配使用,可换用RigidBody3D。 内存释放: 首选node.QueueFree(); 次选立即释放的node.Free(); C#则可以补一个node.Dispose(); 只UI移除用remove_child(); 注意 - node.Dispose()不会释放非托管资源,可能会导致内存泄露,且会处于释放后状态,若再调用Free()则报ObjectDisposedException; 故应先调用node.Free()释放底层资源,然后再调node.Dispose()释放C#托管资源。 节点Free()后再访问就要判定下:GodotObject.IsInstanceValid(obj) 或 IsInstanceIdValid(若持有id) 两种核心基类 - Node(手动Free()释放) - Node3D、Control等 Resource(单例/引用计数自动GC) - PackedScene(即tscn场景类型),Texture,GDScript,CSharpScript等 弱引用解决内存泄露 - var wr=GodotObject.WeakRef(obj); Variant obj=wr.GetRef(); 树形场景: 场景树:GetTree(); 或 静态获取(SceneTree)Engine.GetMainLoop(); 节点可以直接new出来并add;或通过持有id来获取 GodotObject.InstanceFromId(id) 但场景则只能通过序列化方式(PackedScene)存在,故需要显式的实例化:GD.Load("res://scene.tscn").Instantiate (); 首选GD.Load方法;次选比GD.Load多一个CacheMode.Reuse参数的ResourceLoader.Load; 异步载入用ResourceLoader.LoadThreadedRequest("res://s.tscn"); 轮询后取用 if (ResourceLoader.LoadThreadedGetStatus("res://s.tscn") == ThreadLoadStatus.Loaded){ ResourceLoader.LoadThreadedGet("res://s.tscn"); } 调用Load返回预载入(首次缓存)PackedScene,ps.Dispose()或ps.Free()释放缓存;执行ps.Instantiate()后则返回可使用的node了。 序列化时除rootNode之外的节点必须设置node.Owner = rootNode; 否则不予储存:var s = new PackedScene(); s.Pack(node); ResourceSaver.Save(s, "user://path/s.tscn"); 节点关系: 物质实体(MeshInstance3D)的Position移动会过于平稳机械,应让玩家角色体(CharacterBody3D)包裹一下,通过其MoveAndSlide()来模拟真实世界的惯性或滑动。 玩家角色体(CharacterBody3D/Sprite2D)类似可穿透实物的鬼魂,有中心点但无大小,搭配了子节点碰撞体(CollisionShape3D)就穿不透了,有中心点但无大小,设置了Shape才会有碰撞体积; 或给玩家角色体节点内添加了物质实体(MeshInstance3D)等可见体才能被看到。 玩家角色体或StaticBody2D默认无重力下坠,但RigidBody3D则默认会重力下坠。 CollisionShape3D适合规则型形状物体;CollisionPolygon3D适合多边形精细物体 不设置 物质实体(MeshInstance3D) 的Texture材质属性则啥都看不到,IDE中直接拖入图片即可,或:sprite2D.Texture = ImageTexture.CreateFromImage(Image.LoadFromFile(@"D:\x\x.jpg")); 节点控制: 按Name路径定位 - GetNode("Area2D/CollisionShape2D"); 返回null并忽略路径不存在 - GetNodeOrNull("Area2D/CollisionShape2D"); 按Name路径和属性名(仅取路径末端)定位属性 - GetNodeAndResource("Area2D/CollisionShape2D:shape:extents"); 看不到但节点还在 - node.Hide(); 节点不存在于节点树中但内存未释放 - node.GetParent().CallDeferred(Node.MethodName.RemoveChild, node); 彻底不存在 - node.QueueFree(); 清空子节点 - var p = GetNode("Game"); foreach (var n in p.GetChildren()) { p.RemoveChild(n); } Button节点无法与其他节点同时点按,故应换为支持多点触控的TouchScreenButton(设置texture_normal后方可见); 判断鼠标位置是否在节点范围 - GetRect().HasPoint(ToLocal(inputEventMouseButton.Position)) 选择框取值 - GetNode ("CheckBox").ButtonPressed; // 或不触发信号的 set_pressed_no_signal(1) 遍历所有子节点: void childrenNodes(Node node) { foreach (var n in node.GetChildren()) { if (n is MeshInstance3D) { GD.Print("MeshInstance3D - " + n.GetPath()); } GD.Print(n.GetPath()); childrenNodes(n); } } 线程: await ToSignal(GetTree().CreateTimer(2.0f), SceneTreeTimer.SignalName.Timeout); new System.Threading.Thread(() => { Thread.Sleep(1000); GD.Print("After Sleep"); ap.CallDeferred(AnimationPlayer.MethodName.Play, "test"); }).Start(); 信号: IsConnected无法判断“+=”方式连接的信号,但Connect(...)方式的则判定正确。 C# nameof(method)效果等同Godot MethodName.method 信号增加尾参:注意 - 只有GDScript才有bind方法! var t:Timer=get_node("Timer"); #t.one_shot=true; t.timeout.connect(Callable(self,"m").bind(123)); t.start() 解决C#的Lambda中无法使用-=取消连接信号的语法限制: 首选 IsConnected判断方式避免重复连接; 或 ap.AnimationFinished += (sn) => { var list = ap.GetSignalConnectionList(AnimationPlayer.SignalName.AnimationFinished); foreach (var dict in list) // signal、callable、flags { ap.Disconnect(AnimationPlayer.SignalName.AnimationFinished, (Callable)dict["callable"]); } }; ap.Play("Idle"); 或 一次性连接信号 - ap.Connect(AnimationPlayer.SignalName.AnimationFinished, new Callable(this, MethodName.xxx), (uint)ConnectFlags.OneShot); 文件存储: 只读文件夹前缀 res:// 或 可写文件夹前缀 user:// 游戏数据或配置存储目录格式为 - user://x.txt 转化为绝对路径 - ProjectSettings.GlobalizePath("res://x.dll") ResourceLoader.Load(...)不识别绝对路径(C:/x.txt这种),路径前缀必须为 res://、user:// 或 uid:// ,未填写则自动补 res:// ,或用外部文件专用类 Image.LoadFromFile(@"D:\x\x.jpg")。 访问绝对路径文件: 文本格式 - FileAccess.Open(fPath, FileAccess.ModeFlags.Read).GetAsText(); var fa = FileAccess.Open(fPath, FileAccess.ModeFlags.Read); var bytes = fa.GetBuffer((long)fa.GetLength()); fa.Close(); 简单k/v存储: var cf = new ConfigFile(); cf.SetValue("path", "k", "v"); cf.SetValue("path", "k2", v2); // 值支持数组、对象等 cf.Save("user://x.ini"); // 存在则覆盖 if(cf.Load("user://x.ini")==Error.Ok){var v=(string)cf.GetValue("path", "k");} // 以下为读取配置文件: if (cf.Load("user://default.ini") == Error.Ok && cf.HasSection("a.b")) { cf.EraseSection("a.b"); cf.Save("user://default.ini"); } // 存在则覆盖 着色器(Shader): 作用 - 为模型或人物的边缘进行发光和描边(Outline)等。 原理 - 顶点着色器 每个3D坐标顶点调用一次vertex()函数;片段着色器 每个像素调用一次fragment()函数。 x.gdshader shader_type canvas_item; void fragment(){ COLOR = vec4(0.4, 0.6, 0.9, 1.0); }
产品实务
根节点: 根节点通常仅占位而已,故首选基类Node,次选Node3D(红色)、Node2D(紫色/基类为CanvasItem)、Control(多个Anchor锚点布局/绿色/适合HUD等),其他节点类型也可用但适用场景比较特化。 当Control的Anchor布局(绿圈加号图标)乏力时可结合更高级的CenterContainer来简化此过程,被其包裹后直属节点的Anchor配置将失效,全权由其接管。 CenterContainer是只居中不缩放,AspectRatioContainer则是保持宽高比率缩放。 容器布局: HBoxContainer属性theme_override_constants/separation用于设置子节点间距;若想4面均设置,可用MarginContainer的theme_override_constants/margin_top等。 常用: 设置所处碰撞层 - area2D.CollisionLayer = 0b1101; // 倒着数,1为勾中 地板 - 通常用 StaticBody3D + 子节点(CollisionShape3D.Shape属性:无限平面WorldBoundaryShape3D) + (MeshInstance3D.Mesh属性:有限平面PlaneMesh) IDE中物体(RigidBody3D)下陷地板一部分后,实际运行的时候会整体顶到地板上方。 地板和上方物体均应设置碰撞体(CollisionShape3D等),否则会互相穿透或无限坠落。 CollisionShape3D不可见时(visible=false)碰撞体仍然有效,disabled=true时方失效。 俩WorldBoundaryShape3D对撞无效,会报:Collisions between world boundaries are not supported 鼠标、键盘: 鼠标位置 - GetViewport().GetMousePosition(); 不松则每帧触发 - Input.IsActionJustReleased("ui_accept"); 不松也仅按下时的那帧触发 - Input.IsActionPressed("ui_accept")) 键盘键值发送和接收: var iek = new InputEventKey(); iek.CtrlPressed = true; iek.ShiftPressed = true; iek.AltPressed = true; iek.Keycode = Key.Escape; iek.Pressed = true; // 执行后松开 Input.ParseInputEvent(iek); // 模拟键盘按键 // 或 模拟鼠标: var ie = new InputEventMouseButton() { ButtonIndex = MouseButton.Left, Position = GetViewport().GetScreenTransform() * GetGlobalTransformWithCanvas() * new Vector2(10, 20), }; Input.ParseInputEvent(ie); // 只会被Godot程序感知:Ctrl+Shift+Alt+Escape public override void _UnhandledInput(InputEvent ie) { if (ie is InputEventKey iek) { GD.Print(iek.AsText()); if (iek.Pressed && iek.Keycode == Key.Escape) { GetTree().Quit(); } } } 相机: 玩家节点放2个摄像头,一个第一人称视角,另外放一个俯视角,根据情况切换:GetNode("CharacterBody3D/Camera3D").MakeCurrent(); 视角: 走动和站立动画切换:_PhysicsProcess函数内 if (direction != Vector3.Zero) { ... animationPlayer.Play("walk"); } else { ... animationPlayer.Play("idle"); } 前后左右转向:_PhysicsProcess函数内 ... if (velocity.Length() > 0) { var zx = new Vector2(velocity.Z, velocity.X); var n = GetNode ("player"); // 90度转向(向后则180度):vector2.Angle() n.Rotation = new Vector3(n.Rotation.X, zx.Angle(), n.Rotation.Z); } Velocity = velocity; MoveAndSlide(); 射线: public override void _PhysicsProcess(double delta) { var q = PhysicsRayQueryParameters3D.Create(Vector2.Zero, new Vector2(50, 100)); // q.collide_with_areas = true // 使Area3D也参与碰撞 GetWorld3D().DirectSpaceState.IntersectRay(q).Collider; } // if (r.Count > 0){ 获取射线所及碰撞体 } 上文Create(写死入参)可通过鼠标位置为起点: if (@event is InputEventMouseButton emb && emb.Pressed && emb.ButtonIndex == MouseButton.Left) { float RayLength = 1000.0f; // 冗余些,使用时再计算最靠近处实际线段长度。 var camera3D = GetNode ("Camera3D"); var pos = emb.Position; // GetViewport().GetMousePosition(); var from = camera3D.ProjectRayOrigin(pos); // 摄像头的3D中心点position,不转镜头 var to = from + camera3D.ProjectRayNormal(pos) * RayLength; // 返回归一化后的方向,乘上长度则等比例将射线延长出去。 } AI寻路行为: 说明 - 寻路使用导航节点,通过场景级脚本_PhysicsProcess更新AI组的navAgent.set_target_position; 寻路不使用导航节点的方式(敌人脚本):direction = (playerTarget.Position - Position).Normalized(); AI行为状态简单的话用有限状态机,复杂点的可以上行为树(比如边走路边说话的并行动作Parallel)。 AI寻路参考 - https://www.bilibili.com/video/BV1pY411Z7kR/ 行为树插件(3.x起支持C#) - https://godotengine.org/asset-library/asset/1349 https://bitbra.in/beehave/#/manual/leaf_nodes GDS行为树实例 - https://gdscript.com/solutions/godot-behaviour-tree/ C#/Mono行为树插件 - https://github.com/playajames419/Godot4-BehaviorTree GDS行为树插件 - https://godotengine.org/asset-library/asset/2514 行为树原理参考 - https://www.cnblogs.com/KillerAery/p/10007887.html 导航寻路: NavigationServer3D 不是Node,而是一个API类。 NavigationRegion3D 默认生效范围是根节点,只会“烘培”大于其半径 navigation_mesh.agent_radius 值(乘2即顶视图目标体直径)的物体,青色区域标识。 烘焙 - 会跳过type="PackedScene"的节点,可通过SourceGeometryMode设为GROUPS_WITH_CHILDREN分组解决;或将其放入NavigationRegion3D子节点内也能识别, 烘培测试地板 - https://github.com/godotengine/godot-demo-projects/raw/master/3d/material_testers/models/test_bed/test_bed.glb NavigationAgent 节点是根据设置进来的target_position变换为get_next_path_position()进行position移动。 实例用法 - https://www.danieltperry.me/post/godot-navigation/ https://docs.godotengine.org/en/stable/tutorials/navigation/navigation_using_navigationagents.html#actor-as-characterbody3d 放入移动物体(CharacterBody3D)的节点内后:比如作为Player的子节点,就能持续感知到Player当前的Position了。 na.TargetPosition = Vector3.Zero; 或 navAgent.set_target_position(NavigationServer3D.map_get_closest_point(...)) 或 map_get_closest_point_to_segment(camera_ray射线与最近导航区相交处...) # https://github.com/godotengine/godot-demo-projects/blob/master/3d/navigation/navmesh.gd _PhysicsProcess(double delta) var velocity = Velocity; if (!IsOnFloor()){ velocity.Y -= gravity * (float)delta; } // 启用重力 if (!na.IsNavigationFinished()) { var v = GlobalPosition.DirectionTo(na.GetNextPathPosition()) * Speed / 2; velocity = new Vector3(v.X, velocity.Y, v.Z); LookAt(GlobalPosition + velocity, Vector3.Up, true); // 看向目标 } Velocity = velocity; MoveAndSlide(); [可选/可用代码替代] 可取代NavigationAgent的代码方式 - https://www.bilibili.com/video/BV1Gp4y1Z7db/ https://www.youtube.com/watch?v=og4O_YRayRc 关键代码: # 超出导航区则取最近处的导航区位置 var cp = NavigationServer3D.map_get_closest_point(get_world_3d().navigation_map,Vector3(0,0.25,0)) # 起步至终点相连的导航区数组 var points = NavigationServer3D.map_get_path(get_world_3d().navigation_map,global_position,cp,true) func _physics_process(delta): var next_position = points[index] - global_position ... 代码有缺失,请参看视频教程中完整版(其未提供源码工程) ... Mesh网格: SurfaceTool编程式生成网格 - https://docs.godotengine.org/zh-cn/4.x/tutorials/3d/procedural_geometry/surfacetool.html [比SurfaceTool更底层]编程式生成复杂MeshInstance3D网格 - https://docs.godotengine.org/en/stable/classes/class_arraymesh.html#description [适合实时生成]编程式生成网格 - https://docs.godotengine.org/zh-cn/4.x/classes/class_immediatemesh.html 修改Mesh - https://docs.godotengine.org/zh-cn/4.x/classes/class_meshdatatool.html 碰撞: 可通过MeshInstance3D的工具栏“网格->创建xxx碰撞同级”生成严丝合缝的CollisionShape3D碰撞体。 或 通过(Blender等)3D建模物体名后缀识别为碰撞体 - https://docs.godotengine.org/zh-cn/4.x/tutorials/assets_pipeline/importing_3d_scenes/node_type_customization.html 穿透 - 空心的ConcavePolygonShape3D(“创建Trimesh(三角网格)碰撞同级”)会穿透WorldBoundaryShape3D,仅适用于StaticBody3D,可换用其他“创建...碰撞同级”,或换掉后者。 ConvexPolygonShape3D是实心的,即使对象完全位于其内部,也能够检测到碰撞。 近战武器攻击判定: 添加AnimationPlayer节点,添加轨道:属性轨道 -> 选节点CollisionShape2D属性disabled -> 武器碰撞默认禁用,当动画武器伸出后再“插入关键帧”取消其禁用,进而触发判定。 远程子弹射击: 子弹节点通常用CharacterBody2D或CharacterBody3D,也能用Area2D。 发射出去后不该受玩家枪管视角影响 - https://docs.godotengine.org/zh-cn/4.x/tutorials/scripting/instancing_with_signals.html 3D中展示2D: 直接Node2D或Control,或使用Sprite3D、SubViewport制作跟随3D角色的血条。 NPC对话插件 - Dialogue Manager、Dialogic
游戏版号 - 2017年起中国境内
手机和PC游戏上架必须申请版号!