微机派对1.11最终更新带来了扩展的模组制作功能,包括自定义小游戏、头目关卡和场景。 在本教程系列的第二部分中,我们将学习如何创建自定义关卡以及如何编写自定义头目关卡的脚本。 1. 自定义小游戏 2. 自定义头目关卡 >> 你当前所在位置 << 3. 自定义场景 这些指南按顺序设计,建议按顺序学习,即使你只对某个特定部分感兴趣。 本指南不适合初学者。你需要具备一定的脚本知识,尤其是Lua脚本语言。 开始之前 本指南是《模组制作第一部分:自定义小游戏》的直接续篇。强烈建议您先阅读上一部分内容,因为我们不会重复之前学过的知识。 在这一部分,我们将学习如何制作自定义Boss关卡。每个自定义Boss关卡都需要自定义地图,因此我们也会学习如何使用MicroKit制作自定义地图。 作为示例,我们要制作的Boss关卡名为【史诗鸭子大战】。在这个Boss关卡中,玩家将共同对抗一只会投掷投射物的巨型鸭子。玩家击中鸭子可获得分数,被鸭子击中则会失去分数。当鸭子的生命值归零时,Boss战将提前结束。MicroKit:介绍与准备 MicroKit是MicroWorks的官方模组工具包。除了提供大量工具和辅助功能外,其主要功能是制作自定义关卡。 现在,请调整你的预期。这个编辑器不提供笔刷、地形创建等高级功能,所以如果你期望在此构建你的关卡设计作品集,那可能会让你失望! MicroKit的关卡编辑器本质上是一个实体编辑器。你通过创建、放置和编辑实体来构建关卡,供MicroWorks后续加载。其中一个实体当然是模型实体,你可以为其分配导入的模型。你的关卡核心将是你导入并放置的一系列模型的集合。这就是为什么你必须提前在外部3D编辑器(如Blender)中准备好所有关卡资源。MicroKit接受.obj和.nmd文件作为模型。 在这个示例中,我会创建一个非常简单的六边形竞技场。如果你不想自己创建,可以获取此关卡的最终.nmd文件,并导入它(确保同时获取相关的.nmf、.nmt和纹理文件!)

这里实际上包含两种模型——平台本身和散落在平台上的草丛。平台将启用碰撞功能,而草丛则会禁用碰撞功能。 MicroKit:入门指南 让我们启动MicroKit,然后点击顶部菜单栏的“文件”,再选择“新建”->“关卡”来创建一个新关卡。 关卡编辑界面将会打开。让我们来了解一下每个窗口的功能:

场景视图 场景视图用于显示你的关卡。将光标悬停在窗口上,按住鼠标右键即可开始移动视角。 * W键(或上方向键):向前移动 * A键(或左方向键):向左移动 * S键(或下方向键):向后移动 * D键(或右方向键):向右移动 * 空格键:向上移动 * 左Ctrl键:向下移动 松开鼠标右键即可停止移动并重新控制光标。 将光标悬停在场景视图中的实体上,点击鼠标左键可选中该实体。点击空白区域将取消选择。 选中实体后,选中区域中心会出现 gizmo 工具,通过按住并拖动各个控制柄,可以对选中的实体进行移动、旋转或缩放操作。将光标置于窗口上,按下键盘上的以下任意键可更改 gizmos( gizmo 工具): * T 键:位置 * R 键:旋转 * S 键:缩放 按下 Ctrl + D 可复制当前选中的对象。 资源浏览器 资源浏览器是您的导入资源库。您可以将资源分配给需要它们作为输入的实体,例如需要模型资源的模型,或需要声音资源的音频源。 只需点击【加载资源】,然后导航至您要导入的资源即可。 实体列表 实体列表显示关卡中所有当前实体。用鼠标左键选择它们,或用鼠标右键(或在选中实体时按键盘上的 Delete 键)移除它们。 有一些默认实体无法移除,因为它们是关卡不可或缺的部分。这些实体包括:方向光、后期处理和环境体积。 我们稍后会详细介绍所有实体,现在先来看其他窗口: 实体属性 当你选择一个实体时,其属性会显示在此窗口中。你可以在此调整它们的属性。 通常实体默认会有【变换】类别,用于控制它们在世界中的位置、旋转和缩放。 它们还会有【Lua属性】类别,可用于设置该实体是否对Lua脚本可见。当实体可见时,你可以通过脚本使用CustomLevelManager[agiriko.digital]:GetExposedEntityByName(name)获取对它的引用,其中“name”是该实体在编辑器中的名称。暴露实体的名称必须唯一,否则它们会相互覆盖。(例如,如果有两个暴露实体都名为“MyEntity”,那么尝试获取“MyEntity”时将只返回最后一个。) 之后,会有一个用于实体特定属性的类别。 要编辑字段,你可以双击该字段来设置新值,也可以按住并拖动来调整值。 关卡属性 此窗口实际上有两个标签页:关卡属性和视图属性。 关卡属性包含与你的关卡相关的属性。 - 关卡缩放会缩放关卡中的所有模型实体,但不会缩放其他实体。通常,在开始添加其他实体后,你不会想要调整关卡缩放。* 【死亡高度】决定了玩家低于该高度时游戏会判定玩家死亡。这在你制作可能会让玩家掉出边界的关卡时非常有用,能避免玩家一直坠落。 * 【生成虚空平面】决定是否在死亡高度生成一个碰撞平面。如果你的关卡封闭良好,或者没有使用不会消失的投射物武器(比如躲避球),通常不需要担心这个设置。虚空平面的存在是为了解决物理物体无限坠落并达到特定高度时会破坏物理系统的问题。在这种情况下,虚空平面可以像渔网一样捕获这些投射物。* 重生时间决定玩家死亡后重生所需的时长。设置为0可禁用重生。 * 背景音乐 - 将音频资源拖入此参数,可为该关卡设置自动播放的音乐。 Boss关卡请留空,因其有独立的音乐设置。 查看属性不会实际影响关卡,而是仅用于编辑器中调整场景相机的设置。这些设置大多不言自明。 实体 简要介绍各实体: * 方向光:控制关卡的整体光照,类似阳光。可调整强度、颜色和旋转角度(用于阴影)。 * 后期处理:后期处理效果的容器。有4种不同效果可用: - 雾:即雾气效果。你可以将其设置为体积光模式,该模式会使光线在雾中散射,从而产生体积光效果。体积光会被【反照率】颜色属性染上相应色调。 - 色彩调整:一些基础的图像后期处理,例如饱和度、对比度或颜色滤镜。 - 泛光:控制关卡中泛光的强度和色调。 - 天空:控制关卡的天空。拖动立方体贴图资源可设置天空盒。你可以使用【工具->天空盒创建器】来创建立方体贴图资源。 * 环境体积:在渲染管线中,全局光照会受到天空的影响。这意味着,例如,如果你的天空盒纹理是深蓝色,那么环境的阴影也会呈现深蓝色。 在MicroWorks中,我们已将其与实际渲染的天空解耦,使其成为一个内部但不可见的“天空”。这让我们能更好地控制环境的外观,无论天空呈现何种状态。 这正是环境体积所负责的功能。它本质上是另一种仅对全局光照产生影响的天空。

