《MMO 服务器开发》3.3 分线、副本与主城/野外

同一张地图模板,落地方式却要分三六九等:野外和主城该共享分线,副本要一人一实例。本节从零写 internal/scene 的 LineManager 与 Router:分线按容量塞满一条开下一条,副本每次进入开独立实例,并用统一入口把两种落点收敛到一个 EnterWorld。全部 L1,go test 可直接跑。

3.3 分线、副本与主城/野外

前两节分别解决了地图模板(3.1)和实例(3.2)。现在把两者合起来看一个更实际的问题:玩家「进入一张地图」到底意味着什么?

答案不是唯一的。主城和野外是共享的——几百个玩家挤在同一条时间线上,能互相看见、能交易、能抢怪,人太多才拆成多条「线」。副本是独占的——一队人进本,开一个只属于他们的实例,打完就散。同样是「进入晨曦大陆的一张图」,两者的资源模型、人数上限、回收策略完全不同。

如果代码里把这两种情况混在一起,最常见的后果是:野外图被人开了几千个实例(每个人都以为是副本),或者副本图被人共享到互相干扰(一队人打本,隔壁队突然冒出来抢 boss)。本节的做法是:把「进图」这件事收敛成一个统一入口,内部分成分线和独立实例两条路,各走各的资源模型。全部 L1,go test 可直接跑。

三种落点:野外、主城、副本

先给地图分类。我们把地图的类型(SceneKind)作为它的静态元信息之一,而不是运行时才判:

  • 野外(open world):分线共享。同一张图可以有 N 条线,玩家被分到其中一条;线内互相可见,跨线不可见。容量由「每条线装多少人」决定。
  • 主城(main city):本质也是野外,只是容量更大、更强调聚集(摆摊、聊天、组队都在这里)。实现上不特殊处理,只是 CapacityPerLine 给得高。
  • 副本(dungeon):独占实例。每次「进入」都开一个新的地图实例,一队人(或一个人)独占,退出即回收。

关键区别是:野外图的实例数是有上限的(分线数 = 在线人数 / 每条线容量),副本图的实例数是随进入次数线性增长的(来一次开一个)。两种增长模型必须分开管,否则容量规划会算错。

落点资源模型实例数增长谁能看见谁
野外共享分线人数 / 线容量,有上限线内互相可见
主城共享分线(大容量)人数 / 大容量线内互相可见
副本独占实例随进入次数增长仅本队可见

分线:容量驱动,塞满一条就开下一条

分线的核心算法只有一句话:优先塞进第一条没满的线;都满了,就开一条新线。 这是一个贪心,但对玩家体验足够——它保证新开的线只有一个人,人不会零散地铺在几十条半空的线上。

Line 与 Instance 的区别要拎清:Line 不持有地图数据,它和同地图其他分线共享同一份 MapData,只隔离玩家集合。所以开一条新线的成本极低——一张 map[uint64]struct{} 而已。

下面是完整的 internal/scene/line.go,L1,go test ./internal/scene/ 可以直接跑:

// file: internal/scene/line.go
// tier: L1
// verify: go test ./internal/scene/
package scene

import (
	"errors"
	"sync"
)

// LineID 是一条分线的 ID。
type LineID uint64

// SceneKind 决定「进入这张地图」怎么落地:共享分线,还是独立实例。
type SceneKind uint8

const (
	KindOpenWorld SceneKind = iota // 野外 / 主城:多人在同一条分线里共享
	KindDungeon                    // 副本:每次进入开一个独立实例
)

// WorldMap 是地图模板的元信息:类型 + 每条分线的人数上限。
type WorldMap struct {
	ID              uint32
	Kind            SceneKind
	CapacityPerLine int
}

// Line 是一条分线:与同地图其他分线共享同一份地图模板,只隔离玩家集合。
type Line struct {
	ID       LineID
	MapID    uint32
	players  map[uint64]struct{}
	capacity int
}

func (l *Line) PlayerCount() int { return len(l.players) }

// LineManager 负责野外/主城的分线分配:塞满一条就开下一条。
type LineManager struct {
	mu    sync.Mutex
	next  LineID
	lines map[LineID]*Line
	byMap map[uint32][]LineID
}

func NewLineManager() *LineManager {
	return &LineManager{
		lines: make(map[LineID]*Line),
		byMap: make(map[uint32][]LineID),
	}
}

