Главная » Хабрахабр » [Перевод] Google’s Shell Style Guide (на русском)

[Перевод] Google’s Shell Style Guide (на русском)

Какой Shell использовать

Bash единственный язык shell скриптов, который разрешается использовать для исполняемых файлов.

Используйте set для установки shell опций, что бы вызов вашего скрипта как bash <script_name> не нарушил его функциональности. Скрипты должны начинаться с #!/bin/bash с минимальным набором флагов.

Ограничение всех shell скриптов до bash, дает нам согласованный shell язык, который установлен на всех наших машинах.

Одним из примеров могут стать пакеты Solaris SVR4, для которых требуется использование обычного Bourne shell для любых скриптов. Единственное исключение составляет если вы ограничены условиями того под что вы программируете.

Когда использовать Shell

Shell следует использовать только для небольших утилит или простых скрптов-оберток.

Это руководство по стилю является скорее признанием его использования, а не предложением использовать его в широком применении. Хотя shell-скриптинг не является языком разработки, он используется для написания различных утилит во всем Google.

Некоторые рекомендации:

  • Если вы чаще всего вызываете другие утилиты и делаете относительно небольшое манипулирование данными, shell является приемлемым выбором для задачи.
  • Если производительность имеет значение, используйте что-нибудь другое, но не shell.
  • Если вы обнаружите, что вам нужно использовать массивы более чем для назначения $, вы должны использовать Python.
  • Если вы пишете скрипт длиной более 100 строк, вы, вероятно, должны писать его на Python. Имейте в виду, что скрипты растут. Перепишите свой скрипт на другом языке раньше, чтобы избежать трудоемкой перезаписи позднее.

Расширения файлов

Библиотеки должны иметь расширение .sh и не должны быть исполняемыми. Исполняемые файлы не должны иметь расширения (сильно предпочтительно) или расширение .sh.

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

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

SUID/SGID

SUID и SGID запрещены на shell-скриптах.

Хотя bash усложняет запуск SUID, это все еше возможно на некоторых платформах, поэтому мы явно запрещаем его использование. Тут слишком много проблем с безопасностью, из-за чего почти невозможно обеспечить достаточную защиту SUID/SGID.

Используйте sudo для обеспечения повышенного доступа, если вам это необходимо.

STDOUT vs STDERR

Все сообщения об ошибках должны отправляться в STDERR.

Это помогает разделить нормальное состояние от актуальных проблем.

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

err() { echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $@" >&2
} if ! do_something; then err "Unable to do_something" exit "${E_DID_NOTHING}"
fi

Заголовок файла

Начинайте каждый файл с описанием его содержимого.

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

Пример:

#!/bin/bash
#
# Perform hot backups of Oracle databases.

Комментарии к функциям

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

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

Все комментарии к функциям должны включать:

  • Описание функции
  • Используемые и измененные глобальные переменные
  • Получаемые аргументы
  • Возвращаемые значения, отличные от стандартных exit codes в последней команде.

Пример:

#!/bin/bash
#
# Perform hot backups of Oracle databases. export PATH='/usr/xpg4/bin:/usr/bin:/opt/csw/bin:/opt/goog/bin' ########################################
# Cleanup files from the backup dir
# Globals:
# BACKUP_DIR
# ORACLE_SID
# Arguments:
# None
# Returns:
# None
########################################
cleanup() { ...
}

Комментарии по реализации

Комментируйте сложные, неочевидные, интересные или важные части вашего кода.

Не комментируйте все. Это предполагается обычной практикой комментирования кода в Google. Если есть сложный алгоритм или вы делаете что-то необычное, добавьте короткий комментарий.

TODO Комментарии

Используйте TODO комментарии для кода, который является временным, краткосрочным решением или довольно хорошим, но не идеальным.

Это соответствует соглашению в руководстве C++.

Двоеточие является необязательным. TODO коментарии должны включать слово TODO заглавными буквами, а затем ваше имя в круглых скобках. Предпочтительно также указывать номер бага/тикета рядом с элементом TODO.

Пример:

# TODO(mrmonkey): Handle the unlikely edge cases (bug ####)

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

Отступы

Без табов. Отступ 2 пробела.

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

Длина строк и длина значений

Максимальная длина линии — 80 символов.

Литеральные значения, которые могут быть длиннее чем 80 символов и не могут быть разделены разумно разрешены, но настоятельно рекомендуется найти способ сделать их короче. Если у вас есть необходимость в написании строк длиной более 80 символов, это должно быть сделано с помощью here document или, если это возможно, встроенным newline.

