1.3 差异化定位与设计约束

定位不该是一句 PPT 口号,而是一串可判定的判据。本篇把《王朝纪元》的一句话定位拆成 must / must_not / should 三类约束,再用一个约束检查器把候选功能逐条过筛,输出接受 / 告警 / 否决三档,让每次功能评审都有可复算的门禁,而不是靠拍脑袋。

前两节朝内画了品类图谱、朝外拆了竞品,这一节把它们收成一句话,再翻译成能跑的判据。一句定位如果只能贴在立项 PPT 上、不能否决任何一个具体功能,那它就是装饰;反过来,如果它能对每个候选功能回答「接受、告警、还是否决」,它才真正约束了产品。

《王朝纪元》的定位是这样一句话:

轻量 4X 沙盘 + 联盟赛季制,服务碎片时间的中度策略玩家,拒绝重氪碾压与高强度日常。

这句话不是随便写的——它同时继承前两节的结论:方向选 4X 沙盘(1.1 里离卡牌 SLG 最远),放弃资源掠夺(1.2 里唯一的强惯例背离),把联盟当长期目标的唯一路径(1.2 的惯例轴,也是 1.1 里方差最低的轴——正因为它不区分品类,才适合当内部骨架)。本节的任务是让这句话可判定。

定位陈述的三种反模式

在把定位拆成约束之前,先确认它不是下面三种废话之一:

反模式典型例子为什么不能用
形容词堆砌「史诗级、沉浸式、高自由度」无法判定,也约束不了任何系统
竞品复述「像《三国志·战略版》但更好」「更好」没有方向,架构无法取舍
万能覆盖「服务所有喜欢策略的玩家」同时命中三个互斥方向,等于没定位

一条合格的定位陈述必须同时说清三件事:做什么(方向)、为谁做(用户)、不做什么(约束)。上面那句《王朝纪元》的定位三项俱全——方向是 4X 沙盘,用户是碎片时间中度玩家,不做什么是拒绝重氪与高强度日常——所以它才拆得开。

定位是一句话,约束是一串判据

把一句话定位拆成约束,关键是每个约束都绑到一个可测量的属性上。约束分三类:

  • must:必须满足,不满足说明产品没在做自己声称的事;
  • must_not:必须不做,做了就是跑偏;
  • should:最好满足,违反只降分告警,不否决。

「拒绝高强度日常」这种表述无法判定,但翻译成「每日强制重复任务次数 ≤ 9」就可以判定。下面这段是 D 层配置:把定位拆成 6 条约束,再列 6 个候选功能及其可测属性。

{
  "version": 1,
  "game": "王朝纪元",
  "statement": "轻量 4X 沙盘 + 联盟赛季制,服务碎片时间的中度策略玩家,拒绝重氪碾压与高强度日常。",
  "constraints": [
    { "id": "C1", "kind": "must", "text": "单次有效决策可在碎片时间完成", "attr": "sessionMinutes", "op": "le", "value": 15 },
    { "id": "C2", "kind": "must_not", "text": "不做对玩家基地的资源掠夺", "attr": "griefing", "op": "le", "value": 1 },
    { "id": "C3", "kind": "must_not", "text": "不设高强度每日重复劳动", "attr": "dailyTasks", "op": "le", "value": 9 },
    { "id": "C4", "kind": "must_not", "text": "付费不可直购战力", "attr": "p2wDirect", "op": "eq", "value": false },
    { "id": "C5", "kind": "should", "text": "联盟协作是长线目标的唯一路径", "attr": "allianceRequired", "op": "ge", "value": 2 },
    { "id": "C6", "kind": "should", "text": "赛季周期落在 8-10 周", "attr": "seasonWeeks", "op": "between", "value": [8, 10] }
  ],
  "features": [
    { "id": "F1", "name": "碎片化出征", "attrs": { "sessionMinutes": 12, "griefing": 0, "allianceRequired": 1 } },
    { "id": "F2", "name": "基地掠夺战", "attrs": { "griefing": 3 } },
    { "id": "F3", "name": "每日 20 次重复采集", "attrs": { "dailyTasks": 20 } },
    { "id": "F4", "name": "氪金直购兵力", "attrs": { "p2wDirect": true } },
    { "id": "F5", "name": "赛季结算与重置", "attrs": { "seasonWeeks": 9 } },
    { "id": "F6", "name": "联盟集结玩法", "attrs": { "allianceRequired": 3 } }
  ]
}