// Assign 把玩家放进 mapID 的一条分线:优先第一条未满的,都满了就新开一条。
func (lm *LineManager) Assign(m *WorldMap, player uint64) LineID {
	lm.mu.Lock()
	defer lm.mu.Unlock()
	for _, id := range lm.byMap[m.ID] {
		l := lm.lines[id]
		if len(l.players) < l.capacity {
			l.players[player] = struct{}{}
			return id
		}
	}
	lm.next++
	l := &Line{
		ID:       lm.next,
		MapID:    m.ID,
		players:  make(map[uint64]struct{}),
		capacity: m.CapacityPerLine,
	}
	l.players[player] = struct{}{}
	lm.lines[l.ID] = l
	lm.byMap[m.ID] = append(lm.byMap[m.ID], l.ID)
	return l.ID
}

// Leave 把玩家移出一条分线;空分线保留,由运维侧按需合线。
func (lm *LineManager) Leave(id LineID, player uint64) bool {
	lm.mu.Lock()
	defer lm.mu.Unlock()
	l, ok := lm.lines[id]
	if !ok {
		return false
	}
	delete(l.players, player)
	return true
}

// LinesOf 返回某地图当前的分线数量,用于观测「是不是分得太碎」。
func (lm *LineManager) LinesOf(mapID uint32) int {
	lm.mu.Lock()
	defer lm.mu.Unlock()
	return len(lm.byMap[mapID])
}

// CountOn 返回某条分线当前人数。
func (lm *LineManager) CountOn(id LineID) int {
	lm.mu.Lock()
	defer lm.mu.Unlock()
	if l, ok := lm.lines[id]; ok {
		return len(l.players)
	}
	return 0
}

var ErrNoSuchMap = errors.New("scene: 未注册的地图")

// Router 把「进入一张地图」统一成两种落点:
//   - 野外 / 主城 -> 进一条共享分线(复用 LineManager)
//   - 副本       -> 开一个独立实例(复用 InstanceManager)
type Router struct {
	lines     *LineManager
	instances *InstanceManager
	maps      map[uint32]*MapData
}

func NewRouter(lines *LineManager, instances *InstanceManager) *Router {
	return &Router{lines: lines, instances: instances, maps: make(map[uint32]*MapData)}
}

// RegisterMap 注册地图模板,进入副本时要拿它开实例。
func (r *Router) RegisterMap(md *MapData) { r.maps[md.ID] = md }

// EnterWorld 返回落点:进分线则 line != 0,进副本则 inst != 0。
func (r *Router) EnterWorld(m *WorldMap, player uint64, tick int64) (LineID, InstanceID, error) {
	switch m.Kind {
	case KindOpenWorld:
		return r.lines.Assign(m, player), 0, nil
	case KindDungeon:
		md, ok := r.maps[m.ID]
		if !ok {
			return 0, 0, ErrNoSuchMap
		}
		inst := r.instances.Create(md, tick)
		if err := r.instances.Enter(inst.ID, player, tick); err != nil {
			return 0, 0, err
		}
		return 0, inst.ID, nil
	default:
		return 0, 0, ErrNoSuchMap
	}
}

Assign 里 Leave 只是把玩家从线里删掉,不删线。一条空线保留下来,下次有人进直接复用,避免「开线—拆线—再开线」的抖动。真正要合线(把两条半空的线并成一条)是运维动作,需要把人先迁走再删线,不该由高频的进出路径自动触发。

Router.EnterWorld 是本节的重点:它对外只暴露一个入口,内部按 Kind 分派。野外走 LineManager.Assign,副本走 InstanceManager.Create——注意副本不检查重复进入,每次调用都开新实例,这是有意为之:同一个人今天开本、明天再开本,本该是两个独立实例。返回的 (LineID, InstanceID) 里恰好有一个非零,调用方据此知道该把人挂到哪。

用路由把两种落点钉死

分派逻辑最容易写错的地方是「该共享的共享了、该独占的又共享了」,所以用测试把两种落点各自的形状固定下来。下面这段是同一 L1 包的一部分:

