Рубрика развивается при поддержке

Hyper Casual игра на Unity с нуля. #2 Первый уровень

В закладки
Аудио

Уровень будет состоять из ячеек - пустых (пол, который нужно закрасить) и стен. Первым делом создадим enum с этими типами.

_/Scripts/Dynamic/CellType.cs:

namespace AwesomeCompany.RollerSplat.Dynamic { public enum CellType { Floor, Wall } }

Затем создадим класс Level, в котором будет информация об ячейках и стартовой позиции шара.

_/Scripts/Dynamic/Level.cs:

using UnityEngine; namespace AwesomeCompany.RollerSplat.Dynamic { public class Level { public CellType[,] cells { get; } public Vector2Int spawnPoint { get; } public Level(CellType[,] cells, Vector2Int spawnPoint) { this.cells = cells; this.spawnPoint = spawnPoint; } } }

Далее создадим класс, который будет хранить все динамические данные игры (пока что только информацию об уровнях), но сделаем его через интерфейс. Это позволит в дальнейшем легко подменить класс и, например, загружать данные с сервера.

_/Scripts/Dynamic/DataProviders/IDynamicDataProvider.cs:

using System.Collections.Generic; namespace AwesomeCompany.RollerSplat.Dynamic { public interface IDynamicDataProvider { List<Level> levels { get; } } }

Создадим тестового провайдера. Добавим в него один уровень 5x5 со стартовой позицией шара снизу слева.

_/Scripts/Dynamic/DataProviders/DebugDynamicDataProvider.cs:

using System.Collections.Generic; using UnityEngine; namespace AwesomeCompany.RollerSplat.Dynamic { public class DebugDynamicDataProvider : IDynamicDataProvider { public List<Level> levels { get; } public DebugDynamicDataProvider() { var cells = new[,] { {CellType.Wall, CellType.Wall, CellType.Wall, CellType.Wall, CellType.Wall}, {CellType.Wall, CellType.Floor, CellType.Floor, CellType.Floor, CellType.Wall}, {CellType.Wall, CellType.Floor, CellType.Wall, CellType.Floor, CellType.Wall}, {CellType.Wall, CellType.Floor, CellType.Floor, CellType.Floor, CellType.Wall}, {CellType.Wall, CellType.Wall, CellType.Wall, CellType.Wall, CellType.Wall}, }; var spawnPoint = new Vector2Int(1, 1); var level = new Level(cells, spawnPoint); levels = new List<Level> {level}; } } }

Также необходимо создать класс для ячеек, которые будут находиться на сцене. Добавим в него метод Initialize, который принимает индекс ячейки и устанавливает позицию объекта в соответствии с индексом.

_/Scripts/Gameplay/Cell.cs:

using UnityEngine; namespace AwesomeCompany.RollerSplat.Gameplay { public class Cell : MonoBehaviour { public Vector2Int index { get; protected set; } public virtual void Initialize(Vector2Int index) { this.index = index; transform.position = new Vector3(index.x, 0f, index.y); } } }

Теперь создадим префабы пола, стены и шара и поместим их в папку _/Resources/Gameplay.

При создании удаляем с объектов коллайдеры, потому что мы не будем использовать физику и они нам ни к чему.

Создаем класс LevelFactory, который будет отвечать за создание объектов. В нем два метода: создать шар и создать ячейку.

_/Scripts/Gameplay/LevelFactory.cs:

using System; using AwesomeCompany.RollerSplat.Dynamic; using UnityEngine; using Object = UnityEngine.Object; namespace AwesomeCompany.RollerSplat.Gameplay { public static class LevelFactory { private static readonly Transform _container; private static readonly GameObject _ballPrefab; private static readonly Cell _floorPrefab; private static readonly Cell _wallPrefab; static LevelFactory() { _container = new GameObject("LevelContainer").transform; _ballPrefab = Resources.Load<GameObject>("Gameplay/Ball"); _floorPrefab = Resources.Load<Cell>("Gameplay/Floor"); _wallPrefab = Resources.Load<Cell>("Gameplay/Wall"); } public static GameObject CreateBall(Vector2Int spawnPoint) { var ball = Object.Instantiate(_ballPrefab, _container); ball.transform.position = new Vector3(spawnPoint.x, 0f, spawnPoint.y); return ball; } public static Cell CreateCell(CellType cellType, Vector2Int index) { Cell cell; switch (cellType) { case CellType.Floor: cell = Object.Instantiate(_floorPrefab, _container); break; case CellType.Wall: cell = Object.Instantiate(_wallPrefab, _container); break; default: throw new ArgumentOutOfRangeException(nameof(cellType), cellType, null); } cell.Initialize(index); return cell; } } }

