Сообщества
GameDev
GameDev

GameDev

Постов 26
Подписчиков 4

О сообществе

Посты, посвящённые разработке игры

Разрешено

Писать про разработку игр

Запрещено

Писать не про разработку игр

Менеджер пакетов для Godot

Но не плагин, а вшитый прям в движок

0
Читалка

Стоит оговориться, что речь не просто про 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 и добавить туда ссылку на наше окно, добавить пункт меню и его обработчик для открытия нашего окна

_menuPopup.AddItem("NuGet Packages...".TTR(), (int)MenuOptions.NuGetPackages);
private enum MenuOptions
{
    CreateSln,
    NuGetPackages,
}
private void OpenNuGetManager()
        {
            if (!File.Exists(GodotSharpDirs.ProjectCsProjPath))
            {
                ShowErrorDialog("No C# project found. Create a C# solution first.".TTR());
                return;
            }

            if (_nugetWindow == null)
            {
                _nugetWindow = new NuGetManagerWindow();
                _nugetWindow.SetUnparentWhenInvisible(true);
            }

            EditorInterface.Singleton.PopupDialogCentered(_nugetWindow);
        }

На выходе получаем удобный способ управления внешними пакетами

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

«Танчики» на HTML5

Доделано

0

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

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

Ещё раз всем спасибо! Ушёл писать какую-нибудь очередную ерунду.


Всем хороших игр!

Движок для визуальных новел

@variussoft/vn-engine

0

Пост совершенно незапланированный, а стихийный. Так уж получилось, что я написал свой движок для создания визуальных новелл. Сделал я это по весьма простой причине: главенствует на этом поприще РенПай. Но как я смог понять, там всё печально. Использовать Юнити, Годот, Анреал для таких целей – выстрел из пушки по воробьям.

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

Собирался сделать это чисто для себя и зарегистрировал на npm чисто чтобы было удобнее обновлять на реальных проектах. Но в итоге за двое суток существования движка набралась почти тысяча скачиваний

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

Для создания шаблона игры достаточно выполнить команду:

npm create @variussoft/vn-game my-game

Обновить движок в уже существующем проекте можно командой:

npm i @variussoft/vn-engine

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

Обратную связь пишите здесь в комментариях или на почту: nick@variussoft.ru

«Танчики» на HTML5

Разные виды врагов и бонусы

0
Читалка

Игра почти готова, но не хватает ещё парочки нюансов)

Сразу смотрим результат, а я пока распишу, что же тут происходит

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();
        }
    }
}

Вообще в данном случае было бы логично вынести логику сброса эффекта бонуса в сам бонус. Чтоб он там в фоне считался, а ядру просто отдавал событие или менял внутри себя флаг. Как будто там будет почище. Подумал об этом только сейчас, отрефакторю к следующему выпуску.

Следующий этап уже не про новые фичи, а про полировку, оптимизацию и доработку.

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


Всем хороших игр!