* 模型:3D模型。将模型资源拖入模型参数。 * 音频源:音频播放器。将声音资源拖入作为音频文件。 * 灯光:发光实体。每个动态灯光实体都有性能消耗,因此使用时要注意,尽量不要超过10个。 * 反射探针:捕捉周围场景并影响反光表面的实体。记得点击【烘焙】查看效果。最多允许8个反射探针。 * 生成点:玩家将在此处生成的点。 * 示例凯:精确尺寸的凯,用于测试关卡比例。它们不会被序列化到你的关卡中。 * 触发器:当玩家进入时会发送【TriggerEnterEvent[agiriko.digital]】事件的触发器,以及【TriggerLeftEvent[agiriko.】【数字】当玩家离开时触发。向lua公开并添加ListenFor以附加代码。 杀手:触碰后会杀死玩家的触发器。 标记点:用于标记特定位置的实体。向lua公开以获取其位置和旋转角度。 弹跳器:将玩家弹向指定目标点。需要为其分配一个【目标点】实体。 传送器:将玩家传送到指定目标点。需要为其分配一个【目标点】实体。 目标点:目标点位。可分配给弹跳器或传送器。 武器生成器:按设定间隔生成选定武器。将冷却时间设为0可使其不再重生。 MicroKit:创建关卡 MicroKit非常棒!既然我们已经了解了它的使用方法,就可以自由创建任何想要的关卡了。那么我们开始制作那个 boss 关卡吧。 我已经导入了我们之前创建的模型,并在 MicroKit 中编辑了它们的材质(以防你忘记,你需要在资源浏览器中选择资源,然后在实体属性中点击【编辑材质】)。 我还确保勾选了草地模型上的【碰撞启用】选项。 从实体列表中创建两个模型实体,分别指定舞台和草地模型,这样我们的几何结构就完成了!

现在让我们把关卡美化一下。我已经使用顶部菜单栏【工具】部分中的天空盒创建器制作了一个自定义天空盒。 天空盒是按以下顺序构建的,因此提供纹理时请确保所有内容都对齐:
创建天空盒后,将其提供给天空后处理以进行设置。
现在我们开始调整所有其他后期处理效果和光照,直到达到我们满意的效果。

看起来不错!现在剩下的就是设置一些生成点,我们还会添加几个武器生成器,里面有激光手枪,玩家可以捡起来和鸭子战斗。

搞定啦!自定义关卡已准备就绪!这没那么难,对吧? 微机工具箱:保存关卡 保存关卡非常简单,只需前往“文件”->“保存”(如果你尚未保存过该关卡,可选择“另存为”)。你也可以使用Ctrl + S快捷键。 保存关卡时,系统会生成一个.lua文件和一个assets文件夹。lua文件包含关卡的所有信息,这些信息将传递给MicroWorks;而assets文件夹自然存放你导入和使用的所有资源。 要让该关卡在MicroWorks中被识别为自定义关卡,我们必须将此lua文件(以及assets文件夹)放入StreamingAssets/CustomMaps目录下的一个文件夹中,该文件夹的名称需与你的lua文件名称相同。例如: StreamingAssets/CustomMaps/EpicDuckFight/EpicDuckFight.lua 就是这样!你的自定义关卡已准备就绪。你可以通过在开发者控制台中输入“load NAME”来加载它,其中“NAME”是你的lua文件名称。例如,“load EpicDuckFight”。但请确保在调用它之前,你已处于一个活动关卡中,比如沙盒模式。 你也可以创建一个场景(主菜单中的附加内容 -> 场景)来加载你的自定义关卡,不过这部分内容我们将在下一部分学习。 首领关卡:准备工作 准备首领关卡与准备微型游戏非常相似。让我们导航至StreamingAssets文件夹并创建一个“BossStages”文件夹。 在这个文件夹中,我们将创建一个“.bos”文件。和.mcg文件一样,.bos文件实际上是一个json文件,用于告知游戏这是一个首领关卡描述文件。 与不过,mcg中我们无法在一个文件内定义多个Boss关卡,而且还有更多设置需要填写: { "BossName": "EpicDuckFight", "Author": "noam 2000", "LevelName": "EpicDuckFight", "Script": " BossStages EpicDuckFight EpicDuckFight.lua", "Theme": " BossStages EpicDuckFight Music EpicDuckFight.ogg", "StartAfterDelay": 1, "MinPlayers": 1, "Length": 90, "Icon": " BossStages EpicDuckFight Textures Icon.png", "Background": " BossStages EpicDuckFight Textures Background.png", "I18n": { "en": " BossStages EpicDuckFight Locale en.json" } } 小贴士:此描述文件也可通过MicroKit生成,操作路径为文件->新建->Boss关卡。 * BossName:Boss的内部名称,将用于本地化。 * Author:作者名称。* 关卡名称:自定义关卡的名称。 * 脚本:要执行的脚本文件路径。 * 主题:音乐文件的路径。 * 延迟后开始:决定加载关卡后等待多长时间开始 boss 战。 * 最小玩家数:启动此 boss 战需要多少名玩家? * 时长:此 boss 阶段应持续多久?(以秒为单位) * 图标:boss 图标纹理的路径。 * 背景:boss 加载屏幕背景纹理的路径。 * 国际化:本地化文件路径表。 这次需要准备的东西可真不少!让我们从本地化文件开始吧。你的本地化文件必须包含四个必填项: Boss名称、Boss描述,每种游戏模式各一项(LPS继承自生存模式描述,Boss Rush继承自积分赛模式描述)。键名如下: * BossStage_NAME * BossStage_NAME_Description_Pointmatch * BossStage_NAME_Description_Scorematch * BossStage_NAME_Description_Survival 其中“NAME”是你在.bos文件中提供的名称。例如: { "values": { "BossStage_EpicDuckFight": "Epic Duck Fight", "BossStage_EpicDuckFight_Description_Pointmatch": "尽可能多地击中鸭子,同时避免死亡!"* 每25次命中得1分 * 死亡时失去2分 * 造成最后一击可获得额外1分 史诗鸭战斗_描述_积分赛:尽可能多地对鸭子造成命中,并避免死亡! * 每次命中得30分 * 死亡时失去1000分 * 造成最后一击可获得500分奖励 史诗鸭战斗_描述_生存赛:尽可能多地对鸭子造成命中,并避免死亡! * 每50次命中获得1条命 * 死亡时失去1条命现在让我们准备纹理。 对于图标,需要1:1比例的纹理,分辨率最好为256x256或512x512。 对于背景,比例有点特别——2.874:1。这是因为它需要适应宽屏显示器,但要注意大多数时候,两侧是不可见的。推荐分辨率为1600x556、2048x713,如果你确实需要更高分辨率,也可以选择4096x1426。在MicroWorks中,Boss关卡背景通常是模糊的,所以事先模糊图像是个好主意。
最后但同样重要的是音乐。请准备好任意你喜欢的.ogg格式音轨,并将其放置在指定路径。再次特别感谢Musearys和Tyra为这个 Boss 关卡制作了音轨。 同时记得准备.lua脚本。一旦我们手头集齐所有资源,就会开始着手处理它。 Boss 关卡:资源 现在我们需要准备 Boss 关卡的所有资源。我们将从音效开始,因为我们要在关卡中嵌入部分音效。 我们需要为以下每种情况准备音效: * 鸭子出现(当鸭子生成时) * 鸭子受击(当鸭子被击中时。我会制作5种不同的变体音效,并在每次鸭子被击中时随机切换)* 鸭子被击败(当鸭子被打败时) * 开炮(当鸭子开火时) * 投射物爆炸(当鸭子的投射物爆炸时) * 命中反馈(当我方投射物击中鸭子时,一种不错的额外反馈效果)

和往常一样,你可以从文件中获取这些音效。 接下来是模型方面。我们需要两种模型:鸭 boss 本体以及鸭 boss 的投射物。 我们要将鸭 boss 放置在关卡内,并将其暴露给 lua,这样就能从脚本中获取对它的引用。不过鸭的投射物是动态生成的,因此我们必须将它们导入到 MicroKit 中并导出,以便之后进行动态生成。 让我们再次启动 MicroKit 并加载我们的关卡(导航至我们之前生成的 .lua 文件)。我会使用上一篇微游戏指南中用过的鸭子模型,但会给它装上手臂加农炮来发射投射物。

我会将模型命名为【DuckBoss】,并确保它能被Lua脚本调用。现在只需调整其缩放比例并将其放置到你想要的位置即可。
看起来很棒!不过,实际上,当 Boss 战开始时,我希望鸭子能从下方升起,以营造更强烈的戏剧效果。所以我要记录下鸭子的起始位置和结束位置,这样之后就能通过代码来实现它的缓动效果: 起始位置:0, -45.849, 62.732 结束位置:0, 5.849, 62.732 记下来了!现在还有最后一件事——我们要在关卡中添加几个音频源。每个加农炮配两个,鸭子本身配一个主音频源。我们会将它们暴露给 Lua,这样就能获取它们的引用,并在需要时播放。我们还会利用加农炮声音的位置来生成投射物。 创建音频源,将它们放置在相关位置,给它们起一个合适的名称,并将其暴露给 Lua。对于加农炮的音频源,由于其声音不会改变,我们可以直接导入已为其创建的声音并在实体中进行设置,这样能节省一些编码时间。

准备就绪!在保存关卡前,我们先把鸭子和音频源移到之前记录的起始位置。当我们加载关卡时,鸭子会在底部,当 Boss 开始时,鸭子会上升。我们将通过代码把音频源设为鸭子的子物体,以确保它们能和鸭子一起移动。 保存关卡后,我们快速制作鸭子的投射物,它们就是红色的鸭子。在我导入并调整材质后,我会将碰撞设置为禁用。我们将通过代码为这些鸭子添加玩家触发器,而且为此不需要它们有碰撞(在这种情况下更建议禁用碰撞,这样就不会干扰触发器)。

投射物已设置完成。我们右键点击资源并将其导出至:“StreamingAssets/BossStages/EpicDuckFight/Models/DuckProjectile/DuckProjectile.nmd”。我们的资源已准备就绪,关卡中也已填充所需实体。接下来让我们进入代码部分! 首领关卡:脚本定义 打开首领描述符中指定的.lua脚本,开始填写定义内容。在代码中设置首领关卡比设置微型游戏简单得多。我们无需调用多个特殊函数,也无需提供大量新参数。实际上,所有内容可归结为: return { OnBossBegin = function() end, OnBossTick = function() end, OnBossEnd = function() end } 没错!我们只需从脚本中返回一个表格,该表格包含以下3个键值定义: * OnBossBegin - 当Boss阶段开始时执行一次的函数。 * OnBossTick - 在Boss阶段运行期间每帧执行的函数。 * OnBossEnd - 当Boss阶段结束时执行一次的函数。 与微型游戏类似,这些函数将在所有人(主机和客户端)上运行。 我们可以在返回范围之外创建这3个函数,使代码更易于编写,然后将这些函数传递给返回值。以下是《Epic Duck Fight》的相关功能说明: --- Boss 功能 --- -- 为玩家奖励分数的函数 local function AwardScore(player) -- 仅主机允许调整分数 if not worldInfo:IsServer() then return end -- 根据游戏模式奖励分数 if coordinator:IsPointmatch() then player:AddBossScore(pointsAward) elseif coordinator:IsSurvival() then player:AddLives(lifeAward) else player:AddBossScore(scoreAward) end end -- 为玩家奖励额外分数的函数 local function AwardBonusScore(player) -- 仅主机允许调整分数 if not worldInfo:IsServer() then return end -- 游戏结束时添加额外生命没有意义。如果协调器:处于生存模式() 则 返回 结束 -- 根据游戏模式奖励分数 如果协调器:处于积分赛模式() 则 玩家:添加首领分数(分数奖励) 否则 玩家:添加首领分数(得分奖励) 结束 结束 -- 从玩家处扣除分数的函数 本地函数 扣除分数(玩家) -- 仅主机允许调整分数 如果 世界信息:不是服务器() 则 返回 结束 -- 根据游戏模式扣除分数 如果 协调器:处于积分赛模式() 则 玩家:添加首领分数(扣除分数 * -1) 否则如果 协调器:处于生存模式() 则 玩家:减少生命数(扣除生命数) 否则 玩家:添加首领分数(扣除分数 * -1) 结束 结束 首领关卡:逻辑部分 2 现在我们将学习如何制作生命值条用户界面。 在MicroWorks的lua中,有两种可用的用户界面元素:UIImage和UIText数字](两者都可以通过worldInfo:CreateEntity(Entity.UIImage/Entity.UIText)创建)。 这些UI元素在其变换中包含比常规对象更多的信息,因此具有特殊的RectTransform[agiriko.digital]组件。当你想要获取UI元素的变换时,你需要调用":GetRectTransform()"而不是":GetTransform()"。 例如,使用矩形变换,你可以通过SetSizeDelta设置UI元素的XY大小。你还需要设置锚点和轴心等属性。 锚点允许你将UI元素“锚定”到其父矩形的某一侧(默认情况下是整个屏幕)。例如,如果我们将图像锚定在屏幕的右上角,那么锚定位置将相对于该角落,即便是在不同的宽高比下进行游戏,我们也能确保该UI元素会出现在所有人屏幕的右上角,无论使用何种显示器。 锚点还能让UI元素从一侧拉伸到另一侧。你会注意到有“AnchorMin(锚点最小值)”和“AnchorMax(锚点最大值)”。例如,如果我们将锚点最小值设为(0,0)(左下角),锚点最大值设为(1,1)(右上角),图像就会拉伸至整个屏幕。-- 当 Boss 开始时调用 local function OnBossBegin() end -- 当 Boss 计时时调用 local function OnBossTick() end -- 当 Boss 结束时调用 local function OnBossEnd() end return { OnBossBegin = OnBossBegin, OnBossTick = OnBossTick, OnBossEnd = OnBossEnd, } 太好了!我们可以开始处理逻辑了。 Boss 阶段:逻辑第一部分 准备好,这部分内容会很多!我们有很多新东西要学。 在大多数情况下,我们会以相当简单和基础的方式来处理问题。实际上,我们要从服务器同步到客户端的唯一内容就是鸭子射击的时机和鸭子被杀死的时机。 让我们从变量和参数开始。让我们获取那些需要频繁调用的类,并加载我们创建的资源: -- 资源根目录路径 local contentRoot = " BossStages EpicDuckFight" -- 获取协调器 local coordinator = worldInfo:GetCoordinator() -- 获取自定义关卡管理器 local customLevelManager = worldInfo:GetCustomLevelManager() -- 我们的资源 local duckProjectile = LoadResource(contentRoot .. " Models DuckProjectile DuckProjectile.nmd", ResourceType.Model) local projectileExplodeSound = LoadResource(contentRoot .. " Sounds ProjectileExplode.ogg", ResourceType.Audio) local hitRegisterSound = LoadResource(contentRoot .. " Sounds HitRegister.ogg", ResourceType.Audio) local duckStartSound = LoadResource(contentRoot .. " Sounds DuckRising.ogg", ResourceType.音频) local duckEndSound = LoadResource(contentRoot .. " Sounds DuckKilled.ogg", ResourceType.Audio) local duckHitSound = { LoadResource(contentRoot .. " Sounds DuckHit_01.ogg", ResourceType.Audio), LoadResource(contentRoot .. " Sounds DuckHit_02.ogg", ResourceType.Audio), LoadResource(contentRoot .. " Sounds DuckHit_03.ogg", ResourceType.Audio), LoadResource(contentRoot .. " Sounds DuckHit_04.ogg", ResourceType.Audio), LoadResource(contentRoot .. " Sounds DuckHit_05.ogg", ResourceType.Audio), } local bossIcon = LoadResource(contentRoot .. " Textures Icon.png", ResourceType.纹理) 让我们从MicroKit中获取之前公开的实体: -- 我们的实体 local duckBoss = customLevelManager:GetExposedEntityByName("DuckBoss") local duckAudioSource = customLevelManager:GetExposedEntityByName("DuckMainSound") local duckCannonSoundR = customLevelManager:GetExposedEntityByName("DuckCannonSoundR") local duckCannonSoundL = customLevelManager:GetExposedEntityByName("DuckCannonSoundL") 在这个Boss关卡中,我们将学习如何制作UI,为鸭子Boss创建一个生命值条。让我们为将作为生命值条的图像实体以及良好、中等和不良生命值的颜色预留空间: -- UI实体 local healthBar local goodHealthColor = Color(0.333, 0.921, 0.203, 1) local midHealthColor = Color(0.921, 0.752, 0.203, 1) local badHealthColor = Color(0.内部数据:创建一个名为【playerData】的表格,该表格将包含每位玩家的信息,并以玩家的网络ID作为索引。例如,若要记录某玩家射击鸭子的次数,可将该信息保存至【playerData[player:GetNetworkID()].hits】。 此外,创建一个可在指定范围内生成随机值的伪随机数生成器(PRNG)。尽管无需同步这些值(这正是PRNG的优势所在),但它会以随机值作为预种子,通常比Lua的math.random函数更可靠。该生成器还包含其他有助于随机性的功能,例如【Chance()】函数。-- 内部 -- 用于存储玩家信息的表格 -- 其键将是玩家网络ID(可通过:GetNetworkID()获取) local playerData = {} -- 伪随机数生成器 local PRNG = MakePRNG() 最后是属性数据: -- Boss属性 local pointsAward = 1 local pointsBonus = 1 local pointsRemove = 2 local hitsToPoint = 25 local scoreAward = 30 local scoreBonus = 500 local scoreRemove = 1000 local hitsToScore = 1 local lifeAward = 1 local lifeRemove = 1 local hitsToLife = 50 -- 鸭子属性 local duckActive = false local duckHP = 7500 local duckBonusHPPerPlayer = 725 local duckCurrentHP -- 射击属性 local delayBetweenShots = 0.375 local minShootCount = 2 local maxShootCount = 12 local minShootBreak = 1.5 local maxShootBreak = 4 local projectileTravelTime = 1 让我们创建一些函数,以便根据游戏模式来授予或扣除分数。 对于基于分数的游戏,你需要对玩家调用:AddScore()或:AddBossScore()。对于基于生命的游戏,你需要调用:AddLives()或:SubtractLives()。 这些函数必须仅从主机调用,因为它们被标记为(Server)。 制作 boss 关卡时,处理得分时必须确保涵盖所有可用的游戏模式。coordinator[agiriko.【digital】包含帮助你检查当前正在游玩的游戏模式的函数: * IsPointmatch() * IsScorematch() * IsSurvival() * IsLPS() * IsBossRush() 不过,大多数时候,你只需要检查积分赛和生存模式,其余模式都可以采用与计分赛相同的计分规则。这是因为在LPS模式中没有 boss 关卡,而 Boss Rush 本质上就是计分赛。

轴心点用于设置矩形的中心点。位置、旋转和缩放都围绕轴心点进行,例如,如果垂直缩放一个轴心点在中心的元素,其上下两侧会分别向各自的方向缩放。但如果将轴心点设置在顶部,该元素则只会向下缩放。 假设你将锚点设置在屏幕的右上角,这意味着锚定位置(0,0,0)会将元素放置在屏幕的右上角,而元素实际如何与该角落对齐,则取决于其设置的轴心点。

感到困惑?没关系!让我们来创建生命值条。 我们首先制作一个透明背景,它将作为实际彩色生命值条和Boss图标的父级。之后当我们销毁该背景时,其内部的子物体也会一并被销毁。 ——为鸭子Boss创建生命值条UI local function CreateHealthBar() ——创建背景 local healthBarBackground = worldInfo:CreateEntity(Entity.UIImage) healthBarBackground:SetColor(Color(0.03, 0.03, 0.03, 0.5)) ——对于UI对象,我们需要获取一个【RectTransform】。local healthBarBackgroundTransform = healthBarBackground:GetRectTransform() -- 锚点决定UI元素相对于屏幕的锚定位置(有助于管理不同的宽高比) -- 每个轴(X或Y)上0表示左/下,1表示右/上 -- 我们希望生命值条位于屏幕的中上方 -- 因此锚点设置为(0.5,1)(水平中点,垂直顶点) healthBarBackgroundTransform:SetAnchorMin(Vector3(0.5, 1, 0)) healthBarBackgroundTransform:SetAnchorMax(Vector3(0.5, 1, 0)) -- 现在我们将设置UI元素的轴心点 -- 我们希望轴心点也位于中上方 -- 这样锚定位置(0,0,0)就能完美对齐屏幕顶部 healthBarBackgroundTransform:SetPivot(Vector3(0.ListenToCollisions() 是一个可在游戏对象[agiriko.digital]上调用的函数,当投射物或玩家接触该对象时(若 addPlayerTrigger 设为 true),它会使该对象触发“ObjectCollision”事件。 我们将为此事件启动一个 ListenFor,并将 duckBoss 游戏对象作为调用者传入,这样只有当鸭 boss 被击中时,我们提供的代码才会执行。 从该事件的负载中,我们将获取击中鸭子的投射物,并从该投射物中获取更多信息,特别是发射它的玩家以及它造成的伤害。然后我们会将这些信息传递给另一个函数 OnDuckHit(player, damage)。 Boss 阶段:逻辑第 4 部分 OnDuckHit 是一个相当复杂的函数,所以我们现在就来定义它,并逐步讲解。首先,如果鸭子未处于活跃状态,我们将从该函数返回——我们不希望在鸭子被击败后继续执行此代码,即使它仍受到攻击。 ——如果鸭子未激活,返回 if not duckActive then return end 接下来,我们将从鸭子的当前生命值中减去 projectile 造成的伤害。我们还要处理反馈,比如摇晃鸭子的位置,或播放击中确认音效。 ——首先,减去鸭子的当前生命值 duckCurrentHP = duckCurrentHP - damage ——短暂摇晃鸭子 duckBoss:GetTransform():ShakePosition(0.1, Vector3One(), 100) ——如果是我们造成的击中,播放击中确认音效 if player:IsLocalController() then hitRegisterSound:SetVolume(0.4) hitRegisterSound:SetPitch(PRNG:RangeF(0.875, 1.125)) hitRegisterSound:PlayOnce2D() end 之后,我们必须首先检查鸭子当前的生命值是否为0或以下。如果是,那就意味着我们赢了,我们可以调用鸭子被击杀的函数并返回,以防止其余代码运行。 由于鸭子的死亡需要同步,我们只会在主机上调用该函数,主机将把信息同步给客户端。 哦,对了——还要向造成最后一击的人奖励额外分数。 -- 如果鸭子当前生命值低于0,我们获胜 if duckCurrentHP <= 0 then -- 主机必须通知所有客户端击杀鸭子 if worldInfo:IsServer() then -- 但首先,向造成最后一击的玩家奖励一些额外分数 AwardBonusScore(player) OnDuckKilled() worldGlobals.KillDuckRPC() end return end 我们会记下来,稍后再回来创建“OnDuckKilled()”函数(以及RPC),但现在,得继续处理OnDuckHit。 我们知道,过了这一步鸭子还没被杀死,我们可以编写代码来处理它被击中的情况。首先,让我们更新血条: -- 更新血条 UpdateHealthBar() 然后,从我们在表格中定义的音效里播放一个随机的击中音效。为了避免音效过于频繁,我们设置有20%的几率播放(看到了吧?我说过伪随机数生成器会派上用场的!) -- 播放随机击中音效 -- 但只有20%的几率。否则可能会太吵。如果随机数生成器判定20%概率成功,则 鸭子音频源:设置音频剪辑(鸭子击中音效[随机数生成器:范围内随机(1, 鸭子击中音效总数)]) 鸭子音频源:播放() 结束 现在我们需要处理计分系统。我们将在已创建的玩家数据表格中记录玩家击中鸭子的次数。 -- 增加玩家的击中次数 如果玩家数据[玩家:获取网络ID()]为空,则 玩家数据[玩家:获取网络ID()] = {击中次数 = 0} 结束 玩家数据[玩家:获取网络ID()].击中次数 = 玩家数据[玩家:获取网络ID()].击中次数 + 1 然后我们将根据游戏模式以及在变量中定义的所需击中次数,检查玩家是否有资格获得分数奖励。现在我们由主机处理计分 如果worldInfo:IsServer(),则 本地requiredHitsToScore = hitsToScore 如果coordinator:IsPointmatch(),则 requiredHitsToScore = hitsToPoint 否则如果coordinator:IsSurvival(),则 requiredHitsToScore = hitsToLife 结束条件 如果playerData[player:GetNetworkID()].hits除以requiredHitsToScore的余数为0,则 调用AwardScore(player) 结束条件 结束条件 这样应该可以了!现在让我们回到我们想要创建的OnDuckKilled()函数。 当鸭子被击杀时,我们将: 1. 将"duckActive"设为false 2. 销毁生命值条 3. 播放结束音效 4.将鸭子放回起始位置并提前结束 Boss 战 -- 鸭子被击杀时运行的函数 local function OnDuckKilled() -- 将 duckActive 设置为 false duckActive = false -- 销毁血条 healthBar:GetRectTransform():GetParent():GetGameObject():Destroy() -- 播放鸭子被击杀的音效 duckAudioSource:SetAudioClip(duckEndSound) duckAudioSource:Play() -- 将其缓动回起始位置 local unriseTime = 5 duckBoss:GetTransform():TweenPosition(Vector3(0, -45.849, 62.732), unriseTime) -- 我们将等待 2 秒然后结束 Boss 战 if worldInfo:IsServer() then RunAsync(function() Wait(Seconds(2)) coordinator:ForceStopRound() end) end end 我们还将创建服务器到客户端的远程过程调用,用于通知客户端击杀鸭子。 -- 服务器到客户端远程过程调用 -- 击杀鸭子。RPC("KillDuckRPC", 网络发送方式.多播, 传输可靠性.可靠, 函数() 若世界信息:是否为服务器()则 返回 结束 调用鸭子被击杀事件() 结束) 首领关卡:逻辑部分5 我们的首领现在可以被攻击且能被击杀,但它不会反击。所以让我们通过创建在"StartDuck()"中调用的"DoProjectileLoop()"函数来解决这个问题。 射击循环将由主机专门处理。每隔几秒,鸭子会进入攻击模式,此时它会获取游戏中所有玩家,并在短延迟内朝随机目标进行设定次数的射击。完成攻击后,它会进入一段休息时间,之后再次开始攻击。 主机会根据目标的地面位置确定发射投射物的方向。然后,主机将更新客户端以生成并向该位置发射投射物。 首先,我们必须确保此函数不在客户端上运行: -- 如果不是主机则返回 if not worldInfo:IsServer() then return end 然后,我们将在RunAsync中创建一个非常大的while循环,只要“duckActive”为true,该循环就会一直运行。当鸭子死亡时“duckActive”被设为false,循环就会停止。-- 运行异步 RunAsync(function() -- 在鸭子激活期间持续循环 while duckActive do -- 获取随机射击次数 local shootCount = PRNG:Range(minShootCount, maxShootCount) -- 获取所有玩家 local players = worldInfo:GetAllActivePlayers() -- 每次射击都瞄准随机玩家 -- 并等待射击延迟 for i = 1, shootCount do -- 获取随机目标 local target = players[PRNG:Range(1, #players)] -- 获取发射火炮 -- 如果i是偶数,将从右侧火炮发射 -- 否则,从左侧火炮发射 local fireFromRight = i % 2 == 0 -- 获取目标位置 local targetPos = target:GetTransform():GetPosition() -- 我们将从目标位置底部发射一条射线 -- 以确定投射物的发射位置 local targetGroundInfo =5, 1, 0)) -- 现在我们将设置位置和大小 -- 我们将Y位置偏移-100,以便在屏幕顶部和生命值条之间留出一些空间 healthBarBackgroundTransform:SetAnchoredPosition(Vector3(0, -100, 0)) healthBarBackgroundTransform:SetSizeDelta(Vector3(400, 30, 0)) 然后是生命值条本身。 -- 现在让我们在背景内部创建实际的生命值条 -- 因为我们在创建背景之后创建生命值条,所以它将渲染在背景之上 healthBar = worldInfo:CreateEntity(Entity.UIImage) healthBar:SetColor(goodHealthColor) -- 我们将把生命值条作为子物体附加到背景上, -- 这样锚点将相对于背景矩形,而不是整个屏幕。CastRay(目标位置, 向量3向下(), 50) -- 如果命中对象为空,可能是玩家悬停在地面上方 -- 这种情况下,我们将保持玩家的位置 如果对象存在(目标地面信息:获取命中对象()) 那么 目标位置 = 目标地面信息:获取命中位置() 结束 -- 现在我们知道了投射物的发射位置,我们将拆分每个轴 -- 因为我们无法向RPC传递向量 局部 位置X = 目标位置.x 局部 位置Y = 目标位置.y 局部 位置Z = 目标位置.z -- 最后,运行RPC 世界全局.ShootProjectileRPC(posX, posY, posZ, fireFromRight) -- 等待射击延迟 Wait(Seconds(delayBetweenShots)) end -- 等待随机时长的射击间隔 Wait(Seconds(PRNG:RangeF(minShootBreak, maxShootBreak))) end end) 这里的内容比较复杂,让我们逐步解析: * 设置本地变量shootCount,该变量将决定鸭子在此次攻击中的射击次数。 * 获取所有玩家,并开始一个循环,循环次数为shootCount的值。 * 通过从玩家列表中随机选择一名玩家来确定目标。 * 选择要发射的大炮。如果当前循环索引为偶数,则从右侧发射;否则,从左侧发射。 * 获取目标的位置,并向下发射一条射线,以检查目标下方地面的位置点。如果它们下方确实有地面,我们将使用射线检测获得的点覆盖目标位置。 现在我们拆分位置向量,因为无法通过RPC发送向量。 我们使用收集到的数据调用RPC,并在进行下一次射击前等待射击延迟。 循环结束后,我们等待射击间隔延迟,然后重新开始新一轮攻击。 现在让我们定义ShootProjectileRPC和ShootProjectile函数: -- 服务器->客户端RPC -- 向指定位置发射投射物 RPC("ShootProjectileRPC", SendTo.Multicast, Delivery.可靠的,函数(目标X,目标Y,目标Z,从右侧发射) 发射投射物(目标X,目标Y,目标Z,从右侧发射) 结束) -- 向指定位置发射投射物的函数 本地函数 发射投射物(目标X,目标Y,目标Z,从右侧发射) -- 获取要发射的加农炮 本地 加农炮 如果 从右侧发射 则 加农炮 = 鸭子加农炮声音右 否则 加农炮 = 鸭子加农炮声音左 结束 -- 播放发射声音 加农炮:设置音高(伪随机数生成器:范围F(0.9,1.1)) 加农炮:发射() --构建目标点 本地目标点 = 三维向量(目标X, 目标Y, 目标Z) --生成投射物 本地投射物 = 鸭子投射物:生成(加农炮:获取变换():获取位置(), 单位四元数()) --使投射物朝向目标点 投射物:获取变换():看向(目标点) --使投射物监听碰撞 投射物:监听碰撞(真, 1.75) -- 监听此投射物的物体碰撞 ListenFor("ObjectCollision", function(pay) -- 若碰撞涉及玩家,则获取该玩家 -- 若玩家是我方,则我们会自杀 -- 无论何种情况,我们都会销毁投射物 if pay:HasPlayer() then local player = pay:GetPlayer() if player:IsLocalController() then player:Kill() end DestroyProjectile(projectile) end end, projectile) -- 现在让投射物向目标猛冲 projectile:GetTransform():TweenPosition(targetPoint, projectileTravelTime) -- 等待移动时间后,让投射物爆炸 RunAsync(function() Wait(Seconds(projectileTravelTime)) DestroyProjectile(projectile) end) end ShootProjectile()也并非易事。让我们分析一下它的作用: * 首先,我们根据提供的“fireFromRight”布尔值来确定从哪门 cannon(炮台)发射。还记得我们说过在 MicroKit 中设置音频源时,会利用音频源的位置来确定发射位置吗?现在就是它们发挥作用的时候了! * 确定发射的炮台后,我们播放所选的音频源,并使用提供的目标位置轴重新构建三维向量。 * 我们从音频源的位置生成 duck projectile(鸭子投射物)模型,并将其旋转以朝向目标点。 * 我们让投射物监听碰撞,并将“addPlayerTrigger”设置为 true,因为我们希望它在玩家接触到时做出反应。我们还会将边界乘数设置为 1。75,因为我们希望它具有【致命性】。 现在我们为这个投射物触发的【物体碰撞】事件设置监听器。如果碰撞涉及玩家,我们会获取该玩家并检查是否是我们自己。如果是,我们就会自我消灭。 无论该玩家是否是我们自己,我们都会用一个即将定义的函数销毁投射物。 现在我们的小家伙已准备就绪,可以进行攻击了,我们将按照变量中设置的飞行时间,让它缓动到目标位置。 飞行时间结束后,我们会用同一个销毁投射物函数来销毁它。-- 用于销毁投射物的函数 local function DestroyProjectile(projectile) -- 若投射物已被销毁,则返回 if not ObjectExists(projectile) then return end -- 播放投射物爆炸音效,无需音频源 -- (没错,这是可行的,只是可操作选项会少一些!) projectileExplodeSound:SetVolume(0.5) projectileExplodeSound:SetPitch(PRNG:RangeF(0.9, 1.1)) projectileExplodeSound:PlayOnce(projectile:GetTransform():GetPosition()) -- 销毁投射物 projectile:Destroy() end 当我们调用"DestroyProjectile(projectile)"时,会首先检查投射物是否已被销毁(若已销毁,其值将为nil)。如果投射物处于激活状态,我们将直接从资源中播放已加载的爆炸音效,无需生成音频源。 然后我们会实际销毁该投射物。呼! 首领关卡:逻辑部分6 现在,我们需要处理的最后一件小事是从死亡玩家的分数中进行扣除。为此,我们将为【PlayerDied】事件添加一个监听器,并从负载中获取死亡玩家的信息。-- 我们希望将锚点和轴心点设置在左侧,这样(0,0,0)就位于背景的左侧。 -- 并且将轴心点设为左侧后,调整宽度时它只会朝一个方向(向左)移动。 local healthBarTransform = healthBar:GetRectTransform() healthBarTransform:SetParent(healthBarBackgroundTransform) healthBarTransform:SetAnchorMin(Vector3(0, 0.5, 0)) healthBarTransform:SetAnchorMax(Vector3(0, 0.5, 0)) healthBarTransform:SetPivot(Vector3(0, 0.5, 0)) healthBarTransform:SetSizeDelta(healthBarBackgroundTransform:GetSizeDelta()) healthBarTransform:SetAnchoredPosition(Vector3Zero()) 然后,最后是首领图标: -- 最后,我们只需将首领图标放在生命值条旁边 local bossIconImage = worldInfo:CreateEntity(Entity.-- 监听玩家死亡 -- 若玩家死亡,我们将扣除分数 监听事件("PlayerDied", 函数(参数) -- 仅在主机端执行 若 世界信息:是否为服务器() 为否 则 返回 结束 局部 玩家 = 参数:获取死亡玩家() 扣除分数(玩家) -- 重置该玩家的击中计数器 若 玩家数据[玩家:获取网络ID()] 为 nil 则 玩家数据[玩家:获取网络ID()] = { 击中数 = 0 } 结束 玩家数据[玩家:获取网络ID()].击中数 = 0 结束) 这部分内容相当多!但首领关卡通常都是复杂的设计。上述所有内容都来自于首领战开始函数。 至于首领战每帧更新函数,我们打算...嗯,实际上什么都不做。我们已经在首领战开始函数中涵盖了逻辑循环。 而在首领战结束函数中,我们只是...需要将“duckActive”设为false,以防玩家在计时器结束前未击杀鸭子,避免鸭子在 Boss 战结束后继续射击。 -- Boss 战结束时调用 local function OnBossEnd() -- 若鸭子未被击杀,我们将 duckActive 设为 false duckActive = false end 这样就完成了!Boss 关卡现已制作完成并可进行测试!微型游戏指南中关于测试与调试以及打包与上传的提示和准则同样适用于此处。只需确保打包所有必要文件,即 BossStages 文件夹和 CustomMaps 文件夹! 回顾与结语 总结一下,制作 Boss 关卡的步骤如下: 添加 Boss 关卡描述符(.将bos)放入StreamingAssets/BossStages文件夹中。 我们需要指定首领关卡数据,包括脚本文件路径、自定义地图名称以及加载界面资源。 在脚本中,我们返回一个包含3个键的表格:"OnBossBegin"、"OnBossTick"、"OnBossEnd"。每个键的值都是一个待运行的函数。 恭喜你完成第二部分的学习!在这一部分中,我们学习了如何创建自定义地图以及自定义首领关卡。 下一部分,我们将学习如何创建可通过额外菜单加载到自定义地图中的自定义场景,以及如何让这些场景执行脚本,从而实现自定义游戏模式。 模组制作第三部分:自定义场景 再次提醒,我们在此处创建的模组源代码可在指定的谷歌云端硬盘文件夹中获取。如有任何问题,请联系我们。下次再见!UIImage) bossIconImage:SetTexture(bossIcon) local bossIconImageTransform = bossIconImage:GetRectTransform() local maxBossIconSize = healthBarBackgroundTransform:GetSizeDelta().y bossIconImageTransform:SetSizeDelta(Vector3(maxBossIconSize, maxBossIconSize, 0)) -- 我们也会将首领图标设为背景的子物体 -- 并将锚点设为左侧,但轴心设为右侧 -- 这样(0,0,0)就位于生命值条的左侧,但该元素会在外侧而非内侧。 bossIconImageTransform:SetParent(healthBarBackgroundTransform) bossIconImageTransform:SetAnchorMin(Vector3(0, 0.5, 0)) bossIconImageTransform:SetAnchorMax(Vector3(0, 0.5, 0)) bossIconImageTransform:SetPivot(Vector3(1, 0.5, 0)) bossIconImageTransform:SetAnchoredPosition(Vector3(-15, 0, 0)) end 恭喜!在客户端调用此函数将创建一个生命值条界面。现在我们需要补充一个根据鸭子状态更新界面的函数: -- 根据鸭子生命值更新生命值条的函数 local function UpdateHealthBar() -- 首先,我们必须获取比例(鸭子濒临死亡的程度) local ratio = duckCurrentHP / duckHP -- 我们将获取生命值条的背景作为其原始大小的参考 local parentSize = healthBar:GetRectTransform():GetParent():GetRectTransform():GetSizeDelta() local width = parentSize.x local height = parentSize.y -- 我们将根据比例重新计算宽度 width = math.lerp(0, 宽度, 比率) -- 然后设置新的尺寸 healthBar:GetRectTransform():SetSizeDelta(Vector3(宽度, 高度, 0)) -- 现在我们只需要根据比率设置合适的颜色: 如果 比率 > 0.666 则 healthBar:SetColor(良好生命值颜色) 否则如果 比率 > 0.333 则 healthBar:SetColor(中等生命值颜色) 否则 healthBar:SetColor(低生命值颜色) 结束 结束 首领关卡:逻辑第三部分 让我们看一下我们的OnBossBegin()函数。 首先,我们要计算首领的生命值。我们将采用我们定义的初始值,然后为游戏中的每个玩家添加一个奖励值。 我们还会将鸭子的当前生命值设置为我们计算出的这个新的最大值。-- 根据玩家数量设置鸭子生命值 local playerCount = worldInfo:GetActivePlayersCount() duckHP = duckHP + (duckBonusHPPerPlayer * playerCount) duckCurrentHP = duckHP 我们要获取所有通过MicroKit公开的音频源,并将它们附加到鸭子身上,这样当鸭子移动时,音频源也会随之移动。 -- 将音频源附加到鸭子Boss -- 这将确保它们与鸭子Boss一起移动 local duckBossTransform = duckBoss:GetTransform() duckAudioSource:GetTransform():SetParent(duckBossTransform) duckCannonSoundR:GetTransform():SetParent(duckBossTransform) duckCannonSoundL:GetTransform():SetParent(duckBossTransform) 现在我们可以让鸭子升起并播放开始音效。当鸭子完成它的简短介绍后,我们将通过一个名为【启动鸭子】的新函数将其设为激活状态。 -- 当 Boss 战开始时,我们会将鸭子缓动到它的最终位置, -- 同时播放升起音效。 -- 当它完成升起后,我们会将鸭子设为激活状态。 异步运行函数() 局部升起时间 = 5 鸭子 Boss 变换组件:缓动位置(三维向量(0,5.849,62.732),升起时间) 鸭子音频源:设置音频剪辑(鸭子开始音效) 鸭子音频源:播放() 等待(秒(升起时间)) 启动鸭子() 结束 在我们现在要定义的【启动鸭子】函数中,我们将执行三件事: 1. 将变量【鸭子激活】设为真 2. 创建生命值条 3. 开始射击循环 4.让鸭子响应碰撞,并附加被击中时的代码 -- 用于将鸭子切换到活跃状态的函数 local function StartDuck() duckActive = true -- 创建生命值条 CreateHealthBar() -- 开始发射循环 DoProjectileLoop() -- 让鸭子首领响应碰撞 duckBoss:ListenToCollisions() -- 监听来自鸭子的碰撞 ListenFor("ObjectCollision", function(pay) if pay:HasProjectile() then local player = pay:GetProjectile():GetPlayer() local damage = pay:GetProjectile():GetDamage() OnDuckHit(player, damage) end end, duckBoss) end 第一步非常简单,而且我们已经定义了CreateHealthBar(),所以这就是第二步!




换一换 
















