前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >让我们从头做一个 MUD 吧!

让我们从头做一个 MUD 吧!

作者头像
韩伟
发布2024-05-07 16:58:01
1140
发布2024-05-07 16:58:01
举报
文章被收录于专栏:韩伟的专栏

为什么要做一个 MUD

MMORPG 曾经是中国游戏行业中最火的游戏品类,这一类游戏的开发成本也是巨高无比。但是,早期的 MMORPG,其结构却并不是特别复杂,譬如《梦幻西游》这类网游,在最早期的时候,参考的技术只是 MUD 而已。

关于 MUD,我不想过多的介绍其历史和技术底层,只是想告诉大家,这是一种“瘦客户端”的游戏:

  • 整个虚拟的游戏世界,都运行在服务器上,客户端仅仅是提供玩家对服务器世界的输入、输出功能而已
  • 服务器的内存中,保存着整个虚拟世界的信息,包括场景、角色、物品、战斗等等,随着服务器程序的运行,这个虚拟世界也在产生昼夜和四季的变换
  • 玩家通过输入文字命令,去操作自己在虚拟世界中的角色;服务器也通过文字,把世界中的各种信息输出给玩家。不同的玩家可以在同一个服务器世界中相遇、相交。甚至游戏的开发者,我们称为“巫师”,也是直接进入这个世界后,进行程序开发,就如同在飞行的飞机上改造飞机。

显然,这种完全基于文字的游戏,不可能称为游戏的主流,但是这类游戏依然有它的价值:

  1. 对于失明人士来说,这种游戏几乎是他们唯一可以玩的电子游戏
  2. 作为 MMORPG 这种类型来说,MUD 的服务器端技术是这类游戏技术的起源,具有很大的学习价值
  3. 在 ChatGPT 这类自然语言 AI 流行的今天,这类文字游戏可以利用最新的 AI 技术,发挥出超强的生命力

所以,让我们从头来做一个 MUD 吧!

开源地址:https://daxiaohan.coding.net/public/luamud/luamud/git/files

神说:要有光

开发一个 MUD,几乎等于要构建一个“赛博空间”的虚拟世界。要创造一个世界,最开始需要什么呢?

  1. 首先,要有一门编程语言。这门语言不但构造这个世界,而且还需要能在这个世界中运行,同时用来表达这个世界的信息。
  2. 其次,这个运行在电脑内存中的世界,需要和处于“外界”的玩家联系,这种联系需要两个方面功能:玩家进行联系、能保存玩家的状态

对于第一个需求,显然一门脚本语言是非常适合的,譬如 Python、JS,但我更喜欢 Lua,因为这门语言的非常纯粹,附带的东西非常少,很适合从零开始。对于第二个需求,则需要设计一种网络服务功能,以及一种文件存档功能,来让玩家“活”在这个虚拟世界中,这两个功能,也是 Lua 语言唯一需要依赖的外部功能。对于网络功能,我使用最基本的 luasocket 这个库;而文件功能,Lua 语言自带的 io 包已经可以胜任了。于是,在有了 Lua 和 luasocket 之后,这个世界可以开始建造了。

世界的结构

对于游戏最基本的功能,那些和游戏世界的描述最不相关,但是必的能力,就好像我们世界中的物理定律的东西,我称为 “MudOS”,它包括以下几个功能:

  1. 游戏世界的时间主线:程序入口和主循环,定时器功能
  2. 游戏世界和玩家的接口:网络服务器,解析命令数据包
  3. 游戏世界给玩家的存档:文件存储以及数据序列化、反序列化了
  4. 游戏世界中所有的“对象”模型(Lua 没有官方的“对象模板”形式的“类”,因此对象的继承能力需要自己实现)

具体的游戏世界功能,我称为“MudLib”,这部分代码设定了具体不同的游戏的差异,这部分代码使用 MudOS 的功能,来构建各种的玩法。对于一个 MMORPG 来说,往往需要有场景、角色、道具、技能等等。

MudLib 与 MudOS 的关系