// snippet: internal/scene/line_test.go(摘录)
func TestAssignFillsLinesThenOpensNew(t *testing.T) {
	lm := NewLineManager()
	city := &WorldMap{ID: 1, Kind: KindOpenWorld, CapacityPerLine: 2}

	// 5 个玩家、每条线容量 2 -> 应该开出 3 条线:2/2/1。
	lines := make([]LineID, 0, 5)
	for p := uint64(1); p <= 5; p++ {
		lines = append(lines, lm.Assign(city, p))
	}
	if got := lm.LinesOf(1); got != 3 {
		t.Fatalf("分线数 = %d,期望 3", got)
	}
	if lines[0] != lines[1] {
		t.Fatal("前两个玩家应落在同一条线")
	}
	if lines[2] != lines[3] {
		t.Fatal("第 3、4 个玩家应落在第二条线")
	}
	if lines[4] == lines[0] || lines[4] == lines[2] {
		t.Fatal("第 5 个玩家应落在第三条线")
	}
	if c := lm.CountOn(lines[0]); c != 2 {
		t.Fatalf("第一条线人数 = %d,期望 2", c)
	}
	// 走掉一个,空位应被后来者复用,不新开线。
	lm.Leave(lines[0], 1)
	again := lm.Assign(city, 6)
	if again != lines[0] {
		t.Fatalf("复用空位失败:得到线 %d,期望 %d", again, lines[0])
	}
	if lm.LinesOf(1) != 3 {
		t.Fatal("复用空位不应新开分线")
	}
}

func TestRouterOpenWorldVsDungeon(t *testing.T) {
	lm := NewLineManager()
	im := NewInstanceManager(10, 8)
	r := NewRouter(lm, im)

	md, _ := NewMapData(2, 16, 16)
	r.RegisterMap(md)

	field := &WorldMap{ID: 1, Kind: KindOpenWorld, CapacityPerLine: 10}
	dungeon := &WorldMap{ID: 2, Kind: KindDungeon}

	// 野外:两个玩家落同一条线。
	l1, i1, err := r.EnterWorld(field, 100, 0)
	if err != nil {
		t.Fatal(err)
	}
	l2, _, _ := r.EnterWorld(field, 101, 0)
	if i1 != 0 || l1 == 0 || l1 != l2 {
		t.Fatalf("野外落点异常: l1=%d l2=%d inst=%d", l1, l2, i1)
	}
	// 副本:两个玩家各拿一个独立实例。
	_, d1, _ := r.EnterWorld(dungeon, 100, 0)
	_, d2, _ := r.EnterWorld(dungeon, 101, 0)
	if d1 == 0 || d2 == 0 || d1 == d2 {
		t.Fatalf("副本应各自独立: d1=%d d2=%d", d1, d2)
	}
	if im.LiveCount() != 2 {
		t.Fatalf("副本实例数 = %d,期望 2", im.LiveCount())
	}
	// 未注册地图的副本进入要报错,而不是 panic。
	bad := &WorldMap{ID: 99, Kind: KindDungeon}
	if _, _, err := r.EnterWorld(bad, 1, 0); err != ErrNoSuchMap {
		t.Fatalf("未注册地图 = %v,期望 ErrNoSuchMap", err)
	}
}

TestAssignFillsLinesThenOpensNew 把分线的贪心形状固定成 2/2/1:容量 2、5 个玩家,前两个同线、中间两个同线、最后一个开新线。然后走掉一个、再来一个,断言复用空位而不是新开线——这是「线不抖动」的验收。

TestRouterOpenWorldVsDungeon 则对照两种落点:野外的两个玩家落到同一条线(l1 == l2、实例号为 0),副本的两个玩家各自拿到独立实例(d1 != d2、线号为 0)。最后一行是防御性测试:未注册地图的副本进入必须返回 ErrNoSuchMap 而不是空指针 panic——服务端宁可直接拒了,也不要在一个没加载的模板上开实例。

本节小结 / 实测结论

本机(Go 1.26.0,darwin/arm64)实跑结果:

$ go vet ./...
(无输出,通过)

$ go test ./internal/scene/
ok  	aegis/internal/scene	0.4s   (10 个 test 全绿)

$ go test -run TestAssignFillsLinesThenOpensNew -v ./internal/scene/
=== RUN   TestAssignFillsLinesThenOpensNew
--- PASS: TestAssignFillsLinesThenOpensNew (0.00s)

本节把「进入一张地图」收敛成了一个入口 Router.EnterWorld,内部两条路:野外/主城走 LineManager 的分线贪心,副本走 InstanceManager 的独立实例。至此,场景层的地基就搭完了——地图模板(3.1)、实例(3.2)、落点路由(3.3)三件套齐全,internal/scene 包的 10 个测试全绿。

下一章进入 AOI:有了地图和实例,接下来的问题是「一个实体该看见谁」。3.1 的 GridSize = 8.0 那把标尺,会在 4.1 的九宫格 AOI 里再次派上用场。

阅读导航:上一节:3.2 实例创建与回收 · 下一节:4.1 九宫格 AOI 从零实现 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标