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 从零实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。