世界的时间线

MudOS/main.lua

这个世界有一个叫做“世界心脏(Heart Of World)”的唯一全局对象,所有在游戏中,会随着时间变化的对象,都需要通过 Add() 方法把自己加入这个对象;在对象“死去”的时候,用 Del() 方法去掉自己的引用。一旦“加入了”世界心脏后,这些对象的 HartBeat() 心跳方法就会跟随“世界心脏”定期跳动,所有的对象需要不断运行的行为,都可以放入自己的心跳方法里。

代码语言:javascript
复制
--- Timer System
HeartOfWorld = {
  rate = 1,     -- beat times per second
  members = {}, -- all hearts in here
  last_beat_time = 0
}

function HeartOfWorld:Add(heart)
   ......
end

function HeartOfWorld:Del(heart_or_idx)
  ......
end

function HeartOfWorld:Tick()
  ......
      -- Make hearts beating
    for idx, obj in pairs(self.members) do
      if obj.HeartBeat ~= nil and type(obj.HeartBeat) == 'function' then
        obj:HeartBeat(now)
      end
    end
  ......
end

接纳玩家

如果让玩家能接入这个世界,需要有两个过程:

  1. 监听网络,记录在线的玩家
  2. 处理用户输入和给于输出

对于网络功能,我开发了一个 TCP 服务器,这个服务器可以 Start() 方法监听玩家的连接,接收玩家发来的数据;以及用 SendTo() 方法发回消息给玩家。

值得注意的是,LUA 使用的单线程异步的 IO 模型,所以网络服务需要一个持续性的循环进行驱动。这里把“世界心脏”的触发也放到网络服务的主循环中了。而更好的做法应该是“世界心脏”负责主循环,并且在主循环中操作网络 IO。

代码语言:javascript
复制
--- A TCP server can be set a recieving handler.
TcpServer = {
  num2client = {}, -- 通过玩家 ID 找到客户端对象的索引表
  client2num = {}, -- 通过客户端对象找到玩家 ID 的索引表
  clients = {},    -- 客户端列表
  conn_count = 0   -- 当前连接总数
}

function TcpServer.Start(self, bind_addr, handler)
  ......

  -- 游戏主循环 --
  while true do
    -- Processing network events
    ......

    -- Processing heartbeat timer
    HeartOfWorld:Tick()
  end
end

function TcpServer.SendTo(self, client_id, message, no_ret)
  ......
end

function TcpServer.CloseClient(self, client_id)
  ......
end

...
  print("Starting TCP server ...")
  TcpServer:Start({}, handler)
...

由于需要处理玩家的行为,我设计了一个“命令系统”,这个系统存放了所有的“命令”。玩家发来的所有行为数据,“命令系统”都会尝试解释成一个“命令”,如果解释成功,就会去调用对应的“命令方法”。

另外,为了让“命令方法”更容易编写,我对已经连接到服务器上的玩家,设计了一个记录这些玩家对象的在线列表。我以一次“会话”来描述玩家的在线状态,设计了一个“会话池”来保存所有的在线玩家的对象。命令代码运行时,可以很方便的获得在线的所有玩家对象,同时也可以通过 THIS_PLAYER 这个全局对象,来获得当前发出命令的玩家对象的引用。

代码语言:javascript
复制
-- Sessions pool --
SessionPool = {}
...
--- A command system which you can set a command to it.
CommandSystem = {
  cmd_tab = {}, -- 存放所有命令的容器

  ......

  ProcessCommand = function(self, user_id, command_line)
    .......
    -- 命令行方式解析输入的文字
    string.gsub(command_line, "[^ ]+", function(w) table.insert(cmds, w) end)
    ......
    local cmd_fun = self.cmd_tab[cmd] -- 查找命令
    ......
    THIS_PLAYER = SessionPool[user_id] -- Shotcut: this_player
    ......
      local ret = cmd_fun(cmds)  -- 运行命令
      Reply(PROMPT, true)
      return ret
    ......
  end
}

响彻天际

