小游戏不是「缩小的手游」。它面对的是一组手游没有的硬约束,而这些约束会一路反向决定你做什么品类、把核心循环设多长、界面能铺几层、社交怎么接进分发。这套书要讲的,就是这组约束如何变成可执行的设计决策,并且每一步都能被跑出来的数字检验。
一、小游戏的约束,和手游不是一套
先把「小游戏」和「手游」的分野说清楚,因为很多设计错误来自把两者当成同一件事。它们共享触摸屏、碎片时间与免费+内购的商业模式,但在下面四条上是结构性不同:
| 维度 | 手游(原生 App) | 小游戏(平台内) |
|---|---|---|
| 分发 | 应用商店下载安装 | 平台内即点即玩,无需安装 |
| 包体 | 数百 MB 到数 GB | 首包与总包都有硬上限,需分包 |
| 启动 | 可接受首次下载与长启动 | 冷启动要在数秒内进游戏 |
| 渲染 | 原生 UI 框架 + 自绘 | 单个 canvas,没有 DOM 排版 |
| 能力 | 系统 API 相对完整 | 能力由宿主平台注入,各平台不一致 |
这四条不是「优化项」,而是设计前提。它们决定的东西比多数人以为的多得多:
包体上限决定品类与美术风格。 当首包只有几 MB 时,你放不下大量高分辨率贴图与语音。合成类、放置类之所以在小游戏里格外常见,一部分原因正是它们天然省资源——少量贴图配大量数值,内容量靠配置表而不是靠美术资产堆。这不是巧合,是约束筛选的结果。
首包加载时间决定核心循环的位置。 冷启动要在数秒内让玩家玩到东西,意味着登录即玩、没有长开场动画、新手引导必须短,核心循环要尽量前置。你在手游里可以花三分钟铺垫世界观,在小游戏里这三分钟足够流失掉大半用户。
无 DOM 渲染决定 UI 的实现成本。 单个 canvas 上没有浏览器的自动排版、滚动与层级管理。一个在网页里免费的「长列表滚动」或「多层级弹窗」,在 canvas 里都要自己写,而且每一次重绘都有成本。结论很直接:界面要扁平、层级要少、信息要能一屏放下。
平台 API 的不一致决定架构必须解耦。 登录、分享、支付、广告全是宿主注入的异步接口,微信、抖音、快手、QQ 各家命名与行为都不同。如果游戏逻辑直接调用某个平台的 API,换一个平台就要重写一遍。正确做法是让逻辑只依赖一层薄薄的宿主抽象——这正是本套「一份逻辑,两种宿主」主张的由来。
再补一条:小游戏的分发入口本身就是社交。分享、好友助力、群排行是获得新用户的通道,所以社交不是「通关后加个分享按钮」的附加功能,而是获量手段。这直接改变了社交系统的设计目标——它要服务于传播,而不只是服务于留存。
二、约束怎么变成可算的数字
上面的判断如果只停在「要注意包体」,那就还是散文。这套书要求每个判断都能落到一个可以跑出来的数字上。比如「首包到底能放多少东西」,下面这段是 S 层仿真脚本,读 config/ch00_budget.json(全整数,单位 KB / 秒 / 毫秒),把包体、冷启动与单局时长的约束翻译成可分配的资源名额:
# file: sim/ch00_constraint_budget.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch00_constraint_budget.py --config config/ --out out/
"""平台约束预算:把「首包多大、冷启动多快、单局多长」翻译成可分配的资源名额。
读 config/ch00_budget.json(全整数,单位 KB / 秒 / 毫秒),算出:
- 首包扣掉代码体积后,还能放多少张贴图、多少段音频;
- 分包里能放多少关配置;
- 冷启动预算内最多加载多少资源;
- 单局目标时长下,玩家要经历多少次核心循环、多少次正反馈。
产出 JSON + CSV + SVG,正文引用的都是这里的真实输出(同机相对量级)。
"""
from __future__ import annotations
import argparse
import json
import os
import xml.etree.ElementTree as ET
import pandas as pd
def load(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def compute(cfg: dict) -> dict:
pkg, load, sess = cfg["package"], cfg["load"], cfg["session"]
code = pkg["codeBudgetKB"]
first_left = pkg["firstPackageBudgetKB"] - code
atlas_in_first = first_left // pkg["atlasAssetKB"]
audio_in_first = first_left // pkg["audioAssetKB"]
# 首包只放「立刻要看到」的资源,贴图按一半名额预留给后续按需加载
atlas_immediate = atlas_in_first // 2
sub_left = pkg["subpackageBudgetKB"] - code
levels_in_sub = sub_left // pkg["levelConfigKB"]
cold_start_assets = load["coldStartBudgetMs"] // load["perAssetLoadMs"]
loops_per_session = sess["sessionTargetSec"] // sess["coreLoopSec"]
feedback_per_session = sess["sessionTargetSec"] // sess["feedbackEverySec"]
return {
"firstLeftKB": first_left,
"atlasInFirst": atlas_in_first,
"audioInFirst": audio_in_first,
"atlasImmediate": atlas_immediate,
"levelsInSub": levels_in_sub,
"coldStartAssets": cold_start_assets,
"loopsPerSession": loops_per_session,
"feedbackPerSession": feedback_per_session,
}
def write_svg(path: str, labels: list[str], values: list[int], title: str) -> int:
W, H, PAD = 640, 360, 48
v_max = max(values) or 1
bar_w = (W - 2 * PAD) / len(values) * 0.6
gap = (W - 2 * PAD) / len(values)
p = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">',
f'<rect width="{W}" height="{H}" fill="#ffffff"/>',
f'<text x="{PAD}" y="{PAD - 20}" font-size="14" fill="#111827">{title}</text>',
]
for i, (lab, val) in enumerate(zip(labels, values)):
x = PAD + gap * i + (gap - bar_w) / 2
h = (H - 2 * PAD) * (val / v_max)
y = H - PAD - h
p.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="{h:.1f}" fill="#2563eb"/>')
p.append(f'<text x="{x + bar_w / 2:.1f}" y="{y - 6:.1f}" font-size="11" text-anchor="middle" fill="#111827">{val}</text>')
p.append(f'<text x="{x + bar_w / 2:.1f}" y="{H - PAD + 16:.1f}" font-size="10" text-anchor="middle" fill="#6b7280">{lab}</text>')
p.append(f'<line x1="{PAD}" y1="{H - PAD}" x2="{W - PAD}" y2="{H - PAD}" stroke="#9ca3af"/>')
p.append("</svg>")
svg = "\n".join(p)
with open(path, "w", encoding="utf-8") as f:
f.write(svg)
ET.fromstring(svg)
return len(svg.encode())
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(os.path.join(args.config, "ch00_budget.json"))
r = compute(cfg)
rows = [
("首包可放贴图", r["atlasInFirst"]),
("首包可放音频", r["audioInFirst"]),
("分包可放关卡", r["levelsInSub"]),
("冷启动可加载资源", r["coldStartAssets"]),
("单局核心循环", r["loopsPerSession"]),
("单局正反馈", r["feedbackPerSession"]),
]
df = pd.DataFrame(rows, columns=["item", "count"])
df.to_csv(os.path.join(args.out, "ch00_budget.csv"), index=False)
svg_bytes = write_svg(
os.path.join(args.out, "ch00_budget.svg"),
[x[0] for x in rows],
[x[1] for x in rows],
"《合成小岛》平台约束下的资源名额",
)
report = {
"source": "sim/ch00_constraint_budget.py",
"config": os.path.join(args.config, "ch00_budget.json"),
**r,
"csv": os.path.join(args.out, "ch00_budget.csv"),
"svg": os.path.join(args.out, "ch00_budget.svg"),
"svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "ch00_budget.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑(config/ch00_budget.json:首包 4096 KB、分包 20480 KB、代码 512 KB、贴图 96 KB、音频 48 KB、关卡配置 6 KB、冷启动 3000 ms、单资源 12 ms、单局 180 s、循环 30 s、反馈间隔 15 s)输出:
{"source": "sim/ch00_constraint_budget.py", "config": "config/ch00_budget.json", "firstLeftKB": 3584, "atlasInFirst": 37, "audioInFirst": 74, "atlasImmediate": 18, "levelsInSub": 3328, "coldStartAssets": 250, "loopsPerSession": 6, "feedbackPerSession": 12, "csv": "out/ch00_budget.csv", "svg": "out/ch00_budget.svg", "svgBytes": 1865}
这组数字读出来的设计含义是具体的:
- 首包 4096 KB 扣掉 512 KB 代码后只剩 3584 KB,按 96 KB 一张贴图只够 37 张,按 48 KB 一段音频只够 74 段——所以美术资产必须图集化、低分辨率、复用,这属于第 4 章「包体预算」要处理的事。
- 分包(20480 KB)扣掉代码后能放 3328 份 6 KB 的关卡配置——这意味着「关卡数量」几乎不受包体限制,受限的是玩法与美术,不是内容量。合成 + 关卡的小游戏能靠配置表铺很长内容线,正是踩在这个缝里。
- 冷启动 3000 ms、单资源 12 ms 意味着首屏最多加载 250 个资源——超出这个数就要分帧加载或延后加载,这属于第 4.2 节「首包加载与冷启动约束」。
- 单局 180 s、循环 30 s 意味着玩家一局要经历 6 次核心循环、约 12 次正反馈——这是第 6.2 节「循环时长与反馈密度」的输入。
这些是同机相对量级的数字,配置一改结论就变,绝对值不可移植。它们的作用不是给你一个「标准答案」,而是演示约束怎么变成可以调参、可以争论的数字。
三、这套书的三卷结构
《小游戏开发实战》共三卷,卷一讲设计、卷二讲实现、卷三讲运营:
| 卷 | 卷名 | 回答的问题 | 章数 |
|---|---|---|---|
| 卷一 | 市场、品类与产品设计 | 做什么、给谁做、值不值得投 | 15 章 45 节 |
| 卷二 | 引擎实现与体验打磨 | 怎么把它做成能跑的东西 | 16 章 48 节 |
| 卷三 | 上线变现与长线运营 | 怎么上线、怎么赚钱、怎么长线 | 16 章 48 节 |
你手上这一卷是卷一。它不写引擎 API、不写渲染管线、不写服务端——那些是卷二和站内既有专书的事。卷一从头到尾只做一件事:把「要不要做、做什么、怎么做设计」变成可决策、可复算的过程。
四、可运行性主张
这套书给自己定了一条硬规矩:每一节至少有一个可运行的产物。产物分六档:
- S 仿真层:Python 数值仿真,跑出 JSON + CSV + SVG。
- P 内核层:TypeScript 配置驱动逻辑内核,
node --test断言。 - W 网页层:单文件 HTML 可玩原型,浏览器打开就能玩。
- D 静态层:能力矩阵、预算表、检查清单、配置 schema。
- X 不可复现:真实平台后台数据、真实榜单、真实买量、真实审核结果,每处显式标注。
- B 基准:稳定性、偏差、回本对照,产出 JSON,软告警不硬失败。
支撑这套主张的是两条工程主张:一份 config,三种消费(Python 仿真、TS 内核、浏览器原型读同一份数字)与一份逻辑,两种宿主(游戏逻辑只依赖宿主抽象,mock 与真机共用同一段代码)。两条主张的完整口径在目录页 与附录 B ,读法与环境准备在前言 。
五、与站内既有内容的边界
站内已有 game-engine 与 minigame 两个小游戏平台专题(共 49 篇,SaaS 创业口径),有 小程序小游戏开发综述 (96 行单篇),有 Cocos 游戏开发系列 (引擎 API 教程)。本套与它们不重叠:本套讲的是「在平台上做一款游戏」的单款产品口径,是成体系且可运行的,且不写引擎 API 教程。完整分工与出局清单见目录页 。
准备好之后,从前言 了解怎么读,或直接进入第 1 章 1.1 小游戏的定义与边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。