你的第一个游戏模式
逐步构建一个小型死亡竞赛游戏模式。完成本教程后,你将习得:
- 创建并配置一个 game-mode 包
- 在
Server/、Client/和Shared/文件夹中组织代码 - 在地图的出生点生成玩家,并重生他们
- 将另一个包(default-weapons)用作需求
- 保持分数与所有玩家同步
- 在客户端绘制一个简单的 HUD
- 从客户端发送一个远程事件,并在服务器上校验
本教程接续自快速开始。如果你还没有完成,请从那里开始搭建你的服务器!
第 1 步:创建包
我们使用 CLI 创建一个新包,这次类型为 game-mode:
./NanosWorldServer.exe --cli add package my-deathmatch
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,这样在开始新游戏时它会被推荐。
[meta]
title = "My Deathmatch"
author = "myself"
version = "0.0.1"
[game_mode]
# ... 保留 CLI 生成的其他设置 ...
# 包需求
packages_requirements = [
"default-weapons",
]
# 兼容地图 - 通过主菜单开始新游戏时要高亮显示的地图
compatible_maps = [
"default-playground-map",
]
现在在 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/ 中的代码在服务器和客户端两者上运行。这是放置双方都需要知道的设置的理想位置。我们在这里只存储值,绝不生成实体(否则每一端都会生成自己的副本!):
-- 游戏设置,在服务器和客户端上都可用
KILLS_TO_WIN = 10
RESPAWN_TIME = 3000 -- 毫秒
第 4 步:生成与重生玩家
在服务器上,我们在地图的一个随机出生点为每个玩家生成一个带有武器的角色,并在死亡几秒后重生:
-- 返回当前地图的一个随机出生点
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:
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 事件,给击杀者加一分:
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 读取它:
-- 生成一个只在我们要求时重绘的 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 步:从客户端发送动作
最后,让我们允许玩家通过按键来嘲讽。输入只存在于客户端,因此客户端必须请求服务器播放动画,然后服务器再将其同步给所有人。
在客户端,我们注册一个按键绑定(玩家可以在设置中更改按键),并在按下时发送一个远程事件:
-- 注册一个名为 "Taunt" 的按键绑定,默认使用 T 键
Input.Register("Taunt", "T", "Taunt")
Input.Bind("Taunt", InputEvent.Pressed, function()
-- 请求服务器执行嘲讽
Events.CallRemote("Taunt", Reliability.Reliable)
end)
在服务器上,我们接收它。记住:永远不要信任客户端!作弊者可以随意多次调用此事件,因此我们要检查玩家现在是否可以嘲讽,并限制其频度:
-- 存储每个玩家上次嘲讽的时间
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