对于连接到这个世界的玩家,必须要有一个手段让玩家知道这个世界中发生的事情。我设计了一个 Channel 类型来完成这个功能,它负责做对某个范围的玩家进行网络广播。玩家可以被加入到一个或者多个 Channel 中,然后根据世界的逻辑,他们会收到广播的信息。

代码语言:javascript
复制
--- Broadcast system
Channel = {
  members = {}
}

......

function Channel:Join(user_id, member)
...
end

function Channel:Leave(user_id)
...
end

function Channel:Say(message, ...)
  for user_id, member in pairs(self.members) do
    local ignore = false
    for i, sender in ipairs { ... } do
      if member == sender then
        ignore = true
      end
    end

    if ignore == false then
      TcpServer:SendTo(user_id, message)
    end
  end
end

保存玩家数据

玩家存档的格式,我希望是一段 Lua 源码,这段源码记录了一个 table 对象。——这个功能由 MudOS/serialize.lua 实现。对于玩家的登录密码,展示记录密码的 md5。不记录密码的原文,是为了防止这个游戏的数据有问题之后,让玩家的常用密码也给泄露了。

对于整个玩家记录的功能,我设计了一个叫 UserData 的“类”,每个玩家的存档就是一个 UserData 类的对象。这个对象提供了 Save/Load 的方法,这两个方法会使用 serialize.lua 的代码,对存档内容进行解析和编码。

把内存中的对象数据,保存到文件,或者通过网络发出去,需要把对象的数据进行某种编码。这个过程称为“序列化”,相反的过程则为“反序列化”。这里的 MudOS/serialize.lua 就是对玩家存档数据进行“序列化/反序列化”的代码。

代码语言:javascript
复制
--- Save/Load user data
require("MudOS/serialize")
local md5 = require("MudOS/md5")
......

UserData = {
  user_name = nil,
  pass_token = nil,

  .......

  Load = function(user_name, password)
    ......
  end,

  Save = function(self)
    .......
    local save_obj_name = 'player_' .. self.user_name
    io.output(save_file)
    save(save_obj_name, self)
    io.close(save_file)
    return true
  end,

  .......
}

盘古开天地

游戏世界中的具体事物非常繁多复杂,所以我把这些称为 MudLib,然后设计一个整体加载全部具体事物的脚本 index.lua,这个脚本具体去加载各种“游戏系统”。真正对于游戏世界的详细描述,放在 MudLib 目录下。

代码语言:javascript
复制
-- Load GameLib level code
print("Start to load GameLib ...")
require("MudLib/index")

-- Start up network procedule
...
  print("Starting TCP server ...")
  TcpServer:Start({}, handler)
...

在一个 MMORPG 中,基本玩法的构造,可以分成多个“游戏系统”,每个系统用一个或几个 Lua 脚本作为入口

MudLib/index.lua

代码语言:javascript
复制
...
print("正在构建空间系统 ...")
require("MudLib/space")

print("正在构建房间系统 ...")
require("MudLib/room")
require("MudLib/map")

print("正在构建角色系统 ...")
require("MudLib/char")

-- TODO 构建“道具系统”

print("正在构建战斗系统 ...")
require("MudLib/combat")

print("正在构建命令系统 ...")
dofile(MUD_LIB_PATH .. "cmds.lua")
...

至此,这个网络游戏世界所需要的最基本功能,已经完全具备了,下一步就需要开始构建真正的游戏世界了。

空间

空间 Space 是一个可以存放其他物体的物体。在空间中的物体,本身也可以是一个空间。譬如房间里面有人,人身上能放背包,背包里面还能放东西。

MudLib/space.lua

