Как стать автором
Обновить

Внеочередной урок по Godot 4.0: пиксели и RPG (часть первая, в которой человечек научился ходить)

Время на прочтение10 мин
Количество просмотров13K

Disclaimer

Не являюсь гуру Godot, не претендую на абсолютные знания и не имею докторскую степень по чему-либо. Всячески приветствую советы по улучшению кода и прочим идеям. Следовать урокам исключительно на свой страх и риск.

Урок Серия уроков будет посвящена созданию простой RPG в 1bit пиксель-арт стиле, где я постараюсь рассмотреть всю ту боль и страдания, с которыми обычно встречаются новички - джиттер, расплывающиеся пиксели, борьба с разрешением и прочее.

The Beginning

Чтобы было больнее и интересней, проект будет на Godot 4.0 alpha. Святой Хуан обещает, что скоро будет бета, так что всяческие долгострои уже вполне можно начинать пилить на четвертой версии.

Для начала - создадим проект. Этот сложный и ответственный шаг, я думаю, местная аудитория может сделать самостоятельно, но на всякий случай - скриншот:

Vulkan Clustered нам не особо нужен - у нас 2д с лоурез текстурами.
Vulkan Clustered нам не особо нужен - у нас 2д с лоурез текстурами.

Сразу залезем в настройки проекта. У нас pixel-art игра, и есть разные способы стилизации под пиксели, лично я люблю просто и банально работать в пониженном разрешении. Так не будет пикселей разного размера, смеси пиксель-арта и не пиксель-арта. Так что поставим базовое разрешение, допустим, 480 на 320 - как в старые добрые времена.

Также отличной идеей будет кликнуть на Advanced Settings в правом верхнем углу и включить pixel snap - это сильно спасёт от дальнейшей головной боли и всяческих дрожащих пикселей.

Также, как мне напомнили, можно поставить дефолтный filter на nearest - чтобы меньше было заморочек при работе с пиксельными лоурез текстурами.

Структура проекта

По моим скромным наблюдениям, большая часть уроков, рассчитанных на новичков страдают отсутствием нормальной структуры. Часто вся логика идёт в скрипте одной-единственной сцены, как пример. Это может быть нормально для тетриса или пинг-понга, или какой-либо другой небольшой игры, но принесёт разработчику кучу головной боли, если он внезапно решит расширять свой проект и двигаться дальше, where no developer has gone before.

То, что предлагаю я, не истина в последней инстанции, но один из вполне удобных вариантов. Суть банальна. У нас есть синглтон, который управляет сценами, сохраняет состояние игрока/его прогресс, управляет сохранениями. Новая игра? Запрос из меню к синглтону, мол, дай мне менюшку нового персонажа, пожалуйста! Игрок перешёл из одной локации в другую? Синглтон, поменяй карту, игрок спавнится в точке А!

Итак. Для начала, сделаем тот самый большой и страшный класс для управления сценами и прочими весёлыми штуками. Назовём его ZaWarudo World.gd, заодно сразу добавим в синглтоны - то есть, Autoload по-местному. Для этого подойдёт в общем-то любая базовая нода, которая наследована от Node. Я подозреваю, что читатель может сделать это сам, но на всякий случай пошагово:

Выбираем Script, в панельке где-то сверху и посередине редактора
Выбираем Script, в панельке где-то сверху и посередине редактора
Жмакаем File -> New Script
Жмакаем File -> New Script

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

Небольшой оффтопик
Кстати, на тему структуры. Я встречал несколько основных идей организации проекта:
1. По логике, сцена+скрипт+ресурсы (в нашем случае - png файлы) в одной папке
2. По типу файла (в одном месте - картинки, в другом - сцены, в третьем - скрипты)
3. Микс (то, что буду использовать я) - "сырые" ресурсы в одной папке, сцены+скрипты к сценам - в другой, отдельные скрипты, не привязанные к сцене - в третьей.
Выглядит это примерно так:

Возвращаемся обратно к нашему World.gd. Сохраняем наш синглтон в Scripts\Singletons и добавляем в Autoload:

Выбираем файл, кликаем на Add, получаем такой результат:

К структуре проекта мы ещё не раз вернёмся, пока же перейдём к пока-ещё-не-многострадальному World.gd

World.gd
extends Node

# Наши константы
const MAP_ROOT = "res://Scenes/Maps/"

