简介:这份Unity 2019.1.14f1版本的放置类RPG项目源码,定位为移动端游戏开发参考与二次开发基底,适合掌握基础Unity操作、想深入C#游戏逻辑的中级开发者。项目完整度较高,包含角色养成、Boss战斗、宠物收养、炼金术等模块,也涵盖冒险模式、季节活动、神秘之门等系统,便于研究闲置玩法与RPG成长曲线的结合方式。资源打包为zip,共2000个文件,约359.12MB;文件类型覆盖155个prefab预制体、127个mat材质、71个cs脚本、21个asset配置、12个shader着色器,以及png美术图、json/txt配置和md文档,Unity工程目录结构完整,可直接用指定版本打开查看。目前已有530人学习下载,从源码中可以学习到放置类游戏的核心循环搭建、离线收益与成就任务系统设计,以及UI交互和战斗表现的实现细节;同时可复用美术素材和代码框架,缩短同类项目原型开发周期。
1. 一套Unity放置RPG项目源码,先分清"能跑"和"值得抄"两件事
Almost a Hero 这种命名在放置RPG里很典型:主角永远差一步成为英雄,但数值从登录那一刻起就在涨。工程视角下,一份 Unity 放置类RPG源码的含金量不在特效,而在三件事:自动战斗的循环设计、滚雪球式的数值产出、能扛住长期游玩的存档体系。对 Unity 开发者来说,放置品类的源码是最划算的阅读材料——循环从玩家操作驱动变成系统自动驱动,模块边界比常规 RPG 清晰得多,5 年以上经验看架构取舍,新手看最小可玩闭环。不替任何仓库背书,只讲这类源码最常见的工程解法:数值底盘怎么搭、自动战斗与存档怎么落地、手游端最容易翻车的性能点在哪。适合做放置玩法的客户端、独立开发者和刚接手这类项目评估存量代码的人。
2. 放置RPG数值底盘剖析:产出公式、离线收益与升级曲线
放置品类的数值就是玩法本身,拆源码先看懂它的产出链路。所有放置RPG本质上都是同一台机器:时间换资源、资源换成长、成长换更多资源。源码的价值在于让这条链路可配置、可离线计算、可回溯,而不是把数值公式散落在各个 MonoBehaviour 里。
2.1 核心循环抽象:战斗只是产出金币的前置过程
放置RPG和传统RPG最本质的区别在输入侧:战斗不需要玩家实时确认,变成一条定时触发的产出链路。源码里最常见的形式是一个全局心跳类,每秒 tick 一次,依次完成"检查目标、造成伤害、计算掉落、累加资源、判断升级"。这段逻辑通常会收敛到纯 C# 类里,不挂 MonoBehaviour,原因很直接:离线结算和在线心跳要复用同一套代码和同一组时间单位,一旦战斗逻辑依赖 Update 和 Time.deltaTime,离线那段根本没法模拟,只能另写一套,结果就是两套逻辑迟早分叉。
// GameLoopManager.cs —— 放置游戏每秒心跳,驱动全部产出 public class GameLoopManager : MonoBehaviour { private float _timer; private const float TickInterval = 1f; private void Update() { _timer += Time.deltaTime; if (_timer < TickInterval) return; _timer = 0f; // 顺序固定:先战斗后经济,离线模拟才能按同一顺序回放 BattleEngine.Simulate(1f); EconomySystem.Tick(1f); QuestSystem.TryAdvance(); } }这里的两个设计点值得抄:一是 TickInterval 取 1 秒而不是逐帧结算,让产出公式在离线和在线拿到同一组入参;二是 BattleEngine 不直接碰 UI,只写内存里的 PlayerProgress,UI 层通过事件或版本号感知变化,避免战斗逻辑和界面耦合。参数上最容易被忽略的是 Time.timeScale:如果游戏做了暂停、慢动作演出或者技能时停,Update 里的心跳也会被拖慢,严格做法是心跳内部用 DateTime.UtcNow 做时间差值,不依赖缩放后的 deltaTime,这样任何演出效果都不会影响产出节奏。
2.2 离线收益结算:时间戳、倍率与上限
离线收益是放置品类的命脉。源码里最常见的方案是:每次玩家资源变动时同步维护一个 lastLoginUnix 时间戳,下次启动时用当前 UTC 时间减去上次登录时间得到离线秒数。这个差值必须做两个钳制——离线时长上限和产出衰减,否则玩家一个月不登录,回来直接点满全天赋,数值系统当场破产。
// OfflineEarningCalculator.cs —— 离线结算与参数 public struct OfflineReward { public double gold; public double exp; public int seconds; } public static OfflineReward Calculate(PlayerProgress progress, long lastLoginUnix) { long elapsed = DateTime.UtcNow.ToUnixTimeSeconds() - lastLoginUnix; elapsed = Math.Min(elapsed, progress.MaxOfflineSeconds); // 上限收敛 double goldPerSec = progress.GoldPerSecond * 0.6f; // 离线只有在线 60% return new OfflineReward { gold = goldPerSec * elapsed, exp = progress.ExpPerSecond * elapsed * 0.5f, // 经验再打对折 seconds = (int)elapsed }; }参数说明:MaxOfflineSeconds 在源码里通常不是常量,而是一个玩家可升级的"离线时长上限",初始 2 小时、点满天赋 8 小时,比直接封顶更有养成纵深。0.6 和 0.5 两个衰减系数建议收敛到一张数值配置表,后续活动双倍离线收益、VIP 加成都是从这张表上做乘区扩展。结算的调用时机同样关键:必须在存档加载完成之后、玩家点击任何按钮之前执行,结算完立刻把 lastLoginUnix 刷新为当前时间,否则玩家反复杀进程重进就能无限刷离线奖励。
2.3 数值曲线选型:从线性到指数,放置游戏为什么越滚越大
放置RPG的爽感来自数值膨胀,但膨胀必须有节奏感。20 级时每级加 10 点攻击,玩家无感;200 级时每级攻击翻倍,玩家才愿意等下去。源码里最常见的做法是分段曲线:前期用线性保证新手不劝退,中期切指数制造质变点,后期用对数做软上限,防止 double 也扛不住的溢出。
| 曲线类型 | 公式示例 | 典型区间 | 翻车点 |
|---|---|---|---|
| 线性 | damage = base + level * step | 1~50 级 | 后期成长感缺失 |
| 指数 | damage = base * pow(growth, level) | 51~300 级 | 底数差 0.01,百级后溢出 |
| 对数软上限 | damage = cap * (1 - exp(-k * level)) | 300 级后 | k 调不好,曲线形同虚设 |
指数曲线在 C# 里有一个隐蔽的精度坑:float 超过 16777216(2^24)后整数精度开始掉,到了万亿级别会出现"金币加了等于没加"的假象。所以放置游戏源码里普遍用 double 存战斗数值,只在显示层用 K/M/B 缩写字符串。另一个容易被忽略的点是消耗与产出的增速差:升级消耗按 1.12 指数涨、产出按 1.08 涨,中间 4 个百分点的差值靠玩家主动点技能和离线收益补足,这就是放置游戏留存设计里最核心的一对参数,改源码数值时先动这两个底数,不要单独去改某个技能倍率。
提示:double 也不是无限精度,配置表里所有数值超过 1e12 后,统一走"科学计数法显示 + 高位截断"的处理,避免 UI 上出现一排看不懂的 0。
3. 拆解Unity项目源码:目录组织、自动战斗与UGUI刷新策略
打开一套 Unity 3D 放置项目源码,先看目录结构再决定从哪里读起。放置类玩法模块边界清晰,好的源码一眼能看出"战斗、经济、存档、UI"四条主线;差的源码把所有逻辑堆在几个巨型 MonoBehaviour 里,这种项目修复成本高过重写。如果源码是拿 Unity 2021 写的,你用 2023 强行打开,大概率碰到一堆 API 过时告警,先核对版本号再决定迁移成本。
3.1 目录按功能分包,公共代码单独收敛
新项目比老项目更倾向按功能分包而不是按类型分包,放置游戏尤其如此:经济系统修改频繁,战斗系统相对稳定,按功能分后改经济不会碰战斗代码,多人协作的冲突面大幅缩小。Assets/Scripts 下常见的参考结构是这样:
Assets/Scripts/ ├── Battle/ # 战斗引擎、敌人生成、状态机 ├── Economy/ # 金币产出、离线收益、商店定价 ├── Heroes/ # 英雄数据、升级、天赋 ├── Save/ # 存档序列化、校验、迁移 ├── UI/ # 界面控制器、数字滚动、列表回收 └── Config/ # ScriptableObject 数值配置目录说明:Config 单独拎出来是因为放置游戏调数值的频率远超其他品类,策划不应该改代码。用 [CreateAssetMenu] 让策划在 Project 面板右键直接新建数值配置,用 [ContextMenu] 在 Inspector 上挂"发一笔钱、刷一波怪"这类调试命令,这是标准的 Unity 扩展用法。如果源码里没有这层配置抽象,说明它还停留在 DEMO 阶段,抄的时候把硬编码数值全部抽到 ScriptableObject 或 JSON 里再往下走。编辑器工具类要放进 Editor 文件夹,否则会打进发布包。
| 目录组织方式 | 优点 | 缺点 | 适用阶段 |
|---|---|---|---|
| 按类型分包(UI/Data/Enemy) | 新成员好找文件 | 改一个玩法要跨多个目录 | 原型期 |
| 按功能分包(Battle/Economy/Save) | 玩法内聚、冲突少 | 公共代码容易重复 | 正式项目 |
| 按模块分包(Hero/Core/UI) | 可整模块替换 | 边界设计成本高 | 中大型项目 |
3.2 自动战斗状态机:状态切换和执行分离
自动战斗在源码里通常是一台最小状态机,最少包含 Idle、Attack、Skill、Dead 四个状态。放置游戏的特点是状态切换完全由时间和数值条件驱动,不需要输入事件,一个 Update 驱动的状态机足够,没必要引入复杂框架。
// BattleStateMachine.cs —— 英雄的自动战斗状态机 public enum BattleState { Idle, Attack, Skill, Dead } public class BattleStateMachine : MonoBehaviour { [SerializeField] private HeroStat stat; private BattleState _state; private float _timer; private void Update() { if (_state == BattleState.Dead) return; _timer -= Time.deltaTime; if (_timer > 0f) return; switch (_state) { case BattleState.Idle: _state = BattleState.Attack; // 攻击间隔到了,切攻击态 _timer = 0f; // 置零,下一帧立即执行 break; case BattleState.Attack: if (stat.Energy >= stat.SkillCost) // 能量足够优先放技能 { _state = BattleState.Skill; _timer = stat.SkillCastTime; } else { DoAttack(); _state = BattleState.Idle; _timer = stat.AtkInterval; } break; case BattleState.Skill: DoSkill(); _state = BattleState.Idle; _timer = stat.AtkInterval; break; } } }新手最容易抄错的地方是把 DoAttack 写在 Idle 分支里,导致同一次间隔内动作被触发两遍;正确做法是状态切换和动作执行分离,切换只负责改状态和重置计时器,动作只发生在进入目标状态之后。从 Idle 转入 Attack 时把计时器置零,让攻击在下一帧执行,既不会出现同帧双击,也不会因为重置计时器而多吃掉一倍攻击间隔。参数上三处值得注意:AtkInterval 不是固定值,源码里通常会叠加攻速 buff;SkillCastTime 用来占位技能前摇,没有这个参数,技能动画会被连续 Update 打断;Energy 的回复速率决定了战斗节奏,放置游戏里普遍做成每 0.5 秒回 1 点、受击杀加成。如果原项目用协程堆攻击延时,改成状态机后 Update 频率和内存分配都会明显改善。
3.3 UGUI源码级视角下的刷新策略:Text和进度条别每秒重建
放置游戏的 UI 是几类游戏里最容易写烂的:金币每秒变两次、血条每秒跳、离线奖励弹窗频繁触发,每个 UI 元素都在 Update 里轮询数据源再赋值的话,一场战斗下来 GC 曲线直接起飞。UGUI 源码里 Text.text 的 setter 每次赋值都会请求网格重建,内容没变也会走一遍完整管线,所以有效的做法是版本号驱动或者值变更通知:
// GoldTextBinding.cs —— 版本号驱动,跳过无变更的每帧赋值 public class GoldTextBinding : MonoBehaviour { [SerializeField] private Text goldText; private long _lastVersion = -1; private double _lastValue; private void Update() { if (PlayerProgress.Instance.Version == _lastVersion) return; long v = PlayerProgress.Instance.Version; double gold = PlayerProgress.Instance.Gold; if (v != _lastVersion || gold != _lastValue) { goldText.text = FormatNumber.ToShort(gold); // 1.2K / 3.4M _lastValue = gold; _lastVersion = v; } } }参数说明:Version 是 PlayerProgress 上的 long 自增计数器,每次资源变动都加一,UI 脚本只需要记录上一次看到的版本号,不需要维护具体数值的订阅关系,这是放置项目里性价比最高的 UI 解耦方式。FormatNumber.ToShort 内部用 StringBuilder 拼 K/M/B 缩写,这是 UI 层最大的 GC 来源,务必避免 string 逐帧拼接。进度条同理:Image.fillAmount 每帧赋值即使数值没变也会标记 Canvas 脏,赋值前先做一次浮点阈值比较;如果源码用补间动画驱动 fillAmount,确认动画结束的 OnComplete 回调里有没有释放补间实例,否则 Canvas 会一直被无效标记拖着重建。滚动列表超过一屏时,RectMask2D 在移动端的裁剪开销不小,列表项建议走对象池加内容复用,少堆 Mask 和 Canvas。
注意:Canvas 重建在 Profiler 里显示为 Canvas.SendWillRenderCanvases,持续高于 4ms 就先查上面的两类赋值。
4. 放置RPG存档三件套:Unity序列化、版本迁移与时钟防篡改
存档是放置RPG的"最后一公里",玩法做得再好,存读写崩一次就是流失。放置存档有两个硬指标:写入频率高,每 30 秒或者每次重要操作都要落盘;跨版本可迁移,加了新英雄新天赋不能把老玩家挡在门外。
4.1 三种存档格式的取舍:JSON、二进制还是混合方案
ScriptableObject 只适合做配置资产,不适合做玩家存档:编辑器模式下它会自动落盘,真机上不好控制写入时机,而且玩家数据不应该和项目资产混在同一套资源体系里。JSON 是源码里最常见的落盘格式,可读、可 diff、可手工改档,缺点是体积大;二进制体积小、解析快,但版本升级必须维护迁移工具。实际项目我一般用混合方案:核心进度用 JSON,局外的日志型数据(统计、成就、邮件)归到另一份小存档,避免一次改动全量重写。
| 存档格式 | 体积 | 解析速度 | 可调试性 | 版本迁移成本 |
|---|---|---|---|---|
| JSON(Newtonsoft) | 大 | 中等 | 高 | 低 |
| 二进制(自研序列化) | 小 | 快 | 差 | 高 |
| JSON + 二进制混合 | 中 | 中等 | 中 | 中 |
选择依据只有一个:你的改版频率。独立项目和快速迭代的产品无脑选 JSON,数值系统要长期运营折腾的,才值得为二进制投入序列化和迁移成本。
4.2 存档结构设计:版本号在前,字段迁移在后
几乎每一份放置游戏源码最后都会被存档兼容性坑一次:上线后加了新英雄、新天赋,老玩家读旧档直接反序列化异常。标准做法是存档第一层永远带 version 字段,读取时拿到旧版本号做链式迁移,不要让反序列化器理解所有历史版本,一路升到当前版本再进游戏。
// SaveManager.cs —— 版本化存档读取与迁移入口 [Serializable] public class GameSaveData { public int version; public double gold; public int heroLevel; public long lastLoginUnix; public List<int> unlockedHeroIds; public string checksum; // 附加字段,计算哈希时排除自身 } public static class SaveMigrator { private const int CurrentVersion = 3; public static GameSaveData Migrate(GameSaveData data) { while (data.version < CurrentVersion) { switch (data.version) { case 1: data.unlockedHeroIds ??= new List<int> { 0 }; data.version = 2; break; case 2: if (data.gold < 0) data.gold = 0; // 修历史脏数据 data.version = 3; break; default: return data; } } return data; } }逻辑说明:迁移函数必须是幂等的,同一个旧档跑两次得到相同结果;反序列化必须用 try-catch 包住,失败后优先恢复上一次自动存档,而不是直接删档重来,这是回流玩家的底线。写入策略上我一般保留三个写点:玩家切后台时、每 30 秒定时、每完成一个关卡波次时,三个入口共用同一个 Serialize 方法,防止漏写和格式不一致。老存档字段缺失时,反序列化器会丢默认值,所以要给 List 和 double 这类字段做空值兜底,而不是指望 Newtonsoft 替你处理好所有兼容。
4.3 本地时钟防篡改:校验和、时间漂移与WebGL特例
离线收益依赖时间戳,玩家把系统时间往后拨就能白嫖离线奖励,放置游戏必须处理。源码里常见的防线有三层:第一层校验和,对存档主体(排除 checksum 自身)算 SHA256 或轻量哈希存进文件,任何手工改档都会导致校验失败;第二层时间漂移检测,存档里带 UTC 基准时间,发现系统时间跳变超过阈值就拒绝结算本轮离线收益;第三层数值合理性检查,离线结算出的资源超过理论上限时自动回落,即使前两层被绕过也拿不到超额收益。
// SaveValidator.cs —— 存档完整性与时间合法性校验 public static bool Validate(GameSaveData data) { string checksum = ComputeChecksum(data); if (checksum != data.checksum) return false; long elapsed = DateTime.UtcNow.ToUnixTimeSeconds() - data.lastLoginUnix; if (elapsed > data.maxOfflineSeconds * 2) return false; // 超过理论上限视为作弊 return true; }参数说明:maxOfflineSeconds * 2 的系数是给时区跳变和关机时长留的余量,发行区域横跨多时区的产品可以放宽到 2.5,但不宜再大,否则时间拨号作弊就压不住了。风险最高的场景其实是 WebGL:Unity 发布 WebGL 时存档落在浏览器 IndexedDB,IDBFS 写入失败会导致存档静默丢失,所以写档接口必须返回写入结果,启动时校验存档文件存在性和完整性,失败要能走一次本地回滚。这里可以用 Unity 宏定义给不同平台开不同的存档路径:#if UNITY_WEBGL走浏览器存储并加重试,#elif UNITY_EDITOR走编辑器临时目录,移动端走 persistentDataPath,宏同样可以用来区分线上包和调试包的校验日志等级。
5. Unity游戏优化:用Profiler定位放置RPG的三个性能瓶颈
放置游戏画面不复杂,但性能问题比动作游戏更隐蔽——问题不在单帧峰值,而在持续存在的低效。把 Unity Profiler 的 CPU Usage 时间轴开起来,跑十分钟的挂机流程,重点查三类开销。
第一类,Canvas 重建聚合。Text 和 Image 的事件性赋值都会在 Canvas.SendWillRenderCanvases 里体现,这一项常驻 4ms 以上就按第三章的版本号方案做绑定改造,目标是把同一帧被标记重建的 Canvas 数量压到 2 个以内。第二类,Update 调用总量。300 个英雄如果每个挂一个带 Update 的 MonoBehaviour,即使空转也有生命周期和函数调用的固定开销,把它们收拢进一个 TickManager,由管理器统一派发,Profiler 里的总调用数能掉一个数量级。第三类,GC Alloc 尖峰。观察 Heap.Alloc 曲线,放置玩法的常态应控制在每帧 0~2KB,出现锯齿状尖峰就挨个排查字符串拼接、LINQ 和匿名委托,这三样是 UI 层 GC 的主要来源。
shadow 和分辨率属于白送的分:Unity 的实时阴影对放置游戏收益很低,玩家注意力在数字而不是角色阴影上,交付时普遍把 Shadow Quality 降到最低或用范围光替代;移动端分辨率设置要做上限保护,GPU 耗时超过 16ms 时优先从 1080p 降到 720p,体感几乎无差别,发热和耗电明显下降。这两项属于高性价比调整,比优化几十个空 Update 更值。
验证方法上,我习惯用 Profiler 的 Deep Profile 单独跑离线收益结算,这是最容易出现"一帧算一个月收益"的路径;再把游戏切后台 5 分钟再回前台,对比前后内存曲线,内存没回落说明有单例或事件没释放。最后把设备切到最低电量模式压测 20 分钟,那组数据才是玩家真实的体验线。
本文还有配套的精品资源,点击获取