在之前的文章中,我们所有的角色,包括玩家和 NPC (none player character —— 非玩家角色)都在同一个场所中,虽然对于一个巨大的元宇宙城市来说,这是一种实现方案,但在对于具体工程上的实现来说,这个方案是不可行的,因为即使在同一座元宇宙的城市中,玩家和 NPC 也是分布在不同的场景,遵循不同的规则。
规则/剧本的脚本化,除了 GM 指令以外,我们还有大量的配置和游戏内的剧本需要在底层的元宇宙逻辑之上构建,比如说我们有一个元宇宙的咖啡厅,咖啡厅里面有一个 Waiter,显然,人力的成本是很贵的,我们不能雇佣玩家担任这个 Waiter 的角色(少数角色扮演的爱好者除外),我们的 Waiter 需要在玩家进店的时候说欢迎,然后递上饮品单,当玩家坐下的时候,弯腰/跪坐提供服务。。。
我们可以直接使用 Rust 语言编写一个 Waiter 的结构,根据周围的玩家情况触发自身的行为,但是显然,对于大量的需要遵循一定规则或者剧本的 NPC 来说,使用脚本来描述其行为是一个更好的主意。
我们可以为巨大元宇宙场景中的每一个 NPC 绑定一个脚本,但是这样服务端的内存和 CPU 开销将是十分巨大的,按照分而治之的基本思路,我们可以将 NPC 限制在不同的场景中,其脚本的范围也限制在这个场景中。
这就需要对我们之前的底层结构进行修改,我们的元宇宙服务器需要支持同时存在的多个场景,场景之间相互独立,但是可以通过进门/出门,或者奇异博士画个圈圈的方式,在不同的场景之间切换。在早期的 MMORPG 中,这就是一个典型的副本模式。
我们把一个独立的场景叫做 World
World 需要有一个名字,一个绑定的脚本,里面包含了初始化代码,比如说创建几个 NPC,处理消息的代码,比如说某一个玩家进来的时候,所有的 Waiter 都喊,“欢迎光临”。一个最大容纳的人数,以及当前人数,还需要一个 启动时间,表示 world 的存续情况。
我们将 world 分成两种,启动的时候加载的 world 和可以根据需要加载的 world。
为了简化问题,我们假定所有的 world 都具有一个唯一的名字,不如说即使有 10 家星巴克,我们也用 北京路 111 号星巴克,南京路 222 号星巴克分别命名。
struct WorldInfo {
script_name: String,
size: u32,
max: u32,
}
复制代码
这是加载或者未加载的 world 通用的结构,包括绑定脚本的名字,world 的大小,以及 world 容纳的最大玩家数量。
当加载一个 world 之后,设置这个数据结构:
struct ActiveWorld {
start: std::time::Instant, //启动时间
current: u32, //当前人数
caller: Caller<WorldRequest, WorldResponse>,
}
复制代码
启动时间,当前玩家人数,以及外部世界用来对 world 发送消息的 句柄。
#[derive(Debug, Clone)]
pub struct WorldManager { // 多世界管理器,以后可以跨服务器管理
worlds: BTreeMap<String, (WorldInfo, Option<ActiveWorld>)>,
}
impl WorldManager {
pub fn new()-> WorldManager {
WorldManager{worlds: BTreeMap::new()}
}
pub fn add(&mut self, name: &str, script_name: &str, size:i64, max: i64) {
self.worlds.insert(name.into(), (WorldInfo{script_name: script_name.into(), size: size as u32, max: max as u32}, None));
}
pub fn run(&mut self, name: &str, script_name: &str, size:i64, max: i64) {
self.add(name, script_name, size, max);
self.start(name);
}
pub fn start(&mut self, name: &str)-> bool {
if let Some(value) = self.worlds.get_mut(name) {
if value.1.is_none() {
let (caller, handler) = crossbeam_channel::unbounded();
let c = caller.clone();
let info = value.0.clone();
value.1 = Some(ActiveWorld{start: std::time::Instant::now(), current: 0, caller});
std::thread::spawn(move || {
let mut script = crate::script::Script::new(&info.script_name, &c).unwrap();
let mut w = World::new(info.size as i32, move |targets, msg| {
script.notify(targets, &msg);
});
while !w.step(&handler) {
}
});
return true;
}
}
false
}
pub fn list(&self)-> Vec<(String, u32, Option<(std::time::Instant, u32)>)> {
self.worlds.iter().map(|(k, v)| (k.clone(), v.0.max, v.1.as_ref().map(|a| (a.start.clone(), a.current.clone()) ))).collect()
}
}
复制代码
世界管理器的结构和方法如上述代码,包块创建,增加一个 world(未实际加载),以及实际加载一个 wrold。
list 是 列出所有的 world。
我们使用 OnceCell 来创建一个全局唯一的 WorldManager 对象:
pub static MANAGER: Lazy<Mutex<WorldManager>> = Lazy::new(|| {
let mut engine = rhai::Engine::new();
engine.register_type::<WorldManager>().register_fn("world_manager", WorldManager::new)
.register_fn("add", WorldManager::add).register_fn("run", WorldManager::run);
let manager = engine.eval_file::<WorldManager>("script/worlds.rhai".into()).unwrap();
Mutex::new(manager)
});
复制代码
注意,我们使用脚本引擎来初始化所有的 world,初始化的脚本是 script/worlds.rhai
其代码看起来是这样的:
let manager = world_manager();
manager.add("一米阳光", "script/main.rhai", 1024, 100);
manager.run("休息大厅", "script/main.rhai", 1024, 1000);
manager
复制代码
创建了一个世界管理器,增加一个一米阳光世界,启动一个休息大厅世界,大小都是 1024, 承载的最大人数分别是 100 人和 1000 人。
我们在启动时候看看世界的情况
world::MANAGER.lock().map(|m| println!("{:?}", m.list()) ).ok();
复制代码
执行的结果是这样的:
可以看到,一个只有结构未加载的世界 一米阳光,和一个已经加载的世界 休息大厅。
评论