跳至正文
版本:bleeding-edge 🩸

你的第一个游戏模式

逐步构建一个小型死亡竞赛游戏模式。完成本教程后,你将习得:

  1. 创建并配置一个 game-mode 包
  2. 在 Server/、Client/ 和 Shared/ 文件夹中组织代码
  3. 在地图的出生点生成玩家,并重生他们
  4. 将另一个包(default-weapons)用作需求
  5. 保持分数与所有玩家同步
  6. 在客户端绘制一个简单的 HUD
  7. 从客户端发送一个远程事件,并在服务器上校验
提示

本教程接续自快速开始。如果你还没有完成,请从那里开始搭建你的服务器!

第 1 步:创建包​

我们使用 CLI 创建一个新包,这次类型为 game-mode:

Terminal
./NanosWorldServer.exe --cli add package my-deathmatch
NanosWorldServer
INFO Please enter the Package Title: (my-deathmatch)
My Deathmatch
INFO Please enter the Package Author: ()
myself
INFO Please enter the Package Type ('script', 'game-mode', 'map' or 'loading-screen'): (game-mode)
game-mode

这会创建文件夹 Packages/my-deathmatch/,其中包含 Server/、Client/ 和 Shared/ 文件夹,以及一个 Package.toml。

信息

game-mode 是一个和 script 一样的包,区别在于同一时间只能加载一个游戏模式。它是你服务器的“主”包,并会出现在游戏的新游戏屏幕中。

第 2 步:配置 Package.toml​

打开 Package.toml。它包含一个带有包信息的 [meta] 部分,以及一个带有其设置的 [game_mode] 部分。我们需要做两处更改:

  • 将 default-weapons 添加到 packages_requirements,这样我们就能生成它的武器。它将在我们的包之前加载。
  • 将 default-playground-map 添加到 compatible_maps,这样在开始新游戏时它会被推荐。
Packages/my-deathmatch/Package.toml
[meta]
title = "My Deathmatch"
author = "myself"
version = "0.0.1"

[game_mode]
# ... 保留 CLI 生成的其他设置 ...
# 包需求
packages_requirements = [
"default-weapons",
]
# 兼容地图 - 通过主菜单开始新游戏时要高亮显示的地图
compatible_maps = [
"default-playground-map",
]

现在在 Config.toml 中将其设置为服务器的游戏模式:

Config.toml
[game]
map = "default-playground-map"
game_mode = "my-deathmatch"
# 如果需要,可以移除在“快速开始”做的 "my-awesome-package"
packages = [
]
提示

如果你的服务器中未安装 default-weapons,请使用 ./NanosWorldServer.exe --cli install package default-weapons 下载它,或使用 --auto_download 启动服务器。

第 3 步:共用设置​

Shared/ 中的代码在服务器和客户端两者上运行。这是放置双方都需要知道的设置的理想位置。我们在这里只存储值,绝不生成实体(否则每一端都会生成自己的副本!):

Packages/my-deathmatch/Shared/Index.lua
-- 游戏设置,在服务器和客户端上都可用
KILLS_TO_WIN = 10
RESPAWN_TIME = 3000 -- 毫秒

第 4 步:生成与重生玩家​

在服务器上,我们在地图的一个随机出生点为每个玩家生成一个带有武器的角色,并在死亡几秒后重生:

Packages/my-deathmatch/Server/Index.lua
-- 返回当前地图的一个随机出生点
function GetRandomSpawnPoint()
local spawn_points = Server.GetMapSpawnPoints()

