从此
上网
📄文章 #️⃣专题 🌐上网 📺 🛒 📱

游戏开发编程技术

综合

  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游戏上架必须申请版号!