6.1 一句话玩法与核心循环拆解

把「一句话玩法」拆成可判定的核心循环:用配置驱动《合成小岛》的动作 → 资源 → 反馈 → 再投入链条,再用 S 层脚本算出每个动作的时长与占整轮循环的比例,产出 CSV 与 SVG 时长条形图,并讲清占比读出来的三个设计结论与可检验自检表。

站内已有一篇小游戏开发综述 ,那篇是引擎选型 / 性能 / 变现的单篇综述,把「小游戏开发」这件事横向铺开讲了一遍;本章给的是核心循环的可运行拆解与可玩原型——同一件事,但落到《合成小岛》这款具体产品上,从「一句话玩法」一直拆到能跑的代码。区别在写法:那篇是单篇综述,本套是成体系 + 可运行,每一节都有能在本机真跑出数字的脚本或原型。

上一章我们讲完了目标用户、动机与生命周期,回答了「给谁做、他为什么留下来」。从这一节开始进入玩法本身。而玩法的第一步,不是画界面,不是列系统,而是把游戏压缩成一句话,再把这句话拆成一个能被检验的循环。

本章也是贯穿项目《合成小岛》真正落地的地方。前五章都在做外部判断(市场、榜单、品类、平台、用户),从这一节起,我们开始往这款具体的产品里填内容,并且每一步都留下能跑的产物。

一句话玩法:能被检验才算数

「一句话玩法」是个被用滥的说法。多数团队的版本长这样:「一款轻松治愈的合成小岛游戏,收集物品、升级岛屿、装饰家园」。这句话读起来没错,但它没法被检验——它没有主语(玩家做什么动作)、没有宾语(动作产出什么资源)、没有闭环(资源怎么再投入)。你拿它去问程序「第一版做什么」,程序还是不知道。

可检验的一句话玩法要包含四个要素:动作(玩家点/拖/放什么)、资源(动作产出什么)、反馈(玩家凭什么知道做对了)、再投入(资源怎么回到下一个动作)。《合成小岛》的版本是:

拖拽两个相同物品合成高一级物品,用产出金币升级岛屿、解锁新地块,换来更高级物品与更快的产出。

拆开看:动作是「拖拽合成」,资源是「高一级物品 + 金币」,反馈是「合成光效 + 数字跳动」,再投入是「升级产出器 / 解锁新地块」。四个要素齐了,这句话才算立得住。

核心循环不是四个箭头,是「动作 → 资源 → 反馈 → 再投入」

拿到一句话玩法之后,下一步是把它展开成循环。多数人展开成四个箭头:产出 → 消耗 → 成长 → 再产出。问题在于,四个箭头里没有主语也没有宾语——「产出」是谁产出、产出什么,全靠脑补。展开成循环时,每一环都必须落到一个具体的玩家动作上,否则这个循环就是一张好看的示意图。

《合成小岛》的核心循环有五个动作,覆盖从产物品到开新图的全过程。下面这份配置是 D 层(静态层)产物:它把循环写成一张可被脚本读取的表,本身不产生数字,但把「一句话玩法」正式固化成结构。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "oneLiner": "拖拽两个相同物品合成高一级物品,用产出金币升级岛屿、解锁新地块,换来更高级物品与更快的产出。",
  "note": "《合成小岛》核心循环拆解:动作 → 资源 → 反馈 → 再投入。本文件只做结构与时长的静态刻画;反馈密度的动态模型见 config/ch06_feedback.json。",
  "actions": [
    { "id": "collect", "name": "点按产出器", "phase": "产出",   "durationMs": 1200, "gives": "tier1物品",   "feedback": "物品弹出+短音效" },
    { "id": "merge",   "name": "拖拽合成",   "phase": "转化",   "durationMs": 2600, "gives": "tier+1物品",  "feedback": "合成光效+轻震动" },
    { "id": "sell",    "name": "交付出售",   "phase": "变现",   "durationMs": 900,  "gives": "金币",       "feedback": "金币飞入+数字跳动" },
    { "id": "upgrade", "name": "升级产出器", "phase": "再投入", "durationMs": 3200, "gives": "更高产出速率", "feedback": "等级+1+进度条" },
    { "id": "unlock",  "name": "解锁新地块", "phase": "再投入", "durationMs": 4200, "gives": "新产出位",     "feedback": "地图扩张动画" }
  ],
  "round": ["collect", "merge", "sell"]
}

五个动作里,collect 是「产出」相位、merge 是「转化」、sell 是「变现」、upgrade 与 unlock 都是「再投入」。每个动作都带三样东西:时长(durationMs,一次这个动作玩家大约花多久)、给出什么(gives)、反馈是什么(feedback)。注意 round 字段——它定义了「最短一圈」:产出 → 合成 → 出售,这三步是玩家感受到一次完整闭环的最短路径。

用 S 层脚本把循环的时长结构算出来

有了这张表,第一个能被量化的东西就是时长结构:整轮循环总共多久、每个动作占多少、最短一圈多久。下面这段是 S 层仿真脚本,读 config/ 里的循环配置,算出每个动作的时长与千分比占比,并画出条形图。

