Pull to refresh

Одной лишь мышкой

Reading time14 min
Views3.1K

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

А теперь перейдём к делу, а почему бы нам сделать простой инвентарь с Drag&Drop`ом и бонусом от меня?

Начнём. Я не дизайнер, поэтому будет функциональный вариант, задизайните потом сами.

Сначала создам проект и накидаю необходимые для работы ноды в минимальном варианте: 

В контрол кидаем PanelContainer, его через кнопку Layout(Вид) растягиваем по всему контролу и сразу накидываем флаги на расширение по высоте и ширине:

Чилдом кидаем ГридКонтейнер(сетка), в неё мы уже будем кидать наши элементы, так же для удобства отладки добавим кнопку “поднятия” предмета, она будет генерировать рандомный элемент с рандомным кол-вом.

У нас будет 8 столбцов в инвентаре и 4 строчки, для необходимого разнообразия подготовил иконки итемов.

Скачаем с гугла шрифт и закинем его в контрол, чтобы мы могли менять размер шрифта:

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

Далее закидываем в главную сцену следующий скрипт
extends Control

export (int, 1, 20) var columns = 8
export (int, 1, 20) var rows = 4
onready var inv = $InvContainer/InvContent
const slot_scene = preload("res://Slot.tscn")
func _ready():
 inv.columns = columns
 for i in range(columns*rows):
  var slot = slot_scene.instance()
  inv.add_child(slot)

Промежуточный вариант примерно такой:

Открываем сцену слота, добавляем туда ещё одну панель, добавляем ей пустой стиль, в неё TextureRect для иконки и Label для кол-ва элементов:

Ставим для Иконки такие параметры, если кому интересно, напишите в комментариях, я подробнее расскажу про все параметры, которые использовал в статье:

Для текста похожие параметры:

В Slot создаём скрипт, и кидаем тестовый код
extends PanelContainer

onready var item = $Item
onready var icon = $Item/Icon
onready var count = $Item/Count

var item_type = null
var item_count = 0

func _ready():
 update_data({"type": "item_type_1", "count": 0})

func update_data(data = null):
 item.visible = data != null
 if data:
  icon.texture = load("res://graphics/%s.png" % data.type) #Динамическая загрузка иконки
  count.text = str(data.count)

Получаем такую картину:

Теперь займёмся кнопкой очистки:

Изменяем главный скрипт
extends Control

export (int, 1, 20) var columns = 8
export (int, 1, 20) var rows = 4

onready var inv = $InvContainer/InvContent

const slot_scene = preload("res://Slot.tscn")

func _ready():
 $InvContainer/HBoxContainer/Clear.connect("pressed", self, "clear_inventory")
 inv.columns = columns
 for i in range(columns*rows):
  var slot = slot_scene.instance()
  inv.add_child(slot)
  
func clear_inventory():
 for child in inv.get_children(): #Пробегаем по чилдам инвентаря
  child.update_data() #делаем апдейт без параметров

Очистка очень простая, коннектимся к сигналу кнопки и функцией из цикла с одной строчкой очищаем инвентарь.

Далее кнопка рандомного добавления.

Для начала в скрипт слота изменим так:
extends PanelContainer

onready var item = $Item
onready var icon = $Item/Icon
onready var count = $Item/Count

var item_data = null

func _ready():
 update_data()

func empty():
 return item_data == null

func update_data(data = null):
 item.visible = data != null
 item_data = data
 if item:
  icon.texture = load("res://graphics/%s.png" % item_data.type) #Динамическая загрузка иконки
  count.text = str(item_data.count)
 return true
Закидываем в главный скрипт новые функции:
 func has_empty_slot(): #Метод проверки наличия хотя бы одной пустой ячеки
 for child in inv.get_children(): #Пробегаем по чилдам инвентаря
  if child.empty():
   return true
 return false

func get_empty_slot(): #Метод получения случайной пустой ячеки
 var slot = null
 if has_empty_slot(): 
  #Обязательно нужно проверить, что у нас есть пустые ячейки
  #Иначе при полном инвентаре будет бесконечный цикл при полном инвентаре и игра зависнет
  while slot == null: #Ищем случайную пустую ячейку, пока не найдём
   var temp_slot = inv.get_child(rng.randi_range(0, columns*rows-1))
   if temp_slot.empty():
    slot = temp_slot
    break
 return slot

func add_item(): #Слот добавления случайного предмета, который подключен к кнопке
 var slot = get_empty_slot()
 if slot:
  var data = {"type":"", "count": 0}
  data.type = "item_type_" + str(rng.randi_range(1, 8))
  data.count = rng.randi_range(1, 999)
  slot.update_data(data)

И не забудь подключить сигнал кнопки в методу “add_item”, и всё заработает.

Следующим шагом реализация D&D(Drag&Drop).

Для начала, нужно создать отдельную сцену итема, т.к. нам нужен в двух местах.

Выглядит дерево примерно так:

Сразу создадим внутренний скрипт для итема, он простой, чисто устанавливает значение.

Скрипт итема
extends PanelContainer

onready var icon = $Icon
onready var count = $Count

const path_to_items_icons = "res://graphics/%s.png"

func set_data(item_data):
 icon.texture = load(path_to_items_icons % item_data.type) #Динамическая загрузка иконки
 count.text = str(item_data.count)

Далее приступаем к слоту:

Сюда мы закинули нашу сцену с итемом, плюс добавился лейбл “Num”, в нём лежит номер слота, я его использовал для отладки, вы можете просто скрыть его или удалить из сцены и из скрипта главной сцены. Кстати о главной сцене, в ней тоже произошли изменения:

Добавился как раз наш итем, координатно ни к чему не привязанный (без контейнеров), а зачем читайте дальше)

Теперь самое сложное, это скрипт главной сцены, там произошло куча изменений
extends Control

export (int, 1, 20) var columns = 8 #кол-во столбцов инвентаря
export (int, 1, 20) var rows = 4 #кол-во строчек инвентаря

const slot_scene = preload("res://Slot.tscn") #Подгружаем при компиляции сцену слота

onready var inv = $InvContainer/InvContent #Хранилище слотов
onready var titem = $TempItem #Это как раз наш временный итем, он нужен для отображения перетаскивания
onready var rng = RandomNumberGenerator.new() #Инициализация объекта класса рандомайзера
onready var item_dragging = null #Здесь хранится итем при перетаскивании
onready var prev_slot = null #Слот из которого мы перетаскиваем итем

func ready():
 titem.visible = false #скрываем временный итем
 rng.randomize() #запускаем рандомайзер
 $InvContainer/HBoxContainer/Clear.connect("pressed", self, "clear_inventory")
 $InvContainer/HBoxContainer/Add.connect("pressed", self, "add_item")
 inv.columns = columns #ограничиваем кол-во слолбцов отображения
 for i in range(columns*rows): #Цикл создания слотов
  var slot = slot_scene.instance() #Создаём объект слота
  slot.name = "Slot%d" % i #Задаём ему имя, в целом не обязательное действия, но для отладки удобно
  slot.get_node("Num").text = str(i) #Как раз тот самый номер слота, если удаляете из сцены слота
текстовое поле, то эту строчку тоже нужно удалить
  inv.add_child(slot) #Добавление слота в хранилище

func clear_inventory(): #Функция очистки хранилища
 for child in inv.get_children(): #Пробегаем по чилдам инвентаря
  child.update_data() #делаем апдейт без параметров

func has_empty_slot(): #Метод проверки наличия хотя бы одной пустой ячеки
 for child in inv.get_children(): #Пробегаем по чилдам инвентаря
  if child.empty():
   return true
 return false

func get_empty_slot(): #Метод получения случайной пустой ячеки
 var slot = null
 if has_empty_slot(): 
  #Обязательно нужно проверить, что у нас есть пустые ячейки
  #Иначе при полном инвентаре будет бесконечный цикл при полном инвентаре и игра зависнет
  while slot == null: #Ищем случайную пустую ячейку, пока не найдём
   var temp_slot = inv.get_child(rng.randi_range(0, columns*rows-1))
   if temp_slot.empty():
    slot = temp_slot
    break
 return slot

func add_item(): #Слот добавления случайного предмета, который подключен к кнопке
 var slot = get_empty_slot()
 if slot:
  var data = {"type":"", "count": 0}
  data.type = "item_type_" + str(rng.randi_range(1, 8))
  data.count = rng.randi_range(1, 999)
  slot.update_data(data)
  
func find_slot(pos:Vector2, need_data = false): #Метод поиска слота по координатам
 #второй параметр - необязательный, он говорит функции искать в позиции слот с итемом или нет
 for c in inv.get_children(): #Пробегаем по чилдам инвентаря
  if (need_data and not c.empty()) or (not need_data):
   if Rect2(c.rect_position, c.rect_size).has_point(pos):
    #Создаём прямоугольник из координат слота и его размеров, чтобы 
    #легко одним методом проверить находится ли точка в этом прямоугольнике
    return c
 return null

func _process(delta):
 var mouse_pos = get_viewport().get_mouse_position() #Получаем позицию мышки

 if Input.get_mouse_button_mask() == BUTTON_LEFT: #Проверяем нажата ли левая кнопка мыши
  if not item_dragging: #если мы уже не тащим элемент
   var slot = find_slot(mouse_pos, true)#ищем под курсором слот с итемом
  
   if slot: #если слот найден
    item_dragging = slot.item_data #сохраняем в хранилище данные итема
    titem.set_data(item_dragging) #во временнный итем пихаем данные
    titem.visible = true #показываем временный итем
    titem.rect_position = slot.rect_position #перемещаем временный итем в координаты слота
    prev_slot = slot #сохраняем слот из которого будем тащить итем
    slot.update_data() #очищаем слот из которого тащим
  else: #если мы уже тащим итем, то перемещаем временный итем под курсор, со смещением от половины размера итема(чтобы центр итема был под курсором)
   titem.rect_position = lerp(titem.rect_position, mouse_pos - titem.rect_size/2, 0.3)
  
 else: #если кнопка отпущена
  if item_dragging: #если у нас в хранилище есть итем
   var slot = find_slot(mouse_pos, false) #Ищет слот под курсором
   if slot: #если он есть, то пытаемся закинуть в слот данные
    if not slot.update_data(item_dragging): #если не получилось, то возвращаем итем обратно
     prev_slot.update_data(item_dragging)
    
prev_slot = null #очищаем ссылку на старый слот
item_dragging = null #сбрасываем хранилище итема
titem.visible = false #скрываем временный итем

Я постарался и прокомментировал практически каждую строчку

Чтобы нам ещё хотелось ? Я бы сделал обмен между слотами, мусорку и в конце будет ещё бонус)

Для начала дополним и чуть изменим скрипт слота
func check_data(data):
 return "all" in available_types or data.type in available_types

func update_data(data = null):
 item.visible = data != null
 item_data = data
 if item_data:
  if check_data(data):
   item.set_data(item_data)
   return true
  return false
 return true

Теперь главный скрипт, в нём нужно поменять лишь функцию _process:

_process
func _process(delta):
 var mouse_pos = get_viewport().get_mouse_position() #Получаем позицию мышки
 if Input.get_mouse_button_mask() == BUTTON_LEFT: #Проверяем нажата ли левая кнопка мыши
  if not item_dragging: #если мы уже не тащим элемент
   var slot = find_slot(mouse_pos, true)#ищем под курсором слот с итемом
   if slot: #если слот найден
    item_dragging = slot.item_data #сохраняем в хранилище данные итема
    titem.set_data(item_dragging) #во временнный итем пихаем данные
    titem.visible = true #показываем временный итем
    titem.rect_position = slot.rect_position #перемещаем временный итем в координаты слота
    prev_slot = slot #сохраняем слот из которого будем тащить итем
    slot.update_data() #очищаем слот из которого тащим
  else: #если мы уже тащим итем, то перемещаем временный итем под курсор, со смещением от половины размера итема(чтобы центр итема был под курсором)
   titem.rect_position = lerp(titem.rect_position, mouse_pos - titem.rect_size/2, 0.3)
 else: #если кнопка отпущена
  if item_dragging: #если у нас в хранилище есть итем
   var slot = find_slot(mouse_pos) #Ищет слот под курсором
  
#Вариант №1
#if slot: #если он есть, то пытаемся закинуть в слот данные
#if slot.empty(): #если в слот пустой
#if slot.check_data(item_dragging): #подходит ли данные к слоту, то обновляем данные
#slot.update_data(item_dragging)
#else: #если нет, то возвращаем итем обратно
#prev_slot.update_data(item_dragging)
#else: #если слот не пустой, то проверяем подходят ли данные для обмена, если подходят меняем местами
#if slot.check_data(item_dragging) and prev_slot.check_data(slot.item_data):
#prev_slot.update_data(slot.item_data)
#slot.update_data(item_dragging)
#else: #если нет, то возвращаем обратно
#prev_slot.update_data(item_dragging)

#Вариант №2
   if slot: #если слот найден
    if slot.check_data(item_dragging): #сразу проверям подходит ли к новому слоту данные, тобишь имеет ли смысл делать проверки дальше
     if slot.empty(): #если в слот пустой
      slot.update_data(item_dragging)
     else: #если слот не пустой, то проверяем подходят ли данные найденного слота для предыдущего
      if prev_slot.check_data(slot.item_data): #если подходит, то обновляем
       prev_slot.update_data(slot.item_data)
       slot.update_data(item_dragging)
    else:
     prev_slot.update_data(item_dragging)
prev_slot = null #очищаем ссылку на старый слот
item_dragging = null #сбрасываем хранилище итема
titem.visible = false #скрываем временный итем

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

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

Скрипт слота
extends PanelContainer

signal dropped(data)

export (Array) var available_types = ["all"] 
#массив для ограничения доступности типов предметов для этой ячейки

enum Actions {NONE, TRASH} #Перечисление с допустимиы действиями слота

var cur_act = Actions.NONE #установка переменной действия слота в стандартное положение

onready var item = $Item

var item_data = null #Здесь будет словарь с данными предмета

func _ready():
 update_data()

func set_action(new_value):
 cur_act = new_value
 $Item.visible = false
 $Trash.visible = false
 match cur_act:
  Actions.NONE:
   $Item.visible = true
  Actions.TRASH:
   $Trash.visible = true
  
func empty():
 return item_data == null

func check_data(data):
 if cur_act:
  return true
 return "all" in available_types or data.type in available_types

func update_data(data = null):
 if data and cur_act:
  emit_signal("dropped", data)
  return true
 item.visible = data != null
 item_data = data
 if item_data:
  if check_data(data):
   item.set_data(item_data)
   return true
  return false
 return true
Главный скрипт
func ready():
 titem.visible = false #скрываем временный итем
 rng.randomize() #запускаем рандомайзер
 $InvContainer/HBoxContainer/Clear.connect("pressed", self, "clear_inventory")
 $InvContainer/HBoxContainer/Add.connect("pressed", self, "add_item")
 inv.columns = columns #ограничиваем кол-во слолбцов отображения
 for i in range(columns*rows): #Цикл создания слотов
  var slot = slot_scene.instance() #Создаём объект слота
  slot.name = "Slot%d" % i #Задаём ему имя, в целом не обязательное действия, но для отладки удобно
  slot.get_node("Num").text = str(i) #Как раз тот самый номер слота, если удаляете из сцены слота текстовое поле, то эту строчку тоже нужно удалить
  slot.set_action(slot.Actions.NONE)
  if i == columns*rows-1:
   slot.set_action(slot.Actions.TRASH)
   slot.connect("dropped", self, "trash_dropped")
  inv.add_child(slot) #Добавление слота в хранилище
  
func trash_dropped(data):
 print("dropped ", data)

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

Ну а теперь бонус, сделаем полноценный инвентарь игрока.

Добавляем доп панель для инвентаря и накидываем ещё слотов:

Helmet и другие это тоже слоты, как и те, которые мы генерируем.

В скрипте слота нужно чутка дополнить
extends PanelContainer

signal dropped(path, data) #Сигнал помещения итема в корзину
signal accepted(path, data) #Сигнал помещения итема в слот

export (Array) var available_types = ["all"] 
#массив для ограничения доступности типов предметов для этой ячейки

enum ACTIONS {TRASH, NONE = -1} #Перечисление с допустимыми действиями слота

export (ACTIONS) var current_action = ACTIONS.NONE

onready var item = $Item

var item_data = null #Здесь будет словарь с данными предмета

func _ready():
	set_action(ACTIONS.NONE)
	update_data()
	add_to_group("Slots")

func set_action(new_value):
	current_action = new_value
	
	$Item.visible = false
	$Trash.visible = false
	$Num.visible = false
	
	match current_action:
		ACTIONS.NONE:
			$Item.visible = true
			$Num.visible = true
		ACTIONS.TRASH:
			$Trash.visible = true

func empty():
	return item_data == null

func check_data(data):
	if current_action == ACTIONS.TRASH:
		return true
	return "all" in available_types or data.type in available_types

func update_data(data = null):
	if data and current_action == ACTIONS.TRASH:
		emit_signal("dropped", get_path(), data)
		return true
	item.visible = data != null
	item_data = data
	if item_data:
		if check_data(data):
			item.set_data(item_data)
			emit_signal("accepted", get_path(), data)
			return true
		return false
	return true

Ну и теперь самое главное:

Скрипт главной сцены
extends Control

export (int, 1, 20) var columns = 8 #кол-во столбцов инвентаря
export (int, 1, 20) var rows = 4 #кол-во строчек инвентаря

const slot_scene = preload("res://scenes/Slot.tscn") #Подгружаем при компиляции сцену слота

onready var inv = $PlayerInv/Inv/InvContent
onready var item_dragged_view = $DraggedItem
onready var clearButton = $PlayerInv/Inv/Button/Clear
onready var addButton = $PlayerInv/Inv/Button/Add

onready var rng = RandomNumberGenerator.new() #Инициализация объекта класса рандомайзера

onready var item_dragged = null #Здесь хранится итем при перетаскивании
onready var slot_dragged = null #Слот из которого мы перетаскиваем итем

func _ready():
	item_dragged_view.visible = false #скрываем временный итем
	rng.randomize() #запускаем рандомайзер
	clearButton.connect("pressed", self, "clear_inventory")
	addButton.connect("pressed", self, "add_item")
	inv.columns = columns #ограничиваем кол-во слолбцов отображения
	for i in range(columns*rows): #Цикл создания слотов
		var slot = slot_scene.instance() #Создаём объект слота
		slot.name = "Slot_%d" % i #Задаём ему имя, в целом не обязательное действия, но для отладки удобно
		slot.get_node("Num").text = str(i) #Как раз тот самый номер слота, если удаляете из сцены слота
		# текстовое поле, то эту строчку тоже нужно удалить
		inv.add_child(slot) #Добавление слота в хранилище
		if i == columns*rows-1:
			slot.set_action(slot.ACTIONS.TRASH)

	for slot in get_tree().get_nodes_in_group("Slots"):
		slot.connect("accepted", self, "slot_accepted")
		slot.connect("dropped", self, "trash_dropped")
			
func slot_accepted(path, data):
	print("accepted ", path, " ", data)

func trash_dropped(path, data):
	print("dropped ", path, " ", data)

func clear_inventory(): #Функция очистки хранилища
	get_tree().call_group("Slots", "update_data")

func has_empty_slot(): #Метод проверки наличия хотя бы одной пустой ячеки
	for slot in get_tree().get_nodes_in_group("Slots"): #Пробегаем по всем слотам доступным
		if slot.empty() and slot.current_action != slot.ACTIONS.TRASH:
			return true
	return false

func get_empty_slot(): #Метод получения случайной пустой ячеки
	var rand_slot = null
	if has_empty_slot(): 
		var empty_slots = [] #Массив пустых слотов
		for slot in get_tree().get_nodes_in_group("Slots"): #Перебираем все слоты и ищем пустые и слоты с недопустимыми экшенами
			if slot.empty() and slot.current_action != slot.ACTIONS.TRASH:
				empty_slots.push_back(slot)
		rand_slot = empty_slots[(rng.randi_range(0, empty_slots.size()-1))] #выбираем случайный слот из пустых
	return rand_slot

func add_item(): #Слот добавления случайного предмета, который подключен к кнопке
	var slot = get_empty_slot()
	if slot:
		var data = {"type":"", "count": 0}
		data.type = "item_type_" + str(rng.randi_range(1, 8))
		data.count = rng.randi_range(1, 999)
		slot.update_data(data)

func find_slot(pos:Vector2, need_data = false): #Метод поиска слота по координатам
	#второй параметр - необязательный, он говорит функции искать в позиции слот с итемом или нет
	for c in get_tree().get_nodes_in_group("Slots"): #Пробегаем по чилдам инвентаря
		if (need_data and not c.empty()) or (not need_data):
			if c.get_global_rect().has_point(pos):
				#Создаём прямоугольник из координат слота и его размеров, чтобы 
				#легко одним методом проверить находится ли точка в этом прямоугольнике
				return c
	return null

func _process(delta):
	if Input.get_mouse_button_mask() == BUTTON_LEFT: #Проверяем нажата ли левая кнопка мыши
		if not item_dragged:
			_start_drag()
		else:
			_update_drag()
	else: #если кнопка отпущена
		if item_dragged:
			_stop_drag()

func _start_drag():
	var mouse_pos = get_viewport().get_mouse_position()
	var slot = find_slot(mouse_pos, true)#ищем под курсором слот с итемом
	if slot: #если слот найден
		item_dragged = slot.item_data #сохраняем в хранилище данные итема
		item_dragged_view.set_data(item_dragged) #во временнный итем пихаем данные
		item_dragged_view.visible = true #показываем временный итем
		item_dragged_view.rect_position = slot.get_global_rect().position #перемещаем временный итем в координаты слота
		slot_dragged = slot #сохраняем слот из которого будем тащить итем
		slot.update_data() #очищаем слот из которого тащим

func _update_drag():
	var mouse_pos = get_viewport().get_mouse_position()
	#перемещаем временный итем под курсор, со смещением от половины размера итема(чтобы центр итема был под курсором)
	item_dragged_view.rect_position = lerp(item_dragged_view.rect_position, mouse_pos - item_dragged_view.rect_size/2, 0.3)
	
func _stop_drag():
	var mouse_pos = get_viewport().get_mouse_position()
	var slot = find_slot(mouse_pos) #Ищет слот под курсором
	if slot: #если слот найден
		if slot.check_data(item_dragged): #сразу проверям подходит ли к новому слоту данные, тобишь имеет ли смысл делать проверки дальше
			if slot.empty(): #если в слот пустой
				slot.update_data(item_dragged)
			else: #если слот не пустой, то проверяем подходят ли данные найденного слота для предыдущего
				if slot_dragged.check_data(slot.item_data): #если подходит, то обновляем
					slot_dragged.update_data(slot.item_data)
					slot.update_data(item_dragged)
		else:
			slot_dragged.update_data(item_dragged)
		
		slot_dragged = null #очищаем ссылку на старый слот
		item_dragged = null #сбрасываем хранилище итема
		item_dragged_view.visible = false #скрываем временный итем
		

Полный листинг в моём гитхаб репозитории

UPD: Подправил функцию get_empty_slot в последнем листинге, чтобы убрать возможность попадания в бесконечный цикл. в гите так же обновлено.

UPD #2: Сделал небольшой рефакторинг. Плюс отказался от массива слотов, перенёс всё в Группы сцен Годо.

Также в моём телеграмм канале вы можете ознакомится с предыдущими статьями, и первыми прочитать следующие - https://t.me/holydevlog

Tags:
Hubs:
+4
Comments11

Articles