-- 如果地图没有出生点,则回退到地图中心
if (#spawn_points == 0) then
return Vector(0, 0, 100), Rotator()
end

local spawn_point = spawn_points[math.random(#spawn_points)]
return spawn_point.location, spawn_point.rotation
end

-- 给角色一把武器
function GiveWeapon(character)
-- AR4 是由 default-weapons 包定义的类
local weapon = AR4(character:GetLocation(), Rotator())
character:PickUp(weapon)
end

-- 为玩家生成一个角色
function SpawnPlayer(player)
local location, rotation = GetRandomSpawnPoint()

local character = Character(location, rotation, "nanos-world::SK_Male")
player:Possess(character)

GiveWeapon(character)
end

-- 当玩家加入时,生成他们的角色
Player.Subscribe("Spawn", SpawnPlayer)

-- 当包(重新)加载时,为已经连接的玩家生成角色
Package.Subscribe("Load", function()
for _, player in pairs(Player.GetPairs()) do
if (not player:GetControlledCharacter()) then
SpawnPlayer(player)
end
end
end)

-- 当玩家离开时,销毁他们的角色
Player.Subscribe("Destroy", function(player)
local character = player:GetControlledCharacter()
if (character) then
character:Destroy()
end
end)

现在让我们在角色死亡后重生它们。角色死亡时会留在世界中(处于布娃娃状态),所以我们只需要稍后对它调用 Respawn:

Packages/my-deathmatch/Server/Index.lua
Character.Subscribe("Death", function(character, last_damage_taken, last_bone_damaged, damage_type_reason, hit_from_direction, instigator)
Timer.SetTimeout(function()
-- 角色可能在此期间已被销毁(例如玩家离开了)
if (not character:IsValid()) then return end

local location, rotation = GetRandomSpawnPoint()
character:Respawn(location, rotation)

-- 武器在死亡时掉落了,所以我们给一把新的
GiveWeapon(character)
end, RESPAWN_TIME)
end)

使用 package reload my-deathmatch 重新加载你的包并加入服务器:你将会带着一把步枪出生,并在死亡后重生!

提示

想独自测试吗?生成一个可供射击的 NPC:Character(Vector(300, 0, 100), Rotator(), "nanos-world::SK_Male")。

第 5 步:记录分数​

让我们统计每个玩家的击杀数。我们会将其作为玩家上的一个同步值存储,这样所有客户端都能知道每个人的分数,包括之后加入的玩家。

更新 Death 事件,给击杀者加一分:

Packages/my-deathmatch/Server/Index.lua
Character.Subscribe("Death", function(character, last_damage_taken, last_bone_damaged, damage_type_reason, hit_from_direction, instigator)
-- 'instigator' 是造成伤害的玩家(它可以是 nil,例如坠落时)
local victim = character:GetPlayer()

if (instigator and instigator ~= victim) then
local kills = instigator:GetValue("kills", 0) + 1

-- 'true' 会将该值与所有客户端同步
instigator:SetValue("kills", kills, true)

if (kills >= KILLS_TO_WIN) then
EndRound(instigator)
end
end

-- ... 上一步中的重生计时器 ...
end)

-- 宣布获胜者并重置每个人的分数
function EndRound(winner)
Chat.BroadcastMessage(winner:GetName() .. " 赢得了本回合!")

for _, player in pairs(Player.GetPairs()) do
player:SetValue("kills", 0, true)
end
end

第 6 步:绘制 HUD​

现在在客户端上,让我们使用 Canvas 在屏幕上显示本地玩家的击杀数。由于该值已同步,客户端可以直接使用 GetValue 读取它:

Packages/my-deathmatch/Client/Index.lua
-- 生成一个只在我们要求时重绘的 Canvas(auto_repaint_rate = -1)
HUD = Canvas(true, Color.TRANSPARENT, -1, true)

HUD:Subscribe("Update", function(self, width, height)
local local_player = Client.GetLocalPlayer()
if (not local_player) then return end

local kills = local_player:GetValue("kills", 0)
self:DrawText("Kills: " .. kills .. " / " .. KILLS_TO_WIN, Vector2D(50, height - 80), FontType.OpenSans, 24, Color.WHITE)
end)

-- 当任何玩家值发生变化时重绘 HUD(例如击杀数)
Player.Subscribe("ValueChange", function(player, key, value)
if (key == "kills" and player == Client.GetLocalPlayer()) then
HUD:Repaint()
end
end)

-- 当本地玩家生成时以及屏幕尺寸变化时也重绘
Client.Subscribe("SpawnLocalPlayer", function(local_player)
HUD:Repaint()
end)

Viewport.Subscribe("Resize", function(new_size)
HUD:Repaint()
end)

请注意我们使用了 Shared/Index.lua 文件中的 KILLS_TO_WIN,它在两端都可用!

提示

对于更复杂的 UI,你可以使用 HTML/CSS/JavaScript 配合 WebUI。参见基本 HUD(HTML)。

第 7 步:从客户端发送动作​

最后,让我们允许玩家通过按键来嘲讽。输入只存在于客户端,因此客户端必须请求服务器播放动画,然后服务器再将其同步给所有人。

在客户端,我们注册一个按键绑定(玩家可以在设置中更改按键),并在按下时发送一个远程事件:

Packages/my-deathmatch/Client/Index.lua
-- 注册一个名为 "Taunt" 的按键绑定,默认使用 T 键
Input.Register("Taunt", "T", "Taunt")

Input.Bind("Taunt", InputEvent.Pressed, function()
-- 请求服务器执行嘲讽
Events.CallRemote("Taunt", Reliability.Reliable)
end)

在服务器上,我们接收它。记住:永远不要信任客户端!作弊者可以随意多次调用此事件,因此我们要检查玩家现在是否可以嘲讽,并限制其频度:

Packages/my-deathmatch/Server/Index.lua
-- 存储每个玩家上次嘲讽的时间
local last_taunt_time = {}

-- 在服务器上,远程事件的第一个参数始终是发送它的玩家
Events.SubscribeRemote("Taunt", function(player)
-- 验证玩家拥有一个存活的角色
local character = player:GetControlledCharacter()
if (not character or character:IsDead()) then return end

-- 只允许每 3 秒嘲讽一次
local now = Server.GetTime()
if (last_taunt_time[player] and now - last_taunt_time[player] < 3000) then return end
last_taunt_time[player] = now

-- 在服务器上调用:每个玩家都会看到该动画
character:PlayAnimation("nanos-world::A_Mannequin_Taunt_Bow", AnimationSlotType.FullBody)
end)

Player.Subscribe("Destroy", function(player)
last_taunt_time[player] = nil
end)

结语​

恭喜,你创建了你的第一个游戏模式! 🎉 你现在已经了解了任何游戏模式都会用到的基础知识:生成和重生玩家、使用需求项、同步数据、绘制 UI 以及在客户端和服务器之间通信。

一些继续改进它的想法:

  • 显示一个包含所有玩家击杀数的记分板(在客户端遍历 Player.GetPairs())。
  • 从 default-weapons 中可用的武器里,给玩家不同的武器。
  • 使用 Failed to load function data. 添加队伍。
  • 使用持久化数据保存最佳玩家。

通过本教程中使用的核心概念继续学习:

玩家生命周期core-concepts/scripting/player-lifecycle 网络与复制core-concepts/scripting/networking-and-replication 永远不要信任客户端core-concepts/scripting/security-remote-events 输入与按键绑定core-concepts/scripting/input-and-key-bindings 包加载与 Lua 环境core-concepts/packages/package-loading-and-lua-environment