代码语言:javascript
复制
---代表一个物理空间物体
--@param environment 所处环境
--@param content 内容
SpaceObject = {
  environment = nil, 
  content = {}, 

  New = function(self, value)
    ...
  end,

  --查找本身包含的内容物
  --@param #table key 内容物的属性名,如果是nil则对比整个内容物体
  --@param #table value 要查找的属性值或者内容物本身
  --@param #function fun是找到后的处理函数,形式fun(pos, con_obj)
  --@return #table 返回fun()的返回值(仅限第一个返回值)数值,或者是找到的对象数组
  Search = function(self, key, value, fun)
    ...
  end,

  Leave = function(self)
    ...
  end,

  Put = function(self, env)
    ...
  end,

  Dispose = function(self)
    ...
  end
}

World = SpaceObject:New()  -- 所有物理空间存放的位置
World.channel = Channel:New() -- 构建一个世界频道

最重要的是,用一个全局变量 World 给这个游戏世界,一个唯一的、全局的空间对象,所有在游戏中的物理对象,都放在这个对象中。

另外 World.channel 展示了一个游戏的空间系统,除了要能“放下物体”以外,同时也需要一个广播频道,才能让所有在这个空间中的玩家,获得空间最新的信息变化。而这个 channel 属性,是预备用来作为全服广播对象的。

当我们有了最基础的“空间”概念,就可以开始构建具体的一个个场景:房间了

MudLib/room.lua

代码语言:javascript
复制
Room = {
  title = "虚空",
  desc = "这里一片白茫茫",
  exits = {}, --east="xxx", west="yyy", ...
  channel = World.channel
}

function Room:New(value)
  local ret = NewInstance(self, value, SpaceObject)
  ret:Put(World)
  ret.channel =  Channel:New()
  return ret
end

function Room:ToStr()
  local output = [[
--%s--  
%s
这里的出口:
%s
这里有:
%s]]

  ...
  
  return string.format(output, self.title, self.desc, exits_str, content_str)
end

作为文字游戏的“房间”,需要有三个东西:

  1. 这个场景是什么样子的,通过 ToStr() 实现
  2. 这个场景和其他什么地方连通,以便角色可以移动,通过 exits 属性实现。这个属性是个 Table,key 是出口方向,value 是连接的场景
  3. 这个场景的广播频道,用于让本场景内的信息可以发送给玩家,通过 channel 实现

对于具体的房间,只要填写上述 1,2 两个部分的数据,就可以构建出任何状态的场景

MudLib/map.lua

代码语言:javascript
复制
BornPoint = Room:New({
  title = "出生点",
  desc = "这里是一片空地,周围站着很多刚注册的新手玩家。",
  exits = {
    east = "NewbiePlaza",
    west = "SmallRoad"
  }
})

NewbiePlaza = Room:New({
  title = "新手广场",
  desc = "光秃秃的黄土地上,有几棵小树。",
  exits = {
    west = "BornPoint"
  }
})

SmallRoad = Room:New({
  title = "小路",
  desc = "这条小路荒草蔓延。似乎是通往外界的唯一道路。",
  exits = {
    east = "BornPoint"
  }
})

角色

一个游戏里面当然需要人,一般包括两类:

  1. NPC
  2. Player

因此我也设计了两个类型,一个叫 Char(角色),一个叫 Player。其中 Player “继承”于 Char。对于角色来说,设计了以下几个方法:

  • 新建/消失
  • 说话。调用当前场景的 channel 进行广播。
  • 移动。进入当前场景,并且会广播进入的动作。
  • 描述。当角色被观察时,把角色的描述、状态进行返回。固定描述用 Desc() 返回。
  • 心跳。这是最重要的方法,所有角色存在的“状态”,都需要在这个方法中描述。这里实现了最基本的“战斗状态”:只要发现了被标记为“敌人”的角色,就调用“战斗系统”发起攻击。 对于 Player 类型,除了上述的内容以外,还有自己的存档对象 user_data,以及专门给玩家单人发送信息的方法 Reply()

MudLib/char.lua

行为

整个游戏最复杂的部分就是行为部分,这是由一系列的“命令”组成的。一部分是游戏最基本的命令:

  • 登录
  • 注册
  • 登出
  • 移动
  • 观察
  • 说话

基本上可以认为是一个聊天室的功能。这部分能力实现在 MudLib/cmds.lua 当中。