# Используйте 'here document's
cat <<END;
I am an exceptionally long
string.
END # Включенные newlines подходят тоже
long_string="I am an exceptionally long string."

Пайплайны

Пайплайны должны быть разделены каждый на одну строку, если они не помещаются на одной строке.

Если папйплайн помещается в одну строку, он должен быть на одной строке.

Это относится к цепочке команд, объединенной с использованием '|' а также к логическим соединениям, использующим '||' и '&&'. Если нет, его следует разделить, что бы каждая секция находился на новой строке и отступом на 2 пробела для следующей секции.

# Все помещается на одной линии
command1 | command2 # Длинные комманды
command1 \ | command2 \ | command3 \ | command4

Циклы

Помещайте ; do и ; then на тойже линии что и while, for или if.

То есть: ; then и ; doдолжны быть в той же строке, что и if/for/while. Циклы в оболочке немного разные, но мы следуем тем же принципам как и с фигурными скобками при объявлении функций. else должен быть в отдельной строке, а закрывающие операторы должны быть на собственной линии, вертикально выровненной с открывающей инструкцией.

Пример:

for dir in ${dirs_to_cleanup}; do if [[ -d "${dir}/${ORACLE_SID}" ]]; then log_date "Cleaning up old files in ${dir}/${ORACLE_SID}" rm "${dir}/${ORACLE_SID}/"* if [[ "$?" -ne 0 ]]; then error_message fi else mkdir -p "${dir}/${ORACLE_SID}" if [[ "$?" -ne 0 ]]; then error_message fi fi
done

Оператор case

  • Отделяйте варианты в 2 пробела.
  • Для однострочных вариантов требуется пробел после закрывающей скобки шаблона и перед ;;.
  • Длинные или многокомандная варианты должны быть разделены на несколько строк с шаблоном, действиями и ;; на раздельные строки.

Многострочные действия так же имеют отступы на отдельный уровнь. Соответствующие выражения отступают на один уровень от case и esac. Шаблонам выражений не должны предшествовать открытые круглые скобки. Нет необходимости помещать выражения в кавычки. Избегайте использование &; и ;;& обозначений.

case "${expression}" in a) variable="..." some_command "${variable}" "${other_expr}" ... ;; absolute) actions="relative" another_command "${actions}" "${other_expr}" ... ;; *) error "Unexpected expression '${expression}'" ;;
esac

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

verbose='false'
aflag=''
bflag=''
files=''
while getopts 'abf:v' flag; do case "${flag}" in a) aflag='true' ;; b) bflag='true' ;; f) files="${OPTARG}" ;; v) verbose='true' ;; *) error "Unexpected option ${flag}" ;; esac
done

Расширение переменных

