Вводные: всегда хотел работать в геймдеве, поэтому решил начать с малого — попробовать сделать хоть какой‑то прототип игры с нуля. С C# знаком на среднем уровне, с блендером на нулевом), с Unity чуть‑чуть (делал тетрис, и пытался сделать мультиплеер для него поверх Steam через Spacewar).
Изначально идея была сделать что‑то на минут 10–15, как обычно в голове много идей и мелочей, реализация которых поможет создать более приятный геймплей, но когда доходит дело до реализации, то приходит понимание, что не все так легко, как кажется.
Выбор пал на создание какой‑нибудь простенькой хоррор игры, которую необходимо пройти, т.к. опыта в Blender у меня нет, я решил сделать что‑то максимально простое, поэтому я посмотрел пару гайдов на ютубе и начал «творить».
Решил сделать все в виде тайлов, из которых затем соберу комнаты и коридоры между ними, больше всего времени я потратил на создание модели врага, которая будет бегать за игроком.
Стена была сделана из нескольких плоскостей, на которые была натянута текстура кирпичной кладки, предварительно пикселизованная.

В качестве врага решил сделать скелета, на которого потом натянул ткань (как же с ней весело работать — нет), т.к. это первый опыт прошу не судить строго)


Также сделал модельку алтаря и кубика на нем, которые необходимо будет подбирать игроку, чтобы пройти игру
Затем, когда все модельки были готовы, приступил к созданию комнат. Решил построить несколько видов комнат и префаб коридора, который позволит потом связывать их между собой, изначально сделал 3 вида комнат, но потом решил остановиться на двух — пустая и та, в которой есть алтарь с кубиком.



Затем приступил к созданию процедурной генерации для комнат. Алгоритм прост — у нас есть стартовая комната, которая всегда одинакова, у нее есть 4 выхода, задаем количество комнат с алтарями и комнат без них, затем случайным образом выбираем незанятый выход из комнаты и ставим там коридор, а на конце коридора комнату, затем повторяем так, пока не будут расставлены все комнаты.
После этого необходимо расставить комнаты с алтарями, решил поставить их как можно дальше от игрока, но получалось так, что комнаты с алтарями могли находится друг около друга в случае, если там находятся единственные незанятые выходы, поэтому добавил проверку на дистанцию до ближайшего алтаря, это помогло решить проблему. Если в конце оставались незанятые выходы, то они просто закрывались стеной, из которой и состоит комната.
В процессе столкнулся с багом, который не смог решить, уже потом обдумывал как можно было бы сделать систему генерации более гибкой изначально, чтобы избежать его, на будущее буду знать. Баг заключался в том, что комнаты могли появится друг в друге, если две комнаты своими выходами смотрят друг на друга, а затем обе используют свои выходы для генерации соседней комнаты.



