16.3 交付与后续

三卷本收官清点:150 节正文加一条跨语言工具链(config / sim / core / web / out 七个目录),三卷各留一份交接产物;说明三条使用入口——照着读、照着复现、换成自己的规格当模板;划清边界——机制侧可算,主观体验只能用代理指标做相对比较,美术 / 性能 / 网络 / 商业化需真实业务环境(X 档);最后交代三个后续方向,并让三卷的三个问题在此闭合。

16.3 交付与后续

站内 《小游戏开发实战》 五卷本收官时给的是一份「上线清单」——渠道、买量、留存、出海,落点是一款产品真的上线。本套《游戏设计》三卷本收官给的不是上线清单,而是一份可复用的机制工具链:150 节全部围绕同一份 config/play_spec.json 展开,每节的可运行代码都落在同一套目录结构里,换个规格就能当模板用。这一节清点交付物、说明怎么用、划清能力的边界,并交代后续可以往哪走。

交付物:150 节加一条工具链

三卷共 150 节正文(16 章 × 3 节 + 目录 + 附录,每卷 50 节),加上卷三这最后一章。工具链不是「一堆散脚本」,而是七个各有分工的目录:

目录放什么档位
config/规格与清单:play_spec.json、变体注册表、装配检查表、交付清单D
sim/Python 仿真与核对脚本S
core/src/ashdeck/本体内核:类型 / PRNG / 卡牌 / 战斗 / digestP
core/genre/五个品类内核:塔防 / 三消 / 平台跳跃 / 自走棋 / 放置P
core/test/内核测试P
web/浏览器原型的构建与验证W
out/全部产物:单文件 HTML、截图、核对用的 JSON—

交付清单本身也是数据,把「三卷齐、合计 150 节、工具链目录齐、各卷交接产物在、基线 digest 未漂移」这几条写成可核对的字段。下面这份清单的 baseDigest 就是那条贯穿三卷的字符串——从卷一第一次算出来到卷三收官,一个字节都没漂。

{
  "id": "gd_proto_16_deliverable",
  "spec": "ashdeck",
  "book": "《游戏设计》三卷本",
  "totalSections": 150,
  "baseDigest": "{\"block\":0,\"cardCount\":12,\"damageConserved\":true,\"dealt\":50,\"enemyHp\":0,\"energy\":1,\"hand\":[\"strike\",\"jab\",\"heavy\"],\"over\":true,\"played\":9,\"playerHp\":48,\"turn\":4,\"won\":true}",
  "volumes": [
    {
      "volume": "玩法机制与可执行规格",
      "prefix": "gd-mech-",
      "sections": 50,
      "question": "规格怎么写、怎么跑、怎么证明自洽",
      "handoff": "out/gd_mech_16_handoff.json"
    },
    {
      "volume": "策略、涌现与体验度量",
      "prefix": "gd-play-",
      "sections": 50,
      "question": "机制跑起来涌现什么、玩家体验到什么",
      "handoff": "out/gd_play_16_handoff.json"
    },
    {
      "volume": "原型、可玩性与品类",
      "prefix": "gd-proto-",
      "sections": 50,
      "question": "同一份规格换品类还成不成立",
      "handoff": "out/gd_proto_16_replay.json"
    }
  ],
  "toolchain": [
    "config",
    "sim",
    "core/src/ashdeck",
    "core/genre",
    "core/test",
    "web",
    "out"
  ],
  "howToUse": [
    "从卷一 1.1 读起:先看机制如何被写成规格字段,再看 S / P / W 三处消费。",
    "想复现某一步:读该节的 file: 代码块,按 verify: 里的命令在本机跑一遍。",
    "想换品类:改 config/play_spec.json 的 rules / params 段,或参照 config/gd_proto_13_variants.json 加一个变体。",
    "想当模板:把 config/play_spec.json 换成自己的规格,core/ 与 web/ 工具链不改即可复用。"
  ],
  "limits": [
    "机制侧可算:守恒、不变量、确定性回放、策略空间、涌现度、相变宽度、品类距离。",
    "主观体验(好不好玩 / 心流是否真的发生)只能用代理指标做相对比较,禁绝对化。",
    "美术 / 性能 / 网络 / 商业化需真实业务环境,命令行不可复现(X 档)。",
    "真实玩家测试数据、线上留存与付费,本套一律不覆盖。"
  ],
  "next": [
    "把六品类变体扩到真实规则集(每个品类补到 30+ 条规则)并重跑闸门。",
    "把机制距离矩阵与《风格与品类美术实战》的风格距离矩阵做联合聚类。",
    "把代理指标接到真实玩家测试数据上,做机制量到体验量的回归。"
  ]
}