注意一个设计细节:候选功能只声明它触碰到的属性。F2「基地掠夺战」只声明 griefing: 3,不声明会话时长——因为约束检查是「属性缺失即不适用」,这样每个功能只被它真正相关的约束筛,避免为了填满表格而编属性。

约束检查器(S 层)

下面这段是 S 层仿真脚本:读上面的配置,把每个候选功能逐条过约束,输出「接受 / 告警 / 否决」三档,产出 JSON + CSV + SVG。

# file: sim/ch01_positioning_constraints.py
# tier: S
# verify: python3 sim/ch01_positioning_constraints.py --config config/ --out out/
"""定位→设计约束检查器:读 config/ch01_positioning.json,
把「一句话定位」拆成可判定的约束,再拿候选功能逐条过筛,产出 JSON + CSV + SVG。

约束分两类:
- must / must_not:硬约束,违反即否决该功能;
- should:软约束,违反只降分并告警。
这样「定位」就不再是口号,而是每次评审都能跑一遍的门禁。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
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 check_constraint(c: dict, attrs: dict) -> str | None:
    """返回 None=通过,'hard'=硬违反,'soft'=软违反;属性缺失视为不适用(None)。"""
    if c["attr"] not in attrs:
        return None
    v, op, target = attrs[c["attr"]], c["op"], c["value"]
    if op == "le":
        ok = v <= target
    elif op == "ge":
        ok = v >= target
    elif op == "eq":
        ok = v == target
    elif op == "between":
        ok = target[0] <= v <= target[1]
    else:
        raise ValueError(f"未知算子 {op}")
    if ok:
        return None
    return "hard" if c["kind"] in ("must", "must_not") else "soft"


def evaluate(cfg: dict) -> list[dict]:
    rows = []
    for f in cfg["features"]:
        hard = [c["id"] for c in cfg["constraints"] if check_constraint(c, f["attrs"]) == "hard"]
        soft = [c["id"] for c in cfg["constraints"] if check_constraint(c, f["attrs"]) == "soft"]
        rows.append({"id": f["id"], "name": f["name"], "hardViolations": hard, "softViolations": soft,
                     "verdict": "reject" if hard else ("warn" if soft else "accept")})
    return rows


def write_csv(path: str, rows: list[dict]) -> None:
    with open(path, "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["feature", "verdict", "hardViolations", "softViolations"])
        for r in rows:
            w.writerow([r["id"], r["verdict"], "|".join(r["hardViolations"]), "|".join(r["softViolations"])])


def write_svg(path: str, rows: list[dict]) -> None:
    colors = {"accept": "#2e8b57", "warn": "#d68910", "reject": "#c0392b"}
    left, top, gap = 220, 60, 40
    width, height = left + 200, top + gap * len(rows) + 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": "20", "y": "30", "font-size": "18",
                                "font-family": "sans-serif"}).text = "《王朝纪元》候选功能 vs 定位约束"
    for i, r in enumerate(rows):
        y = top + i * gap
        ET.SubElement(svg, "text", {"x": str(left - 12), "y": str(y + 18), "font-size": "13",
                                    "text-anchor": "end", "font-family": "sans-serif"}).text = r["name"]
        ET.SubElement(svg, "rect", {"x": str(left), "y": str(y), "width": "150", "height": "26",
                                    "fill": colors[r["verdict"]]})
        v = r["hardViolations"] + r["softViolations"]
        ET.SubElement(svg, "text", {"x": str(left + 8), "y": str(y + 18), "font-size": "12",
                                    "fill": "white", "font-family": "sans-serif"}).text = \
            r["verdict"] + (" " + ",".join(v) if v else "")
    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_positioning.json"))
    os.makedirs(args.out, exist_ok=True)
    rows = evaluate(cfg)
    summary = {"version": 1, "game": cfg["game"], "statement": cfg["statement"],
               "nConstraints": len(cfg["constraints"]), "nFeatures": len(cfg["features"]),
               "accepted": [r["id"] for r in rows if r["verdict"] == "accept"],
               "warned": [r["id"] for r in rows if r["verdict"] == "warn"],
               "rejected": [r["id"] for r in rows if r["verdict"] == "reject"], "rows": rows}
    with open(os.path.join(args.out, "ch01_positioning_constraints.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
    write_csv(os.path.join(args.out, "ch01_positioning_constraints.csv"), rows)
    write_svg(os.path.join(args.out, "ch01_positioning_constraints.svg"), rows)
    print(json.dumps(summary, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

同机实跑(6 约束 × 6 功能):

accepted: [F5, F6]
warned:   [F1]        # F1 碎片化出征:allianceRequired=1 低于 should 的 2
rejected: [F2, F3, F4]
  F2 基地掠夺战        reject  hard=[C2]   # griefing=3 违反「不做资源掠夺」
  F3 每日 20 次重复采集 reject  hard=[C3]   # dailyTasks=20 违反「不设高强度日常」
  F4 氪金直购兵力      reject  hard=[C4]   # p2wDirect=true 违反「付费不可直购战力」

结果读法:三档各自意味着什么

  • reject(硬违反):功能与定位直接冲突,必须改或砍。三个被否决的功能恰好命中三条 must_not——这不是巧合,而是定位在起作用:它真的否决了东西。 一条否决不了任何功能的定位是失效的。
  • warn(软违反):功能不违规,但离定位的「理想形态」有距离。F1 碎片化出征的联盟依赖只有 1,低于 should 要求的 2——它本身没问题,但提示「出征玩法还没把联盟绑进去」,是个待办而非否决。
  • accept:与定位一致,放行。F5 赛季结算(9 周落在 8–10 区间)、F6 联盟集结(依赖度 3)都干净通过。

这里有个值得强调的方法论:硬约束应该是「少而硬」,软约束才是「多而柔」。 如果把 C5、C6 也设成 must,检查器会把大部分创新功能一律否决,约束就变成了枷锁;反过来如果把 C2、C4 设成 should,「拒绝重氪碾压」就沦为空话。分类的关键是问自己:这条约束被违反时,产品还是不是它自己?是——should;不是——must。

约束会互相打架

约束一旦多了,就会互相冲突。上面 6 条里就藏着两对张力:

  • C1(碎片时间)× C5(联盟唯一路径):联盟玩法天然需要多人协调与等待,容易把单次会话拉长。碎片玩家能否参与联盟集结,是产品必须正面回答的问题——本卷第 3 章会用「异步可参与的联盟行为」来化解这对张力。
  • C3(无高强度日常)× C6(8–10 周赛季):不靠每日打卡维持活跃,就必须靠赛季节点的集中事件拉动。这意味着产出的时间分布不能是平的,而要有明显的赛季波形——这条待办会落到第 10 章的产出曲线仿真上。

约束冲突不是错误,而是设计空间的边界。 检查器只负责把违反暴露出来,怎么在两条约束之间找平衡,是策划的活。把冲突写成待办、交给下游章节,比假装它们不存在要诚实得多。

怎么把检查器用起来

这套检查器不是为了跑一次,而是为了每次评审都跑:

  1. 新功能进评审前先补属性,只补它真正触碰到的字段,避免为了填表而编数字。
  2. 跑一遍检查器,得到接受 / 告警 / 否决三档。
  3. reject 的功能要么改、要么砍;warn 的功能进待办,连同它的软违反约束一起记录。
  4. 约束本身也要复盘:如果某个 must 频繁否决团队公认的好功能,先怀疑约束写错了,而不是功能做错了。

第 4 步最关键。约束是定位的投影,投影歪了,往往是定位本身需要修正——这时候应该回到 1.1 / 1.2 重看品类与竞品结论,而不是硬压功能。

定位与约束如何流向后续章节

这套约束不是写完就归档的文档,它会一路向下游传递:

约束下游章节兑现方式
C1 碎片时间第 3 章 核心循环单次会话可完成一次有效决策的节奏设计
C2 无掠夺第 14 章 交易与市场用市场替代掠夺做资源再分配
C3 无高强度日常第 10 / 11 章 产出与消耗产出靠长线而非每日打卡
C4 无直购战力卷二 付费点设计付费点映射到外观/加速而非战力
C5 联盟唯一路径第 4 章 系统地图联盟系统在依赖图上处于高位

这张表也解释了本卷的结构:第 1 章定约束,第 3–15 章逐条兑现。 如果某条约束在后文找不到兑现它的章节,要么约束是多余的,要么产品缺了一块系统。

小结

  • 定位必须翻译成可判定的约束(must / must_not / should),否则无法否决任何功能。
  • 约束检查器(S 层)把候选功能过成接受 / 告警 / 否决三档,是可复算的评审门禁。
  • 硬约束要「少而硬」,软约束「多而柔」;判据是「被违反时产品还是不是它自己」。
  • 三个被否决的功能正好命中三条 must_not——定位真的在干活,这是它有效的最直接证据。
  • 第 1 章到此收束:品类(1.1)→ 用户与竞品(1.2)→ 定位与约束(1.3)。下一章转向玩家本身。

阅读导航:上一节:1.2 目标用户与竞品拆解方法 · 下一节:2.1 玩家动机模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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