# Тут хранится наша текущая сцена. Это был очень важный комментарий.
var current_scene : Node

# Мы не будем пользоваться встроенной командой change scene. Но узнать, какая сцена
# у нас идёт изначально, хотелось бы. 
func _ready():
	current_scene = get_tree().current_scene

# Небольшой хелпер - чтобы не писать каждый раз полностью название карты.
func change_map(map_name: String, params={}):
	var path = MAP_ROOT.plus_file(map_name + ".tscn")
	change_scene(path, params)
	
# Код смены сцены
func change_scene(path: String, params={}):
	if ResourceLoader.exists(path):
		if is_instance_valid(current_scene):
			current_scene.queue_free()
		# Для тяжелых сцен где-то в этом месте можно было бы воткнуть лоадер, но
		# у нас всё грузится практически моментально - поэтому пока "покатит".
		current_scene = load(path).instantiate()
		get_tree().root.add_child(current_scene)
		# Для смены карты, к примеру, нам нужно знать, где спавнить игрока. 
		# В такие моменты нас спасёт вызов init с переданными параметрами!
		if current_scene.has_method("init"):
			current_scene.call_deferred("init", params)
	else:
		printerr("No such scene: ", path)

По факту, мы просто и банально игнорируем встроенные get_tree().change_scene() и current_scene.

Код документирован и прост, не думаю, что что-то нужно сильно пояснять.

Но где-то сцену надо менять. Для этого нам нужна стартовая сцена. Самый простой вариант - это подумать, что игрок видит, когда запускает игру? Правильно, заставку главное меню!

В меню нам пока хватит одной кнопки, к которой на сигнал "pressed" будет присоединён простой метод:

func _on_start_game_pressed():
	World.change_map("StartRegion/Woods")

Что за "StartRegion/Woods", спросите вы. И будете правы. Это наша карта.

Карты, пока без денег и двух стволов

Настало время сделать карту - то есть, место, где будет происходить всё действие. Тут мы задействуем всю мощь и мировое господство возможности наследования сцен. Для начала сделаем базовую сцену карты, назовём её MapBase.tscn и тут же приделаем к ней скрипт Map.gd. В итоге желаем получить что-то вроде этого:

Раньше, в Godot 3, была волшебная нода YSort, сейчас её функционал перенесли во владения Node2D. Так что у нод MapBase, Characters и Environment (в скиншотах возможны опечатки, хе-хе) нужно не забыть включить волшебную галку:

Данная волшебная галка делает следующее волшебство: объект, который стоит выше - одновременно стоит и дальше, вне зависимости от его положения в дереве сцены.

Но на карте ничего нет. Кроме камеры (у которой пока что включена галка current) и пары пустых нод. Надо что-то добавить!