Далее создаем класс GameController, который будет управлять игрой. Пока что в нем один метод Play, в который передается необходимый уровень. Контроллер берет данные уровня и создает необходимое количество ячеек и шар с помощью LevelFactory.

_/Scripts/Gameplay/GameController.cs:

using AwesomeCompany.RollerSplat.Dynamic; using UnityEngine; namespace AwesomeCompany.RollerSplat.Gameplay { public class GameController : MonoBehaviour { public void Play(Level level) { var xLength = level.cells.GetLength(0); var zLength = level.cells.GetLength(1); for (var x = 0; x < xLength; x++) { for (var z = 0; z < zLength; z++) { var cellType = level.cells[x, z]; var index = new Vector2Int(x, z); LevelFactory.CreateCell(cellType, index); } } LevelFactory.CreateBall(level.spawnPoint); } } }

Добавляем ссылки на GameController и DynamicDataProvider в класс App. Теперь он имеет вид:

using AwesomeCompany.Common; using AwesomeCompany.Common.UI; using AwesomeCompany.RollerSplat.Dynamic; using AwesomeCompany.RollerSplat.Gameplay; using AwesomeCompany.RollerSplat.UI.Windows; using UnityEngine; namespace AwesomeCompany.RollerSplat { public class App : Singleton<App> { public IDynamicDataProvider dynamicDataProvider { get; private set; } public WindowManager windowManager { get; private set; } public GameController gameController { get; private set; } protected override void Awake() { base.Awake(); dynamicDataProvider = new DebugDynamicDataProvider(); windowManager = new GameObject("WindowManager").AddComponent<WindowManager>(); windowManager.transform.SetParent(transform); gameController = new GameObject("GameController").AddComponent<GameController>(); gameController.transform.SetParent(transform); } private void Start() { windowManager.Show<MainMenuWindow>(); } } }

Добавляем кнопку в класс MainMenuWindow, подписываемся на ее событие "Click". При нажатии берем первый уровень из списка уровней, передаем его GameController и закрываем окно.

using System.Linq; using AwesomeCompany.Common.UI; using UnityEngine; using UnityEngine.UI; namespace AwesomeCompany.RollerSplat.UI.Windows { public class MainMenuWindow : WindowBase { [SerializeField] private Button _playButton; protected override void Awake() { base.Awake(); _playButton.onClick.AddListener(OnPlayButtonClicked); } private void OnPlayButtonClicked() { var level = App.instance.dynamicDataProvider.levels.First(); App.instance.gameController.Play(level); Close(); } } }

Аттрибут [SerializeField] позволяет редактировать приватные переменные из редактора.

Далее нужно эту кнопку добавить в префаб окна. Я скачал SVG иконку кнопки play (256х256) с flaticon.com, закинул ее в figma.com, изменил цвет заливки на белый и сделал экспорт в PNG.

PNG картинку кладем в папку _/Sprites и меняем у нее в окне Inspector тип на Sprite (2D and UI), чтобы ее можно было добавить в интерфейс.

Открываем префаб MainMenuWindow, создаем кнопку, удаляем из нее дочерний объект Text, перетаскиваем иконку в компонент Image. Ставим галочку Preserve Aspect (для сохранения пропорций изображения) и кликаем Set Native Size чтобы установить размер кнопки равный размеру изображения. Перетаскиваем кнопку в поле PlayButton у объекта MainMenuWindow.

Перемещаем временно вручную камеру в нужное положение, примерно так:

Нажимаем Play и видим созданный уровень и шар. На сегодня это все. Еще раз ссылка на репозиторий, он будет обновляться вместе с выходом статей.

