Добавлен , опубликован

О проекте:

Раз уж пошла эпоха вайбкодинга, решил я тоже присоединиться к этому буму и создать свой проект. Всё чего касается этого проекта было либо навайбкожено, либо куплено специально для проекта.
Big Head Engine — RTS/RPG-прототип на Panda3D с редактором карт, data-driven контентом и первой активной картой Angel Arena.
Что уже есть:
  • Редактор размещения юнитов, регионов, декораций, terrain
  • Runtime scripts через MapScript
  • Кастомные unit attributes: love, hate, death, life
  • Hero select, leveling, inventory, HUD, multiplayer foundation
  • Asset pipeline для GLB/BAM, иконок, VFX particles
Чего планирую, что будет кардинально отличаться от Warcraft 3:
  • Тип карт с возможностью присоединиться в любой момент
  • Упор на работу с картой преимущественно в IDE
  • Хранение состояния карт на сервере. Потенциал для донатов, сейвов персонажей для RPG, активации событий на карте
  • Инвентарь 8 слотов, сумка 8 слотов, слоты для зелий
Чего не планирую:
  • Редактор объектов отсутствует, но ничего не составит труда навайбкодить его
В качестве полигона для испытаний я создал карту Angel Arena, разработчиком которого я был давным давно (А точнее версии World of Angel Arena)

Самое интересное, скринчики

Редактор:

Игра:

Документация:

Примеры скрипта

Скрипт на логику работы крипов как в Angel Arena
from __future__ import annotations

from big_head_engine.core.scripting import MapScript, ScriptEventRegistry, ScriptGameAPI


NEUTRAL_PLAYER_ID = 6
KILLS_TO_BOSS = 40
MAX_CREEP_LEVEL = 5
NORMAL_DROP_CHANCE_PERCENT = 5
RESPAWN_DELAY_SECONDS = 3.0
TELEPORT_EFFECT = "teleport flash"
GATE_OPEN_ANIMATION = "Open"

CREEP_SPAWN_REGIONS: dict[int, str] = {
    1: "region_015",
    2: "region_016",
    3: "region_017",
    4: "region_018",
    5: "region_019",
    6: "region_020",
    7: "region_021",
    8: "region_022",
}

CREEP_BOSS_REGIONS: dict[int, str] = {
    1: "region_027",
    2: "region_041",
    3: "region_035",
    4: "region_037",
    5: "region_040",
    6: "region_034",
    7: "region_032",
    8: "region_029",
}

CREEP_SET_ITEMS: dict[int, tuple[str, ...]] = {
    index: (
        f"creep_{index}_head",
        f"creep_{index}_gloves",
        f"creep_{index}_armor",
        f"creep_{index}_weapon",
        f"creep_{index}_boots",
        f"creep_{index}_pants",
    )
    for index in range(1, 9)
}


