全程用 AI 做一款商业级手游 · EP7 表现层与手感:从“能跑“到“摸起来爽“
前 6 集把一款商业 F2P 的系统都立起来了玩法、数据、经济、商城、留存。但它们全是纯逻辑——能跑没手感、还很糙方块是纯色块、UI 是扁平方框、消行没声音。这一集EP7做表现层美术、动效、音频把它从能跑做到像个正经商业产品。先看做完的样子——方块是生成的糖果光泽块、背景是生成的渐变、按钮/面板全换成圆角光泽 UI都和方块一个风格皮肤系统也接到了表现层换一个皮肤整盘方块实时重染这是霓虹皮肤红→粉、青→紫、绿→亮绿光泽保留美术怎么来的方块是一张程序化生成的「白色光泽方块」用当前皮肤调色板染色所以换肤能重染背景、UI 按钮/面板是 gpt-image-2 出图、程序抠底、设 9-slice 后用Image.color染成各自配色。全程没用一张 Unity 默认占位图。这些都是 Claude 用 MCP 把图导进工程、设好导入参数、进 PlayMode 截图比对调出来的。剩下的是动起来好不好、响不响。表现层有个老大难手感天生难测——砸下去那一下爽不爽没法写断言。但我不想因此放弃验证——所以这一集的核心思路是把爽拆成可断言的部分。思路把手感拆成纯函数动效的本质是一个值随时间怎么变。把怎么变这条曲线抽成纯缓动函数它就可断言了——动效组件只是按时间采样这条曲线曲线对不对在数学层就钉死。publicstaticclassEasing{publicstaticfloatOutQuad(floatt)1f-(1f-t)*(1f-t);// 减速// 回弹收尾前先冲过 1 再回落 —— 方块落位的砸感publicstaticfloatOutBack(floatt){constfloatc11.70158f,c3c11f;floatut-1f;return1fc3*u*u*uc1*u*u;}// punch 缩放先放大到 1amount 再弹回 1 —— 点击/得分蹦一下publicstaticfloatScalePunch(floatt,floatamount){if(t0f||t1f)return1f;return1famount*Mathf.Sin(t*Mathf.PI);// 半正弦两端 1中间峰值}}爽的来源——OutBack/OutElastic在收尾前冲过 1 再回落——正是可以断言的OutBack(0.8) 1。左图画出来那几条冲出顶线再回落的曲线就是砸和弹的手感把表现接到玩法上就有了迸裂——消行/炸弹的时候被清掉的每一格都炸出一簇粒子。这里用的是Unity 自带的ParticleSystem不是手搓的精灵动画球形发射、带重力下坠、尺寸和透明度随生命周期衰减粒子贴图就用那张白色光泽方块startColor染成金色/暖橙和方块一个风格。发射是手动的——一次消行就在每个被清的格点Emit一簇voidEmitBurst(ListVector3centers,Colorcol){varmain_ps.main;main.startColorcol;foreach(varcincenters)_ps.Emit(newParticleSystem.EmitParams{positionc},14);// 每格爆 14 颗}这里centers是哪些格藏着一个我一开始做错的细节。最自然的想法是比对落子前后哪些格子由满变空就在那些格上爆——但刚落下去补满那一行的那块格子是空→满→消前后都不算’由满变空’于是它没特效整行就缺了那么一格看着不像整行。正确做法是让棋盘把清掉了哪几行哪几列直接告诉表现层ClearFullLines顺手记一份LastClearedRows/Cols表现层就对整行 / 整列每一格爆粒子一格不漏。炸弹则相反——它清的是一片不规则区域那就用由满变空的比对。两种清除两种取法。下面这张是真机把整行消除的迸裂截下来的——整整一行 8 格每格都在往外炸包括最左那格刚补满的怎么给动画截到这一帧ParticleSystem有个Simulate(t)——把系统确定性地推进到第 t 秒再Pause配合关掉自动随机种子useAutoRandomSeedfalse同一帧每次都一模一样。所以动画难定格在这里不成立用 MCP 想截哪一帧截哪一帧。一个小坑AddComponentParticleSystem默认就开始播了得先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_loopI-V-vi-IV 和弦进行 琶音 软 hi-hat 的可循环 BGM右图那 4 条是place / line_clear / combo / coin的真实波形直接从合成的 WAV 读出来画的——不是示意图是实际进游戏的声音。落子是一个短促衰减包络消行/连消是一串琶音的多个起音金币是高能量的方波。AudioManager对玩法只读按语义键播放音频层有一条铁律——它对游戏逻辑只读订阅事件、查表、出声从不反向影响玩法。表现层只认语义键Sfx.LineClear不关心文件名publicenumSfx{Click,Place,LineClear,Combo,Coin,Win,GameOver}publicvoidPlay(Sfxs){floatvolResolveSfxVolume();// 含静音/分轨0 直接不出声if(vol0f)return;if(!_clips.TryGetValue(s,outvarclip)||clipnull)return;_sfxSource.PlayOneShot(clip,vol);}音量是 EP1 那套自动存档的Propertymaster × 分轨 × (静音?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回弹(t0.81) · OutElastic振荡(1) · ScalePunch两端1且峰值≈1amount B 音频7个音效全加载 · BGM加载 · 事件映射Coin→Audio/coin · 每个语义键都有clip C 音量默认有效1/BGM0.6 · master0.5生效 · 钳制(sfx2→1) · 静音→0 · 取消静音恢复 23/23 PASS 音效加载 7/7, BGMok钉死的核心缓动曲线边界精确动效不能起跳或终点跳变、回弹确实 overshoot不 overshoot 就没手感、音频7 个全加载且映射正确漏一个就是哑的、音量钳制 静音彻底归零静音必须真静音。这一集的产物与诚实的话Easing缓动数学核心AudioManager事件驱动播放对玩法只读AudioSettings音量/静音持久化。8 个程序化合成 WAV 进 Resources/Audio0 美元 0 素材。顺手修了SingletonMono编辑模式DontDestroyOnLoad的 bug。23 条断言全绿 4 条合成音效真实波形。诚实地讲我做的是手感的可测内核——缓动曲线、音频的播放决策逻辑。真正摸起来爽不爽的最终判断还是得人在真机上玩一遍才算数断言能保证曲线对、声音响但保证不了好听“带感”那是主观的。另外程序化合成的音频是够用、对味的休闲音效但和专业音乐人做的 BGM/音效比质感还有差距——这是我在 EP0 就标过的边界bespoke 音乐仍在纯 AI 工具链的射程之外。但一分钱素材不花把一套成系统的游戏音效和动效核心做出来并验证这件事成立。下一篇EP8数据与运营——埋点分析、远程配置不发版改数值、热更新框架接入。把前面这些系统接上上线后还能调的能力。工具funplay-unity-mcp开源工程本系列做出来的完整 Unity 工程已开源上一篇EP6 留存系统