Материал опубликован пользователем.
Нажмите кнопку «Написать», чтобы поделиться мнением или рассказать о своём проекте.

Написать
{ "author_name": "Сергей Чемоданов", "author_type": "self", "tags": ["unity","tutorial","1"], "comments": 25, "likes": 61, "favorites": 196, "is_advertisement": false, "subsite_label": "gamedev", "id": 68568, "is_wide": false, "is_ugc": true, "date": "Sun, 08 Sep 2019 23:23:09 +0300", "is_special": false }
0
{ "id": 68568, "author_id": 29446, "diff_limit": 1000, "urls": {"diff":"\/comments\/68568\/get","add":"\/comments\/68568\/add","edit":"\/comments\/edit","remove":"\/admin\/comments\/remove","pin":"\/admin\/comments\/pin","get4edit":"\/comments\/get4edit","complain":"\/comments\/complain","load_more":"\/comments\/loading\/68568"}, "attach_limit": 2, "max_comment_text_length": 5000, "subsite_id": 64954, "last_count_and_date": null }
25 комментариев
Популярные
По порядку
Написать комментарий...
18

Нихуя не понял, но в закладки добавил.

Ответить
1

всё очень прикольно, но тебе с таким на хабр.
здесь не оч любят такой формат

Ответить
13

Почему это? Лично я давно ждал нормальной ждвижухи на dtf.

Ответить
1

А лучше на ютуб.

Ответить
3

Все круто, жду продолжения!
Для иконок могу посоветовать https://game-icons.net/ там сразу можно и цвета и форму подложки настроить

Ответить
3

Большое спасибо автору за материал.

Согласен с большинством. Код избыточен и перегружен для казуальной игры. И уж тем более для первоначального знакомства с Unity.

Код пишется или с большой закладкой на будущее. Или с попыткой показать больше возможности языка и Unity.

Но имея опыт работы с Unity и С# мне материал нравится и понятен. А некоторые идеи взял себе на заметку. Думаю автору стоит продолжать в том же духе. И не менять сложность и стиль изложения.

Хотел дополнить по окнам, я с ними работаю иначе. По модели MVP. Правда я Model и Presenter объединяю в один класс. И интерфейс делаю событийно-ориентированный. Я вообще часто использую event.

Ответить
1

Вообще не всосал причин определенных решений.

Сначала вы зачем-то впиливаете интерфейс с единственной реализацией, что есть плохо (я понимаю, что вы, видимо, в следующей статье сделаете еще реализацию, но сейчас её нет - и это плохо для тутора и путает).

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

Потом вы берете и делаете фабрику уровня тупо статиком, что увеличивает связность и заставляет вас использовать ресурсы просто чтобы получить префабы (Что делать не надо. Я потом уже посмотрел - вы вообще не гнушаетесь использовать ресурсы).

Потом вы добиваете это все тем, что делаете "корень композиции" синглтоном. Зачем? К нему никто не обращается, а если кто будет - надо бить его по рукам.

Я не вижу здесь никакой системы и зачем столько бесполезного шума в коде туторного проекта.

Ответить
0

Писал в прошлом посте вопрос, повторю: почему объекты создаются в Awake?
Например, вот:
windowManager = newGameObject("WindowManager").AddComponent<WindowManager>();
Вы так же поступили с канвасом в прошлом посте. Почему нельзя просто оставить их на сцене?

Другой вопрос: почему используется static?
private static readonly GameObject _ballPrefab;

Ответить
1

"Почему нельзя просто оставить их на сцене?" - WindowManager и GameController можно и так и так. Я просто делаю одним из методов.

А у окон есть метод Close, который обращается к WindowManager, и чтобы вручную не закидывать в редакторе в каждое окно ссылку на WindowManager я создаю их в коде.

LevelFactory является static классом, чтобы не создавать его экземпляр, соответственно ссылки на префабы тоже static.

Ответить
0

Всё понял, спасибо!

Ответить
0

Потому что кто-то пытался поиграть в проектирование но проиграл

Ответить
0