怎么用:三条入口

清单里 howToUse 那四条其实收敛成三条入口。照着读——从卷一 1.1 起,先看机制怎么被写成规格字段,再看它在 S / P / W 三处被消费;照着复现——每节的代码块都带 file: 路径与 verify: 命令,拷到本机就能跑;换成自己的规格——改 config/play_spec.json 的 rules 与 params 段,core/ 与 web/ 一行不动即可复用。核对脚本把这三条都验了一遍:

#!/usr/bin/env python3
# file: sim/gd_proto_16_handoff.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_16_handoff.py
"""16.3 交付与后续(S 层):三卷总收官的可核对形态。

三卷回答三个问题(规格怎么写 / 跑起来涌现什么 / 换品类还成不成立),交付物是同一份
config/play_spec.json 与同一条工具链。本脚本读 config/gd_proto_16_deliverable.json,逐条核对:
  ① 三卷齐、各 50 节、合计 150 节;
  ② 工具链目录全部存在(config / sim / core/src/ashdeck / core/genre / core/test / web / out);
  ③ 每卷的交接产物存在,且卷一卷二的冻结接口 fail == 0;
  ④ 交付清单里的基线 digest 与现算一致(规格没被偷偷改过);
  ⑤ howToUse / limits / next 三段非空。
落 out/gd_proto_16_handoff.json。全整数、无浮点、无 random。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
MANIFEST = os.path.join(ROOT, "config", "gd_proto_16_deliverable.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def load_json(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def main() -> int:
    spec = ashlib.load_spec()
    man = load_json(MANIFEST)
    vols = man["volumes"]
    base = ashlib.digest(ashlib.run_spec(spec))

    checks = {
        "三卷齐": len(vols) == 3,
        "合计 150 节": sum(v["sections"] for v in vols) == man["totalSections"] == 150,
        "工具链目录齐": all(os.path.isdir(os.path.join(ROOT, d)) for d in man["toolchain"]),
        "各卷交接产物存在": all(os.path.exists(os.path.join(ROOT, v["handoff"])) for v in vols),
        "基线 digest 未漂移": man["baseDigest"] == base,
        "howToUse 非空": len(man["howToUse"]) >= 1,
        "limits 非空": len(man["limits"]) >= 1,
        "next 非空": len(man["next"]) >= 1,
    }
    # 卷一卷二的冻结接口必须仍 fail == 0
    for v in vols[:2]:
        h = load_json(os.path.join(ROOT, v["handoff"]))
        checks[v["volume"] + " 接口 fail==0"] = h.get("fail", 1) == 0
    bad = [k for k, val in checks.items() if not val]

    print("=== 16.3 三卷总收官 · 交付物核对 ===")
    for k, val in checks.items():
        print(f"  {'ok  ' if val else 'FAIL'} {k}")
    for v in vols:
        print(f"  {v['volume']:16s} {v['sections']} 节  {v['question']}")
    print(f"totalSections={man['totalSections']} baseDigest={base}")
    print(f"fail={len(bad)} {'(交付物齐备)' if not bad else bad}")

    out = {"id": man["id"], "book": man["book"], "spec": spec["id"],
           "volumes": vols, "totalSections": man["totalSections"],
           "toolchain": man["toolchain"], "baseDigest": base,
           "howToUse": man["howToUse"], "limits": man["limits"], "next": man["next"],
           "checks": checks, "fail": len(bad)}
    os.makedirs(os.path.join(ROOT, "out"), exist_ok=True)
    with open(os.path.join(ROOT, "out", "gd_proto_16_handoff.json"), "w", encoding="utf-8") as f:
        json.dump(out, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print("wrote out/gd_proto_16_handoff.json")
    return 1 if bad else 0


if __name__ == "__main__":
    raise SystemExit(main())
=== 16.3 三卷总收官 · 交付物核对 ===
  ok   三卷齐
  ok   合计 150 节
  ok   工具链目录齐
  ok   各卷交接产物存在
  ok   基线 digest 未漂移
  ok   howToUse 非空
  ok   limits 非空
  ok   next 非空
  ok   玩法机制与可执行规格 接口 fail==0
  ok   策略、涌现与体验度量 接口 fail==0
  玩法机制与可执行规格       50 节  规格怎么写、怎么跑、怎么证明自洽
  策略、涌现与体验度量       50 节  机制跑起来涌现什么、玩家体验到什么
  原型、可玩性与品类        50 节  同一份规格换品类还成不成立
totalSections=150 baseDigest={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
fail=0 (交付物齐备)
wrote out/gd_proto_16_handoff.json

十项全 ok、fail=0。卷一卷二两份交接产物的 fail 仍为 0,意味着后卷没有偷偷改动前卷冻结的接口——这是「后卷依赖前卷」这条白名单规则的运行期证据。

边界:机制侧可算,其余需真实环境

limits 那四条必须原样摆出来,因为它们划的是能力的边界,不是免责声明。机制侧真能算:守恒、不变量、确定性回放、策略空间熵、涌现度、相变宽度、品类距离——这些都有整数口径、可复现。但主观体验(好不好玩、心流是否真的发生)只能用代理指标做相对比较,禁绝对化:本套从不说「提升 N 倍」,只说「A 规格的带宽比 B 宽」。美术、性能、网络、商业化四类问题需要真实业务环境,命令行不可复现,一律进 X 档;真实玩家测试数据、线上留存与付费,本套一律不覆盖。

// file: core/test/gd_proto_16_handoff.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_16_handoff.test.ts'
//
// 16.3 三卷总收官在 TS 侧的复现:交付清单是**数据**,P 层同样能读它并断言三卷齐、150 节、
// 工具链目录存在、基线 digest 未漂移、各卷交接产物存在。清单若与工作区真值脱节,本测试即红。

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

import { runSpec } from "../src/ashdeck/combat.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import type { Spec } from "../src/ashdeck/types.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const SPEC: Spec = JSON.parse(readFileSync(join(ROOT, "config", "play_spec.json"), "utf8"));
const MAN = JSON.parse(
  readFileSync(join(ROOT, "config", "gd_proto_16_deliverable.json"), "utf8"),
) as {
  totalSections: number;
  baseDigest: string;
  toolchain: string[];
  howToUse: string[];
  limits: string[];
  next: string[];
  volumes: { volume: string; prefix: string; sections: number; handoff: string }[];
};

test("三卷齐、各 50 节、合计 150 节", () => {
  assert.equal(MAN.volumes.length, 3);
  assert.equal(MAN.totalSections, 150);
  assert.equal(MAN.volumes.reduce((a, v) => a + v.sections, 0), 150);
  assert.deepEqual(MAN.volumes.map((v) => v.prefix), ["gd-mech-", "gd-play-", "gd-proto-"]);
});

test("工具链目录全部存在", () => {
  for (const d of MAN.toolchain) {
    assert.ok(existsSync(join(ROOT, d)), "缺目录 " + d);
  }
});

test("各卷交接产物存在", () => {
  for (const v of MAN.volumes) {
    assert.ok(existsSync(join(ROOT, v.handoff)), v.volume + " 缺 " + v.handoff);
  }
});

test("交付清单的基线 digest 与现算一致(规格未漂移)", () => {
  assert.equal(MAN.baseDigest, digestSnap(runSpec(SPEC)));
});

test("howToUse / limits / next 三段非空", () => {
  assert.ok(MAN.howToUse.length >= 1);
  assert.ok(MAN.limits.length >= 1);
  assert.ok(MAN.next.length >= 1);
});
ℹ tests 5
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

后续:三个方向

next 那三条是顺着能力边界往外推,不是许愿。第一,把六品类变体从「骨架规格」扩到真实规则集(每个品类补到 30+ 条规则)再重跑闸门——现在每份变体只写了几条核心规则,够证明结构同构,不够当成品。第二,把本节工具链算出的机制距离矩阵,与 《游戏美术与技术美术》 那套风格距离矩阵做联合聚类——机制近且风格近的品类,可能是同一批玩家的重叠区。第三,把代理指标接到真实玩家测试数据上,做机制量到体验量的回归;这一步就跨出命令行能覆盖的范围了,需要真实业务环境。

三卷的三个问题在此闭合

卷一问「规格怎么写、怎么跑、怎么证明自洽」,交付的是一份可执行规格 + 三处消费的同一个 digest。卷二问「机制跑起来涌现什么、玩家体验到什么」,交付的是一层代理指标 + 相变与心流的可算形态。卷三问「同一份规格换品类还成不成立」,交付的是六品类实例 + 一道 6/6 闸门 + 一张距离矩阵。三个问题共用一份 config/play_spec.json、一条跨语言 digest、一套目录结构——所以它们不是三个独立专题,是一条流水线。换掉这份规格,整条流水线仍然成立;这正是这套工具链想留给读者的东西:机制可以被写成可跑的规格,规格可以被换品类重跑,而每一次重跑都留下可核对的证据。

阅读导航:上一节:16.2 一份规格的全链路回放 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