← Back to list

unity-2d-farm-rpg
by henboffman
⭐ 1🍴 0📅 Jan 21, 2026
SKILL.md
name: unity-2d-farm-rpg description: Specialized guidance for developing 2D farming RPG games in Unity with C#. Use when working on farm simulation mechanics, crop systems, NPC interactions, inventory management, day/night cycles, seasonal systems, tile-based worlds, or any 2D farm RPG game development in Unity. Covers ScriptableObjects, state machines, save systems, UI patterns, and pixel-perfect rendering.
Unity 2D Farm RPG Development
Guide for building 2D farming RPG games in Unity using C#.
Project Architecture
Recommended Folder Structure
Assets/
├── Scripts/
│ ├── Core/ # Managers, singletons, game state
│ ├── Player/ # Movement, interaction, stamina
│ ├── Farming/ # Crops, soil, tools, watering
│ ├── Inventory/ # Items, storage, equipment
│ ├── NPCs/ # Dialogue, relationships, schedules
│ ├── Time/ # Day/night, seasons, calendar
│ ├── UI/ # Menus, HUD, tooltips
│ ├── World/ # Tilemap, weather, environment
│ └── Save/ # Persistence, serialization
├── ScriptableObjects/
│ ├── Items/ # Item definitions
│ ├── Crops/ # Crop data
│ └── NPCs/ # Character data
├── Prefabs/
├── Sprites/
├── Tilemaps/
└── Resources/
Core Systems
ScriptableObject Pattern for Data
Use ScriptableObjects for all game data:
[CreateAssetMenu(fileName = "New Crop", menuName = "Farm/Crop Data")]
public class CropData : ScriptableObject
{
public string cropName;
public Sprite[] growthStages;
public int daysToGrow;
public Season[] validSeasons;
public ItemData harvestItem;
public int harvestYield = 1;
public bool regrows;
public int regrowDays;
}
[CreateAssetMenu(fileName = "New Item", menuName = "Farm/Item Data")]
public class ItemData : ScriptableObject
{
public string itemName;
public Sprite icon;
public ItemType itemType;
public int stackSize = 99;
public int sellPrice;
public int buyPrice;
[TextArea] public string description;
}
Time Manager
public class TimeManager : MonoBehaviour
{
public static TimeManager Instance { get; private set; }
public event Action<int> OnHourChanged;
public event Action OnDayChanged;
public event Action<Season> OnSeasonChanged;
[SerializeField] private float secondsPerGameMinute = 0.5f;
public int CurrentHour { get; private set; }
public int CurrentDay { get; private set; }
public Season CurrentSeason { get; private set; }
public int CurrentYear { get; private set; }
private float minuteTimer;
private int currentMinute;
void Update()
{
minuteTimer += Time.deltaTime;
if (minuteTimer >= secondsPerGameMinute)
{
minuteTimer = 0;
AdvanceMinute();
}
}
private void AdvanceMinute()
{
currentMinute++;
if (currentMinute >= 60)
{
currentMinute = 0;
CurrentHour++;
OnHourChanged?.Invoke(CurrentHour);
if (CurrentHour >= 24) AdvanceDay();
}
}
private void AdvanceDay()
{
CurrentHour = 6; // Wake up time
CurrentDay++;
OnDayChanged?.Invoke();
if (CurrentDay > 28)
{
CurrentDay = 1;
AdvanceSeason();
}
}
}
Crop Growth System
public class CropTile : MonoBehaviour
{
private CropData cropData;
private int currentGrowthStage;
private int daysSincePlanted;
private bool isWatered;
private SpriteRenderer spriteRenderer;
public void Plant(CropData data)
{
cropData = data;
currentGrowthStage = 0;
daysSincePlanted = 0;
UpdateSprite();
}
public void OnDayAdvance()
{
if (cropData == null) return;
if (isWatered)
{
daysSincePlanted++;
UpdateGrowthStage();
}
isWatered = false;
}
public void Water()
{
isWatered = true;
// Visual feedback for watered soil
}
private void UpdateGrowthStage()
{
int stagesCount = cropData.growthStages.Length;
int daysPerStage = cropData.daysToGrow / stagesCount;
int newStage = Mathf.Min(daysSincePlanted / daysPerStage, stagesCount - 1);
if (newStage != currentGrowthStage)
{
currentGrowthStage = newStage;
UpdateSprite();
}
}
public bool CanHarvest => currentGrowthStage >= cropData.growthStages.Length - 1;
public ItemData Harvest()
{
if (!CanHarvest) return null;
var item = cropData.harvestItem;
if (cropData.regrows)
{
currentGrowthStage = cropData.growthStages.Length - 2;
daysSincePlanted = cropData.daysToGrow - cropData.regrowDays;
}
else
{
cropData = null;
}
UpdateSprite();
return item;
}
}
Inventory System
[System.Serializable]
public class InventorySlot
{
public ItemData item;
public int quantity;
public bool CanAddItem(ItemData newItem, int amount)
{
if (item == null) return true;
return item == newItem && quantity + amount <= item.stackSize;
}
}
public class InventoryManager : MonoBehaviour
{
public static InventoryManager Instance { get; private set; }
public event Action OnInventoryChanged;
[SerializeField] private int inventorySize = 36;
private InventorySlot[] slots;
public bool AddItem(ItemData item, int quantity = 1)
{
// First try to stack with existing
for (int i = 0; i < slots.Length; i++)
{
if (slots[i].item == item && slots[i].quantity < item.stackSize)
{
int canAdd = item.stackSize - slots[i].quantity;
int toAdd = Mathf.Min(canAdd, quantity);
slots[i].quantity += toAdd;
quantity -= toAdd;
if (quantity <= 0)
{
OnInventoryChanged?.Invoke();
return true;
}
}
}
// Then find empty slots
for (int i = 0; i < slots.Length; i++)
{
if (slots[i].item == null)
{
slots[i].item = item;
slots[i].quantity = Mathf.Min(quantity, item.stackSize);
quantity -= slots[i].quantity;
if (quantity <= 0)
{
OnInventoryChanged?.Invoke();
return true;
}
}
}
OnInventoryChanged?.Invoke();
return quantity <= 0;
}
public bool RemoveItem(ItemData item, int quantity = 1)
{
int remaining = quantity;
for (int i = slots.Length - 1; i >= 0 && remaining > 0; i--)
{
if (slots[i].item == item)
{
int toRemove = Mathf.Min(slots[i].quantity, remaining);
slots[i].quantity -= toRemove;
remaining -= toRemove;
if (slots[i].quantity <= 0)
slots[i].item = null;
}
}
OnInventoryChanged?.Invoke();
return remaining <= 0;
}
}
Player Systems
Grid-Based Movement
public class PlayerMovement : MonoBehaviour
{
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private LayerMask collisionLayer;
private Vector2 movement;
private Rigidbody2D rb;
private Animator animator;
private Vector2 lastDirection = Vector2.down;
void Update()
{
movement.x = Input.GetAxisRaw("Horizontal");
movement.y = Input.GetAxisRaw("Vertical");
if (movement != Vector2.zero)
lastDirection = movement.normalized;
UpdateAnimation();
}
void FixedUpdate()
{
rb.MovePosition(rb.position + movement.normalized * moveSpeed * Time.fixedDeltaTime);
}
private void UpdateAnimation()
{
animator.SetFloat("MoveX", movement.x);
animator.SetFloat("MoveY", movement.y);
animator.SetFloat("LastMoveX", lastDirection.x);
animator.SetFloat("LastMoveY", lastDirection.y);
animator.SetBool("IsMoving", movement.sqrMagnitude > 0);
}
public Vector3Int GetFacingTilePosition()
{
Vector3 facingPos = transform.position + (Vector3)lastDirection;
return Vector3Int.FloorToInt(facingPos);
}
}
Tool Usage System
public class ToolController : MonoBehaviour
{
[SerializeField] private Tilemap soilTilemap;
[SerializeField] private TileBase tilledSoilTile;
private PlayerMovement playerMovement;
private StaminaSystem stamina;
public void UseTool(ToolType tool)
{
Vector3Int targetTile = playerMovement.GetFacingTilePosition();
switch (tool)
{
case ToolType.Hoe:
TillSoil(targetTile);
break;
case ToolType.WateringCan:
WaterCrop(targetTile);
break;
case ToolType.Scythe:
HarvestCrop(targetTile);
break;
case ToolType.Axe:
ChopTree(targetTile);
break;
case ToolType.Pickaxe:
BreakRock(targetTile);
break;
}
}
private void TillSoil(Vector3Int position)
{
if (!stamina.UseStamina(2)) return;
// Check if tillable ground
soilTilemap.SetTile(position, tilledSoilTile);
// Play animation and sound
}
}
NPC & Dialogue Systems
Dialogue System
[CreateAssetMenu(fileName = "New Dialogue", menuName = "Farm/Dialogue")]
public class DialogueData : ScriptableObject
{
public string speakerName;
public Sprite portrait;
[TextArea(3, 5)] public string[] lines;
public DialogueChoice[] choices; // Optional branching
}
[System.Serializable]
public class DialogueChoice
{
public string choiceText;
public DialogueData nextDialogue;
public int friendshipChange;
}
public class DialogueManager : MonoBehaviour
{
public static DialogueManager Instance { get; private set; }
public event Action OnDialogueStart;
public event Action OnDialogueEnd;
private Queue<string> currentLines;
private DialogueData currentDialogue;
public void StartDialogue(DialogueData dialogue)
{
currentDialogue = dialogue;
currentLines = new Queue<string>(dialogue.lines);
OnDialogueStart?.Invoke();
DisplayNextLine();
}
public void DisplayNextLine()
{
if (currentLines.Count == 0)
{
EndDialogue();
return;
}
string line = currentLines.Dequeue();
// Update UI with line
}
private void EndDialogue()
{
OnDialogueEnd?.Invoke();
}
}
NPC Schedule System
[System.Serializable]
public class ScheduleEntry
{
public int hour;
public Vector3 destination;
public string animation;
}
public class NPCSchedule : MonoBehaviour
{
[SerializeField] private ScheduleEntry[] weekdaySchedule;
[SerializeField] private ScheduleEntry[] weekendSchedule;
private NPCMovement movement;
void Start()
{
TimeManager.Instance.OnHourChanged += CheckSchedule;
}
private void CheckSchedule(int hour)
{
var schedule = IsWeekend() ? weekendSchedule : weekdaySchedule;
foreach (var entry in schedule)
{
if (entry.hour == hour)
{
movement.MoveTo(entry.destination);
break;
}
}
}
}
Save System
Save Data Structure
[System.Serializable]
public class GameSaveData
{
public PlayerSaveData player;
public List<CropSaveData> crops;
public List<InventorySlotSaveData> inventory;
public TimeSaveData time;
public Dictionary<string, int> npcFriendships;
}
[System.Serializable]
public class CropSaveData
{
public Vector3Int position;
public string cropId;
public int growthStage;
public int daysSincePlanted;
public bool isWatered;
}
JSON Save Manager
public class SaveManager : MonoBehaviour
{
private string SavePath => Path.Combine(Application.persistentDataPath, "save.json");
public void SaveGame()
{
var data = new GameSaveData
{
player = GetPlayerData(),
crops = GetCropData(),
inventory = GetInventoryData(),
time = GetTimeData(),
npcFriendships = GetFriendshipData()
};
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(SavePath, json);
}
public void LoadGame()
{
if (!File.Exists(SavePath)) return;
string json = File.ReadAllText(SavePath);
var data = JsonUtility.FromJson<GameSaveData>(json);
ApplyPlayerData(data.player);
ApplyCropData(data.crops);
ApplyInventoryData(data.inventory);
ApplyTimeData(data.time);
ApplyFriendshipData(data.npcFriendships);
}
}
UI Patterns
Inventory UI with Drag & Drop
public class InventorySlotUI : MonoBehaviour, IPointerClickHandler,
IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler
{
[SerializeField] private Image itemIcon;
[SerializeField] private TextMeshProUGUI quantityText;
private int slotIndex;
private InventorySlot slotData;
public void UpdateSlot(InventorySlot data, int index)
{
slotData = data;
slotIndex = index;
if (data.item != null)
{
itemIcon.sprite = data.item.icon;
itemIcon.enabled = true;
quantityText.text = data.quantity > 1 ? data.quantity.ToString() : "";
}
else
{
itemIcon.enabled = false;
quantityText.text = "";
}
}
public void OnBeginDrag(PointerEventData eventData)
{
if (slotData.item == null) return;
DragManager.Instance.StartDrag(this, slotIndex);
}
public void OnDrop(PointerEventData eventData)
{
DragManager.Instance.TrySwap(slotIndex);
}
}
Pixel-Perfect Setup
Camera Configuration
public class PixelPerfectSetup : MonoBehaviour
{
void Start()
{
// Ensure PixelPerfectCamera component is attached
var pixelPerfect = GetComponent<PixelPerfectCamera>();
pixelPerfect.assetsPPU = 16; // Match your sprite PPU
pixelPerfect.refResolutionX = 320;
pixelPerfect.refResolutionY = 180;
pixelPerfect.upscaleRT = true;
pixelPerfect.pixelSnapping = true;
}
}
Common Patterns
Event-Driven Architecture
Use UnityEvents or C# events for loose coupling:
// In TimeManager
public event Action<Season> OnSeasonChanged;
// In CropManager
void Start()
{
TimeManager.Instance.OnSeasonChanged += HandleSeasonChange;
}
void OnDestroy()
{
TimeManager.Instance.OnSeasonChanged -= HandleSeasonChange;
}
Object Pooling for Crops/Items
public class CropPool : MonoBehaviour
{
[SerializeField] private GameObject cropPrefab;
[SerializeField] private int initialPoolSize = 50;
private Queue<GameObject> pool;
public GameObject Get()
{
if (pool.Count == 0)
return Instantiate(cropPrefab);
var obj = pool.Dequeue();
obj.SetActive(true);
return obj;
}
public void Return(GameObject obj)
{
obj.SetActive(false);
pool.Enqueue(obj);
}
}
Reference Files
- references/tilemap-setup.md - Tilemap configuration and rule tiles
- references/animation-trees.md - Animator controller setup for 4-directional sprites
- references/seasons-weather.md - Seasonal and weather system implementation
Score
Total Score
40/100
Based on repository quality metrics
✓SKILL.md
SKILL.mdファイルが含まれている
+20
○LICENSE
ライセンスが設定されている
0/10
○説明文
100文字以上の説明がある
0/10
○人気
GitHub Stars 100以上
0/15
○最近の活動
3ヶ月以内に更新がある
0/10
○フォーク
10回以上フォークされている
0/5
✓Issue管理
オープンIssueが50未満
+5
✓言語
プログラミング言語が設定されている
+5
○タグ
1つ以上のタグが設定されている
0/5
Reviews
💬
Reviews coming soon