А теперь к сути дела. Я, кажется, писал про эту фигню ещё на капи (а может уже и тут, но в самом начале).
В общем, я разрабатываю приложение для удобного создания и ведения персонажей по ДнД 5e.
Параллельно я разрабатываю ещё и программу для мастеров для ведения игр. И эти приложеньки будут взаимосвязаны между собой (получение урона, начисление опыта, выдача предметов и т.д.).
1 из 6
Собсна... в чём вопрос-то. Я не умею писать мобильные прилоржения. Думаю, это понятно, исходя из того, что у Лиспублики всё ещё нет нативного своего. Но я учусь и для практики взял для себя вот этот вот проект.
Но мне нужна помощь в тестировании. Если вы вдруг шарите за ДнД и готовы помучиться попользоваться недоделкой и покидаться в меня критикой и обратной связью, то я буду крайне рад!
На сегодняшний день приложение умеет только создавать нового персонажа и пока больше ничего.
Стоит оговориться, что речь не просто про Godot как таковой. Потому как я сделал форк Redot'а (форка Godot'а) и там уже в исходниках движка внёс исправления.
Мне показалось странным, что такой базовой вещи, как менеджер пакетов нет в движке из коробки, поэтому я вооружился буквами и пошёл насыпать их в код. В качестве первого этапа разработки подключил АПИ NuGet'а, дальше в будущем планирую добавить добавление пакетов из гита и в последствии, думаю, это будет логично, планирую поднять собственный репозиторий, чтобы хранить там свои какие-то переиспользуемые пакеты или пакеты, предназначенные только для движка.
Вообще, если вы используете для работы не встроенный редактор скриптов, а VS, то стандартного нюгет пакета вполне хватит на первое время, но так как я сморю чуть дальше и чуть глубже, я пошёл по пути собственного инструмента. Дополнительно: я пишу на шарпе, а Годот по умолчанию работает с GDScript и шарп у него подключается отдельно. Я сбилдил свою версию так, чтоб она по умолчанию работала с шарпом.
И так, что же у нас тут по менеджеру пакетов?
В папочке modules/mono/editor/GodotTools/GodotTools/ создаём новую папку нашего инструмента, я назвал её NuGet на текущем этапе. При доработках думаю, переименую уже во что-то более универсальное.
Внутри папки создаётся три файла: NuGetManagerWindow.cs
Класс описывающий окно, наследует и имплементрирует ConfirmationDialog, ISerializationListener. Описать обязательно несколько методов:
public override void _Ready()
{
Title = "NuGet Package Manager";
Size = new Vector2I(800, 600);
Exclusive = true;
_apiClient = new NuGetApiClient();
BuildUI();
RefreshInstalledPackages();
}
BuildUI и RefreshInstalledPackages соответственно это не обязательные прям с точки зрения Годота методы, просто я в них вынес нужную мне логику.
Второй класс NuGetPackageInfo - это буквально описание каждого пакета. То есть просто DTO для пакетов с основной информации:
/// <summary>
/// Represents a NuGet package from search results.
/// </summary>
public class NuGetPackageInfo
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
[JsonPropertyName("authors")]
[JsonConverter(typeof(StringOrArrayConverter))]
public List<string> Authors { get; set; } = new();
[JsonPropertyName("projectUrl")]
public string ProjectUrl { get; set; } = string.Empty;
[JsonPropertyName("iconUrl")]
public string IconUrl { get; set; } = string.Empty;
[JsonPropertyName("totalDownloads")]
public long TotalDownloads { get; set; }
[JsonPropertyName("versions")]
public List<NuGetVersionInfo> Versions { get; set; } = new();
public string AuthorsString => string.Join(", ", Authors);
}
Ну и NuGetApiClient – логично, что дёргает запросы в сторону нюгета, для получения информации о пакетах.
После того, как мы всё что нужно описали, надо отредактировать GodotSharpEditor.cs и добавить туда ссылку на наше окно, добавить пункт меню и его обработчик для открытия нашего окна
Короткий познавательный пост, основанный на моей больной фантазии. Все совпадения случайны.
Обратился как-то один человек на профильном форуме с вопросом, как запустить (не сказать чтобы старую) игру на Windows 10. Игра (хотя я бы назвал это технодемо) была разработана на легендарном XNA 4.0 (земля пухом) и планировалась к выпуску через Steam Greenlight (и ему земля пухом) еще в далеком 2012, после чего заброшена и убрана со всех магазинов.
Игра была куплена тем человеком еще в тот момент, когда она легально продавалась. А спустя годы она просто перестала подавать признаки жизни. $5 за полную версию уплочены, а поиграть нельзя. Обидно.
Так как типовые решения по типу установки XNA Redistributable не помогли, то был предложен единственно возможный выход из ситуации - отладка через dnSpy.
Внимание: обратная разработка хоть прямо и не запрещена законодательно, но разрешенные случаи весьма ограниченны. В других странах разрешен явный запрет любых манипуляций через лицензию, превращающий это занятие во вполне наказуемое. Не повторяйте дома.
Так как ПО было приобретено легально и оно не работает, то этот теоретический случай допустим в соответствии со статьей 1280 гражданского кодекса.
Демонстрировать буду на бесплатной демоверсии, скачать можно здесь.
dnSpy
Театр начинается с вешалки, а программа на C# с атрибутов сборки. Они нам сейчас не сильно интересны, поэтому пытаемся сразу запускать отладку. С неизвестными программами лучше использовать виртуальную машину, так как может быть встроена какая-нибудь пакость для кулхацкеров, но мне можно и так.
Ошибка. Открываем стек вызовов и ищем место, где произошел провальный запрос в сеть.
А вот и виновник торжества. По сообщению из исключения можно сделать вполне однозначный вывод, что проблемой является скупердяйство и непредусмотрительность автора, который решил перестать платить за домен и хостинг, а вместе с этим случайно (а может и намеренно) сделал запуск игры невозможным без интернета. Так как на сайте заявлено отсутствие DRM, то это явно недосмотр.
Что тут происходит?
Если почитать документацию на XNA, то этот метод предназначен для загрузки контента и вызывается автоматически при запуске игры. Первым делом выполняется инициализация некоторых вещей, после чего открывается файл настроек (options.xml) и начинает последовательно считываться. Как только считываются параметры аккаунта для таблицы рекордов, то происходит отправка логина+пароля на официальный сайт и попытка получить ID. Если сервер возвращает Failure, то открывается диалог входа, который можно пропустить. Если сервер возвращает число, то игра запускается. А вот если сервер недоступен, то вылетает исключение, которое никак не обрабатывается и приводит к молчаливому вылету.
Диалог входа официально можно пропустить.
На этом этапе есть 2 варианта действий:
Вручную отредактировать настройки и убрать пункт об аккаунте. Тогда игра будет всегда запускаться без входа.
Физически убрать запрос на несуществующий сайт.
Нормальные герои всегда идут в обход, так что переходим к написанию небольшого патча. (На самом деле, о первом варианте я додумался не сразу. Более того, полная версия зависает при убирании этого пункта из настроек).
Выделяем строчку с запросом и нажимает "Изменить инструкции IL". Открывается окно с заголовком "Изменение тела метода".
Это - язык MSIL. Он очень прост и сильно напоминает Forth, на самом деле, но новичка может отпугнуть.
Ключевым элементом является стек. Что такое стек я уже когда-то рассказывал. Это некоторое место, на вершину которого можно последовательно добавлять значения (числа, строки и прочие объекты), а так же снимать их в обратном порядке.
Не эти стеки, но суть та же. Можно добавлять сверху новые фишки и снимать с вершины старые.
ldloc.2: Значение локальной переменной под номером 2 (с типом строки)
ldstr: Строку "&p=".
ldloc.3: Значение локальной переменной под номером 3 (тоже строка).
Потом мы вызываем String::Concat(), принимающий в себя 4 строки и соединяющий их вместе. При вызове метода будут сняты 4 верхние строки со стека и добавлена новая строка.
Вызов следующего метода (WebClient::DownloadString()) снимает строку, полученную от String::Concat(), и веб-клиент. В результате должна остаться только 1 строка, возвращенная WebClient::DownloadString().
Следующим этапом мы должны снять эту строку со стека и сохранить её в локальную переменную 1, но этого не произойдет, так как WebClient::DownloadString() всегда будет проваливаться и выбрасывать исключение. Исключение, если его не обработать сразу, будет раз за разом проваливаться глубже по стеку вызовов, пока не достигнет обработчика или дна. Так как никаких обработчиков по пути нет, то исключение достигает дна и приводит к вылету.
Чтобы избежать получения исключения, мы можем убрать всё, что предназначено для запроса в сеть. Примерно вот так:
8 инструкций превратились в 2: загрузка строки с числом (которое может быть любым) и сохранение в локальную переменную под номером 1.
Сохраняем и проверяем результат.
Было.
Стало.
Из декомпилированного кода полностью исчез кусок с запросом. Осталась лишь загрузка константной строки и сравнение с "Failure", которое всегда будет успешным.
Сохраняем модуль и пытаемся запускать опять.
Жопиздан!
С этой задачей успешно справился человек, который не смыслит в программировании вообще ничего. По его словам, он джва года ждал эту игру!
Но не всё так просто. Если попытаться пройти любой уровень, то вылетит то же самое исключение.
Для исправления точно так же выделяем строку и опять редактируем инструкции в теле метода. Но так как отправка статистики нонче ни на что не влияет, то просто удаляем выделенные инструкции без остатка.
А для надежности выделяем WebClient, нажимаем "Анализировать" и смотрим, где еще может ждать нас подлянка.
В Finish.Update() мы ошибку только что исправили, в Game1.LoadContent() исправили в самом начале, а Login.button1_Click(), Program.Main() и Title.Update() нас не интересуют, так как достаточно просто не делать того, что приведет к обращению в сеть (а конкретно: не открывать диалог входа, не указывать никакие аргументы при запуске и не пытаться авторизоваться через главное меню (вроде бы не реализовано)).
Прошлой ночью мы накатили обновление на Лиспублику, о нём вы можете прочитать в официальном посте, но там не было рассказано маааа-а-а-аленькое обновленьице. А дело всё в том, что мы запустили на базе Лиспублики игровую платформу. Называется она Лиспублика-Аркады.
На данный момент там лежат мои поделки, о которых я тут много писал. Вы можете зайти, поиграть, посоревноваться друг с другом (есть таблицы лидеров), играйте совместно (в танчиках есть есть онлайн режим :) ).
А ещё! И это очень важно, если вы уметете создавать игры, способные работать под веб, то загружайте их к нам!
У платформы есть SDK, который даёт вам доступ к основному функционалу: сохранение прогресса, сохранение рекордов, накопление и расходование токенов(если ваша игра хочет, чтоб игроки тратили некие очки). Если будут идеи по расширению функционала, я всегда готов почитать ваши предложения в комментариях!
Игра почти готова, но не хватает ещё парочки нюансов)
Сразу смотрим результат, а я пока распишу, что же тут происходит
1. Я добавил разные типы врагов. Как и в оригинале, это базовые, быстроходные и бронированные. Последние плюсом ко всему имеют бронебойные патроны.
2. Я добавил новый вид бонуса: «Строитель». Поднимая мастерок игрок может построить несколько бетонных и несколько кирпичных блоков. Именно для баланса этого бонуса и введены вражеские танки, которые могут уничтожать в том числе и бетонные стены.
Давайте смотреть
export class Bonus {
/**
* По всему коду натыкал как можно больше комментов, чтоб понятно было куда собака зарыта
*
* @param {number} x - Логическая координата X
* @param {number} y - Логическая координата Y
* @param {string} type - Тип бонуса (BonusType.STAR, BonusType.GRENADE, etc.)
*/
constructor(x, y, type) {
this.x = x;
this.y = y;
this.type = type;
// Время жизни бонуса
this.lifetime = Duration.BONUS_LIFETIME;
this.blinkTime = Duration.BONUS_BLINK_TIME;
// Состояние
this.collected = false;
this.expired = false;
// Анимация
this.animationFrame = 0;
this.animationTimer = 0;
this.animationSpeed = 6; // Кадров игры между сменой кадра анимации
// Мигание перед исчезновением
this.blinkTimer = 0;
this.blinkSpeed = 4; // Быстрое мигание
this.visible = true;
}
/**
* Обновление состояния бонуса
*/
update() {
if (this.collected || this.expired) return;
// Уменьшаем время жизни
this.lifetime--;
// Проверка истечения времени
if (this.lifetime <= 0) {
this.expired = true;
return;
}
// Анимация спрайта
this.animationTimer++;
if (this.animationTimer >= this.animationSpeed) {
this.animationTimer = 0;
this.animationFrame = (this.animationFrame + 1) % BONUS_SPRITE.frameCount;
}
// Мигание перед исчезновением
if (this.lifetime <= (Duration.BONUS_LIFETIME - this.blinkTime)) {
this.blinkTimer++;
if (this.blinkTimer >= this.blinkSpeed) {
this.blinkTimer = 0;
this.visible = !this.visible;
}
}
}
/**
* Сбор бонуса игроком
*/
collect() {
this.collected = true;
}
/**
* Проверка, истёк ли бонус
*/
isExpired() {
return this.expired || this.collected;
}
/**
* Получить bounding box в логических координатах
*/
getBounds() {
return {
x: this.x,
y: this.y,
width: BONUS_SIZE,
height: BONUS_SIZE
};
}
/**
* Отрисовка бонуса
*/
render() {
if (this.collected || this.expired) return;
if (!this.visible) return; // Мигание
const ctx = foregroundCtx;
// Конвертация логических координат в физические
const px = GAME_FIELD_X + this.x * GAME_SCALE;
const py = GAME_FIELD_Y + this.y * GAME_SCALE;
const size = BONUS_SIZE * GAME_SCALE;
const sprite = getBonusSprite(this.type);
if (sprite) {
const frameX = this.animationFrame * BONUS_SPRITE.frameWidth;
ctx.drawImage(
sprite,
frameX, 0,
BONUS_SPRITE.frameWidth, BONUS_SPRITE.frameHeight,
px, py,
size, size
);
} else {
// Fallback: цветной квадрат
this.renderFallback(ctx, px, py, size);
}
}
/**
* Fallback отрисовка (цветной квадрат с буквой)
*/
renderFallback(ctx, px, py, size) {
// Цвета для разных типов бонусов
const colors = {
[BonusType.STAR]: '#FFFF00',
[BonusType.GRENADE]: '#FF4500',
[BonusType.TIMER]: '#00BFFF',
[BonusType.SHOVEL]: '#8B4513',
[BonusType.TANK]: '#00FF00',
[BonusType.HELMET]: '#C0C0C0',
[BonusType.GUN]: '#FF00FF',
[BonusType.BUILDER]: '#334455'
};
const letters = {
[BonusType.STAR]: 'S',
[BonusType.GRENADE]: 'G',
[BonusType.TIMER]: 'T',
[BonusType.SHOVEL]: 'L',
[BonusType.TANK]: '+',
[BonusType.HELMET]: 'H',
[BonusType.GUN]: 'P',
[BonusType.BUILDER]: 'B'
};
ctx.fillStyle = colors[this.type] || '#FFFFFF';
ctx.fillRect(px + 2, py + 2, size - 4, size - 4);
ctx.fillStyle = '#000000';
ctx.font = `bold ${size * 0.6}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(letters[this.type] || '?', px + size / 2, py + size / 2);
}
}
Где и как учитываются бонусы?
Во-первый в main.js я сразу создаю бонус для строительства
// DEBUG: Тестовый бонус Builder рядом с игроком
const testBonus = new Bonus(PLAYER_SPAWN_POINT.x + 32, PLAYER_SPAWN_POINT.y - 32, BonusType.BUILDER);
bonuses.push(testBonus);
Это сделано для тестов, но, возможно, я даже оставлю в конечном варианте
Дальше в кор геймплее
const collectedBonus = checkTankBonusCollision(player, bonuses);
if (collectedBonus) {
applyBonusEffect(collectedBonus);
}
// И дальше по коду сам метод
function applyBonusEffect(bonus) {
switch (bonus.type) {
case BonusType.STAR:
// Апгрейд оружия (до уровня 3 максимум)
if (player && player.bulletLevel < 3) {
player.bulletLevel++;
// Уровень 1+ даёт 2 пули
if (player.bulletLevel >= 1) {
player.bulletCount = 2;
}
}
break;
case BonusType.GUN:
// Мгновенный максимальный апгрейд
if (player) {
player.bulletLevel = 3;
player.bulletCount = 2;
}
break;
case BonusType.TANK:
// Дополнительная жизнь
playerLives++;
break;
case BonusType.HELMET:
// Временная неуязвимость
if (player) {
player.hasShield = true;
player.shieldDuration = Duration.EFFECT_SHIELD;
}
break;
case BonusType.TIMER:
// Заморозка врагов
freezeTimer = Duration.EFFECT_TIMER;
break;
case BonusType.GRENADE:
// Уничтожение всех врагов на экране
for (const enemy of enemies) {
if (!enemy.destroyed) {
enemy.destroyed = true;
// Создаём взрывы
const centerX = enemy.x + TANK_SIZE / 2;
const centerY = enemy.y + TANK_SIZE / 2;
effects.push(createBigExplosion(centerX, centerY));
}
}
break;
case BonusType.SHOVEL:
// Укрепление базы
shovelTimer = Duration.EFFECT_SHOVEL;
fortifyBase();
break;
case BonusType.BUILDER:
// Режим строителя: даёт блоки для размещения
if (player) {
player.builderBricks += 8;
player.builderSteel += 4;
}
break;
}
bonus.collect();
}
Далее все таймеры эффекты глобальные сбрасываются вот тут
function updateGlobalEffects() {
// Таймер заморозки
if (freezeTimer > 0) {
freezeTimer--;
}
// Таймер укрепления базы
if (shovelTimer > 0) {
shovelTimer--;
if (shovelTimer <= 0 && baseFortified) {
restoreBase();
}
}
}
Вообще в данном случае было бы логично вынести логику сброса эффекта бонуса в сам бонус. Чтоб он там в фоне считался, а ядру просто отдавал событие или менял внутри себя флаг. Как будто там будет почище. Подумал об этом только сейчас, отрефакторю к следующему выпуску.
Следующий этап уже не про новые фичи, а про полировку, оптимизацию и доработку.
Писал я план ещё в самом начале, так что часть из этих пунктов уже сделана, но всё равно нужно будет пройтись по всем.
да!!!
тоже. да. Вертелось где-то на задворках, но вспомнил про креветку.