В порядке приоритета: соблюдайте то, что уже используется; добавляйте свои переменные в кавычки; предпочитайте "${var}" перед "$var", но уточняйте детали.

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

  1. Импользуйте тот же стиль, что вы найдете в существующем коде.
  2. Помещайте переменные в кавычки, смотрите раздел Кавычки ниже.
  3. Не помещайте в кавычки единичные символы специфичные для shell / позиционные параметры, если это строго не необходимо и во избежании глубокой путаницы.
    Предпочитайте фигурные скобки для всех остальных переменных.

    # Раздел рекомендуемых случаев # Придерживайтесь стиля для 'специальных' переменных: echo "Positional: $1" "$5" "$3" echo "Specials: !=$!, -=$-, _=$_. ?=$?, #=$# *=$* @=$@ \$=$$ ..." # Фигурные скобки обязательны: echo "many parameters: ${10}" # Фигурные скобки исключающие путаницу: # Output is "a0b0c0" set -- a b c echo "${1}0${2}0${3}0" # Предпочтительный стиль для остальных переменных: echo "PATH=${PATH}, PWD=${PWD}, mine=${some_var}" while read f; do echo "file=${f}" done < <(ls -l /tmp) # Раздел нежелательных случаев # Переменные без кавычек, переменные без фигурных скобок, # единичные символы в фигурных скобках, специфичные для shell echo a=$avar "b=$bvar" "PID=${$}" "${1}" # Неправильное использование: # должно расскрываться как "${1}0${2}0${3}0", а не "${10}${20}${30} set -- a b c echo "$10$20$30"

    • Всегда используйте кавычки для значений, содержащие переменные, подстановки команд, пробелы или метасимволы оболочки, до тех пор пока не требуется безопасное расскрытие значений не в кавычках.
    • Предпочитайте кавычки для значений которые являются "словами" (в отличие от параметров команд или имен путей)
    • Никогда не помещайте в кавычки целые числа.
    • Знайте как работают кавычки для шаблонов совпадений в [[.
    • Используйте "$@", если у вас особых причин использовать $*.

# 'Одинарные' кавычки указывают, что никакой подстановки не требуется.
# "Двойные" кавычки указывают, что полстановка необходима/допускается. # Простые примеры
# "подстановка комманды в кавычках"
flag="$(some_command and its args "$@" 'quoted separately')" # "переменные в кавычах"
echo "${flag}" # "никогда помещайте целые числа в кавычки"
value=32
# "помещайте подстановку комманд в кавычки", даже если вы ожидаете числа
number="$(generate_number)" # "Используйте кавычки для слов", но не обязательно
readonly USE_INTEGER='true' # "Используйте кавычки для специальных мета-символов shell"
echo 'Hello stranger, and well met. Earn lots of $$$'
echo "Process $$: Done making \$\$\$." # "опции комманд и имена путей"
# (Здесь предполагается, что $1 содержит значение)
grep -li Hugo /dev/null "$1" # Менее простые примеры
# "Используйте кавычки для переменных, если не доказанно что": ccs не может быть пустым
git send-email --to "${reviewers}" ${ccs:+"--cc" "${ccs}"} # Предикат позиционного параметра: $1 модет быть удален
# Одинарные кавычки оставляют регулярное выражение как есть.
grep -cP '([Ss]pecial|\|?characters*)$' ${1:+"$1"} # Для передачи аргументов,
# "$@" правильно почти всегда, и
# $* неправильно почти всегда
#
# * $* и $@ будут разделены пробелами, разбивая аргументы
# которые содердат пропуски пропуская пустые значения;
# * "$@" будет передавать аргументы как есть, так что
# никакие из переданныз аргументов не будут потеряны;
# В большинстве случаев это то, что вы и хотите получить
# передавая аргументы
# * "$*" расскрывается в один аргумент, соединяя остальные аргументы
# в один разделяя их (обычно) пробелами,
# Так что отсутсвие аргументов передаст пустую строку
# (Почитайте 'man bash' для nit-grits 😉 set -- 1 "2 two" "3 three tres"; echo $# ; set -- "$*"; echo "$#, $@")
set -- 1 "2 two" "3 three tres"; echo $# ; set -- "$@"; echo "$#, $@")

Подстановка комманд

Используйте $(command) вместо обратных кавычек.

Формат $ (command) не изменяется в зависимости от вложенности и его легче читать. Вложенные обратные кавычки требуют экранирования внутренних с помощью \.

Пример:

# Это предпочтительнее:
var="$(command "$(command1)")" # Это нет:
var="`command \`command1\``"

Проверки, [ и [[

]] более предпочтительнее чем [, test или /usr/bin/[. [[ ...

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

# Это гарантирует, что строка слева состоит из символов
# типа `alnum`, за которым следует имя строки.
# Обратите внимание, что правая сторона не должена быть
# в кавычках. Для подробностей, смотрите
# E14 по адресу https://tiswww.case.edu/php/chet/bash/FAQ
if [[ "filename" =~ ^[[:alnum:]]+name ]]; then echo "Match"
fi # Это соответствует точному шаблону "f*" (в данном случае не сработает)
if [[ "filename" == "f*" ]]; then echo "Match"
fi # Это выдаст ошибку "too many arguments", так как f* будет расскрыт
# в содержимое текущей директории
if [ "filename" == f* ]; then echo "Match"
fi

Проверка значений

Используйте кавычки, а не дополнительные символы, где это возможно.

Поэтому, полученный код намного проще читать, используйте проверки для пустых/непустых значений или пустых значений, без использования дополнительных символов. Bash достаточно умен, чтобы работать с пустой строкой в тесте.

# Делайте так:
if [[ "${my_var}" = "some_string" ]]; then do_something
fi # -z (длина строки равна нулю), и -n (длина строки не равна нулю):
# предпочтительнее для проверки пустого значения
if [[ -z "${my_var}" ]]; then do_something
fi # Это допустимо (пустые кавычки), но не рекомендуется:
if [[ "${my_var}" = "" ]]; then do_something
fi # Но не так:
if [[ "${my_var}X" = "some_stringX" ]]; then do_something
fi

Чтобы избежать путаницы в том, что вы проверяеете, явно используйте -z или -n.

# Используйте это
if [[ -n "${my_var}" ]]; then do_something
fi # Вместо этого, поскольку ошибки могут возникать, если ${my_var}
# расскроется в флаг для проверки.
if [[ "${my_var}" ]]; then do_something
fi

Выражения подстановки для имен файлов

Используйте явный путь при создании выражений подстановки для имен файлов.

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

# Вот содержимое каталога:
# -f -r somedir somefile # Это удаляет почти все в директории с force
psa@bilby$ rm -v *
removed directory: `somedir'
removed `somefile' # В отличие от:
psa@bilby$ rm -v ./*
removed `./-f'
removed `./-r'
rm: cannot remove `./somedir': Is a directory
removed `./somefile'

Eval

eval следует избегать.

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

# Что тут установленно?
# Успешно ли завершилось? Частично или полностью?
eval $(set_my_variables) # Что произойдет, если одно из возвращаемых значений имеет в нем пробел?
variable="$(eval some_function)"

Пайпы в While

Переменные, измененные в цикле while, не распространяются на родителя, потому что команды цикла выполняются в сабшелле. Используйте подстановку команд или цикл for, предпочтительнее пайпов в while.

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

last_line='NULL'
your_command | while read line; do last_line="${line}"
done # Это вернет 'NULL'
echo "${last_line}"

Используйте цикл for, если вы уверены, что ввод не будет содержать пробелы или специальные символы (обычно это не предполагает пользовательский ввод).

total=0
# Делайте так, только если в возвращаемых значениях отсутствуют пробелы.
for value in $(command); do total+="${value}"
done

Использование подстановки комманды позволяет перенаправить вывод, но выполняет команды в явном сабшеле, в отличии неявного сабшела, который создает bash для цикла while.

total=0
last_file=
while read count filename; do total+="${count}" last_file="${filename}"
done < <(your_command | uniq -c) # Это выведет второе поле последней строки вывода из
# комманды.
echo "Total = ${total}"
echo "Last one = ${last_file}"

Помните, что простые примеры, порой, гораздо проще решить с использованием такого инструмента, как awk. Используйте циклы while, где нет необходимости передавать сложные результаты в родительский shell — это типично, когда требуется более сложный "парсинг". Это также может быть полезно, когда вы специально не хотите изменять переменные родительской среды.

# Тривиальная реализация выражения awk:
# awk '$3 == "nfs" { print $2 " maps to " $1 }' /proc/mounts
cat /proc/mounts | while read src dest type opts rest; do if [[ ${type} == "nfs" ]]; then echo "NFS ${dest} maps to ${src}" fi
done

Названия функций

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

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

# Одиночная функция
my_func() { ...
} # Часть пакета
mypackage::my_func() { ...
}

Ключевое слово функции неотделенно от (), когда оно присутствует после имени функции, это улучшает их быструю идентификацию как функций.

Название переменных

Что касается имен функций.

Имена переменных для циклов должны быть одинаково названы для любой переменной, которую вы перебираете.

for zone in ${zones}; do something_with "${zone}"
done

Названия константы переменных окружения

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

Константы и всё, что экспортируется в окружение, должны быть в верхнем регистре.

# Константа
readonly PATH_TO_FILES='/some/path' # Константа, и переменная
declare -xr ORACLE_SID='PROD'

Таким образом, это вполне нормально устанавливать константу через getopts или на основе условия, но она должна быть сделана readonly сразу после этого. Некоторые вещи остаются постоянными при их первой установке (например, через getopts). Обратите внимание, что declare не работает с глобальными переменными внутри функций, поэтому рекомендуется readonly или export вместо этого.

VERBOSE='false'
while getopts 'v' flag; do case "${flag}" in v) VERBOSE='true' ;; esac
done
readonly VERBOSE

Назания исходных файлов

Нижний регистр, с подчеркиванием для разделения слов, если это необходимо.

Это касается соответствия другим стилям кода в Google: maketemplate или make_template, но не make-template.

Переменные только для чтения

Используйте readonly или declare -r, чтобы убедиться, что они только для чтения.

Когда вы объявляете переменную, предназначенную только для чтения, сделайте это явным. Поскольку глобальные широко используются в shell, важно уловить ошибки при работе с ними.

zip_version="$(dpkg --status zip | grep Version: | cut -d ' ' -f 2)"
if [[ -z "${zip_version}" ]]; then error_message
else readonly zip_version
fi

Использование локальных переменных

Объявление и назначение должны выполняться на разных строках. Объявляйте переменные, зависящие от функции, с local.

Это позволяет избежать загрязнения глобального пространства имен и непреднамеренно устанавливать переменные, которые могут иметь значение вне функции. Убедитесь, что локальные переменные видны только внутри функции и ее дочерних элементов, используя local при их объявлении.

Объявление и присвоение должны идти разными командами, когда значение присваивания обеспечивается подстановкой команды; поскольку local не обрабатывает exit code из подстановленной команды.

my_func2() { local name="$1" # Разделяйте строки для декларации и назначения: local my_var my_var="$(my_func)" || return # НЕ ДЕЛАЙТЕ этого: $? содержит exit code от 'local', а не my_func local my_var="$(my_func)" [[ $? -eq 0 ]] || return ...
}

Расположение функций

Не скрывайте исполняемый код между функциями. Поместите все функции в файл чуть ниже констант.

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

Это делает код трудным для подражания и приводит к неприятным неожиданностям при отладке. Не скрывайте исполняемый код между функциями.

main

Функция, называемая main, требуется для сценариев достаточно длинных, чтобы содержать хотя бы одну другую функцию.

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

main "$@"

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

Проверка возвращаемых значений

Всегда проверяйте возвращаемые значения и дайте информативные возвращаемые значения.

Для команд не использующих пайплайн используйте $? или провяйте непосредственно через оператор if, чтобы было проще.

Пример:

if ! mv "${file_list}" "${dest_dir}/" ; then echo "Unable to move ${file_list} to ${dest_dir}" >&2 exit "${E_BAD_MOVE}"
fi # Или
mv "${file_list}" "${dest_dir}/"
if [[ "$?" -ne 0 ]]; then echo "Unable to move ${file_list} to ${dest_dir}" >&2 exit "${E_BAD_MOVE}"
fi Bash также имеет переменную `PIPESTATUS`, которая позволяет проверять код возврата со всех частей пайплайна. Если необходимо проверить усшно ли завершен или произошел отказ всего пайпа, то приемлемо следующее: ```bash
tar -cf - ./* | ( cd "${dir}" && tar -xf - )
if [[ "${PIPESTATUS[0]}" -ne 0 || "${PIPESTATUS[1]}" -ne 0 ]]; then echo "Unable to tar files to ${dir}" >&2
fi

Однако, так как PIPESTATUS будет перезаписан, сразу как только вы выполните какую-либо другую команду, если вам действительно нужно обработать все события где произошла ошибка в пайплайне, необходимо переназначить PIPESTATUS другой переменной сразу после запуска команды (не забывайте, что [ является командой и уничтожит PIPESTATUS).

tar -cf - ./* | ( cd "${DIR}" && tar -xf - )
return_codes=(${PIPESTATUS[*]})
if [[ "${return_codes[0]}" -ne 0 ]]; then do_something
fi
if [[ "${return_codes[1]}" -ne 0 ]]; then do_something_else
fi

Встроенные функции или Внешние комманды

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

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

Пример:

# Предпочитайте это:
addition=$((${X} + ${Y}))
substitution="${string/#foo/bar}" # Вместо этого:
addition="$(expr ${X} + ${Y})"
substitution="$(echo "${string}" | sed -e 's/^foo/bar/')"

Используйте здравый смысл и БУДЬТЕ КОНСИСТЕНТНЫ.

Пожалуйста уделите несколько минут, чтобы прочитать раздел Parting Words в нижней части руководства C++.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

OpenSceneGraph: Групповые узлы, узлы трансформации и узлы-переключатели

Когда происходит рисование точки, линии или сложного полигона в трехмерном мире, финальный результат, в конечном итоге, будет изображен на плоском, двухмерном экране. Соответственно, трехмерные объекты проходят некий путь преобразования, превращаясь в набор пикселей, выводимых в двумерное окно. Идеологически и «чистые» ...

«Монстры в играх или 15 см достаточно для атаки»

Спасибо читателям за проявленный интерес, рад что материалы полезны. Представляю вам четвертую статью цикла.Сегодня я хотел бы разобрать такой важный элемент для любого монстра — дистанция атаки. Их существует всего три: ближняя средняя дальняя В первой части разберем типичные примеры ...