1.1 SLG 品类图谱与细分(4X / 城战 / 卡牌 SLG)

「SLG」几乎从不指同一件事。本篇用一张 8 轴设计图谱把 4X 沙盘、城战城建、卡牌 SLG 量化区分开,跑出三个方向的两两距离与真正的分界线排序,再给一个配置驱动的分类器,把任意产品画像映射到最近的细分方向,为《王朝纪元》的立项定位提供可复算的底稿。

「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 目标用户与竞品拆解方法 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引