Godot —— 从入门到入土 教程(已更新到GDS高级篇第五节)
本帖最后由 电童·Isamo 于 2023-6-27 19:02 编辑伴随着版主dasasdhba的第一个Godot引擎暨国内Mario Forever圈内第一个Godot MF引擎的发布,MF制作进入了全新的多元化平台时代
过去,我们一直使用着经典的 Clickteam Fusion Developer 2.5 开发平台来制作自己的MF引擎。然而随着使用时间的不断增长,该平台的问题也日益凸显,且正在逐渐落后于日益增长的开发需要。这时,我们就不能将目光仅局限于CTF之上了。于是,一大批非CTF的引擎呼之欲出——Game Maker版的MF on GM(by dasasdhba),Godot版的Storm Engine(by TeamCE)和Berry Editor(by dasasdhba)成为了这些引擎中的典型。
而本人则选择了更具有开发潜力的后者——Godot
随着本人对该引擎的日趋了熟,本人更有把握以尽可能易吸收、易理解的语言来描述引擎的用法。
当然,由于部分操作不经常使用,教程中也会出现瑕疵,届时也望熟习者能够在本贴下方补充,鄙人将不胜感激。
回归正题,开本帖的目的,就是从入门到入土地讲解Godot的基本用法、操作和代码编写。其中基本用法和操作在本贴中只会讲一些比较常用的,重点会放在代码的讲解上。故如果你已有一定的编程基础(尤其是python),那么本贴中的代码部分对你来说将会得心应手。
(本帖中主要以3.X为主,关于4.X的内容,我已在本版单独开了一个新帖进行讲解,主要是讲解3.X和4.X版本的一些变化,以及新的GDScript及其语法。如果你已经打算直接入坑4.X,那么本人还是推荐先从本贴开始入手,然后比照这个帖子进行学习)
本帖中会经常用到的链接:
官网:点我进入
官方doc文档(内容非常丰富,学有余力可以来此学习,自带中文(但是大部分是机翻,推荐看英文原文):点我进入
鸣谢表:
[*]ZQH真强悍:指出本贴数学篇——矩阵与坐标变换中有关仿射变换的表述错误,现已进行更正
接下来是本贴的目录(不定时更新),其中标蓝的表示已完成的小节
[*]Godot是什么?Godot的部署及其基础操作介绍
[*]节点、场景和场景的实例化
[*]资源简介与节点的属性
[*]GDScript其一:GDScript入门
[*]GDScript其二:GDScript的导出、一些常用关键字
[*]GDScript其三:函数(方法)与类
[*]GDScript其四:虚函数、节点的简单导入和onready变量
[*]GDScript其五:信号
[*]GDScript其六:数组
[*]GDScript其七:字典
[*]GDScript其八:Setget方法
[*]GDScript其九:帧步处理
[*]GDScript其十:tool脚本、绘制图形
[*]GDScript其十一:添加、移除与销毁节点
[*]GDScript数学一——矩阵与坐标系变换
[*]GDScript数学二——简单随机数和范围限定
[*]GDscript数学三——线性插值
[*]GDScript高级篇其一:协程yield,SceneTree类的简单学习
[*]GDScript高级篇其二:实例化场景、高级导入节点与%型节点
[*]GDScript高级篇其三:高级分支条件句,封装,引用Array和Dictionary的本质
[*]GDScript高级篇其四:动态调用、FuncRef和延迟处理
[*]GDScript高级篇其五:初始化虚函数及其处理顺序
[*]GDScript高级篇其六:is-a与has-a(类的归属与属性检测)
[*]GDScript高级篇其七:静态方法、单例的介绍与应用
[*]GDScript高级篇其八:高级导出
[*]GDScript高级篇其九:高级信号
[*]GDScript高级篇其十:枚举的实质
[*]GDScript高级篇其十一:Object类——所有对象的根类
[*]GDScript高级篇其十二:Notification——一切虚函数的本质
[*]GDScript高级篇其十三:帧步处理的本质及其性能优化
[*]GDScript高级篇其十四:节点树深入、场景的简单管理
[*]GDScript高级篇其十五:Resource类和自定义资源
[*]GDScriptQ&A-1:是对象、节点还是资源?
[*]GDScriptQ&A-2:循环引用(仅限Godot3)
[*]节点篇其一:Timer
[*]节点篇其二:CollisionShape2D
[*]节点篇其三:StaticBody2D和KinematicBody2D
[*]节点篇其四:Area2D和RayCast2D
[*]节点篇其五:TransformRemoter2D
[*]节点篇其六:Sprite和AnimatedSprite
[*]节点篇其七:AnimationPlayer的简单应用
[*]实战篇其一:简易的滑动运动物体
[*]实战篇其二:物品交互
[*]实战篇其三:踩踏
[*]实战篇其四:状态资源与状态机
[*]构架篇:如何从0构造一个引擎?——关于我重做了好几次引擎这件事
本帖最后由 电童·Isamo 于 2023-2-24 12:56 编辑
Godot是什么?Godot的部署及其基础操作介绍
[*]什么是Godot?
Godot(全称Godot Engine)是Juan Linietsky和Ariel Manzur开发一款制作游戏的软件,可以制作2D和3D游戏。通过基于节点的架构来设计游戏,3D渲染器设计可以增强3D游戏的画面。具有内置工具的2D游戏功能以像素坐标工作,可以掌控2D游戏效果。面向团队的设计从架构和工具到VCS集成,Godot专为团队中的每个人设计。编辑器可在Windows、Mac OS和Linux系统中运行,支持导出游戏到Windows、Mac OS、Linux、Android、iOS、UWP和HTML5等平台。(注:从3.5版本起编辑器也支持在Android端上运行)
[*]引擎有官网吗?
当然,点击这里前往官网
[*]怎么下载Godot引擎?
前往下载地址后,找到
https://s1.ax1x.com/2022/09/19/x9qCQI.png
点击后,你会跳转到如下页面
https://s1.ax1x.com/2022/09/19/x9qkef.png
我们很容易发现右侧有四个下载按钮,其中每两个按钮上方都有一行字来描述,这行字便是你要下载的子版本:Godot 3.X分为Standard版本和Mono版本,如图,后者追加对C#脚本的支持。所以,如果你选择下载后者,那么在用编辑器写代码时,你就可以直接利用Mono版本自带的C#语言库编写脚本,而不需要其他额外操作。https://s1.ax1x.com/2022/09/19/x9LqUO.png上图是对于你下的godot对你的电脑配置的需求,其中对于Mono版本,你还需要安装.NET SDK才能正常使用Mono版的C#功能本帖只讨论Standard版本然后,根据你电脑的操作系统的位数,点击带有对应位数的按钮下载。
[*]如何查看自己的电脑是32位的还是64位的?
以Windows11为例,右键任务栏的windows图标,找到“系统”并点击https://s1.ax1x.com/2022/09/19/x9OSKI.png之后你可以在此看到你的系统的位数:https://s1.ax1x.com/2022/09/19/x9Oia8.png如果是x86,则为32位系统(注:Windows11没有x86位!);如果是x64,则为64位系统
[*]安装
点击下载按钮后,你下载的将会是一个zip压缩包https://s1.ax1x.com/2022/09/19/x9Ouq0.png下载完成后,打开该zip压缩包,里面会有两份文件(有些版本则是一个,具体还请以你的压缩包内的实际情况为准),都不要删除,将其解压到一个指定的文件夹下https://s1.ax1x.com/2022/09/19/x9OGRJ.png我这里因为下的安装包只有exe所以就只有这一个文件。
[*]项目管理器
打开软件后,会有一个加载界面,加载界面结束后,你将会见到如下界面,该界面便是项目管理器https://s1.ax1x.com/2022/09/19/x9OrJe.png如果你是第一次打开本软件的话,则会弹出一个弹窗:“当前管理器内没有工程,可从右侧按钮中新建一个工程”,点ok关闭即可软件本身有中文和地区检测,故如果你在国内,你无需担心默认语言的问题这里先介绍一下右侧的按钮:
[*]编辑:需要选中一个已有工程才能生效,点击后将打开该工程的编辑器,等价于双击一个已有工程
[*]运行:需要选中一个已有工程才能生效,点击后直接从主场景(后面会讲到)处开始运行当前工程(相当于直接从头运行一个没有被导出【后面会讲】为exe的程序)
[*]扫描:点击后,会在你所选的文件夹(路径)(如:https://s1.ax1x.com/2022/09/19/x9OIJg.png)中扫描所有含.project的文件并将其导入至项目管理器
[*]新建工程:点击后,将会弹出该弹窗:https://s1.ax1x.com/2022/09/19/x9OxFU.png 项目名称填完后,点击右侧的“创建文件夹”后即可在下面的项目路径下新建一个名为你填的项目名称的空文件夹,渲染器这里推荐选择OpenGL ES 3.0(简称GLES3.0,如果你的电脑配置实在是太老太老,则建议选择右侧的OpenGL ES 2.0【GLES2】)
[*]导入:点击后,在你给定的路径https://s1.ax1x.com/2022/09/19/x9OIJg.png下找到.project文件并双击即可将选中的.project文件导入到项目管理器内
[*]重命名:需要选中一个已有工程才能生效,重命名选中的工程
[*]移除:需要选中一个已有工程才能生效,从项目管理器中移除选中的工程,也可以在弹出的弹窗中勾选“同时移除项目文件”来将你不需要的工程从你的电脑中彻底移除
[*]移除缺失项:由于有时候你可能会执行的蜜汁操作,会导致项目管理器你存在缺失项工程,这些工程大多数都是因为从资源管理器内直接删除工程源文件而遗留下来的。点击该按钮后,所有缺失项都将会从你的项目管理器内清除
考虑到开发者开发的参考需要,Godot特意提供了Asset Lib,只需点击
https://s1.ax1x.com/2022/09/19/x9X30P.png
即可切换到素材库界面。注:1.素材库中只会显示示例工程(下面会讲到素材库的另一个版本,它不会显示示例工程)2.部分地区的网络可能连接不上素材库而导致素材库内无法显示内容。出于不可抗力,请自行百度搜索该解决方法
[*]编辑器界面
这里以Berry Editor 为例,双击打开,会弹出一个加载界面,加载界面结束后,你会看到如下界面,这便是该工程的编辑器界面
https://s1.ax1x.com/2022/09/19/x9XwXn.png
我们先讲解位于左上、左下、右侧和中间的内容:
左上角的窗口有两个选项:场景和导入
[*]场景:当前场景的节点树(后面会讲到),你可以在这里操作该场景里的节点(后面会讲到)
[*]导入:只有在选择了资源(后面会讲到)后该选项卡下的界面才会显示,这个我们会在讲资源的时候会详细讲解
左下角的窗口即为文件系统窗口,它显示的就是你当前的工程文件下的文件,res://为当前工程文件夹,比如说我把Berry Editor的所有文件放在了D:\Projects\BerryEditor目录下,那么res://就是D:\Projects\BerryEditor。你可以从文件系统窗口中将一个文件(夹)拖动到其余三个部分内
右侧的窗口有两个选项,分别为检查器选项卡和节点选项卡。检查器选项卡可以显示一个选中的节点(后面会讲)和资源的基础属性,同时支持在检查器里修改这些属性(有些属性是实时修改的,意味着你修改了这个属性后,对应的节点将会立即对该属性作出反应)。节点选项卡我们后面再讲。
最后就是中间的这个大窗口,他就是编辑器的主界面,任何呈现在游戏/程序里的东西,也就是你希望玩家/使用者看到的东西,都需要放在主界面里,如果不放在主界面里,那么它在导出成exe文件(后面会讲到)后也就不会在游戏/程序中显示出来。
接下来我们讲解一下主界面上方的四个按钮:https://s1.ax1x.com/2022/09/19/x9v9q1.png从左到右分别为:2D编辑模式,3D编辑模式,脚本编辑模式 和 素材库
[*]2D编辑模式,该编辑模式下的场景只有横、纵两个维度,这个模式也是本帖主要使用的编辑模式,所有2D节点(后面会讲)都只能在该编辑模式下显示https://s1.ax1x.com/2022/09/19/x9vNss.png
[*]3D编辑模式,该编辑模式下的场景只有横、纵、竖三个维度,如果你有做3D游戏或借用3D效果的需要,可以使用该模式,但本帖不会对该模式下的内容进行任何讲解,如需学习还请自行查阅官方doc(见1L)。所有的3D节点(后面会讲)都只能在该编辑模式下显示https://s1.ax1x.com/2022/09/19/x9vdZq.png
[*]脚本编辑模式,在此模式下编辑脚本https://s1.ax1x.com/2022/09/19/x9vDiT.png
[*]素材库,同项目管理器中的素材库,不过这里的素材库只能显示插件等素材,不会显示示例工程
[*]开始开发前你需要做的准备
首先我们打开
https://s1.ax1x.com/2022/09/19/x9v5FK.png
https://s1.ax1x.com/2022/09/19/x9vzY8.png
将上图中的“限制编辑器视图”取消勾选,如若保持勾选,则在主界面移动视图时,你可移动的视图范围将会受到限制接下来我们找到并点击https://s1.ax1x.com/2022/09/19/x9x301.pnghttps://s1.ax1x.com/2022/09/19/x9xD0I.png将窗口宽高根据情况进行调整(这里调为了默认的640*480),建议勾选使用垂直同步(否则会出现部分代码高帧率执行的问题,后面会讲到)https://s1.ax1x.com/2022/09/19/x9xR1g.png将拉伸按上图进行修改即可https://s1.ax1x.com/2022/09/19/x9xd6H.png将帧缓冲分配调为2Dhttps://s1.ax1x.com/2022/09/19/x9xfXj.png必须开启GPU像素吸附,否则图像在拉伸后会出现裂缝https://s1.ax1x.com/2022/09/19/x9xI7q.png这里将UV收缩勾选以防在导出到部分windows平台上后程序内的图像出现问题
以上就是本节的全部内容了,接下来我会介绍关于项目编辑器里一些比较常用的选项卡/窗口
本帖最后由 电童·Isamo 于 2022-9-20 00:39 编辑
第一节·补充环节
[*]图层名称
在后面的章节中我们会学习PhysicsBody2D的有关内容,这里先简单介绍一下Godot中的图层:Godot中的图层是一个很宽泛的概念,有Canvas图层、渲染图层、物理图层和导航图层等四种主要的图层,其中后三者的名称可以在项目设置里直接设置,而Canvas图层只能在使用Canvas节点(后面会讲到)时才能使用。除Canvas图层外,渲染图层、物理图层和导航图层都只有32个https://s1.ax1x.com/2022/09/19/xCSXwR.png你可以在上图的界面中给每个图层赋予一个名称,这些名称的作用我们将会在讲PhysicsBody相关的内容后再做说明。
[*]键位映射
https://s1.ax1x.com/2022/09/19/xCpkmd.png上图为键位映射编辑选项卡,你可以在这里编辑给定的动作键,甚至你还可以自行添加/删除动作键,如能合理使用该功能将会呈现出十分强大的效果。关于这些键位映射的详细用法,我们到后面讲解Input单例时再做说明。
[*]自动加载
https://s1.ax1x.com/2022/09/19/xCpmff.png上图即为自动加载的选项卡,这里存放的东西就叫做该工程的单例(Singleton)或全局对象(Global Object),本帖中我们习惯用前者称呼这些内容。至于什么是单例,以及其用途用法,我们会在专门的一节中讲解单例。
[*]插件
https://s1.ax1x.com/2022/09/19/xCp30s.png上图即为插件选项卡。插件是Godot中十分强大且恐怖的存在,它们小到可以提醒你当前时间,大到直接修改你的编辑器工作界面,甚至是菜单栏,可以说这是其他引擎所未有的一个优势。你可以从Asset Lib(即素材库)中下载对应的插件并安装后,可在此控制其开关状态。如果代码能力十分优秀,你甚至可以直接编辑相关代码,更甚者可以创建一个属于自己的Godot插件。不过制作插件暂时不在本帖的教程范围内,故感兴趣的同学可以前往官方doc(见1L)进行学习 本帖最后由 电童·Isamo 于 2023-6-24 10:34 编辑
节点、场景和场景的实例化
[*]什么是节点
节点(Node)是Godot中组成程序功能的最基本单位。小到一个按钮、一个苹果的精灵图、一个角色,大到一整个场景乃至一整个程序(需要实例化,下面会讲),都需要用到节点。节点也是构成一个场景的最基本单位(下面会讲)
Godot中,节点主要分为以下几类(标斜体的表示无法被直接使用,本帖常用的几类将标红加粗):
[*]Node:最基本的节点,是其他所有节点的最基础单位,也可以说,其他任何节点都是这个节点所派生出来的拓展节点,它们均具有这个最基本节点的属性
[*]Spatial:在4.0中叫做Node3D,即3D编辑模式下需要使用到的节点。如果你有3D游戏的需求,那么建议使用这类节点
[*]CanvasItem:2D节点最基本的节点,即2D编辑模式下需要使用到的节点都是这个节点的拓展节点。Control节点和Node2D节点都是拓展自这个节点
[*]Control:图形用户控制界面(GUI或者UI)类节点的最基本节点,诸如按钮、滑条等组件均为该节点的拓展节点
[*]Node2D:2D编辑模式下需要使用到的节点的最基本的节点
[*]AnimationPlayer系列的节点:负责处理动画的一系列节点,具有一定的高级功能
[*]AudioStreamPlayer:负责处理音频的节点
[*]CanvasLayer:负责处理视觉图层(Canvas图层)的节点的最基本节点
[*]HTTPRequest:负责发送HTTP(S)请求的节点
[*]Navigation系列的节点:负责处理节点导航的一系列节点
[*]SkeletonIK:骨骼IK节点
[*]Timer:负责倒计时的节点
[*]Tween:负责处理插值(平滑效果)的节点
[*]Viewport:负责处理视图,名为root的Viewport节点是所有节点的最基本的根节点
[*]WorldEnvironment:负责处理渲染环境
[*]如何新建一个节点
方法一:https://i.imgtg.com/2022/10/28/PwlPX.png选中一个节点,然后点击箭头所指向的“+”按钮,会弹出如下界面https://i.imgtg.com/2022/10/28/Pw99t.png上述界面便是添加节点界面,根据自己的需求双击对应类型的节点即可方法二:https://i.imgtg.com/2022/10/28/Pw2Ux.png选中一个节点,右键点击,找到“添加子节点”并点击即可弹出如方法一图二所示的窗口注:上述节点都是在创建你所选的节点的子节点(下面会讲)
[*]操作节点
节点可以进行如下操作:https://i.imgtg.com/2022/10/28/Pw8dp.pnghttps://i.imgtg.com/2022/10/28/PwuaU.png其中的【实例化子场景】和【将分支保存为场景】我们下面会讲到
[*]场景
在讲节点树之前,我们先来学习一下什么是场景。提到场景(Scene),我们学过用过Clickteam Fusion都知道:场景不就是一个容纳若干对象的大容器嘛。但是,在Godot里,场景的概念就与CTF截然不同了。这么说吧,CTF里的场景等同于Game Maker里的Room,他们都属于是一种容纳小对象的容器(Container),就好比一个房子,每个房间都是场景,每个房间里的物品就是这个场景里的对象(Object),而这些对象又包含了许许多多不同的东西,包括这个对象的不同组件(Component)和属性(Property)。然而CTF和GM的场景都有一个缺陷,那就是:开发者只能按照独体式思维来思考问题,即不同场景就是不同场景,你不容我,我不容你,A场景不可以塞到B场景里作为B场景的子场景(Subscene),反之亦然。同时,这种思维只承认大宏观的容器为场景,诸如玩具车、玩偶、桌子这些放在房间里的小宏观物件,则不算做场景。面对这个问题,Godot做出了大胆的尝试:那不妨就让这些房间里的物品也变成场景吧!于是,Godot的场景的概念的轮廓就诞生了——它不再局限于传统的独体式思维,而是利用了全新的一种思维来解决这个问题,即:这是一幢房子,房子本身就是一个最大的房间,这就是最大的场景。房子里又分为若干个房间,这些房间也是一个个场景,只不过它们变成了整个房子这个大场景的子场景。每个房间里有着不同的物品,这些物品里面也或多或少会包含一些东西。你像房间A里有一辆玩具车,而玩具车包含骨架、轮子、马达……这样一想,玩具车好像也是“场景”了,而且还是这个玩具车所在的房间的子场景。再往下分析:马达里有什么?磁铁和线圈,对吧,这样一来,马达也包含东西了,也是个“场景”……这样一思考下来,你也许就在冥冥之中已经掌握了Godot开发中最基本的一种思维了:含析式思维。用人话讲就是:只要物体A包含事物B,不管事物B是什么,物体A都可以叫做场景。Godot的场景的概念十分广泛,大到一整个程序、一个游戏房间(舞台),小到一个对象、一个经常复用组件,乃至一个节点,都可以说是一个场景。
[*]如何新建场景
方法一:
https://i.imgtg.com/2022/10/28/PwdNN.png
在菜单栏中找到场景——新建场景并点击即可
方法二:
https://i.imgtg.com/2022/10/28/PwINY.png
在编辑器主界面上方的选项卡右侧找到“+”并点击即可
[*]节点树
创建了新的场景后,我们将学习什么是节点树(SceneTree)
先来看一个例子:
https://i.imgtg.com/2022/10/28/Pw3Ev.png
上面这张图取自我正在做的引擎ZL Constructor的level场景,可以看到,这个场景里包含了若干个节点。但很有趣的是,这些节点被有序化地安置了,可以看到它们左侧有一些像枝条一样的折线,连接着不同的节点,像是构成了一棵以节点为主体的树一样。这些折线和其所连接的节点就构成了这个场景的节点树(SceneTree),而最上方的那个节点(图中的Level)便是这个场景的根节点(Root Node)。
每个场景不能没有根节点,且有且只能有一个根节点。因此,根节点就代表着这个场景,是这个场景及其内部其他节点(包括它自己在内)的代名节点(Tagger Node)。
我们还能看到,有一些节点的枝杈是连接到其他节点上的,枝杈两端的节点就构成了父(Parent)子(Child)节点的关系,我们以枝杈上面所连接的节点叫做父节点(Parent),枝杈下方所连接的所有节点称为这个父节点的子节点(Children)。
在Godot中,一个父节点可以存在若干个子节点,但这些子节点只能有一个共同的父节点,这些子节点彼此称为兄弟节点(Siblings),且这些子节点也可以分叉出若干个子节点,叫做它们的父节点的孙节点(Grandchildren)
换句话说,任何一个节点只能存在一个父节点(可以与其他节点共用,也可以不共用),且可以包含若干个子节点,子节点可以不同地套子节点,子节点的子节点还可以派生出子节点……
但归根结底,这些节点都要位于根节点之下。换句话说,根节点是这些节点的祖节点(Ancestor)。
在一个场景中,根节点的所有子节点的祖节点,等价于这个场景的主节点(Owner)
最终,一系列节点在以根节点为基础,其余节点作为根节点的子节点这样的架构下,形成了这个场景的场景树(SceneTree)
https://i.imgtg.com/2022/10/28/Pw3Ev.png
我们仍然以上图为例,来说明这个节点的子节点
首先Level为这个场景的根节点,其子节点为Camera2D, TimerLevel 和 Coordinate。而Coordinate又派生出了若干个子节点:Tiles, PlayerMario,QuestionBlock1, QuestionBlock2……
反过来说,PlayerMario, Tiles和那几个QuestionBlock节点的父节点是Coordinate,Camera2D, TimerLevel和Coordinate的父节点是根节点Level,Level就是这个场景内所有节点的祖节点暨这个场景的主节点
上面的这些节点就构成了这个场景里以Level为根节点的节点树
[*]创建根节点
方法一:从新建场景中创建根节点
https://i.imgtg.com/2022/10/28/RMzhb.png
创捷场景后直接在场景面板里选择一个进行创建即可
方法二:将已有场景内的某个节点转化为根节点
这个方法适用于你不小心把一个本该是根场景的节点错加成子场景,亦或是反悔时使用
https://i.imgtg.com/2022/10/28/RM7sK.png
右键应作为根节点的节点,找到“设为场景根节点”并点击即可
[*]根节点的子节点的基本操作
将节点置于某一节点下(或:让某节点成为另一个节点的子节点)
方法一:
https://i.imgtg.com/2022/10/28/RMKIC.png
将节点拖动到目标父节点上,使其出现矩形高亮框后松开鼠标左键即可
方法二:
https://i.imgtg.com/2022/10/28/RMgjX.png
选中目标子节点,右键找到“重设父节点”并点击,在弹出的窗口中选择目标父节点即可
给一个节点设新的父节点
https://i.imgtg.com/2022/10/28/RMyti.png
选中目标子节点,右键找到“重设父节点为新节点”并点击,在弹出的窗口中选择你希望的新创建的父节点的类型即可
设置兄弟节点
https://i.imgtg.com/2022/10/28/RMpWt.png
将某个节点的子节点拖到下方节点处出现线形高亮后松开鼠标左键即可,亦或是右键找到“重设父节点”并点击,并在弹出的窗口中选中其父节点的父节点即可
[*]保存场景
在你做完一个关卡、一个敌人、一个角色、乃至一个物件后,我们需要将其保存以便于我们下面要讲到的实例化与日后的编辑。
https://i.imgtg.com/2022/10/28/ROywF.png
在菜单栏的【场景】选项中有如图所示的三种保存方法,但前两种是最常用的,如果是新建的场景,保存时系统则认为是另存为。这里就直接以另存为的方式来讲解
https://i.imgtg.com/2022/10/28/RMPsj.png
如上图所示,场景默认以.tscn结尾,但是godot提供了三种后缀名:tscn、scn和res。对于这些后缀名,需要点击“所有可用类型”之后再选项框里选择对应的后缀才可以成功保存为带有对应后缀的场景。Godot的场景文件不可以直接改后缀,即使是改为给定的三种,只要不是原来的后缀均会导致场景文件假损坏。
在Godot中,以tscn结尾的场景文件可以在文本编辑器中被直接打开,因此,本人十分推荐以这种格式存储你的场景
在保存完场景以后,我们就可以在我们的文件系统里找到你之前保存过的场景了,如图箭头所示
https://i.imgtg.com/2022/10/28/ROpDP.png
[*]场景实例化
在保存完了一个场景之后,我们就可以随时编辑这个已经保存好场景了。
而我们前面也提到过,Godot中的场景大到一整个程序,一整个游戏舞台,小到一个对象乃至一个节点。既然连像物品、角色这样比较小的物件都能是场景,那么,有没有一种方式,把我已经保存过的场景直接塞入一个新的场景中,让他作为场景里的一个对象(节点)存在呢?
比如,我现在做好了一个名叫level.scn的游戏舞台场景,也做好了一个叫player.res的角色对象场景,那么我该怎么把player.res这个对象(场景)放入level.scn这个更大的舞台(场景)中呢?
这些问题的答案,便是我们这一小节要讲的场景实例化(Scene Instantiation)
[*]什么是(场景)实例化?什么是实例?
场景实例化,顾名思义,就是将场景作为对象应用到另一个场景当中。由于在计算机语言学中,对象在某个地方所呈现出来的真实样本(术语上叫做:对象的实现)叫做实例,因此这一过程也就叫做场景的实例化(Instantiation of Scene),简称实例化。
Godot中,将一个场景A放入目标场景B中后实例化,这个实例化后的场景(记作A')就叫做场景A在场景B中的一个实例化场景(Instantiated Scene)。原来的那个场景A就叫做场景A'的源场景(Source Scene),而场景A'就叫做场景A的一个实例(Instance)
实际上,场景A可以在场景B中被实例化出若干个实例化场景,那么如果我在场景B中多次实例化场景A,那么实例化后的场景A1、A2……An都叫做场景A的实例(Instances)
https://i.imgtg.com/2022/10/28/RMEiq.png图1
https://i.imgtg.com/2022/10/28/RM4rU.png图2
如上图2,A、A2和A3都是上图1中场景A的实例
[*]实例化场景和根节点的关系
我们在上一小节学习节点树的时候提到过:根节点代表着一个场景,是这个场景及其内部其他节点(包括它自己在内)的代名节点。由于根节点a代表了其所在的场景A,那么场景A在场景B中被实例化以后,场景A的实例(记作A')在场景B中就以场景A的根节点a的身份出现了。也就是说,这个根节点a就代名(Tag)了这个场景A'内包括它自己在内的所有节点。与此同时,这个时候场景A'内原来含有的节点的主节点仍然是这个实例化场景的根节点a,但这个场景A'的根节点的主节点,在打开其对应的场景编辑器时仍为其本身,但在场景B内则以场景B的根节点(记作b)为主节点。
同时需要注意的是:当源场景中的对应节点或属性发生变化后,该源场景的所有实例化场景内的对应节点和对应属性均会发生对应的变化。反之,如果一个场景的(所有)实例化场景发生变化,则该源场景则不会发生任何变化。
由于场景文件名与其内部根节点名可以不相同,因此建议在保存场景的时候将场景名命名为其内部根节点名的snake_case写法
[*]在一个场景中加入实例化场景/将一个场景在另一个场景中实例化
方法一:
https://i.imgtg.com/2022/10/28/RMdV1.png
https://i.imgtg.com/2022/10/28/RMYrD.png
在节点面板上方找到锁链形按钮,点击后在弹出的“实例化子场景”中双击选择一个你希望实例化的场景即可
方法二:
https://i.imgtg.com/2022/10/28/RMfQF.png
从文件系统管理器中直接将你希望实例化的场景拖拽到目标场景的目标节点下即可(此时实例化后的场景节点将作为目标节点的根节点)
有了场景的实例化,角色、敌人、金币等小宏观场景就可以通过实例化的形式来进入像游戏舞台这样的大宏观场景内了
实例化后的场景节点右侧都会有这个按钮https://i.imgtg.com/2022/10/28/ROJ8b.png,点击后即可在新打开的场景选项卡窗口内编辑源场景
[*]显示并允许编辑实例化后的场景的根节点下的所有子节点
实际上,场景A实例化到另一个场景B内后,实例化后的场景A'默认是以其根节点a的身份显示并存在的,而里面的东西只是被【隐藏】起来了,实际运行的时候,这些子节点仍然存在。但是,如果我们到后面需要打开看一看,甚至需要编辑这些子节点,该怎么办?
这个时候,我们就需要一个菜单选项来帮我们解决这个问题了:
https://i.imgtg.com/2022/10/28/ROMQS.png
如上图,只需要将右键菜单中的“子节点可编辑”勾选,即可显示该节点下方的所有子节点,如下图
https://i.imgtg.com/2022/10/28/ROZAI.png
注:显示出来的子节点的属性(后面会讲到)的文字均为金色
如果取消勾选上述右键菜单中的“子节点可编辑”,则已经编辑后的子节点的属性将会还原为在源场景内的默认值(实际上并不会立即还原)
[*]节点抽出成场景
前面或多或少也已经明示或暗示到,节点本身就是一个场景。
场景实例化,本质上是将场景转化为节点,那么反过来,节点是否也可以从节点树中抽离并保存为场景呢?
答案是可以的
https://i.imgtg.com/2022/10/28/ROOCN.png
如上图1,右键一个不是实例化场景节点的节点,找到并点击“将分支保存为场景”,之后像另存为场景那样将场景保存到一个指定的位置即可。
需要注意的是:
[*]利用这个方法创建的场景,根节点是你选中的那个节点,其子节点均为该根节点的子节点
[*]利用这个方法创建的场景,其内部所有节点的属性的值均为其对应源节点的对应属性的对应值
[*]利用这个方法创建的场景,会将源节点直接实例化为新建场景的实例化场景节点,同时自动将该节点下的所有子节点隐藏
[*]父子节点的属性继承(针对CanvasItem下属的所有节点有效)
(注:本小节涉及到部分GDScript内容)
我们前面提过,一个节点只能有一个父节点,这是因为父子节点的一个重要特征——属性继承(Property Inheritance)
之前的教程图片中有部分用的是Node,然而Node并不存在坐标和变换这些图形学概念。这时,我们就需要将眼光放在具有变换、Z索引等图形学概念的、功能更强大的Node2D身上了
Node2D本身具有位置、旋转和缩放等三个基本的变换元素,而这些就属于Node2D的属性(Property)。
对于CanvasItem下属的所有节点(其中就包括Node2D)而言,所谓属性继承,就是说父节点有关Transform的属性,都会对其子节点产生影响(实际上是其子节点会对这些属性进行继承)。
这里我们就以两个Node2D节点所派生的Sprite节点为例,来视觉化展示一下父子对象的属性继承
注意:Sprite派生自Node2D这个类,意味着Node2D的所有属性,包括位置、旋转、缩放等,在Sprite节点上亦存在且有效
https://i.imgtg.com/2022/10/28/ROkqj.png
上图中大的图像为Sprite,小的图像为Sprite2
我们尝试更改一下子节点Sprite2的位置:
https://i.imgtg.com/2022/10/28/ROxAp.png
可以看到,子节点的位置发生了变化,而父节点的位置却丝毫未改
我们尝试更改一下父节点Sprite的位置:
https://i.imgtg.com/2022/10/28/ROFCY.png
可以看到,父节点Sprite向右移动了一段距离,子节点Sprite2也跟着向右移动了一段相同的距离
现在,我们更改一下子节点Sprite2的旋转:
https://i.imgtg.com/2022/10/28/ROinv.png
同样地,父节点Sprite的旋转没有发生任何变化
同理,我们改一下父节点Sprite的旋转:
https://i.imgtg.com/2022/10/28/ROnDq.png
我们会惊奇地发现,子节点居然也跟着父节点旋转了,并且是绕着父节点的旋转中心而旋转的!
那么缩放这里就不再展示了,是同样的效果
由此观之,当父节点的某些属性(如坐标、旋转、缩放,三者合称变换)被修改以后,其子节点就会继承这些属性的影响。
如果一个节点A下面有两个子节点B和C,那么当A的属性改变时,B、C的属性也会随之改变,反之,当B或C的属性发生改变时,C或B、父节点A的属性则不会随之改变,因为B、C互为兄弟节点,互相独立,无法将属性直接共享。
但是,如果一个节点A下面有一个子节点B,而子节点B下面又有一个子节点C,那么当A的属性发生改变时,B、C的属性都会发生改变;当B的属性发生改变时,A的属性不会发生改变,但C的属性会随B的属性的改变而发生改变;如果C的属性发生改变,则A、B的属性均不会发生改变
[*]场景继承
实际上,我们会遇到这样一个问题:我想制作一个角色,但是这个角色有很多信息都是我接下来要做的角色里所包含的。如果我要直接复制这个场景的话,一旦我哪天想要改这个角色的底层信息,那么我就需要连带着修改其他的角色场景的相关信息。尤其是到了开发后期,角色越来越多,我还要修改越来越多的角色信息……好生麻烦!
那么,有没有一种方法,可以让我只需要修改一个角色的信息,就可以同步修改掉其他角色的相关信息呢?答案就是我们这节课要学习的场景继承
[*]何谓“场景继承”
场景继承(Scene Inheritance),指一个场景以自己为模板,派生出另一个场景,而作为模板的场景的属性等信息,在派生出的场景里均会得到继承。这里这个模板场景就被称为父场景(Parent scene),而派生出来的场景就被称为这个父场景的子场景(Child scene)。
场景继承后,子场景内所有从父场景内拷贝过来的属性和节点均会被继承。父场景的相应节点或属性发生变更时,子场景相对应的节点或属性也会发生同样的变更。但子场景内的变化并不会影响到父场景。
举个例子,我有一个场景名叫“苹果”,我创建了个继承这个场景的子场景“红富士苹果”,那么红富士这个场景内的所有节点及其属性均会从“苹果”里相对应的地方拷贝进来,并且,即便红富士里的某些节点的属性发生了变化,其父场景“苹果”里对应节点的对应属性却不会发生任何变化,然而反过来,如果“苹果”内的一些节点或属性发生了变化,则“红富士苹果”的相应节点或属性也会发生对应的变化。无论我在红富士这个场景内加多少节点,或者把那些只加在红富士这个场景内的节点(全部)删除,都不会导致苹果这个场景内的节点树发生变化。
需要注意的的是:在子场景中无法删除父场景中存在的节点
[*]如何新建继承场景?
方法一:
https://i.imgtg.com/2022/10/28/ROrYG.png
https://s1.ax1x.com/2022/09/20/xPcFd1.png
https://s1.ax1x.com/2022/09/20/xPcCL9.png
如上图,在菜单栏“场景”选项下找到“新建继承场景”,然后会弹出一个窗口(如上图2),之后打开需要作为父场景的场景文件,然后会弹出一个新的场景窗口,另存为一个新的场景即可
方法二:
https://i.imgtg.com/2022/10/28/ROK51.png
在文件系统管理器内右键需要作为父节点的场景文件,找到并点击“新建继承场景”,剩余操作同方法一
创建后的继承场景,其根节点是一个实例化的场景节点,如下图:
https://s1.ax1x.com/2022/09/20/xPcusH.png
不过好奇的小伙伴也会问了:这场景继承跟根节点的场景化好像啊,那是不是说,根节点也可以场景化啊?
答案是不可以的!
能这么问是因为,场景继承本身就是根节点的场景化
如果你尝试对根节点使用“将分支保存为场景”的话,会弹出这个报错:
https://s1.ax1x.com/2022/09/20/xPcJW8.png
仔细看途中红框的部分,第一个说明:根节点是不可以被抽离成场景的。但是第二个红框却说:可以使用场景继承来将根节点抽离成场景。
本质上,场景的继承,其实就是先将根节点及其子节点抽离出所在场景之后保存成场景,然后再变为实例化场景加回源场景。然而我们知道:场景不能没有根节点。这显然是Godot所不允许的,因此,必须从父场景的根节点出派生出去一个子场景,而这个根节点本身不需要被抽离出它所在的场景,这才是保证这一前提的最佳方案。因此,编辑器是不允许开发者将根节点抽离成场景的。
而继承后的子场景,本质上就是父场景的根节点使用了一种不需要将自己抽离出去再加回来的“将当前分支保存为场景”,使创建出去的子场景的根节点,就是父场景的根节点的实例化场景节点。因此,子场景的根节点是实例化节点这事儿也就说得通了
[*]主场景
我们已经知道:Godot的场景,大到一整个程序、一个游戏舞台,小到一个对象乃至一个节点。但是,我们也只是把游戏舞台、对象和节点这些东西变成了场景并将其进行了实例化,而整个程序究竟是谁来负责启动、运行,我们仍然还无从得知。这个时候,我们就需要引入一个新的概念——主场景(Main Scene)
主场景是游戏/程序的入口,任何Godot工程要想从头到尾完整地运行,就不能没有主场景。它就是我上面所提到的【最大的场景】,是负责整个程序的场景。
[*]设置主场景
https://s1.ax1x.com/2022/09/20/xPHn4e.png
在文件系统管理器中,右键你需要设为入口场景的场景,找到并点击“设为主场景”,即可设为主场景
https://s1.ax1x.com/2022/09/20/xPHM3d.png
主场景的文件名会在文件系统管理器内显示为蓝色
设置主场景后,就需要把过渡场景或关卡场景实例化为该场景内根节点的子节点才能完整运行整个游戏
至此,第二节就全部讲完了
本帖最后由 电童·Isamo 于 2022-10-28 16:02 编辑
资源简介与属性除了节点,Godot中还有一些物件,他们虽然无法像节点那样直接加入到节点树中,成为节点树的一部分,但它们可以以节点为载体,通过装载到节点这个载体上来参与程序的执行,并在一定程度上影响节点的效果,这样的物件就叫做资源(Resource)
[*]什么是资源?
资源(Resource),在Godot中指的是:不直接性地加入节点树中,却能够通过装载到节点内部,进而改变节点在节点树中的表现的这一类物件的统称。资源可根据其性质、影响的对象的不同而产生不同的分类。
(实际上,资源的定义是指用于存储一系列非内置类型信息的容器)
按照性质的不同,资源可分为:
[*]基本资源
[*]图文资源
[*]音视频资源
[*]数理资源
[*]其他资源
按照影响的对象的不同,资源可分为:
[*]基础节点资源
[*]3D资源
[*]2D资源
[*]UI资源
[*]动画资源
等等。
[*]几种常用的资源
在本贴中,我们将会经常用到以下几类资源:
[*]Shape2D
[*]AudioStream
[*]Texture
[*]SpriteFrames
[*]Tileset
[*]Font
[*]Script
等资源,接下来我将对上述六种资源做一个简单的介绍,涉及到具体细节的,如其属性、方法等,我们会在相应的实践篇和实战篇中进行详解。
[*]Shape2D
Shape2D是Godot中存储形状的资源,可用于CollisionShape2D节点中来定义Area2D、PhysicsBody2D等节点的影响范围。其形状可以是经典的矩形、亦可以是椭圆、胶囊形、箭头形、线形等形状
[*]AudioStream
AudioStream是Godot中存储音频的资源,用于AudioStreamPlayer(2D/3D)中来将该AudioStream中的音频播放出来。AudioStream存储的音频文件可以是mp3、ogg、wav等音频格式。
[*]Texture
Texture是Godot中存储图片图形的资源,用于Sprite和资源SpriteFrames中来将该Texture所含图片图形呈现在程序中。其又派生出14种子类型的资源,由于数量庞杂,且多数不为本帖所常用,故这些子类型资源不再在此全部提出。
需要注意的是,Texture本身并不能被直接创建,但是.png、.jp(e)g等图片文件则是直接的Texture文件,可以被直接导入到Sprite等节点中
[*]SpriteFrames
SpriteFrames是Godot中存储动画图集的资源,用于AnimatedSprite节点中,并由后者将该SpriteFrames呈现在程序中并播放相关动画。SpriteFrames内部存有不同的动画图集,在使用AnimatedSprite的时候可通过脚本来调用并播放
[*]Tileset
Tileset是Godot中存储图块的资源,用于TileMap节点中,由后者将前者呈现在程序中。Tileset本身具有许多属性,这里不再进行讲解,在实践篇中我们将会详细讲述这些属性中的常用属性
[*]Font
Font是Godot中存储字体的一类资源,包括BitmapFont和DynamaticFont,用于具有文本显示功能的子节点,由后者将前者呈现在程序中。在后期学习draw_string()方法的时候,这个资源也十分重要。
[*]Script
Script是Godot中存储脚本的一类资源,包括GDScript、GDNative等。它们是Godot中极为重要的资源,没有脚本,整个软件就没有了动力。我们将会从下一节开始介绍脚本。
[*]“导入”选项卡设置导入的图片和音频
在第一节里面我们简单介绍了“导入”这个位于场景节点树面板右侧的选项面板,在文件系统管理器中选择一份资源后,这个选项卡就会由一行字变成一系列信息。
本帖中我们最常用到的是Texture和AudioStream这两个资源
https://i.imgtg.com/2022/10/28/RO4fB.png
我们在文件系统管理器中选中一个图片,便能让上图所示的导入选项卡显示出详细信息
其中我们重点介绍一下“过滤”,如果你不取消勾选这个选项的话,你的素材导入进去后所呈现出的样子将会是比较模糊的,去掉后即可显示这个图片原先的样子
https://i.imgtg.com/2022/10/28/ROH5s.png
上图为勾选了【过滤】选项的情况
https://i.imgtg.com/2022/10/28/ROLKK.png
上图是取消勾选【过滤】的情况
其他选项本帖中不常用,故不再进行讲解
接下来讲解音频的【导入】面板
由于Godot中导入不同格式的音频格式,其【导入】面板也不尽相同,这里就只展示.wav音频和.ogg/.mp3音频的【导入】面板
https://i.imgtg.com/2022/10/28/ROE0a.png
上图为.wav格式的音频的导入面板
https://i.imgtg.com/2022/10/28/ROGwS.png
上图是.ogg/.mp3格式的导入面板,需要注意的是:.ogg/.mp3格式的音频在导入面板中默认开启【循环】,故如果你导入的.ogg/.mp3音频是游戏音效的话,请务必取消勾选【循环】!
以上就是资源的简单介绍了,接下来我们来学习节点的属性
[*]什么是属性
在第二节我们学习节点的时候,我们就已经听到这样一个概念了:节点的属性。那么,到底什么是属性呢?
属性(Property),从编程语言的角度来说,就是对象的成员变量。不过这个概念我们会在下一节中讲到。我们本节先以直观的认知来认识属性。
我们就以节点为准,来讲解节点的属性
https://i.imgtg.com/2022/10/28/ROS1N.png
如上图,当我们选中一个节点后,右侧的检查器就会显示很多信息,这些信息就是我们选中的节点的属性,
我们主要看一下这个检查器内的内容
https://i.imgtg.com/2022/10/28/RO0DC.png
我们会很明显地看到,检查器左边有一些文字,这些文字就叫做属性名(Property name),将鼠标悬停在这些属性名上,你就可以获得这些属性对应的成员属性名(Property name of a member)。右侧的数字、选项等就是这些属性所对应的属性值(Value)。
一个属性由(成员)属性名和属性值两个部分组成,属性名用于区分、寻找、引用、解释说明属性值,而属性值则赋予属性名代码性内容,是决定属性的关键
因此,当你理解了属性之后,请回到第二节再次学习后半部分的内容,你会有一层更深的体会。
[*]如何修改属性?
由于Godot过于人性化的属性修改设计,因此我们可以选择自行摸索。不过,出于教程起见,这里我还是需要说明一些输入规则:
[*]看到带x,y,z的,你可以输入小数
[*]看到有数字的,你可以输入小数或整数
[*]看到有一串字,旁边有个箭头的,可以点开选择其他给定的字(值)
[*]看到有空白栏,一个字也没有的,可以输入任何文字
[*]看到有【空】字样的,可以将一个资源放在这里
[*]看到有Size字样的,点开可以看得更多
[*]……
Godot里直接修改属性的操作还有很多,这里就不再一一列举了,各位同学可以自行摸索学习。
至此,资源简介和属性就到此结束了 本帖最后由 电童·Isamo 于 2023-3-14 16:10 编辑
GDScript其一:GDScript入门从本节开始,我们就要学习整个Godot里最核心的内容——代码编程了。
由于Godot支持的脚本语言千千万,故我们本帖只以Godot原生的GDScript为范本进行学习与实操。
如果你对C#非常上手,那么你可以去1L的官网中下载并安装Mono版本,API可查看1L的官方doc。
回归正题,我们在开始学习写GDScript之前,我们先来讲解一些最基本的一些干货,以便于你对GDScript有一个更好的理解
[*]何为脚本
脚本(Script)一词,亦可称为“剧本”。这个词很形象,给一个对象写脚本,就好像给一个舞台剧写剧本,或者给一个演员写台本一样。因为你写的东西决定着整个舞台剧每个演员的动作、语言,所以剧本就起着规范、指导、驱动的作用。同样地,你写的脚本决定着一个对象的属性、这个对象的行为,驱动着这个对象。由于“脚”是人行走最基本的部位,因此形象地称之为“脚本”。
就像来自不同国家的编剧写不同语言的剧本一样,你使用的计算机语言不同,你的脚本也就不同,其文件后缀也就不同。
在Godot中,GDScript脚本以.gd结尾
[*]脚本在节点上
我们在学习场景节点的时候讲过他们的几个特征(权当复习一下):
[*]场景可以在另一个场景中实例化,作为另一个场景的子节点出现
[*]节点是整个程序里执行不同功能的最基本单位
[*]父节点的属性会影响其所有子节点的对应的属性,即其所有子节点会继承父节点的对应属性
但是,单独的一个简单节点,并不能起到多大的作用,甚至连最基本的位移,它都无法脱离脚本而自主实现。因此,我们在学习到【GDScript高级篇其十——Object】之前,我们暂时规定:所有的脚本均应应用于节点上(也就是下面会提到的挂载脚本到节点上)
只有有了执行代码的脚本的节点,才算是有灵魂的节点。因此:节点是脚本的躯壳,脚本则是节点的灵魂
那么,在上一节我们提到,Script是一种资源,于是,我们就可以将脚本以资源的形式载入到节点中。
接下来,我将会讲解如何新建一个脚本,以及如何将脚本加载到一个节点上
[*]脚本第一步:创建脚本
首先我们切换到脚本编辑器
https://i.imgtg.com/2022/10/28/ROj0p.png
之后我们就会来到一个类似于下图所示的页面,如果没有任何东西,没有关系,因为这个工程里没有任何你打开过的脚本。
https://i.imgtg.com/2022/10/28/RO81Y.png
接下来,我们找到文件——“新建脚本”并点击,如下图所示
https://i.imgtg.com/2022/10/28/ROuXq.png
之后会弹出如下页面:
https://i.imgtg.com/2022/10/28/ROXHv.png
其中第一项我们不要改,第二项暂时不用管他,我们下面会讲到,第三项我们也暂时不用管他,第四项亦不须理会。
我们直接看到路径,这个是你的脚本所保存的位置,点击右侧的文件夹形状的按钮,会弹出如下窗口:
https://i.imgtg.com/2022/10/28/RO3xc.png
选择好一个指定的路径后,只需要点击打开即可确定你新建的脚本所需要保存到的位置。
别忘了给你的脚本改个名称哦~
之后点击“创建”,就会弹出如下界面:
https://i.imgtg.com/2022/10/28/ROCJr.png
那么恭喜你,你已经成功学会了如何创建一个新的脚本。
接下来,我们将学习如何将一个脚本加载到一个节点上(或者说,将脚本挂载给一个节点,让这个节点挂载这个脚本)
[*]脚本第二步:挂载脚本
方法一:
https://i.imgtg.com/2022/10/28/ROwlM.png
选中目标节点,在其检查器内找到最底部的Script,点击空,将会弹出如下窗口:
https://i.imgtg.com/2022/10/28/RaOBG.png
当然,你可以从这里新建脚本,之后的操作还请见“脚本第一步:创建脚本”内的相关内容
下面我们有两个选项
点击“快速加载后”,会弹出如下窗口:
https://i.imgtg.com/2022/10/28/RaaZ1.png
找到你新创建的那个脚本双击即可
方法二:
https://i.imgtg.com/2022/10/28/Rao0I.png
仍然是这个窗口,找到加载,会弹出一个窗口,在窗口内找到你新创建的那个脚本文件双击即可。
方法三:
https://i.imgtg.com/2022/10/28/Ra5MD.png
在文件系统管理器内直接将新创建的脚本文件拖拽到目标节点上即可
方法四:
https://i.imgtg.com/2022/10/28/RaB7F.png
直接在脚本编辑器里左边的脚本筛选框中找到你新创建的脚本,并拖拽到目标节点上即可
我们成功将脚本挂载到了节点上,在需要保存脚本的时候,我们按下Ctrl Alt S三个键即可。
需要注意的是:
[*]每个节点只能挂载一个脚本,所以,如果你想尝试在一个脚本上挂载两个及以上的脚本,这是完全不可能的。请考虑在其下方添加子节点,并让其子节点来代行挂载这些脚本以实现一个节点挂载两三个脚本的效果
[*]每个脚本在修改后不能立即生效,需要保存后才能让新保存的脚本生效
同时,下图中的“继承”选项中的节点类型一定要与你挂载的目标节点的类型相一致,或目标节点的类型为“继承”中的类型的派生类型才可以让脚本安全地挂载在目标节点上(原因我们将会在后面某一节中进行讲解)
https://i.imgtg.com/2022/10/28/RaTH6.png
好了,基本的操作我们就讲到这里,接下来我们就正式开始讲解GDScript的入门编写了
[*]GDScript 基础——extends限制脚本所需要挂载的节点
下面是两个简单的GDScript代码的范例:
extends Node
const T = 1
var a = 10
var b = "what"
var c = null
func foo() -> void:
pass
func foo2() -> void:
c = Node.new()extends Node2D
func _draw() -> void:
draw_circle(Vector2.ZERO,3,Color.webgray)
func _process(delta: float) -> void:
update()我们可以发现,这两行代码都有一个共同的特点:它们均以extends开头,在脚本编辑器中,它显示为红色。这些标红色的英文单词就是GDScript的关键字(Key words)
阅读提示:本帖中,所有关键字均以加粗标红的Verdana字体显示,以匹配编辑器内的代码文字显示,同时方便阅读。
我们接下来来学习这个放在脚本开头的extends
extends,为继承关键字,位于脚本开头时,表示这个脚本:
[*]继承自一个类或一个脚本
[*]继承自的类或脚本决定了这个脚本可以适用于哪个或哪些节点
extends的格式如下:
extends <类或一个脚本的路径>其中extends后面所跟的类就决定了这个脚本可以挂载到哪个类型的脚本上,至于什么是类,以及为什么还可以跟脚本路径,我们会在后面讲到函数与类的时候会讲到,同时将会把extends的更一般的用法给一并讲解。这里就暂时理解为:
extends <节点类型名>部分节点类型名可以回到第二节“节点、场景和场景实例化”一讲中进行查阅
如:
extends Node上面的一行代码就表示该脚本只能挂载到类型为Node的节点及其派生类节点上,由于所有节点都派生自Node,故该脚本可以挂载到任何一个节点上
extends Node2D上面的一行代码表示该脚本只能挂载到类型为Node2D的节点及其派生类节点上,由于Sprite、KinematicBody2D也是派生自Node2D的,因此这个脚本也可以挂载到Sprite节点和KinematicBody2D节点上
extends Sprite上面一行代码表示该脚本之只能挂载到类型为Sprite的节点及其派生类节点上,由于Sprite没有原生派生节点,因此在不声明具名类并继承Sprite类(后面会讲到)的情况下,该脚本只能挂载在Sprite脚本上
在讲到【函数与类】这一讲之前,我们需要注意:
[*]extends只能位于脚本开头(但也可以位于脚本其他地方,不过有关于这一情况的我们到后面会进行讲解)
[*]extends只能存在一个,倘若出现了两个及以上的 extends,则会导致脚本编辑器报错
extends Node
extends Node2D
func _do_something() -> void:
pass上述代码就会导致脚本编辑器报错,原因是 extends 出现了两次
以上就是关于 extends 这个关键字最基本的使用方法
[*]GDScript 基础——声明量
编写代码,最基础最基础的莫过于声明量。
量(Values)是代码中存储数据信息和处理运算的最小单元,它可以参与一段代码的运算和读写。一旦失去了量这个概念,那么这个脚本的存在意义也就基本上尽失了。
所以,不管是学习哪个计算机语言,包括我们这个GDScript,声明量是我们最基本也是最基础的编写操作和环节
在GDScript非高级篇里,我不会深入地涉及到计算机原理等相关知识,各位同学可以放心,我们只需暂时记住:声明变量是为了参与代码的运算就可以了。
代码中,任何一个代数运算的前提,就是要先声明相对应的变量。
[*]标识符——命名量的基础
在学习如何声明量之前,我们需要先学习GDScript中的标识符
所谓标识符(Identifier),就是声明变量、函数、信号这些量时这写变量和函数的名字。
标识符以纯拉丁字母a~z、A~Z、数字0~9和下划线"_"为合法字符,除此之外输入的标识符均为非法。
以数字开头的标识符也是非法的标识符,且foo和FOO、FoO、FOo、fOO、foO、Foo等均不属于同一个标识符。
标识符本身就是一个特殊的字符串,只是在声明变量、函数、信号等定义量的时候,你无需也不应输入""(后面会讲到""表示字符串)
下面列举一些合法和非法的标识符,供各位同学学习参考
[*]instance_maker(合法)
[*]abilityCreator(合法)
[*]_you_and_Me(合法)
[*]up2three4U(合法)
[*]2UareMine(非法,以数字开头)
[*]^ssas(非法,含未规定为标识符的字符)
[*]$lllk(非法,含不合规的符号)
注意:一些在GDScript中也合法的符号,如@、$、#等,在作为标识符时不应算作合法字符!
[*]数据类型——一切数据的基础
我们经常会用到很多很多种类的数据,比如整数、小数、真值、一串字符……像这些,将一系列同类数据整合在一起的集合,我们就称之为一个数据类型(Data type,简写为Type)在我们后面要学习声明数值量之前,我们需要学习这些数据类型
由于Godot中数据类型繁多,我们只选择2D游戏开发中比较常用的几个类型进行说明:
基本数据类型
[*]int:Integer的缩写,叫做整数型,简称整型,这种类型表示的是不带小数点的整数,不论正负,如-4, 0, 7, 11, 129等
[*]float:叫做浮点数型,简称浮点型,表示带小数点的数,不论正负如0.3, -3.1415926, 0.7893, 1.414514等
[*]bool:Boolean的缩写,叫做布尔型或真值型,简称布尔或真值,只有两个数:true(代表1)和false(代表0)
[*]String:叫做字符串类型,简称字符串,用英文双引号""括住一串字符即为一个字符串,内部可以有空格及其他特殊字符,如"my family"、"同学"、"大好きだ"等
[*]null:叫做空类型,简称空。它虽然是一个数据类型,也是一个特殊的数值,但它不包含任何信息,不能赋值为任何其他值
内置数学类型
[*]Vector2:二维向量,它包含x和y两个基本元素,且均为float型元素
[*]Vector3:三维向量,它包含x、y和z三个基本元素,且均为float型元素。虽然是主要用于三维要素的向量,但在2D粒子生成器里有该类型的数据,请注意!
[*]Rect2:二维矩形,它包含x、y、w、h四个基本元素,且均为float型元素
[*]Transform2D:二维线性变换矩阵,它包含x、y和origin三个基本元素,且均为Vector2型元素
课程导引:关于Vector2和Transform2D更为详细讲解,可参考本篇教程《矩阵与坐标变换》一章
引擎内置类型
[*]Color:颜色,可看作一个四维向量(Vector4),它包含r、g、b和a四个基本元素,且均为float型元素
[*]NodePath:节点路径,它以@"xxx"格式的数据(后面会讲)或String类型为其基本元素类型
[*]Object:对象,是所有Godot非内置类型的基类
容器内置类型
[*]Array:数组,以[]为数组的直接定义,可容纳任何类型的数据
[*]Dictionary:字典,以{}为字典的直接定义,可容纳任何类型的数据,且其内部的数据以键-值的形式相匹配
此外,所有的Node也都属于数据类型,但不是内置类型
[*]量的声明
GDScript中有四种量:
[*]常量
[*]变量
[*]枚举
[*]定义量
我们逐一讲解这些量
[*]常量(Constant):在脚本中始终不变的量,它不受其它因素的影响,永远都是一个固定的数值。常量是脚本中应用较为广泛的一类量。
[*]变量(Variable):在脚本中可能会改变的量,它可能会受到其他因素的影响,其数值是不固定的。变量是脚本中应用最为广泛的一类量。
[*]枚举(Enum):本质上是一系列常量的集合,在GDScript中则只限定于一系列整型常量的集合。与常量一样,枚举也是一类应用较为的量。
[*]定义量(Definition):这种量只用于定义一个特定的信息,并将这个信息赋予一定的属性,如函数、信号、具名类和内部类等等。定义量也是脚本中应用最为广泛的一类量。
我们把常量和变量合称为数值量(Numeric value)。上述这些量在声明时均需使用标识符命名。
我们接下来讲学习如何声明这四种量
[*]常量的声明
常量用const关键字来声明,并规定要用全大写字母来书写常量名(如果遇到多个单词,用下划线"_"隔开)。格式如下:
const NAME = <值>其中NAME就是你键入的全大写的常量名,一个等号表示给这个非定义量赋予一个初始值(Initial value),等号后面的<值>可以替换成其他任何一个在Godot所给的数据类型的数值
如下面这个常量:
const ABC = 12表示声明了一个常量ABC,其值为12
不过,这也只是最基本的例子,根据前面学过的的数据类型,我也可以这么写
const DEF = 12.76
const STRING = "I love you"下面这种情况也是允许的
const D = 32
const E = D
const F = E + 7只不过,这里常量E的值变成了常量D的值。像这样,将一个数值量的标识符直接作为值赋给另一个值,这样的操作我们叫做对数值量D的引用(Reference)。上述片段中,常量E引用了常量D的值,此时常量D的值也就赋给了常量E,为32,而常量F的值则是先引用了常量E的值再+7,其结果就是:D = 32
E = 32
F = 39但是以下这种情况,脚本编辑器就会报错:
const E = D
const D = 32
const F = E + 7这是为什么呢?这里,我们先要学习一个概念:代码的执行顺序。
不管是哪个编程语言,计算机执行代码的顺序默认都是从上往下,即从第一行往最后一行执行的。GDScript亦是如此,从脚本最开头的那一行代码开始执行脚本内的所有代码,一直执行到该脚本最后一行代码且执行完成后才算结束。在后面我们讲到函数的调用的时候,这个结论依然成立。
声明常量也遵循从上往下声明与定义的原则。那,啥叫常量的定义?在上面这段错误代码中,我们先声明了E变量,给它以D变量的值,计算机执行到常量E这里,就叫做常量E被定义(Defined)了。但是在计算机执行这个代码的时候,它知道E变量是谁,但不知道D变量是谁,因为按照计算机从上往下定义变量的原则,D还没有被定义,所以这样子写,计算机因无法找到常量D的定义而导致报错。所以,在引用一个数值量之前,必须先声明那个要被引用的数值量,然后才能在别处用正确的方式引用这个数值量。我们在声明常量的时候,只需要常量的值在Godot所给的数据类型里存在就行了。但有些情况下,一些工程是靠团队开发的,每个成员之间可能并不熟悉你写的代码。假如真有这么一个成员,看到你声明了一个常量A,其值为32,然后这个成员就把A的初始值改成了"pee",你并不知道,然后你第二天如期地运行一下你写的程序,发现出现了bug或者闪退报错了。这可怎么办?
因此,我们需要给声明的数值量加以限定,以防止发生这类情况。格式如下所示:
const NAME:<数据类型> = <对应数据类型下的值>在变量名后面加:<数据类型>就可以让一个指定的数值量的值限定在给定的数据类型范围内,如下所示:const A:int = 3
const B:float = 5.0上面这段代码的声明是没有问题的,但下面这段就会导致编辑器报错:
const TEXT:String = 114514原因在于114514是int型的变量,而TEXT要求给的值一定要是String类型的,这就导致类型不匹配而导致报错。
解决方法就是把int型的114514变成String类型的"114514"就行了(注意字符串的双引号一定要括到这个字符串所要包含的内容才行!)当然,如果你不知道你输入的数值是属于什么类型的话,可以这样写:const NAME: = <值>这种语法我们就叫做推断类型(Type inferrence)计算机会根据你后面的数值来自动将变量定义为这个数值所在的数据类型const A: = 1
const B: = "myself"上述代码中常量A会被计算机自动定义为int,常量B则会被自动定义为String
[*]变量的声明
在我们学会了声明常量以后,我们接下来就要学习如何声明变量了
变量的声明类似于常量,采用var关键字来声明变量,并规定采用下划线+全小写(即snake_case,蛇形命名法)标识符来命名变量:
var name = <值>其中name为你的变量名,<值>可以取在Godot数据类型内的任何值
下面是几个声明变量的例子:
var a = 14
var b = 4.12
var c = "MysSQL"
跟常量一样,变量也支持量的引用:
const TEST = 1
var is_reference = TEST
var inheriter = is_reference
这时,变量 is_reference 就引用了常量 TEST 的值,为1;而下面的变量inheriter也因为引用了is_reference的值而变成1
跟常量一样,变量也支持数据类型的限定语法:
var name:<数据类型> = <对应数据类型下的值>接下来的例子将展示何为变量的数据类型限定:
var a:int = 14
var b:float = 20
var c:String = "TEST"
var d:Object = Object.new()
可以看到,变量d是一个新建立的节点(新建语法我们后面会讲到),这时这个变量就不再是一个数值了,而是代表着一个对象(Object)(实际上节点也是一种对象,这个我们到后面会讲到)。任何一个变量,如果其值是一个对象,则我们称该变量为这个对象的引用变量(Referencial variable),简称引用(Reference)。
课程导引:
关于引用的详细说明,我们在《函数与类》这一节中将会进一步学习
注意1:声明变量时,如果变量的限定类型为float而右侧为整数,则右侧的整数可以不加.0,但是对于const和我们下节课会学的export var,则必须要加上“.0”
注意2:声明变量时,虽然计算机依然遵守从上往下定义每个变量这一规则,但是由于变量的一些特殊性,使得计算机在发现一个变量引用了一个尚未定义的变量后,会尝试在所有变量中寻找这个未定义的变量,若找到了这个未定义变量的声明,则将该未定义的变量提前定义并将引用指向这个变量;只有在计算机翻遍了所有已声明的变量后,都没有找到这个变量的声明,此时编辑器才会报错
同常量一样,变量也支持推断类型语法:
var name: = <值>
必须注意:
推断类型语法和不带":"而直接赋值的动态类型(Dynamic type)语法不一样的地方在于:前者会在计算机定义数值量时给这个数值量定义一个类型,这个变量的类型在运行时不能被修改,否则会报错;而后者的类型则是不固定的,意味着计算机在定义这个数值量的时候,不会给它一个给定的类型。
如果该数值量是个变量,则后面的代码可以使该变量的值根据实际情况发生类型自动转换(Type Automatical Conversation,简称TAC):
extends Node
var phase: float = 10.0
func _ready() -> void:
phase += 1这里涉及到了调用变量和函数的操作,我们下节课会学到
有关赋值运算符“+=”,会在本节课后面讲到,这里先说明一下:
a += b -> a = a+ b
a /= b -> a = a / b
上述代码中,我声明了一个phase变量,在函数中进行调用并运算,很显然,我让phase这个变量加上1,但注意:这里的1明显是int类型的,而phase是float类型的,但结果是正确的,因为这个时候phase被强制转换成了int进行运算。假如我换成下面这个片段:
extends Node
var phase: float = 10.0
func _ready() -> void:
phase /= 310/3我们都知道是3.333333333333...,对吧?然而,上述代码中这个算式的结果居然是3!这是因为在GDScript中,只有两个float相除才可以把完整的结果算出来,如果被除数或者除数有一个是int,则结果就是去掉小数的整数了。所以,如果想要把3.33333333打出来,我们需要把3改为3.0才可以。
关于这点,我们会在后面学习这类赋值运算符时会再次强调
当然,声明变量还可以使用隐式声明法(Default-value-omitted Claiming):
var name这个时候,如果不规定其数据类型的话,它就等同于:
var name = null如果隐式声明这个变量时限定了其数据类型,则它就等同于:
var name:<数据类型> = <该数据类型的默认值>这些数据类型的默认值如下:
[*]int、float:默认值为0(0.0)
[*]String:默认值为""
[*]Vector2、Vector3:默认值为Vector2.ZERO(Vector3.ZERO)
[*]Rect2:默认值为Rect2()
[*]Transform2D:默认值为Transform2D()
[*]Object(包括Node及其派生类型):默认值为null
[*]Color:默认值为Color.white
[*]NodePath:默认值为@""
[*]Array:默认值为[]
[*]Dictionary:默认值为{}
注意:隐式声明语法只能用于变量,常量则必须赋值
[*]枚举(常量)的声明
我们已经介绍了数值量(即常量和变量)的声明,接下来,我们将学习枚举(常量)的声明与引用
枚举常量用enum关键字来声明,其格式如下:
enum EnumName {
VALUE_1,
VALUE_2,
...
VALUE_N
}其中EnumName为你枚举集合的名称,采用大驼峰命名法(也叫帕斯卡命名法,PascalCase)来命名枚举,枚举内容用一对花括号{}表示,里面的VALUE_1一直到VALUE_N都是该枚举的内容常量,且这些内容常量只能是整型常量,不能使用数据类型限定语法。
枚举内默认将第一个常量的值赋为0,第二个为1,以此类推。
下面是一个声明枚举的例子:
enum TestEnum {
VALUE1,
VALUE2,
VALUE3,
VALUE4
}上面的代码片段中声明了一个名为TestEnum的枚举,里面包括VALUE1~VALUE4四个变量,且其内部的四个常量的值分别为:VALUE1 = 0, VALUE2 = 1, VALUE3 = 2, VALUE4 = 3。
枚举的引用不同于数值量,它需要利用以下格式来进行引用:
enum EnumA {
VALUE1,
VALUE2,
}
const VALUE = EnumA.VALUE2
var some_value:int = EnumA.VALUE1
其中some_value就是需要引用枚举的变量,EnumA就是被引用的枚举,结合我们马上会讲到的运算符"."来获取其内容,在英文句点后面键入你需要引用的具体的一个内容常量名即可。同时,常量 VALUE 引用了枚举EnumA的VALUE2这个内容常量,这也是允许的,因为枚举里的内容常量也是可以被引用的数值量。
除此之外,枚举的内容常量可以被赋予初始值:
enum TestEnum {
VALUE1 = 12,
VALUE2 = 24,
VALUE3 = -8,
VALUE4 = 8
}只要保证这些内容常量的数据类型均是整型(int)即可
有关定义量的声明,如函数、信号、类 等,我们会在其对应的章节中进行学习
现在,布置一个作业:
[*]声明一个变量,名称为test1,赋值为12
[*]声明一个变量,名称为queue_checker,赋值为null
[*]声明一个常量,名称为PASCA,赋值为字符串Pasca
[*]声明一个变量,名称为que,限定类型为字符串,引用常量PASCA的值
[*]声明一个枚举,名称为FuturePlan,内容有四个常量:MY_LIFE, MY_COLLEGE, MY_BAG, MY_GROUP
[*]声明一个枚举,名称为ExamScores,内容有三个常量且具有初始值:STUDENT_A = 10, STUDENT_B = 9, STUDENT_C = 9
[*]声明一个变量,名称为enum_me,引用上一个枚举ExamScores中STUDENT_B的数值
[*]注释
注释(Comment)是GDScript中非常有用的辅助语法,注释可用于说明解释代码的作用,同时也可以当作一个你写小说随笔的好语法(才不是呢!!【恼】)
在计算机执行脚本时,注释会被计算机直接忽略
注释以井号#开头,#后面的一整行内容均会被视为注释
# 这是一个注释后面的代码学习中,本帖都将会以注释的方式来解释一些代码中的问题。
操作提示:按键盘上的Ctrl和K可以将让代码和注释互换
[*]GDScript 基础——运算符
我们已经学了如何声明量(定义量除外),现在我们还需要学习运算符号才可以让我们的代码真正发挥出运算的功能。
接下来依次介绍GDScript中的运算符
一类运算符,运算结果为int或float:
[*]+:加法
[*]-:减法
[*]*:乘法
[*]/:除法
[*]%:先做除法,然后用得到的商求余(只适用于int类型,如果需要float类型的取余请使用fmod(a,b))
[*]-a:取a的相反数:a为正数时-a为负数,a为负数时-a为正数,a为0时则-a仍为0
[*]+=:a += b 等价于 a = a + b
[*]-=:a -= b 等价于 a = a - b
[*]*=:a *= b 等价于 a = a * b
[*]/=:a /= b 等价于 a = a / b
[*]%=:a %= b 等价于 a = a % b
注意:如果是int除以int,若其结果中存在小数,则只保留结果中的整数部分(即去掉小数点后面的所有数字),对于这种情况,前文有解决方法,可倒回去学习。
二类运算符,运算结果为bool
注意:true => 1, false => 0
[*]>:大于
[*]<:小于
[*]>=:大于或等于
[*]<=:小于或等于
[*]==:等于
[*]!=:不等于
[*]&&、and:与。两个bool均为true时,结果为true;只要有一个false,则结果为false
[*]||、or:或。两个bool只要有一个为true,则结果为true;若均为false,则结果为false
[*]!、not:非。一个bool如果为true,则结果为false;如果为false,则结果为true
注意:单个等号“=”是赋值号,用于声明数值量时将其右侧的值或引用的内容赋给左边;而双等号“==”则是比较两个值的相等关系
三类运算符,运算结果是二进制数
注意:1 => true, 0 => false,下列运算均是对一串二进制数中每个0和1进行运算
[*]&:位与运算,0 & 1 = 0,1 & 1 = 1,0 & 0 = 0
[*]|:位或运算,0 | 1 = 1, 1 | 1= 1,0 | 0 = 0
[*]~:位非运算,~1 = 0,~0 = 1
[*]<<:位左移运算,二进制数中所有0和1向左移动一定数目的位,如 a << b就是让a中的所有0和1均向左移动b个位。移位后,新的二进制数的总位数必须与原来的二进制数的总位数保持一致。故高位算元(算元指0和1)需要舍弃,低位算元均需补为0。
[*]>>:位右移运算,二进制数中所有0和1向右移动一定数目的位,如 c >> d 就是让c中的所有0和1均向右移动d个位。移位后,新的二进制数的总位数必须与原来的二进制数的总位数保持一致。对无符号数,高位算元补0。
[*]<<=:a <<= b 相当于 a = a << b
[*]>>=:a >>= b 相当于 a = a >> b
四类运算符,结果视情况而定
[*]a if b else c:三目运算符,其中b的结果必须为bool,如果b为true,则整体结果为a,否则整体结果为c
[*]缩进
缩进(Indentation)是表示代码块和作用域的重要标识,通过按下键盘上的Tab键来创建一个缩进。它的长度默认相当于四个空格,且会在一行的左侧用“>|”表示一层缩进。
如果没有缩进,则其缩进层数为0
具有相同缩进层数的代码属于同一作用域:
func _ready() -> void:
# 作用域1
if phase > 0:
# 作用域2
print("me")
if phase > 5:
#作用域3
# 作用域2
# 作用域1
注意:以下情况会报错
[*]在空格后使用缩进
[*]在缩进后使用空格
[*]把四个空格当缩进用
虽然把缩进当空格用,在Godot里并不会报错,但编程上极其不建议这样写,除非你没事找事
[*]括号
GDScript中,括号(Brackets)是表示域或运算优先级的成对符号,在GDScript中,一共有三种括号:
[*]圆括号(),定义运算域
[*]方括号[],定义数组
[*]花括号{},定义枚举与字典
GDScript种的括号具有一个特点:括号内的内容允许跨行,但需要在保证语法完整的前提下才可以换行,此时括号内的多行代码仍然视作一行代码
如果括号内的内容多行显示,建议使用缩进来表示括号的范围,具体请见下面的例子
下面的代码以圆括号为例,剩余两种括号同理
var a = (
3 + 5 + (
8 - 2 * 3
)
)
# 等同于
var a = (3 + 5 + (8 - 2 * 3))GDScript中,圆括号可以定义运算域(Calculation Field)
var b = (2 + 4) * 6# 结果为36不管是计算机学上还是数学上,如果出现运算域,都是先计算运算域内的结果,再将运算域内的结果与其他数或运算域进行计算
如果出现了运算域嵌套,则先计算最内层的运算域,然后计算外层的运算域,由内向外依次运算,直至运算完最外层运算域为止。
var d = ((2 + 4) * 6) / 3# 先计算2+4=6,再计算6*6=36,最后计算36/3=12,故结果为12如果你不知道各个运算符的优先级,或者你把握不住这些运算的优先级,亦或是你想强调运算的整体性以防止歧义,都可以使用圆括号来表示。
[*]实践
接下来,我们来写一个实践作业:
[*]制作一个脚本,该脚本可以挂载在Node2D及其派生类型的节点上,需要准备一个名为CollisionType的枚举,内含WALL,CEILING和FLOOR这三个内容常量,不加默认值。准备三个常量:A、B、C,其中A为int类型,值为5;B引用A的值并加上3,C引用B的值并除以2。
[*]在上述脚本中,再声明三个变量:a、b、c:其中a引用常量B并除以2,b引用变量a并除以8,c引用常量C并除4求余
我们来逐一分析一下这个问题:
首先,脚本需要挂载到Node2D上,那么我们就需要在脚本开头写上(或者将开头一行修改为):
extends Node2D这样,脚本就可以挂载到Node2D及其派生类型的节点上了。
然后,需要声明一个名为CollisionType的枚举,里面有三个内容常量。那么我们就很容易写出来这个枚举:
enum CollisionType {
WALL,
CEILING,
FLOOR
}接下来,我们要声明三个常量并赋予初始值,根据题意,我们有:
const A:int = 5
const B = A + 3
const C = B / 2
接下来,我们需要声明三个变量并赋予初始值,根据题意,我们有:
var a = B / 2
var b = a / 8
var c = C % 4那么最终的脚本就是:
extends Node2D
enum CollisionType {
WALL,
CEILING,
FLOOR
}
const A:int = 5
const B = A + 3
const C = B / 2
var a = B / 2
var b = a / 8
var c = C % 4
补充
[*]当一个数值量引用另一个数值量时,改变这个引用者数值量的数值后,并不会影响被引用的数值量的数值;反之,如果被引用的数值量发生改变,则引用该数值量的数值也将发生改变
[*]声明常量时不能引用变量来进行赋值,但在声明变量时,可以引用变量或常量来进行赋值
以上就是本节的全部内容了,下一节我们将会讲解变量的导出以及其它的关键字
本帖最后由 dasasdhba 于 2022-9-22 00:16 编辑
部分比喻过于奇妙反而更难理解,读者遇到这种情况可自行略过,无需深究。
此外,学习游戏引擎而言,理论只是铺垫工作,实践才是最有效的学习方式,切忌“学习”大量纯理论而不实践。
目前教程的内容在一些细节操作讲解方面相比官方文档比较详细,初学者可以参考以快速熟悉软件界面。
顺便,关于本帖部分未来计划更新内容,至少在 MF 开发中用的极少甚至根本不需要。其实泛泛而谈,我们学习任何一个软件,往往只是因为其中有一部分功能是我们想要的,软件有什么就学什么并不明智,我们只需要关注自己想要的东西。
本帖最后由 电童·Isamo 于 2022-11-23 20:49 编辑
GDScript其二:GDScript的导出变量、一些常用关键字我们已经学习了如何声明数值量,为我们的代码编写打下了坚实的基础。
然而,我们声明的数值量,如果不加以处理,也仅仅是保存在脚本内部且写死的。
有些时候,我们希望这些带着脚本的节点的对应变量能(在一定范围内)有不同的初始值,而且方便我们这些开发者能够不需要深入这些节点的脚本,就可以直接修改这个节点的参数(Parameter)。这时,我们就需要一个全新的概念:导出(Export)
不过在讲GDScript的导出之前,我需要对我们第三节学过的概念——属性(Property)进行一个新的讲解。
[*]属性和变量
我们在第三节就已经初步认识了属性,只不过那个时候只是以检查器这一直观的视角来定义所谓的属性。
实际上,在Godot中,一个不加脚本的节点的属性,等同于这个节点的内部脚本(Native Script,其实就是这个节点的C++源码【Source Code】)中所声明的数值量
(由于还没有学到Object,因此在学到高级篇【Object】之前,我们只考虑节点这一情况)我们还以第三节讲到的Node节点为例
https://i.imgtg.com/2022/10/28/Rabzb.png
上图中,我将鼠标悬停在了Node的Editor Description上,就会显示出该节点的属性名:editor_description。实际上,这就是Node在源码中声明的一个变量
同时我们可以在上面很容易看到清晰明了的“属性”,二字。难道说,editor_description真的是个变量吗?
下面是该属性从C++源代码翻译成GDScript后的的代码:
export(String, MULTILINE) var editor_hint = ""可以明显地看到有var关键字,这就是我们上一节学到的声明变量的关键字。
而窗口中“属性”二字的出现,也坐实了一个事实:一个节点的属性,就是一个节点的脚本(或其C++源代码)的变量(数值量)
(注:这里我没有说GDScript脚本,是因为“变量”这个概念是通用的,你不管使用GDScript也好,还是C#也好,编程语言中的变量(严格地说应该是成员变量),在面向对象时就是所对应对象的属性。同时,我在“变量”二字后面补充了个“数值量”,是因为常量其实也是一个对象的属性,用目前所学过的知识来说,就是:常量和变量都是一个脚本所挂载到的节点的属性)
注意:这里我说了“一个节点”,就意味着,同一个脚本,同一个属性,除非特殊处理,否则在每个节点中这些相同的属性的值都是独立且互不干扰的。我们假设有a、 b、c三个节点,挂载的都是E这一个脚本,E脚本里有一个名叫abcd的变量的声明,初始值为1。当你修改了a节点的abcd这个属性的时候(比如改为15),b、c两个节点的abcd的值是不会发生改变的。
同时,若无特殊说明,本帖今后所说的【一个节点的属性】,就代指这个节点所挂载的脚本内声明的变量(数值量),或其C++源代码中已经声明的变量,或其在检查器中可以被直接修改的变量
[*]变量的导出
既然我们已经理清了变量(数值量)和属性的关系,那么我们接下来就要尝试解决这样一个问题:
我写了一个名叫enemy_movement.gd的脚本,制作了三个对象场景(在高级篇【Object】这一讲之前我们统一简称“对象”):板栗仔、乌龟、紫乌龟。本质上,它们都是敌人,都应该挂载这个脚本。在脚本里我声明了这样一个变量:
var speed:float = 1.0那么这些敌人的速度就是1.0了。
然而,紫乌龟的速度我们都知道是2.0而非1.0,如果我把上面的值改为2.0的话,不考虑给每个敌人单独写一份脚本,那么这些敌人的速度就全都是2.0了。
试想一下,假如一共有128种这样的敌人,如果每个敌人的速度都不一样,那么我就需要写128份不同的脚本,每一份脚本都要声明这个变量,还要一个个改初始值,想想都麻烦。
那么,有没有一种方法,既不需要给每个敌人单独写脚本,又可以给每个敌人不同的速度呢?
这个时候,我们就需要借助一个叫做导出变量(Exported value)的手段了:
导出变量使用关键字export,这个关键字只能用于变量声明关键字var前,用在其他关键词前,或者直接使用,都会导致编辑器报错。
导出变量的格式是(以下三种的任意一种均可):
export var name:<内置数据类型(可选)> = <数值>
export(<内置数据类型>) var name: = <数值>
export(<内置数据类型>) var name:<内置数据类型> = <数值>
其中name为导出变量(属性)的名字,<内置数据类型>只能是上一节所提到的内置数据类型(不包括Object、Node及其派生类型)
注意:第三个导出变量的格式的两个<内置数据类型>必须是同一个内置数据类型
下面的导出变量是合法的:
export var test1:String = ""
export(int) var test2 = 5
export(NodePath) var path:NodePath = @""而下面的几个则是非法导出:
export var node:Node = null# 报错原因:给定的数据类型不是内置数据类型
export(int) var text:String = ""# 报错原因:前后给定的内置数据类型不一致
导出了一个变量之后,我们选中这个脚本所挂载到的节点,在其检查器栏最上方即可看到这个变量的操作栏。
以下图为例:
https://i.imgtg.com/2022/10/28/RakJl.png
可以看到,声明了一个导出变量tt后,点击该脚本所挂载到的节点A时,A的属性显示在了右侧的检查器中,其中最上面的Tt就是这个导出变量tt
我们修改这一数值
https://i.imgtg.com/2022/10/28/Raxlg.png
这时后我们会在Tt这个编辑栏左侧看到一个旋转的箭头,点击它即可恢复到初始值1
我们刚刚提到:不同节点挂载了同一脚本,其属性是互相独立不共通的。这一点同样适用于导出变量。
我们还以那三个敌人和它们所挂载的脚本为例
如果我们把speed改为导出变量,那么这个时候,检查器里就出现了Speed这一属性。
我们只修改紫乌龟的Speed,将其改为2,那么结果如下图:
https://i.imgtg.com/2022/10/28/RaFTB.png
https://i.imgtg.com/2022/10/28/RahZs.png
https://i.imgtg.com/2022/10/28/RaicK.png
可以发现,除了紫乌龟的Speed是2以外,其他剩下的两个敌人的Speed属性的值均为1。
导出的变量,在检查器内修改其值以后,在程序运行时以修改过的值为准。
这样一来,我们就可以用最省事的方式来“定制”我们的敌人了。
善用导出变量,有以下好处:
[*]降低了代码的工作量,只需要一个脚本,几个导出变量,就可以制作出具有不同属性的节点
[*]降低了代码的耦合度,提升了代码的灵活性。只需要修改导出属性,就能创造出无限的可能性
[*]导出变量的具体类型
前面我们提到过:导出变量可以限制一些数据类型。下面我们以一段代码来介绍导出变量可以限制的类型
export var integer:int = 1
export var float_num:float = 0.8
export var string:String = "hi, world!"
export var velocity:Vector2 = Vector2.ZERO
export var cube_size:Vector3 = Vector3.ZERO
export var rect:Rect2 = Rect2()
export var transformer:Transform2D = Transform2D(0,Vector2.ZERO)
export var color:Color = Color.blue
export var nodepath:NodePath = @"."
export var array:Array = []
export var dictionary:Dictionary = {}
export var any_resource:Resource = null# Resource也可以被替换为其派生出的子类型(注:此外,还有一些内置数据类型是可以被导出的,学有余力的同学可以点击这里前往官方doc查看详细内容)
至此,我们就已经学习完导出变量的基本操作了,有关其更加高级的操作,我们会在后面的高级篇的【高级导出】中进一步学习导出变量
[*]一些常用关键字
我们在前面已经接触了extends、const、var、enum、export这五个关键字。实际上,Godot还有许多其他用途丰富的关键字,这里我们就挑几个最常用到的来进行说明。剩下的关键字,我们会在其专门的一节中进行介绍
在学习这些关键字前,我们需要先介绍一个很重要的概念:作用域(Scope)
[*]作用域
在我们执行一段代码的时候,我们不可能说会让这些代码全部从上到下,从头到尾执行一遍,我们需要给这段代码加一个限定范围,让这段代码只在这一个范围内执行,这个范围就叫做这段代码的作用域(Scope)。
GDScript不像其他编程语言(如C++、C#、Java等)那样带上花括号{}来表示作用域,它是以冒号加Tab缩进(按下Tab所形成的长空格,在左侧会以“>|”的形式出现)的形式来表示一个作用域。其中Tab缩进(若无特殊说明,本帖中的缩进均指Tab缩进)的次数(也就是 >| 的个数)表示这个作用域的层级
下面以一段伪代码来展示一个作用域:
func _demo() -> void:
作用域1
作用域1
作用域1
if a:
作用域2
if b:
作用域3
作用域2
作用域2
作用域1
作用域1由于本贴中代码块不支持Tab缩进,因此请在复制完本帖中的代码后自行将开头的空格改为Tab缩进!
接下来,我们正式开始学习这些关键字。但有一个前提:接下来的所有关键字全部需要写在函数中,如果写在函数外,则会导致编辑器报错。
这里先给同学们呈现上一个函数,以方便后面的学习:
func _ready() -> void:
<codes>
同学们将<codes>替换成你要学习的代码片段即可
[*]条件关键字
我们在学习上一节的【运算符】一小节中曾经接触过一个运算符。叫做“三目(元)运算符”,也就是
var value = a if b else c实际上,这个运算符是由两个条件控制关键字if和else变化而来的。
接下来我们就来学习条件关键字(Condition keywords)。
条件关键字一共有三个:if, else和 elif,其中elif关键字是else if两个关键字的缩写。
其运行格式如下:
if a: # a的结果必须是bool型
<codes1>
elif b: # b的结果也必须是bool型
<codes2>
else:
<codes3>条件关键词的语法用人类语言可以表达为:
如果(if) a 的运算结果为true:
运行代码段1
否则,判断这条条件,如果(elif) b 的运算结果为true:
运行代码段2‘
……
否则(else):
运行代码段3同时,if下面也可以不接elif,elif下面也可以不接else,但elif上面必须承接一个if,else上面必须承接一个if或者elif
下面的示例错误地使用了条件关键字:
if a:
<codes1>
else:
<codes2>
# 编辑器报错,理由是if(elif)和else(elif)的作用域中间不能出现空行
else:
<codes1>
elif:
<codes2>
if a:
<codes3>
# 编辑器报错,if必须在elif和else上,elif必须在if之下、else之上,而else只能位于if和elif之下条件关键字允许嵌套使用,如下:
if a:
<codes1>
if b: # 只有在if a的结果为true,上面的codes1执行完毕后才会执行条件判断
<codes1-1>
elif c:
<codes1-2>
else:
<codes1-3>
<codes1>
elif d:
<codes2>
if e: # 只有在elif d的结果为true,上面的codes2执行完毕后才会执行条件判断
<codes2-1>
else:
<codes2-2>
else:
<codes3>
if f: # 只有在上面的codes3被执行且完毕后才会执行条件判断
<codes3-1>
注意:一组条件关键词(if、elif、else)中,只要有一个成立,计算机就只会执行那个成立的条件后面的代码,而其余的条件语句就不会再被计算机检测与执行!
因此,如果需要计算机连续检测一系列条件,请使用if并列句:
if a: # 计算机会检测该条件
<code1>
if b: # 计算机会检测该条件
<code2>
else:
# 如果上面的if后条件结果为true,则这段代码就不会被计算机执行
# 反之则上面的if后面的code2不会被执行,而这个else后面的code3被执行
<code3>在某些特殊条件下,你还可以使用分支条件句来代替if并列句(见后面【分支条件句】小节)
[*]循环语句
在实际的编写过程中,我们希望能够在一个作用域内重复执行多次同一段代码,这个时候我们就需要用到循环(Loop)来解决这个问题
在学习循环之前,我们需要先学习一下GDScript执行循环的原理:
当计算机执行代码到循环体后,计算机会根据循环关键字后给定的条件,从循环给定的作用域开头开始运行,一直往下运行到作用域末端,之后,只要循环的条件仍然能够满足,就会重复执行上述作用域内的代码若干次。每次执行作用域内的代码前,计算机都会检验循环关键字后给的条件是否满足,如果不满足条件,则立刻终止循环,并继续往下执行循环体下方的代码。循环内的代码会在几乎一瞬间内完成,而非以肉眼可感知的间隔依次执行并完成。
接下来我们学习GDScript中的两个循环关键字:while和for
while关键字的用法如下:
while a: # a的结果必须是bool
<looping codes>上述代码中,只要a的结果为true,就会重复执行looping codes中的代码,直到a的结果为false,才会跳过该段代码继续往下执行下面的代码
下面是一个while循环的示例:
var a = 10
while a > 0: # 只要a大于0就一直循环下面的代码,否则不执行
a -= 1 # 每循环一次,a就减1
print(a) # 把每次循环的a的值打印到控制台最终会把9 8 7 6 5 4 3 2 1 0依次打印到控制端,而我们肉眼看见的就是这九个数字被几乎同时全部打出来。
以上就是while的语法,接下来介绍for循环的用法。
for循环的基本格式如下:
# i为整数:
for i in <整数j>:
<looping codes>
# 最基本的用法,i从0开始,循环执行<looping codes>j次。下面是一个循环的简单示例:
for i in 10: # i从0开始循环到9
print(i) # 依次打印出 0 1 2 3 4 5 6 7 8 9
# 呈现时,这九个数字是几乎同时呈现在控制台上的关于for和while的更多用法,我们会在日后的学习中逐步探索。
[*]分支条件句
我们前面学过了条件关键字所构成的结构(也就是所谓的条件句【Condition blocks】),但这种条件句在某些情况下也有一定的不足之处。
来看下面一段示例代码:
enum Enum {
A, B, C, D, E, F, G, H, Z
}
var b = 0
if b == Enum.A:
<codes>
if b == Enum.B:
<codes>
if b == Enum.C:
<codes>
if b == Enum.D:
<codes>
...
if b == Enum.Z:
<codes>
else:
<codes>我们发现,这段条件句都有个共同的特点:它们都是以==作为条件判断的运算主体,而且还重复出现了枚举Enum。
试想一下,如果变量b换个长度超过28个字符的名字,那么这些条件关键字后面所跟的条件中的b就得一个个替换成这个超长的名字,而且,如果Enum里的内容常量的数量超过了10个,那么这一套换下来,就已经差不多过去了一分钟。
因此,对于这一类连等判断式,我们需要一种更加高效、一劳永逸的方法来解决这个问题。这就是分支条件句(Switch pattern)
GDScript中,分支条件句的关键字只有一个:match
match的基本用法如下:
match a: # a为要被检测的变量,相当于"=="左边的变量
b: # 等价于 if a == b:
<code1>
c: # 等价于 if a == c:
<code2>
d,e,f: # 等价于if后面只要a == d、a == e和a == f这三个条件中有一个为true即可,也就是if a == d || a == e || a == f
<code3>
_: # 上面的条件一个都不满足时执行(等价于对上述所有条件的else的归纳)
<code4>下面为分支条件句的一个例子:
var a = 10
match a:
3: # if a == 3:
a = "I love you"
6: # if a == 6:
a = "Show your love"
8: # if a == 8:
a = "Yes, man"
_:# 上面的都不满足时执行:
a = "What?"以上便是分支条件句的基本用法,关于更加高级的用法,我们会在后面的学习中进行研究
[*]流程控制词
有时候,我们不希望循环或者分支条件句能够全部执行完,这时,流程控制词(Process-controlling keywords)就派上了用场
流程控制词一共有两种:break和continue
我们来同时学习一下break和continue的用法:
for a in b:
<codes>
if c:
break
elif d:
continue
while e:
<codes2>
if f:
break
elif g:
continue上述两段代码中,break会强制退出当前循环,并不再继续执行循环内未执行的代码,之后让计算机继续往下执行下面的代码;而continue则是强制进入下一轮次的循环,并不再继续执行当前这一轮此内的循环内未执行的代码。
从这两个词的英文词义break“中断”和continue“跳行”,我们也可以猜出这两个关键字在循环中的作用。
除了循环,continue还可以用在分支条件句中:
match a:
1:
<codes>
if b:
continue
<codes_vir>
2:
<codes>
上述代码中,当条件句b内的continue被执行后,计算机将会停止执行 <code_vir> 这一段的代码,同时将进行a==2的检测
因此,continue在分支条件句中可以强制跳过一个条件所管辖的作用域的代码的执行,并让计算机继续检测下面的条件。
[*]作用域占位符
当一个作用域内没有任何代码执行时,如果什么都不写,会导致编辑器报错:
if a:
# 注释不算代码
# 此处没有任何代码,但是编辑器报错了因为在GDScript中,计算机认为这个作用域缺少一个结尾。
这个时候,我们就需要使用占位符pass来充当一个占位符,提示计算机:这是个空的作用域。
if a:
pass
# 编辑器不报错,因为计算机已经意识到这是个空的作用域了如果作用域内有代码,则pass不会影响整个作用域内代码的执行,它只是一个占位符,提示计算机:这是个空的作用域,你就不要报错了
以上就是本节的全部内容了,下一节开始我们就要开始学习非常重要的概念——函数和类了。
本帖最后由 电童·Isamo 于 2022-10-28 17:07 编辑
GDScript其三:函数(方法)与类我们已经在前面的两节中已经学习过GDScript中最基础的几个概念和操作,都是为了本节内容而打下的铺垫。从本节开始,我们将要正式步入GDScript中最为重要的领域——函数和类。
[*]函数
实际上,我们在上一节里已经接触到了一个函数:
func _ready() -> void:
<codes>那么,到底什么是函数呢?
我们在初二的数学课本上就已经学过关于函数(Function)的定义了:当一个自变量x发生变化时,因变量y也发生了变化,则我们称因变量y是自变量x的函数。到了高中,由于更多初等函数的加入(包括常函数y=a,a是一个常数),函数的定义又变为了:在集合X中,存在任意一个数x,使得在集合Y中,都有唯一一个数y与之对应,记它们的映射关系为f,则称y为x经过映射f而得到的函数。
计算机中(也包括GDScript),函数(Function)就是指一系列算法(有序)排列而成的算法或算法集。
GDScript中,除声明变量以外,所有计算均应在函数所定义的定义域(以后统称这个函数的函数体【Function body】)内进行,否则会被编辑器报错
[*]函数的声明
函数的声明需要用到func关键字,格式如下:
func function_name<idf>():
<codes>其中,function_name为你声明的函数的函数名称,采用snake_case命名法来命名。
下面就是一个简单函数的例子:
func my_func():
a += 1
[*]函数体内的变量调用
我们刚才提到:除声明变量外,所有计算均应在函数体内进行。那么接下来我们将会学习在函数体内使用已声明的数值量进行简单的计算
但在开始学习这个技术之前,我们先需要学习另一个概念:调用(Call)变量
调用变量,指的是将一个已经声明过的量进行直接引用,当这个引用后的量发生变化以后,其所引用的原变量也会发生相应的变化。
它与引用(Reference)不同的是:引用是只引用原变量,但不改变原变量;而调用则不但引用原变量,同时还让会让原变量因引用后的变量发生变化而发生变化。
下面是一个引用和调用的区别例子:
var a = 1
func tutorial():
var b = a # 变量b【引用】了变量a
b += 1
print(b, ",", a) # 打印结果是2,1
func tutorial2():
var b = a
a += 1 # 调用了变量a可以看到,上面的两个函数中,局部变量b(接下来会讲局部变量,这里顺便提到了)引用了变量a的值。而在第一个函数中,变量b加上了1,因为是引用了a的值,因此只是b加上了1,但是a却丝毫没有发生变化。因此打印的结果是2,1。而第二个函数中,虽然局部变量b引用了变量a的值,但是我在这个函数中调用了a变量并且修改了它的值,这个时候这个调用的原变量a(其实就是在上面声明过的a变量)被计算机定义后,在执行这个函数时,这个变量a就会加上1而变成2,从而使打印结果为1,2。
我们还可以从赋值的角度来区分引用和调用:
var a = <值> # 这是一个声明好的变量a,计算机在运行程序时将其定义
var b = a # 位于单等号右侧的同名变量a就是指a被b所引用
a = 1 + 2 # 位于单等号左侧的同名变量a就是指a被调用到某个作用域内同时需要注意的是:引用可以用于函数体外,但调用变量只能用于函数体内
还有一点需要注意:常量只能被引用,但不可以被调用,因为常量是不会也不能被改变的量!
[*]函数的局部变量和脚本变量
在上一小节的例子中,我们无意间接触了局部变量b。那么,什么是局部变量呢?
所谓的局部变量(Local variable),指的就是在一个指定的作用域内才可以使用的变量。声明局部变量后,这个变量只能在这个作用域及其更下级作用域内使用,而不能被其他函数或者上一级的作用域所访问(Access,即引用和调用)
在计算机执行代码时,若离开了这个局部变量所在的作用域,则局部变量会被计算机销毁而不能再被使用。且每次当计算机执行该作用域内的局部变量定义时,都会重新定义该局部变量的值。也就是说,每执行一次作用域,该作用域内的局部变量都会被赋为你在脚本编辑器内为它所赋予的值,之后再被计算机定义
局部变量的声明类似于脚本变量的声明,同样是使用var关键字来声明:
func function():
var local_var = 1其中function就是你的目标函数,local_var就是你的局部变量的名字。
隐式声明、限定数据类型等声明脚本变量的句法,在声明局部变量时依旧有效
下面分别为一个正确的局部变量示范和一个错误的局部变量的示范:
func correct():
var a = 1
var b = 2
if a == 1:
var c = 3
b += 1
c -= b
print(c + b + a)
func wrong():
var a = 1
var b = 2
if a == 1:
var c = 3
b += 1
c -= b
print(c + b + a)# 会被编辑器报错,原因是变量c只是在条件句这个作用域中被定义的,而出了这个作用域就无法使用c这个变量了而与局部变量相对的,就是脚本变量,也就是之前我们学习的那些在函数体之外的变量了,它们又称为这个脚本的全局变量(Global variable),只要它们已被声明,它们就可以在该脚本内的任何地方被访问。
需要注意的是:export关键字和onready(下一节会学到)关键字不可以用于局部变量!
[*]函数的参数
有时候,我们希望向函数中预先输入一些数,就好像数学上的函数一样,这个时候,我们就需要给函数一些输入参数:
带参函数(Function with parameters/arguments)的声明方法就是在声明函数时,向函数名后的括号内填入参数名:
func function_params(param1, param2, ..., paramN):
<codes>上述代码中,函数名后面的参数可以有无限多个,但我的建议是不要超过10个,否则会很影响代码的可读性。
每个参数之间用逗号","隔开
函数的参数与变量一样,可以在函数体内的任何作用域内被引用,同时也支持变量的数据类型限定语法
如果参数在声明时,后面接了个单等号 + 数值("= value"),这个时候这个参数就称为含值参数(Parameter with default value)
但含值参数的声明有一个前提:普通参数之后,连续多个参数必须均为含值参数。如果在这些连续的含值参数中插入了一个普通参数,则会导致编辑器报错。
下面是含值参数的声明对比示例:
func compare(v1:int,v2:int=1,v3:int=5):
<codes>
func miscompare(v1:int,v2:int=2,v3:int,v4:int=5): # 编辑器会报错,原因是含值参数v2和v4中间插入了普通参数v3
<codes>
[*]函数的调用与返回
我们既然声明了一个函数,那么自然必须要让它有用武之处。这个时候,我们就需要【使用】这个函数,这一过程就叫做调用函数(Call functions)
调用函数的方法,就是直接将函数名和其后面的圆括号写出来:
func foo(): # 声明一个函数foo()
<codes>
func main():
foo() # 调用函数foo()调用函数时,计算机会转到这个函数所在的函数体并依次执行函数体内的所有代码,当所有代码都执行完毕后,才会返回到这个函数被调用的地方继续往后或往下执行
如果这个函数带有参数,则调用函数时,需要把参数的值输入圆括号内。
func foo(param1,param2):
<codes>
func main():
foo(1,2) # 调用含参函数foo
foo(1) # 会报错,原因是没有输入足够的参数
注意:调用含参函数时,你输入的参数的值和函数被声明时函数的参数的出现顺序是一一对应的。上面的例子中,输入的数值1对应声明的参数param1,输入的2就对应着param2。
如果被调用的含参函数中有含值参数,则在调用时,可不需要对应输入含值参数的值。如果输入,则输入的值将覆盖该含值参数在函数体内的值:
func foo(i1,i2 = 3):
<codes>
func main():
foo(1) # 合法,因为i2是含值参数,可以不用输入i2所对应的值,此时i2 = 3
foo(1,5) # 合法,但此时i2被输入的值所覆盖,在调用时,i2 = 5
当然,也有一些函数,它们在进行运算之后会产生结果,我们希望在调用这个函数的时候可以获得这一结果,并将这个结果进行运算或赋值。这时我们就需要一个概念:函数的返回(Return)
所谓函数的返回,就是指当计算机运行完毕这个函数体,或者执行到函数体内的某一特定关键字处时,返回到函数被调用的地方的这一情况。但是,有些情况下我们希望函数能够在返回时,将原来调用这个函数的地方转换成一个值,这个时候,我么就需要用到返回关键字return
return只能作用于函数体内,且一个作用域内只能有一个return关键字,否则编辑器将会报错
当计算机执行到return关键字所在的那一行时,计算机将会停止运行return之后的所有代码,并离开函数体,返回到函数所在的地方。如果return后面带有数值量,则会将该数值量提取到函数被调用的地方,作为该函数执行的结果。
下面是return的一个示例:
func foo(a):
if a == 1:
var b = 2
return b
if a == 2:
var c = 3
return c
if a == 3:
return
if a == 4:
var d = 6
return d
func main():
print(foo(1)) # foo()中的局部变量 b将会被作为函数的结果返回到这里,其后的代码不再被执行
print(foo(2)) # foo()中的局部变量 c将会被作为函数的结果返回到这里,其后的代码不再被执行
print(foo(3)) # 由于return后面没有值,因此默认以null作为结果返回到这里,其后的代码不再被执行
print(foo(4)) # foo()中的局部变量 d将会被作为函数的结果返回到这里
在GDScript中,声明函数时,你还可以强制指定函数的返回类型。语法如下:
func function() -> <类型>:
<codes>
return result
其中function为目标函数,<类型>就是你需要让函数输出(返回)的结果的类型。
如果一个函数被强制指定了返回类型,则函数体内的所有作用域(包括函数体本身),都需要return关键字来返回一个对应返回类型的值:
func foo() -> int:
var a:String = "hi"
return a # 报错,原因是a的类型与函数强制返回的类型int不匹配
func foo2(k:bool) -> bool:
if k:
return false
else:
if !k:
k = true
return k
# 报错,原因是有一个(或几个)作用域内缺少return关键字
当然,也有一些情况下函数确实返回不出来数值,但就是为了防止【有开发成员不小心篡改成有返回数值的而导致出问题】这个问题。这个时候,我们可以将强制返回类型语法中箭头[->]后面的类型名改为关键字void。它表示这个函数什么值都不返回。此时return关键字后面不能跟任何数值量,只起到【中断函数运行并离开所在函数,让计算机返回函数被调用的地方继续往下执行代码】的作用。
func foo(a) -> void:
if a == 1:
return
if a == 2: # Mark A
<codes1>
func main():
foo(1) # 由于a等于1,因此函数foo()的Mark A处及以下的函数均被跳过执行,同时本函数继续往下执行<codes2>
<codes2>
返回值函数和无返回值函数(以下分别称为有果函数和无果函数/功能函数)的共同作用:
[*]都是处理一系列算法/处理一系列事件的集合
[*]都是降低代码复用的结构
它们的不同作用:
[*]有果函数可以用来编写读取值的函数,如get_last_velocity(),am_I_mario(),is_block_hit(),等。也有少量有果函数可以同时负责主要事件/算法的处理同时输出结果
[*]功能函数主要用于处理一系列事件,或者设置值,如set_velocity(a,b,c),die()等等
至此,我们就学完了函数的内容,接下来我们将初步接触面向对象中一个非常重要的概念——类(Class)
[*]【类】为何物?
在计算机编程语言中,类(Class)就是一个对象的基床。任何一个对象,都是要以类为模板来进行创建的。
GDScript也不例外,任何一个对象,都是以类为模板来进行创建的,而这些以类为模板而被创建出来的对象,就叫做这个类的实例(Instance)
实际上,Godot中的节点、脚本、资源等等,它们都是Object类所派生出来的类的实例。本质上,这三者也都可以叫做对象(Objects)
我们这一小节就围绕类来讲解脚本的实质与extends的真实用法,对象的成员属性和成员方法,父子类的特性与父方法重写,调用类(节点)的成员,以及GDScript中几种类的声明。
[*]脚本的实质与extends的真实用法
我们在学习GDScript其一时就已经学习了一个叫做extends的关键字,那时候我们规定的是:
[*]这个关键字只能用于开头,后面只能跟节点的类型名
[*]这个关键字只能在该脚本中出现一次
实际上,extends的意思是:将一个类进行扩展,使得该类能够继承(Inherit)这个被扩展的类的所有东西。
而本质上,GDScript中的脚本是类的基床,代表着一个类。
所以换句话说:extends就是用于继承、扩展类的关键字,而其所在的脚本便创建了这个被继承类的一个子类(Child class),而这个被扩展的类也就理所当然地叫做父类(也叫做超类,Super class或Parent class)
我们以新的视角再来看下面这行代码:
extends Node上述代码的意思就是说:这个脚本(所代表的类)继承了Node这个类里的所有东西,然后创建了Node这个类的一个子类。当该脚本挂载到一个节点上时,该脚本就会尝试将该节点作为这个脚本所代表并继承的子类的一个实例来修饰。
只不过,如果不加以特殊修饰,那么这个类就是一个没有名字的类(即非具名类),我们会在后面学习具名类时就可以为这个没有名字的类赋予名字。
这样一来,我们前面初步学习extends时提到的【一定要让extends后面跟随的类型设为你要挂载到的节点的类型】的真正含义,在前面的几句话里也就得到答案了。
既然脚本利用继承关键字extends继承了一个类的同时还创建了这个类的子类,那么是不是就意味着继承也可以用在脚本上?
答案是肯定的。
继承脚本,如果这个脚本所代表的类(注:不是所继承的类)为非具名类,那么就需要在继承关键字extends后加上这个脚本的存放路径:
extends "res://your_script.gd"这时就代表这个脚本继承了路径所指向的脚本所成的类,并创建了这个类的子类。
由于我们通常让脚本继承自节点,并将这个脚本赋予在节点上,因此也可以说是节点继承了这个脚本所创建的类。
(也可以说:这个节点就是这个脚本所代表的类的一个实例。其实上面有句话也提到了这一点)
因此,只要是需要类继承的地方,都可以使用继承关键字extends
还要注意一点:GDScript只允许单继承,不能多重继承,即不可以继承两个及以上的类
[*]类的成员
我们上面已经学过:脚本代表着一个类。接下来,我们就要学习面向对象中另一个重要的概念:类的成员(Member)
GDScript中,一个类的成员,就等于这个类所对应的脚本里所声明的所有量,包括常量、变量、枚举、函数、信号等(但不包括函数里声明的所有局部变量)
前三者则又统称为这个类的成员属性,简称这个类的属性;而后两者则又称为这个类的成员方法(Method),简称这个类的方法
而类又是创建对象实例的基床和模板,因此,这个类的实例的成员,就是这个类的成员
由于节点也是其所挂载的脚本所代表的类的一个实例,因此我们也可以说:一个节点的属性就是这个脚本所代表的类的属性,一个节点的方法就是这个脚本所代表的类的方法。
而我们又学过:每个节点的属性,除非是父子关系,否则是互相独立、互不干扰的,这一点同样适用于其挂载的脚本所代表的类所声明的属性和方法。
[*]父子类的特性与父方法重写
前面提到过,一个类(子类)继承自另一个类(父类)后,父类中的所有东西都可以在子类中使用。这里的“所有东西”,指的就是父类的成员。
因此换句话说:父类中的成员会继承给子类,并使之成为子类的成员的一部分。
我们看下面两个脚本的代码:
# New script
extends Node
var a = 10
var b = 15# New script 2
extends "res://new_script.gd"
var c = 20
func _ready() -> void:
print(a,",",b,",",c)# 打印结果10,15,20很显然,new script2继承了new script,因此new script中的两个属性a、b均可以在new script2中直接使用
同样地,父类中声明的方法,在子类中也可以被直接调用
# New script
extends Node
var a = 10
var b = 15
func foo():
print(a + b)# New script 2
extends "res://new_script.gd"
func _ready() -> void:
foo() # 打印结果为25但反过来,父类不可以调用子类的成员,否则编辑器会报错
# New script 2
extends "res://new_script.gd"
func foo():
print("I am here!")
# New script
extends Node
func _ready() -> void:
foo() # 报错,父类不能调用子类的成员导出变量也属于成员的范畴,不过在检查器中显示时,会先显示父类的导出变量,然后才会显示子类的导出变量
# New script
extends Node
export var parent:int = 1
export var something:float = 0.8# New script 2
extends "res://new_script.gd"
export var child:int= 3
export var something2:float = 0.78导出结果如下图所示
https://i.imgtg.com/2022/10/28/RaZlX.png
(勘误:应该是父类属性和子类属性)
当然,我们也会遇到这样一种情况:
我给父类写了个方法,名叫作killed(),但是父类的方法的内容在子类不能完全一样,可却又要保证方法名字不变。这个时候我们就需要用到一个叫做方法重写(Override)的手段来解决这个问题了。
这个手段很简单,只需要在子类中声明一个跟需要被重写的父类方法一模一样的方法即可:
# New script
extends Node
func foo():
pass
# New script 2
extends "res://new_script.gd"
func foo(): # 父类的方法foo()被子类重写
print("child") 然而,在子类中重写后的方法会直接覆盖掉父类,这时如果你希望仍然可以使用父类的同名方法,只需要在调用foo()的时候在foo()前面加一个英文句点"."即可:
# New script
extends Node
func foo():
print("parent")# New script 2
extends "res://new_script.gd"
func foo():
.foo() # 调用父类同名函数,打印"parent"
print("child") # 再执行下面的代码,打印"child"
[*]调用类(节点)的成员
前面我们是在子类调用了父类的成员,但实际上,一个工程内的所有对象不可能全都由父子类来定义并让子类调用父类。这个时候,我们就需要学习如何从一个类中调用另一个类的方法(或者说,如何从一个节点的脚本中调用另一个节点的(相同)脚本的方法)
我们只需要按照下面的格式就可以成功调用外部类(节点)的成员:
<对象(的引用)>.<目标对象的属性或者方法>在调用外部类或者成员时,我们只需要获取这个对象,然后打上英文的句点".",之后输入这个对象的属性或方法即可调用成功
下面的代码就是一个调用类的成员的例子:
# New script,挂载在名为NewScriptNode的节点上
extends Node
var s = 1
var t = 2
func foo():
print(s + t)# New script 2
extends Node
onready var target:Node = $NewScriptNode # 挂载new_script.gd的节点的引用
func _ready() -> void:
target.s = 2 # NewScriptNode节点的属性s被调用,修改值为2
target.foo() # NewScriptNode节点的方法foo()被调用,打印值为4不过这里我们提前涉及到了导入节点,我们到下一节再讲导入节点。
上面的代码就很好地示范了如何调用其他节点的成员,不过调用外部节点的成员是有一定限制的,具体是什么限制我们到下一节会细讲
[*]几种类的声明
GDScript中可以声明三种类:空白类(脚本)、内部类(Inner class)和具名类(也叫公开类,Named class或Public class)
声明空白类就是我们的创建脚本的操作,这里就不再多讲了。
接下来我们学习声明内部类和具名类
[*]声明内部类
声明内部类用class关键字来声明,其格式如下:
class ClassName<idf> (extends Class):
<类体>其中ClassName就是你要声明的内部类的名字,采用PascalCase命名法命名,括号中的部分可不要,但默认会继承Object类
类体必须位于内部类的作用域内,且类体内的内容等同于写脚本时脚本中的内容
下面是一个内部类和内部类外的声明的范例:
extends Node
var t:int = 4
var s:int = 8
class TestClass extends Node: # 声明一个名叫 TestClass的内部类
var r = 9
var q = 12
func _ready() -> void:
print(r + q)内部类声明好后,你不可以直接从该脚本中调用内部类的成员。这虽然不会导致编辑器报错,但会导致程序在运行时出问题,如:
extends Node
var t:int = 4
var s:int = 8
class TestClass extends Node:
var r = 9
var q = 12
func _ready() -> void:
print(r + q)
func _ready() -> void:
print(TestClass.r) # 直接调用内部类的成员,虽然不会报错,但是运行时会出现问题GDScript里要想调用一个内部类的成员,必须新建这个内部类的实例并引用之。
extends Node
var t:int = 4
var s:int = 8
class TestClass extends Node:
var r = 9
var q = 12
func _ready() -> void:
print(r + q)
onready var new:TestClass = TestClass.new() # 由于TestClass是节点,因此需要onready关键字限定,我们下一节会讲
func _ready() -> void:
print(new.r) # 这样子才能成功打印该属性
具名类在制作库的时候,配合单例使用会更有奇效,我们会在讲解单例的时候再进一步学习内部类。
[*]声明具名类
具名类需要用class_name关键字来声明,其格式如下:
extends <类>
class_name ClassName<idf>, "图标路径"(可选)其中ClassName为你要声明的具名类的名字,图标路径可有可无
一个脚本只能声明一个具名类,这一点很像Java中的【一个类文件中只能有一个公开类】
声明了具名类之后,我们便可以在节点管理器中找到我们的具名类
extends Node
class_name Newhttps://i.imgtg.com/2022/10/28/RayTt.png
需要注意的是:
[*]每个具名类都会在节点管理器中都会显示声明这个具名类的脚本,非常方便开发者溯源。
[*]如果你继承的是Node2D类,那么请在Node2D选项下面找到你声明的具名类节点,如果你继承的是Sprite类,那么请在Sprite下方找到你声明的具名类节点,其他节点亦是如此。
继承关键字extends后面可以加上你声明的具名类的类名,这个时候,脚本就会继承这个具名类
# Script 1
extends Node2D
class_name New# Script 2
extends New # 继承了Script 1所代表的类New(Node2D)
class_name New2这样,New2就是New1的子类、Node2D的孙类了。
如果具名类后面同时声明了"图标路径",那么节点管理器中就会直接显示这个图标而非所继承的类的图标
补充
[*]默认情况下,直接调用某个节点的脚本内的变量和方法,都只是针对这个脚本所挂载到的那一个节点,并不会影响挂载了同一脚本的其他节点
[*]默认情况下,直接调用某个场景实例化后的节点的脚本内的变量和方法,都只会影响被调用成员的脚本所挂载到的实例化场景节点实例,并不会影响该场景实例化后的其它节点实例
以上就是函数(方法)与类的全部内容了,下一节我们将要开始学习虚方法、onready关键字和节点的导入
本帖最后由 电童·Isamo 于 2022-12-11 14:21 编辑
GDScript其四:虚函数、节点的简单导入和onready变量前面三节我们学会了GDScript中的三个基本操作:数值量的声明、导出变量和声明函数。其中数值量(包括导出变量)和函数统一称为这个脚本所代表的类的成员,也是脚本所挂载到的那个节点的成员的一部分。
本节中,我们将学习虚函数(虚方法)、节点的简单导入和onready变量。
[*]虚函数
实际上,我们在上一节和上上一节的课程中就已经接触到了一个虚函数(Virtual method):
func _ready() -> void:
<codes>虚函数有一个共同的特点:它们的函数名都以下划线"_"开头,但有些情况下,我们也可以声明一个以下划线"_"开头的函数。因此,如果你想知道一个函数是否是虚函数,可以这么做:
按住Ctrl,将鼠标移到要检测的函数的函数名上,此时,该函数名下方会出现下划线
https://i.imgtg.com/2022/10/28/Ragvx.png
此时鼠标左键点击该函数名,这时将会跳转到一个页面:
https://i.imgtg.com/2022/10/28/Ramcj.png
这个页面就是Node的属性和方法大全页面,如果以后想要直到一个类到底有哪些成员,可以直接在这个页面里查询。
回归正题,如上图所示,如果你看到一个方法名右侧有"virtual"这个单词,就说明它是一个虚函数(虚方法),大部分虚函数会因系统的某个或某些固定的事件的触发而被触发。具体的事件可以阅读该虚函数下方的文字说明。
为方便起见,这里先附上几个常用的虚函数及其触发说明。其中有四个虚函数(标红),我们会在高级篇进行更加深入的讲解。
虚函数虚函数触发说明
_init() 该对象被载入内存中初始化时触发,为该对象的构造函数(Structure function)
_enter_tree()脚本所挂载到的节点进入当前场景的节点树时触发
_exit_tree()脚本所挂载到的节点离开当前场景的节点树时触发
_ready()脚本所挂载到的节点进入当前场景的节点树并就绪时触发
_process()每一个空闲帧(即电脑屏幕刷新率限制内的帧数)就触发一次,一秒内触发的次数与你电脑的显示器的屏幕刷新率有关(在后面会讲到该虚函数的作用机理)
_physics_process()每一个物理帧(即程序运行时程序内已经设定好的帧数,等同于CTF中设置程序属性时的Frames Rate)就触发一次,默认一秒内触发60次(在后面会讲到该虚函数的作用机理)
_input(event)当有键位输入时触发
_unhandled_input(event)当有键位输入但还未被_input(event)方法处理时触发
这些虚函数我们在后面会经常用到,这里我们暂时以_ready()虚函数为例:
extends Node
func _ready() -> void:
print("I'm ready!")根据虚函数表对_ready()函数的描述,我们需要把脚本挂载到一个节点上,并且要把该节点放入当前场景的节点树中(如果该节点就是该场景的根节点则请跳过这一步)。运行程序,之后会在控制台中看到:
I'm ready!接下来,我们尝试以调用方法的形式把上述代码改写一下:
extends Node
func _ready() -> void:
foo()
func foo():
print("I'm ready!")再运行一次,结果是一样的。
至此我们把虚函数的简单介绍就已经学习完了,我们会在后面的学习中逐步学习更多的虚函数及其用法。
[*]节点的简单导入
我们在上一小节中学习了_ready()虚函数的基本用法。实际上,我们在上一节已经接触到了一种导入节点的写法。这次,我们将学习如何导入节点。
导入节点(Import node)是获取一个节点,并使其能在脚本中可被调用的最基本的手段和必须要经过的步骤。如果不导入节点,那么我们在上一节学习的【调用其它节点的成员】这一方法也就无能为力了。因此,要想调用其它节点的成员,我们必须先导入节点才行。
[*]导入节点的方法
导入节点一个叫做get_node()的函数,这个函数只能在Node类中使用。
使用方法如下:
get_node(<NodePath>) # 是有果函数,返回一个Node,该Node就是通过NodePath而得到的其中的NodePath就是我们下面要讲的节点路径了。
节点路径以@号开头,其格式如下:
@"节点路径"我们以下图所示的作为一个调用节点路径的一个例子
https://i.imgtg.com/2022/10/28/RaJOp.png
对于Root节点而言,Parent节点的路径是:
@"Parent"而Child的节点路径是:
@"Parent/Child"而Root的节点路径则是:
@”/root/Root“实际上,节点路径的写法有很多,下面是从官网doc某处搬运过来的所有节点路径的格式与写法,可供各位同学学习参考:
# 没有路径前缀表示此路径是相对于当前节点的
@"A" #子节点A
@"A/B" # A的子节点B
@"." # 当前节点
@".." # 父节点
@"../C" # 兄弟节点C
# 携带前缀路径表示场景树的绝对路径
@"/root" # 等同于 get_tree().get_root()
@"/root/Main" # 主场景——"Main"(假设主场景的名字为Main)
@"/root/MyAutoload" # 自动加载脚本——MyAutoload(如果有) 然而,在调用get_node()这个方法时,"@ "符号可不用输入。因为在执行程序的时候。计算机就自动在""前面加上了@符号
(实际上,get_node()方法内输入的String,会被自动展开为get_node(NodePath(node_path)))
仍然以上面的为例,以Root为准,在Root中获取Parent时可以这么写:
get_node("Parent")对于Parent下的Child,我们有:
get_node("Parent/Child")而对于Root本身,我们有:
get_node(".")或者,在获取节点自身时,我们也可以将上述方法直接替换成关键字self,表示直接引用这个脚本所挂载到的节点。
然而,对于挂载该脚本的节点而言,get_node()也未免有些影响敲代码的效率了(?)。为此,Godot引入了一个字符“$”来直接替换get_node():
# 对于Root而言
$Parent
# 等价于
get_node("Parent")当然,$符号也可以跟节点路径,但此时节点路径不能加上"@"符号:
# 脚本在Parent上
$".." # 获取Parent的父节点Root,等价于
$"../Parent/Child" # 获取Parent的父节点的子节点Parent的子节点Child,等价于$Child
但是,如果这个语法的前面含有一个对象的调用,则该语法非法,需要替换成get_node()
下面是"$"语法糖的合法与非法示例,供各位同学参考学习
对于Parent节点而言
$Child# 合法
$".."# 合法
$Child.$".."# 非法,不可以在其它节点的引用后再次使用该语法。
$Child.get_node("..") # 合法
[*]如何成功导入节点
我们已经学会了导入节点的方法,但是,到底如何成功地导入节点,还是一个未知数。
接下来,我们将学习如何成功地导入节点。
导入节点需要遵守如下步骤:
[*]声明一个变量,并给它指定一个Node类及其子类,这个指定的类必须是你要导入的节点的类型或该节点的类型的父类
[*]在虚函数_ready中调用这个引用变量,并给这个变量赋值,值即为get_node()语法(也包括$语法糖)
下面我们仍然以Root-Parent-Child这个结构为例,将脚本挂载在Root节点上,来学习如何导入这些节点:
extends Node
var par:Node = null
var child:Node = null
func _ready() -> void: # 注:导入节点必须写入_ready()变量中,不可以直接在声明变量时直接将值赋给变量
par = $Parent
child = $Parent/Child需要注意的是:导入的节点只能在当前场景的场景树中有效,超出这个范围则无法导入节点
至此,我们就已经学会如何导入一个节点了
[*]onready变量(载入变量)
前面一小节我们学会了如何导入节点:先声明变量,再在_ready()函数中调用这个变量并将这个变量赋的值设为利用get_node()或$语法糖而获取的节点。
然而,如果我们一次性要导入十几个节点,这样写未免会显得麻烦,同时还会占据代码的大部分篇幅。
为此,GDScript引入了一个关键字:onready
这个关键字跟导出变量关键字export一样,只能加在声明变量关键字var前,且不能用于局部变量。但同时,export关键字和onready关键字不可同时使用!
onready的作用,就是将其后面所声明的变量在节点加入当前场景的节点树并就绪时再由计算机定义,而在就绪前,onready后所声明的变量则会使用默认值。
给一个声明的变量前加onready,等同于将这个变量先声明在函数体外,然后再在_ready()虚函数中调用并赋值。
# 仍然以Root-Parent-Child为例
extends Node
onready var parent:Node = $Parent # 等价于var parent:Node = null,然后在虚函数_ready()中parent = $Node
onready var child:Node = $Parent/Child # 等价于var child= null,然后在虚函数_ready()中child = $Parent/Child
当然,在前面的章节里我们也提到过——导出变量是可以导出NodePath的。配合导出的NodePath变量,加上get_node()方法来使用onready变量导入节点的话,将会使你的开发更加灵活。
在利用导出NodePath导入节点时,为防止因节点路径为空造成get_node()方法报错,建议使用get_node_or_null(<NodePath>)方法,它在节点路径为空或该节点路径所指向的节点不存在时返回null而不是报错。
extends Node
export var node:NodePath = @"" # 留空,以便于日后开发修改
onready var node_got:Node = get_node_or_null(node) # 获取node这个NodePath所指向的节点,使用get_node_or_null()以避免node的值为空节点路径时编辑器报错我们只需要在挂载了该节点的检查器中找到这个导出的节点路径:
https://i.imgtg.com/2022/10/28/RaRLY.png
点击右侧的指定,会弹出如下窗口:
https://i.imgtg.com/2022/10/28/Ratuv.png
然后双击你要指定的节点,就可以将该节点在编辑器中预导入(Pre-import)进脚本啦~
至此,我们就已经学习完了虚函数、节点的简单导入和onready变量的用法。
下一节开始,我们将要学习GDScript中另一个比较重要的概念——信号(Signal)
本帖最后由 电童·Isamo 于 2022-10-28 17:44 编辑
GDScript其五:信号我们在前四讲中已经学会了如何声明数值量,如何让脚本继承类并挂载到节点上,如何声明函数,以及虚方法和onready变量的简单应用。
在本节课开始之前,我们先来回忆一下如何调用其它节点的方法:
<节点引用>.<方法>例如下图这棵节点树:
https://i.imgtg.com/2022/10/28/RaL9r.png
其中A的脚本为:
extends Node
onready var child:Node = $Child
func _ready() -> void:
child.foo() # child为子节点的引用,foo()为Child节点的一个方法而Child的脚本(这里就当作Child有脚本)为:
extends Node
func foo():
pass试想一下这个情况,假如我有三个Child,分别为Child1、Child2和Child3
其中,除了第一个Child1子节点的方法仍然为foo()外,其它两个分别为foo2()和foo3(),
为了一次性获取A的所有子节点,并调用A下所有子节点的某个方法,于是有了以下的代码片段:
extends Node
func _ready() -> void:
var children:Array = get_children()
for i in children: # 获取A的所有子节点
i.foo() # 调用A的所有子节点的该方法看上去好像一劳永逸了,然而我们前面提到过,除了Child1的方法名是foo()以外,其余两个的方法名均不是foo(),这就会导致这段代码在程序运行时报错。
在实际的工程中,会有很多类似地情况发生。同时也会有一些情况是:我并不能确定目标节点的目标方法叫什么名,但我希望能够通过这个函数来调用这个目标节点的目标方法。当然,我们还可以思考这一一个情况:我现在有一个函数function(),我希望它在被调用时,能够发送(Emit)一个通知(Notice),然后我可以自由选择让一部分节点在接收到这个通知之后,能够迫使接收这个通知的节点的某个特定的方法被强制调用。而实现这个手段的工具,便是我们今天要学的,GDScript中另一个十分重要的概念——信号(Signal)
[*]何谓信号?
信号是Godot中将某些事件与脚本中的方法相连接的一种重要媒介(Media),它可以让若干个节点的方法响应于一个特定的信号,而这个信号又可以在某些事件被触发或某些函数需要对外发送事件通知时所产生。它是降低GDScript节点之间高耦合度的重要工具。
你可以简单地理解为:村长在广播里喊人,然后被村长点到的人都急忙忙地赶到了村长办公室门口。“喊人”这个动作就是信号,而“被喊到的人”就是接收这个信号的节点,而“这些人跑到村长办公室门口”就是节点在接收到了特定的信号之后调用的特定方法。
[*]在节点面板中查看信号
我们在第一节中讲基本界面时曾经提到了检查器右侧的节点面板,而该面板中的其中一个子面板便是节点的信号
我们在场景树面板中选中一个节点,就可以在节点面板的信号子面板中查看该节点的所有信号:
https://i.imgtg.com/2022/10/28/RaQvG.png
这些便是该选中的节点的信号
[*]如何将信号连接到某个方法上
我们前面提到过,信号发送时,是让特定的节点接收的,而要想沟通信号和这些节点,就必须要让信号发射源(Signal source)连接(Connect)到这些需要接收该信号的节点的特定方法上。
我们有两种连接方式:手动连接(也叫信号面板初始连接)和自动连接(又称信号代码连接)
手动连接的方法,就是双击你要连接的信号(这里我以ready()信号为例):
https://i.imgtg.com/2022/10/28/RacOI.png
然后会弹出如下窗口:
https://i.imgtg.com/2022/10/28/RadND.png
在上面,你可以选择一个要连接到的带脚本的节点,然后在下方的“接收方法”中输入你需要接收该信号的方法名即可。
不过这里有个编程习惯:我们通常会将接收信号的方法以"_on"或者"_"+大写字母开头为方法名的开头,同时将它们声明在脚本的末尾部分,并与其他方法以两空行隔开。而这些方法我们又称之为信号方法(Signal method)
当然,我的建议是大部分情况下用"_"作为方法名开头即可,如果接收方法还有可能会被其它节点所调用,则不需要加"_"前缀
不过,你写的接收方法也可以是一个节点本身就自带的方法。如queue_free()。
确定好要连接的方法以后,点击连接,系统会自动跳转到脚本编辑器界面。
如果你填写的接收方法在目标节点的目标脚本中并不存在,则会在该脚本中自动声明一个同名方法:
# 连接前
extends node# 连接后
extends Node
func _on_A_ready() -> void:# 系统自动声明的方法
pass # Replace with function body.我们只需要在编写时将pass那部分替换成你的代码即可
手动连接完毕后,你会在该方法名最左侧看到一个绿色的图标:
https://i.imgtg.com/2022/10/28/RasEF.png
点击这个图标,会弹出如下界面:
https://i.imgtg.com/2022/10/28/RaVu6.png
在这个界面中,你可以查看所有连接到这个方法的信号及其信号源节点和信号目标节点
而自动连接则需要一个名叫connect的方法,该方法的使用格式如下:
connect(<信号名:String>, <信号接收节点>, <信号接收方法名:String>)其中,信号名为你要连接的信号的名称,以字符串的形式出现。信号接收的节点为目标节点,如果连接到的是当前脚本内的方法,可用self关键字代替。信号接收方法名为你要将该信号连接到的方法的名称,以字符串的形式出现。
注:当方法名以字符串的形式出现时,不可将括号带入!
如果要使用connect()方法来连接信号,则一般写在_ready()虚方法内。
下面以Child为例,讲解自动连接信号的方法
extends Node
onready var A:Node = get_parent() # get_parent()方法可以获取该节点的直接父节点
func _ready() -> void:
A.connect("ready",self,"_on_A_ready") # 注:如果要将别的节点的信号连接到自身,请引用该节点并调用其connect()方法
# 将父节点 A 的信号 ready 连接到下面的方法 _on_A_ready() 上
func _on_A_ready() -> void:
passready()信号的作用等同于_ready()方法,因此,一旦A的_ready()方法被触发,则会发送ready信号,而ready信号连接到了节点Child的一个方法 _on_A_ready() ,因此就会调用该方法。
实际上,一个信号可以连接多个节点的多个方法,反之,一个方法也可以接收来自多个节点的多个信号。
由此,信号实际上更加适合制作像组件节点这种可能会添加到不同节点,而这些节点在处理同一种事件时可能会出现不同操作的情况。当然,对于需要节点接耦的场合(比如制作一个玩家精灵对象和玩家主对象,需要把玩家精灵对象塞到玩家对象中,并同时由玩家主对象来操控玩家精灵对象,且在玩家精灵对象不存在的情况下,玩家主对象照常能用),信号依然是你的不二选择。
[*]如何解除信号连接
既然信号可以连接到方法上,那它也理应可以解除连接。
与连接信号一样,解除信号连接的方法也分为手动和自动两种。
手动解除方法:前往信号子面板,找到、选中已经建立连接的信号并右键,会弹出一个菜单:
https://i.imgtg.com/2022/10/28/RafFP.png
在弹出的菜单中选择“断开连接”即可。
自动解除方法则使用disconnect()方法,其结构如下:
disconnect(<信号名:String>, <信号接收节点>, <信号接收方法名:String>)其语法类同于connect()语法,只不过是断开目标信号和目标方法而已。
(注意:connect()和disconnect()方法不可以被重复调用,否则会导致调试器报错。虽然不会中断程序运行,但大量的调试器报错则会引起开发者不适。如需防止被二次调用,请配合is_connected()方法使用,该方法的结构等同于disconnect(),用于检查某个信号是否已经连接到某个方法上)
[*]查看节点中某个信号的触发条件
实际上,我们只从信号名称上来猜测信号被触发时的条件,在某些情况下会造成“只知其一,不知其二”的偏负面效果,如果想要确切地知道一个节点的信号的触发条件,就需要查看这个信号的描述。
方法也很简单,将鼠标悬停在对应的信号上一段时间即可:
https://i.imgtg.com/2022/10/28/RalRb.png
上图就是查看ready信号被触发时的条件
[*]自定义信号
前面我们所提到的信号,都只是局限在节点自带的信号上。而实际上,我们也可能会希望我们有些方法在其被调用期间内也可以发送信号,让其它节点的方法也能够接收到这个信号。这时,我们就需要声明自定义信号(Custom signal)了
声明自定义信号需要用到signal关键字,其格式如下:
signal my_signal<idf>其中,my_signal就是你要声明的自定义信号的名称
声明完成后,你就可以在挂载该脚本的节点的信号子面板中找到该自定义信号了。
https://i.imgtg.com/2022/10/28/Ra2Ug.png
如上图所示,你在脚本中所声明的自定义信号会显示所有节点自带信号的最上方,这就使得开发者可以非常直接方便地使用自己声明的信号。
之后,我们便可以在函数内部控制我们已声明好的自定义信号发送。发送信号需要emit_signal方法,其结构如下:
emit_signal(<信号名:String>)其中信号名同connect()方法中的信号名一样,为字符串。该方法用于发送一个指定的信号,
下面仍然以本节课中的A节点为例,声明一个自定义信号,并将该自定义信号发送出去:
extends Node
signal my_signal
func _ready() -> void:
emit_signal("my_signal")接下来,我们让子节点连接到这个信号my_signal,连接到的方法为_on_A_my_signal
extends Node
func _on_A_my_signal() -> void:
print("Signal received")# 将会在A节点调用emit_signal("my_signal")时打印Signal received只要emit_signal()方法被调用,他就会立即发送其内部所代表的信号,同时也让其它连接到该信号的对应节点的对应方法也立即被调用。
[*]含有参数的自定义信号
但是,函数是可以带参数的,可要是一个信号连接到了带有参数的方法上,这可怎么办?
实际上,节点中有些信号和别的不太一样。
https://i.imgtg.com/2022/10/28/Ra6yB.png
就拿上面的这两个为例,可以看到,这两个信号的括号里面是带有参数的。其实,如果这个方法通过手动连接的形式在某个节点中自动创建了一个信号方法,则这个信号方法也是带参数的:
func _on_A_child_entered_tree(node: Node) -> void:
pass # Replace with function body.这就是带参信号(Signal with parameters),它支持一个参数的发送,并让目标方法的参数也成为这个带参信号的参数。
对于自定义节点的声明而言,带参信号需要如下声明:
signal my_signal(param1,param2,...,paramN)只需要在信号名后面输入括号,然后括号内输入若干参数即可。
需要注意的是:声明的自定义带参信号是不可以对其参数使用数据类型限定语法的!也不可以给其每个参数设置初始值!
带参信号所连接到的方法,其参数数量必须要和带参信号中的参数数量一致。并且,带参信号的每个参数,在目标方法上也是一一对应的。
下面仍然以A作为父节点,Child作为子节点,来示范一个带参信号的例子:
extends Node
signal my_signal(param1,param2)子节点Child:
extends Node
func _on_A_my_signal(param1, param2) -> void:
pass # Replace with function body.由于我是手动将my_signal连接到子节点上的,并且子节点的脚本在连接该信号之前并不存在 _on_A_my_signal() 方法,因此,该方法内的参数是直接自动搬运自源信号的。
如果需要发射一个带参信号,我们仍然可以使用emit_signal()方法,此时它的用法如下所示:
emit_signal(<信号名:String>,param1,param2,...,paramN)其中信号名后面的若干个参数param要与你发射的带参信号的参数保持一致。
上面的那个例子用emit_signal()改写就是:
extends Node
signal my_signal(param1,param2)
func _ready() -> void:
emit_signal("my_signal",1,2) 发送带参信号,参数分别为1和2extends Node
func _on_A_my_signal(param1, param2) -> void:
print(param1 + param2)# param1 = 1, param2 = 2,故打印结果为3注:由于声明带参信号时不能直接指定其参数的数据类型,因此在编写发送带参信号的代码前,请务必确定好你要连接到的方法的参数的数据类型,并根据这一数值类型来确定你要发送的带参信号的参数。
[*]连接信号时给带参信号的目标方法绑定特定参数
实际上,在我们连接信号时,我们也可以将参数预留在我们声明的带参信号上,在发送到指定方法后将该方法的参数自行替换为这些预留的参数。
如果是手动连接带参信号,则需要先选中要发射带参信号的节点,然后在其信号子面板里找到并双击你要发射的带参信号,这时会弹出如下窗口:
https://i.imgtg.com/2022/10/28/Ra8ds.png
我们找到右下角的“高级”开关并将其打开,会出现如下窗口:
https://i.imgtg.com/2022/10/28/RauaK.png
在新出现的窗口右侧,我们就可以添加目标方法的给定参数了,注意:添加的参数于要连接到的目标方法的参数是一一对应的,且不能通过拖拽来调整其顺序。
我们还以A-Child这对父子节点为例:
# A节点# Child 节点注意,A节点中的emit_signal方法里并没有发送参数,如果我用手动连接的方法来绑定默认参数,则应如下设置:
https://i.imgtg.com/2022/10/28/Ra3ES.png
回到如上图所示的节点,如果我给定的默认参数分别为1,2,则首先在“添加额外的参数”下方的选项框里选择第一个参数(1)的数据类型
https://i.imgtg.com/2022/10/28/RaCIN.png
在这个例子里,我们的第一个参数为1,可以是real(float),也可以是int,这里就以int为准。
然后点击右侧的“添加”按钮,就会出现如下所示的情况:
https://i.imgtg.com/2022/10/28/RoMFC.png
这个时候,我们把上面的0修改为1,即可完成第一个参数的设定。
类似地,我们也可以完成第二个参数的设定。完成后如下图所示:
https://i.imgtg.com/2022/10/28/RoORL.png
然后点击“连接”即可,此时,将鼠标悬停在该信号连接上,会弹出如下提示:
https://i.imgtg.com/2022/10/28/Roaji.png
运行程序,控制台将会打印结果“3”。
如果是自动连接,则connect()方法需要按如下格式编写:
connect(<信号名>,<目标节点>,<目标方法名>,)在中括号内对应输入要绑定的默认参数即可。
如果使用自动连接来改写上述示例,则Child的示例如下:
extends Node
onready var a:Node = get_parent() # 引用父节点A
func _ready() -> void:
a.connect("my_signal",self,"_on_A_my_signal",) # 使得父节点A在emit_signal后不带任何参数的情况下而被调用时,目标方法的param1为1,param2为2
func _on_A_my_signal(param1, param2) -> void:
print(param1 + param2)
结果是一样的。
关于信号连接带默认参数的更详细的用法,我们会在高级篇【信号的深入】一篇中讲解。
以上就是本节的全部内容了,下一节我们将会讲到GDScript中的一种重要的数据类型——数组(Array)的用法 本帖最后由 电童·Isamo 于 2023-5-3 22:32 编辑
GDScript其六:数组在前面的几节课的学习中,我们已经初步掌握了编写gdscript的基本操作。那么在本节课和下节课的学习中,我们将会学习GDScript中的两种容器数据类型——数组(Array)和字典(Dictionary)的用法。
本节课我们将开始学习GDScript中数组的用法。
[*]何谓数组
在我们学习数组之前,我们先来看一个例子:
假设我有一系列数据,如下所示:
extends Node
var a1:int = 1
var a2:int = 4
var a3:int = 9
var a4:int = 17
var a5:int = 28那么,我们假设有一个方法foo,需要调用这一系列数据,那么就需要一个个地分行进行调用:
func foo() -> void:
a1 = 2
a2 = 6
a3 = 9
a4 = 18
a5 = 30但是这样一来有一个弊端:假如这一系列数据不是5个,而是20个、50个,甚至更多,甚至你连有多少个都不知道。这个时候,我们就需要用一个【容器】来将这些数据的值依次存入,并且我们要想引用或调用其中的值,我们也只需要通过这个容器来实现即可。这个容器就是数组(Array)
因此,数组就是用来存储一系列数值的容器(Container)
这就好像一行书架,每一本书就是该行书架上的一个内容物。
[*]数组的声明
声明数组的方法不同于声明一般数值,它的声明方法如下:
var my_array:Array = 其中my_array就是你要声明的数组名,给数组赋值时,等号右侧的一系列数值需要放在中括号"[ ]"内,这个中括号就是用来定义这个数组的容纳范围的。凡是在该中括号内的数值,均为该数组的一部分,我们称这些数值为该数组的元素(Elements)。
[*]数组的索引与数组内的元素的访问
有些时候,我们希望知道该数组的某个元素在该数组中的位置,也就是这个元素位于该数组的第几位,这个位置就叫做这个元素在这个数组中的索引(Index)。它是一个int类型的数,表示的这个元素在该数组中的位码。
在访问这个数组中的元素的时候,我们就需要利用这个元素在这个数组中的索引来访问这个元素。
访问的方法如下:
extends Node
var my_array:Array =
# 访问某个数组内的元素时,先引用或调用该数组的名称,
# 然后在其名称后面加上中括号"[ ]",并在括号内输入你要调用的元素所在的索引,
# 索引0表示数组最左侧的元素,即左中括号"["右侧紧邻的这个元素
# 若索引为正整数,则表示第一个元素右侧的第几个元素
# 若索引为负整数,则索引-1表示该数组最右侧的元素,即右中括号"]"左侧第一个元素,
# 小于-1的索引表示最后一个元素左侧第|n|-1个元素,如-2表示最后一个元素左侧第|-2|-1 = 1个元素
var q = my_array
# 引用my_array的第一个元素:1
func _ready() -> void:
my_array = 3
# 调用my_array的第二个元素:2,并修改该值为3,
# 则现在的my_array为
var r = my_array[-1] - 3
# 引用了my_array的最后一个元素4,并在赋值时减去3,故该局部变量r为1因此,按照正常人的思路,我们也就可以这样子调用这个新的数组copy:
extends Node
var my_array:Array =
var copy:Array = my_array # 引用了my_array
func _ready() -> void:
copy = 5 # 调用了copy的第四个元素
print(my_array) # 将原数组打印出来?按照正常的思路,最后打印到控制台上的结果肯定是而不是,对吧?然而实际上,现实却是非常残酷的:
其实这是因为:GDScript中的数组在直接引用自另一个数组时,会进行相互引调绑定(Arrays-link)。也就是说,如果你在声明一个数组的时候直接将其值引用自另一个数组,那么这两个数组就形成了链接关系,对其中一个数组进行修改,就会导致与之相链接的其它数组都会发生对应的变化。
刚才的例子如果我换作
extends Node
var my_array:Array =
var copy:Array = my_array # 引用了my_array
func _ready() -> void:
my_array = 5
print(copy)结果是类似的。
那么该怎么样才能让copy这个数组既能引用自my_array,又不会跟my_array相绑定呢?这时候,我们就需要在引用my_array时复制(Copy)一份该数组,并将该复制出来的数组赋值给copy,才可以使copy既拥有my_array的值,又不会跟my_array相互影响。
要想执行该操作,就需要对my_array调用duplicate()方法:
var copy:Array = my_array.duplicate() # 使用my_array的拷贝以避免与my_array互相绑定这样,上面的例子中两个数组直接就不会相互影响了
利用数组的拷贝来声明数组,我们就可以更加方便、安全地调用这个数组了
[*]数组的运算
同普通数值一样,数组也有其对应的运算,然而,它的运算却又不同于普通数值的四则运算。
由于数组的运算数目较多,接下来就以代码片段的形式来呈现给大家:
extends Node
var my_array:Array =
# 数组的加算/数组的合并
# 将两个数组用"+"连接,其结果就是两个数组的元素融并后的新数组
# 注:两个数组中的相同元素,在加算后不会被融并为一个元素。
var new:Array = my_array +
# new =
# 数组的比较
# 将两个数组用"=="连接,若两个数组内含有的元素完全一致,则结果为true,
# 否则结果为false
var new_test:Array =
var new2:bool = (new == my_array) # 结果为false
var new3:bool = (new_test == my_array) # 结果为true
# 数组的反置
# 对目标数组调用invert()方法,使该数组反向排序
var invert:Array = my_array.duplicate()
invert.invert()
# invert =
# 数组的乱置
# 对目标数组调用shuffle()方法,将该数组内的元素随机重排
var shuffle:Array = my_array.duplicate()
shuffle.shuffle()
# shuffle = 或者或者……
# 数组的减算/数组元素的剔除
# 对目标数组调用remove()方法或者erase()方法
# remove(index:int),将该数组的index索引上的元素剔除出该数组
# erase(value),将该数组中值为value的元素剔除出该数组
var remove:Array = my_array.duplicate()
remove.remove(2) # 将该数组中的第三个元素3剔除,结果为
remove.erase(4) # 将该数组中值为4的元素剔除,结果为
# 数组的扩算
# 对目标数组调用append()/push_back()方法、insert()方法或push_front()方法
# append(value)/push_back(value),向该数组的末尾追加一个值为value的元素
# insert(index:int,value),在索引所指向的元素后面插入一个值为value的元素
# push_front(value),在该数组的第一个元素前面插入一个值为value的元素
var front:Array = my_array.duplicate()
front.push_front(0) # 在该数组的第一个元素1前插入元素0,则结果为
var append:Array = my_array.duplicate()
append.append(10) # 在该数组末尾追加一个元素10,则结果为
var insert:Array = my_array.duplicate()
insert.insert(2,6) # 在该数组的第三个元素3后面插入一个元素6,则结果为
# 数组的放缩
# 对目标数组调用resize()方法
# resize(size:int),以第一个元素为准,将该数组向右进行扩展或从右向左进行收缩
# 若扩展出了原数组中没有的新元素,则这些新元素的值为null
# 若从右向左收缩了该数组,则会从右向左依次剔除这些超出收缩后的数组范围的元素
# 对于扩展数组,可以使用fill()方法对其默认值进行调整。
var zoom:Array = my_array.duplicate()
zoom.resize(7) # zoom =
zoom.fill(1) # zoom =
var zoom2:Array = my_array.duplicate()
zoom2.resize(2) # zoom2 =
# 数组的清空
# 对目标数组调用clear()方法,将该数组内所有元素剔除
var empty:Array = my_array.duplicate()
empty.clear() # empty = []
[*]数组的其它操作
除了上述的运算以外,数组还有一些其它比较实用的操作,我们接下来仍然以代码片段的形式来展示这些常用的操作:
extends Node
var my_array:Array =
# 数组的检索
# 对目标数组调用find()方法/rfind()方法
# find(value,from:int = 0),从索引from所指向的元素开始从左向右依次查找是否含有值value的元素,
# 如果有则返回该元素在该数组中的索引,如果没有或该索引不存在于该数组中则返回-1
# rfind(value,from:int = -1),从索引from所指向的元素开始从右向左依次依次查找是否含有值value的元素,
# 如果有则返回该元素在该数组中的索引,如果没有或该索引不存在于该数组中则返回-1
# 当from为默认值-1时,该方法等同于find_last(value)
func find() -> void:
print(my_array.find(4)) # 结果为3
print(my_array.find(3,1)) # 结果为2
print(my_array.rfind(2)) # 结果为1,等同于find_last(2)
# 数组的含有检测
# 对目标数组调用has()方法
# has(value),如果该数组中含有值为value的元素,则结果为true,否则结果为false
# 等价于:value in <Array>
func has() -> void:
print(my_array.has(2)) # 结果为true
print(1 in my_array) # 结果为true
print(my_array.has(5)) # 结果为false
# 数组的空置检测
# 对目标数组调用empty()方法,如果该数组不含任何元素(即该数组为空数组),则结果为true,否则结果为false
func empty() -> void:
print(my_array.empty()) # 结果为false
# 获取数组中的头元素和尾元素
# 对数组调用front()方法/back()方法
# front()方法用于获取该数组中的第一个元素,而back()方法则用于获取该数组中的最后一个元素
func head_and_end() -> void:
print(my_array.front()) # 结果为1
print(my_array.back()) # 结果为4
# 数组的敲除
# 对目标数组调用pop_front()方法/pop_at()方法/pop_back()方法
# pop_front()将该数组中的第一个元素敲除出该数组,并将其作为返回值使用,同时使调用该方法的数组发生变化
# pop_at(index:int)将该数组中索引为index的元素敲除出该数组,并将其作为返回值使用,同时使调用该方法的数组发生变化
# pop_back()将该数组中的最后一个元素敲除出该数组,并将其作为返回值使用,同时使调用该方法的数组发生变化
func pop() -> void:
print(my_array.duplicate().pop_front()) # 结果为1,此时数组为
print(my_array.duplicate().pop_at(2)) # 结果为3,此时数组为
print(my_array.duplicate().pop_back()) # 结果为4,此时数组为
# 数组的长度/大小
# 对目标数组调用size(),返回该数组所含的元素数
func size() -> void:
print(my_array.size()) # 结果为4
[*]数组的遍历访问
前面我们也只是对一个数组内的某一个数值进行了引用或调用,然而实际上,我们也希望能够一次性对这个数组内的某些或全部元素进行访问。这个时候,我们就需要借助for循环体语法来帮助我们达成这个目的。
利用for循环来遍历访问一个数组内的所有元素的代码如下:
var my_array:Array =
for i in my_array:
<codes>其中关键字in后面必须是你要遍历的数组,且这个方法只能遍历访问其全体元素,要想限制访问一部分元素,你需要配合使用continue关键字或break关键字来控制循环体的流程,这里就还请各位同学自行学习、思考与探究。
下面用一段代码来展示遍历访问一个数组的例子:
extends Node
var my_array:Array =
func _ready() -> void:
for i in my_array:
print(i) # 会将1、2、3、4依次打印到控制台上
[*]数组内元素的数据类型的多样性
实际上,GDScript并不像Java等强类型语言那样对数组内每个元素的数据类型进行硬性限制,因此,你的数组里可以混入其它数据类型的元素:
var my_array:Array = 但在Godot 3.X中,除导出数组外并没有限定数组可容纳的数据类型的语法,因此,在声明一个不固定数据类型的数组的时候一定要注意你所声明的数组所要容纳的数据类型(换句话说,一定要注意你所声明的数组内每个元素其对应的数据类型),否则可能会出现隐患。
[*]导出数组限制元素的数据类型
虽然直接声明在脚本内部的数组在Godot 3.X版本中并不能限制数据类型,但是导出数组却可以限定该数组内元素的数据类型,其语法如下:
export(Array,<数据类型>)var my_array:Array = 此时,该数组内的所有元素都是属于该数据类型的数据,这样,我们就不会让元素因其数据类型错误而导致程序报错了。
[*]高次数组/多维数组
既然数组可以容纳单个数值,那它是否也可以容纳其它数组或者字典等容器呢?答案是可以的。
如果一个数组内又包含了另一个数组,我们就称这个数组是一个高次数组/多维数组(Multi-dimension array),反之则称这个数组是个一次数组/一维数组。
如果是形如
var my_array:Array = [, ]这样含有两层数组嵌套(即第一层是最外侧的[, ],第二层则分别是内部的和)的数组,则我们称之为二次数组/二维数组
类似地,形如
var my_array:Array = [[, ], [, ]]这样含有三层数组嵌套的数组,我们就称其为三次数组/三维数组
出于可读性起见,我建议各位同学在初次写高次数组的时候养成随手换行的习惯。如下面这个例子
var my_array:Array = [, ]如果换行则是这样的视觉呈现
var my_array:Array = [
,
]
# 亦或是
var my_array:Array = [
,
]对于三次数组来说同理
var my_array:Array = [
[
,
],
[
,
]
]
# 亦或是
var my_array:Array = [
[
,
],
[
,
]
]这样就会使开发者更加直观地了解到这个高次数组的架构
[*]高次数组内元素的访问
对于一个高次数组来说,其外层数组中的(某些)元素是数组,因此,如果我们直接按照引调/访问一个一次数组的方法来访问一个高次数组的元素,那么它最终并不会返回其更加深层次的元素,如下面这段代码:
extends Node
var my_array:Array = [
,
]
func _ready() -> void:
print(my_array)
# 返回的是而不是1或者2因此,如果想要调用一个高次数组的内数组的值,则需要这么写:
var my_array:Array = [
,
]
func _ready() -> void:
print(my_array) # 结果为2
print(my_array) # 结果为4
对于高次数组内深层次的元素的访问,需要如下编写:
my_array...其中,中括号对的数量表示访问的次数/维数(Dimension)/深度(Depth),index1表示最外层数组内的对应元素的索引,indexN表示在前面一层数组内对应元素的索引
对于一个二次数组而言,如果其访问的维数为1,则访问到的就是单个数值或数组;如果其维数为2,则其访问到的就是单个数值
对于一个三次数组而言,如果其访问的维数为1或2,则访问到的就是单个数值或数组;如果其维数为3,则其访问到的就是单个数值
[*]高次数组的深层拷贝
前面我们已经学过了一次数组的拷贝,其目的是防止在声明一个数组并使其引用自另一个数组的时候造成这两个数组相互绑定。
但是,对于高次数组来说,只是简单的duplicate()还远远不够,看下面这个例子:
extends Node
var my_array:Array = [
,
]
var my_array2:Array = my_array.duplicate()
func _ready() -> void:
my_array2 = 5
print(my_array)
# 结果是[,]这是因为,GDScript中的duplicate()中如果不写任何参数,则默认只是对该数组进行了浅层复制,其内部的数组(包括后面要讲到的字典),都会与源数组进行相互绑定。
为了防止这一现象的出现,我们需要更加深层地复制源数组。方法很简单,就是向duplicate()方法中输入参数true即可:
extends Node
var my_array:Array = [
,
]
var my_array2:Array = my_array.duplicate(true)
func _ready() -> void:
my_array2 = 5
print(my_array)
# 结果是[,]这样就不会出现上上个例子中出现的问题啦~
[*]高次数组的遍历访问
我们前面学过了一次数组的遍历访问,是使用for循环体来实现的。对于高次数组而言,我们依旧可以使用for循环体来对其内部数组的元素进行访问
var my_array:Array = [
,
]
func _ready() -> void:
for i in my_array:
for j in i: # 因为这个时候我们能确定i就是数组,所以j是可以遍历i内的元素的
print(j) # 将1、2、3、4依次打印到控制台上本质上就是嵌套使用for循环来逐次遍历内部数组的元素。
注:这种方法只适用于纯高次数组,即只有数组作为外层数组的元素的高次数组。如果是有非数组数值的高次数组,这种方法可能会出问题,需要借助is Array来判断是否为数组,进而配合continue关键字来进行流程控制
以上就是关于数组的使用方法了。下一节我们将会学习另一个数据类型——字典(Dictionary)的使用
本帖最后由 电童·Isamo 于 2022-12-11 14:56 编辑
GDScript其七:字典在前面的学习中,我们已经学习了其中一种容器数据类型——数组(Array)的相关知识,并学会了如何声明、访问、操作一个(高次/多维)数组。而今天,我们将会学习另外一个重要的容器数据类型——字典(Dictionary)
[*]何为字典
在开始本节的学习之前,我们先来回顾一下数组的声明相关知识:
我们假设需要声明一个数组array:
var array:Array = 可以看到,这是一个完全由int类型的数所组成的数组,我们称之为int数组。
然而我们总会有这样一个情况:我事先并不知道数组内某个元素在哪里,但是我知道这个元素的数据类型是什么,我希望通过这个数据类型来找到它。可能这个例子与我们今天要讲的有比较大的出入,那我们也可以有这么一个情况:数组本身是有序排列的,但假设我并不知道这个数组是以哪种序列排的,可我就只想要某一个数值。这种情况下,我们不可能保证我们能通过直接访问数组索引的方法来访问到我需要找的那个值,即便我可以通过一些Array内置类型里给定的方法,写个方法来搜,也挺费事的不是嘛。
我们甚至还有比这个更强烈的需求:我希望通过一个给定的名字,来找到这个名字所对应的数值。像这样,将一系列名字和其对应数值相绑定,之后融合到一个数据容器里,这个数据容器就叫做字典(Dictionary)。正如其名,你可以通过一个给定的名字来访问到这个名字所对应的数值。这个名字就叫做键(Key),这个数值就叫做这个键的值(Value)
[*]字典的声明
字典的声明方法如下:
var dictionary_name:Dictionary = {
<body>
}可以看到,字典声明时,其作用域是由一对花括号"{}"构成的,而<body>里的就是对字典内每个键值对(也叫做这个字典的元素)进行的声明。
字典内的元素有两种声明方法:值量法和赋值法。
方法一:值量法
用值量法声明字典如下所示:
var dic:Dictionary = {
"name":value1,
0: value2,
5.6: value3,
Vector2.ZERO: value4, # 只要":"前的数值是内置数据类型所指定的数值,在该方法声明的字典中均视为合法字典键名
}
方法二:赋值法
用赋值法声明字典如下所示:
var dic:Dictionary = {
b = "hello",
c = "how are you?",
t0 = "thanks"
}需要注意的是:只有在用值量声明法声明字典时,才可以将字典中的每个元素的键设为一个非名称数值,如整数、小数、向量等。而赋值声明法声明的字典则需要遵从变量的命名规则命名。
同数组一样,字典内的每个元素均需要用英文逗号","隔开!
[*]字典内某个元素的访问
既然声明了一个字典,那我们肯定是要拿来进行应用的,不然就是白占了一个内存空间(不懂电脑原理的这段话可以忽略)。那么,如何访问一个已经声明了的字典内的元素呢?
访问一个字典内的元素的方法有些不同于访问数组内的元素的方法。根据你声明字典时所选方法的不同,也就有了下面两种不同的字典内元素访问的方法:值索引访问法和成员名化访问法
方法一:值索引访问法
该方法适用于使用值量法声明的字典,操作如下:
extends Node
var dic:Dictionary = {
"b": Vector2.ZERO,
Vector2.ONE: "hi!",
123: 6.8
}
func _ready() -> void:
var e = dic # key_value 为键名/键名值
print(dic["b"]) # 结果为 (0,0)
print(dic) # 结果为"hi!"
print(dic) # 结果为6.8
# 当然,dic中的key_value也可以是其它的数值量
方法二:成员名化访问法
这种方法适用于使用值量法声明的字典(前提是该字典内所有的键均是字符串)以及用赋值法声明的字典。方法如下:
extends Node
var dic:Dictionary = {
b = Vector2.ZERO,
c = 123,
d = "hi",
t1 = "hehe",
}
func _ready() -> void:
var e = dic.value_name # value_name 为键变量名,如果该字典是用值量法命名的,则该value_name就是将键名去掉双引号""后的键名标识符
print(dic.b) # 结果为 (0,0)
print(dic.c) # 结果为123
print(dic.d) # 结果为"hi!"
print(dic.t1) # 结果为"hehe"
[*]字典的声明·混合声明
实际上,GDScript中的字典并没有强制说明在声明时只能使用刚才所给的字典声明方法中的其中一个。因此,你也可以这样声明一个字典:
extends Node
var dic:Dictionary = {
b = Vector2.ZERO, # 赋值法
"c":"hello", # 值量法
Vector2.ZERO:123, # 值量法
e = "hehe" # 赋值法
}那么,这种利用混合声明法声明的字典,在调用时一定要慎重,因为你写的一段代码事先可能并不知晓该字典的声明情况:
func _ready() -> void:
var e = dic.value_name # value_name 为键变量名
print(dic.b) # 结果为 (0,0)
print(dic.c) # 结果为hello
print(dic) # 结果为123
print(dic.e) # 结果为"hehe"因此,我建议初学者最好选择其中一种方法去声明字典。如果你已经有了一定的GDScript基础,且也会声明字典,我也建议尽量择其一而从之,不然因混合声明而带来的不便,如果存续到开发后期,那将会是非常大的隐患。
[*]字典的运算及其操作
同数组一样,字典这个容器数据类型也有其对应的运算和操作,接下来我们以代码片段的形式来介绍之
extends Node
var dic1:Dictionary = {
a = 1,
b = "hello",
c = Vector2.ZERO
}
var dic2:Dictionary = {
a = 1,
b = "hello",
c = Vector2.ZERO
}
func equal() -> void:
# 字典的比较
# 字典不能利用符号进行比较。和数组不一样的是,字典不可以直接利用 == 来确认两字典是否一模一样
# 如果要比较两个字典是否一模一样,则需要按照如下代码进行
var result:bool = (dic1.hash() == dic2.hash()) # 结果为true
# 下面就是直接引用两个字典进行比较,从而造成比较结果不正确
var result2:bool = (dic1 == dic2) # 结果为 false
# 由于可能会涉及到字典内的某个键的值是个字典(即字典嵌套)的情况,你需要使用deep_equal方法来比较两字典的全等性
var result3:bool = deep_equal(dic1,dic2) # 即便dic1,dic2内出现了字典嵌套,只要两字典的结构完全一样,结果就是true
func dduplicate() -> void:
# 字典的复制
# 对一个字典调用duplicate()方法,返回该字典的拷贝副本
# 同数组一样,直接引用一个字典,相当于让新字典和被引用字典进行相互绑定,故一般需要duplicate()
# 同数组一样,duplicate方法默认进行浅层复制,即:若duplicate()方法中不传入参数true,则内层字典会与原字典进行相互绑定
var copy_surface:Dictionary = dic1.duplicate() # 浅层复制,内层字典仍然会与原字典进行相互绑定
var copy_deep:Dictionary = dic2.duplicate(true) # 深层复制,内层字典与原字典隔离开来,不会与原字典进行相互绑定
func merge() -> void:
# 字典的融并/字典的加法
# 对一个字典调用merge()方法,将另一个字典融并到一个字典中
# 传入的第二个参数如果不填,则会将重复键合并为一个键;如果传入true,则不会将重复键合并
var dic3:Dictionary = dic1.duplicate()
var dic4:Dictionary = dic2.duplicate()
dic3.merge(dic2) # 因为合并了重复键,故结果与dic1无异
dic4.merge(dic2,true) # 此时不合并重复键,故结果为dic1中每个键出现两次
func clear() -> void:
# 字典的清空
# 对一个字典调用clear()方法,将该字典清空
var empty:Dictionary = dic1.duplicate()
empty.clear() # 结果为 empty = {}
func size() -> void:
# 获取字典的大小
# 对一个字典调用size()方法,返回该字典含有的键值对(该字典的元素)数
var size:int = dic1.size() # 结果为3
func erase() -> void:
# 字典内元素的擦除/字典的减法
# 对一个字典调用erase()方法,传入该字典要擦除的元素的键名值,擦除该键及其所对应的元素
# 如果对应键存在,则返回true,否则返回false
var ers:Dictionary = dic1.duplicate()
var result:bool = dic1.erase("a") # 返回true,同时dic1中的 a = 1 将被擦除出该字典
func has() -> void:
# 字典的键的存有检测
# 对一个字典调用has()方法,传入要检测的键名值
# 如果含有该键名值所对应的键,则返回true,否则返回false
# 等价于 键名值 in 这个字典
var has_ele:bool = dic1.has("a") # 等价于 "a" in dic1,返回true
[*]字典内元素的遍历访问
同数组一样,字典也可以通过循环体进行遍历访问:
extends Node
var dic1:Dictionary = {
a = 1,
b = "hello",
c = Vector2.ZERO
}
func _ready() -> void:
for i in dic1:
print(i) # 如果直接访问i,则返回的只是键名值
print(dic1) # 这样写则是获取该键名值所对应的键所对应的值考虑到有嵌套字典的情况,且字典内的每个键所对应的值也不一定是字典,因此遍历访问嵌套字典也是比较困难的操作。
但实际上,遍历访问(嵌套)字典这种操作在编写MF游戏时也不是很常用,因此这里就不再教各位同学如何遍历嵌套字典了,各位同学如有兴趣可参考上一节的高次数组/多维数组的遍历访问的方法来研究如何遍历嵌套字典。
至此,我们就学完了两个容器数据类型——数组和字典。下一节我们将学习GDScript中一个重要的操作:setget函数
本帖最后由 电童·Isamo 于 2023-5-3 22:52 编辑
GDScript其八:Setget方法我们学完了两个容器数据类型——数组和字典的基本操作,接下来,让我们把目光聚焦回变量的声明上,来学习今天我们将要学的setget函数
[*]何为Setget函数/方法?
我们仍然以声明一个变量为例:
var a:int = 1假如我们希望在a发生变化的那一瞬间,能够调用一个函数,那么这个函数我们就叫做这个变量a的set函数(Setter Function)。当然,对应地,假如我们希望在该变量a被引用的那一瞬间也能调用一个函数,那么这个函数我们就叫做这个变量a的get函数(Getter Function)
一个变量的set函数和get函数合称这个变量的setget函数(Setter & Getter Function)
[*]Setget函数的声明方法
一个变量的setget函数的声明方法如下:
var a:int = 1 setget set_a, get_a # setget关键字定义a变量的set函数和get函数
func set_a(new) -> void:
a = new # 这里必须将new赋值给a,原因我们会在下面提到
func get_a() -> int:
return a # 这里必须返回a变量,否则会造成无效引用,且返回值类型必须与变量a的数据类型一致一个变量的setget函数需要在该变量赋值的后面由setget关键字所定义,setget关键字后面需要分别写上set函数和get函数的名称(不带括号!)。之后在下方声明这两个函数。
注意,你在setget关键字后声明的两个函数的名字必须与你下面要声明的两个函数要对应一致!
实际上,有些变量可能只有set函数或者只有get函数,这里也一并呈上其对应的声明方法:
extends Node
var a:int = 1 setget set_a # 只有set函数的变量
var b:int = 2 setget ,get_b # 只有get函数的变量,注意不能省略","!
func set_a(new) -> void:
a = new
func get_b() -> int:
return b
[*]Setget函数的触发原理
当你从脚本外的其他地方(如别的脚本、一个节点的检查器)以某种方式,或用self.修改了一个含set函数的变量时,计算机则不会修改该变量,而是会直接执行该变量的set函数,此时被修改后的值会作为该set函数的参数传入这个set函数当中。在set函数中对该set函数所监测的变量进行赋值操作不会再次调用该set函数。执行完该变量的set函数后,计算机就会认为该变量已经被修改过了,因而便不会再去将那个被修改后的值赋给这个变量。这也就解释了为什么你需要在set函数中将参数的值赋给源变量。
https://s1.ax1x.com/2022/11/07/xvEUvn.png
当你从脚本外的其它地方(如别的脚本、一个节点的检查器)以某种方式,或用self.引用了一个含get函数的变量时,计算机则不会立即将该变量的值传到被引用的地方,而是会直接执行该变量的get函数,此时该get函数返回的值将会被传到该变量被引用的地方。执行完该变量的get函数后,计算机就会认为该变量已经完成引用了,因而便把原本要传入的数值给吞掉了。这也就解释了为什么你必须要在get函数中将变量进行返回。
https://s1.ax1x.com/2022/11/07/xvVSIS.png
(勘误:对于上图,应该为:如果是return null,则b = null)
注:当setget函数用于导出变量时,如果该导出变量的值不是初始值,则会触发setget该导出变量所对应的setget函数。此时的setget函数会优先于_ready()和onready执行,这一点一定要注意!
[*]Setget函数的一个应用
在MF的编写中,setget函数的一个重要的应用便是马里奥状态的变更检测:
extends KinematicBody2D
signal suit_changed(new_suit)
export var suit:int = 0 setget set_suit # 玩家状态
func set_suit(new) -> void:
# 一旦玩家状态的变量发生变更,如外部脚本调用或在检查器内修改时,该函数就会被触发
# 或者使用self.suit调用suit变量,也会触发该set函数
suit = new # 将new的值赋给suit,如果没有这一步,则suit永远不会发生改变,除非是脚本内调用,如suit = 2(该脚本内)
emit_signal("suit_changed",new) # 直接将新状态的值作为信号发射出去,供其它节点调用
以上便是setget函数的基本用法,因为setget函数的应用不是很普遍,所以这里只列举一些基本的用法。如果你对setget函数感兴趣,可以在本节学习的基础上进行深入的研究
下节课我们将会学习GDScript中极为重要的一类虚函数:帧步函数
本帖最后由 电童·Isamo 于 2023-2-17 20:00 编辑
GDScript其九:帧步处理学习完了GDScript中的两个重要的容器数据类型——数组(Array)和字典(Dictionary)的操作之后,从本节开始,我们又一次回到了GDScript函数的学习中。不过这次,我们要学的函数,或者说是虚函数,要比之前我们见过的函数都不太一样……
[*]何为帧步处理
在我们学习什么是帧步处理之前,我们先来引入两个概念——帧和步。
我们在看动画的时候,我们人类的眼睛会一直以为我们所看到的东西是连贯的,这是因为我们人眼将视觉信息传递给大脑时是有视觉差的,这种视觉差的导致我们无法捕捉动画的每一瞬间。因此,这一系列的视觉差,就会让大脑自行补正这两个被眼睛捕捉到的视觉信息之间的空白信息,这就是视觉补间(Visual tween)。由于视觉补间,我们在看动画的时候,就会感到十分连贯。而我们人眼在每1/24秒所捕捉到的画面,我们就叫做标准视觉帧(Standard visual frame)。而我们将这1/24取倒数,便是我们人眼的视觉捕捉频率(Frequence of visual capture),其单位为帧/秒。是不是发现这个单位里有一个“帧”?那么,什么是帧(Frame)呢?
我们可以做个小实验:拿出一个小册子,最好全空白的,然后你在第一页上画一个小人,再在第二页上画上这个小人,但这个时候的小人需要比第一页上的那个小人要靠右,再在第三页上再画一个比第二页上的那个小人还靠右的小人……画够一定页数后,把册子翻回第一页,用你的大拇指或大拇指甲盖缓缓拨动页边角(要保证翻页尽量快但不要太快),并注视你所画的小人。你会神奇的发现:你的小人居然动起来了!
你所画的这些每一个小人,就可以称作帧(Frame)。帧,就是动画运行时的每一个定格画面,单位为f。
既然我们学习了帧,那么我们在看动画、玩游戏的时候,都或多或少会涉及到一个与帧息息相关的概念——帧率(Frame rate)
帧率,也叫帧速度,是指单位时间内所展示(专业上叫播放(Play))的帧的数量,单位为f/s,它其实还有一个写法,为我们最常见的fps。对,fps指的就是帧率。
一个动画在播放时,帧率越快,一秒内所呈现的画面也就越多,反之则越少。结合我们刚刚了解的关于人眼的视觉捕捉频率,其实,只要低于24fps,我们的人眼就会明显感到画面运行不流畅,因为我们的人眼每1/24秒捕捉一次画面,而当人眼进行下一次刷新的时候,低于24fps播放的动画还没切换到下一帧时就被人眼又一次捕捉了,随着时间的推移,我们就会觉得这个动画很不舒服。
那如果我们把fps调高一些呢?30fps的动画似乎会比24fps及以下的动画看起来更舒服些,而50fps的动画则会令人基本舒适,60fps的动画则是最丝滑的动画,令人非常舒适,120fps的动画……就未免有些快了点了。
接下来,我们需要扯到另一个概念:步(Step)
实际上,步(Step)就是指计算机持续处理架构基本相同的一系列事件时的时间间隔,换句话说就是:步指的就是计算机执行一系列代码的频率,也就是每隔多长时间才会执行一次,我们在GDScript中所指的步,基本上就是以帧率为单位的步,我们就称之为帧步(Frame-rate-based step),它是帧率的倒数。
我们今后所说的步,如无特殊说明,均指帧步。
(其实这里的步,其全称叫做步长(Step length),只不过是为了方便引出帧步这个概念才这么说的。)
所谓的帧步处理(Frame-rate-based Step Process),就是计算机基于帧步来持续处理这一系列事件的过程(Process)。
[*]GDScript中的帧步处理及其处理函数
在我们学习GDScript的帧步处理前,我们必须要知道一个非常重要的概念:帧线程(Frame thread)。它指的是一个基于某个帧率来持续执行一系列帧步处理的处理通道(由于线程涉及到一点计算机知识,这里我们就姑且理解为处理通道。就是说,一个帧线程,指的是一个基于某个帧率来执行一系列帧步处理的地方,这个地方就像一个通道一样,把一系列代码排在这个通道里依次执行)
Godot中有两种帧线程:空闲帧线程(Idle frame thread)和物理帧线程(Physics frame thread)。我们先来介绍一下这两种帧线程:
-空闲帧线程:又可叫显示器频率帧线程或渲染帧线程(Rendering frame thread),顾名思义,它的处理帧率是根据你的显示器刷新率而定的,比如你的显示器刷新率是60Hz,那么你的帧率就基于60Hz而定。并且,这种帧线程的执行帧率是波动的,意味着它的帧率很不固定,即其步是变动的。还是以这个60Hz的显示器为例,游戏在运行时,该空闲帧线程的帧率会基于这个显示器的刷新率而上下浮动,其步也就随之发生变动。但是,如果启用了V-Sync(垂直同步),则该线程的帧率会尽可能同物理帧线程的帧率相一致,但其波动性依然存在。同时,游戏的渲染都是在这个线程上执行的。
-物理帧线程:它的处理帧率以一个规定的机械频率(即物理帧线程的帧率)而定。物理帧线程的帧率是恒定的,其步自然而然就是固定的。
物理帧线程的帧率可以在菜单栏的项目——物理——通用中设置,如下图所示,物理FPS即为该线程的处理帧率:
https://s1.ax1x.com/2022/11/09/zS7rIP.png
(实际上,物理帧线程的帧率就等价于Clickteam Fusion 2.5中的Frame Rate,如下图所示)
https://s1.ax1x.com/2022/11/09/zS7bzF.png
在GDScript中,负责在空闲帧线程上持续处理事件的虚函数为_process(),负责在物理帧线程上持续处理事件的虚函数则为_physics_process()
这两个函数在写的时候需要带上参数delta:float,这个参数表示处理该线程的步,即对应虚函数所在的帧线程的步。
_process()和_physics_process() 的执行原理(我们也合称为帧步处理原理)如下代码所示,特别注意该代码片段中的注释说明!
extends Node
# 执行对应的帧线程时,场景会从根节点开始由上至下依次对每个有脚本,且脚本里有帧步处理虚函数的节点执行其脚本内所含有的帧步处理虚函数,我们称这个过程为轮询(Circular calling)
# 个人建议把空闲帧线程的虚函数写在物理帧线程的虚函数的上面
func _process(delta: float) -> void:
# 该虚函数表示在空闲帧线程上每隔delta就执行一次其内部的代码,delta表示该虚函数所在的帧线程的步
# 由于空闲帧线程中帧率的波动性,因此,下面在打印出的步【不是】恒定的
print(delta)
# 结果可能为:
# 0.0166666667 第一空闲帧
# 0.0159431515 第二空闲帧
# 0.0166464239 第三空闲帧
# 0.0165321415 第四空闲帧
# ...
func _physics_process(delta: float) -> void:
# 该虚函数表示在物理帧线程上每隔delta就执行一次其内部的代码,delta表示该虚函数所在的帧线程的步
# 由于物理帧线程使用帧率的是电脑被指定的机械帧率,因此,下面打印出的步【是】恒定的
print(delta)
# 结果为:
# 0.1666666667 第一物理帧
# 0.1666666667 第二物理帧
# 0.1666666667 第三物理帧
# 0.1666666667 第四物理帧
# 0.1666666667 第五物理帧
# ...
# Godot中,物理帧会在一轮轮询中优先被执行,执行次数由空闲帧和物理FPS决定。假设物理FPS均是60帧,当空闲帧要求要跑120FPS时,物理帧线程帧步处理函数就会在本次轮询中被执行2次。只有在物理帧步处理函数的执行次数满足了空闲帧的要求之后,才会调用空闲帧线程帧步处理函数,且每次轮询都只会调用空闲帧线程帧步步处理函数【一次】。
# 当场景树中的所有节点均执行完空闲帧线程帧步处理函数_process()后,游戏画面将会被刷新(系统自动调用了强制重新绘制的函数,可以见后面【绘制】一节)
# 之后,计算机从根节点开始往下进行下一轮的轮询,如此反复上述操作。
[*]为什么要学习帧步处理?(注:今后所说的帧要根据代码情况来判断是空闲帧线程上的帧还是物理帧线程上的帧)
有人会问:帧步处理不就是每帧执行一遍其中的代码嘛,有什么用吗?
对于这个问题,我有一个非常简单直白的回答——运动。
对的,既然是每一帧执行一次,那我们肯定就会问自己:有什么事物是持续运作的呢?这答案不就很明显了嘛——运动。
我们在处理任何运动的时候,都是要依靠帧步处理来完成的,如果只依靠我们所学的_ready(),那是远远不够的。
下面一段代码是写在一个Sprite节点上的,请仔细认真研读这段代码,细细揣摩_ready()和帧步处理虚函数的不同:
extends Sprite
func _ready() -> void:
position.x += 2 # 实际上是精灵向右移动了2像素,之后就再也没有移动过
func _process(delta:float) -> void:
position.x += 2 # 实际上是精灵在持续向右移动,移动速度是2像素/步
至此,我们就已经把帧步处理的基本用法给讲完了。实际上,由于其一些性质,我们也要对这种函数的使用加以一定的限制,原因我们会在讲性能优化和SceneTree的时候深究。
[*]帧步处理实践
实践一:会旋转的精灵。
准备一个带贴图的精灵,如下图所示:
https://s1.ax1x.com/2022/11/10/zSqBZ9.png
然后,给这个精灵节点附加一个脚本,脚本内容如下:
extends Sprite
export var rotation_speed:float = PI/12 # 可在该精灵的检查器中进行调整
func _physics_process(delta: float) -> void:
# 由于涉及到旋转以及空闲帧线程的一些性质,为了能够保证结果的稳定性,这里使用物理帧步处理
rotate(rotation_speed) # 这里输入的参数为弧度,表示节点在当前的旋转的基础上再旋转一个角度
按下F6运行当前场景,你会看到:你的精灵旋转起来了!
实践二:会走的精灵
仍然是准备一份精灵节点,赋予其图片,挂载上一个脚本,其内容如下:
extends Sprite
export var speed:float = 3.0 # 可在该精灵的检查器中进行调整
func _physics_process(delta: float) -> void:
# 由于涉及到旋转以及空闲帧线程的一些性质,为了能够保证结果的稳定性,这里使用物理帧步处理
position.x += speed结果就是:该精灵会以给定的速度speed像素/步向右运动
以上就是本节的全部内容了,下节课我们将学习Godot引擎中最为强大的代码——工具代码以及一个新的关键字:tool
本帖最后由 电童·Isamo 于 2023-2-18 16:32 编辑
GDScript其十:tool脚本、绘制图形
上一节中我们学习了有关帧步处理的操作,知道了如何让一段代码在每一帧都执行一次,也就是在游戏的持续期间内不停地执行一段代码。诸如运动、旋转等涉及到位移变换的操作,以及其他一些可能需要让游戏每帧都执行的一些操作,都需要在帧步处理中执行。
出于对工程的安全考虑,我们无法在编辑器中直接运行我们写好的帧步函数及其他方法,但有些情况下,我们就是希望要在编辑器里执行一些帧步处理操作,甚至说,我们希望可以直接在编辑器里预览一些物件在代码中的实现效果。这时,我们就需要学习本节课的一个重点内容了——tool脚本
[*]何谓Tool脚本?
实际上,tool脚本的名称来源于tool这个关键字,它表示这个关键字所在的脚本可以在Godot的编辑器中运行。这个关键字是Godot里最特殊的一个关键字:它在每个脚本中只能存在一个,且必须位于脚本的第一行。
tool # 必须位于此处,且每个脚本中只能存在一个tool关键字
extends Node2D加上这个关键字后,你的代码就可以在Godot的编辑器模式中运行了。
举个例子:
tool
extends Node2D
func _process(delta: float) -> void:
position.x += 1上述代码在写完后,需要点击脚本编辑器中的【文件】——【软重载脚本】才能使其运作,且每次修改完tool脚本中的内容后,你都要这样操作才能在编辑器里运行新修改好的代码。如果你所编写的脚本所附着的节点是场景根节点,且在另一个场景中存在实例,那么你可以不需要上述操作,直接返回这个含实例的场景即可看到最新的效果。
上述代码会使其所附着的Node2D节点每个空闲帧向右移动1像素
需要注意的是,如果你在tool脚本中的代码运行时运行了工程,那么此时该节点的属性并不会还原回其最初的样子。
仍然以上面这段代码为例。假如这个节点的原坐标是(0,0),而我在这个节点运行到位于(320,0)处时测试工程,那么在测试运行时,该Node2D一开始便会在(320,0)处启动,然后继续先向右以1pix/frame的速度运动
除此之外,还需要注意:
[*]在tool脚本中不建议使用onready关键字或_ready()虚函数来导入节点。在tool脚本中,如果必须要导入节点,请使用get_node()或者$语法来获取节点(必要时可配合接下来要提到的一个Engine.editor_hint属性来对其运行范围加以限制)
[*]在tool脚本在执行帧步处理时,请务必对其运行加以限制,因为如果运行帧步处理的节点数量过多,却又不加以控制,则会对你的编辑器性能产生非常大的负面影响。有关这点的原因涉及到帧步处理函数的执行原理,会在高级篇中进行讲解
[*]在tool脚本中执行帧步处理时,请不要print()过长的内容,也不要接连print()含有非空字符串的内容,否则会不停弹出的报错,影响控制台版面
[*]在tool脚本中执行代码导入时,如果其内部存在节点的导入,但是被导入的节点的代码出现问题,此时若相关代码存在于tool脚本中,则下方的控制台将会不停抛出异常。为避免这些异常给您的编辑器的性能带来巨大开销,同时防止这些异常提示刷屏(从而导致潜在的性能开销),建议先将该tool脚本的关键字tool注释掉(即在其前面加【#】),然后再进行相关的修改,最后将这个tool前的【#】去除
[*]限制使用tool脚本
有时候,我们并不希望在游戏运行的时候运行tool脚本,或者在脚本编辑器内,我们也不希望tool脚本内有一部分代码被(实时)执行,这个时候,我们就需要调用Engine单例的editor_hint属性了。它表示当前代码是否是在编辑器模式下运行的,为false则表示在游戏模式下运行。
tool
extends Node2D
func _process(delta: float) -> void:
if Engine.editor_hint:
print("In editor!")
# 在编辑器中请重启一下工程,或按上述方法软重载脚本,然后你会发现控制台上会接续打印出“In editor!”,但是进入游戏后则不会。
else:
print("In game!")
# 调试游戏时你会发现控制台上会接续打印出In game!
[*]绘制图形
要说在哪方面最能凸显tool脚本的强大,我觉得接下来的图形绘制(Drawing)可以算是tool脚本的最佳得力拍档。配合tool脚本,在编辑器中实时绘制一些图形,你就可以随时预览一些物件的效果,是Godot里非常强大的组合功能!
接下来,我们就来一起学习绘制图形。
不过在讲绘制图形之前,我们需要注意:绘制图形操作只能在CanvasItem及其子类节点中执行(即:只有Control类节点和Node2D类节点才可以使用绘制图像操作)!
[*]如何绘制图形
绘制图形需要用到一个虚函数——_draw()。
所有需要进行绘制图形的操作(这里也可以说,所有以draw_开头的函数)都要写入这个虚函数内,否则在执行时会报错,提示:需要将该函数写入_draw()虚函数内
(实际上,draw信号所连接的函数也可以代替_draw()虚函数使用)
extends Node2D
func _draw() -> void:
pass上面就是一个最基础的绘制函数。当我们在编辑器内进行诸如切换场景等会通知绘制的操作时,_draw()函数就会被触发一次,在收到绘制更新通知后,_draw()函数就会执行其内部的代码。
接下来我们看一个绘制圆的代码:
extends Node2D
func _draw() -> void:
draw_circle(pos,radius,color)
上面的代码片段中我们会在距离该节点pos的地方绘制一个半径为radius,颜色为color的圆。
我们将上面的这段代码里draw_circle()的参数进行实例化替换:
extends Node2D
func _draw() -> void:
draw_circle(Vector2(64,64),16,Color.blue)
假设这个Node2D的坐标为(0,0),那么其效果将如下图所示:
https://s1.ax1x.com/2022/12/22/zXJzUP.png
[*]绘制图形的属性继承及其调整
如果我们将这个Node2D的坐标调整为(32,32),那么上图又会发生以下的变化:
https://s1.ax1x.com/2022/12/22/zXYADs.png
上图中粉色点点就是这个Node2D所在的位置
这里我们就要讲解关于绘制时的坐标、旋转以及缩放了。它有点类似于我们在第二节所讲到的“子节点的属性继承”。你可以把绘制出来的东西当作绘制它的Node2D的子Node2D节点,这样子理解的话,你就大概明白绘制的Transform继承了:
在绘制图形的时候,被绘制的图形,其位置,旋转和缩放均会继承调用绘制图形函数的脚本所附着的节点的位置、旋转和缩放,即被绘制的图形的Transform会继承原节点的Transform
我们再举一个例子,这次,我们绘制一个矩形:
extends Node2D
func _draw() -> void:
draw_rect(Rect2(0,0,64,64),Color.aqua)
使用draw_rect()函数来绘制一个矩形。注意:第一个参数中Rect2()数据结构的前两位就是这个矩形的左上角点(也就是基顶点)的坐标,后两位则是该矩形以该基顶点为基础分别向右、向下扩展的距离(单位为pix),之后形成的矩形(上面这段代码片段就声明了一个基顶点位于(0,0),大小为64*64的矩形)。代码结果如下图所示:
https://s1.ax1x.com/2022/12/22/zXYDrd.png
我们将Node2D的旋转修改为15°,结果如下所示
https://s1.ax1x.com/2022/12/22/zXYcIP.png
我们把Node2D的缩放调整为(0.5,0.5),结果如下所示
https://s1.ax1x.com/2022/12/22/zXY2Pf.png
实际上,绘制出的图形并不是像Node2D那样的节点,这使得我们无法直接对其绘制属性进行调节。但是,Godot却提供了一些函数,使得这个被绘制的图形也可以像节点那样调整其位移、旋转和缩放。这其中就有一个函数:draw_set_transform()
它的用法如下:
draw_set_transform(pos,rot,scale)
# pos就是要调整的位移
# rot就是要调整的旋转
# scale就是要调整的缩放这个函数在被执行后,会使其下方的所有绘制函数均以经由该函数所调整后的属性进行绘制。如果绘制图形函数的下方再次出现了draw_set_transform(),则刷新掉之前的属性调整,并使该draw_set_transform()重新对接下来图形绘制的属性进行调整,之后其下方的绘制函数均以这个新的draw_set_transform()函数所调整的属性来执行绘制。
我们看下面这段代码:
extends Node2D
func _draw() -> void:
# Node2D 的坐标为(32,32),旋转为0rad,缩放为1:1
draw_set_transform(-position,PI/3,Vector2.ONE)
draw_rect(Rect2(0,0,64,64),Color.red)
# 第一个draw_set_transform对绘制属性进行了调整:使坐标相对于Node2D而言为-position的位置,旋转相对于Node2D而言为顺时针旋转Π/3rad结果如下:
https://s1.ax1x.com/2022/12/22/zXYORU.png
[*]更新绘制
实际上,这些绘制并不会在tool脚本中被实时执行。还记得前面提到过的吗?
当我们在编辑器内进行诸如切换场景等会通知绘制的操作时,_draw()函数就会被触发一次,在收到绘制更新通知后,_draw()函数就会执行其内部的代码。是的,必须要收到绘制通知之后才能执行一次绘制图形的操作。然而我们有时候希望在编辑器里能够实时预览绘制效果,尤其是像某些物件的轨迹发生变动后还能预览其新的轨迹这样需求,这时我们必须要对图形绘制进行手动且实时的更新(Update Drawing)。
我们使用update()函数来更新绘制。调用update()函数后,update()函数会立即发送一个绘制图形的通知。通常而言,如果需要实时更新绘制的话,我们需要将update函数写在_process()帧步处理函数里。
tool
extends Node2D
func _draw() -> void:
draw_set_transform(-position,PI/3,Vector2.ONE)
draw_rect(Rect2(0,0,64,64),Color.red)
draw_set_transform(Vector2.ZERO,0,Vector2.ONE * 3)
draw_rect(Rect2(0,0,64,64),Color.aqua)
func _process(delta: float) -> void:
update()
保存一下,切回场景编辑器(有时也请重启一下工程),你就会发现:
https://s1.ax1x.com/2022/12/22/zXtaoq.png
我们尝试拖动一下Node2D节点,就会神奇地发现:这个蓝块居然也跟着节点动了起来!
https://s1.ax1x.com/2022/12/22/zXtrSU.png
利用好tool脚本和图形绘制操作,我们就可以充分发挥Godot的这一大优势,来实时监测、调整我们的工程了,非常让人舒心!
关于绘制函数,也请各位同学前往官方doc(见1L)搜索“draw_”以进行更加深入的学习。
以上就是tool脚本和绘制节点的操作。下一节我们将会学习添加、移除和删除节点。同时下一节也将是基础篇的最后一节,希望各位同学能够认真学好最后一节基础课呀~
本帖最后由 电童·Isamo 于 2023-5-3 23:09 编辑
GDScript其十一:添加、移除与销毁节点在前面几节的课中,我们学习了最基础的GDScript编程,知道了什么是类、继承、数组、字典、信号。到后来又学会了如何导入节点,如何实现持续的帧步操作,再到后来,我们学习了tool脚本以及绘制图形。本节课,我们将会学习基础的节点树操作:将一个节点添加到节点树内,将一个节点从节点树内删除,以及将一个节点彻底移除出节点树。
[*]为什么要学习本节?
实际上,我们的项目不可能说是完全静态的。我们在节点篇讲过:Godot里大到舞台、小到敌人、物品,都是节点。既然如此,敌人、物品肯定会有出现、消失的情况,对吧,这实际上就分别对应了节点的加入和节点的移除。如果我们不学习这一节的话,我们就很难将这些敌人、物品添加到舞台,亦或是从舞台中移除,而且就连高级篇中利用代码将场景实例化的操作都将毫无用武之地。这一节既是基础篇的最后一节,又是基础篇和高级篇最重要的过渡章节。
所以请同学们打气十二分精神,一起学习这最后一节基础篇内容吧~
[*]节点树外的节点
我们在学习节点篇学习节点时曾提到:一个场景由根节点及其若干子节点构成,且根节点和这些子节点共同构成了这个场景的节点树/场景树。
然而实际上,节点不一定必须依赖于节点树而存在,它们也可以选择在节点树之外存在。我们就称这些在节点树外的节点为游离节点(Orphan Nodes)。
这个时候就会有同学问了:既然节点都能游离在节点树之外存在,那么它可以在节点树外部执行脚本吗?好问题,我的回答是:看情况。
实际上,对于在节点树外的节点,其_process()方法和_physics_process()方法均会失效,因为如果你利用我们某一节里提到的方法查询了这两个虚函数的话,你会发现它们要想执行其实都有一个共同的大前提:该节点必须位于节点树内。
所以,利用这个特性,我们可以将一部分节点变成游离节点,使其_process()和_physics_process()方法均失效,这在某些情况非常有效。然而实际上,我们还有直接控制这两个虚函数的方法,会让这个操作更为方便,就不在这里进行学习了。
[*]将游离节点加入节点树
我们既然知道了节点可以脱离节点树而存在,那么我们总会希望能够从众多游离的节点中选择一个游离节点,将其加入节点树(或者说得形象点,把这个游离节点挂到节点树上),这就是添加节点(Adding node)操作。
添加节点需要用到add_child()和add_child_below_node()方法,它们俩的用法分别如下:
# 假设该脚本挂载在节点A上
add_child(node,give_it_a_readable_name = false)
# 该函数会将游离节点node加入到该节点A的最下方,并作为该节点A的子节点
# 如果你希望在调试的时候知道这个新加入的节点叫啥名,可以把give_it_a_readable_name设为true
add_child_below_node(node,child_node,give_it_a_readable_name = false)
# 该函数会将游离节点child_node加入到节点node的最下方,并作为该节点node的子节点
# 注:调用该方法的节点不能是节点node它本身!如果是,请考虑使用add_child()方法,见上。
# 如果你希望在调试的时候知道这个新加入的节点叫啥名,可以把give_it_a_readable_name设为true不管你是调用的add_child()方法,还是add_child_below_node()方法,它们都会将新加入的节点放置在其所有子节点的最下方:
https://s1.ax1x.com/2022/12/23/zjgCp4.png
如上图所示,虽然实际上在编辑器模式下不会这样显示,但在程序运行(调试)时,你可以点击节点树编辑器上方的【远程】按钮以进入程序的实时节点树界面
https://s1.ax1x.com/2022/12/23/zjgic9.png图1
https://s1.ax1x.com/2022/12/23/zjgAn1.png图2
注:如图2所示,远程节点树必带一个root原始根节点!
回到图1,图1所示的节点树中,节点A、B为你在编辑器中就已经添加过的节点,节点JustAdded表示节点Node刚刚调用完add_child()方法后,从众多游离节点(以后我们称类似众多XXX的概念为XXX池【pool】)中从拿出来并挂在节点树上作为自己子节点的节点,而节点NowAdded则表示节点Node在添加完节点JustAdded后,调用add_child()时从游离节点池里所添加进来的节点。注意看这两个被添加进来节点的排布顺序与前文对于其被添加时的时间顺序的描述,想一想:是否印证了“将新加入的节点放置在其所有子节点的最下方”这句话。
这样一来,只要游离节点池里有足够多的游离节点,我们就可以让这些游离节点加入到当前的节点树当中,使其成为其中的一部分,不再游离。
[*]将节点树上的节点移出节点树
既然我们学会了如何将一个游离节点从游离节点池中加入到节点树中,那我们自然也要学它的逆向操作——将节点树上的某个节点移出节点树,使其变成游离节点(形象地说就是:把某个节点从节点树上摘除)
我们需要使用到remove_child()和remove_and_skip()方法来对一个节点(的子节点)进行移除,但需要注意:这两个方法同add_child()和add_child_below_node(),它们的调用者(即执行主语【Subject】)是不一样的!
remove_and_skip()
# 将调用该方法的节点移出节点树,并使其成为游离节点
# 同时,该节点原来所含有的子节点都将成为其原父节点的子节点
remove_child(child)
# add_child()方法的逆方法,将该节点的一个子节点从节点树中移出,此时该节点及其子节点都会变成游离节点,但其仍然保留与这些子节点的父子关系
# 注:会使该子节点child的owner变为null接下来我们讲解一下remove_and_skip()方法的原理
https://s1.ax1x.com/2022/12/23/zj29VP.png
我们以上图中的节点ToBeRemoved节点为准,并对其调用remove_and_skip()方法,即:
# 写在Node的脚本里
$ToBeRemoved.remove_and_skip()执行结果如下图所示:
https://s1.ax1x.com/2022/12/23/zj2FPS.png
调用remove_and_skip()方法以后,我们会发现:ToBeRemoved节点已经不见了,而其子节点A、B成为了其原父节点Node的子节点。
注意:游离节点是无法在Godot里查看的,需要调用print_stray_nodes()方法来查看全部游离节点
调用了remove_and_skip()或remove_child()后的节点,会被计算机储存在游离节点池中。
[*]将节点彻底从内存中删除
我们在刚刚的那一小节中学习了如何将节点从节点树上摘除/移除,但还记得上文提到的吗:
调用了remove_and_skip()或remove_child()后的节点,会被计算机储存在游离节点池中。
这里我们需要扯一点计算机小知识:实际上,在程序运行的时候,我们经常提到的内存(Memory)这东西就是用来给程序提供运行空间的,它直接存储程序的各种信息,并接受程序的直接读取与访问。游离节点池实际上就是计算机在内存中给我们的游戏划分的一个小区域,里面的信息就是这个游戏的节点树外的所有游离节点。当然,也包括我们的节点树在内,都是在内存上开辟的一块用于存储游戏信息的区域。节点就是这些区域当中的信息,也就是说,只要内存中还有这个信息,这个节点就还算存在。但是内存并不会那么大度地无限制地给你空间来存储你的游戏信息,一旦你的游戏中有过多的节点(不论它们是否在节点树上),内存就会被这些节点信息撑爆,进而导致内存休克(也就是计算机上讲到的内存泄漏【Memory Leak】)。为了防止我们的游戏因为过多不必要的(游离)节点而被撑爆内存,我们需要对一些(游离的)节点进行彻底的删除,让他们从内存上彻底消失,给内存腾出一片空间。接下来,我们就将学习如何将一个节点从内存上彻底删除(也就是,将一个节点从节点树上摘除后粉碎之)。
这个方法很简单——对一个节点调用queue_free()方法即可
queue_free()
# 让调用该方法的节点从内存中彻底消失,不论这个节点是否在节点树上它类似于Clickteam Fusion中的Destroy动作。
这里需要讲解一下移除节点和删除节点(注意用词的区别!)这两个操作的异同:
[*]相同点:都是将一个节点从节点树中移出的操作。
[*]不同点:移除节点只是将一个节点移出节点树,它侧重于移出这个操作,并不会将其从内存中删除,也就是说,你如果只是移除了一个节点,那么就意味着你还可以把这个被移除出去的节点通过一些手段再加回来。而删除节点会将这个节点从内存中彻底删除,它更强调删除这个操作,会将节点的信息从内存中抹去,并将内存中对应的位置腾空以存储其他游戏信息。删除了一个节点以后,你是没有任何办法再将它加回节点树的!
然而我们会发现,这个方法名的前面有queue这个单词,可能就会有好奇的同学查了查这个单词的意思——排队。欸?这么说节点是排队删除的?
可以这么理解,但实际上,节点的删除机理与我的这个帖子中关于CTF中Destroy延迟销毁的机理十分类似。Godot程序会将调用了queue_free()的节点储存在一个列表当中,并在程序的空闲帧(不懂的可以倒回去看帧步处理那一章复习一下)结束、开始渲染画面时再将这些节点删除掉。实际上,我们也可以调用对任何对象(包括节点)都通用的free()方法,它会立即将调用该方法的对象(节点)删除掉,而不是将其排在一个列表里,在空闲帧的下一帧开始时删除。因此,为了防止节点被删除后,有别的脚本导入了这个节点将导致程序报错,Godot要求对于节点最好要使用queue_free()方法,这样会让你的程序更加安全,也更加易于维护。
以上就是所有GDScript基础篇的内容了。非常感谢各位同学这么长时间来的学习与支持。我们的GDScript基础篇虽然要与我们说再见了,但并不意味着我们就此完成了对GDScript的学习。俗话说,对于一个事物的学习是永无止境的,我们能做的只有不断地钻研与发现,而不能仅仅止步于我们仅有的学习资源。学习不是老师牵着鼻子甩着鞭子赶出来的,而是自己凿壁借光、刻苦努力拼出来的。所以,我们只是取得了阶段性的成就。接下来,我们还会插入几篇数学篇,以及更加重要、更加深奥的高级篇的学习。也希望各位同学能够继续努力,争取早日利用你的所学来创造出属于你自己的游戏吧!
本帖最后由 电童·Isamo 于 2023-3-9 18:09 编辑
GDScript数学一:矩阵与坐标系变换我们学完了GDScript的所有基础篇的内容,接下来,在讲解更加深奥的高级篇之前,我们需要补充一些数学营养,给各位讲解简单的线性代数和随机数及其在Godot(GDScript)中的操作原理。本系列教程需要你学过初高中的有关代数和几何的知识(包括但不限于函数、平面几何、平面向量、任意角的三角函数、极坐标、参数方程等,如果高中有选学过4-2矩阵的那可以不用学本节关于变换矩阵的知识了,直接看如何在godot里实操吧)。如果你已经学习过线性代数和随机数的相关理论,但不会在Godot中操作它的话,你可以直接跳过基本讲解,直接查看其在Godot中的操作方法。但如果你既学过线性代数和随机数的相关理论,又知道如何进行操作的话,那么恭喜你,可以静待高级篇了(嘿嘿,你挺厉害的嘛)
那么我们本系列的第一个话题就是——矩阵与坐标系变换。
[*]平面坐标系和二阶矩阵
在讲解矩阵和坐标系变换之前,我们需要重新认识一下我们的坐标系:
我们在初高中学习坐标系的时候,用的是平面直角坐标系,这个坐标系有如下特征:
[*]两坐标轴(数轴)相互垂直
[*]两个坐标轴的单位均为1
我们在高中学习平面向量的坐标表示时,又学到了基向量,对于平面直角坐标系来说,x轴的基向量就是i = (1,0),y轴的基向量就是j = (0,1)。同时我们也知道,对于平面直角坐标系上的任何一个向量,我们都可以用一对不共线基向量来表示这个向量。比如a = (4,3),那么他就等价于a = 4i + 3j。
好了,我们就复习到这里,接下来我们思考这样一个问题:假如我想要一个新的坐标系,这个坐标系有以下两个特点的任意一个或全部:
[*]两坐标轴不一定需要垂直
[*]每个坐标轴的单位不一定非得是1,而且两个坐标轴的单位长度可以相等,也可以不相等
如果你还记得基向量的话,那么我觉得我们可以利用基向量来搞一下这个新的坐标系。
假如说x轴的基向量i = (1,2),y轴的基向量j = (-1,2),那么这两个向量的点乘为1*(-1)+2*(-2)=-5 < 0,说明两基向量互不垂直,也就是这两个坐标轴互不垂直。我们看一下单位长度:|i| = √(1^2 + 2^2) = √5,|j| = √((-1)^2 + 2^2) = √5,说明两坐标轴都不以1为单位长度。那么,在这个坐标系下,我还可以表示a= (4,3)么?答案是可以的,只不过我们需要按照这个坐标系的基向量进行处理。实际上,对于平面内任何一个向量,我们都可以用一对不共线的基向量来表示。我们既然有了一对基向量,那么a = 4i + 3j这个表示依然是可以的,然而它的意义就与表示在平面直角坐标系中的意义完全不一样了,因为这是a在这个新的坐标系中的坐标,而非平面直角坐标系中的坐标如果说我们的平面直角坐标系是这样的:https://s1.ax1x.com/2023/01/04/pSFNsTx.png
那么这个新的坐标系就是这样的(相对于平面直角坐标系而言):https://s1.ax1x.com/2023/01/04/pSFNHtf.png
实际上,平面上的任意一个向量a = (x,y)还可以这样表示:https://s1.ax1x.com/2023/01/04/pSFU13D.png
也就是可以表示成纵向排列的形式,这就是列向量(Column vector)。那么,平面直角坐标系中的那一对向量就可以分别表示为:https://s1.ax1x.com/2023/01/04/pSFUv8O.png
其中i表示x坐标轴上的基向量,j表示y坐标轴上的基向量。但是这样表示起来还是有点废墨水,那我们不妨直接这样合并起来写:https://s1.ax1x.com/2023/01/04/pSFaEPf.png
像这样,用一个大方括号将一对向量的列向量表示拼合在一起,就叫做一个二阶矩阵(2x2 Matrix),用大写英文字母表示。由于这个矩阵中的两个列向量是一个坐标系的一对基向量,因此这个矩阵也叫做这个坐标系的变换矩阵(Matrix of transformation)。如无特殊说明,下文中的矩阵均指对于某个坐标系的变换矩阵(简称某个坐标系的变换或某个坐标系的矩阵,也可以简称某个坐标系),且下文中的所有变换均与矩阵相关。由于本贴不支持对矩阵的直接编辑,因此以来表示一个二阶变换矩阵,它就等价于:https://s1.ax1x.com/2023/01/04/pSFdYkt.png
我们在刚才就顺带学习了平面直角坐标系的变换矩阵,其实就是:https://s1.ax1x.com/2023/01/04/pSFaEPf.png
那么,在开头所举的那个例子中,我们列举了两个基向量:i = (1,2),j = (-1,2)。这对基向量所形成的新的坐标系的变换矩阵就应该是:https://s1.ax1x.com/2023/01/04/pSFdo7R.png
一定要注意:每个二阶矩阵的列m表示一个基向量im,行n表示这个向量在维度n上的坐标分量。简记为:行维列向。(实际上,数学上也可以用一横行表示一个基向量,也就是与列向量相对的行向量【Row vector】(实际上是列向量的转置),即:https://s1.ax1x.com/2023/01/04/pSFwYDJ.png
但是我们这里规定以列向量表示基向量的形式为标准。因为一个是Godot就是这样考虑的,再一个也是因为我们实际上用的最多的还是列向量,行向量可以转置成列向量,而且这也利于我们后面要讲的矩阵运算,所以我们只能规定以列向量为基向量的表示形式
)
[*]应用二阶矩阵变换
实际上,一个平面上的任何一点的坐标,都可以看成是原点指向该点的向量,如在平面直角坐标系中,一个点A的坐标为(3,2),那么它的坐标用向量就可以表示为a = (3,2),方向从原点O指向点A那么接下来,我们就可以回顾一下上一节中那个向量(4,3)的问题。如果说它表示在平面上的一点A的坐标,那么点A在平面上的坐标那就是(4,3)无疑。但如果我把它换到上节中的这个坐标系里:https://s1.ax1x.com/2023/01/04/pSFdo7R.png
这个点在新坐标系中的坐标依旧是(4,3),可它又该如何在原先的平面直角坐标系中被表示出来呢?我们先不妨把这个坐标系的变换矩阵记作A,把这个点A的坐标向量记作x,把它在新坐标系中的(4,3)用平面直角坐标系坐标表示的向量记作y,那么我们就可以这样表示这个向量y:y = Ax实际上,上节讲到的所谓列向量,本质上就是一个列数为1,行数为2的矩阵,因此,上述运算我们就叫做矩阵乘法,只不过这里是矩阵乘上一个列向量,它表示在变换A下的向量x,在平面直角坐标系中的坐标为y。也就是说,即便在新的坐标系中,点A的坐标依旧是(4,3),但在新坐标系的影响下,其在平面直角坐标系上的坐标就会发生变化,而向量y所表示的正是变化后的点A在平面直角坐标系上的坐标。https://s1.ax1x.com/2023/01/04/pSFDF9x.png
上图就表示了A点的变化,其中带下角标0的均表示在平面直角坐标系上的情况。可以看出,点A在应用了A变换以后,进入了新的坐标系,虽然在新的坐标系中的坐标依旧是(4,3),但是在原来的平面直角坐标系中,这个点A的坐标就不一样了。矩阵乘向量,实际上就是求在平面直角坐标系下某个向量经由某个矩阵A的变换后的向量在平面直角坐标系中的表示。说白了就是,矩阵乘向量,就是用平面直角坐标系的坐标去表示别的坐标系的坐标。那么,我们知道了矩阵乘向量的几何意义,可怎么运算呢?矩阵乘向量的算法如下:https://s1.ax1x.com/2023/01/04/pSFDqVH.png
注意:矩阵乘向量是有顺序的,矩阵必须位于向量左侧,它表示对其右侧的向量进行该矩阵的变换因为我们说过,平面上的任何一个向量都可以表示成一对基向量相加的形式,那么在这里就是说,原向量的x分量乘上这个坐标系变换A的第一列向量(也就是该坐标系A的x轴的基向量)加上原向量的y分量乘上这个坐标系变换的第二列向量(也就是该坐标系A的y轴的基向量),则该向量在坐标系A中仍可以以(x,y)的形式表示,但是在平面直角坐标系中,就必须以x(a,b)+y(c,d)的形式来表示,因为变换A的这对基向量也是用平面直角坐标系的坐标来表示的。那么,我们就可以求出A点在坐标(4,3)保持不变的情况下,在坐标系A中时其在平面直角坐标系中的坐标了:https://s1.ax1x.com/2023/01/04/pSFrYz6.png
[*]二阶矩阵相乘
既然矩阵可以乘向量,那么矩阵是否可以乘矩阵呢?答案是可以的:设A、B是两个坐标系的矩阵,则两矩阵相乘(A×B)的算法如下:https://s1.ax1x.com/2023/01/04/pSFshjK.png
实际上,两个矩阵相乘,其几何意义就是让右边的变换B的坐标系映射在左边的变换A的坐标系中,用人话讲就是,把右边的坐标系B转换到左边的坐标系A中。我们在学习Node2D的属性继承的时候,实际上就是父节点的变换乘上了子节点的变换(当然更严格的来说,是子节点的仿射变换应用到父节点的仿射变换中去了,等会儿会讲到)。
[*]向量(矩阵)的平移、旋转、缩放和剪切矩阵(仿射变换)
我们既然可以利用二阶矩阵对平面直角坐标系上的任意一个向量进行变换,那么接下来就让我们一起学习一些特别且常用的二阶矩阵。- 平移矩阵实际上,我们也不一定非得让新的坐标系A的原点定死在跟平面直角坐标系的原点O一样的位置,我们甚至可以让点坐标系A的原点O'离开O,放在平面上的其他地方:https://s1.ax1x.com/2023/01/04/pSFc80O.png
上图中绿色的坐标系就表示这个放荡的坐标系A。但这该怎么用矩阵表示啊?其实,我们不妨给这个矩阵再添加一个附属的列向量b,使b所表示的就是坐标系A的原点在平面直角坐标系上的位置,如下所示:https://i.328888.xyz/2023/03/09/o4qbN.png
其中,(x,y)为要被变换的点的坐标,(Δx,Δy)就表示新坐标系A的原点O'在平面直角坐标系下的坐标如果将一个点A(2,1)变换到坐标系B+(3,2)中,那么这个点A在平面直角坐标系中的坐标就是https://i.328888.xyz/2023/03/09/oAVCb.png
- 旋转矩阵
我们知道,在平面直角坐标系下的矩阵为https://s1.ax1x.com/2023/01/04/pSFaEPf.png
那如果说,我把标准直角坐标系逆时针旋转90°,那么这个矩阵就成了:https://s1.ax1x.com/2023/01/04/pSF28Fe.png
这样的话,一个向量(或者也可以考虑为一个点)也就绕着原点跟着逆时针旋转了90°那如果说我再逆时针旋转90°呢?https://i.328888.xyz/2023/03/09/oA2Ly.png
再逆时针旋转90°试一下?https://i.328888.xyz/2023/03/09/oAbR8.png
你发现了什么?你是不是感觉x轴的基向量是按照(cost,sint)这种模型而变化的,而y轴的基向量是按照(-sint,cost)的模型而变化的呢?那么接下来,我们就可以将这个猜想进行验证:假如说一个坐标系A是绕着点O旋转θ(不论角度弧度)得到的,那么这个坐标系应该如下所示:https://s1.ax1x.com/2023/01/04/pSF2sYQ.png
显然,基向量i与x轴的夹角就是θ,基向量j与基向量i垂直,故基向量j与x轴的夹角就是θ+π/2。于是,这个新的坐标系A的一对基向量的坐标也就是i = (cosθ,sinθ),j = (cos(θ+π/2) = -sinθ,sin(θ+π/2) = cosθ)那么我们也就得出了旋转矩阵的表示,即https://s1.ax1x.com/2023/01/04/pSF2oY4.png
- 缩放矩阵
我们仍然以平面直角坐标系的矩阵为例
https://s1.ax1x.com/2023/01/04/pSFaEPf.png
我们知道,现在两个轴的单位长度都是1,那假如我想让其中一个轴或者两个轴的单位长度发生变化,那么我们就需要对相应基向量进行数乘放缩,也就是
https://s1.ax1x.com/2023/01/04/pSFRCpd.png
假如有一个向量a = (1,3),那么在上述变换下,这个向量a在平面直角坐标系中的坐标就是(m,3n)
当m或n为负数时,表示该坐标系与原坐标系在x轴或y轴上基本呈现出反转态势(放在图像上就是:新图像看起来是原图像被水平镜像或垂直镜像后再被水平或垂直缩放至给定的大小)。特别地,当m=-1时,原向量关于y轴对称;当n=-1时,原向量关于x轴对称,当m,n均为-1时,原向量关于原点中心对称
- 剪切矩阵
实际上,我们也可以让一个本来看起来挺正常的一个向量被横向滑移或者纵向滑移成更加扁平的向量,这就是剪切变换(Shear),用矩阵表示就是:
https://s1.ax1x.com/2023/01/04/pSFR6AO.png
其中变换A表示水平方向上滑移剪切,变换B表示在竖直方向上滑移剪切
坐标系的变化也如下所示:
https://s1.ax1x.com/2023/01/04/pSFRR9H.png
纵向滑移剪切的坐标系还请各位同学自行脑补。
[*]矩阵变换与图像处理
我们都知道,一张图可以被分解成若干个像素,而这些像素呢,我们又可以看成是一个个点。前面我们又说了,一个点可以用从原点出发指向这个点的向量表示。因此,图像的平移、旋转、缩放和剪切,本质上就是这些点参与进了矩阵变换当中。
然而需要谨记的一点是:在绝大多数计算机图形处理中,平面上的y轴的正方向永远都是向下的,也就是说,其基向量j = (0,1)表示的是向下1个单位长度而不是向上一个单位长度
[*]在Godot中应用向量和变换矩阵
说了这么多,那么在godot中,我们又该如何操作向量和矩阵呢?
- 声明向量和矩阵
我们在学习GDScript基础的时候就已经接触过两个数据类型了:Vector2和Transform2D,实际上,它们分别就是二维向量和二维线性变换矩阵
接下来,我们一起来学习一下这两种数学量的声明:
# 声明向量
var vector:Vector2 = Vector2() # 声明一个(0,0)的向量
var vector_zero:Vector2 = Vector2.ZERO # 同上,只是用了枚举声明法
var vector_left:Vector2 = Vector2.LEFT # 声明一个(-1,0)的向量
var vector_right:Vector2 = Vector2.RIGHT # 声明一个(1,0)的向量
var vector_up:Vector2 = Vector2.UP # 声明一个(0,-1)的向量,再次强调:Godot中y轴正方向向下
var vector_down:Vector2 = Vector2.DOWN # 声明一个(0,1)的向量
var vector_one:Vector2 = Vector2.ONE # 声明一个(1,1)的向量
var vector_inf:Vector2 = Vector2.INF # 声明一个(+∞,+∞)的向量,一般不推荐用
var vector_any:Vector2 = Vector2(m,n) # 声明一个(m,n)的向量
# 声明矩阵
var transform:Transform2D = Transform2D() # 声明一个的变换矩阵
var transform_normal:Transform2D = Transform2D.IDENTITY # 同上
var transform_flipx:Transform2D = Transform2D.FLIP_X # 声明一个[-1 0 0 1]的变换矩阵
var transform_flipy:Transform2D = Transform2D.FLIP_Y # 声明一个的变换矩阵
var transform_any1:Transform2D = Transform2D(m,n,o,p,q,r) # 声明一个+的变换矩阵
var transform_any2:Transform2D = Transform2D(Vector2(m,n),Vector2(o,p),Vector2(q,r)) # 同上,不过使用三个向量来表示的- 向量操作函数
我们光知道如何去声明变量还是远远不够的,实际上,我们更希望能够操作这些向量。下面以表格的形式来给各位同学讲解向量的操作:
在讲解之前需要厘清几个概念:
[*]原向量:调用Vector2中相关方法的向量,如var t:Vector2 = s.abs()中,s就是原向量
[*]Godot的2D坐标系与CTF一样,都是Y轴正方向向下,但与CTF不同的是:Godot中任意角的正方向是顺时针而非逆时针。
方法名 返回值类型(若无void) 用途说明
abs()Vector2返回一个向量,其x、y分量分别为原向量的x、y分量的绝对值
angle()float返回原向量与X轴正半轴形成的夹角的弧度
angle_to(to: Vector2)float返回从原向量起旋转到向量to后所旋转过的角的弧度,这个角度满足任意角的正负性
angle_to_point(point: Vector2)float返回原向量所表示的点到点point所连的线段与X轴形成的夹角的弧度
aspect()float返回原向量的x、y分量的比值,即x/y
bounce(n: Vector2)Vector2返回原向量关于法向量n的反射向量,即以法向量n所在直线为入射光线在镜面上反射的法线,入射光线(原向量)满足光的反射定律,返回的就是这个反射出去的光线(反射向量)。法向量n的共线方向不影响最终结果
ceil()Vector2返回一个向量,其x、y分量分别为原向量的x、y分量被向上取整后的整数值
cross(with: Vector2)float返回原向量与with向量的叉乘(向量积)向量的z坐标。注意:在二维坐标系中的叉乘是无法被表示出来的,因此,这个结果返回的是假设在三维坐标系内位于平面xOy的两向量叉乘后得出的向量。但这个叉乘出来的向量必然是在z轴上的,因此前面说的是返回这个在z轴上的向量的z坐标而非其三维向量表示。且对于二维平面坐标系内两向量叉乘所形成的“伪向量”的“伪z轴”,我们规定:以出屏幕的方向为正,入屏幕的方向为负。
cubic_interpolate(b: Vector2, pre_a: Vector2, post_b: Vector2, weight: float)Vector2返回一个向量,以pre_a和post_b为句柄,在这个向量和b之间进行三次插值,并在weight位置返回结果。weight的范围是0.0 到 1.0,表示插值的量。(本说明直接转自doc)
direction_to(to: Vector2)Vector2返回一个向量,为原向量指向目标向量to的单位向量
distance(_squared)_to(to: Vector2)float返回原向量到目标向量to的距离(的平方)(方法名的括号内的内容对应说明里的括号的注释)
dot(with: Vector2)float返回原向量与向量with的点乘(数量积)
floor()Vector2返回一个向量,其x、y分量均为原向量的x、y分量向下取整后的结果
is_equal_approx(with: Vector2)bool判定:若原向量与向量with近似相同(即原向量的x分量和y分量均与向量with的x分量与y分量分别对应近似相等),则结果为true
is_normalized()bool判定:若原向量为单位向量,则为true
length(_squared)()float返回原向量的模长(的平方)(方法名的括号内的内容对应说明里的括号的注释)
limit_length(length: float = 1.0)Vector2返回一个向量,使其模长不超过length。如果不输入参数length,则使返回的向量的模长不超过1.0
linear_interpolate(to: Vector2, weight: float)Vector2返回一个向量,其结果为原向量到向量to的线性插值,插值量为weight,其范围为
move_toward(to: Vector2, delta: float)Vector2返回一个向量,其结果为原向量以delta长度尝试移动到向量to后的(过程)向量。该方法不会导致向量“移动过头”。若delta为负值,则会让向量向远离向量to的方向移动。
normalized()Vector2返回原向量的单位向量
posmod(mod: float)Vector2返回原向量各分量与mod分别经fposmod()方法运算后得到的新分量组成的向量
posmodv(modv: Vector2)Vector2返回原向量各分量与向量modv的各分量分别经fposmod()方法运算后得到的新分量组成的向量
project(b: Vector2)Vector2返回原向量在向量b上的投影向量,即acos<a,b>(注意运算顺序!)
reflect(n: Vector2)Vector2返回原向量在向量n所在直线上镜面反射后的向量。注意:与bounce()不同,reflect()实际上是将原向量看作入射光线,将向量n所在的直线看作镜面,返回值为该入射光线的反射光线。向量n的共线方向不影响最终结果
rotated(angle: float)Vector2返回原向量旋转angle弧度后所得的向量
round()Vector2返回一个向量,其x、y分量分别为原向量的x、y分量被四舍五入取整后的整数值
sign()Vector2返回一个向量,其x、y分量分别为原向量的x、y分量的符号值(即正数为1,负数为-1,0为0)
slerp(to: Vector2, weight: float)Vector2返回一个向量,其结果为原向量到向量to的球面插值,插值量为weight,其范围为。注:两向量必须都是单位向量
slide(n: Vector2)Vector2返回原向量经法向量n处理后得到的在法向量n所在平面上滑动的向量
snapped(by: Vector2)Vector2返回一个向量,其x、y分量分别为原向量的x、y分量分别最接近by的x、y分量的整数值
tangent()Vector2返回原向量逆时针旋转90°后的向量
- 二维变换矩阵操作函数
接下来,我们来看看矩阵都有哪些操作方法吧!
需要事先声明的是:下文中如无特殊说明,变换一词均代指二维变换矩阵
方法名 返回值类型(若无则void) 用途说明
affine_inverse()Transform2D返回该仿射变换的逆变换,包含平移、旋转和缩放
basis_xform(v: Vector2)Vector2 将向量v在矩阵中进行一次变换,然后返回之,无视该变换的平移向量
basis_xform_inv(v: Vector2)Vector2先求该变换的逆变换(无视该变换的缩放),然后再用该逆变换去对向量v进行变换,最后返回之,无视该变换的平移向量
get_origin()Vector2获取该变换的平移向量,即该变换所指代的坐标系原点
get_rotation()float获取该变换的旋转角弧度,即该变换所指代的坐标系相对于标准平面直角坐标系的旋转角弧度
get_scale()Vector2获取该变换的缩放,即该变换所指代的坐标系的i、j基向量
interpolate_with(to: Transform2D, weight: float)
Transform2D
返回该变换到变换to经线性插值后的结果,插值量为weight,范围为0.0 到 1.0
inverse()Transform2D
求该变换的逆变换,只包含平移和旋转
is_equal_approx(trans: Transform2D)bool对该变换的每个分量与变换trans中的对应分量分别进行is_equal_approx()运算,如果总运算结果表明两变换大致相似,则返回true
orthonormalized()
Transform2D
返回该变换逆时针旋转90°后的变换
rotated(angle: float)Transform2D
返回该变换旋转angle弧度后的变换
scaled(s: Vector2)Transform2D
返回该变换中各基向量分别放缩s.x、s.y倍后所构成的新的变换。注意:在x轴上的负scale会被godot认为是在y轴上的-1倍缩放后再让变换旋转180°
translated(offset: Vector2)Transform2D
返回该变换相对于其旋转和缩放而言的偏移量,即该偏移会受旋转和缩放的影响
xform(v: Vector2)Vector2将向量v在该矩阵中变换后返回出去,会受到该矩阵的平移向量的影响
xform_inv(v: Vector2)
Vector2
先求该变换的逆变换(无视该变换的缩放),然后再将向量v在该逆变换中进行变换后返回出去,会受到该矩阵的平移向量的影响
- Node2D(Control也算,如无特殊说明,下文均只以前者为例)类节点与坐标变换
我们在学习节点的时候,曾经稍微提及过:Node2D及其派生节点是可以继承父节点的变换的。然而当时我们并没有学习变换,也不知道是个啥,只知道Node2D可以继承父节点的坐标、旋转和缩放。实际上,Node2D在作为一个节点的子节点以后,就会自动继承这个父节点的变换。这些我们已经在讲节点的那章学过了,就算带各位同学再复习一下。
可是,子Node2D节点是如何继承父Node2D节点的变换呢?
实际上,Node2D含有三个与变换有关的参数:position、rotation和scale,分别对应了该节点在平面直角坐标系上的仿射变换的平移、旋转和缩放。用人话说就是,每一个Node2D及其派生节点,它自己的变换就可以看作一个用矩阵+向量来表示的坐标系。
一个Node2D的变换的构成是:+position
因此,你在Node2D(如无特殊说明,Node2D可包括其派生节点在内)的检查器的Transform一项下看到的:
https://s1.ax1x.com/2023/02/18/pSLr12D.png
实际上就对应了上面蓝色字所表示的变换,即该Node2D的变换
那么,假设A、B均是Node2D,且B是A的子节点,那么B的变换就可以用代码表示为
B.global_transform = A.global_transform.translated(B.position).rotated(B.rotation).scaled(B.scale)上述代码表示,B的变换等价于先相对于A的变换位移position,然后再相对其旋转rotation弧度,再相对其缩放scale倍。也就是说,B节点的变换是相对于其直接父节点A而言的,这个变换就叫做节点B的局部变换(Local transform),用transform(Transform2D类型)这个属性表示,同时,position、rotation和scale这三个属性也是构成节点B的局部变换的成分,分别叫做节点B的局部坐标、局部旋转和局部缩放。当然,随着我们节点树的越发复杂,我们往往还是会回归到一个绝对的变换系里,这个绝对的变换系通常也就是我们的标准平面直角坐标系。Godot中也给出了相对于标准平面直角坐标系的变换——全局变换(Global transform),用global_transform(Transform2D类型)这个属性表示,其扩展了三个属性:global_position、global_rotation和global_scale,也分别与position、rotation和scale一一相对,分别叫做这个Node2D节点的全局坐标、全局旋转和全局缩放。
在编辑器界面的2D编辑界面下,你可以看到屏幕上的两条带颜色的直线:
https://s1.ax1x.com/2023/02/18/pSLroM4.png
这两条直线就是Godot中2D模式下的标准平面直角坐标系的坐标轴,其交点就是这个标准平面直角坐标系的原点。
Node2D节点的变换还具有以下几个性质:
[*]如果一个Node2D节点是一个场景的根节点,那么其全局变换就等价于它自身的局部变换
[*]如果一个Node2D节点是一个场景的根节点,且该根节点的局部变换无偏移、无旋转且缩放比例为1:1,则其直接子节点(注:必须是直接子节点,不可以是孙节点这种间接子节点!)的全局变换就等价于这个子节点的局部变换
假设A、B、C三个节点均是Node2D,且B节点是A节点的直接子节点、C节点是B节点的直接子节点,若A的局部变换没有发生任何变化且位于(0,0),那么我令B的局部坐标为(1,1),C的坐标也为(1,1),则C相对于A的坐标就应该为(2,2)而非(1,1),但B相对于A的坐标就等于(1,1)。同时,C的全局坐标也应该是(2,2),B的全局坐标也为(1,1)。此时我若令A的坐标为(1,1),则B、C的局部坐标仍然分别为(1,1)和(2,2),但是C的全局坐标却变为了(3,3)、B的全局坐标却变成了(2,2)、A的全局坐标为(1,1)。这便是举例来说明全局坐标和局部坐标的关系及推式,至于全局旋转和局部旋转的关系以及全局缩放和局部缩放的关系,也可以类比这个例子来推出,然而需要注意的是:缩放叠加使用的算则不是加算而是乘算!
- Node2D类节点的坐标系转化
上一小节最后的例子或多或少地在暗示我们:Godot中的变换处理并没有想象中那么简单!我们不但要知道局部变换与全局变换的关系,还需要掌握这局部变换->全局变换、全局变换->局部变换,以及★任意变换A->任意变换B★这三种变换的相互转化。学习如何转化是很简单的事情,但何时去用、如何去用,这便是使用Godot时最大也是最困难最棘手的挑战,需要我们去在实战中面对。接下来我们就简单地学习一下如何转化坐标系(变换的转换)。
局部 -> 全局
我们假设根节点的变换为:
https://s1.ax1x.com/2023/02/20/pSXE8xJ.png
此时,根节点下有一直接子节点,如下图所示:
https://s1.ax1x.com/2023/02/20/pSXE324.png
我们要想获得它的全局变换,其实也很简单:只需要获取其global_transform方法即可。
假设我们事先不知道这个子节点的除global_transform之外的global_*属性,那么我们就可以利用矩阵乘法和Vector2的方法来获取这些数据:
extends Node2D # 挂在根节点上
onready var global_pos: Vector2 = $MainLayer.global_transform * position
onready var global_rot: float = $MainLayer.global_transform.x.angle() # 角度比较特殊,但我们可以通过获取全局变换的基向量i的旋转来获取其全局旋转
onready var global_scl: Vector2 = $MainLayer.global_transform * scale
func _ready() -> void:
print(global_pos,", ",rad2deg(global_rot),", ",global_scl)
此时控制台打印出来的就是MainLayer这个子节点的全局变换的三个要素了。实际上,我们可以分别访问global_position、global_rotation和global_scale这三个分属性来修改global_transform。
但全局变换有一个前提:该节点必须位于节点树内才可以使用。如果该节点不在节点树内,则会出现后台报错(不会影响程序运行,也不会影响结果。但为了防止这些报错占用后台资源及可能的潜在安全隐患,需要注意!)。尤其是有些情况下,我们需要设置不在节点树上的节点的预设全局坐标,这个时候我们可以用Node2D类提供的的to_global()方法,传入该Node2D的局部坐标(局部变换在节点树外依然可用),然后用变量保存,等节点创建好后,我们再将该变量赋给global_position分属性就行了。
var gpos: Vector2 = to_global(position)
全局 -> 局部
这类情况通常用来处理获取某节点到间接父节点的相对位置这类情形。
我们看下面这个例子:
https://s1.ax1x.com/2023/02/20/pSXVfmR.png
上图中蓝箭头所指向的分别对应了蓝箭头的尾部所对应的节点,其中A节点的局部变换为
https://s1.ax1x.com/2023/02/20/pSXV4Tx.png
B节点的局部变换为:
https://s1.ax1x.com/2023/02/20/pSXVotK.png
如果我想知道B节点相对于根节点的局部坐标(也就是根节点获取间接子节点相对于其自身的局部坐标),那么我们就可以使用Node2D类提供的to_local()方法,输入目标子Node2D节点(可以是直接子节点,也可以是间接子节点)的全局坐标,即可获取这个量
# 在根节点内
var root_to_B: Vector2 = to_local($A/B.global_position)
如果真的只想用局部变换的话,你既可选择transform属性,也可选择position、rotation和scale这三个分属性。但是注意我们前面提到的:一个Node2D节点的局部变换只相对于其直接父节点而言。因此,如果你想获取某Node2D节点的间接子Node2D节点相对于其自身的局部坐标,我们就需要让这个间接父Node2D节点的局部变换的仿射逆(原因见下)右乘上这个间接子节点的全局变换:
var parent_to_indirect_child:Transform2D = transform.affine_inverse() * $A/B.global_transform
任意变换A -> 任意变换B(仿射逆的应用)
如果说前面的两种转化都只是小儿科的话,那么两个任意变换的转化,才是本节的重头戏。
我们看下面的例子:
https://s1.ax1x.com/2023/02/20/pSXZg4f.png
上图中A、B均为根节点的直接子节点,这样的话,其局部变换均直接与根节点挂钩,而A、B二者的变换之间就不存在什么继承关系。假如这两个节点是两个不同的坐标系,那么如果我想让A坐标系里的一个物体在B坐标系内创建了子弹,但创建的坐标依旧是A的坐标系,这该怎么办?
还记得矩阵乘法的含义吗?把乘号右边的变换转换到在乘号左边的变换当中。由于子弹是被创建在B坐标系下的,而我们要用的是A坐标系里的变换,因此我们就需要使用矩阵乘法来解决这个问题。不过默认情况下,直接使用矩阵乘法会把位移和缩放也算进去,从而导致结果与预期出现误差,因此我们需要先对B坐标系进行仿射求逆(affine_inverse()),然后利用这个仿射逆再去右乘这个子弹的局部变换。
# 挂载在根节点上
var bullet:Transform2D = $A.transform.translated(Vector2(0,-16)) # 假设这是子弹的变换
var bullet_in_B:Transform2D = $B.transform.affine_inverse() * bullet当然,我们还会遇到这样一种情况:我想知道A坐标系的局部变换在B坐标系下的表示,我们依然可以采用类似于这种方法的方法去解决:
var A_in_B:Transform2D = $B.transform.affine_inverse() * $A.transform如果仅限坐标这一个因素的话,我们可以考虑使用Transform2D的xform()方法,它可以返回另一个变换内的向量在当前变换内的表示:
var A_in_B:Vector2 = $B.transform.affine_inverse().xform($A.position)如果连B的平移都不考虑,那么我们就可以将xform()替换为basis_xform(),表示相对于标准平面直角坐标系的位置。对于上例而言,如果替换成basis_xform(),则结果与A的position无异:
var A_in_B:Vector2 = $B.transform.affine_inverse().basis_xform($A.position)实际上,在我们知道某两个任意的Node2D节点,但需要让它们比较一个相对性(如:位置差)的时候,我们更偏向使用这两个Node2D节点的全局变换的仿射逆来比较,因为仿射求逆操作在一般情况下会直接把这两个全局变换逆变换回在标准平面直角坐标系下的变换,更容易让我们去理解与操作。
我们看下面这个例子,实际上是上面这个例子的变体:
https://s1.ax1x.com/2023/02/21/pSXo8VP.png
其中C1、C2是两个坐标系Node2D节点,C1的局部变换为:
https://s1.ax1x.com/2023/02/21/pSXotPS.png
C2的局部变换为:
https://s1.ax1x.com/2023/02/21/pSXoavj.png
我们在编辑器中肉眼可见:A节点在B节点的左侧,但是该如何去检测这个关系呢?因为A、B这两个节点分属不同的坐标系,我们就需要对它们的全局变换进行仿射求逆(注:如果此处用局部变换则结果仍然不正确,即便你再求结果的仿射逆,也依然得不到一个预期的结果),然后利用已经求逆后的的变换再去进行比较。
# 写在根节点内
var a_inv:Transform2D = $C1/A.global_transform.affine_inverse()
var b_inv:Transform2D = $C2/B.global_transform.affine_inverse()
var result:bool = a_inv.get_origin().x < b_inv.get_origin().x # 这里的比较符号和被比较的分量根据你的实际情况进行修改注意:对于非单位矩阵变换的坐标系Node2D节点下的子Node2D节点而言,这个方法所得到的结果是准确的,但如果你对本来就已经是单位矩阵(也就是无位移、无旋转、缩放为1:1的默认变换)的坐标系Node2D节点下的子Node2D节点的全局变换进行仿射求逆,则结果反而会有误差。
以上就是所有有关矩阵变换的的知识了,不知道各位同学是否对矩阵变换以及在Godot中矩阵变换的操作有了基本的认识与理解呢?那就让我们在以后的学习中去进一步学习、思考与探索吧!
本帖最后由 电童·Isamo 于 2023-3-26 14:20 编辑
GDScript数学二:简单随机数和范围限定上一小节中我们学习了比较头大但也很强大的矩阵变换以及Node2D的变换的一些知识,相比各位同学都已经头脑蜂暴了吧(。咳咳,开玩笑的,那么本节我们就来学习一些比较轻松的数学操作:简单随机数和范围限定
[*]随机数原理简介
实际上,我们生活中经常会遇到一些随机事件,也就是我们不能100%推测出下一秒将要发生的事件,这个事件的发生没有规律,但它们的发生频率往往会趋近于一个固定的数值,这个数值就叫作这个事件发生的概率(Rate)。然而本节课,我们需要学习的反而是计算机中产生随机数的原理,至于随机事件的产生概率,我们会在后面的学习中顺带去进行讲解。
不知道各位同学是否喜欢玩一些抽卡氪金的游戏呢?就像我,喜欢玩原神,基本上每次都是75发左右保底出金。但是你们有想过:为什么有些人就能不吃保底出金,而我却不行?其实并不是每个人都这样,而且别的人难道就没有跟你一样的情况么?当然,我们这节课不是来诉苦抽卡的,而是要告诉各位同学们一个事实:你们所谓的抽不到,实际上就是随机数(Random number)在作祟。
计算机所做到的随机并不是像真实情况的那样的真随机(Real randomization),而是利用一系列算法(高度)模拟真实随机情况的伪随机(Fake randomization),之所以叫伪随机,一个是因为算法本身是固定的,再一个就是这个算法所得的结果是可被预期的,而并非像真随机那样不可被预期。我们现在所用的二进制非量子计算机,理论上也只能产生伪随机数。
伪随机数的产生离不开两样东西:一个是算法,另一个就是种子(Seed,是个数值),伪随机数的产生原理大体上就是:将给定的种子放入给定的算法中,通过算法来生成伪随机数,并在生成完一个伪随机数后,将伪随机算术模块中的状态进行更新以防止连续重复出现同一个数。
[*]在Godot中使用随机数
Godot中默认会将随机种子设为-1,表示任意一个随机种子。至于它是怎么选的随机种子,我们就不得而知了。
Godot中有一类rand_*函数,它就是随机数生成函数,我们下面来看一下这些函数的使用方法:
函数名函数返回值(若无则void) 函数说明
randomize() void将随机种子根据当前系统的时间进行随机化,即将当前系统时间作为一级种子,随机生成二级种子,用这个二级种子来构造随机数
randi()int返回一个正32位整数,用randi() % N 可以获取这个区间内的随机整数
randf()float从这个区间内随机挑选一个数返回
rand_range(a:float, b: float)float从这个区间内随机挑选一个数返回
rand_seed(seed:int)Array返回由这个种子而产生的随机数和新种子
[*]随机数的简单应用
随机数的一个最基本的应用便是抽奖概率,下面让我们以一段代码来解释这一写法:
func gatcha() -> void:
# 生成随机数
rate = rand_range(0,1) # 为了方便理解这里直接使用 rand_range() 函数了
# 判断随机范围
if rate > 0 && rate <= 0.2: # 中奖概率为20%
print("Bonus: Sword ***")
elif rate > 0.2 && rate <= 0.6: # 中奖概率为40%
print("Bonus: Coin x 100")
elif rate > 0.6 && rate <= 0.7: # 中奖概率为10%
print("Bonus: Sword *****")
else: # 中奖概率为30%
print("Empty")在上面这个例子中,我们将奖品均分,所以必须利用完这个区间内的所有分区段,分区段的长度就等于这个抽奖的概率,越长就说明几率越大,但同时也会使得其它奖品所获得的概率有所减小。
考虑到有些人会一欧到底,这里我会对新的随机种子进行二次处理,算法是我随便写的,你也可以随便写一个随机数处理算法
extends Node
var sd: int = 0
var rate: float
# 这里必须要用到 RandomNumberGenerator 对象来获取种子
var randomer: RandomNumberGenerator = RandomNumberGenerator.new()
func _ready() -> void:
for i in 100: # 模拟百连抽
_reset_random()
gatcha()
func _reset_random() -> void:
# 防止有的人一欧到底
if sd == 0 || (sd > 0 && sd < 1):
randomer.randomize()
sd = randomer.seed
else:
var temp: String = str(sd)
temp.erase(-1,6)
randomer.seed = sd / 100 + int(temp) # 算法你可以自己编
sd = randomer.seed
func gatcha() -> void:
# 生成随机数
rate = randomer.randf_range(0,1) # 为了方便理解这里直接使用 rand_range() 函数了
print(int(rate * 100)," point" + ("s" if rate > 0.01 else ""))
# 判断随机范围
if rate > 0 && rate <= 0.2: # 中奖概率为20%
print("Bonus: Sword ***")
elif rate > 0.2 && rate <= 0.6: # 中奖概率为40%
print("Bonus: Coin x 100")
elif rate > 0.6 && rate <= 0.7: # 中奖概率为10%
# 如果你希望大奖没那么容易出现的话那么你可以对此进行一些调整
rate -= 0.09
if rate > 0.6 && rate < 0.7:
print("Bonus: Sword *****")
else: # 中奖概率为30%
print("Empty")当然,有些人也会一非到底,这也是不行的,为了能够让玩家吃到保底,我们需要写一个保底机制:
extends Node
var sd: int = 0
var rate: float
var must: int
# 这里必须要用到 RandomNumberGenerator 对象来获取种子
var randomer: RandomNumberGenerator = RandomNumberGenerator.new()
func _ready() -> void:
for i in 80:
_reset_random()
gatcha()
func _reset_random() -> void:
# 防止有的人一欧到底
if sd == 0 || (sd > 0 && sd < 1):
randomer.randomize()
sd = randomer.seed
else:
var temp: String = str(sd)
temp.erase(-1,6)
randomer.seed = sd / 100 + int(temp) # 算法你可以自己编
sd = randomer.seed
func gatcha() -> void:
# 生成随机数
rate = randomer.randf_range(0,1) # 为了方便理解这里直接使用 rand_range() 函数了
# print(int(rate * 100)," point" + ("s" if rate > 0.01 else ""))
print(must)
# 先判断保底
if must >= 75:
print("Bonus: Sword ***** (must be 100%)")
must = 0 # 既然抽到五星武器了,那咱就可以把保底清空了
return
# 判断随机范围
if rate > 0 && rate <= 0.2: # 中奖概率为20%
print("Bonus: Sword ***")
must += 1
elif rate > 0.2 && rate <= 0.6: # 中奖概率为40%
print("Bonus: Coin x 100")
must += 1
elif rate > 0.6 && rate <= 0.7: # 中奖概率为10%
# 如果你希望大奖没那么容易出现的话那么你可以对此进行一些调整
rate -= 0.09
if rate > 0.6 && rate < 0.7:
print("Bonus: Sword *****")
must = 0 # 既然抽到五星武器了,那咱就可以把保底清空了
else:
must += 1
print("Empty")
else: # 中奖概率为30%
must += 1
print("Empty")如果你们觉得有用的话就可以mark一下,以后说不定就有用了呢(
[*]范围限定
一提到对某个数进行范围限定,我们马上就会想到这段代码:
func _ready() -> void:
var a: int = 1
var b: int = 4
var c: int
if a < b:
a = b
elif a > c:
a = c乍一看是不是很简单?但实际上,如果你想用一行代码就能完成范围限定,那么在GDScript中有一个叫clamp()的方法,它允许你将输入的数值进行范围限定,用法如下:
clamp(value, min, max)上述代码会将输入的数值限制在这个区间内,其原理就等于刚刚那段代码
当然,有些情况下我们希望让一个数在变化的时候限制在一个范围内,且当它高于上限时,让它赋值为下限,反之亦然。我们可以用wrapf()和wrapi()实现这个效果:
wrapf(value, min, max)
wrapi(value, min, max)
# 当value > max时,value -= max - min
# 当value < min时,value += max - min当我们写圆周运动时,我们最好将相位用这个函数进行限定,因为在计算机中,一个数是有上下限的,即使三角函数允许,但在计算机里,如果这个数超过这个上下限,就可能会出问题。因此,我们需要将其相位限制在π)这个范围内,这样保证游戏在运行时不会出问题。
var phase:float
func circle(center:Vector2, R:float, frequency:float) -> Vector2:
phase = wrapf(phase + frequency,0,TAU)
return Vector2.RIGHT.rotated(phase) * R
以上就是关于随机数和范围限定的简单运用了。当然有关随机数和范围限定的更多运用,我们会在日后的实战中进行探索与学习。
本帖最后由 电童·Isamo 于 2023-3-26 16:38 编辑
GDScript数学三:线性插值
在前面两小节的学习中,我们知道了Godot对2D节点的位置、旋转和缩放等信息的操作是以二维变换矩阵运算的形式进行处理的,于是通过学习简单的线性变换知识,我们知道如何去用坐标系与变换的思维去思考如何处理两个2D节点的变换信息。之后,我们学习了随机数和范围限制,其中我们重点学习了伪随机数的操作,知道如何利用随机数去做一些诸如抽奖之类的机制。对于范围限定,我们主要了解了clamp(v,a,b)函数和wrapx(v,c,d)系列的函数,前者用于将变量v限定在内,后者则是将变量v进行“循环”:当变量v大于d时,变量v-=d-c;当变量v小于c时,变量v+=d-c。今天我们将学习在Godot编程中一类很重要的数学操作:线性插值(Linear Interpolation)
[*]线性插值
注:以下为个人理解,非严格的数学定义。如果想要严格的数学定义,请自行百度!
线性插值,在Godot中我个人的理解是:我们可以在数轴上任取两个点a,b(其中a<b),这两个点a,b就形成了一条线段。我们将线段ab按一定比例均分为n个小节,其长度为(b-a)/n,我们将其记作d,则a+knd(k=n-1,为插值节点的数量)就叫做这个线段(或者这个线性区间)的一个线性插值(Linear Interpolation)。同时,这个小节的长度的倒数n/(b-a)我们就叫做这条插值线段(或者这个线性插值区间)的插值比(Ratio),也叫权重(Weight)。如下图所示:https://i.328888.xyz/2023/03/26/iDrnZb.png
在Godot中有五种直接操作线性插值的方法:move_toward()、lerp()、lerp_angle()、inverse_lerp()和range_lerp()。下面我们将逐一学习这五个方法
[*]步进插值函数 move_toward()
move_toward()函数,也叫做“步进插值函数”,顾名思义,其插值量会根据给定的值进行步进。其结构如下:move_toward(from: float, to: float, delta: float)该函数的作用便是将数值from向to“移动”了delta个单位长度后的结果返回出去。举个例子:如果from = 40、to = 75、delta = 15,那么返回的结果就是40 + 15 = 55。如果移动了delta个单位长度后的数值“超过”了沿其移动方向上的to(即,如果向正方向移动,则“超过”定义为结果大于to;反之,如果向负方向移动,则“超过”定义为结果小于to),则会将返回的结果强制为to。再举个例子:如果from = 60、to = -100、delta = 200,那么理论上的结果应该是60 - 200 = -140,但是很明显,-140“超过”了其移动方向上的to值-100,因此最终的返回结果就是-100而非-140。这种插值非常适用于做玩家加速移动时限制最大速度,以前我们在写的时候还要多写几行代码来防止玩家的速度超过上限,现在我们就可以利用move_toward()这个函数来一步到位了:move_toward(speed, max_speed, acceleration)在Godot中,float类型的数还有一种名为INF的数值,即正无穷,其值就等于float类型的数的上限值,因此我们如果将move_toward()中的参数to改为INF,那么就等价于from += delta如果为-INF,则等价于from -= delta注:以上所有例子中的delta均为正整数或0
当然,我们也可以尝试把delta设为一个负值,这个时候他就不再表示让from向to移动了,而是表示让from向远离to的方向移动|delta|个单位。假如我令from = 100、to = 200、delta = -50,那么返回的结果就是100 - 50 = 50而不是150
[*]比例插值函数 lerp()
lerp()函数的名字就是linear interpolate这两个单词中抽取了l(in)e(a)r (inter)p(olate)这四个字母后组合而成的。它的用法不同于move_toward(),结构如下所示:lerp(from: Variant, to: Variant, weight: float) # weight ∈ ,Variant可以是int, float, Vector2, Vector3 等类型他就是利用我们前面讲到的插值权重来进行插值的函数。假如我们令from = 100, to = 200, weight = 0.2,那么其返回的结果就是100 + 0.2 *(200-100)= 120;同理,我们令from = 100,to = 50,weight = 0.2,则返回的结果就是100 + 0.2*(50 - 100)= 90。那么假如我们让from为变量,其它的为常数,又会发生什么呢?我们不妨令from = 10, to = 100,weight = 0.1,设from = lerp(from, to, weight)对于每一帧,我们有:
[*]1: from = 10 + 0.1 * (100 - 10) = 19
[*]2: from = 19 + 0.1 * (100 - 19) = 27.1
[*]3: from = 27.1 + 0.1 * (100 - 27.1) = 34.39
[*]4: from = 34.39 + 0.1 * (100 - 34.39) = 40.951
[*]5: from = 40.951 + 0.1 * (100 - 40.951) = 46.8559
[*]6: from = 46.8559 + 0.1 * (100 - 46.8559) = 52.17031
[*]7: from = 52.17031 + 0.1 * (100 - 52.17031) = 56.953279
[*].....
实际上,如果将它视作一个点C从点A到点B的移动的话,你会发现:这个点的速度越靠近点B越小,以致到最后几乎为0
[*]求插值比例函数 inverse_lerp()
当然,如果我知道了两个数的值以及位于这两个数中间的数的值,却想知道这个中间的数在这两个数中所占的线性插值的比例,这个时候我们就需要使用lerp()的反函数inverse_lerp(),它的结构如下:
inverse_lerp(from: float, to: float, value: float)当我确定了from和to以后,我只要输入一个在from和to之间的value,他就能给我这个值在这两个数值所构成的线性插值区间中所对应的插值比。其算法为:
r = (value - from) / (to - from)
假如我令from = 20、to = 70,value = 30,那么结果就是(30 - 20)/(70 - 20)= 0.2
[*]对角度求插值函数 lerp_angle()
我们也可以对角度求线性插值,对于角度的线性插值,Godot很人性地提供了lerp_angle()函数,用法几乎同等于lerp(),但会求旋转角度最小的结果:
lerp_angle(0, PI/2, 0.2)对于上面这个插值,很显然,通过逆时针旋转到PI/2的角度要明显大于通过顺时针旋转到π/2的角度,因此,它最终会选择对通过顺时针旋转到π/2角度的这个区间进行线性插值。结果就是π/10
注:由于浮点数运算的局限性,当to - from大致为 π + 2kπ(k∈Z)时,线性插值的选择方向就会比较随机,这个时候建议使用lerp()插值以防出现问题
[*]映射插值函数 range_lerp()
有时候,我们希望将某个数值在某个线性插值区间A内的比例r用于另一个线性插值区间B当中,并得到在该比例r的作用下线性插值B的插值结果q,这个时候我们就需要使用映射插值函数range_lerp(),其结构如下:
range_lerp(value: float, from1: float, to1: float, from2: float, to2: float)上述函数会先使用inverse_lerp()函数求出value在这个线性插值区间内的线性插值比例r,之后将该比例r用lerp()函数作用在这个线性插值区间内,最终返回在这个线性插值区间内按线性插值比例r插值后的值。
假如我令value = 75、from1 = from2 = 0、to1 = 100、to2 = 10,那么最终的结果就是7.5,运算过程可参考inverse_lerp()的运算过程和lerp()的运算过程
以上就是关于Godot内操作线性插值的所有方法了。 本帖最后由 电童·Isamo 于 2023-7-7 17:42 编辑
GDScript高级篇其一:协程yield,SceneTree类的简单学习从本节课开始,我们就要学习难度更高,层次更深的GDScript知识了。如果你对GDScript还不够熟练,请自行复习基础篇中你有所不会的内容,因为从本节课开始,我们将不再复习基础篇中已经学过的内容,除非与所学的新内容有紧密关联时会稍微复习。
本节课我们将学习GDScript中对函数进行条件中断的操作yield,以及对场景树类SceneTree有一个简单的认识与学习
[*]GDScript的协程——等待执行
可能“协程”这个词语你们乍一听会觉得十分陌生,但如果我以下面这个例子来进行解释的话,你们也许就能对“协程”有所理解了。
假如我写了个系统,需要监测用户输入之后在控制台里打印出用户所输入的内容,那么我们这里就需要使用到Control类下的LineEdit节点来获取用户输入的内容了。有关用户输入的内容我们会在单独的一个帖子里进行讲解,这里先放上代码:
extends Node
onready var input: LineEdit = $Input # 从场景树中将节点按住Ctrl键拖动到这里会自动生成onready变量,仅3.5及以上版本可实现
func _ready() -> void:
# 提示等待输入结果
print("Waiting for input...")
# 等待输入
# 该怎么写呢??
# 输出结果
print("Your input is, %s" % input.text)
这个时候问题就来了,如果我啥都不写的话,那么下面那个print()不就连带着一块执行了嘛?我现在需要的就是让计算机在执行完第一个print()之后等待,等到我输入了东西并确认之后再执行第二个print()。这个等待执行的过程就是所谓的“协程”。
执行等待执行的协程我们需要用到yield()函数(注:yield不是关键字!)。对于等待执行,yield()函数有以下用法:
yield(<object>, <signal>)当计算机执行到yield()函数处的时候,计算机会暂停执行下方的所有代码片段,会等待yield()函数中的对象所含的信号被发射出来后再继续往下执行下方的代码。
这里LineEdit节点的有关“输入并确认”的信号叫做text_entered,因此我们的代码就应该是:
extends Node
onready var input: LineEdit = $Input # 从场景树中将节点按住Ctrl键拖动到这里会自动生成onready变量,仅3.5及以上版本可实现
func _ready() -> void:
# 提示等待输入结果
print("Waiting for input...")
# 等待输入,暂不执行yield()下方的所有代码片段
yield(input, "text_entered")
# 当用户输入完毕并按下Enter键时,才会继续往下执行代码
# 输出结果
print("Your input is, %s" % input.text)当然,如果能够配合我们后面讲到的场景树计时器来进行计时,那么有些情况下一些复杂的算法将会事半功倍
func loop_by_inverval(duration: float) -> void:
# 代码片段
# ....
# ....
yield(get_tree().create_timer(duration, false), "timeout")
loop_by_inverval(duration)func happy_birthday() -> void:
yield(get_tree().create_timer(1), "timeout")
print(3)
yield(get_tree().create_timer(1), "timeout")
print(2)
yield(get_tree().create_timer(1), "timeout")
print(1)
yield(get_tree().create_timer(1), "timeout")
print("Happy Birthday!")上面的两个示例就给我们展示了如何利用yield和场景树计时器来进行延迟处理,假如没有这种语法,我估计还要靠Timer节点+match语法来进行选择编写,写起来耗时还不美观
除了能够等待信号发射以外,我们还可以等待函数的执行完毕yield(function, "complete")下面的例子就很好地给我们展示了如何等待函数的执行完毕(示例来自官方,注释有所更改)func _ready():
yield(countdown(), "completed") # 调用countdown()函数,并等待其执行完毕
print('Ready')
func countdown():
yield(get_tree(), "idle_frame") # 这行代码保证"completed"能够被准确监测,原因我们下面会讲
print(3)
yield(get_tree().create_timer(1.0), "timeout")
print(2)
yield(get_tree().create_timer(1.0), "timeout")
print(1)
yield(get_tree().create_timer(1.0), "timeout")
[*]GDScript的协程——函数交替
实际上,yield()还可以用来进行函数的交替运行,这个其实也算作yield()作等待执行功能使用,只不过这里的yield()函数中不需要添加任何参数罢了
func _ready() -> void:
var state: GDScriptFunctionState = test()
# ↑↑↑调用test()函数,并获取协程信息↑↑↑
print(2)
state.resume()
# 调用resume()函数后,返回到test()函数中最近一次调用的yield()的那一行
# 代码处并忽略yield()继续往下执行剩余的代码
# 注意:被交替的函数不建议强制返回值,
# 如果必须要强制返回一个GDScriptFunctionState,使代码规范,请在调用yield()的函数的最后加上return null
func test():
print(1)
yield()
# 执行到此后,暂停执行下面的print(3),而是返回到被调用的地方_ready(),
# 并将当前的协程信息返回给_ready()中的state变量
print(3)
[*]GDScript协程的作用原理(实质)
通过上面函数交替的例子,我们也许注意到了一个细节:GDScriptFunctionState。这又是个什么玩意儿?
实际上,当计算机执行到yield()函数的时候,yield()函数就会将当前函数的执行信息保存在这个名叫GDScriptFunctionState的对象当中,并以类似于return关键字的作用将这个信息返回给调用了yield()的函数(我们姑且称之为本函数)所被调用的地方(我们姑且称之为原函数),也就是将这个GDScriptFunctionState对象返回给了原函数。如果我用一个变量来调用这个本函数的话,那么当本函数的yield()被调用时,yield()就会把本函数当前执行的信息GDScriptFunctionState返回给这个变量并作为其值,之后,如果我想让本函数从上一次调用了yield()的地方继续往下执行的话,就可以调用这个state的resume()方法,来恢复该本函数的执行。
在不声明可以返回GDScriptFunctionState对象的自定义函数的情况下,只有yield()函数才可以返回GDScriptFunctionState!
https://s1.ax1x.com/2023/03/28/pp6GY7V.png
对于yield(Object, "signal_name"),则该机理会有所变化:当计算机执行到带有给定对象和给定信号的yield()函数处时,计算机会暂停执行yield()下方的代码(注:并不会影响程序的整体运行!),等到给定对象的给定信号发出后,才会继续往下执行。GDScriptFunctionState对象中有一个completed信号,你是不是觉得很熟悉?没错,这不就是上小节的那个yield(function, "complete")语法吗!实际上,当你试图去写yield(function, "completed")这样的写法时,就默认了function必须返回一个GDScriptFunctionState,因为只有这个对象才会有这个信号,那么谁会产生GDScriptFunctionState这个对象呢?答案不言而喻——yield()函数,因此,前面某个例子里那个yield(get_tree(), "idle_frame")的作用也就不言而喻了——将yield(function, "completed")中的function替换为一个GDScriptFunctionState对象的实例,这个实例保存的正是那个函数function的运行信息,等函数function执行完毕后,该实例会发出completed信号,yield(function, "completed")足以恢复下面代码的执行。
以上就是关于协程的学习了。有关其更加深入的学习,我们将会在日后的学习与实战当中进行讲解。
[*]场景树与SceneTree类
我们在初学Godot的时候就已经学习了什么是场景树(节点树),那么今天,我们将会用GDScript的知识来更进一步地学习这个所谓的场景树。
实际上,场景树是一个名叫SceneTree的类的实例。我们可以通过学习SceneTree类的一些属性和方法,来了解用代码对场景树的一些操作。
<|> 如何获取SceneTree类的实例?
实际上,我们是无法直接获取SceneTree类的实例的,但如果有一个节点在节点树上的话,我们就可以通过调用节点的get_tree()方法来获取这个节点所在的场景树
(也就是一个SceneTree类的实例)
# 写在某个位于场景树内的节点的脚本里
func _ready() -> void:
var tree: SceneTree = get_tree()
# 上面的代码就获取了这个节点所在的场景树的引用
注:
1. 只有位于场景树内的节点才可以调用get_tree()
2. 所获取的其实并不只是该节点所在的场景树,而是整个游戏这个最大的场景树,它是以游戏根节点root为根节点,单例节点(以后会讲到)及当前场景的节点树作为其子节点的节点树
我们获取了场景树以后,就可以对整个游戏进行操作,因为我们所获取的不只是当前的节点树这一个场景树,而是整个游戏的场景树(见上方的“注2”)
对于目前Mario Forever的游戏开发而言,我罗列出了以下会经常使用到的SceneTree属性及方法:
<|> 属性(名称:类型)
[*]paused: bool -- 用于暂停游戏,若为true则游戏暂停,反之则游戏恢复正常。我们后面会在实战篇学习如何暂停游戏。
[*]current_scene: bool -- 游戏显示的节点树场景,可以通过一些方法来对其进行操作,如切换、重载等。
[*]root: Viewport -- 当前游戏场景树的根节点,即root节点。Viewport是一个节点的类型,用于显示游戏画面。
<|> 方法(名称(参数):返回类型,若无返回类型则留空)
[*]call_groups(group: String, method: String): -- 对一个节点组(下面会讲到)内的所有节点调用名为method的函数(可不是名字就叫method,而是你希望要调用的那个函数的名字)。
[*]call_groups_flag(flags: int, group: String, method: String): -- 类同上面这个方法,但需要一个flag来修饰操作。
[*]change_scene(to: String): -- 将current_scene更改为文件路径to所指向的场景文件的实例化场景,即更改当前场景。
[*]change_scene_to(to:PackedScene): -- 类同上面这个方法,不过这次to是一个packedscene资源(下一节课会讲到)而非一个文件路径。
[*]create_timer(duration: float, pause_mode_process: bool = true): SceneTreeTimer -- 创建一个一次性计时器,返回这个一次性计时器的引用,如果pause_mode_process为false,则当游戏暂停时,该计时器也会暂停。通常用来配合前面学到的yield()函数来等待执行代码。注:该计时器采用的是空闲帧线程计时!
[*]create_tween(): SceneTreeTween -- 创建一个SceneTreeTween补间对象,有关补间对象,我们会在讲SceneTreeTween对象时再进行学习。
[*]get_frame(): int -- 获取自游戏开始以来到调用该函数时所经过的帧数
[*]get_node_count(): int -- 获取游戏场景树内的所有节点的数量总和,包括每个节点的子节点。
[*]get_nodes_in_group(group: String): Array -- 获取节点组group内的所有节点,以数组的形式返回
[*]has_group(group: String): bool -- 如果某个节点组group存在,则返回true
[*]queue_delete(obj: Object) -- 排队删除对象obj,如果该对象是Node,则等价于node.queue_free()
[*]quit(): -- 退出游戏/关闭程序
[*]reload_current_scene(): Error -- 重载current_scene,如果发生错误则会返回一个Error枚举
[*]set_group(group: String, property: String, value: Variant): -- 对节点组group内所有节点,将其名为property的属性赋值为value
[*]set_group_flags(call_flags: int, group: String, property: String, value: Variant): -- 类同上面这个方法,但需要提供call_flags
对于带flag的方法,感兴趣的同学可以按住ctrl点击这个方法查看对应的flags
实际上,SceneTree也有两个值得我们重视的信号:
[*]idle_frame -- 当场景树准备执行空闲帧步处理时发送
[*]physics_frame -- 当场景树准备执行物理帧步处理时发送
这两个信号都会先于场景树中所有节点的_process()和_physics_process()发送,之后才会根据帧线程来执行这两个方法。其实,所有的节点之所以能够自动执行_process()和_physics_process(),并非节点本身就是这样设计的,而是通过节点树的这两个信号的发送来执行的。我们可以看作是:当我们声明了一个_process()方法时,就相当于_process()自动连接到了节点树的idle_frame信号上,只不过比其它直接通过代码连接的执行顺序靠后一些。
这里是为了方便将这两个虚方法及其对应信号衔接才这样说的,实际上这种说法是很不严谨甚至是错误的,_process()和_physics_process()的本质是节点树的父类MainLoop的_idle()虚方法和_iteration()虚方法,而这两个方法则是由notification(通知)驱动的,至于什么是notification,我们以后会讲到
[*]场景树内节点的分组
在前面学习SceneTree的某些方法的时候,你们或许注意到那些标蓝的方法,它们都有一个共用词:group,即分组。在Godot中,分组是一个非常实用的功能,如果说类是区分不同节点的标志的话,那么分组就区分不同节点的不同作用的标志。
分组管理有两种方式:
[*]通过在编辑器内手动给一个节点预设一个分组
[*]通过代码管理一个节点的分组
<|> 手动管理分组
我们可以选中一个节点,在编辑器右侧的窗口中找到“节点”选项卡后点击,找到“分组”按钮并点击,就会出现如下图所示的界面
https://s1.ax1x.com/2023/04/11/ppOw6gI.png(图1)
如果没有分组,你的这个界面下方是一片空白,这时我们可以先输入要加入的组的名称,可以随便起(毕竟是字符串)。输入完毕后,点击右侧的“添加”按钮,即可将该节点加入到以这个名称为名的组中
https://s1.ax1x.com/2023/04/11/ppOwWb8.png(图2)
实际上,一个节点可以添加到多个组中,如果遇到组别太多无法管理的情况,我们可以点击上方的“管理分组”按钮来查看节点的分组情况:
https://s1.ax1x.com/2023/04/11/ppOwT8s.png(图3)
如上图所示,如果所选中的组含有节点,就会在右侧“分组中的节点”中显示位于改组中的节点,如果你想往这个组里添加新的节点,就可以在“不在分组中的节点”中选中一个节点,然后点击中间的“添加”按钮,即可将该节点也加入该分组内。
如果要从组中移除某个节点,可以按图2所示,先选中你要移除出的分组,然后点击旁边的垃圾桶按钮来讲该分组删除,亦或按图3所示,从“分组中的节点”内选中一个节点,然后点击中间的“移除”按钮,将该节点从该分组中移除出去。
注:如果一个分组没有任何节点存在,那么这个分组就会被系统自动删除
<|> 代码管理分组(方法(参数):返回值类型,若无返回值则留空)
对于GDScript,我们主要使用到以下三个方法来管理节点分组(这些方法都是在Node类):
[*]add_to_group(group: String): -- 将该节点加入到分组group中,如果已经加入到这个分组,则会报错
[*]remove_from_group(group: String): -- 将该节点从分组group中移除出去,如果节点已经不在分组group内,则会报错
[*]is_in_group(group: String): bool -- 如果该节点在分组group中,则返回true,否则返回false。
注意到那个加红的is_in_group()方法了么?实际上,这个方法就是分组的精髓方法。首先,如果要防止添加/移除节点组时报错,需要用这个方法去检测;其次,通过方法来比较两个节点是否在同一分组内,从而产生不同的处理逻辑,以达到“节点可能相同,但归属/作用不一定/一定不相同”。
以上就是关于SceneTree以及分组的介绍了
本帖最后由 电童·Isamo 于 2023-7-7 18:23 编辑
GDScript高级篇其二:实例化场景、高级导入节点与%型节点上节课我们学习了在Godot中利用yield()函数执行协程操作——让函数“等待”另一个函数的执行,同时顺便简单地学习了SceneTree类(即场景树)及其部分属性和方法,由其中带group的方法,我们又顺便学习了分组在编辑器内和代码上的操作。
本节课,我们将结合第二章和GDScript基础篇第四章的内容,来学习利用代码实现场景实例化的操作,并同时学习高级的导入节点的方法以及%型节点的用法。
[*]场景与PackedScene
我们在第二章学习节点和场景时,就已经知道:
[*]场景是由根节点及其若干子节点组成的结构,节点是场景的组成成分。
[*]场景大到舞台、小到单个节点。
[*]场景可以在另一个场景当中实例化。
[*]场景的根节点是代表这个场景的节点,如果该场景A被实例化在另一个场景B中,除非开启“子节点可编辑”,否则只有该根节点a能显示在这个目标场景B当中。
然而,实际情况是:诸如子弹这类物件,我们是事先保存好的,但是并不是说要一个个拖到关卡场景的节点树里,然后在发射源的脚本里写些代码,让这些子弹物件场景在关卡这个大场景里进行操作。如果要真是那样写,那确实太麻烦了,而且你还不能保证发射子弹的发射源的数量是可控的,对吧。所以,我们实际上都会用代码来实例化像子弹这样在游戏运行的过程中被高频创建的物件场景。
在学习如何用代码实例化一个场景之前,我们需要学习一个资源:PackedScene。
PackedScene是Godot中用于存储场景的资源(Resource)。什么是场景?刚刚说了,场景就是根节点及其子节点嘛。因此,PackedScene我们也可以说是用于储存一个根节点及其子节点的资源。在Godot中,资源可以被直接存储到硬盘上,而PackedScene的格式,正是我们学习第二章的内容时时所学到的保存场景的那个格式——tscn/scn/res。
因此,我们在文件系统窗口里看到的一个个以tscn/scn结尾的文件,实际上都是一个个PackedScene。
了解了这些,我们接下来就可以着手来研究如何用代码在场景里实例化一个节点了。
我们要想用代码实例化一个场景A到场景B中,首先我们在场景B的脚本中获取这个PackedScene:
const PACKED_SCENE: PackedScene = preload("你tscn文件的路径.tscn")
# 比如我要实例化的场景叫A.tscn,在res://some_folder/文件夹下,那么
# 这里就应该这样写
const PACKED_SCENE: PackedScene = preload("res://some_folder/A.tscn")上面我们用到了一个叫preload()的方法,它可以静态读取参数所代表的路径所指向的资源文件。至于什么是静态读取资源文件,我们会在后面的章节中学到
实际上,我们更希望在编辑器模式下随时随地调整我们要读取的PackedScene文件,因此,我们可以利用GDScript基础篇第二讲学到的导出变量的操作,来将我们要读取PackedScene的变量作为一个对外接口导出到检查器中:
export var my_packed_scene: PackedScene
# 如果你这里有一个默认读取的tscn文件,则你可以在后面用
# preload()静态读取资源我们日后会经常以导出变量作为读取PackedScene的主要手段,但如果情况特殊,我们也会使用常量进行读取
实际上,这样做还有一个好处:我们可以在文件系统窗口中找到我们想要的文件,然后按住左键拖动它,将其拖动到对应位置即可,如图所示:
https://s1.ax1x.com/2023/04/25/p9KV6II.png
读取了PackedScene文件之后,我们可以在doc中查阅PackedScene类的相关信息,会发现下面这个函数:
Node instance(edit_state: GenEditState = 0) const
实例化场景的节点层次结构。触发子场景实例化。在根节点上触发一个 Node.NOTIFICATION_INSTANCED 通知。
这个就是我们要进行核心操作——场景实例化的函数。但需要注意,如果我们使用了export var来存储场景,且没有赋予初始值,为防止出现用null场景来调用instance()函数而导致程序报错,我们需要加一条if来判断存储PackedScene的导出变量是否为空,如果为空,则不进行instance()。
export var my_packed_scene: PackedScene
# 如果你这里有一个默认读取的tscn文件,则你可以在后面用
# preload()静态读取资源
func _ready() -> void:
# 判断存储变量是否为null,如果是,则停止进行实例化
# 这里使用了return来中断函数的进行,实际中断方法
# 还要根据你的实际情况来进行选择
if !my_packed_scene:
return
# 用另一个变量来存储实例化后的场景的根节点
var ins: Node = my_packed_scene.instance()根据doc所述,instance()会返回调用该函数的PackedScene的实例化场景的根节点,为方便后续使用,我们需要用一个变量来存储这个节点
然而,这还没有结束,我们只是实例化了一个场景,但用这个方法实例化的场景,其根节点并不在场景树内。为了能够让它在场景树上显示,我们还需要借助我们GDScript基础篇最后一节所学的添加节点的操作来将这个根节点真正添加到场景树中:
export var my_packed_scene: PackedScene
# 如果你这里有一个默认读取的tscn文件,则你可以在后面用
# preload()静态读取资源
func _ready() -> void:
# 判断存储变量是否为null,如果是,则停止进行实例化
# 这里使用了return来中断函数的进行,实际中断方法
# 还要根据你的实际情况来进行选择
if !my_packed_scene:
return
# 用另一个变量来存储实例化后的场景的根节点
var ins: Node = my_packed_scene.instance()
# 这里借助调用该脚本的节点来将ins所指向的实例化场景的根节点添加进节点树中
# 实际上,add_child()会把ins作为其子节点添加进节点树上
# 因此,你需要根据实际情况选择合适的节点来进行这一操作
add_child(ins)
至此,我们总结一下利用代码将场景实例化的操作步骤:
[*]声明一个常量并用preload()读取一个PackedScene资源文件,或者用export var xxx: PackedScene来让我们选择性读取PackedScene资源文件。
[*]在某个函数中,如果你要对导出变量所指向的PackedScene资源进行实例化操作,那么要先检测这个导出变量是否为null,如果为null,则中止接下来的实例化操作
[*]对被读取的该PackedScene调用其instance()函数以获取实例化后的场景的根节点,并用一个变量对该根节点进行存储
[*]利用添加节点函数add_child()来将该根节点添加到场景树当中。注意:调用add_child()的节点要根据你的实际情况来进行选择
当然,这只是针对所有节点而言的,实际应用中,我们可能更多地会实例化根节点为Node2D或者Control类型的场景。我们这里就先以Node2D为例讲解一下用代码实例化Node2D节点以后的其它一些注意事项:
[*]实例化一个Node2D节点以后,一定要注意:实例化并不意味着将这个实例化后的根节点连带着其子节点一同加入到场景树当中,而是将其缓存到游离节点池内,一定要用add_child()等方法来讲这个刚实例化的节点串(也就是这个根节点及其子节点)挂在到场景树上。
[*]请注意:如果你使用了一个Node2D来调用add_child()方法,它是不会将其变换传递给这个实例化的Node2D根节点上的。因此,如果你需要让被实例化的根节点应用调用add_child()的Node2D节点的变换,请在该Node2D调用完add_child()后将其变换赋值给该实例化根节点。这一点非常重要,尤其是对于要做子弹发射的同学来说,是必须要记住的一条。这里的变换也不一定只能是transform,你可以根据自己的实际情况进行选择,如position,scale,rotation等
[*]上述操作中,赋值变换也可以放在add_child()方法的前一行编写,但一定要写在实例化节点的后面!
[*]你也可以将全局变换赋值给实例化的Node2D节点,但注意:这个操作只能放在add_child()之后编写,否则会在程序执行到这段代码时弹错,虽然不会中断游戏运行,不会影响运行结果,但也可能会使最终成品产生运行安全隐患。如果你执意要写在前面的话,请使用set_deferred()方法:
[*]ins.set_deferred("global_transform", global_transform)
注:当一个场景通过instance()函数实例化成功,且该实例化节点被add_child()函数加入到场景树时,会立刻触发其初始化函数(见后面“初始化函数及其顺序”一章)。此时如果add_child()的上下文与该实例化节点的_ready()函数中的内容有所冲突,请慎重考虑add_child()的执行位置。
[*]节点导入进阶——动态导入
我们在基础篇已经学过用onready关键字来导入节点了,但当时我们只是局限于一种以get_node("静态节点路径")/$的方式来获取节点的,我们称之为静态导入(Static import)。之所以叫静态导入,主要是因为我们向get_node()里输入的参数是静态不变的。也就是说,利用静态导入的方式获取的节点,虽然路径已知,结构明了,可一旦节点路径发生改变,那么这个时候,静态导入就会使用错误的节点路径来获取可能就不存在的节点或者与预期不相符的节点,就可能存在运行安全隐患。为此,我们本次就要学习与之相对立的动态导入(Dynamic import)节点的方法来解决这个问题。
其实,我们主要就是研究“动态”二字的含义——可变量。没错,解决问题的关键就在于:将get_node()中的参数转化为动态的参数,而非一开始就写死的。还记得get_node()能传递什么类型的参数嘛?对,就是NodePath,为此,我们就需要把传入的参数设为一个NodePath类型的变量即可:
# 声明动态NodePath
var my_node_path: NodePath
# 利用onready来获取这个动态的NodePath所指向的节点
onready var node: Node = get_node(my_node_path)乍一看是不是很简单?其实,在学习导出变量的时候,我们也学习了导出变量所支持的数据类型,这其中就包括了NodePath。因此,我们可以把这个节点路径变量转化为导出变量:
# 声明动态NodePath
export var my_node_path: NodePath
# 利用onready来获取这个动态的NodePath所指向的节点
onready var node: Node = get_node(my_node_path)当然,我们这里建议使用get_node_or_null()来获取目标节点,因为这个方法相比get_node()方法更加安全,它会在节点路径非法的时候直接返回null而非报错,从而防止了潜在的运行安全隐患。
如果你希望你获得的节点是一个指定类型的节点,只需要修改onready处变量的节点类型,然后在get_node()方法后面加上 as <你修改的那个节点类型> 即可。
# 声明动态NodePath
export var my_node_path: NodePath
# 利用onready来获取这个动态的NodePath所指向的节点
# 使用as来强制限定节点类型
onready var node: AnimatedSprite = get_node(my_node_path) as AnimatedSprite遗憾的是,在Godot 3中,我们还无法直接从export这一环节开始就过滤出我们希望的变量,不过在Godot 4当中,官方就允许直接在导出变量时直接指定节点路径所指向的节点的类型了,感兴趣的同学可以点击此处了解。
[*]半动态导入的节点——%型节点(仅3.5及以上版本)
我们前面说到,要想解决静态导入节点的潜在问题,就需要将传入的参数动态化。然而,我们可能有些情况下还是希望有些节点使用静态导入的模式来被动态地导入到脚本中。也就是说:不论这个节点位于哪里,都能被脚本安全地导入进去。在Godot 3.5版本中新加入了一个功能,叫做“唯一化节点名称”,开启了这个功能后的节点,只要其名称在场景树里是唯一确定的,不论它在场景树的哪个地方(不论其父节点是谁),都可以被某个或某些脚本直接以%+节点名的形式导入。因为这种形式以%开头,因此在这个模式下的节点又被叫做%型节点。为了书写与交流的简便,本教程以%型节点指代唯一化节点名称节点。
<|> 如何将节点变为%型节点?
与连接信号一样,将一个节点变为%型节点也分为手动和自动两种形式:
手动转换的方法很简单,右键一个节点,找到带%的那行文字,点击它即可。
https://s1.ax1x.com/2023/05/03/p9YsGDJ.png
(注:lz此时使用的是3.6 beta1版本。因版本差异,本行文字可能有中文,也可能只显示英文)
此时你会看到如下所示的图标
https://s1.ax1x.com/2023/05/03/p9YsBvD.png
这个图标就说明这个节点已经变成了%型节点,如果需要取消的话,可以点击那个%图标来将其转换回一般节点。
当你尝试将%型节点以按住ctrl+鼠标左键的形式拖入脚本编辑器中后,你会发现这样的一行代码(以上图为例):
onready var collision_polygon_2d: CollisionPolygon2D = $"%CollisionPolygon2D"在Godot 3中,%型节点的节点路径的格式非常简单,即"%+节点名",然后get_node()/$照常输入即可
注:$符号是允许$""的形式出现的。当出现诸如"."、".."这类不定路径或者"%+节点名"时,只能使用后者这种形式,其他情况下可以去掉""
这个时候,不论这个%型节点位于场景树的哪个地方,都会被这个脚本安全地导入进来。
自动设为%型节点的方法需要操作节点的unique_name_in_owner属性,将其设为true表示将该节点变更为%型节点,设为false则表示恢复为一般节点。
需要注意的是,%型节点的节点名在场景树中必须保证唯一确定。如果一个%型节点在场景树中存在多个同名的%型节点,则会导致潜在的指代安全隐患。因此,不要滥用%型节点,除非你能确保场景树中有这个节点名的节点是唯一确定的。
以上就是本节的全部内容了。相信各位同学在学完本节课的内容后应该就可以开始着手操作自己的第一个小实例了吧。
本帖最后由 电童·Isamo 于 2023-6-1 00:02 编辑
GDScript高级篇其三:高级分支条件句,封装,引用Array和Dictionary的本质上节课我们学习了如何用代码来实例化一个场景(节点),并将其加入到场景树当中,同时还学习了动态导入节点树上的场景,将NodePath的作用真真正正地发挥了出来。
本节课中,我们将会分别学习:match语法的高级用法,封装理论,以及Array和Dictionary这两个容器的作用实质。
(为方便起见,本节课起不再用特殊字符标识关键字)
[*]match的高级语法
我们在基础篇里已经学过了match的基本用法,不过,在面对更加复杂的情况时,match还能否胜任呢?我们今天就来学习一下match更加高级的语法:
<|> 变量判据
我们之前在学习match语法的时候,有一段示例代码,那段代码里的match有个明显的特点:它们的条件绝大多数都是一个固定的值,也就是说,它们的条件主要是常量判据。我们之前所学的这种用法叫做常量判式。
由于match里的每个条件(也就是判据【predicate】)都可以转化成if a == b的形式,而这种形式既支持常量又支持变量,那么我们为什么不能把判词变成一个变量呢?
实际上,我们在学习那节课的时候一开始所举的例子就是以变量为准的,而非常量,因此这里我们就相当于复习一遍这个用法。
match a: # a为要被检测的变量,相当于"=="左边的变量
b: # 等价于 if a == b:
<code1>
c: # 等价于 if a == c:
<code2>
d,e,f: # 等价于if后面只要a == d、a == e和a == f这三个条件中有一个为true即可,也就是if a == d || a == e || a == f
<code3>
<|> 通配判据
实际上,我们可能会出现所被检测的变量在match语法中压根就没有任何条件与之相匹配的情况,但我们仍然希望在这种情况下依然可以执行一些代码,这就需要用到我们接下来要讲的通配判据了。
通配判据就是用来解决在被检测变量无法与任何条件所匹配时所强制指定的一种匹配对象,用单下划线“_”来表示。
下面一段代码摘自之前学过的某段,其中就讲到了这种用法:
var a = 10
match a:
3: # if a == 3:
a = "I love you"
6: # if a == 6:
a = "Show your love"
8: # if a == 8:
a = "Yes, man"
_:# 上面的都不满足时执行:
a = "What?"可以看出,上面的例子中a变量的值为10,但match结构中的那三个常量判据并不能与a的值相匹配,这个时候就只能执行通配判据里的内容了,即“_:”所指向的代码段
这里的_语法的作用相当于Java等语言的switch语法中的default
<|> 赋值判据
godot的match语法甚至还有一种更为疯狂的用法:让声明变量这一过程也作为判据——即赋值判据。这一点其实相当于上面讲到的通配判据,但这个时候你可以在这个赋值判据里直接使用这个作为判据的声明变量。
赋值判据不可以直接赋值,因为此时它会自动赋值为被检测变量的值。
# 摘自 Godot Doc
match x:
1:
print("It's one!")
2:
print("It's one times two!")
var new_var:
print("It's not 1 nor 2, but ", new_var)
<|> 数组判据
当然,甚至是数组也可以作为判据,我们称之为数组判据。与其它判据不同的是,数组判据要求被检测的变量必须是数组,且要与该数组判据的内容完全一致(包括元素、顺序等),才能匹配成功,否则匹配失败。
通配判据也可以作为数组判据数组的元素,这个时候他表示只要对应元素位置上有元素即可,如果通配判据元素后还有其它非通配判据元素,则该非通配判据元素也要与被检测数组的元素一一对应才行,否则也算匹配失败。这时候我们称这个通配判据为占位符(Placeholder)
类似的,赋值判据也可以算作一种占位符
除此之外,数组判据还支持一种不定长度的判据,就是以".."作为判据的末尾元素。这个情况下,该判据只要求..元素前的所有元素与被检测数组的对应元素一一相对即可匹配成功
# 摘自 Godot doc
match x:
[]:
print("Empty array")
:
print("Very specific array")
: # 这里相当于要求数组含3个元素,但只限最后一个元素为"test",第一个元素会被x赋值而继承到该判据所管辖的作用域内
print("First element is ", start, ", and the last is \"test\"")
:
print("Open ended array")
<|> 字典判据
和数组类似,字典也可以作为match语法的判据。与其他判据不同的是,字典判据要求被检测变量是字典,且字典中的键值对及其位置也要和判据的相一致。否则视为匹配失败。
字典判据也支持只输入键的形式,这个时候表示只要改键在被检测变量中一一对应即可,不需要检查其值。(这个模式称为键检查模式)
通配判据和赋值判据也可作为字典中的占位符来使用,可位于键处作为键占位符,也可位于值处作为值占位符。
字典判据同数组判据一样,也支持不定长度判据"..",位置一样是位于判据的最末端,表示只要".."前面的键值对与被检测字典对应部分的键值对一一对应,或者键检查模式下,".."前的键与被检测字典对应部分的键一一对应,即可匹配成功
# 摘自 Godot Doc
match x:
{}:
print("Empty dict")
{"name": "Dennis"}:
print("The name is Dennis")
{"name": "Dennis", "age": var age}:
print("Dennis is ", age, " years old.")
{"name", "age"}:
print("Has a name and an age, but it's not Dennis :(")
{"key": "godotisawesome", ..}:
print("I only checked for one entry and ignored the rest")
<|> 选择性判据
实际上,我们还会涉及到多个判据使用同一段代码的情况,这个时候,我们可以将这些判据写在同一行,并用","隔开,只要被检测变量符合这几个判据中的其中一个,即视为匹配成功:
# 摘自 Godot Doc
match x:
1, 2, 3:
print("It's 1 - 3")
"Sword", "Splash potion", "Fist":
print("Yep, you've taken damage")
[*]封装
封装(Encapsulation)是计算机编程术语,意思是“将对象的属性和实现细节进行隐藏的手段”。乍一听可能同学们会不太理解,我们这里就翻译一下这句话:所谓的封装,简单地来说,就是只把一些关键的,直接的信息暴露(Expose)出来给使用者,而使用者则不需要了解具体的代码。其实,我们所学的函数就属于最简单、最基本的封装。你们问我为什么敢这么说?我们回过头看看封装的核心概念之一:隐藏实现细节。没错,我们在编写函数的时候,也只有我们自己是知道要写的函数的具体算法的,对吧?但假如说你要把你这个函数供给其他人使用,这些人愿意看你这个函数的具体算法是什么吗?绝大多数都不会,反倒是他们希望“我直接用就行了,关你写成啥样个p事【”。没错,函数就是向这些使用者提供了一些关键的、直接的信息——即某一系列具体算法的接口(Interface),使用者只需要操作这个接口——也就是调用这个函数就可以了,无需知晓其内部具体算法。
封装还有一层意思:隐藏对象的属性。这一层意思在很多编程语言中都能够实现,比如:给一个成员属性(即数值量)添加一些访问限制关键字来限制访问范围,像在Java里,给一个成员属性加上private就可以让这个属性只能在这个类内部使用,别的类无法访问、调用这个成员属性,否则代码编辑器就会报错。
比较遗憾的是,Godot并不能实现这一点。也就是说,在Godot里,你在某个类里声明的任何属性,都可以被其他任何类所访问、调用。不过,我们的godot也很贴心地给了我们一个比较折衷的思路:
声明私有(private)变量时,应在每个变量名前加上"_"
这虽然还是无法改变该属性可被访问的范围,但是在面向开发这一方面,开发者可以很明显地知道:这是个私有属性,我最好不要去访问它。
这里我们规定:
[*]所有的私有(private)成分,包括私有属性、私有函数、私有信号等,其变量名均应以"_"开头
[*]所有的公有成分(只要不是私有,就都属于公有变量),其变量名均按标识符拼写规则正常拼写即可
[*]所有的私有成分,默认只允许在该脚本(该类)内部使用,不可以在其他类及其子类使用
[*]所有的公有成分,默认任何其他类均可使用之
此外,我们在基础篇所学的setget函数也是一种封装,但是这种封装十分特殊:它既封装了函数,同时又封装了属性的访问,因为在属性被调用修改时,如果有对应的set函数,则会触发该set函数而非直接给对应属性赋值;同理,当该属性被引用时,如果该属性含有对应的get函数,则会触发该get函数而非直接将值传递给被引用的地方,就相当于我利用了函数来修改/引用这个值,本质上还是调用函数,而我们前面也刚学过,调用函数本质上就是调用被封装过的一系列算法,因此访问一个带setget函数的属性,从这个角度上来讲,该属性就是被函数所封装的。
当然,对于私有属性,如果你希望它能够被外部的一些脚本/类所调用,那么最好使用setget函数来进行桥接,以保证私有属性的封装。
因此,在GDScript中,我们只能封装函数和带setget方法的属性,而对于一般属性的封装,我们只能通过增加前缀来进行伪封装(Fake encapsulation)
[*]Array和Dictionary的引用本质
我们已经在GDScript基础篇中学过了数组和字典这两个容器数据类型。然而,我们在处理一个Array/Dictionary给另一个Array/Dictionary传递值的时候,我们特别提醒:
如果一个Array/Dictionary直接将值赋给另一个Array/Dictionary,则这两个Array/Dictionary之间会进行相互绑定,改变其中一个Array/Dictionary的值,则另一个也会做出对应修改。因此,需要使用duplicate()方法将数值进行复制以后再将复制后的数值传递给另一个属性。
但是,我们并没有深入学习为什么会相互绑定。今天我们就来学习一下。
不过在学习这个本质之前,我们需要学习一下计算机中对数据的内存管理
<|> 数据的内存管理
在任何一门计算机语言中,像函数、属性等信息,在程序运行时都是存储在内存中的,声明这些信息时,计算机会给这些数据一个标签(Tag),然后再给这个标签指定一个地方来存储对应数据,这个标签可以是对应数据在编程语言中声明时的名称(量名、函数名等),而这个指定的地方,就是这个数据对应的内存空间(Memory space),我们要想获取这个内存空间里的数据,就需要知道这个内存空间如何获取,而内存地址(Address)就是用来表示、获取该内存空间的代名牌。每个数据的内存地址都是唯一确定的,因此,只要知道一个数据的地址,我们就能获取这个内存地址所指向的内存空间里的数据。
比如说,我声明了一个叫a的变量,那么在内存中,他就会类似于下面这种形式出现:
Tag : Address
a : 0x00073659在C++等语言中,我们可以用一些特殊符号来获取这个变量的地址:
int a = 10;
int s* = &a;然后利用一些手段来获取这个地址所对应的内存空间里的数据:
cout << s* << endl;我们甚至可以将多个标签绑定到一个地址上:
int a = 5;
int aa* = &a;
int ab* = &a;
cout << aa* <<;
cout << ab* << endl;这就是C++在数据的内存管理上的操作
<|> 引用类型
实际上,我们会发现,上述操作是将多个标签绑定到了同一个内存数据上,我们称这两个数据互为引用,当我们尝试通过其中一个标签来修改数值时,计算机就会在内存中找到这个标签所对应的内存空间,并将其内部的数据进行修改,但与此同时,另一个标签也绑定到了这个内存空间上,因此我们在之后引用这另一个标签时,所得到的值实际上就是前一个标签被修改后的值。
也就是说,我假设有两个变量a、b,他们都绑定到同一个内存空间上,其内部数据为int数值5,如果我通过a修改了这个数值为9,那么我引用b的时候,b的数据就不再是5了,而是这个修改后的数值9,此时a的数值也是9。这也就是说:无论何时何地,只要这两个标签绑定在同一个内存空间上,那么这两个标签的数值就是一模一样的,换句话说就是:这两个量是重合(Overlap)的。
<|> Array和Dictionary是引用类型吗?
实际上,数组和字典就符合我们上面所讲述的性质,这里我们只以Array为例,Dictionary可据此类推。
假设我们声明了一个数组arr:
var arr: Array = 当我们直接给另一个变量brr赋值时:
var brr: Array = a这两个变量arr和brr实际上就绑定到了同一个内存空间上:类似于下面这个关系图:
Tag: Address
arr : 0x10365467
brr : 0x10365467可以看到,arr和brr他们的内存地址是一模一样的,这就说明,它们两个绑定到了同一个内存空间上,共享同一个数据,也即是arr和brr重合。因此,当我们尝试
brr = 的时候,它实际上就等同于
arr = 这就解释了“相互绑定”这个名词究竟是怎么一回事儿。
那么,为什么duplicate()一下,这两个变量就不会“相互绑定”了呢?我们还回到数据的内存管理分析上,当brr按照如下操作赋值时:
brr = arr.duplicate()实际上就是计算机把arr所绑定的内存空间里的数据复制了一份,然后把复制出来的这份存储在了另一个内存空间里,再将brr绑定到这个新的内存空间上,就相当于按如下方式表示:
Tag : Address
arr : 0x10365467
brr : 0x1134AF7C显然,这个时候brr已经与arr不再共用同一个内存空间了,因此arr和brr是两个不同的数组,并不重合。
总而言之,Array/Dictionary都是引用类型的数据类型。
以上就是关于Array和Dictionary引用的实质了。
本帖最后由 电童·Isamo 于 2023-6-27 18:18 编辑
GDScript高级篇其四:动态调用、FuncRef和延迟处理上一节课中,我们学习了有关match的更加高级的语法,同时学习了封装理论,然后又解决了我们之前一直所提的Array和Dictionary的值“相互绑定”的实质。本节课中,我们将会把目光聚焦在函数的调用与处理上,来学习函数更加高级的调用操作
[*]动态调用函数
还记得我们在基础篇学习函数的时候是如何调用函数的吗?假设我声明了一个名叫fx函数,那么要想调用它,我们就需要这样写:
fx()对吧?像这样,我们直接用函数名加()的形式来调用一个函数,这样调用函数的方式叫做静态调用(Static calling),因为这种调用是直接在代码里写死的。
那么既然又静态调用,相对应地,如果我们是用字符串变量来存储一个函数名,然后通过某些手段来调用以这个字符串的值为名的函数,这种方式就叫做动态调用(Dynamic calling)
在Godot中,负责动态调用的函数有call()、call_deferred()和callv()。我们接下来分别讲一下这四个函数的作用:
[*]call(name: String, ...):半不定参数函数,第一个参数name表示要被调用的方法名,之后的所有参数均分别对应被指向的方法所对应的参数,比如我有一个函数,定义为a(b, c, d),那么就如果要用call()函数来调用函数a,那么就需要写成call("a", b, c, d),注意这里b,c,d的顺序,它们分别对应的就是a(b, c, d)中的b、c、d的顺序
[*]call_deferred(name: String, ...):延迟调用版的call(),一会儿再讲
[*]callv(name: String, args: Array):同call(),但是输入参数的部分需要使用数组来处理,还以a(b, c, d)为例。如果要用该函数来调用a(),那么就需要写成callv("a", )
对于SceneTree,由于节点组group的引入,我们就又有了两种调用一组节点的函数的方法:
[*]call_group(group: String, method: String, ...):对一个特定的组group内的所有节点,调用其脚本/类内部名为method的方法
[*]call_group(flags: int, group: String, method: String, ...):同call_grooup(),但是需要给定一个flag,这个方法我们暂时不考虑使用,仅供了解
静态调用最大的弊端,便是无法灵活地调用我们所需要的函数,而动态调用正好可以满足我们这个需求:
# 导出变量便是证明动态调用优越性最好的例证
export var method: String = "test"
func _ready() -> void:
# 这样就可以灵活地调用我们需要调用的函数了
call(method)
func test() -> void:
print("test!")然而,动态调用的弊端也很明显:编辑器并不会告诉我们这个函数是否存在,也就不会因此而报错,就可能会导致因为方法名输入错误而导致程序发生意外崩溃的情况。为了使我们的动态调用更加安全,我们需要使用has_method(name: String)这个函数来检查对应函数是否存在,如果存在,那么返回true,否则返回false。利用这个函数,我们就可以从根本上防止程序因无法找到对应函数而导致的意外崩溃。
# 导出变量便是证明动态调用优越性最好的例证
export var method: String = "test"
func _ready() -> void:
# 为了确保程序运行的安全,我们需要检查对应函数是否存在
# 只有对应函数存在才能调用该对应函数
if has_method(method):
# 这样就可以灵活地调用我们需要调用的函数了
call(method)
func test() -> void:
print("test!")
注:call*()【*表示后缀名,也可以不带】这一类函数的执行主语都是self,也就是说,call()所调用的都是调用者实例的脚本内/类内定义的方法,如果需要更换调用者,就需要写成:目标实例.call()
[*]函数作为变量——FuncRef
我们的函数都是直接声明在脚本/类内部的,但函数本身并不能直接作为一种对象来赋值给一个变量。然而在某些情况下,函数外包(Function export)还是一个非常重要且实用的一种操作,这里所说的“函数外包”指的就是将函数体本身作为一种对象来赋值给某个或某些特定的变量。而要想让函数本身作为一个值传给变量,我们就需要用一个东西来把这个函数打包起来,这个东西就叫做打包器(Wrapper),而在GDScript中,就有对函数进行打包的打包器——funcref()函数。
FuncRef打包器函数的用法如下:
funcref(对象名,一般填self,函数名,字符串)举个例子,比如我想将某对象里的函数打包给一个变量function,那么就要如下操作:
# 声明一个函数,就以test为名
func test():
# 函数体省略
pass
# 然后给变量t限制类型为FuncRef,然后用打包器函数打包test函数
var t: FuncRef = funcref(self, "test")
这样就把函数test打包给了变量t。需要注意的是,因为funcref()打包器函数返回的是一个FuncRef类的实例,因此如果需要限制变量类型的话,请将其限制为FuncRef。
如果想要调用被打包的函数,则需要使用FuncRef类的call_func()方法或者call_funcv()方法
# 接续上面那个例子
func _ready():
t.call_func()
需要注意的是,call_funcv()同callv()方法一样传入的是由参数组成的数组而非一个接着一个的参数!
在有些情况下,打包函数十分有用,但在Godot3里,这种优势还不是特别明显,但在Godot4中,这种优势就显得特别明显了,详见本帖有关Callable类(Godot4中代替FuncRef的类)的内容
以上就是打包函数的使用方法了
[*]延迟调用函数
一般来说,我们都可以随时随地地调用函数,然而实际情况是:在某些情况下,有一部分函数在调用时会导致程序运行时报错(虽然多数情况下不影响程序的正常运行,但出于程序安全考虑,还是建议要预防、处理一下这种报错),我们这里以add_child()函数为例:
const A: PackedScene = preload(打包场景的文件路径)
func somefunc() -> void:
var a: Node = A.instance()
get_parent().add_child(A)当somefunc()函数被调用时,理论上应该没什么问题,但有些情况下,他就会弹出如下报错:
Parent is busy adding child, please use call_deferred() instead
父节点添加子节点被占用,请使用call_deferred()处理没错,我们的电脑也不是说能总是完美地同时画方和圆的,有些时候,电脑也会出现手脚忙不开来的情况,这个时候,我们不妨考虑让这个动作稍微靠后执行一下,让电脑先去处理好它该处理的事情以后再来解决这个事情。这个时候,延迟调用(Deferred calling)就派上了用场
<|> Godot 延迟调用原理
实际上,Godot的延迟调用的本质,就是将本该执行的函数拖到即将进入到下一空闲帧前执行,通俗且粗略地讲,就是在所有代码执行完毕后执行。其实,大部分情况下,个人认为造成前文所述的报错的一个重要原因,就是代码在process类函数中被调用(也包括被process类函数中的函数调用等类似情况)时发生处理冲突而导致计算机无法正常处理本应该处理的事情。
<|> Godot 延迟调用函数
godot中有两类延迟调用的函数:call_deferred()和set_deferred()
[*]call_deferred(name: String, params...):在即将进入到下一空闲帧之前的那一刻调用名为name的函数,且分配其参数params到目标函数中去
[*]set_deferred(name: String, value):在即将进入到下一空闲帧之前的那一刻将值value赋给名为name的属性
如果我们使用call_deferred(),则可以解决前文所述的报错问题:
func somefunc() -> void:
var a: Node = A.instance()
get_parent().call_deferred("add_child", A)
注:对于callv()函数,我们可以使用call_deferred("callv", "func_name", )的形式来进行延迟调用
我们今后会学习有关碰撞箱的内容,碰撞箱有一个属性叫disabled,而如果我们直接对这个属性进行赋值操作,则在一些情况下会导致程序运行时报错。为防止因不明原因而导致的报错,我们最好使用set_deferred()把属性的值延迟赋给disabled属性
set_deferred("disabled", false)
# disabled属性类型为bool除此之外,对Node2D而言,global_*类属性是无法在未进入场景树中的节点进行赋值操作的,同样也需要用set_deferred()进行处理,方法类似上面的代码片段所示,这里就不再细说了。
此外,对于非报错的情况,延迟调用函数还可以用于调整函数调用或属性赋值的顺序,但由于应用太少,这里就不再展示了。感兴趣的同学可以自行探索。
以上就是本节课的全部内容了 非常详细的教程,帮助到我了,感谢! 本帖最后由 电童·Isamo 于 2023-7-7 18:19 编辑
GDScript高级篇其五:初始化虚函数及其调用顺序
上节课我们学习了一些关于函数调用的知识,了解到函数其实还可以进行动态调用,同时还学习了如何打包一个函数,以及一些延迟调用的操作。本节课,我们将深入学习Godot内有关初始化函数的一些知识。
[*]初始化函数
初始化函数,顾名思义,就是具有初始化功能的函数,在Godot中,对于所有对象而言,他们都有一个共同的初始化函数:构造函数(Construction function),当对象一被载入程序的时候,或者对象被new()函数(以后会讲)新建实例化的时候就会被立刻调用。构造函数是所有程序语言里最基础的初始化函数。对于Godot的节点而言,还有两类初始化函数,一类就是我们本帖里用于教学的_ready()函数,而另一个,就是_enter_tree()函数。
[*]构造函数初入
那么,刚刚我们知道了什么是构造函数,接下来我们就简单地学一下初始化函数_init()这个函数可以含参数,也可以不含参数,方法如下:func _init(参数可选):
<函数体>有关参数的内容,我们会在学习Object的时候进行讲解
[*]初始化函数的顺序
刚刚我们提到了,构造函数_init()是当所对应的对象一被程序加载的时候就被调用,因此,它是最先被调用的初始化函数而对于节点而言,_enter_tree()会先于_ready()执行,它是当节点被添加到节点树上的时候就会被调用的初始化函数,而当一切都准备就绪时,_ready()函数才会被调用。因此,对于节点而言,其初始化函数的调用顺序是(从左往右由先到后):_init() > _enter_tree() > _ready()
[*]使用这些初始化函数时需要注意的事项
那么有同学会问了:这三个初始化函数会对一部分代码运行的结果有影响吗?我的回答是:有。首先,最先被调用的构造函数_init()会导致节点无法获取节点树,也就是说,如果你尝试在_init()函数里调用get_tree()方法,则在程序刚开始运行时会报错。这是因为_init()是对象(再次强调,节点也是对象的一种)在被加载的一瞬间或者被new()创建实例的一瞬间所调用的,这个时候的节点还没有进入节点树,因此该节点并不能获取到节点树的信息其次,在_enter_tree()函数里是无法获取子节点的,因为_enter_tree()函数是在节点一进入场景树的时候就被调用的,而这个时候只有这个节点本身被加载进去了,其子节点还需要等待加载,因此如果你这个时候尝试去获取其子节点,则会返回null或者报错(取决于你所使用的获取节点的函数)。而对于_ready(),虽然它是最后一个执行的初始化函数,却是三个初始化函数里最安全的函数,你可以在这个函数里获取节点或者子节点。
[*]节点的加载顺序
那么,为什么_ready()函数就可以获取到子节点,而_enter_tree()函数不行呢?这就牵扯到这两个函数的执行逻辑以及最重要的_ready()的特殊性质了一般来说,这两个初始化函数都是节点进入场景树的一瞬间才执行的,且还是按节点树从上往下的顺序来执行的,而_enter_tree()函数前面也提到了,是在该节点一进入节点树后就会被调用的,而其子节点的_enter_tree()函数,则是要等到父节点的_enter_tree()函数结束后才会从上往下依次执行的。但_ready()就不一样了,虽然大体上依然是按节点树从上往下的顺序逐个执行_ready()函数,然而,当该节点含有子节点的时候,它会先去从上往下逐个执行其子节点的_ready()函数,等所有的子节点都执行完一遍_ready()函数后再回去执行父节点的_ready()函数,如果该节点的子节点里还有子节点,那么就如法炮制。大概就如下图所示:
-节点(执行码,从小到大逐个执行)
"-"表示子节点,"--"表示子节点的子节点
==节点树==A(7)-B(1)-B(4)--C(2)--C(3)-B(5)-B(6)
此外,在_ready()执行后,该节点就会被系统认定为已完全初始化状态,只有这个状态下的子节点才能够被父节点所获取,这就是_ready()函数的特殊性质,也是决定了_ready()函数内能够执行获取子节点操作的关键。
然而,对于兄弟节点的获取,_ready()依旧尤其局限性,尤其是如果A节点位于B节点上方,那么就不能在A节点里获取到兄弟节点B,但反过来B却可以获取到A,因为B在下方执行_ready()的时候A已经进入了完全初始化状态。
以上就是本节课关于初始化虚函数的调用及其顺序相关的内容了
页:
[1]