前 6 集把一款商业 F2P 的系统 都立起来了:玩法、数据、经济、商城、留存。但它们全是纯逻辑------能跑,没手感、还很糙:方块是纯色块、UI 是扁平方框、消行没声音。这一集(EP7)做表现层:美术、动效、音频,把它从"能跑"做到"像个正经商业产品"。
先看做完的样子------方块是生成的糖果光泽块、背景是生成的渐变、按钮/面板全换成圆角光泽 UI(都和方块一个风格):

皮肤系统也接到了表现层:换一个皮肤,整盘方块实时重染(这是霓虹皮肤,红→粉、青→紫、绿→亮绿,光泽保留):

美术怎么来的:方块是一张程序化生成的「白色光泽方块」用当前皮肤调色板染色(所以换肤能重染);背景、UI 按钮/面板是 gpt-image-2 出图、程序抠底、设 9-slice 后用
Image.color染成各自配色。全程没用一张 Unity 默认占位图。这些都是 Claude 用 MCP 把图导进工程、设好导入参数、进 PlayMode 截图比对调出来的。
剩下的是"动起来好不好、响不响"。表现层有个老大难:"手感"天生难测 ------"砸下去那一下爽不爽"没法写断言。但我不想因此放弃验证------所以这一集的核心思路是:把"爽"拆成可断言的部分。
思路:把手感拆成纯函数
动效的本质是"一个值随时间怎么变"。把"怎么变"这条曲线抽成纯缓动函数,它就可断言了------动效组件只是按时间采样这条曲线,曲线对不对在数学层就钉死。
csharp
public static class Easing
{
public static float OutQuad(float t) => 1f - (1f - t) * (1f - t); // 减速
// 回弹:收尾前先冲过 1 再回落 ------ 方块落位的"砸"感
public static float OutBack(float t)
{
const float c1 = 1.70158f, c3 = c1 + 1f;
float u = t - 1f;
return 1f + c3 * u * u * u + c1 * u * u;
}
// punch 缩放:先放大到 1+amount 再弹回 1 ------ 点击/得分"蹦一下"
public static float ScalePunch(float t, float amount)
{
if (t <= 0f || t >= 1f) return 1f;
return 1f + amount * Mathf.Sin(t * Mathf.PI); // 半正弦:两端 1,中间峰值
}
}
"爽"的来源------OutBack/OutElastic 在收尾前冲过 1 再回落 ------正是可以断言的:OutBack(0.8) > 1。左图画出来,那几条冲出顶线再回落的曲线,就是"砸"和"弹"的手感:

把表现接到玩法上,就有了"迸裂"------消行/炸弹的时候,被清掉的每一格都炸出一簇粒子。这里用的是 Unity 自带的 ParticleSystem (不是手搓的精灵动画):球形发射、带重力下坠、尺寸和透明度随生命周期衰减,粒子贴图就用那张白色光泽方块(startColor 染成金色/暖橙,和方块一个风格)。发射是手动的------一次消行,就在每个被清的格点 Emit 一簇:
csharp
void EmitBurst(List<Vector3> centers, Color col)
{
var main = _ps.main; main.startColor = col;
foreach (var c in centers)
_ps.Emit(new ParticleSystem.EmitParams { position = c }, 14); // 每格爆 14 颗
}
这里 centers 是哪些格,藏着一个我一开始做错的细节。最自然的想法是"比对落子前后,哪些格子由满变空,就在那些格上爆"------但刚落下去补满那一行的那块格子,是"空→满→消",前后都不算'由满变空',于是它没特效 ,整行就缺了那么一格,看着不像"整行"。正确做法是让棋盘把"清掉了哪几行哪几列"直接告诉表现层(ClearFullLines 顺手记一份 LastClearedRows/Cols),表现层就对整行 / 整列每一格爆粒子,一格不漏。炸弹则相反------它清的是一片不规则区域,那就用"由满变空"的比对。两种清除,两种取法。
下面这张是真机把整行消除的迸裂截下来的------整整一行 8 格,每格都在往外炸(包括最左那格刚补满的):

怎么给动画截到这一帧?
ParticleSystem有个Simulate(t)------把系统确定性地推进到第 t 秒再Pause,配合关掉自动随机种子(useAutoRandomSeed=false),同一帧每次都一模一样。所以"动画难定格"在这里不成立,用 MCP 想截哪一帧截哪一帧。一个小坑:AddComponent<ParticleSystem>默认就开始播了,得先Stop(...StopEmittingAndClear)再改duration/随机种子,否则会报"系统播放中不能改"------这种细节也是进 PlayMode 跑一遍、看着报错才会发现的。
再加两笔小 juice,靠的还是那几个缓动函数:得分蹦字 (分数变了用 ScalePunch 让数字弹一下)、连消提示 (一次消≥2 行/列,中间弹出"X 连消!",用 OutBack 弹入、再淡出)。下面这张是一次双消------金色"2 连消!"配着整行迸裂一起出来:

音频:全部用 numpy 程序合成,0 美元 0 素材
这一集我特别想验证的一件事:AI 能不能不靠任何音频素材,把一套游戏音效做出来。答案是能------用 numpy 合成正弦/三角/方波 + ADSR 包络,写成 16bit WAV。8 个音频全程序生成:
ui_click(短三角 blip)、place(低 thock + 高频小击)line_clear(上行大调琶音 sparkle)、combo(更亮更长的连消音)coin(经典双音上行方波)、win(大三和弦琶音)、game_over(下行小调)bgm_loop(I-V-vi-IV 和弦进行 + 琶音 + 软 hi-hat 的可循环 BGM)
右图那 4 条是 place / line_clear / combo / coin 的真实波形(直接从合成的 WAV 读出来画的)------不是示意图,是实际进游戏的声音。落子是一个短促衰减包络,消行/连消是一串琶音的多个起音,金币是高能量的方波。
AudioManager:对玩法只读,按语义键播放
音频层有一条铁律------它对游戏逻辑只读 :订阅事件、查表、出声,从不反向影响玩法。表现层只认语义键(Sfx.LineClear),不关心文件名:
csharp
public enum Sfx { Click, Place, LineClear, Combo, Coin, Win, GameOver }
public void Play(Sfx s)
{
float vol = ResolveSfxVolume(); // 含静音/分轨;0 直接不出声
if (vol <= 0f) return;
if (!_clips.TryGetValue(s, out var clip) || clip == null) return;
_sfxSource.PlayOneShot(clip, vol);
}
音量是 EP1 那套自动存档的 Property:master × 分轨 × (静音?0:1),钳到 0,1。"该不该响、用哪个 clip、音量多少"------全是纯逻辑,全可测。真正的 PlayOneShot 是 runtime-only,但它之前的每个判断都在断言覆盖内。
这套音量设置也接了 UI------右上角一个齿轮,点开是设置面板,音乐/音效各一个开关,直接改那两个自动存档的 Property(关掉就 0、打开就恢复),下次进游戏还记得:

顺手修了个框架 bug
写到这才暴露出来:SingletonMono 的 DontDestroyOnLoad 在编辑模式下会直接抛异常 (Unity 限制它只能在 play mode 调)。我的自测在编辑模式跑,一创建 AudioManager 就炸。修复是给两处都加 if (Application.isPlaying) 守卫------DontDestroyOnLoad 本来也只在 play mode 有意义。这正是"坚持给每个系统写可跑的自测"的副产品:它逼着框架在编辑模式也得是干净的。
验证:23 条断言
A 缓动:Linear/OutQuad/OutCubic 边界0/1 · OutQuad减速 · OutBack回弹(t=0.8>1)
· OutElastic振荡(>1) · ScalePunch两端==1且峰值≈1+amount
B 音频:7个音效全加载 · BGM加载 · 事件映射Coin→Audio/coin · 每个语义键都有clip
C 音量:默认有效=1/BGM=0.6 · master=0.5生效 · 钳制(sfx=2→1)
· 静音→0 · 取消静音恢复
==== 23/23 PASS ====
音效加载 7/7, BGM=ok
钉死的核心:缓动曲线边界精确 (动效不能起跳或终点跳变)、回弹确实 overshoot (不 overshoot 就没手感)、音频 7 个全加载且映射正确 (漏一个就是哑的)、音量钳制 + 静音彻底归零(静音必须真静音)。
这一集的产物与诚实的话
Easing(缓动数学核心)+AudioManager(事件驱动播放,对玩法只读)+AudioSettings(音量/静音持久化)。- 8 个程序化合成 WAV 进 Resources/Audio,0 美元 0 素材。
- 顺手修了
SingletonMono编辑模式DontDestroyOnLoad的 bug。 - 23 条断言全绿 + 4 条合成音效真实波形。
诚实地讲 :我做的是手感的可测内核 ------缓动曲线、音频的播放决策逻辑。真正"摸起来爽不爽"的最终判断,还是得人在真机上玩一遍才算数(断言能保证曲线对、声音响,但保证不了"好听""带感",那是主观的)。另外程序化合成的音频是够用、对味的休闲音效,但和专业音乐人做的 BGM/音效比,质感还有差距------这是我在 EP0 就标过的边界:bespoke 音乐仍在纯 AI 工具链的射程之外。但"一分钱素材不花,把一套成系统的游戏音效和动效核心做出来并验证",这件事成立。
下一篇 EP8:数据与运营------埋点分析、远程配置(不发版改数值)、热更新框架接入。把前面这些系统接上"上线后还能调"的能力。
- 工具:funplay-unity-mcp
- 开源工程:本系列做出来的完整 Unity 工程已开源
- 上一篇:EP6 留存系统