Код генерации:
В листинге ниже представлен скрипт, ответственный за генерацию подземелья, особо впечатлительным лучше не смотреть, там все «тяп‑ляп».
using Assets.Scipts;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using static UnityEngine.GraphicsBuffer;
public class LevelGenerator : MonoBehaviour
{
public GameObject CrossRoomPrefab;
public GameManager gameManager;
public GameObject AltarRoomPrefab;
public GameObject CorridorPrefab;
public GameObject EmptyRoomPrefab;
public GameObject WallExitPrefab;
public GameObject Player;
public int NumberOfRooms = 5;
public int NumberOfAltarRooms = 2;
private int AlreadyPickupedRunes = 0;
public double RadiusToCheckExistingOfAltars = 15;
public Vector3 StartPosition = Vector3.zero;
public event Action AllRunesCollectedEvent;
public event Action OneRuneCollectedEvent;
private List<GameObject> placedRooms = new List<GameObject>();
private List<RoomInfo> roomInfos = new List<RoomInfo>();
public List<Vector3> AltarRoomPositions = new List<Vector3>();
public void GenerateLevel()
{
GameObject startRoom = Instantiate(EmptyRoomPrefab, StartPosition, Quaternion.identity);
placedRooms.Add(startRoom);
roomInfos.Add(startRoom.GetComponent<RoomInfo>());
PlaceEmptyRooms();
PlaceAltarRooms();
CloseFreeExists();
}
RoomInfo FindFirstRoomWithFreeExit()
{
for (int i = 0; i < roomInfos.Count; i++)
{
if (roomInfos[i].FreeExits.Count > 0)
{
return roomInfos[i];
}
}
return null;
}
void OccupieExit(RoomInfo roomInfo, GameObject objectToOccupie)
{
roomInfo.OccupiedExits.Add(objectToOccupie);
roomInfo.FreeExits.Remove(objectToOccupie);
}
Transform FindFarthestFreeExitFromPoint(Vector3 pos)
{
Transform farthestExit = null;
float maxDistance = float.MinValue;
for (int i = 0; i < roomInfos.Count; i++)
{
for (int j = 0; j < roomInfos[i].FreeExits.Count; j++)
{
Transform freeExitTransform = roomInfos[i].FreeExits[j].transform;
float distance = Vector3.Distance(pos, freeExitTransform.position);
if (distance > maxDistance && !IsPositionTooCloseToAltarRooms(freeExitTransform.position))
{
maxDistance = distance;
farthestExit = roomInfos[i].FreeExits[j].transform;
}
}
}
return farthestExit;
}
bool IsPositionTooCloseToAltarRooms(Vector3 position)
{
foreach (Vector3 altarPosition in AltarRoomPositions)
{
if (Vector3.Distance(position, altarPosition) < RadiusToCheckExistingOfAltars)
{
return true;
}
}
return false;
}
void PlaceEmptyRooms()
{
for (int i = 0; i < NumberOfRooms; i++)
{
Transform oldExit = FindFirstRandomRoomWithFreeExit();
if (oldExit == null)
{
break;
}
Transform corridorExit;
PlaceCorridor(oldExit, out corridorExit);
GameObject newRoom = Instantiate(EmptyRoomPrefab);
Transform newRoomEntrance = newRoom.GetComponent<RoomInfo>().FreeExits.First().transform;
Quaternion roomRotation = Quaternion.FromToRotation(-newRoomEntrance.forward, corridorExit.forward);
newRoom.transform.rotation = roomRotation;
newRoom.transform.position = corridorExit.position - (newRoom.transform.rotation * newRoomEntrance.localPosition);
RoomInfo newRoomInfo = newRoom.GetComponent<RoomInfo>();
placedRooms.Add(newRoom);
roomInfos.Add(newRoomInfo);
OccupieExit(newRoomInfo, newRoomEntrance.gameObject);
OccupieExit(corridorExit.GetComponentInParent<RoomInfo>(), corridorExit.gameObject);
}
}
void PlaceAltarRooms()
{
for (int i = 0; i < NumberOfAltarRooms; i++)
{
Transform farthestExit = FindFarthestFreeExitFromPoint(Vector3.zero);
if (farthestExit == null)
{
Debug.LogWarning("Нет доступных выходов для размещения комнаты с алтарём.");
break;
}
Transform corridorExit;
PlaceCorridor(farthestExit, out corridorExit);
Transform newRoomEntrance = AltarRoomPrefab.GetComponent<RoomInfo>().FreeExits.First().transform;
Quaternion rotationDifference = Quaternion.FromToRotation(-newRoomEntrance.forward, corridorExit.forward);
Vector3 newPosition = corridorExit.position - (rotationDifference * newRoomEntrance.localPosition);
GameObject newRoom = Instantiate(AltarRoomPrefab, newPosition, rotationDifference * AltarRoomPrefab.transform.rotation);
RoomInfo newRoomInfo = newRoom.GetComponent<RoomInfo>();
placedRooms.Add(newRoom);
roomInfos.Add(newRoomInfo);
AltarRoomPositions.Add(newPosition);
//OccupieExit(newRoomInfo, newRoomEntrance.gameObject);
OccupieExit(newRoomInfo, newRoom.GetComponent<RoomInfo>().FreeExits.First().transform.gameObject);
OccupieExit(corridorExit.GetComponentInParent<RoomInfo>(), corridorExit.gameObject);
}
}
void PlaceCorridor(Transform startPosition, out Transform otherExitFromCorridor)
{
GameObject corridor = Instantiate(CorridorPrefab);
Transform corridorEntrance = corridor.GetComponent<RoomInfo>().FreeExits.First().transform;
Quaternion corridorRotation = Quaternion.FromToRotation(-corridorEntrance.forward, startPosition.forward);
corridor.transform.rotation = corridorRotation;
corridor.transform.position = startPosition.position - (corridor.transform.rotation * corridorEntrance.localPosition);
RoomInfo corridorInfo = corridor.GetComponent<RoomInfo>();
placedRooms.Add(corridor);
roomInfos.Add(corridorInfo);
OccupieExit(corridorInfo, corridorEntrance.gameObject);
OccupieExit(startPosition.GetComponentInParent<RoomInfo>(), startPosition.gameObject);
Transform corridorExit = corridorInfo.FreeExits.First().transform;
otherExitFromCorridor = corridorExit;
}
Transform FindFirstRandomRoomWithFreeExit()
{
List<GameObject> exits = new List<GameObject>();
exits = roomInfos.Where(x => x.FreeExits.Count > 0).SelectMany(x => x.FreeExits).ToList();
if (exits.Count != 0)
{
System.Random rnd = new System.Random();
return exits.ElementAt(rnd.Next(exits.Count)).transform;
}
return null;
}
void CloseFreeExists()
{
List<GameObject> exits = new List<GameObject>();
exits = roomInfos.Where(x => x.FreeExits.Count > 0).SelectMany(x => x.FreeExits).ToList();
for (int i = 0; i < exits.Count; i++)
{
GameObject wall = Instantiate(WallExitPrefab);
Quaternion wallRotation; //= Quaternion.FromToRotation(-wall.transform.forward, exits[i].transform.forward);
wallRotation = Quaternion.LookRotation(-exits[i].transform.forward, Vector3.up);
wall.transform.rotation = wallRotation;
wall.transform.position = exits[i].transform.position;
OccupieExit(exits[i].GetComponentInParent<RoomInfo>(), wall);
}
}
void RuneWasPickuped()
{
AlreadyPickupedRunes++;
OneRuneCollectedEvent?.Invoke();
if (AlreadyPickupedRunes >= NumberOfAltarRooms)
{
AllRunesCollectedEvent?.Invoke();
}
}
public void SubscribeOnPlayer(Movement movement)
{
movement.RunePickuped += RuneWasPickuped;
}
void OnDrawGizmos()
{
foreach (var room in placedRooms)
{
Collider col = room.GetComponent<Collider>();
if (col == null) continue;
Gizmos.color = Color.red;
Gizmos.DrawWireCube(col.bounds.center, col.bounds.size);
}
}
bool IsOverlappingWithOtherRooms(GameObject newRoom)
{
Collider newCol = newRoom.GetComponent<Collider>();
if (newCol == null) return false;
Bounds newBounds = newCol.bounds;
foreach (GameObject placed in placedRooms)
{
if (placed.tag != "Room") continue;
Collider col = placed.GetComponent<Collider>();
if (col == null) continue;
Bounds placedBounds = col.bounds;
if (newBounds.Intersects(placedBounds))
{
return true;
}
}
return false;
}
}
При подборе игроком первого кубика через несколько секунд происходит спавн врага за спиной игрока, и издание им соответствующего крика, затем игроку необходимо собрать оставшиеся кубики, пока за ним гонится враг, изначально я хотел сделать так, чтобы при подборе каждого кубика игроку было бы все сложнее убегать, появлялись различные эффекты, по типу замедления игрока или появления различных препятствий, возможно небольших скримеров, но решил не заморачиваться, и просто сделал так, что когда игрок подбирает последний кубик, то враг увеличивает свою скорость и быстро приближается к игроку, далее два варианта — либо смерть, либо убийство врага с помощью револьвера (взял из интернета бесплатный ассет старого револьвера, чтобы не сильно выбивался из стиля), который мы получаем после подбора последнего кубика. (Думал сделать что‑то в более магической тематике, чтобы у нас был не револьвер, а условная книга, с заклинанием, которое позволит нам выйти победителем, но отбросил эту идею, т.к. не смог бы реализовать само заклинание на +‑ сносном визуальном уровне)
В процессе работы много вопросов возникало по части работы с модельками, проблемы с экспортом в Unity и т. д., с кодом было попроще.

Вывод: модельки — это тяжело, необходимо либо делать проект в паре с кем‑то, кто в этом разбирается, либо найти хороший пак, который лишит нас радости творчества всего созданного в проекте, но сэкономит кучу времени.
В целом, я рад, что попробовал сделать что‑то подобное и довел это до, по моему мнению, уровня «пойдет», надеюсь, что в будущем будет получатся лучше и лучше. Если вдруг будет интересно скачать игру и побегать, то вот ссылка на itch.io
Спасибо за прочтение!