而具体游戏中的额外的命令,则可以通过 MudLib/Cmd/XXX.lua 进行添加,现在包括了:

  • hp 状态查看
  • kill 触发战斗
  • skill 使用技能

虽然游戏命令非常少,但是已经可以构造一个基本的,带技能的战斗玩法。

代码语言:javascript
复制
CommandList.kill = function(cmds)
  local target = nil
  local target_id = cmds[2] -- cmds[1]是指令本身,cmds[2]才是参数
  if target_id == nil then
    Reply("你怒气冲冲的瞪着空气,不知道要攻击谁。")
    return
  else
    if target_id == THIS_PLAYER.id then
      Reply("你狠狠用左脚踢了一下自己的右脚,发现这个行为很傻,于是就停止了。")
      return
    end
    local targets = THIS_PLAYER.environment:Search('id', target_id)
    if #targets == 0 then
      Reply(string.format("没有%s这个东西", target_id))
      return
    elseif targets[1].hp ~= nil and targets[1].hp > 0 then
      target = targets[1]
    else
      Reply("你不能攻击一个死物。")
      return
    end
  end

  if target ~= nil then
    table.insert(THIS_PLAYER.fright_list, target)
    Reply(string.format("你对着%s大喝一声:“納命来!”", target.name))

    --反击
    table.insert(target.fright_list, THIS_PLAYER)
    Reply(string.format("%s对你一瞪眼,一跺脚,狠狠道:“竟敢在太岁头上动土?”", target.name))
    if target.user_id ~= nil then
      target:Reply(string.format("%s向你发起了攻击!", THIS_PLAYER.name))
    end
  end
end

对于命令,只要使用了 CommandList.XXX 就可以定义,其中 XXX 就是命令输入字符。函数中的 cmds 是一个数组,包含玩家输入的整个命令行,以空格进行划分。

最后,说说战斗系统

MudLib/combat.lua

整个战斗系统,实际上只是一个函数 Combat(),这个函数会随心跳,不断被角色所调用。这有点类似一般游戏引擎中的 Update() 驱动逻辑运算。而战斗中的各种技能,都是在这个函数的过程中,根据角色身上的属性,进行不同的运算。

代码语言:javascript
复制
if a_skill ~= nil then
    -- 有招攻无招
    if d_skill == nil then
      damage = double_power
    else
      --这种复杂判断其实应该用哈系表查询,但是if写法更容易表达内在含义
      --tiger>monkey>crane>tiger
      if a_skill == d_skill then
        damage = normal_power
      elseif a_skill == "tiger" then
        if d_skill == "monkey" then
          damage = double_power
        elseif d_skill == "crane" then
          damage = lease_power
        end
      elseif a_skill == "monkey" then
        if d_skill == "tiger" then
          damage = lease_power
        elseif d_skill == "crane" then
          damage = double_power
        end
      elseif a_skill == "crane" then
        if d_skill == "monkey" then
          damage = lease_power
        elseif d_skill == "tiger" then
          damage = double_power
        end
      end

    end
  end

这里设计了三个“技能”,代表三个招式,通过类似锤子剪刀布的方式,影响攻击计算的结果。

最后

MudLib 目录中的文件,把角色、场景、战斗三个基本要素做了一个实例。后续可以从更多的角度去扩展:

  1. 通过 map.lua 构造更多的地图
  2. 通过 Cmd/xxx.lua 增加更多用户可以做的行为,譬如解谜玩法
  3. 通过扩展 Char 类型,增加 NPC
  4. 扩展 Space 类对象,让世界多一些“道具”
  5. 在 combat.lua 中加入更多好玩的战斗玩法
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-04-30,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 韩大 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 为什么要做一个 MUD
  • 神说:要有光
    • 世界的结构
      • 世界的时间线
        • 接纳玩家
          • 响彻天际
            • 保存玩家数据
            • 盘古开天地
              • 空间
                • 角色
                  • 行为
                  • 最后
                  相关产品与服务
                  文件存储
                  文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
                  领券
                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档