...но не сейчас. Это всего лишь базовая сцена, на основе которой мы будем клепать остальные карты. Так что смело кликаем правой кнопкой мышки на нашу BaseMap.tscn и выбираем New Inherited Scene, сохраняем (как можно было заметить выше, у меня это res://Scenes/Maps/StartRegion/Woods.tscn), и добавляем объекты. Пока что это просто деревья. И для таких вот деревьев специально заготовлена нода Environment. А сами деревья можно найти в проекте, который выложен где-то внизу, по пути res://Scenes/Environment/Nature/Tree.tscn.

Наконец-то можно нажать кнопку Play и запустить проект.
Наконец-то можно нажать кнопку Play и запустить проект.

Тонкости спрайтов и пиксельарта

По дефолту пиксельарт-спрайты будут некрасивыми и размытыми. В Godot 3.5 это решалось реимпортом с настройкой фильтра на nearest, в 4.0 настройку перенесли в Node2D. Так что при создании очередного персонажа, постройки или ещё чего крайне рекомендуется, чтобы корневая нода или нода спрайта имела такую вот настройку:

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

Персонаж

Как я писал в самом начале, цель этого урока - заставить персонажа двигаться. Займёмся этим. Изначально я хотел сделать всё просто, но потом Остапа понесло... В общем, у нас определённо будут разные персонажи. И те, кем управляет человек, и те, кем управляет электронный мозг. Поэтому им требуется какой-никакой, а базовый класс и, для удобства, базовая сцена. Далее будет длинная простыня кода, не пугайтесь.

Так должно выглядеть дерево персонажа - подробней можно посмотреть в приложенном проекте.
Так должно выглядеть дерево персонажа - подробней можно посмотреть в приложенном проекте.
Простыня
@tool
extends CharacterBody2D
class_name Character

@export_file("*.json")
var character_data :
	set(value):
		if value == null or typeof(value) != TYPE_STRING: 
			return
		print("Parsing json")
		var json = JSON.new()
		var file = File.new()
		if file.open(value, File.READ) == OK:
			if json.parse(file.get_as_text()) == OK:
				load_character(json.get_data())
				character_data = value
			else:
				printerr("Can't parse json! ", value)
				printerr(json.get_error_line())
				printerr(json.get_error_message())
      file.close()
		else:
			printerr("Can't open json! ", value)
	get:
		return character_data
		
@onready
var image: Sprite2D = $Image
@onready
var anim: AnimationPlayer = $AnimationPlayer

# Стандартные характеристики персонажа
var hp = 10
var max_hp = 10

var speed = 100
var direction = Vector2()

func _ready():
	if Engine.is_editor_hint():
		set_physics_process(false)

# Тут мы берём наш ресурс с персонажем (любители json'a могут просто запарсить его)
# И добавляем анимации. Данная функция - для редактора, чтобы было проще работать дальше. 
func load_character(data: Dictionary) -> void:
	if not anim: 
		return
	# Грузить будем только в редакторе - это чисто вспомогательная функция
	if not Engine.is_editor_hint():
		return
	# В годо 4ке всё переделали, и сначала нам надо создать библиотеку анимаций, 


	var anim_lib : AnimationLibrary
	if not anim.has_animation_library(StringName("Main")):
		anim_lib = AnimationLibrary.new()
		anim.add_animation_library(StringName("Main"), anim_lib)
	else:
		anim_lib = anim.get_animation_library(StringName("Main"))

	image.texture = load(data.texture)
	image.hframes = data.cols
	image.vframes = data.rows
	for animation_data in data.animations:
		# Remove old track if exists
		var delay = 0.2
		# Берем анимацию, если она уже есть. Нет - создаем новую. 
		var animation: Animation = Animation.new() if not anim_lib.has_animation(animation_data.name)\
									else anim_lib.get_animation(animation_data.name)
		# То же самое с треком для фреймов (самой анимации), удаляем старый трек, создаем новый. 
		var track_index = animation.find_track("Image:frame", Animation.TYPE_VALUE)
		if track_index >= 0:
			animation.remove_track(track_index)
		track_index = animation.add_track(Animation.TYPE_VALUE)			
		animation.track_set_path(track_index, "Image:frame")
		# И банально добавляем значения. 
		for i in range(animation_data.start_frame, animation_data.end_frame + 1):
			animation.track_insert_key(track_index, (i-animation_data.start_frame)*delay, i)
			print("Add anim %d" % i)
		animation.loop_mode = Animation.LOOP_LINEAR if animation_data.loop \
								else Animation.LOOP_NONE
		anim_lib.add_animation(animation_data.name, animation)
		# Очевидно, что длинну анимации тоже нужно подправить.
		animation.length = delay * (animation_data.end_frame - animation_data.start_frame )

	image.transform.origin = Vector2(0,0)
	transform.origin = Vector2(0,0)
	
func _physics_process(delta):
	# velocity мы будем брать у классов наследников
	before_move(delta)
	velocity = direction * speed
	move_and_slide()
	animate()
	after_move(delta)
	
func animate():
	if direction.length_squared() <= 0.1:
		# стоим
		if anim.has_animation("Main/Idle"):
			anim.play("Main/Idle")
	else:
		# Идем
		if anim.has_animation("Main/Run"):
			anim.play("Main/Run")
	
	image.flip_h = direction.x < 0
	
# virtual
func before_move(delta):
	pass
	
# also virtual
func after_move(delta):
	pass

load_character - то, что понимать на данный момент не обязательно. Если статья взойдёт хотя бы нескольким людям, я продолжу цикл и обязательно разберу всё это дело. Вкратце, это небольшой "вспомогатор" для того, чтобы было проще создавать персонажей. Он берет json, заполняет базовые поля - текстуру, анимации и т.д., позволяя потом это всё дело править как душе угодно. Пример json'a лежит в Raw/Characters/Wizzard.json.

Как уже сказано выше, весь код разбирать не нужно - вы вообще можете задействовать другой вариант анимации, нежели чем тот, которым пользуюсь я. Основной код у вас при этом не сильно поменяется. Итак, что должен уметь базовый класс?

  • Хранить основные статы

  • Двигать персонажа

  • Анимировать - на данный момент из анимаций у нас будут только Idle && Run

Разберём же, что за фигню творит этот класс.

func _physics_process(delta):
	# velocity мы будем брать у классов наследников
	before_move(delta)
	velocity = direction * speed
	move_and_slide()
	animate()
	after_move(delta)

Основное действо происходит в банальном физическом процессе. Тут есть несколько функций, которые впоследствии мы будем переопределять в классах наследниках, то есть класс персонажа игрока и класс монстра. Это before_move - тут мы собираем "ввод" с игрока или монстра, и after_move - этот кусок исполняется после move_and_slide, а это значит, физику мы посчитали и всяческие столкновения (если понадобятся) уже обновились. animate() оверрайдить пока не нужно, этот кусок кода банально проигрывает анимацию idle, если персонаж стоит и анимацию Run, если он идёт.

Дальше нам жизненно необходимо создать класс-наследник, который будет использоваться для персонажа игрока - назовём его PlayerCharacter.gd

В нём нужно оверрайдить метод before_move - и собрать, таким образом, ввод с клавиатуры.

@tool
extends Character

# override
func before_move(delta):
	direction = Input.get_vector(
		StringName("left"), 
		StringName("right"), 
		StringName("up"), 
		StringName("down")).normalized()

# override
func after_move(delta):
	pass

Да, нужно не забыть добавить все это в InputMap!

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

Возвращаемся к карте

Добавим ноду Node2D "Entries", где будут находиться ноды Position2D, которые будут банально указывать на место спавна игрока при переходе на данную карту.

И обновим код Map.gd:
extends Node2D
class_name Map

var player: PlayerCharacter

func init(params: Dictionary):
	if "entry" in params and $Entries.has_node(params.entry):
		spawn_player($Entries.get_node(params.entry))
	elif $Entries.get_child_count() > 0:
		spawn_player($Entries.get_child(0))
	else:
		printerr("Have no entry points! The Gods cursed us!")
		
func spawn_player(position: Node2D):
	player = load("res://Scenes/Characters/Playable/Wizzard.tscn").instantiate()
	$Characters.add_child(player)

Возможно, вы вспомните, что при смене сцены автоматом вызывается метод init, если он есть. Логика простая - если есть в параметрах entry, то ставим туда игрока. Нет - ставим в первый попавшийся энтри. Если энтри нет вообще - страдать и ловить ошибки.

Спавн игрока на данный момент сделаем максимально простым - потом ещё не раз к нему вернёмся. Следующий пункт программы - камера. Она должна следовать за игроком, что логично :) Но просто взять и прицепить камеру - работает отлично в играх с высоким разрешением, не пиксельных игрушках, нам же весьма желательно, чтобы камера двигалась четко по пикселям. Поэтому повесим небольшой скрипт на камеру, после чего обновим класс карты. Так мы избежим лишнего "зловещего дрожания пикселей".

extends Camera2D

var player: Node2D

func _physics_process(delta):
	if player:
		position.x = floor(player.position.x)
		position.y = floor(player.position.y)
И основной класс карты
extends Node2D
class_name Map

var player: PlayerCharacter

func init(params: Dictionary):
	if "entry" in params and $Entries.has_node(params.entry):
		spawn_player($Entries.get_node(params.entry))
	elif $Entries.get_child_count() > 0:
		spawn_player($Entries.get_child(0))
	else:
		printerr("Have no entry points! The Gods cursed us!")
		
func spawn_player(position: Node2D):
	player = load("res://Scenes/Characters/Playable/Wizzard.tscn").instantiate()
	$Characters.add_child(player)
	$Camera.player = player

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

Заключение

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

И, предотвращая некоторые вопросы - да, большую часть всего этого дела можно было сделать на порядок проще, в одной сцене и с одним скриптом. Почему я делаю так сложно? Чтобы потом было слегка менее больно.

А теперь рисуем остальную сову.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Продолжать рисовать сову?
95.56% Да43
4.44% Нет2
Проголосовали 45 пользователей. Воздержались 5 пользователей.
Теги:
Хабы:
Всего голосов 8: ↑8 и ↓0+8
Комментарии6

Публикации