我们来拆解一个种田游戏,这个游戏种类内部的功能还是比较模板化的,我们来一点点说。
我们大体上分为这么几个部分:
农场运营玩法
角色与玩家互动
物品与背包
存档和进度管理
用户界面系统
农场运营
可以大体上分为:
种植系统:支持种植、成长、收获等完整的植物生命周期;动物系统:包含野生动物、家畜、宠物等,支持喂养、骑乘、驯养等功能;建筑与建造:玩家可以建造、升级、摧毁建筑;采集与合成:支持采集资源、合成物品、制作工具;天气与时间系统:有昼夜变化、天气系统,影响作物和动物。
种植系统
既然都当赛博农民了,那我们当然得首先考虑如何实现种植系统,种地当然首先得有plant类和soil类,也就是种植物和土壤。
Plant.cs
public class Plant : Craftable
{
[Header("Plant")]
public PlantData data; // 植物配置数据
public int growth_stage = 0;
首先Plant继承自Craftable类(可制作物品的基类),包含了专门用于存储种植类数据的PlantData以及表明种植物生长阶段的int类型。
[Header("Time")]
public TimeType time_type = TimeType.GameDays; // 时间类型(天/小时)
public float grow_time = 8f; // 生长所需时间
public bool grow_require_water = true; // 是否需要水才能生长
public bool regrow_on_death; // 死亡后是否重新生长
public float soil_range = 1f;
[Header("Harvest")]
public ItemData fruit; // 果实数据
public float fruit_grow_time = 0f; // 果实生长时间
public bool fruit_require_water = true;// 是否需要水才能结果
public Transform fruit_model; // 果实模型
public bool death_on_harvest;
这是一些种植物可能会涉及到的属性,其中有两个自定义的类TimeType和ItemData:ItemData是一个物品数据类,继承自CraftData,用于定义游戏中物品的各种属性和行为;TimeType是一个枚举类型,用于定义游戏中时间的计算方式。
private void SlowUpdate()
{
// 检查植物生长
if (!IsFullyGrown() && HasUID())
{
bool can_grow = !grow_require_water || HasWater();
if (can_grow && GrowTimeFinished())
{
GrowPlant();
return;
}
}
// 检查果实生长
if (!has_fruit && fruit != null && HasUID())
{
bool can_grow = !fruit_require_water || HasWater();
if (can_grow && FruitGrowTimeFinished())
{
GrowFruit();
return;
}
}
}
这是我们用于检测是否满足生长条件的,如果都满足我们就执行Grow相关函数。可以看到我们需求的条件有水分、时间和HasUID:一个用于检查对象是否具有唯一标识符(Unique ID)的方法。
public void Water()
{
if (!HasWater())
{
if (soil != null)
soil.Water();
PlayerData.Get().SetCustomInt(GetSubUID("water"), 1);
ResetGrowTime();
}
}
public bool HasWater()
{
bool wplant = PlayerData.Get().GetCustomInt(GetSubUID("water")) > 0;
bool wsoil = soil != null ? soil.IsWatered() : false;
return wplant || wsoil;
}
这个是浇水相关的代码,如果种植物还没有被浇水且土壤的实例存在,我们就对土壤实施浇水函数且在玩家的数据中记录植物被浇水,最后重置生长时间;判断植物是否被浇水也是去玩家存储的数据里读取,只要符合植物没有浇水或者土壤没有被浇水就可以返回true——这里的土壤也有一个判断是否浇水的原因是因为游戏内部允许我们先给土壤浇水之后再种植也可以种植之后再浇水。
public void Harvest(PlayerCharacter character)
{
if (fruit != null && has_fruit && character.Inventory.CanTakeItem(fruit, 1))
{
GameObject source = fruit_model != null ? fruit_model.gameObject : gameObject;
character.Inventory.GainItem(fruit, 1, source.transform.position);
RemoveFruit();
if (death_on_harvest && destruct != null)
destruct.Kill();
TheAudio.Get().PlaySFX("plant", gather_audio);
if (gather_fx != null)
Instantiate(gather_fx, transform.position, Quaternion.identity);
}
}
这个是收获相关的代码,当玩家尝试收获植物时,系统首先会检查三个条件:植物是否有果实数据、植物是否已经结果、以及玩家的背包是否有足够空间。如果这些条件都满足,系统会从植物上获取果实(优先使用专门的果实模型,如果没有则使用植物本身作为模型),并将果实添加到玩家的背包中。收获后,系统会更新植物的状态(移除果实),如果植物被设置为收获后死亡,则会销毁该植物。
public SowedPlantData SaveData
{
get { return PlayerData.Get().GetSowedPlant(GetUID()); }
}
存档系统,把种植物的数据存在PlayerData里就好。
public static Plant Create(PlantData data, Vector3 pos, int stage)
{
Plant plant = CreateBuildMode(data, pos, stage);
plant.buildable.FinishBuild();
return plant;
}
Plant的创造方法。
总的来说,Plant类是一个用于管理游戏中植物生长和收获的核心类。它继承自Craftable类,包含了植物的基本属性(如生长阶段、生长时间、水分需求等)和核心功能(如生长、浇水、收获等)。植物系统支持多个生长阶段,每个阶段都有特定的时间要求,并且可以通过浇水来促进生长。植物可以产出果实,玩家可以收获这些果实,收获后植物可能会死亡(取决于设置)。系统还包含了完整的数据保存和加载功能,确保植物的状态在游戏存档中保持。
Soil.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace FarmingEngine
{
/// <summary>
/// 可以浇水的土壤
/// </summary>
[RequireComponent(typeof(UniqueID))]
public class Soil : MonoBehaviour
{
public MeshRenderer mesh; // 土壤的网格渲染器
public Material watered_mat; // 浇水后的材质
private UniqueID unique_id; // 唯一标识组件
private Material original_mat; // 原始材质
private bool watered = false; // 是否已浇水
private float update_timer = 0f; // 更新计时器
private static List<Soil> soil_list = new List<Soil>(); // 土壤列表
void Awake()
{
soil_list.Add(this);
unique_id = GetComponent<UniqueID>(); // 获取唯一标识组件
if(mesh != null)
original_mat = mesh.material; // 获取原始材质
}
private void OnDestroy()
{
soil_list.Remove(this);
}
private void Update()
{
bool now_watered = IsWatered(); // 当前是否已浇水
// 如果状态改变且有网格渲染器和浇水后的材质
if (now_watered != watered && mesh != null && watered_mat != null)
{
mesh.material = now_watered ? watered_mat : original_mat; // 切换材质
}
watered = now_watered; // 更新浇水状态
update_timer += Time.deltaTime;
if (update_timer > 0.5f)
{
update_timer = 0f;
SlowUpdate(); // 慢更新
}
}
private void SlowUpdate()
{
// 自动浇水
if (!watered)
{
if (TheGame.Get().IsWeather(WeatherEffect.Rain)) // 如果是下雨天
Water(); // 浇水
Sprinkler nearest = Sprinkler.GetNearestInRange(transform.position); // 获取最近的洒水器
if (nearest != null)
Water(); // 浇水
}
}
// 浇水
public void Water()
{
PlayerData.Get().SetCustomInt(GetSubUID("water"), 1); // 设置玩家数据,标记为浇水状态
}
// 移除水
public void RemoveWater()
{
PlayerData.Get().SetCustomInt(GetSubUID("water"), 0); // 设置玩家数据,移除浇水状态
}
// 浇水植物
public void WaterPlant()
{
Plant plant = Plant.GetNearest(transform.position, 1f); // 获取最近的植物
if(plant != null)
plant.Water(); // 植物浇水
}
// 判断是否已浇水
public bool IsWatered()
{
return PlayerData.Get().GetCustomInt(GetSubUID("water")) > 0; // 获取玩家数据,判断是否浇水
}
// 获取子UID
public string GetSubUID(string tag)
{
return unique_id.GetSubUID(tag); // 获取唯一标识的子标识
}
// 获取最近的土壤
public static Soil GetNearest(Vector3 pos, float range=999f)
{
float min_dist = range;
Soil nearest = null;
foreach (Soil soil in soil_list)
{
float dist = (pos - soil.transform.position).magnitude;
if (dist < min_dist)
{
min_dist = dist;
nearest = soil;
}
}
return nearest;
}
// 获取所有土壤
public static List<Soil> GetAll(){
return soil_list; // 返回所有土壤列表
}
}
}
Soil类是游戏中土壤系统的核心实现,它继承自MonoBehaviour并需要UniqueID组件。这个类主要负责管理土壤的浇水状态和视觉效果。每个土壤对象都包含一个网格渲染器(mesh)和两种材质(原始材质和浇水后的材质),用于显示土壤的干湿状态。土壤系统维护了一个静态的土壤列表,用于全局管理所有土壤对象。土壤可以通过多种方式被浇水:玩家手动浇水、下雨天气自动浇水、或者通过洒水器自动浇水。当土壤被浇水时,它的外观会改变(切换到浇水后的材质),并且这个状态会被保存在玩家数据中。土壤还可以自动检测附近的植物并为其浇水,实现了土壤和植物之间的互动。系统还提供了获取最近土壤和所有土壤的静态方法,方便其他系统与土壤进行交互。
ActionHoe.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace FarmingEngine
{
/// <summary>
/// 耕地,以便种植植物
/// </summary>
[CreateAssetMenu(fileName = "Action", menuName = "FarmingEngine/Actions/Hoe", order = 50)]
public class ActionHoe : SAction // 继承自可序列化动作基类
{
public float hoe_range = 1f; // 耕地操作的有效范围(单位:米)
// 执行耕地操作
public override void DoAction(PlayerCharacter character, ItemSlot slot)
{
// 计算耕地位置:角色位置 + 朝向方向 * 范围
Vector3 pos = character.transform.position + character.GetFacing() * hoe_range;
// 获取角色的耕地功能组件
PlayerCharacterHoe hoe = character.GetComponent<PlayerCharacterHoe>();
// 若存在组件则调用耕地方法(安全调用避免空引用)
hoe?.HoeGround(pos);
// 获取当前装备槽的物品数据
InventoryItemData ivdata = character.EquipData.GetInventoryItem(slot.index);
// 减少装备耐久度(若物品存在)
if (ivdata != null)
ivdata.durability -= 1;
}
// 验证是否可执行耕地操作
public override bool CanDoAction(PlayerCharacter character, ItemSlot slot)
{
// 仅当物品位于装备槽时才允许耕地(确保手持锄头)
return slot is EquipSlotUI;
}
}
}
实现了一个耕地动作系统,当玩家在农场游戏中执行耕地操作时:
- 动态计算耕地位置:基于玩家角色的当前朝向和预设范围(
hoe_range
),精准定位前方土壤的交互点。 - 调用耕地逻辑:通过
PlayerCharacterHoe
组件执行具体的耕地行为(如土壤状态切换、视觉效果触发),实现逻辑与动作解耦。 - 工具耐久损耗:每次耕地会减少手持农具(如锄头)的耐久度,体现工具的消耗属性。
- 操作条件验证:限制仅当农具被装备在手中(而非在背包)时才能耕地,防止空手操作
ActionHarvest.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace FarmingEngine
{
/// <summary>
/// 收获植物的果实
/// </summary>
[CreateAssetMenu(fileName = "Action", menuName = "FarmingEngine/Actions/Harvest", order = 50)]
public class ActionHarvest : AAction
{
public float energy = 1f; // 操作消耗的能量
// 执行操作
public override void DoAction(PlayerCharacter character, Selectable select)
{
Plant plant = select.GetComponent<Plant>(); // 获取选择对象的植物组件
if (plant != null)
{
string animation = character.Animation ? character.Animation.take_anim : ""; // 获取角色的收获动画
character.TriggerAnim(animation, plant.transform.position); // 触发角色的收获动画
character.TriggerBusy(0.5f, () =>
{
character.Attributes.AddAttribute(AttributeType.Energy, -energy); // 扣除角色能量
plant.Harvest(character); // 收获植物的果实
});
}
}
// 判断是否可以执行操作的条件方法
public override bool CanDoAction(PlayerCharacter character, Selectable select)
{
Plant plant = select.GetComponent<Plant>(); // 获取选择对象的植物组件
if (plant != null)
{
return plant.HasFruit() && character.Attributes.GetAttributeValue(AttributeType.Energy) >= energy; // 如果植物有果实并且角色能量足够,返回 true
}
return false; // 否则返回 false
}
}
}
实现了一个植物收获动作系统,允许玩家在农场游戏中执行收获操作。其核心逻辑包括:
- 执行收获动作(
DoAction
):播放动画、扣除体力、触发植物的收获逻辑。 - 验证操作条件(
CanDoAction
):检查目标是否为植物、植物是否有果实、玩家体力是否充足。
在这里我想要补充一下关于ScriptObject的内容:
可以看到这个项目中的Action类都被设计为基于ScriptableObject :动作被设计为可配置的资产,而不是场景中的组件,这使得它们可以被重复使用和轻松配置。
为什么是ScriptObject呢?特殊在哪里?
ScriptableObject是Unity中的一种特殊资源类型,它是一种不需要附加到游戏对象(GameObject)上就能存在的数据容器。
它的特殊之处主要体现在以下几点:
1. 独立于场景 :ScriptableObject不依赖于场景中的游戏对象,可以在多个场景之间共享数据,非常适合存储游戏配置、角色数据、道具信息等需要跨场景使用的内容。
2. 节省内存 :当多个对象引用同一个ScriptableObject实例时,它们共享的是同一份数据,而不是各自复制一份,这可以显著减少内存占用。
3. 易于编辑 :在Unity编辑器中,ScriptableObject可以像其他资源一样被创建和编辑,开发者可以直接在Inspector面板中修改其属性值。
4. 序列化支持 :ScriptableObject可以被序列化,这意味着它的数据可以被保存到磁盘上,并且在游戏运行时被加载。
5. 无需实例化 :与MonoBehaviour不同,ScriptableObject不需要被实例化到场景中,只需要在项目中创建它的实例,然后就可以在代码中引用它。
6. 适合数据驱动 :ScriptableObject非常适合实现数据驱动的游戏设计,开发者可以将游戏中的各种数据(如角色属性、武器参数等)存储在ScriptableObject中,然后在代码中根据这些数据来驱动游戏逻辑。
在我们之前查看的代码中, Action 类(如 SAction 、 AAction 等)就是基于ScriptableObject实现的,这使得这些动作可以被配置和复用,非常灵活。
Conclusion
整个种植系统的流程实现如下:
准备阶段:创建耕地(ActionHoe.cs)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace FarmingEngine
{
[CreateAssetMenu(fileName = "Action", menuName = "FarmingEngine/Actions/Hoe", order = 50)]
public class ActionHoe : SAction
{
public float hoe_range = 1f;
public override void DoAction(PlayerCharacter character, ItemSlot slot)
{
Vector3 pos = character.transform.position + character.GetFacing() * hoe_range;
PlayerCharacterHoe hoe = character.GetComponent<PlayerCharacterHoe>();
hoe?.HoeGround(pos);
InventoryItemData ivdata = character.EquipData.GetInventoryItem(slot.index);
if (ivdata != null)
ivdata.durability -= 1;
}
public override bool CanDoAction(PlayerCharacter character, ItemSlot slot)
{
return slot is EquipSlotUI;
}
}
}
当玩家角色执行锄地动作时,会在角色前方指定范围内(hoe_range
)的位置进行锄地操作(调用PlayerCharacterHoe.HoeGround()
方法);同时减少该装备(如锄头)的耐久度(durability
),且要求装备必须放置在有效的装备槽中(EquipSlotUI
)。
种植阶段:创建植物(Plant.cs)
// 植物的创建方法
public static Plant Create(PlantData data, Vector3 pos, Quaternion rot, int stage)
{
Plant plant = CreateBuildMode(data, pos, stage); // 创建基础植物对象
plant.transform.rotation = rot; // 设置初始旋转角度
plant.buildable.FinishBuild(); // 标记建造完成
return plant;
}
// 初始化植物
protected override void Awake()
{
base.Awake();
plant_list.Add(this); // 注册到全局植物列表
selectable = GetComponent<Selectable>(); // 获取可选组件
buildable = GetComponent<Buildable>(); // 获取可建造组件
destruct = GetComponent<Destructible>(); // 获取可销毁组件
unique_id = GetComponent<UniqueID>(); // 获取唯一标识组件
// 订阅事件
selectable.onDestroy += OnDeath; // 绑定销毁回调
buildable.onBuild += OnBuild; // 绑定建造回调
// 初始化生长阶段
if(data != null)
nb_stages = Mathf.Max(data.growth_stage_prefabs.Length, 1);
}
实现了植物的动态创建与生命周期管理,通过工厂方法实例化植物,配置位置/旋转并完成建造;注册全局管理、绑定组件事件、初始化生长参数。
生长阶段:植物生长逻辑(Plant.cs)
// 生长条件检测(周期性慢更新,避免每帧检测)
private void SlowUpdate()
{
// 仅当植物未完全成熟且具有唯一标识时执行生长检测
if (!IsFullyGrown() && HasUID())
{
// 检查生长条件:若无需水源(grow_require_water=false)或已有水源
bool can_grow = !grow_require_water || HasWater();
// 同时满足基础条件和生长时间结束时触发生长
if (can_grow && GrowTimeFinished())
{
GrowPlant(); // 执行生长逻辑
return; // 跳过后续检测
}
}
// ...(可能包含其他状态检测)
}
// 植物生长阶段切换方法
public void GrowPlant(int grow_stage)
{
// 验证生长阶段有效性(在数据存在且阶段合法时)
if (data != null && grow_stage >= 0 && grow_stage < nb_stages)
{
// 从玩家数据中获取当前植物的存档记录
SowedPlantData sdata = PlayerData.Get().GetSowedPlant(GetUID());
// 若存档不存在(新种植或数据丢失)
if (sdata == null)
{
// 非初始生成的植物需清理无效数据
if (!was_spawned)
PlayerData.Get().RemoveObject(GetUID());
// 创建新植物存档:记录种类ID、场景、位置、旋转和生长阶段[7](@ref)
sdata = PlayerData.Get().AddPlant(
data.id,
SceneNav.GetCurrentScene(),
transform.position,
transform.rotation,
grow_stage
);
}
else
{
// 更新已有植物的生长阶段[7](@ref)
PlayerData.Get().GrowPlant(GetUID(), grow_stage);
}
// 重置生长计时器(为下一阶段准备)
ResetGrowTime();
// 消耗水资源(模拟生长所需养分)
RemoveWater();
// 从全局植物列表中移除当前对象
plant_list.Remove(this);
// 生成新阶段的植物预制件(基于存档UID)[6](@ref)
Spawn(sdata.uid);
// 销毁当前阶段的植物对象(完成状态切换)
Destroy(gameObject);
}
}
实现了游戏植物生长状态的管理逻辑,具体分为两部分:
生长条件检查(SlowUpdate
):定期检测植物是否未完全成熟(!IsFullyGrown()
)且存在唯一标识(HasUID()
)。若满足生长条件(无需水源或已有水源 can_grow
)且生长时间结束(GrowTimeFinished()
),则调用 GrowPlant()
进入下一生长阶段。
生长执行逻辑(GrowPlant
):
根据目标生长阶段 grow_stage
更新植物数据:
- 通过
PlayerData
获取或创建植物存档数据(SowedPlantData
); - 重置生长计时器(
ResetGrowTime()
)、移除消耗的水资源(RemoveWater()
); - 销毁当前植物对象,生成新阶段的植物(
Spawn(sdata.uid)
和Destroy(gameObject)
),实现生长状态切换
结果阶段:果实生长逻辑(Plant.cs)
// 果实生长条件检测(周期性慢更新,避免每帧检测)
private void SlowUpdate()
{
// ...
// 若当前无果实、果实配置有效且植物有唯一标识时执行检测
if (!has_fruit && fruit != null && HasUID())
{
// 生长条件:无需水源(fruit_require_water=false)或已有水源
bool can_grow = !fruit_require_water || HasWater();
// 同时满足基础条件且果实生长时间结束时触发生长
if (can_grow && FruitGrowTimeFinished())
{
GrowFruit(); // 执行果实生长逻辑
return; // 跳过后续检测
}
}
// ...
}
// 果实生长执行方法
public void GrowFruit()
{
// 验证果实配置存在且当前未生成果实
if (fruit != null && !has_fruit)
{
has_fruit = true; // 标记果实已生成
// 在玩家数据中记录果实状态(1表示已生成)
PlayerData.Get().SetCustomInt(GetSubUID("fruit"), 1);
RemoveWater(); // 消耗生长所需的水资源
RefreshFruitModel(); // 刷新模型以显示果实
}
}
生长条件检测(SlowUpdate
)
通过周期性检测(非每帧执行)判断果实能否生长:需满足无现存果实、配置有效且具备唯一标识的前提,再验证水源条件(若要求)和生长时间是否结束,全部满足则调用 GrowFruit()。
果实生长执行(GrowFruit
)
当条件满足时:
- 更新状态:标记
has_fruit=true
表示果实已生成。 - 数据持久化:通过
SetCustomInt
将果实状态保存至玩家存档(键名为子UID "fruit")。 - 资源消耗:调用
RemoveWater()
移除生长消耗的水资源。 - 视觉更新:刷新模型显示果实(如切换预制件或激活子对象)
收获阶段:获取果实(ActionHarvest.cs&&Plant.cs)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace FarmingEngine
{
// 创建该类的Unity资源菜单项(路径:Create > FarmingEngine > Actions > Harvest)
[CreateAssetMenu(fileName = "Action", menuName = "FarmingEngine/Actions/Harvest", order = 50)]
public class ActionHarvest : AAction // 继承抽象动作基类
{
public float energy = 1f; // 收获动作消耗的体力值
// 执行收获动作
public override void DoAction(PlayerCharacter character, Selectable select)
{
// 获取目标物体的Plant组件
Plant plant = select.GetComponent<Plant>();
if (plant != null) // 确认目标为植物
{
// 获取收获动画名称(若有配置,否则为空)
string animation = character.Animation ? character.Animation.take_anim : "";
// 在植物位置触发动画
character.TriggerAnim(animation, plant.transform.position);
// 延迟0.5秒执行收获(模拟操作耗时)
character.TriggerBusy(0.5f, () =>
{
// 扣除玩家体力
character.Attributes.AddAttribute(AttributeType.Energy, -energy);
// 调用植物的收获方法
plant.Harvest(character);
});
}
}
// 验证是否可执行收获
public override bool CanDoAction(PlayerCharacter character, Selectable select)
{
// 获取目标物体的Plant组件
Plant plant = select.GetComponent<Plant>();
if (plant != null)
{
// 双重验证:植物有果实 + 玩家体力充足
return plant.HasFruit() && character.Attributes.GetAttributeValue(AttributeType.Energy) >= energy;
}
return false; // 非植物对象不可操作
}
}
}
实现了一个植物收获动作,包含执行逻辑(DoAction
)和条件验证(CanDoAction
)。玩家需满足体力条件且植物有果实才能触发收获,执行时会播放动画、扣除体力并调用植物的收获逻辑。
// 执行植物收获操作
public void Harvest(PlayerCharacter character)
{
// 验证:存在果实配置、当前有果实、玩家背包可容纳该物品
if (fruit != null && has_fruit && character.Inventory.CanTakeItem(fruit, 1))
{
// 确定物品生成位置(优先使用果实模型位置)
GameObject source = fruit_model != null ? fruit_model.gameObject : gameObject;
// 玩家背包获取果实(数量为1,在指定位置生成)
character.Inventory.GainItem(fruit, 1, source.transform.position);
RemoveFruit(); // 移除果实状态(内部会设置has_fruit=false)
// 若收获后植物死亡且存在可销毁组件,触发植物死亡
if (death_on_harvest && destruct != null)
destruct.Kill();
// 播放收获音效(如采摘声)
TheAudio.Get().PlaySFX("plant", gather_audio);
// 生成收获特效(如粒子光芒)
if (gather_fx != null)
Instantiate(gather_fx, transform.position, Quaternion.identity);
}
}
实现了玩家在Unity农场游戏中收获植物的核心流程:当玩家执行收获动作时,系统会先验证目标植物是否存在可收获的果实(fruit != null && has_fruit
)且玩家背包有空间容纳(character.Inventory.CanTakeItem
),通过后则根据植物的果实模型位置动态生成掉落物(优先使用fruit_model
位置,否则用植物根部),并调用GainItem
将果实加入玩家背包;紧接着重置植物状态(RemoveFruit()
内部会设置has_fruit=false
,可能触发重新生长计时),并根据配置项death_on_harvest
决定是否销毁植物(例如收割小麦后植物消失,而果树则保留枝干继续生长);最后通过音效系统播放采摘声(gather_audio
)并在植物位置生成粒子特效(如光芒闪烁的gather_fx
)。
工作流如下:
- 玩家装备锄头,使用 ActionHoe 在地面创建 Soil 对象
- 玩家种植种子,调用 Plant.Create() 方法创建植物实例
- 植物通过 SlowUpdate() 方法每0.5秒检查生长条件
- 当满足时间( GrowTimeFinished() )和水分( HasWater() )条件时,植物调用 GrowPlant() 方法生长到下一阶段
- 植物完全成熟后,满足果实生长条件时,调用 GrowFruit() 方法结出果实
- 玩家使用 ActionHarvest 动作收获果实,调用 Plant.Harvest() 方法
- 收获后,植物根据 death_on_harvest 设置决定是死亡还是继续生长
- 整个过程中的关键数据(如生长阶段、果实状态、浇水状态)通过 PlayerData 进行持久化存储
动物系统
农场中一般有三种动物:
- 家畜动物 ( AnimalLivestock ):可以进食、产生产品和成长
- 宠物 ( Pet ):可以跟随玩家、攻击敌人和挖掘
- 野生动物 ( AnimalWild ):可以游荡、逃跑或攻击玩家
每个类都有各自独特的行为和交互方式,但它们共享一些共同的设计模式和机制。
家畜动物 ( AnimalLivestock )
核心机制 :
- 进食:食用特定组的食物,满足条件后产生产品或成长
- 产品生产:根据进食次数和时间周期产生产品,可以自动掉落或需要手动收集
- 成长:当进食次数和时间达到要求后,会成长为新的角色类型
- 数据持久化:存储上次进食时间、成长时间和进食次数
接下来我们来看看源码是怎么实现的。
核心状态管理模块
public enum LivestockState {
Wander, // 游荡:随机移动
FindFood, // 觅食:寻找食物
Eat, // 进食:消耗食物
Dead // 死亡:停止所有行为
}
private void ChangeState(LivestockState newState) {
state = newState;
state_timer = 0f; // 重置状态计时器
}
通过LivestockState
状态机控制动物行为(游荡、觅食、进食、死亡),确保行为逻辑清晰分离。
生存需求模块
private void SlowUpdate() {
if (IsHungry()) target_food = FindFood(); // 饥饿时寻找食物
if (CanGrow()) Grow(); // 满足条件时成长
if (CanProduce()) ProduceItem(); // 满足条件时产出物品
}
模拟动物的饥饿、成长与产出机制,驱动核心游戏循环。
- 饥饿系统:
IsHungry()
判断是否需进食(基于eat_interval_time
时间间隔)- 重置计时器
ResetEatTime()
并累加进食次数GetEatCount()
- 成长系统:
- 达到进食次数(
grow_eat_count
)且时间满足(GrowTimeFinished()
)时触发Grow()
- 替换动物模型为
grow_to
,实现幼崽到成体的演变
- 达到进食次数(
- 产出系统:
- 进食达标后调用
ProduceItem()
生成物品(蛋、毛等) - 支持两种收集方式:直接掉落(
DropFloor
)或手动收集(CollectAction
)
- 进食达标后调用
移动与导航模块
private void FindWanderTarget() {
Vector3 center = wander == WanderNear ? start_pos : transform.position;
wander_target = center + Random.insideUnitSphere * wander_range; // 随机目标点
}
控制动物移动逻辑,优化性能与行为真实性。
- 游荡机制:
WanderBehavior
定义移动模式(原地徘徊/大范围探索),通过FindWanderTarget()
计算随机目标点 - 动态激活:根据玩家距离动态启用/禁用AI(
is_active
),减少远处动物的CPU开销 - 防卡死处理:
IsStuck()
检测移动异常,自动停止或重新寻路 - 速度控制:
is_running
标志区分行走与奔跑(玩家驱赶时触发)
数据持久化模块
void Start() {
// 从存档加载数据
last_eat_time = PlayerData.Get().GetCustomFloat(GetSubUID("eat_time"));
eat_count = PlayerData.Get().GetCustomInt(GetSubUID("eat"));
}
通过PlayerData
保存动物个体状态,支持存档/读档。
- 时间标记:
last_eat_time
/last_grow_time
记录关键事件的发生时刻 - 计数统计:
GetEatCount()
存储进食次数,GetProductCount()
存储待收集物品数 - 唯一标识:
GetUID()
为每个动物分配唯一ID,确保数据准确关联
外部交互接口模块
public void CollectProduct(PlayerCharacter player) {
if (HasProduct()) {
player.Inventory.GainItem(item_produce, 1); // 物品进入背包
DecreaseProductCount(); // 减少待收集数量
}
}
提供玩家与动物交互的API 。
CollectProduct()
:玩家手动收集产出物(如鸡蛋)MoveToTarget()
:驱赶动物至指定位置(如赶回畜棚)StopAction()
:强制中断当前行为(如停止觅食)
宠物 ( Pet )
宠物则是功能更简单的家畜动物,不负责进食和产出而是更简单地跟随玩家。
状态机控制模块
public enum PetState
{
Idle = 0, // 空闲:随机游荡或待命
Follow = 2, // 跟随:追踪主人位置
Attack = 5, // 攻击:锁定敌人并攻击
Dig = 8, // 挖掘:执行挖掘动作
Pet = 10, // 互动:响应抚摸等交互
MoveTo = 15, // 移动:向指定位置行进
Dead = 20 // 死亡:停止所有行为
}
...
...
private PetState _currentState; // 当前状态私有变量
public void ChangeState(PetState newState)
{
// 退出当前状态的清理逻辑(可选)
if (_currentState == PetState.Attack) {
_attackTarget = null; // 清除攻击目标缓存
}
// 更新状态
_currentState = newState;
_stateTimer = 0f; // 重置状态持续计时器
// 进入新状态的初始化逻辑(可选)
if (newState == PetState.Follow) {
_animator.SetBool("Follow", true); // 触发跟随动画
}
}
首先定义了宠物的7种行为状态,每个状态对应特定行为,然后实现状态安全切换,也是基于状态机设计进行逻辑驱动。
跟随控制模块
if (state == PetState.Follow && PlayerIsFar(follow_range)) {
character.Follow(GetMaster().gameObject); // 动态追踪主人
}
实现宠物动态跟随主人,优化移动逻辑与性能,PlayerIsFar()
判断主人距离,触发跟随或停止,
- 游荡模式:随机生成目标点(
FindWanderTarget()
),使用character.MoveTo()
移动。 - 跟随模式:通过
character.Follow()
持续追踪主人位置 character.IsStuck()
检测移动异常,自动停止
战斗与挖掘模块
支持宠物攻击敌人和挖掘物品的能力
驯服与归属模块
动画与事件模块
数据持久化模块
野生动物 ( AnimalWild )
野生动物本质上就是传统的敌人设计,只不过在这个项目中被包装成了动物。
核心枚举定义
// 动物的状态
public enum AnimalState
{
Wander = 0, // 游荡
Alerted = 2, // 警戒
Escape = 4, // 逃跑
Attack = 6, // 攻击
MoveTo = 10, // 移动到指定位置
Dead = 20, // 死亡
}
// 动物的行为
public enum AnimalBehavior
{
None = 0, // 无行为,由其他脚本定义
Escape = 5, // 看到就逃跑
PassiveEscape = 10, // 被攻击时逃跑
PassiveDefense = 15,// 被攻击时反击
Aggressive = 20, // 看到就攻击,一段时间后返回
VeryAggressive = 25,// 看到就攻击,一直追击
}
// 游荡行为
public enum WanderBehavior
{
None = 0, // 不游荡
WanderNear = 10,// 在附近游荡
WanderFar = 20, // 超出初始位置游荡
}
一系列枚举定义,分别定义了野生动物的状态、行为和游荡行为。
状态机实现
public void ChangeState(AnimalState state)
{
this.state = state;
state_timer = 0f;
lure_interest = 8f;
}
private void Update()
{
// 动画
bool paused = TheGame.Get().IsPaused();
if (animator != null)
animator.enabled = !paused;
if (TheGame.Get().IsPaused())
return;
if (state == AnimalState.Dead || behavior == AnimalBehavior.None || !is_active)
return;
state_timer += Time.deltaTime;
if (state != AnimalState.MoveTo)
is_running = (state == AnimalState.Escape || state == AnimalState.Attack);
character.move_speed = is_running ? run_speed : wander_speed;
// 状态处理
if (state == AnimalState.Wander)
{
// 游荡状态逻辑...
}
if (state == AnimalState.Alerted)
{
// 警戒状态逻辑...
}
if (state == AnimalState.Escape)
{
// 逃跑状态逻辑...
}
if (state == AnimalState.Attack)
{
// 攻击状态逻辑...
}
if (state == AnimalState.MoveTo)
{
// 移动到指定位置状态逻辑...
}
// 其他逻辑...
}
感觉没什么好说的,就是非常常规的FSM。
感知和反应机制
// 检测玩家是否在视野范围内
private void DetectThreat(float range)
{
Vector3 pos = transform.position;
// 检测玩家
float min_dist = range;
foreach (PlayerCharacter player in PlayerCharacter.GetAll())
{
Vector3 char_dir = (player.transform.position - pos);
float dist = char_dir.magnitude;
if (dist < min_dist && !player.IsDead())
{
float dangle = detect_angle / 2f; // /2 每侧角度
float angle = Vector3.Angle(transform.forward, char_dir.normalized);
if (angle < dangle || char_dir.magnitude < detect_360_range)
{
player_target = player;
attack_target = null;
min_dist = dist;
}
}
}
// 检测其他角色/可摧毁物体
foreach (Selectable selectable in Selectable.GetAllActive())
{
// 检测逻辑...
}
}
实现了一个AI角色的视野检测功能,主要用于判断玩家或其他可交互目标是否进入AI的视野范围:
- 距离筛选:遍历所有玩家,只处理距离小于
min_dist
(初始为range
)且存活的玩家。 - 角度判断:
Vector3.Angle()
计算目标方向与AI正前方夹角。angle < dangle
:目标在设定的扇形视野内(如120°视野则dangle=60°
)。
- 全向近距离检测:
dist < detect_360_range
时无视角度限制(如距离<3米时任何方向均可见),增强近战逻辑合理性。 - 目标锁定:满足条件时更新
player_target
为当前玩家,并记录其距离(后续遍历中更远玩家不再覆盖)。
// 如果被动物看见玩家则进行反应
private void ReactToThreat()
{
GameObject target = GetTarget();
if (target == null || IsDead())
return;
if (HasEscapeBehavior())
{
ChangeState(AnimalState.Escape);
character.Escape(target);
}
else if (HasAttackBehavior())
{
ChangeState(AnimalState.Attack);
if (player_target)
character.Attack(player_target);
else if (attack_target)
character.Attack(attack_target);
}
}
private void OnDamagedPlayer(PlayerCharacter player)
{
if (IsDead() || state_timer < 2f)
return;
player_target = player;
attack_target = null;
ReactToThreat();
}
private void OnDamagedBy(Destructible attacker)
{
if (IsDead() || state_timer < 2f)
return;
player_target = null;
attack_target = attacker;
ReactToThreat();
}
这个是动物实例检测到玩家后的行为,如果有逃离状态优先逃离,否则进行攻击,设定检测到的玩家为攻击目标,还加入了2s的状态切换冷却时间。
优化机制
void FixedUpdate()
{
if (TheGame.Get().IsPaused())
return;
// 优化,如果距离太远则不运行
float dist = (TheCamera.Get().GetTargetPos() - transform.position).magnitude;
float active_range = Mathf.Max(detect_range * 2f, selectable.active_range * 0.8f);
is_active = (state != AnimalState.Wander && state != AnimalState.Dead) || character.IsMoving() || dist < active_range;
}
private void Update()
{
// ...
update_timer += Time.deltaTime;
if (update_timer > 0.5f)
{
update_timer = Random.Range(-0.1f, 0.1f);
SlowUpdate(); // 优化
}
// ...
}
private void SlowUpdate()
{
// 非频繁更新的逻辑...
}
首先在FixedUpdate
中基于距离和状态动态设定对象的激活状态(is_active
):当对象处于非活跃状态(如“闲逛”或“死亡”)且远离摄像机时,暂停其逻辑更新;反之,若对象正在移动、处于关键行为状态(如攻击或逃跑),或进入摄像机动态计算的激活范围(active_range
,该范围综合了检测半径和交互阈值),则激活更新流程。
其次,通过Update
中的低频定时器(update_timer
)将非紧急逻辑(如环境感知或目标决策)从每帧调用降为半秒触发,并引入随机时间偏移(Random.Range(-0.1f, 0.1f)
),避免大量对象在同一帧同步执行SlowUpdate
导致的CPU峰值。这种策略既维持了AI响应的实时性,又将高频计算转化为可控的中低频任务,结合动态激活筛选,形成双重性能屏障
建筑与建造
建筑和建造是两个相似但不同的概念:建筑主要指新建场景中的建筑(怎么像废话),而建造则是指新建所有可以使用的物品,包括建筑。
建筑
整个建筑的流程实现是这样的:玩家选择建筑→ Buildable 实时验证位置→确认后调用 Construction.Create() 生成实体。
基本数据结构定义(ConstructionData.cs&&Construction.cs)
ConstructionData 定义了建筑的基本属性(如预制体、耐久度等),而 Construction 实现了建筑的行为(如建造、摧毁等)。
using System.Collections.Generic;
using UnityEngine;
namespace FarmingEngine
{
/// <summary>
/// 建筑数据文件
/// </summary>
[CreateAssetMenu(fileName = "ConstructionData", menuName = "FarmingEngine/ConstructionData", order = 4)]
public class ConstructionData : CraftData
{
[Header("--- ConstructionData ------------------")]
public GameObject construction_prefab; // 构建建筑时生成的预制体
[Header("引用数据")]
public ItemData take_item_data; // 可获取的物品数据
[Header("属性")]
public DurabilityType durability_type; // 耐久类型
public float durability; // 耐久度
// 其他代码...
}
}
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
namespace FarmingEngine
{
/// <summary>
/// 建筑是可以由玩家放置在地图上的对象(通过制作或使用物品)
/// </summary>
[RequireComponent(typeof(Selectable))]
[RequireComponent(typeof(Buildable))]
[RequireComponent(typeof(UniqueID))]
public class Construction : Craftable
{
[Header("Construction")]
public ConstructionData data; // 建筑的数据
[HideInInspector]
public bool was_spawned = false; // 是否通过制作或加载生成
// 当建造完成时调用
private void OnBuild()
{
if (data != null)
{
// 将建筑数据添加到玩家数据中
BuiltConstructionData cdata = PlayerData.Get().AddConstruction(data.id, SceneNav.GetCurrentScene(), transform.position, transform.rotation, data.durability);
unique_id.unique_id = cdata.uid; // 设置唯一标识符
}
}
// 当建筑被摧毁时调用
private void OnDeath()
{
// 从玩家数据中移除建筑数据
PlayerData.Get().RemoveConstruction(GetUID());
// 其他代码...
}
// 其他代码...
}
}
位置验证机制( Buildable.cs )
位置验证是建筑系统的核心安全机制,通过多层检测确保建筑放置的合法性:
// 检查是否可以在当前位置建造
public bool CheckIfCanBuild()
{
bool dont_overlap = !CheckIfOverlap(); // 无碰撞
bool flat_ground = CheckIfFlatGround(); // 地面平坦
bool accessible = CheckIfAccessible(); // 可访问
bool valid_ground = CheckValidFloor(); // 地面有效
return dont_overlap && flat_ground && valid_ground && accessible;
}
这些是我们要检测的内容,包括是否与其他物体有碰撞,地面是否平坦,是否可访问,地面是否支持建筑。
public bool CheckIfOverlap()
{
List<Collider> overlap_colliders = new List<Collider>();
LayerMask olayer = obstacle_layer & ~floor_layer; // 排除地面图层
// 检测边界盒碰撞
foreach (Collider collide in colliders)
{
Collider[] over = Physics.OverlapBox(transform.position, collide.bounds.extents, Quaternion.identity, olayer);
foreach (Collider overlap in over)
{
if (!overlap.isTrigger) // 忽略触发器
overlap_colliders.Add(overlap);
}
}
// 检测半径范围内碰撞
if (build_obstacle_radius > 0.01f)
{
Collider[] over = Physics.OverlapSphere(transform.position, build_obstacle_radius, olayer);
overlap_colliders.AddRange(over);
}
// 过滤有效碰撞
foreach (Collider overlap in overlap_colliders)
{
PlayerCharacter player = overlap.GetComponent<PlayerCharacter>();
Buildable buildable = overlap.GetComponentInParent<Buildable>();
if (player == null && buildable != this) // 排除玩家和自身
return true; // 发现有效碰撞
}
return false;
}
...
...
public bool CheckValidFloor()
{
Vector3 center = transform.position + Vector3.up * build_ground_dist;
// 检测中心点和四个方向点
Vector3[] points = new Vector3[] { center, center + Vector3.right * build_obstacle_radius,
center + Vector3.left * build_obstacle_radius,
center + Vector3.forward * build_obstacle_radius,
center + Vector3.back * build_obstacle_radius };
foreach (Vector3 p in points)
{
RaycastHit hit;
bool has_hit = PhysicsTool.RaycastCollision(p, Vector3.down * (build_ground_dist + build_ground_dist), out hit);
if (has_hit && PhysicsTool.IsLayerInLayerMask(hit.collider.gameObject.layer, floor_layer))
return true; // 至少一个点在有效地面上
}
return false;
}
这是具体的检测逻辑内部实现,通过双重检测确保建筑可安全放置:首先在CheckIfOverlap()
中,通过边界盒(Physics.OverlapBox
)和球形范围(Physics.OverlapSphere
)检测障碍物碰撞,利用图层掩码(obstacle_layer & ~floor_layer
)排除地面干扰,并过滤触发器、玩家角色及自身建筑,只要存在其他有效障碍物即返回碰撞状态(不可建造);其次在CheckValidFloor()
中,通过中心点及四方向偏移点向下发射射线(Physics.Raycast
),验证至少有一个点命中指定地面图层(floor_layer
),确保建筑底部有足够支撑(可建造)。两者结合形成完整建造校验:仅当无碰撞障碍物且有地面支撑时,建筑才可放置。
玩家交互流程( PlayerCharacterCraft.cs )
玩家从选择建筑到确认建造的完整交互链。
public void CraftConstructionBuildMode(ConstructionData item)
{
CancelCrafting();
// 创建建筑预览体
Construction construction = Construction.CreateBuildMode(item, transform.position + transform.forward * 1f);
current_buildable = construction.GetBuildable();
current_buildable.StartBuild(character); // 进入建造模式
current_build_data = item;
clicked_build = false;
}
创建建筑预览体并进入建造模式,允许玩家调整建筑位置。
public void TryBuildAt(Vector3 pos)
{
bool in_range = character.interact_type == PlayerInteractBehavior.MoveAndInteract || IsInBuildRange();
if (!in_range) return;
if (!clicked_build && current_buildable != null)
{
current_buildable.SetBuildPositionTemporary(pos); // 临时设置位置
bool can_build = current_buildable.CheckIfCanBuild(); // 执行位置验证
if (can_build)
{
current_buildable.SetBuildPosition(pos); // 确认位置
clicked_build = true;
character.MoveTo(pos); // 移动到建造位置
}
}
}
临时设置建筑位置并验证,可建造则确认位置并移动玩家。
public void CompleteBuilding(Vector3 pos)
{
CraftData item = current_crafting;
if (item != null && current_buildable != null && CanCraft(item, build_pay_slot, true))
{
current_buildable.SetBuildPositionTemporary(pos);
if (current_buildable.CheckIfCanBuild())
{
PayCraftingCost(item, build_pay_slot, true); // 消耗材料
current_buildable.FinishBuild(); // 完成建造
// 数据记录与反馈
character.SaveData.AddCraftCount(item.id);
character.Attributes.GainXP(item.craft_xp_type, item.craft_xp);
TheAudio.Get().PlaySFX("craft", current_buildable.build_audio);
// 清理状态
current_buildable = null;
current_build_data = null;
clicked_build = false;
}
}
}
消耗材料、完成建造、记录建筑数据到玩家数据并清理状态。
实体创建与持久化( Construction.cs )
最终建筑实体的生成与状态保存:
public static Construction Create(ConstructionData data, Vector3 pos, Quaternion rot)
{
// 实例化预制体
GameObject build = Instantiate(data.construction_prefab, pos, data.construction_prefab.transform.rotation);
build.transform.rotation = rot; // 应用旋转
// 初始化组件
Construction construct = build.GetComponent<Construction>();
construct.data = data;
construct.was_spawned = true;
construct.buildable.FinishBuild(); // 触发建造完成
return construct;
}
实例化建筑预制体并初始化组件,设置建筑数据和唯一ID。
private void OnBuild()
{
if (data != null)
{
// 添加到玩家数据
BuiltConstructionData cdata = PlayerData.Get().AddConstruction(
data.id, SceneNav.GetCurrentScene(), transform.position, transform.rotation, data.durability);
unique_id.unique_id = cdata.uid; // 关联唯一ID
}
}
将建筑数据添加到玩家数据中,实现建筑状态的保存。
建造
建造系统的核心数据结构是 CraftData 类,它是所有可制作物品的父类:
/// <summary>
/// 可制作物品(物品、建筑、植物)的父数据类
/// </summary>
public class CraftData : IdData
{
[Header("显示")]
public string title; // 标题
public Sprite icon; // 图标
public string desc; // 描述
[Header("制作")]
public bool craftable; // 可以制作吗?
public int craft_quantity = 1; // 制作数量
public float craft_duration = 0f; // 制作所需时间
[Header("制作成本")]
public GroupData craft_near; // 制作时需要附近的物体组
public ItemData[] craft_items; // 制作所需物品
public GroupData[] craft_fillers; // 制作所需填充物
public CraftData[] craft_requirements; // 制作前需要建造的物品
// 其他代码...
}
建造系统的工作流程是:检查是否可以建造 -> 支付建造成本 -> 开始建造模式 -> 检查建造位置 -> 完成建造。
public bool CanCraft(CraftData item, bool skip_cost = false, bool skip_near = false)
{
if (item == null || character.IsDead())
return false;
if (character.Attributes.GetAttributeValue(AttributeType.Energy) < craft_energy)
return false; // 能量不足
bool has_craft_cost = skip_cost || HasCraftCost(item);
bool has_near = skip_near || HasCraftNear(item);
return has_near && has_craft_cost;
}
检测是否可以建造对应的物品。
public void PayCraftingCost(CraftData item, bool build = false)
{
CraftCostData cost = item.GetCraftCost();
foreach (KeyValuePair<ItemData, int> pair in cost.craft_items)
{
character.Inventory.UseItem(pair.Key, pair.Value);
}
// 其他代码...
}
支付建造成本。
public void StartCraftingOrBuilding(CraftData data)
{
if (CanCraft(data))
{
ConstructionData construct = data.GetConstruction();
PlantData plant = data.GetPlant();
if (construct != null)
CraftConstructionBuildMode(construct);
else if (plant != null)
CraftPlantBuildMode(plant, 0);
else
StartCrafting(data);
TheAudio.Get().PlaySFX("craft", data.craft_sound);
}
}
开始建造模式。
然后执行和前文中一样的位置验证和实体创建和持久化即可。
采集与合成
采集
采集系统是游戏中让玩家能够从植物或其他资源点获取物品的核心系统。它允许玩家通过特定操作(如点击植物)来收获成熟的果实或其他资源,是游戏中资源获取和循环的重要环节。
可采集的物品
在我们的项目中,有这些可以被采集的类:
挖掘点 (DigSpot)、物品提供者 (ItemProvider)、动物食物 (AnimalFood)、植物 (Plant)、地面上的物品 (Item)。
DigSpot.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace FarmingEngine
{
/// <summary>
/// 可以用铲子挖掘的点,将从可销毁对象中获得战利品
/// </summary>
[RequireComponent(typeof(Destructible))]
public class DigSpot : MonoBehaviour
{
private Destructible destruct; // 可销毁对象引用
private static List<DigSpot> dig_list = new List<DigSpot>(); // 挖掘点列表
void Awake()
{
dig_list.Add(this); // 添加到挖掘点列表
destruct = GetComponent<Destructible>(); // 获取可销毁对象组件
}
public void Dig()
{
destruct.Kill(); // 挖掘操作,销毁可销毁对象
}
}
}
ItemProvider.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace FarmingEngine
{
/// <summary>
/// 定时生成物品,玩家可以拾取。例如鸟巢(生成鸟蛋)或钓鱼点(生成鱼类)等。
/// </summary>
[RequireComponent(typeof(Selectable))]
[RequireComponent(typeof(UniqueID))]
public class ItemProvider : MonoBehaviour
{
[Header("物品生成")]
public float item_spawn_time = 2f; // 游戏时间(小时)
public int item_max = 3; // 最大物品数量
public ItemData[] items; // 可生成的物品数据数组
[Header("物品获取")]
public bool auto_take = true; // 是否允许角色通过点击自动获取物品
}
}
AnimalFood.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace FarmingEngine
{
public class AnimalFood : MonoBehaviour
{
public GroupData food_group; // 食物所属的组数据
private Item item; // 物品组件
private ItemStack stack; // 物品堆叠组件
private Plant plant; // 植物组件
public void EatFood()
{
if (item != null)
item.EatItem(); // 如果存在Item组件,则吃掉物品
if (stack != null)
stack.RemoveItem(1); // 如果存在ItemStack组件,则移除一个物品
if (plant != null)
plant.KillNoLoot(); // 如果存在Plant组件,则摧毁植物但不掉落物品
}
}
}
Plant.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace FarmingEngine
{
/// <summary>
/// 植物可以播种(从种子开始),并且可以收获它们的果实。它们还可以有多个生长阶段。
/// </summary>
[RequireComponent(typeof(Selectable))]
[RequireComponent(typeof(Buildable))]
[RequireComponent(typeof(UniqueID))]
[RequireComponent(typeof(Destructible))]
public class Plant : Craftable
{
[Header("Plant")]
public PlantData data; // 植物数据
public int growth_stage = 0; // 生长阶段
[Header("Harvest")]
public ItemData fruit; // 水果物品数据
public bool death_on_harvest; // 收获后是否死亡
[Header("FX")]
public GameObject gather_fx; // 收获特效
public AudioClip gather_audio; // 收获音效
}
}
Item.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
namespace FarmingEngine
{
/// <summary>
/// 物品是可以被玩家拾取、丢弃并放入物品栏的对象。某些物品还可以用作制作材料或被用于制作。
/// </summary>
[RequireComponent(typeof(Selectable))]
[RequireComponent(typeof(UniqueID))]
public class Item : Craftable
{
[Header("Item")]
public ItemData data; // 物品数据
public int quantity = 1; // 数量
[Header("FX")]
public float auto_collect_range = 0f; // 当在范围内时将自动被收集
public AudioClip take_audio; // 收取时的音频
public GameObject take_fx; // 收取时的特效
public UnityAction onTake; // 收取时的事件
}
}
工作流
采集系统的核心是 Selectable 组件,它是玩家与游戏世界交互的基础。所有可采集的物体都必须挂载这个组件,它定义了物体的交互类型、范围和可用动作。
// ... existing code ...
public enum SelectableType
{
Interact = 0, // 与物体的中心交互
InteractBound = 5, // 与碰撞体包围盒内最近的位置交互
InteractSurface = 10, // 表面交互
CantInteract = 20, // 可以点击但无法交互
CantSelect = 30 // 无法点击或悬停
}
// ... existing code ...
采集系统的工作流程主要包括以下几个步骤:
1. 检测与选择
- 玩家接近可采集物体时, Selectable 组件会检测到玩家
- 物体被高亮显示(通过轮廓效果)
- 玩家点击物体触发交互
2. 交互与采集
- 对于植物,调用 Harvest 方法
- 对于地面物品,调用 TakeItem 方法
- 系统检查玩家是否满足采集条件(如距离、工具等)
3. 反馈与奖励
- 播放采集音效和特效
- 物品被添加到玩家背包
- 采集对象可能会被销毁或重置状态
合成
合成系统的核心作用是允许玩家使用现有资源(物品、材料)通过特定规则和流程创建新的物品、建筑或其他游戏元素,丰富游戏的玩法和 progression 体系。
CraftData.cs - 定义了合成数据的基础结构:
// ... existing code ...
public class CraftData : IdData
{
[Header("显示")]
public string title; // 标题
public Sprite icon; // 图标
[TextArea(3, 5)]
public string desc; // 描述
[Header("分组")]
public GroupData[] groups; // 所属分组
[Header("制作")]
public bool craftable; // 可以制作吗?
public int craft_quantity = 1; // 制作数量
public float craft_duration = 0f; // 制作所需时间
[Header("制作成本")]
public GroupData craft_near; // 制作时需要附近的可选物体组
public ItemData[] craft_items; // 制作所需物品
public GroupData[] craft_fillers; // 制作所需填充物
public CraftData[] craft_requirements; // 制作前需要建造的物品
// ... existing code ...
这段代码定义了 CraftData 类,它是所有可制作物品(如物品、建筑、植物)的基础数据类。代码中:
- 包含了显示相关的属性(标题、图标、描述),用于在UI中展示物品信息;
- 定义了分组属性,方便对物品进行分类管理;
- 包含制作相关的属性(是否可制作、制作数量、制作时间、制作排序),控制物品的制作行为;
- 定义了制作成本相关的属性(所需物品、填充物、先决条件、附近所需物体),用于检查制作条件;
- 包含经验和特效相关的属性,用于奖励玩家和提供反馈;
- 提供了一系列方法,用于检查物品所属分组、获取不同类型的数据、计算制作成本、加载数据等功能。
CraftStation.cs - 实现了工作台功能:
// ... existing code ...
[RequireComponent(typeof(Selectable))]
public class CraftStation : MonoBehaviour
{
public GroupData[] craft_groups; // 可以进行合成的组数据
public float range = 3f; // 工作台的使用范围
private Selectable select; // 可选择组件
private Buildable buildable; // 可建造组件
void Awake()
{
station_list.Add(this);
select = GetComponent<Selectable>();
buildable = GetComponent<Buildable>();
select.onUse += OnUse; // 注册使用事件
}
private void OnUse(PlayerCharacter character)
{
CraftPanel panel = CraftPanel.Get(character.player_id);
if (panel != null && !panel.IsVisible())
panel.Show(); // 显示合成面板
}
// ... existing code ...
这段代码定义了 CraftStation 类,它是工作台的实现类。代码中:
- 包含了可合成的组数据和工作台的使用范围,控制哪些物品可以在该工作台合成以及玩家需要靠近到什么程度;
- 引用了 Selectable 和 Buildable 组件,分别用于处理玩家的选择和交互,以及控制工作台的建造状态;
- 在 Awake 方法中,将自身添加到静态列表中,注册使用事件;
- 在 OnUse 方法中,当玩家使用工作台时,显示合成面板;
- 提供了 HasCrafting 方法检查是否有可合成的组,以及 GetNearestInRange 方法获取范围内最近的工作台。
MixingPanel.cs - 实现了混合面板的 UI 和合成逻辑:
// ... existing code ...
public class MixingPanel : ItemSlotPanel
{
public ItemSlot result_slot; // 结果槽
public Button mix_button; // 合成按钮
/// <summary>
/// 检查是否可以进行混合
/// </summary>
public bool CanMix()
{
bool at_least_one = false;
foreach (ItemSlot slot in slots)
{
if (slot.GetItem() != null)
at_least_one = true; // 至少有一个物品
}
return mixing_pot != null && at_least_one && result_slot.GetItem() == null;
}
/// <summary>
/// 混合物品
/// </summary>
public void MixItems()
{
ItemData item = null;
foreach (ItemData recipe in mixing_pot.recipes)
{
if (item == null && CanCraft(recipe))
{
item = recipe;
PayCraftingCost(recipe); // 支付合成成本
}
}
if (item != null)
{
crafed_item = item;
result_slot.SetSlot(item, 1); // 设置结果槽
}
}
// ... existing code ...
- 包含了结果槽和合成按钮,用于显示合成结果和触发合成操作;
- 引用了当前玩家和混合锅,用于交互和数据处理;
- 在 RefreshPanel 方法中,更新面板的显示状态,包括结果槽和合成按钮的可交互状态;
- 提供了 ShowMixing 方法显示混合面板, CanMix 方法检查是否可以进行混合, MixItems 方法执行混合操作;
- 包含了 HasItem 和 HasItemInGroup 方法检查是否拥有足够的物品或物品组, RemoveItem 和 RemoveItemInGroup 方法移除物品或物品组;
- 提供了 CanCraft 方法检查是否可以合成指定物品, PayCraftingCost 方法支付合成成本;
- 在 OnClickMix 方法中,处理合成按钮的点击事件;在 OnClickResult 方法中,处理结果槽的点击事件,将合成物品添加到玩家背包。
整个合成系统的工作流程如下:
- 接近工作台 :玩家移动到 CraftStation 的有效范围内。
- 使用工作台 :玩家与工作台交互,触发 OnUse 事件,打开合成面板。
- 添加材料 :玩家将所需的材料放入合成面板的槽位中。
- 检查条件 :系统检查材料是否满足合成配方的要求,以及是否满足其他条件(如附近有特定物体)。
- 执行合成 :玩家点击合成按钮,系统消耗材料并生成产品。
- 获取结果 :合成后的产品显示在结果槽中,玩家可以将其拾取到背包中。
天气与时间系统
天气系统
天气系统主要负责管理游戏中的天气状态,包括天气的切换、效果展示以及对游戏世界的影响。
定义了两种天气效果:
- None :无特殊天气效果
- Rain :下雨效果
// ... existing code ...
/// <summary>
/// 天气效果枚举
/// </summary>
public enum WeatherEffect
{
None = 0, // 无效果
Rain = 10, // 下雨
}
/// <summary>
/// 天气数据的ScriptableObject类
/// </summary>
[CreateAssetMenu(fileName ="Weather", menuName = "FarmingEngine/Weather", order =10)]
public class WeatherData : ScriptableObject
{
public string id; // 天气数据的唯一标识符
public float probability = 1f; // 天气发生的概率
[Header("Gameplay")]
public WeatherEffect effect; // 天气效果枚举
[Header("Visuals")]
public GameObject weather_fx; // 天气效果的游戏对象
public float light_mult = 1f; // 光照倍数
}
// ... existing code ...
这个是天气数据的定义。
// ... existing code ...
/// <summary>
/// 将此脚本放置在每个场景中,用于管理该场景中可能的天气列表
/// </summary>
public class WeatherSystem : MonoBehaviour
{
[Header("Weather")]
public WeatherData default_weather; // 默认天气数据
public WeatherData[] weathers; // 可能的天气数据列表
[Header("Weather Group")]
public string group; // 具有相同组的场景将同步天气
[Header("Weather Settings")]
public float weather_change_time = 6f; // 天气变化的时间(游戏时间,以小时为单位)
private WeatherData current_weather; // 当前天气数据
private GameObject current_weather_fx; // 当前天气效果对象
private float update_timer = 0f; // 更新计时器
private static WeatherSystem instance; // 单例实例
void Start()
{
if (PlayerData.Get().HasCustomString("weather_" + group)) // 如果存在保存的天气数据
{
string weather_id = PlayerData.Get().GetCustomString("weather_" + group); // 获取保存的天气ID
ChangeWeather(GetWeather(weather_id)); // 更改为保存的天气
}
else
{
ChangeWeather(default_weather); // 否则更改为默认天气
}
}
void Update()
{
update_timer += Time.deltaTime; // 更新计时器
if (update_timer > 1f) // 每秒更新一次
{
update_timer = 0f; // 重置计时器
SlowUpdate(); // 慢速更新,检查是否新的一天或天气变化时间到了
}
}
void SlowUpdate()
{
// 检查是否新的一天
int day = PlayerData.Get().day; // 获取当前天数
float time = PlayerData.Get().day_time; // 获取当前游戏时间(小时)
int prev_day = PlayerData.Get().GetCustomInt("weather_day_" + group); // 获取上次保存的天数
if (day > prev_day && time >= weather_change_time) // 如果当前天数大于上次保存的天数且当前时间超过天气变化时间
{
ChangeWeatherRandom(); // 随机更改天气
PlayerData.Get().SetCustomInt("weather_day_" + group, day); // 保存当前天数
}
}
// 随机更改天气
public void ChangeWeatherRandom()
{
if (weathers.Length > 0) // 如果有定义的天气数据
{
float total = 0f;
foreach (WeatherData aweather in weathers)
{
total += aweather.probability; // 计算总概率
}
float value = Random.Range(0f, total); // 随机一个值
WeatherData weather = null;
foreach (WeatherData aweather in weathers)
{
if (weather == null && value < aweather.probability)
weather = aweather; // 根据随机值选取天气数据
else
value -= aweather.probability; // 减去当前天气数据的概率
}
if (weather == null)
weather = default_weather; // 如果未选取到天气数据,则选择默认天气
ChangeWeather(weather); // 更改天气
}
}
// 更改天气
public void ChangeWeather(WeatherData weather)
{
if (weather != null && current_weather != weather) // 如果新的天气不为空且与当前天气不同
{
current_weather = weather; // 设置当前天气为新的天气
PlayerData.Get().SetCustomString("weather_" + group, weather.id); // 保存当前天气ID
if (current_weather_fx != null)
Destroy(current_weather_fx); // 销毁当前天气效果对象
if (current_weather.weather_fx != null)
current_weather_fx = Instantiate(current_weather.weather_fx, TheCamera.Get().GetTargetPos(), Quaternion.identity); // 实例化新的天气效果对象
}
}
// ... existing code ...
使用 WeatherData 类定义天气的基本属性,包括ID、概率、效果、视觉效果和光照倍数;WeatherSystem 类负责管理天气的切换和状态维护,采用单例模式方便全局访问。天气会在每天的特定时间(默认6点)自动切换,切换时根据天气的概率随机选择下一个天气。天气状态会被保存到 PlayerData 中,确保游戏重启后保持一致。
天气系统的工作流:
1. 初始化 :游戏启动时,从 PlayerData 中加载保存的天气状态,如果没有则使用默认天气。
2. 状态更新 :每秒更新一次,检查是否进入新的一天且时间超过天气变化时间。
3. 天气切换 :如果满足天气切换条件,根据天气概率随机选择下一个天气,并更新相关状态。
4. 效果应用 :切换天气后,销毁旧的视觉效果,创建新的效果,并调整场景的光照强度。
5. 状态保存 :天气切换后,将新的天气状态保存到 PlayerData 中
时间系统
时间系统负责管理游戏中的时间流逝、昼夜交替以及时间显示。
// ... existing code ...
/// <summary>
/// 通用游戏数据(仅一个文件)
/// </summary>
[CreateAssetMenu(fileName = "GameData", menuName = "FarmingEngine/GameData", order = 0)]
public class GameData : ScriptableObject
{
[Header("游戏时间")]
public float game_time_mult = 24f; // 值为1表示游戏时间与现实时间同步,值为24表示1小时现实时间对应1天游戏时间
public float start_day_time = 6f; // 开始一天的时间
public float end_day_time = 2f; // 自动过到下一天的时间
[Header("昼夜变化")]
public float day_light_dir_intensity = 1f; // 白天方向光强度
public float day_light_ambient_intensity = 1f; // 白天环境光强度
public float night_light_dir_intensity = 0.2f; // 夜晚方向光强度
public float night_light_ambient_intensity = 0.5f; // 夜晚环境光强度
public bool rotate_shadows = true; // 是否根据白天转动阴影
// ... existing code ...
通过group
分组标识同组场景共享天气配置,每日固定时间点(weather_change_time
)按概率权重随机切换天气(如晴天60%、雨天30%),并利用PlayerData
保存天气ID("weather_"+group
)及上次变更日期("weather_day_"+group
),确保玩家重新进入时天气一致;同时,切换天气时销毁旧特效(如雨雪粒子),在相机位置实例化新天气对应的预制体(weather_fx
),实现视觉与逻辑的实时同步
// ... existing code ...
/// <summary>
/// 显示天数和时间的时钟
/// </summary>
public class TimeClockUI : MonoBehaviour
{
public Text day_txt; // 显示天数的文本
public Text time_txt; // 显示时间的文本
public Image clock_fill; // 时钟填充图像
void Update()
{
// 获取玩家数据
PlayerData pdata = PlayerData.Get();
// 计算小时数和秒数
int time_hours = Mathf.FloorToInt(pdata.day_time);
int time_secs = Mathf.FloorToInt((pdata.day_time * 60f) % 60f);
// 更新天数和时间的文本
day_txt.text = "DAY " + pdata.day;
time_txt.text = time_hours + ":" + time_secs.ToString("00");
// 判断时钟方向(顺时针或逆时针)
bool clockwise = pdata.day_time <= 12f;
clock_fill.fillClockwise = clockwise;
if (clockwise)
{
// 顺时针填充:从0到1
float value = pdata.day_time / 12f;
clock_fill.fillAmount = value;
}
else
{
// 逆时针填充:从1到0
float value = (pdata.day_time - 12f) / 12f;
clock_fill.fillAmount = 1f - value;
}
}
}
// ... existing code ...
这个是负责显示时间UI的代码。从PlayerData
获取游戏天数(day
)和时间(day_time
),将时间拆分为小时和分钟(例如 8:30
),并以 "DAY X"
格式显示天数,时间文本精确到分钟(自动补零)
通过Image
组件的圆形填充(clock_fill
)模拟昼夜循环:
- 正午前(≤12时):顺时针填充(
fillClockwise=true
),填充比例 =day_time/12
,表示从凌晨到正午的进度。 - 正午后(>12时):逆时针填充(
fillClockwise=false
),填充比例 =1 - (day_time-12)/12
,表示从正午到午夜的进度,形成完整的24小时循环效果。