全程用 AI 做一款商业级手游 · EP7 表现层与手感:从“能跑“到“摸起来爽“

前 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

写到这才暴露出来:SingletonMonoDontDestroyOnLoad编辑模式下会直接抛异常 (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:数据与运营------埋点分析、远程配置(不发版改数值)、热更新框架接入。把前面这些系统接上"上线后还能调"的能力。

相关推荐
咖啡星人k1 小时前
MonkeyCode Prompt工程实践:如何写出高质量的AI编程需求描述
prompt·ai编程·monkeycode
千纸鹤の脉搏1 小时前
多线程的初步使用
java·开发语言·学习·多线程
一条泥憨鱼1 小时前
Harness Engineering(驾驭工程)零基础入门
网络·人工智能·harness·驾驭工程
AIHR数智引擎1 小时前
AI组织进化论:拆解微软、英伟达、Anthropic与Open AI如何重写组织
人工智能·经验分享·microsoft·职场和发展·aihr
2601_955767421 小时前
2026年iPhone17护眼钢化膜推荐:悟赫德测评
网络·人工智能·iphone·#观复盾护景贴·scinique双护技术
weisian1511 小时前
基础篇--概念原理-27-基座模型是什么?怎么理解?——从原理到实战,一篇讲透
人工智能·深度学习·基座模型
科技侃谈1 小时前
从协议打通到RAG工程化:北泰智能全栈自研智慧档案系统架构深度拆解
人工智能
专注VB编程开发20年1 小时前
阿里通义灵码插件安装失败
开发语言·ide·c#·visual studio
Geek_Vison1 小时前
政务一网通APP如何引入AI能力,通过一个AI助手就能够调用所有的功能,实现对话即办事
人工智能·ai·小程序·uni-app·小程序容器