以下系统是我们为 Duvall Drive 的神秘 设置游戏玩法的基础。
游戏状态管理器
游戏状态管理器 / 游戏状态客户端 可能是体验中最复杂的系统,因为它涉及:
- 在大厅内启动玩家,开始将群组传送到主游戏区的倒计时,并将玩家传送到保留服务器。
- 克隆损坏的房间,异步传输它们,并将玩家传送到指定的 CFrame 坐标。
- 抓取和放置密封机制。
- 锁定和解锁门。
- 初始化最后的传送到大厅并播放最后的切场戏。
我们实现了它作为简单的状态机器(更新函数),状态在 DemoConfig (游戏状态枚列)。一些状态处理初始大厅/保留服务器传送,而其他状态则处理寻找任务、触发谜题并解决任务。请注意,除了海豹之外,我们尝试在 GameStateManager 中不要有任务特定代码。
游戏状态主要是服务器端的,但当客户端需要做某些操作,例如显示倒计时、传说或禁用流媒体暂停 UI 时,服务器和客户端(GameStatesClient)通过远程事件 GameStateEvent 进行通信。与大多数情况一样,事件付载有事件“类输入”(Config.GameEvents)作为第一个参数,之后是事件特定数据。
传送游戏状态
有一个由 3 个游戏状态组成的群组,运行三个独特的场景,隐藏传送到损坏房间的过程:热身、飞行和冷却。 热身 持续整个时间,并以几乎黑色的屏幕结束,其中 3D 世界已不可见。在这段时间内,我们克隆房间,为每个玩家获取损坏房间中的所需玩家位置,调用 Player.RequestStreamAroundAsync,并将玩家传送到损坏房间内指定的 CFrame 坐标。这类传送可能会触发直播暂停。当发生流媒体暂停时,客户端显示警告消信息。我们禁用了此默认用户界面以保持体验沉浸式。
在流媒体处理时, 在飞行 运行,保持略有闪烁的黑暗屏幕。当两者Player.RequestStreamAroundAsync返回和客户端通知服务器,流媒体暂停已关闭,每个玩家都足够接近目标位置,我们取消航班过场动画,并启动冷却过场动画。 冷却 的目标是通过平滑地移除黑暗屏幕,使3D世界再次可见。如果 Player.RequestStreamAroundAsync 回传回时间过长或客户端没有报告说流媒体暂停已关闭,我们仍然会在几秒钟后进入冷却场景。
当我们将玩家传送回房间的正常状态时,会出现一个类似的热身、飞行和冷却切场景, 传送热身回 、 传送飞行回 、和 传送冷却回 ,以及在体验结束时运行 传送热身最后 、 传送飞行最后 、和 传送冷却最后 来传送玩家进入终结切场景。
照明和气氛游戏状态
我们知道,我们希望每个房间的正常和损坏状态的视觉外观各不相同,以便给玩家一个清晰的视觉反馈,他们在完全不同的位置。游戏状态允许我们更改正常房间和损坏房间的照明和气氛属性,在这些房间中,GameStateManager选择使用哪些实例取决于玩家是否从正常状态转到损坏状态(TeleportWarmup),或相反(TeleportWarmupBack)。在传送期间播放的事件使整个屏幕变成黑色或白色,因此我们决定在那些时刻改变 Lighting 和 Atmosphere 实例以隐藏进程给玩家。为了简化更改, DemoConfig 包含定义哪些实例需要更改的地图。
锁定门游戏状态
我们想要能够在玩家完成任务时将他们锁在特定房间里,因此我们创建了游戏状态来锁定门:InMission 和 CanGetSeal 。InMission锁定玩家在他们的激活任务室,并CanGetSeal直到他们拿起“恢复”印章为止保持任务室的门锁。我们主要使用这个来确保玩家在完成任务后锁定门,以便他们有动力拿起密封。在他们拿起密封之后,门会解锁,便于他们将其放置在大厅的密封位置内。最后一个任务独特于此典型过程,因为带有密封的房间的门直到玩家解决每个谜题才会锁定(EnableRegularMissionDoors,EnableOneMissionDoors 函数)。
事件管理器
事件管理器 允许我们使用键帧来在时间上执行“行动”,例如:
- 计算实例属性和特性。
- 运行脚本。
- 播放音频。
- 运行相机抖动。
理想情况下,我们会使用基于轨道的用户界面的工具,但为了这个示范,我们手动输入了键和属性名称。 事件管理器 系统由几个脚本和一个事件/函数组成,包括:
- EventManager - 创建和停止事件以及服务器端操作的整体逻辑。
- EventManagerClient - 客户端行动。
- EventManagerModule - 服务器和客户端操作的共同代码。
- EventManagerConfig - 小文件,包含几个命令声明。
- EventManagerDemo - 在游戏特定脚本中定义了此演示的所有实际事件。
- EventManagerEvent , EventManagerFunc - 远程事件和可绑定的函数来运行并停止客户端或服务器的事件。这是其他系统如何设置、奔跑和停止事件的方式。
每个事件都有一个名称、一个带有冷却时间可选信息的部分、运行在启动或结束时的函数、带有插入物(在时间过程中插入任何数量的属性或特性)的部分以及播放音频的部分。
插值
插值可以让对象属性和特性无缝地从一个值更改到另一个值,而不是在关键帧之间分离跳跃。我们定义了插补剂来改变各种视觉效果;例如,以下代码片段显示了我们如何将 TextLabel.TextTransparency 属性在一个由 TextLabel 参数定义的对象上从一个值的 1 变为 0 ,然后稍后返回到 1 再次:
interpolants = {objectParam = "TextLabel",property = "TextTransparency",keys = {{value = 1},{time = .5, value = 0},{time = 2.25, value = 0},{time = 3, value = 1}}}
虽然我们可以定义哪个对象属性或特性属于哪个类似于以下代码示例的对象,但我们希望能够在不同的“对象组”上重复使用相同的事件,以便它能够在客户端上使用流式传输和在运行时创建的对象。
object = workspace.SomeFolder.SomeModel
为了实现这一目标,我们允许以对象名称进行引用,并在事件开始动时传递命名参数。要找到命名对象,我们允许指定事件的“根”,该根可让对象在事件启动时按名称在此根下被找到。例如,在以下代码片段中,事件管理器试图在下找到名为“漫游”的对象。
params = {["RootObject"] = workspace.Content.Interior.Foyer["Ritual-DemoVersion"],},interpolants = {objectName = "Wander",attribute = "TimeScale",keys = {{value = 0.2}}}
我们允许在参数部分传递参数到事件,运行在事件启动时的脚本可以修改现有参数或添加更多参数到“参数”表。在以下示例中,我们有 isEnabled 参数的默认值为 false,“启用”属性对名为 FocuserGlow 的对象将设置为 isEnabled 的值。在事件启动时运行的脚本或调用事件的脚本可以设置 isEnabled,因此我们可以使用相同的事件描述启用和禁用聚焦发光。
params = {isEnabled = false},interpolants = {{objectName = "FocuserGlow",property = "Enabled",keys = {{valueParam = "isEnabled"}}}
参数允许我们引用甚至不存在于体验开始时的对象。例如,在以下代码示例中,运行在事件启动时的函数会创建一个对象,并将 BlackScreenObject 入口设置为指向创建的对象。
{objectParam = "BlackScreenObject",property = "BackgroundTransparency",keys = {{value = 0},{time = 19, value = 0},{value = 1},}}
运行事件、事件实例,并连接到触发器
要运行事件,我们将使用客户端的远程事件或服务器的函数。在以下示例中,我们向 RootObject 和 isEnabled 事件传递了几个参数。内部,创建了事件描述的实例,参数解析为实际对象,函数返回了事件实例的ID。
local params = {RootObject = workspace.Content.Interior.Foyer["Ritual-DemoVersion"]["SealDropoff_" .. missionName],isEnabled = enabled}local eventId = eventManagerFunc:Invoke("Run", {eventName = "Ritual_Init_Dropoff", eventParams = params} )
我们可以通过调用函数“Stop”来停止运行事件:
eventManagerFunc:Invoke("Stop", {eventInstId = cooldownId} )
可能会在客户端运行的“化妆品”(不会对所有玩家进行模拟更改)的稀释剂或其他操作(不会对所有玩家进行模拟更改)可能会导致更平滑的稀插值。在事件描述中,我们可以为所有操作提供默认值,如 onServer = true (没有它,默认为客户端)。每个行动都可以通过设置自己的 onServer 来覆盖它。
为了轻松地将运行事件连接到触发器,我们使用了助助函数 ConnectTriggerToEvent 或 ConnectSpawnedTriggerToEvent , 后者通过名称找到触发器。要允许使用不同触发器触发相同事件,我们可以使用“设置”键和一组触发器音量来调用 eventManagerFunc 。对于触发量在行动作中的示例,请参阅将扩展仓库扩展。
事件参数
除了从脚本中传递的自定义事件参数外,在创建事件时可选传递的其他数据包括玩家、回调(在事件结束时调用)和回调参数。一些事件仅应为一个玩家运行(客户端上运行的事件),而其他事件应为全部有人运行。为了只为一个玩家运行,我们在参数中使用了 onlyTriggeredPlayer = true。
事件可以由 minCooldownTime 和 maxCooldownTime 定义冷却时间。最小和最大提供基于玩家数量的缩放范围,但我们在此演示中未使用它。如果我们需要每个玩家冷却一次,我们就有能力使用 perPlayerCooldown = true 。每个事件都有秒为单位的持续时间,冷却时间和回调基于它进行。要通知关于完成事件的信息,调用代码可以传递回调和参数,它将获得。
调用脚本
我们可以在 Scripts 部分的特定键帧中调用 **** 。例如:
scripts = {{startTime = 2, scriptName = "EnablePlayerControls", params = {true}, onServer = false }}
在上一个例子中, 启用玩家控件 Script需要与事件管理器模块注册,如下所示:
emModule.RegisterFunction("EnablePlayerControls", EnablePlayerControls)
注册函数必须在客户端脚本中调用对客户端调用的函数,以及在服务器脚本中调用 onServer = true 。函数本身会获得事件实例和传递的参数,但在这种情况下,只有一个参数以真实值传递。
local function EnablePlayerControls(eventInst, params)
播放音频
我们对在声音部分的键帧播放非定位音频有限支持,例如:
sounds = {{startTime = 2, name = "VisTech_ethereal_voices-001"},}
请注意,事件完成回调在事件时间过期时触发,但音频操作可能仍在播放后。
运行相机抖动
我们可以在 cameraShakes 部分中定义相机抖动,如下所示:
cameraShakes = {{startTime = 15, shake = "small", sustainDuration = 7, targets = emConfig.ShakeTargets.allPlayers, onServer = true},}
“目标”只能为触发事件的玩家、所有玩家或在半径内的玩家启动,即触发玩家。我们使用了第三方脚本来抵消相机抖动,抖动已预定义:eventManagerDemo.bigShake 和 eventManagerDemo.smallShake 。sustainDuration也可以传递。
任务逻辑
共有 7 个任务,其中只有 6 个使用海豹。大多数任务都有共同参数,虽然有些只适用于带有海豹和传送到恶意房间的任务。每个任务都有一个入口在 DemoConfig 脚本中,包含一组参数在 Config.Missions 地图中:
- 任务根 :一个文件夹,包含所有非损坏版本的对象。
- 门 :直到玩家捡起密封圈为止锁定门。
- 密封名称 / 解决的密封名称 : 非破坏密封和损坏的密封名称。
- SealPlaceName :放置印章的地方。
- 放置密封位置器名称 : 在放置密封位置的地方放置密封对象。
- 传送位置名称 : 一个文件夹的名称,包含 placeholder 网格来定义玩家在移动到损坏房间时的传送位置和旋转,以及返回到正常区域。在两种情况下都使用相同的名称。
- CorruptRoomName : 损坏房间的根文件夹名称(相对于 ServerStorage)。腐化的房间在任务开始时克隆到 TempStorage.Cloned,任务结束时被摧毁。
- 任务完成按钮名称 : 一个在损坏房间中的捷径按钮,立即完成任务。这是为了 调试目的 .
- 快捷键 : 与数字或 Ctrl Shift [Number] 相同的快捷键。
一些任务逻辑在 GameStateManager 脚本中,因为海豹和门提供了大多数任务的主要游戏流程,但大多数任务特定的逻辑在 MissionsLogic 和 MissionsLogicClient 脚本中定义了几种“类型”的任务。类型仅由任务描述中具有特定名称的成员存在来定义。有几种类型的任务:
- 使用钥匙开锁 - 第一个打开门的任务。该类型由 LockName 、 KeyName 定义。
- 匹配物品 - 4 个任务匹配物品。该类型由 MatchItems 定义。
- 用层次布料打扮一个人体模型 - 在阁楼有 1 个任务让玩家收集三件物品。这种类型由 DressItemsTagList 定义。
- 单击项目以完成 - 1 个任务具有此输入,由 ClickTargetName 定义。
每种任务类型都有自己的 StartMissionFunc 和 CompleteMissionFunc .启动函数通常从 MatchItem 地图阅读参数,将名称解释为对象,并设置任何单击检测器或用户界面元素。几乎所有的逻辑都在服务器上,但 MissionsLogicClient 提供用于显示物品计数器的 UI,在许多任务中使用。MissionLogicEvent 远程事件用于服务器 - 客户端通信,定义了一个小的 MissionEvents 传递命令类型。MiscGameLogic 脚本将一些触发器绑定到事件并在发布版本中移除调试对象。
匹配项目逻辑允许“使用”(按住时单击)标有 PuzzlePieceXX 标签的项目与标有 PuzzleSlotYY 标签的项目匹配。在 匹配项 地图中有几个选项可用作参数(如果需要按顺序应用零件,只需要其中一个即可)。我们可以为简单的音频和视觉效果指定名称。当需要将零件放置在特定位置时,额外的“放置”地图可将零件标签映射到定义变形的空白零件名称。
抓取
我们开发了一个简单的抓取系统,用于将对象附加到角色的右臂上以抓住对象。抓取实现在 GrabServer2 和 GrabClient 脚本中。它在 ProcessClick 开始, 这会发射一束射线通过点击/触摸的地点。然后检查我们是否击中了可以被抓取的网格,击中的内容在 maxMovingDist 中,我们可以开始抓取互动。如果模型单击的有 Attachments 调用了 抓取提示 ,我们选择最接近单击位置的。我们记住抓取的部分、它所属的模型,以及结构中最接近的 抓取提示 或单击位置。如果距离超过最大抓取距离,玩家首先需要走到足够接近尝试抓取位置的地方,因此我们调用 Humanoid.MoveTo 。
在每个框架上,我们检查是否有抓取尝试正在进行。如果玩家在 reachDist 内,我们开始播放 ToolHoldAnim 。当玩家在 maxGrabDist 内时,客户端向服务器发送请求实际抓取模型(performGrab 函数)。
服务器端脚本有 2 个主要功能:
- 抓取 - 处理客户端的请求以抓取模型。
- 发布 - 处理要发布抓取模型的请求。
每个玩家持有的信息被保存在 playerInfos 地图中。在获取函数中,我们检查这个模型是否已被另一名玩家抓住。如果是这样 - 一个“EquipWorldFail”被发送到客户端,可以取消抓取尝试。请注意,我们需要处理玩家抓取相同 Model 的不同部分,并在此情况下取消抓取。
如果允许抓取,脚本会创建两个 Attachments , 一个在右手边,另一个使用客户端传回的抓取位置抓取对象。然后创建一个 RigidConstraint 在两个 Attachments 之间。Constraints 和 Attachments 被存储在玩家角色下的 当前握把 文件夹下。抓取也发出声音,禁用抓取的对象上的碰撞,并处理可恢复的物品,如果需要。
要发布抓取模型,客户端脚本连接到 HUD 屏幕控制器的 释放按钮 按钮。一个 Connected 函数向服务器发射事件。在服务器上,释放删除Attachments和Constraints,恢复碰撞,处理任何可用的恢复物品,并在 playerInfos 中清除此客户端的抓取数据。