「SLG」这个词在立项会上几乎从不缺席,却几乎从不指同一件事。有人说的 SLG 是《率土之滨》那种在格子大地图上铺开、按赛季推倒重来的 4X 沙盘;有人说的是《COK》那种围绕基地建设与联盟集结展开的城战;还有人说的是以武将卡牌为核心养成、战斗是阵容对撞的卡牌 SLG。三者共用「策略」「联盟」「大地图」这几个词,但底层系统、数值曲线、付费点几乎不通用。
本章只解决一件事:把一个模糊的「我们要做 SLG」变成一张可复算的品类图谱。它不碰任何具体系统的实现——资源怎么结算、建筑怎么升级、战斗怎么打,那是第 4 到第 6 章;也不碰数值与付费,那是卷二。这里的产物是一张 8 轴设计图谱、一套品类两两距离、一个配置驱动的分类器。
如果你读过本站的 SLG 游戏产品架构专题 ,先把分工说清:那 9 篇拆的是技术架构(前后端分层、模块拆分、通信协议),并且明确写了「不讨论玩法数值」;本卷讲的正是它略过的另一半——产品与数值。两套可以并读:先用那 9 篇确认工程骨架,再用本卷确认产品骨架。
三个方向,而不是一个品类
在公开可观察的产品机制层面(观察时点 2026-10,来源为官方公告、商店页描述与公开试玩),这三个方向的分野大致如下:
| 维度 | 4X 沙盘 | 城战 / 城建 | 卡牌 SLG |
|---|---|---|---|
| 世界 | 持续存在的大地图 | 以自家基地为中心 | 偏关卡/阵容,地图弱 |
| 地图 | 格子地块、逐格占领 | 节点式,基地 + 周边 | 通常无格子 |
| 行军 | 实时行军、可拦截 | 实时行军 | 以阵容对撞为主 |
| 核心养成 | 城建 + 领地 | 城建 + 兵种 | 武将卡牌 |
| 重置 | 赛季重置强 | 弱或无 | 赛季重置强 |
| 联盟 | 是长期目标唯一路径 | 是主要冲突出口 | 是主要冲突出口 |
关键结论:这三个方向不是「同一种玩法加了不同皮」,而是三套互不兼容的系统与数值。 选错方向,后面每一章都要重做。所以立项第一步不是写 PRD,而是先确定自己落在图谱的哪个位置。
八轴设计图谱(S 层)
下面这段是 D 层配置:把三个细分方向放在 8 个设计轴上各打 0–5 分。分值来自公开可观察机制的归纳,不是任何竞品后台数据。
{
"version": 1,
"note": "品类图谱:三个细分方向在 8 个设计轴上的 0-5 分评分,用于量化「哪些轴才是真正的品类分界线」。分值来自公开可观察的产品机制归纳,不是任何竞品后台数据,也不是实测留存/流水。",
"maxScore": 5,
"axes": [
{ "id": "persistentWorld", "name": "持续世界" },
{ "id": "tileGrid", "name": "格子地块" },
{ "id": "marchRealtime", "name": "实时行军" },
{ "id": "heroGacha", "name": "英雄卡牌抽取" },
{ "id": "baseBuildDepth", "name": "城建深度" },
{ "id": "allianceWar", "name": "联盟战强度" },
{ "id": "seasonReset", "name": "赛季重置强度" },
{ "id": "resourceLoopDepth", "name": "资源循环深度" }
],
"genres": [
{ "id": "4x", "name": "4X 沙盘", "scores": [5, 5, 4, 2, 3, 5, 4, 5] },
{ "id": "citywar", "name": "城战 / 城建", "scores": [2, 1, 5, 3, 5, 5, 2, 4] },
{ "id": "cardslg", "name": "卡牌 SLG", "scores": [3, 3, 3, 5, 2, 4, 5, 3] }
]
}
有了这张表,两个问题就能用算术回答,而不是靠吵架:哪两个方向最不像?哪些轴才是真正的分界线? 下面这段是 S 层仿真脚本,读 config/ 算两两 L1 距离与逐轴方差,产出 JSON + CSV + SVG。
# file: sim/ch01_category_map.py
# tier: S
# verify: python3 sim/ch01_category_map.py --config config/ --out out/
"""品类图谱量化:读 config/ch01_category_map.json,算「细分方向两两距离」与「哪些设计轴才是真正的分界线」,产出 JSON + CSV + SVG。
思路:把每个细分方向看成 8 维设计轴上的一个点。
- 两两 L1 距离越大,说明这两个方向在玩家感知上越不像,越不能共用同一套系统与数值。
- 逐轴方差越大,说明这个轴才是真正把品类切开的分界线;方差小的轴是「大家都这么干」的公共惯例。
纯标准库,图表用 xml.etree 手写 SVG,零依赖。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import statistics
import xml.etree.ElementTree as ET
def load_config(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def pairwise_l1(genres: list[dict], axes: list[dict]) -> list[dict]:
out = []
for i in range(len(genres)):
for j in range(i + 1, len(genres)):
a, b = genres[i]["scores"], genres[j]["scores"]
dist = sum(abs(x - y) for x, y in zip(a, b))
out.append({"a": genres[i]["id"], "b": genres[j]["id"], "l1": dist,
"maxL1": len(axes) * 5, "normalized": round(dist / (len(axes) * 5), 4)})
out.sort(key=lambda r: -r["l1"])
return out
def axis_variance(genres: list[dict], axes: list[dict]) -> list[dict]:
rows = []
for k, ax in enumerate(axes):
vals = [g["scores"][k] for g in genres]
rows.append({"axis": ax["id"], "name": ax["name"], "values": vals,
"variance": round(statistics.pvariance(vals), 4), "spread": max(vals) - min(vals)})
rows.sort(key=lambda r: (-r["variance"], r["axis"]))
for rank, r in enumerate(rows, 1):
r["rank"] = rank
return rows
def write_csv(path: str, genres: list[dict], axes: list[dict]) -> None:
with open(path, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["genre"] + [a["id"] for a in axes])
for g in genres:
w.writerow([g["id"]] + g["scores"])
def write_svg(path: str, genres: list[dict], axes: list[dict], max_score: int) -> None:
cell, left, top = 56, 130, 70
width, height = left + cell * len(axes) + 20, top + cell * len(genres) + 20
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(width),
"height": str(height), "viewBox": f"0 0 {width} {height}"})
ET.SubElement(svg, "text", {"x": str(left), "y": "30", "font-size": "18",
"font-family": "sans-serif"}).text = "SLG 品类图谱:设计轴 × 细分方向(0-5)"
for c, ax in enumerate(axes):
ET.SubElement(svg, "text", {"x": str(left + c * cell + cell // 2), "y": str(top - 8),
"font-size": "10", "text-anchor": "middle", "font-family": "sans-serif"}).text = ax["name"]
for r, g in enumerate(genres):
ET.SubElement(svg, "text", {"x": str(left - 8), "y": str(top + r * cell + cell // 2 + 4),
"font-size": "12", "text-anchor": "end", "font-family": "sans-serif"}).text = g["name"]
for c, v in enumerate(g["scores"]):
ratio = v / max_score
red, blue = int(60 + 180 * ratio), int(200 - 150 * ratio)
ET.SubElement(svg, "rect", {"x": str(left + c * cell), "y": str(top + r * cell),
"width": str(cell - 2), "height": str(cell - 2), "fill": f"rgb({red},90,{blue})"})
ET.SubElement(svg, "text", {"x": str(left + c * cell + cell // 2), "y": str(top + r * cell + cell // 2 + 5),
"font-size": "14", "text-anchor": "middle", "fill": "white",
"font-family": "sans-serif"}).text = str(v)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load_config(os.path.join(args.config, "ch01_category_map.json"))
axes, genres = cfg["axes"], cfg["genres"]
os.makedirs(args.out, exist_ok=True)
dists, vars_ = pairwise_l1(genres, axes), axis_variance(genres, axes)
summary = {
"version": 1, "genres": [g["id"] for g in genres], "pairwiseDistance": dists, "axisRanking": vars_,
"topSeparators": [r["axis"] for r in vars_ if r["variance"] >= 1.5],
"commonConventions": [r["axis"] for r in vars_ if r["variance"] < 0.5],
}
with open(os.path.join(args.out, "ch01_category_map.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
write_csv(os.path.join(args.out, "ch01_category_map.csv"), genres, axes)
write_svg(os.path.join(args.out, "ch01_category_map.svg"), genres, axes, cfg["maxScore"])
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
同机实跑(节选):
pairwiseDistance: citywar-cardslg l1=15 (0.375) > 4x-citywar l1=14 (0.35) > 4x-cardslg l1=13 (0.325)
axisRanking: tileGrid 2.6667 > baseBuildDepth/heroGacha/persistentWorld/seasonReset 1.5556
> marchRealtime/resourceLoopDepth 0.6667 > allianceWar 0.2222
topSeparators: [tileGrid, baseBuildDepth, heroGacha, persistentWorld, seasonReset]
commonConventions: [allianceWar]
读法:分界线排序才是可用的结论
三组距离都在 13–15(上限 40),差距不大,说明三个方向整体上属于同一个大类——这正是它们都被叫「SLG」的原因。真正有用的是逐轴方差:
- 格子地块(方差 2.6667)是第一分界线。4X 是 5 分、城战是 1 分,这一条就足以把前两者劈开。它决定了地图系统、寻路、行军、视野的全部实现。
- 城建深度、英雄卡牌抽取、持续世界、赛季重置(各 1.5556)是次级分界线,两两组合出三个方向。
- 联盟战强度(方差 0.2222)几乎不区分:三个方向都靠联盟做长期目标,这是「公共惯例」,不是分界线。把它当卖点写进定位,等于没写。
这张表的用法很直接:如果你的团队只在某个轴上想「和竞品不一样」,先查它的方差。方差低的轴(联盟战、实时行军、资源循环)改了也不构成品类差异;方差高的轴(格子地块、卡牌抽取)动一下,整套系统就要重写。
配置驱动的分类器(P 层)
图谱能画,还要能用来判。下面这段是 P 层原型:读同一份 config/ch01_category_map.json,把任意产品画像(8 个轴上的打分)映射到最近的细分方向。它与 S 层共用配置,距离口径一致(L1)。
// file: proto/src/ch01_category_map.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// bastion 原型层:品类图谱分类器。读 config/ch01_category_map.json,
// 把一个产品在 8 个设计轴上的打分映射到最近的细分方向,并给出「哪些轴才是真分界线」。
// 与 sim/ch01_category_map.py 共用同一份配置,距离口径一致(L1)。
import { loadConfig } from "./core.ts";
export interface AxisDef { id: string; name: string }
export interface GenreDef { id: string; name: string; scores: number[] }
export interface CategoryMap { version: number; maxScore: number; axes: AxisDef[]; genres: GenreDef[] }
export interface Ranked { genreId: string; name: string; distance: number; normalized: number }
export function l1(a: number[], b: number[]): number {
let s = 0;
for (let i = 0; i < a.length; i++) s += Math.abs(a[i] - b[i]);
return s;
}
export function rankGenres(scores: number[], map: CategoryMap): Ranked[] {
const maxL1 = map.axes.length * map.maxScore;
return map.genres
.map((g) => {
const d = l1(scores, g.scores);
return { genreId: g.id, name: g.name, distance: d, normalized: d / maxL1 };
})
.sort((x, y) => (x.distance - y.distance) || x.genreId.localeCompare(y.genreId));
}
export function classify(scores: number[], map: CategoryMap): Ranked & { ranked: Ranked[] } {
if (scores.length !== map.axes.length) {
throw new Error(`维度不匹配:期望 ${map.axes.length},收到 ${scores.length}`);
}
const ranked = rankGenres(scores, map);
return { ...ranked[0], ranked };
}
export function discriminatingAxes(map: CategoryMap): { axis: string; name: string; variance: number }[] {
return map.axes
.map((ax, k) => {
const vals = map.genres.map((g) => g.scores[k]);
const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
const variance = vals.reduce((a, b) => a + (b - mean) ** 2, 0) / vals.length;
return { axis: ax.id, name: ax.name, variance };
})
.sort((a, b) => (b.variance - a.variance) || a.axis.localeCompare(b.axis));
}
export function loadCategoryMap(): CategoryMap {
return loadConfig<CategoryMap>("ch01_category_map");
}
分类器要有断言才算数。下面是配套的 P 层测试,覆盖纯画像归类、中间点排序、维度校验与确定性。
// file: proto/test/ch01.test.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 第 1 章原型测试:品类图谱分类器(proto/src/ch01_category_map.ts)。
import { test } from "node:test";
import assert from "node:assert/strict";
import { l1, classify, rankGenres, discriminatingAxes, loadCategoryMap } from "../src/ch01_category_map.ts";
const MAP = loadCategoryMap();
test("P:L1 距离对称且非负", () => {
const a = MAP.genres[0].scores, b = MAP.genres[1].scores;
assert.equal(l1(a, b), l1(b, a));
assert.ok(l1(a, b) > 0);
});
test("P:纯画像各自归到对应方向", () => {
assert.equal(classify([5, 5, 4, 2, 3, 5, 4, 5], MAP).genreId, "4x");
assert.equal(classify([2, 1, 5, 3, 5, 5, 2, 4], MAP).genreId, "citywar");
assert.equal(classify([3, 3, 3, 5, 2, 4, 5, 3], MAP).genreId, "cardslg");
});
test("P:中间点按最近距离排序", () => {
const ranked = rankGenres([3, 2, 4, 4, 4, 5, 4, 4], MAP);
assert.ok(ranked[0].distance <= ranked[1].distance && ranked[1].distance <= ranked[2].distance);
});
test("P:维度不匹配会抛错", () => {
assert.throws(() => classify([1, 2, 3], MAP));
});
test("P:分界线排序里格子地块排第一,且结果确定", () => {
const axes = discriminatingAxes(MAP);
assert.equal(axes[0].axis, "tileGrid");
const s = [3, 2, 4, 4, 4, 5, 4, 4];
assert.deepEqual(rankGenres(s, MAP), rankGenres(s, MAP));
});
node --test 'proto/**/*.test.ts' 在本机全绿;proto/test/ch01.test.ts 的 5 个用例覆盖三件事:三个纯画像分别归到 4x / citywar / cardslg,中间点距离单调、维度不符抛错,以及分界线首位是 tileGrid。
《王朝纪元》落在哪里
把《王朝纪元》的定位画像([1, 1, 1, 0, 1, 1, 1, 0] 的二值版本)丢进分类器,它离 4X 沙盘最近,离卡牌 SLG 最远。这解释了本卷后面为什么大量出现「格子地块」「赛季重置」「领地占领」——它们是这个方向的分界线轴,必须在产品骨架里正面回答。
反过来,如果某天有人提议「加一套武将抽卡」,分类器会把它往卡牌 SLG 拉,而卡牌 SLG 的城建与地块都很弱——这不是「加个功能」,而是换方向。分类器的价值不是给答案,是让「我们在做哪个方向」这件事在评审时有一个可复算的数字。
小结
- SLG 是三个互不兼容的方向:4X 沙盘、城战城建、卡牌 SLG。选方向 = 选系统与数值,早于写 PRD。
- 用 8 轴图谱把方向量化后,格子地块是最大分界线(方差 2.6667),联盟战几乎不区分(0.2222)。
- 分类器(P 层)与图谱(S 层)共用一份
config/,距离口径一致,可断言、可回放。 - 下一节把「我们是谁」从内部画像转到外部:怎么用公开可观察机制拆竞品。
阅读导航:上一节:目录 · 下一节:1.2 目标用户与竞品拆解方法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。