С вашими уроками на ютубе я бы такие комментарии не писал

Ответить
0

Присылайте ваш кодец, разберу по полочкам за тридцать минуток в чём вы не правы

P.S: Нашёл репозиторий, обязательно сделаю выпуск про наивное проектирование с показательным примером от вас

Ответить
0

Да я не против конструктивной критики, только, думаю, имеет смысл дождаться пока я закончу этот проект ;)

Ответить
0

Да, согласен.
Сейчас есть ряд конструктивных недочётов, но я думаю в процессе работы вы сами их увидите и учитывая ваш уровень я думаю вы сможете с этим успешно справится.

И уже постфактум я бы хотел освятить для своих подписчиков что же происходило (надеюсь репозиторий не закроете и я смог на истории комитов всё показать)

Удачи!

Ответить
1

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

От себя хочу сказать, что нравится стиль повествования автора и сложность (относительно большинства уроков по Unity на Youtube). Про простейшие вещи видео очень много, а вот про то, как все скомпоновать, чтобы постоянно не переделывать и не костылить, к сожалению, мало. Спасибо!

Ответить
0

Могу предположить, потому что данный проект делается в одиночку человеком, являющимся в первую очередь программистом. В данном случае все создание объектов через "new GameObject().AddComponent<>()" происходит в классе App, который является чем-то вроде локатора служб приложения (ru.wikipedia.org/wiki/Локатор_служб)

Ответить
0

"Почему нельзя просто оставить их на сцене?" что бы можно было редактировать уровни с сервера

Ответить
0

Вы что то путаете

Ответить
0

Как у вас всё сложно-то :(

Ответить
0

удалено

Ответить
0

По закладкам можно судить сколько "Мамкиных девелоперов" на DTF)

Ответить
0

Все :) статей больше нет :)

Ответить

Прямой эфир

[ { "id": 1, "label": "100%×150_Branding_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox_method": "createAdaptive", "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfl" } } }, { "id": 2, "label": "1200х400", "provider": "adfox", "adaptive": [ "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfn" } } }, { "id": 3, "label": "240х200 _ТГБ_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fizc" } } }, { "id": 4, "label": "Article Branding", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "p1": "cfovz", "p2": "glug" } } }, { "id": 5, "label": "300x500_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfk" } } }, { "id": 6, "label": "1180х250_Interpool_баннер над комментариями_Desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "h", "ps": "clmf", "p2": "ffyh" } } }, { "id": 7, "label": "Article Footer 100%_desktop_mobile", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjxb" } } }, { "id": 8, "label": "Fullscreen Desktop", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjoh" } } }, { "id": 9, "label": "Fullscreen Mobile", "provider": "adfox", "adaptive": [ "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjog" } } }, { "id": 10, "disable": true, "label": "Native Partner Desktop", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fmyb" } } }, { "id": 11, "disable": true, "label": "Native Partner Mobile", "provider": "adfox", "adaptive": [ "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fmyc" } } }, { "id": 12, "label": "Кнопка в шапке", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fdhx" } } }, { "id": 13, "label": "DM InPage Video PartnerCode", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox_method": "createAdaptive", "adfox": { "ownerId": 228129, "params": { "pp": "h", "ps": "clmf", "p2": "flvn" } } }, { "id": 14, "label": "Yandex context video banner", "provider": "yandex", "yandex": { "block_id": "VI-250597-0", "render_to": "inpage_VI-250597-0-1134314964", "adfox_url": "//ads.adfox.ru/228129/getCode?pp=h&ps=clmf&p2=fpjw&puid1=&puid2=&puid3=&puid4=&puid8=&puid9=&puid10=&puid21=&puid22=&puid31=&puid32=&puid33=&fmt=1&dl={REFERER}&pr=" } }, { "id": 15, "label": "Баннер в ленте на главной", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "p1": "byudo", "p2": "ftjf" } } }, { "id": 17, "label": "Stratum Desktop", "provider": "adfox", "adaptive": [ "desktop" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fzvb" } } }, { "id": 18, "label": "Stratum Mobile", "provider": "adfox", "adaptive": [ "tablet", "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fzvc" } } } ]