class AngelArenaCreeps(MapScript):
    id = "angel_arena_creeps"

    def __init__(self) -> None:
        self.kills_by_spawn: dict[int, int] = {index: 0 for index in range(1, 9)}
        self.spawn_level: dict[int, int] = {index: 1 for index in range(1, 9)}
        self.boss_active: dict[int, bool] = {index: False for index in range(1, 9)}
        self.boss_unit_ids: dict[str, int] = {}
        self.events: ScriptEventRegistry | None = None

    def register(self, events: ScriptEventRegistry, api: ScriptGameAPI) -> None:
        self.events = events
        events.on_unit_death(self.on_unit_death)
        events.every(0.5, self.refresh_labels)
        self.refresh_labels(api)

    def on_unit_death(self, unit: object, api: ScriptGameAPI) -> None:
        unit_id = self.unit_id(unit)
        if unit_id is None:
            return
        definition = getattr(unit, "definition", None)
        tags = list(getattr(definition, "tags", [])) if definition is not None else []
        spawn_index = self.creep_index(tags)
        if spawn_index is None:
            return

        killer = getattr(unit, "last_damage_source", None)
        killer_id = self.unit_id(killer)
        boss_spawn_index = self.boss_unit_ids.pop(unit_id, None)
        if boss_spawn_index is not None:
            self.on_boss_death(boss_spawn_index, unit, unit_id, killer_id, api)
            return

        if "normal_creep" not in tags:
            return
        self.on_normal_creep_death(spawn_index, unit_id, killer_id, api)

    def on_normal_creep_death(
        self,
        spawn_index: int,
        unit_id: str,
        killer_id: str | None,
        api: ScriptGameAPI,
    ) -> None:
        drop_seed = f"normal_drop:{spawn_index}"
        item_seed = f"normal_drop_item:{spawn_index}"
        drop_roll = api.roll_percent(drop_seed)
        did_drop = drop_roll < NORMAL_DROP_CHANCE_PERCENT
        if did_drop:
            item_id = api.choose(item_seed, CREEP_SET_ITEMS[spawn_index])
            if item_id is not None:
                self.give_or_drop_item(
                    item_id,
                    unit_id,
                    killer_id,
                    api,
                    level=self.spawn_level[spawn_index],
                )

        if not self.boss_active[spawn_index]:
            self.kills_by_spawn[spawn_index] = min(
                KILLS_TO_BOSS,
                self.kills_by_spawn[spawn_index] + 1,
            )
            self.refresh_label(spawn_index, api)
            if self.kills_by_spawn[spawn_index] >= KILLS_TO_BOSS:
                self.open_gate(spawn_index, api)
                self.spawn_boss(spawn_index, api)

        if self.events is not None:
            self.events.after(
                RESPAWN_DELAY_SECONDS,
                lambda game_api, spawn_index=spawn_index: self.spawn_normal_creep(
                    spawn_index,
                    game_api,
                ),
            )

    def on_boss_death(
        self,
        spawn_index: int,
        unit: object,
        unit_id: str,
        killer_id: str | None,
        api: ScriptGameAPI,
    ) -> None:
        item_id = api.choose(unit_id, CREEP_SET_ITEMS[spawn_index])
        if item_id is not None:
            boss_level = min(MAX_CREEP_LEVEL, self.spawn_level[spawn_index] + 1)
            self.give_or_drop_item(item_id, unit_id, killer_id, api, level=boss_level)
        if killer_id is not None:
            source_position = api.unit(killer_id).get_position()
            if source_position is not None:
                api.play_effect(TELEPORT_EFFECT, source_position, duration=1.2)
            api.unit(killer_id).teleport_to_region(CREEP_SPAWN_REGIONS[spawn_index])
            api.play_effect_at_region(
                TELEPORT_EFFECT,
                CREEP_SPAWN_REGIONS[spawn_index],
                duration=1.2,
            )

        self.boss_active[spawn_index] = False
        self.kills_by_spawn[spawn_index] = 0
        self.spawn_level[spawn_index] = min(
            MAX_CREEP_LEVEL,
            self.spawn_level[spawn_index] + 1,
        )
        self.close_gate(spawn_index, api)
        self.refresh_label(spawn_index, api)

    def spawn_boss(self, spawn_index: int, api: ScriptGameAPI) -> None:
        if self.boss_active[spawn_index]:
            return
        self.boss_active[spawn_index] = True
        boss_level = min(MAX_CREEP_LEVEL, self.spawn_level[spawn_index] + 1)
        boss_id = api.spawn_unit_in_region(
            f"creep_{spawn_index}_level_{boss_level}",
            NEUTRAL_PLAYER_ID,
            CREEP_BOSS_REGIONS[spawn_index],
        )
        if boss_id is None:
            self.boss_active[spawn_index] = False
            return
        self.boss_unit_ids[boss_id] = spawn_index
        for item_id in CREEP_SET_ITEMS[spawn_index]:
            boss_item_id = api.unit(boss_id).add_item(item_id)
            if boss_item_id is not None:
                api.set_item_level(boss_item_id, boss_level)

    def spawn_normal_creep(self, spawn_index: int, api: ScriptGameAPI) -> None:
        level = self.spawn_level[spawn_index]
        api.spawn_unit_in_region(
            f"creep_{spawn_index}_level_{level}",
            NEUTRAL_PLAYER_ID,
            CREEP_SPAWN_REGIONS[spawn_index],
        )

    def refresh_labels(self, api: ScriptGameAPI) -> None:
        for spawn_index in range(1, 9):
            self.refresh_label(spawn_index, api)

    def refresh_label(self, spawn_index: int, api: ScriptGameAPI) -> None:
        count = self.kills_by_spawn[spawn_index]
        level = self.spawn_level[spawn_index]
        suffix = f" L{level}"
        if self.boss_active[spawn_index]:
            suffix += " Boss"
        api.set_floating_label_at_region(
            f"creep_spawn_counter_{spawn_index}",
            f"{count}/{KILLS_TO_BOSS}{suffix}",
            CREEP_SPAWN_REGIONS[spawn_index],
            z=2.6,
        )

    def open_gate(self, spawn_index: int, api: ScriptGameAPI) -> None:
        gate = api.decoration(f"creep_gate_{spawn_index}")
        gate.set_collision_enabled(False)
        gate.play_animation(GATE_OPEN_ANIMATION, loop=False)

    def close_gate(self, spawn_index: int, api: ScriptGameAPI) -> None:
        gate = api.decoration(f"creep_gate_{spawn_index}")
        gate.play_animation(GATE_OPEN_ANIMATION, loop=False, reverse=True)
        gate.set_collision_enabled(True)

    def give_or_drop_item(
        self,
        item_id: str,
        unit_id: str,
        killer_id: str | None,
        api: ScriptGameAPI,
        level: int,
    ) -> None:
        if killer_id is not None:
            added_item_id = api.unit(killer_id).add_item(item_id)
            if added_item_id is not None:
                api.set_item_level(added_item_id, level)
            if added_item_id is not None:
                return
        dropped_item_id = api.drop_item_near_unit(item_id, unit_id)
        if dropped_item_id is not None:
            api.set_item_level(dropped_item_id, level)

    def unit_id(self, unit: object | None) -> str | None:
        if unit is None:
            return None
        instance = getattr(unit, "instance", None)
        unit_id = getattr(instance, "id", None)
        return unit_id if isinstance(unit_id, str) else None

    def creep_index(self, tags: list[str]) -> int | None:
        for index in range(1, 9):
            if f"creep_type_{index}" in tags:
                return index
        return None

Заранее отвечу на вопросы:

Нахрена на Python?
- Чисто по фану, подумал что на нём проще всего писать скрипты, а ещё архитектурно я собираю проект так, чтобы в любой момент можно было избавиться от Panda3D, и использовать другой Backend движок
Будет ли Open Source
- Да, планирую выложить в Open Source, но серверную часть (если проект взлетит, то будет моим хлебом) выложу только для LAN
Планируешь заработать на вайбкодинге?
- Не особо, при проектировании движка я закладываю источники для монетизации, но это не самоцель, проект делаю для души
`
ОЖИДАНИЕ РЕКЛАМЫ...
31
не канон! где стройные ряды героев рядом с кругом силы, куда нужна завести виспа для выбора персонажа!? всю аутентичность потеряли!
П.С. были бы боты, я бы даже потестил...
4
не канон! где стройные ряды героев рядом с кругом силы, куда нужна завести виспа для выбора персонажа!? всю аутентичность потеряли!
ну так-то в World of Angel Arena уже давно были таверны )
4
П.С. были бы боты, я бы даже потестил...
будут боты непременно, но пока в роадмапе в дальнем ящике
Чтобы оставить комментарий, пожалуйста, войдите на сайт.