#!/usr/bin/env python3
# file: sim/ch06_loop.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch06_loop.py --config config/ --out out/
"""《合成小岛》核心循环拆解:把「一句话玩法」拆成 动作 → 资源 → 反馈 → 再投入。

本脚本只做静态刻画:每个动作的时长、占整轮循环的比例、以及「最短一圈」
(config 里的 round 列表)的耗时。反馈密度的动态模型见 sim/ch06_feedback.py。
产出:out/ch06_loop.json + out/ch06_loop_actions.csv + out/ch06_loop.svg
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


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


def per_mille(part, whole):
    """整数千分比(地板除),与 TS 侧 Math.floor(part*1000/whole) 等价。"""
    return (part * 1000) // whole if whole else 0


def draw_svg(path, actions, total_ms):
    """纯 Python SVG:每个动作一根横条,长度正比于时长。"""
    W, H, PAD, ROW = 720, 60 + 42 * len(actions), 150, 42
    svg = ET.Element(
        "svg",
        {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
         "viewBox": f"0 0 {W} {H}"},
    )
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = (
        "《合成小岛》核心循环各动作时长占比(毫秒)"
    )
    bar_max = W - PAD - 24
    for i, a in enumerate(actions):
        y = 50 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = a["name"]
        w = (bar_max * a["durationMs"]) // total_ms if total_ms else 0
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "20",
                                    "rx": "4", "fill": "#2563eb"})
        ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 21), "font-size": "12",
                                    "fill": "#6b7280"}).text = f'{a["durationMs"]}ms · {a["sharePermille"]}‰'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    total_ms = sum(a["durationMs"] for a in cfg["actions"])
    by_id = {a["id"]: a for a in cfg["actions"]}
    actions = [dict(a, sharePermille=per_mille(a["durationMs"], total_ms)) for a in cfg["actions"]]
    round_ms = sum(by_id[i]["durationMs"] for i in cfg["round"])
    report = {
        "version": cfg["version"],
        "game": cfg["game"],
        "seed": cfg["seed"],
        "oneLiner": cfg["oneLiner"],
        "actionCount": len(actions),
        "fullLoopMs": total_ms,
        "roundIds": cfg["round"],
        "roundMs": round_ms,
        "shareSumPermille": sum(a["sharePermille"] for a in actions),
        "actions": actions,
    }

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "ch06_loop.svg"), actions, total_ms)
    report["svg"] = os.path.join(args.out, "ch06_loop.svg")
    report["svgBytes"] = svg_bytes
    with open(os.path.join(args.out, "ch06_loop.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch06_loop_actions.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "phase", "durationMs", "sharePermille", "gives", "feedback"])
        for a in actions:
            w.writerow([a["id"], a["name"], a["phase"], a["durationMs"], a["sharePermille"], a["gives"], a["feedback"]])

    print(json.dumps({k: report[k] for k in
                      ("actionCount", "fullLoopMs", "roundMs", "shareSumPermille")}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"actionCount": 5, "fullLoopMs": 12100, "roundMs": 4700, "shareSumPermille": 998}

同时落盘 out/ch06_loop_actions.csv(每动作一行)与 out/ch06_loop.svg(时长条形图)。CSV 的前几行是这样:

id,name,phase,durationMs,sharePermille,gives,feedback
collect,点按产出器,产出,1200,99,tier1物品,物品弹出+短音效
merge,拖拽合成,转化,2600,214,tier+1物品,合成光效+轻震动
sell,交付出售,变现,900,74,金币,金币飞入+数字跳动
upgrade,升级产出器,再投入,3200,264,更高产出速率,等级+1+进度条
unlock,解锁新地块,再投入,4200,347,新产出位,地图扩张动画

占比读出来的三个结论

数字出来了,但它自己不会说话。fullLoopMs = 12100、roundMs = 4700、占比 99/214/74/264/347‰,读的时候注意三件事。

第一,最短一圈 4700ms(4.7 秒),是「呼吸频率」。 round 里的产出 → 合成 → 出售三步加起来 4.7 秒,意味着玩家每不到 5 秒就能感到一次完整闭环:点一下出物品、拖一下合成、交出去变金币。这个数字偏长一点,但还在「一局内能重复很多次」的范围内。它直接对应新手期的第一印象——如果最短一圈要 20 秒,玩家还没感到回报就走了。

第二,两个「再投入」动作占了 347 + 264 = 611‰,也就是超过六成时长。 升级产出器(3200ms)与解锁新地块(4200ms)是单次耗时最长的动作,因为它们是决策型动作:玩家要比较收益、看解锁条件、确认花销。这符合合成放置类的节奏——高频小动作(合成、出售)负责爽感,低频大动作(升级、解锁)负责目标感。如果反过来,让「出售」占掉六成时长,循环就会变得琐碎。

第三,千分比之和是 998 不是 1000,这是地板除的正常结果。 每个动作的占比都做了整数地板除,五个动作各丢一点点,加起来少了 2‰。脚本把这个值显式落进 shareSumPermille 字段,就是为了让「百分比加起来不等于 100%」这件事可见而不是被抹掉——本套所有跨语言数字都走整数运算,宁可留一个诚实的 998,也不引入浮点。

相位视角:产出 / 转化 / 变现 / 再投入

占比之外,还有一个更有用的读法:把五个动作按 phase 字段归成四个相位,看每个相位占了几成。

相位动作合计时长作用
产出点按产出器1200ms给玩家「有东西可动」
转化拖拽合成2600ms核心爽点,物品升阶
变现交付出售900ms把物品换成可累积的金币
再投入升级产出器、解锁新地块7400ms把金币换回更强的产出

四相位里,「再投入」占了 7400ms,接近整轮的六成。这不是缺陷,而是合成放置类的固有形态:前三个相位负责快节奏的即时反馈,最后一个相位负责长线目标。玩家在「再投入」里花的每一秒,都是在为下一轮更高的产出做决定。反过来,如果「再投入」只占一成,游戏就会变成纯点击——爽是爽,但没有明天。

相位表还有一个工程用途:它直接对应本卷后面几章的边界。第 7 章的关卡结构管的是「转化」相位的难度节奏,第 8 章的成长与放置收益管的是「再投入」相位的曲线,第 9 章的社交面则挂在「变现」之后。核心循环是这些章节共同的地基,所以它必须先被拆清楚。

顺带说清一个容易混淆的点:这里的「相位」和下一节的「反馈密度」不是一回事。相位回答「时间花在哪个环节」,密度回答「这些时间里玩家被打动了几次」。一个循环可以相位很健康(四相位都有),但密度很低(十分钟才一次反馈),两者要分开量、分开调。

动作粒度:五个动作还是三个

一个常见的争论是:核心循环到底该拆成几个动作?有人主张越少越好,只留「合成」和「升级」两步;有人主张越细越好,把每次点击都算一个动作。两种都错。正确的粒度标准只有一条:一个动作 = 一次「玩家做决定」的最小单位。

按这个标准回看《合成小岛》:点按产出器不是决定(玩家只是想攒物品),但它是一个独立动作,因为它有独立的反馈和产出;拖拽合成是决定(拖哪两个、要不要留一个);交付出售是决定(卖还是留);升级产出器和解锁新地块都是决定(花在哪)。于是五个动作是自然长出来的,不是拍脑袋定的。

粒度定错的代价很直接:拆太粗,durationMs 会把两个不同性质的动作揉进一个数,占比读出来的结论就是错的;拆太细,一个「合成」被拆成「拿起 → 移动 → 放下」三个动作,roundMs 会虚高到没有参考价值。所以本章的配置里,每个动作都对应一个玩家能感知到的完整操作,不多不少。

一句话玩法怎么写才可检验

把上面的经验收成一张自检表。写「一句话玩法」时逐条对照,任何一条答不上来,这句话就还不能进设计案。

要素《合成小岛》反例(不可检验)
动作拖拽两个相同物品「轻松愉快地玩」
资源高一级物品、金币「各种资源」
反馈合成光效、数字跳动「有反馈」
再投入升级产出器、解锁新地块「不断提升」
最短一圈产出 → 合成 → 出售(4.7s)未定义

这张表的价值不在《合成小岛》,而在于它是一个可以拿去评审的模板。评审时不需要争论「这个玩法好不好玩」——那是主观的;只需要问「你的最短一圈是哪三步、多少秒」,答不上来,说明玩法还没想清楚。

还要提醒一点:一句话玩法里的「动作」必须是动词开头的具体操作。如果某个要素只能用一个名词概括(比如「资源是各种材料」),那通常意味着这个循环还没收敛——真正想清楚的设计,动作能一句话说清,资源能点出名字。这也是本章把循环写成配置表而不是画成箭头的理由:配置表会强迫你把每个动作的 gives 字段填成一个具体的资源名,填不出来的地方,就是玩法还没想清楚的地方。

把循环拆解接进设计评审

结构拆解的价值不在跑一次,而在每次改玩法都跑一次。把 config/ch06_loop.json 纳入版本控制,任何一次动作增删都会改变 fullLoopMs 与各动作占比——如果某个动作的占比突然从 74‰ 跳到 400‰,说明这一版把循环的重心压歪了,评审时立刻能看见。这样,「核心循环」就从一个口头共识,变成了一个有数字、有对比、有守卫的设计对象。

还有一个容易被忽略的好处:这份配置是跨章节共享的真源。下一节算反馈密度要读同一份动作时长,第 12 章做原型可玩性验证也会引用这里的 roundMs。一份配置被多处消费,任何一处改了数字,其他几处的结论会自动跟着变——这正是本套「一份 config、三种消费」想达成的效果。

下一节给这个循环装上时钟与密度:一轮 12.1 秒里,玩家平均每秒能收到几次可感知反馈,这个密度落在心流曲线的哪个位置——把静态的结构表变成一条会动的心流曲线。

阅读导航:上一节:生命周期阶段与留存结构 · 下一节:循环时长与反馈密度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制