diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a26e348 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.10", "3.12"] + env: + SDL_VIDEODRIVER: dummy + SDL_AUDIODRIVER: dummy + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: pip install -r requirements-dev.txt + - run: ruff check . + - run: pytest -q + - name: Автоигрок проходит две минуты игры без ошибок + working-directory: game + run: python main.py --bot --fast --frames 7200 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..394c8c3 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,36 @@ +name: Web version + +on: + push: + branches: [main] + paths: ["game/**", "web/**", ".github/workflows/pages.yml"] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install pygbag==0.9.3 + - name: Сборка для браузера (pygbag) + run: python -m pygbag --build --title "Pixel Gamble" --width 960 --height 540 --template web/template.tmpl game + - uses: actions/upload-pages-artifact@v3 + with: + path: game/build/web + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d83d1ce --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + push: + tags: ["v*"] + +jobs: + build: + strategy: + matrix: + include: + - os: windows-latest + file: PixelGamble-windows.exe + - os: ubuntu-latest + file: PixelGamble-linux.tar.gz + runs-on: ${{ matrix.os }} + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -r requirements.txt pyinstaller pillow + - name: Иконка из портрета героя + run: python -c "from PIL import Image; Image.open('game/assets/images/chars/player_face.png').resize((256, 256), Image.NEAREST).save('icon.ico')" + - name: Сборка в один файл + run: pyinstaller --onefile --windowed --name PixelGamble --icon icon.ico --add-data "game/assets:assets" game/main.py + - name: Упаковка (Windows) + if: runner.os == 'Windows' + run: Move-Item dist/PixelGamble.exe ${{ matrix.file }} + - name: Упаковка (Linux) + if: runner.os == 'Linux' + run: tar -czf ${{ matrix.file }} -C dist PixelGamble + - name: Файл в релиз + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.ref_name }}" ${{ matrix.file }} --clobber diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0cd53c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +dist/ +*.spec diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..2ea2e85 --- /dev/null +++ b/README.en.md @@ -0,0 +1,149 @@ +# Pixel Gamble + +[Русский](README.md) · **English** + +[![CI](https://github.com/EDeev/pixel_gamble/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/pixel_gamble/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/EDeev/pixel_gamble)](https://github.com/EDeev/pixel_gamble/releases) + +A pixel-art survival action game in Python and pygame: ten waves of enemies, upgrades between +waves and a final fight with the Red Samurai. A run takes about 10 minutes, and the best score is saved. + +**[▶ Play in the browser](https://edeev.github.io/pixel_gamble/)** · [Download for Windows and Linux](https://github.com/EDeev/pixel_gamble/releases/latest) + +**Status:** started as a Yandex Lyceum school project (2022), turned into a complete game in 2026; +the version shown at the defence is release [v1.0.0](https://github.com/EDeev/pixel_gamble/releases/tag/v1.0.0) + +![Gameplay](docs/gameplay.gif) + +**Stack:** Python 3.10+ · pygame · pygbag (browser build) · PyInstaller · pytest + +## How to play + +| Action | Keys | +|---|---| +| Move | WASD or arrow keys | +| Attack | left mouse button, Space or J | +| Switch weapon | Q, mouse wheel, 1–3 | +| Pause | Esc or P | +| Fullscreen | F11 | + +Mouse attacks aim at the cursor; keyboard attacks go where the hero is facing. + +## What's inside + +- **10 waves.** Enemies come from the edges of the arena, each wave is bigger and tougher, the 10th + is the boss. +- **5 enemy types with their own behaviour:** + - the slime crawls slowly towards you; + - the bat zigzags and flies over rocks; + - the ninja winds up and lunges; + - the skeleton keeps its distance and throws shuriken; + - the cyclops demon flashes a warning and charges. +- **Boss — the Red Samurai**, with three phases: he slashes up close, adds a dash with a shuriken fan + below 60% health, and below 30% speeds up, summons minions and fires a ring of shuriken. +- **Three weapons:** the sword sweeps wide, the lance reaches further and pierces, the staff shoots bolts. +- **11 upgrades:** pick one of three between waves — hearts, damage, speed, new weapons, triple shot, + healing on kills and more. +- **XP and levels.** Hearts drop from enemies and broken rocks and heal you. +- **Menu, pause, settings** (volume, language, fullscreen), run summary and best score. +- **Two languages:** Russian and English. + +| Choosing an upgrade | The boss | +|---|---| +| ![Upgrades](docs/screenshots/upgrades.png) | ![Boss](docs/screenshots/boss.png) | + +## Quick start + +The easiest way is to [play in the browser](https://edeev.github.io/pixel_gamble/) or download a +ready-made file from the [releases page](https://github.com/EDeev/pixel_gamble/releases/latest): +`PixelGamble-windows.exe` or `PixelGamble-linux.tar.gz`, no Python needed. + +From source: + +```bash +git clone https://github.com/EDeev/pixel_gamble.git +cd pixel_gamble +pip install -r requirements.txt +python game/main.py +``` + +The best score and settings are stored in `%APPDATA%\pixel_gamble` (Windows), +`~/.local/share/pixel_gamble` (Linux) or `localStorage` in the browser. + +## How it works + +``` +game/ +├── main.py # entry point (desktop and browser) +├── pixelgamble/ +│ ├── app.py # window, main loop, menu, settings, run summary +│ ├── game.py # a run: waves, combat, camera, in-game HUD +│ ├── player.py # the hero and weapons +│ ├── enemies.py # five enemy types +│ ├── boss.py # the Red Samurai, three phases +│ ├── waves.py # wave plans and the spawn queue +│ ├── upgrades.py # upgrades between waves +│ ├── world.py # the tile arena +│ ├── bot.py # auto-player for tests and GIF recording +│ └── … # assets, effects, UI, texts, saves +└── assets/ # art, sounds, music and fonts +tools/import_assets.py # copies the needed files from the Ninja Adventure pack +web/template.tmpl # page template for pygbag +``` + +The world is drawn at 480×270 and scaled 2×, while the UI is drawn on top at 960×540, so the pixels +stay crisp and the text stays readable. Speeds don't depend on FPS. The game loop is async, so the +same code runs in the browser via [pygbag](https://github.com/pygame-web/pygbag). + +## Checks + +```bash +pip install -r requirements-dev.txt +ruff check . +pytest +python game/main.py --bot --fast --frames 7200 # auto-player, two minutes of play without a window +``` + +The tests run without a window or sound. They check that: +- the auto-player clears the first waves; +- the boss goes through all three phases, and both victory and death lead to the summary screen; +- upgrades apply, and every wave spawns exactly its plan; +- the save survives a corrupted file; +- characters can't pass through walls. + +CI runs all of this on Python 3.10 and 3.12. + +## Building + +- **Release:** a `v*` tag builds `PixelGamble-windows.exe` and `PixelGamble-linux.tar.gz` with + PyInstaller and attaches them to the release. +- **Browser version:** every push to `main` builds it with pygbag and publishes it to GitHub Pages. + To do the same locally: + + ```bash + pip install pygbag==0.9.3 + python -m pygbag --build --width 960 --height 540 --template web/template.tmpl game + ``` + +## Assets + +- Art, sounds, music and the title font — [Ninja Adventure](https://pixel-boy.itch.io/ninja-adventure-asset-pack) + (pixel-boy, CC0 license), license text in `game/assets/LICENSE-ninja-adventure.txt`. +- UI font — [Tiny5](https://github.com/Gissio/font_tiny5) (SIL Open Font License, `game/assets/fonts/OFL.txt`). +- The page template is based on the standard pygbag template. + +## License + +School project (Yandex Lyceum, 2021–2022 academic year), reworked in 2026. The code is open for +study; there is no separate license. + +## Author + +**Egor Deev** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space) + +--- + +
+ ⭐ If you find this project useful, give it a star on GitHub! +

Made with ❤️ — deev.space

+
diff --git a/README.md b/README.md index ae79bca..856eb88 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,148 @@ # Pixel Gamble -[![Python](https://img.shields.io/badge/Python-3.8+-blue.svg)](https://python.org) -[![Pygame](https://img.shields.io/badge/Pygame-2.0+-green.svg)](https://pygame.org) -[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +**Русский** · [English](README.en.md) -## Описание проекта +[![CI](https://github.com/EDeev/pixel_gamble/actions/workflows/ci.yml/badge.svg)](https://github.com/EDeev/pixel_gamble/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/EDeev/pixel_gamble)](https://github.com/EDeev/pixel_gamble/releases) -Pixel Gamble — это полнофункциональная 2D action-RPG игра, разработанная на Python с использованием библиотеки Pygame. Проект демонстрирует реализацию классических игровых механик в современной объектно-ориентированной архитектуре. +Пиксельный экшен на выживание на Python и pygame: десять волн врагов, улучшения между волнами и +финальный бой с Красным самураем. Партия занимает около 10 минут, рекорд сохраняется. -## Ключевые особенности +**[▶ Играть в браузере](https://edeev.github.io/pixel_gamble/)** · [Скачать для Windows и Linux](https://github.com/EDeev/pixel_gamble/releases/latest) -### Игровые механики -- **Система боя**: Реализован динамический боевой процесс с различными типами атак -- **ИИ противников**: Интеллектуальные противники с различными поведенческими паттернами -- **Система прогрессии**: Механика получения опыта и развития персонажа -- **Физика столкновений**: Точная система коллизий с оптимизированными хитбоксами +**Статус:** начат как учебный проект Яндекс Лицея (2022), в 2026 доработан до законченной игры; +версия с защиты — в релизе [v1.0.0](https://github.com/EDeev/pixel_gamble/releases/tag/v1.0.0) -### Техническая архитектура -- **Модульная структура**: Четкое разделение логики игры по компонентам -- **Система камеры**: Следящая камера с плавным движением -- **Карты уровней**: CSV-основанная система генерации игрового мира -- **Аудиосистема**: Интегрированное воспроизведение звуковых эффектов +![Геймплей](docs/gameplay.gif) -## Технологический стек +**Стек:** Python 3.10+ · pygame · pygbag (браузерная сборка) · PyInstaller · pytest -- **Язык программирования**: Python 3.8+ -- **Игровой движок**: Pygame 2.0+ -- **Управление ресурсами**: CSV для уровней, PNG для графики -- **Архитектурный паттерн**: Entity-Component-System (ECS) +## Как играть -## Структура проекта +| Действие | Клавиши | +|---|---| +| Движение | WASD или стрелки | +| Удар | левая кнопка мыши, пробел или J | +| Сменить оружие | Q, колесо мыши, 1–3 | +| Пауза | Esc или P | +| Полный экран | F11 | -``` -pixel_gamble/ -├── code/ -│ ├── main.py # Точка входа в приложение -│ ├── game.py # Основной игровой цикл -│ ├── level.py # Логика уровней и камеры -│ ├── player.py # Класс игрока -│ ├── enemy.py # Система противников -│ ├── entity.py # Базовый класс сущностей -│ └── data.py # Конфигурация и утилиты -├── data/ -│ ├── map/ # CSV файлы карт -│ ├── textures/ # Графические ресурсы -│ └── audio/ # Звуковые файлы -└── requirements.txt # Зависимости проекта -``` +При ударе мышью оружие бьёт в сторону курсора, с клавиатуры — туда, куда смотрит герой. -## Установка и запуск +## Что в игре -### Требования к системе -- Python 3.8 или выше -- Pygame 2.0+ +- **10 волн.** Враги появляются у краёв арены, каждая следующая волна больше и сильнее, 10-я — босс. +- **5 видов врагов с разным поведением:** + - слайм медленно ползёт к игроку; + - летучая мышь петляет и пролетает над камнями; + - ниндзя замахивается и делает выпад; + - скелет держит дистанцию и метает сюрикены; + - демон-циклоп предупреждает вспышкой и бросается тараном. +- **Босс — Красный самурай** с тремя фазами: рубит мечом вблизи, с 60% здоровья добавляет рывок с + веером сюрикенов, с 30% ускоряется, призывает помощников и стреляет кольцом. +- **Три оружия:** меч бьёт широким взмахом, копьё — дальше и насквозь, посох стреляет зарядами. +- **11 улучшений:** между волнами нужно выбрать одно из трёх — сердца, урон, скорость, новое оружие, + тройной заряд, лечение за победы и другие. +- **Опыт и уровни.** Сердечки выпадают из врагов и разбитых камней и лечат. +- **Меню, пауза, настройки** (громкость, язык, полный экран), итоги партии и рекорд. +- **Два языка:** русский и английский. -### Процесс установки +| Выбор улучшения | Босс | +|---|---| +| ![Улучшения](docs/screenshots/upgrades.png) | ![Босс](docs/screenshots/boss.png) | + +## Быстрый старт + +Проще всего — [играть в браузере](https://edeev.github.io/pixel_gamble/) или скачать готовый файл +со [страницы релизов](https://github.com/EDeev/pixel_gamble/releases/latest): `PixelGamble-windows.exe` или +`PixelGamble-linux.tar.gz`, Python для них не нужен. + +Из исходников: -1. **Клонирование репозитория**: ```bash -git clone https://github.com/DeevEV/pixel_gamble.git +git clone https://github.com/EDeev/pixel_gamble.git cd pixel_gamble -``` - -2. **Установка зависимостей**: -```bash pip install -r requirements.txt +python game/main.py ``` -3. **Запуск игры**: +Рекорд и настройки хранятся в `%APPDATA%\pixel_gamble` (Windows), `~/.local/share/pixel_gamble` +(Linux), в браузере — в `localStorage`. + +## Устройство + +``` +game/ +├── main.py # точка входа (рабочий стол и браузер) +├── pixelgamble/ +│ ├── app.py # окно, главный цикл, меню, настройки, итоги +│ ├── game.py # партия: волны, бой, камера, интерфейс боя +│ ├── player.py # герой и оружие +│ ├── enemies.py # пять видов врагов +│ ├── boss.py # Красный самурай, три фазы +│ ├── waves.py # состав волн и очередь появления +│ ├── upgrades.py # улучшения между волнами +│ ├── world.py # арена из плиток +│ ├── bot.py # автоигрок для проверок и записи GIF +│ └── … # ресурсы, эффекты, интерфейс, тексты, сохранения +└── assets/ # графика, звук, музыка и шрифты +tools/import_assets.py # перенос нужных файлов из набора Ninja Adventure +web/template.tmpl # шаблон страницы для pygbag +``` + +Мир рисуется в разрешении 480×270 и масштабируется в два раза, а интерфейс — поверх, в 960×540, +поэтому пиксели остаются чёткими и текст читается. Скорости не зависят от FPS. Игровой цикл +асинхронный, поэтому тот же код работает в браузере через [pygbag](https://github.com/pygame-web/pygbag). + +## Проверки + ```bash -cd code -python main.py +pip install -r requirements-dev.txt +ruff check . +pytest +python game/main.py --bot --fast --frames 7200 # автоигрок, две минуты игры без окна ``` -## Управление +Тесты запускаются без окна и звука. Проверяются: +- автоигрок проходит первые волны; +- босс проходит все три фазы, победа и смерть ведут на экран итогов; +- улучшения применяются, а каждая волна выпускает ровно свой состав; +- сохранение переживает испорченный файл; +- персонаж не проходит сквозь стены. -- **W, A, S, D** — Перемещение персонажа -- **ЛКМ** — Атака -- **ESC** — Выход из игры +CI прогоняет всё это на Python 3.10 и 3.12. -## Архитектурные решения +## Сборка -### Система сущностей -Игра построена на базовом классе `Entity`, обеспечивающем унифицированный интерфейс для всех игровых объектов с поддержкой: -- Движения и физики -- Анимационной системы -- Обработки столкновений +- **Релиз:** тег `v*` собирает `PixelGamble-windows.exe` и `PixelGamble-linux.tar.gz` через + PyInstaller и прикладывает их к релизу. +- **Браузерная версия:** каждый пуш в `main` собирает её через pygbag и выкладывает на GitHub Pages. + Локально это делается так: -### Управление ресурсами -- CSV-файлы для описания структуры уровней -- Модульная система загрузки графических ресурсов -- Централизованное управление игровыми данными + ```bash + pip install pygbag==0.9.3 + python -m pygbag --build --width 960 --height 540 --template web/template.tmpl game + ``` -### Производительность -- Оптимизированная система рендеринга с сортировкой по глубине -- Эффективная обработка столкновений -- Управление памятью через группы спрайтов Pygame +## Ресурсы -## Возможности расширения - -Проект спроектирован с учетом масштабируемости: -- Легкое добавление новых типов противников -- Расширяемая система оружия -- Модульная архитектура уровней -- Гибкая система анимаций +- Графика, звуки, музыка и шрифт заголовка — [Ninja Adventure](https://pixel-boy.itch.io/ninja-adventure-asset-pack) + (pixel-boy, лицензия CC0), текст лицензии — `game/assets/LICENSE-ninja-adventure.txt`. +- Шрифт интерфейса — [Tiny5](https://github.com/Gissio/font_tiny5) (SIL Open Font License, `game/assets/fonts/OFL.txt`). +- Шаблон страницы — на основе стандартного шаблона pygbag. ## Лицензия -Этот проект распространяется под лицензией MIT. +Учебный проект (Яндекс Лицей, 2021–2022 учебный год), доработанный в 2026 — код доступен для +изучения, отдельной лицензии нет. -## Контакты разработчика +## Автор -**Деев Егор Викторович** - Backend Developer -- GitHub: [@EDeev](https://github.com/EDeev) -- Email: egor@deev.space -- Telegram: [@Egor_Deev](https://t.me/Egor_Deev) +**Деев Егор Викторович** — [GitHub](https://github.com/EDeev) · [Telegram](https://t.me/DeevEgor) · [egor@deev.space](mailto:egor@deev.space) ---
- Проект создан в рамках изучения игровой разработки и демонстрации навыков программирования на Python. -

Создано с ❤️ от вашего дорогого - deev.space ©

+ ⭐ Если проект оказался полезным, поставьте звёздочку на GitHub! +

Сделано с ❤️ — deev.space

diff --git a/code/data.py b/code/data.py deleted file mode 100644 index 5dda560..0000000 --- a/code/data.py +++ /dev/null @@ -1,36 +0,0 @@ -from csv import reader -from os import walk -import pygame - -# ОСНОВНЫЕ НАСТРОЙКИ -WIDTH = 1920 -HEIGTH = 1080 -FPS = 60 -TILESIZE = 64 -HITBOX = {'player': -26, 'stone': -10, 'invisible': 0} - -weapon_data = {'sword': {'cooldown': 100, 'damage': 15}} # набор оружия -mobs_data = {'ninja': {'health': 100, 'exp': 250, 'damage': 6, # тип и характеристики моба - 'attack_type': 'leaf_attack', 'attack_sound': '../data/audio/hit.wav', - 'speed': 3, 'resistance': 3, 'attack_radius': 50, 'notice_radius': 300}} - - -def import_csv_layout(path): # ЗАГРУЗКА ФАЙЛОВ CSV КАРТЫ - terrain_map = [] - with open(path) as level_map: - layout = reader(level_map, delimiter=',') - for row in layout: - terrain_map.append(list(row)) - return terrain_map - - -def import_folder(path): # ОБРАБОТКА КАРТИНОК - surface_list = [] - - for i, j, img_files in walk(path): - for image in img_files: - full_path = path + '/' + image - image_surf = pygame.image.load(full_path).convert_alpha() - surface_list.append(image_surf) - - return surface_list diff --git a/code/enemy.py b/code/enemy.py deleted file mode 100644 index 7772598..0000000 --- a/code/enemy.py +++ /dev/null @@ -1,124 +0,0 @@ -from data import * -from entity import Entity - - -class Enemy(Entity): - def __init__(self, monster_name, pos, groups, obstacle_sprites, damage_player, add_exp): - super().__init__(groups) - self.sprite_type = 'enemy' - - # стартовый спрайт - self.import_graphics(monster_name) - self.status = 'idle' - self.image = self.animations[self.status][self.frame_index] - - # положение - self.rect = self.image.get_rect(topleft=pos) - self.hitbox = self.rect.inflate(0, -10) - self.obstacle_sprites = obstacle_sprites - - # характеристики - self.monster_name = monster_name - monster_info = mobs_data[self.monster_name] - self.health = monster_info['health'] - self.exp = monster_info['exp'] - self.speed = monster_info['speed'] - self.attack_damage = monster_info['damage'] - self.resistance = monster_info['resistance'] - self.attack_radius = monster_info['attack_radius'] - self.notice_radius = monster_info['notice_radius'] - - # параметры - self.can_attack = True - self.attack_time = None - self.attack_cooldown = 400 - self.damage_player = damage_player - self.add_exp = add_exp - - # звуки - self.death_sound = pygame.mixer.Sound('../data/audio/death.wav') - self.hit_sound = pygame.mixer.Sound('../data/audio/hit.wav') - self.attack_sound = pygame.mixer.Sound(monster_info['attack_sound']) - self.death_sound.set_volume(0.6) - self.hit_sound.set_volume(0.6) - self.attack_sound.set_volume(0.6) - - def import_graphics(self, name): - self.animations = {'idle': [], 'move': [], 'attack': []} - main_path = f'../data/textures/mobs/{name}/' - for animation in self.animations.keys(): - self.animations[animation] = import_folder(main_path + animation) - - def get_player_distance_direction(self, player): - enemy_vec = pygame.math.Vector2(self.rect.center) - player_vec = pygame.math.Vector2(player.rect.center) - distance = (player_vec - enemy_vec).magnitude() - - if distance > 0: - direction = (player_vec - enemy_vec).normalize() - else: - direction = pygame.math.Vector2() - - return (distance, direction) - - def get_status(self, player): - distance = self.get_player_distance_direction(player)[0] - - if distance <= self.attack_radius and self.can_attack: - if self.status != 'attack': - self.frame_index = 0 - self.status = 'attack' - elif distance <= self.notice_radius: - self.status = 'move' - else: - self.status = 'idle' - - def actions(self, player): - if self.status == 'attack': - self.attack_time = pygame.time.get_ticks() - self.damage_player(self.attack_damage) - self.attack_sound.play() - elif self.status == 'move': - self.direction = self.get_player_distance_direction(player)[1] - else: - self.direction = pygame.math.Vector2() - - def animate(self): - animation = self.animations[self.status] - - self.frame_index += self.animation_speed - if self.frame_index >= len(animation): - if self.status == 'attack': - self.can_attack = False - self.frame_index = 0 - - self.image = animation[int(self.frame_index)] - self.rect = self.image.get_rect(center=self.hitbox.center) - - def cooldowns(self): - current_time = pygame.time.get_ticks() - if not self.can_attack: - if current_time - self.attack_time >= self.attack_cooldown: - self.can_attack = True - - def get_damage(self, player): - self.hit_sound.play() - self.direction = self.get_player_distance_direction(player)[1] - self.health -= player.get_full_weapon_damage() - self.hit_time = pygame.time.get_ticks() - - def check_death(self): - if self.health <= 0: - self.kill() - self.add_exp(self.exp) - self.death_sound.play() - - def update(self): - self.move(self.speed) - self.animate() - self.cooldowns() - self.check_death() - - def enemy_update(self, player): - self.get_status(player) - self.actions(player) diff --git a/code/entity.py b/code/entity.py deleted file mode 100644 index fa2cb86..0000000 --- a/code/entity.py +++ /dev/null @@ -1,44 +0,0 @@ -import pygame -from math import sin - - -class Entity(pygame.sprite.Sprite): - def __init__(self, groups): - super().__init__(groups) - self.frame_index = 0 - self.animation_speed = 0.15 - self.direction = pygame.math.Vector2() - - def move(self, speed): - if self.direction.magnitude() != 0: - self.direction = self.direction.normalize() - - self.hitbox.x += self.direction.x * speed - self.collision('horizontal') - self.hitbox.y += self.direction.y * speed - self.collision('vertical') - self.rect.center = self.hitbox.center - - def collision(self, direction): - if direction == 'horizontal': - for sprite in self.obstacle_sprites: - if sprite.hitbox.colliderect(self.hitbox): - if self.direction.x > 0: # moving right - self.hitbox.right = sprite.hitbox.left - if self.direction.x < 0: # moving left - self.hitbox.left = sprite.hitbox.right - - if direction == 'vertical': - for sprite in self.obstacle_sprites: - if sprite.hitbox.colliderect(self.hitbox): - if self.direction.y > 0: # moving down - self.hitbox.bottom = sprite.hitbox.top - if self.direction.y < 0: # moving up - self.hitbox.top = sprite.hitbox.bottom - - def wave_value(self): - value = sin(pygame.time.get_ticks()) - if value >= 0: - return 255 - else: - return 0 diff --git a/code/level.py b/code/level.py deleted file mode 100644 index b0adbbc..0000000 --- a/code/level.py +++ /dev/null @@ -1,187 +0,0 @@ -from random import choice - -from data import * -from player import Player -from enemy import Enemy - - -class Level: - def __init__(self): - self.display_surface = pygame.display.get_surface() - self.ui = UI() - - # группы основных спрайтов видимых и нет - self.visible_sprites = Camera() - self.obstacle_sprites = pygame.sprite.Group() - - # группа спрайтов способных разрушиться - self.current_attack = None - self.attack_sprites = pygame.sprite.Group() - self.attackable_sprites = pygame.sprite.Group() - - # генерация карты - self.create_map() - - def create_map(self): - layouts = { - 'barryer': import_csv_layout('../data/map/барьеры.csv'), - 'stone': import_csv_layout('../data/map/камни.csv'), - 'entities': import_csv_layout('../data/map/мобы.csv') - } - graphics = {'stone': import_folder('../data/textures/stone')} - - for style, layout in layouts.items(): - for row_index, row in enumerate(layout): - for col_index, col in enumerate(row): - if col != '-1': - x = col_index * TILESIZE - y = row_index * TILESIZE - if style == 'barryer': - Tile((x, y), [self.obstacle_sprites], 'invisible') - if style == 'stone': - random_grass_image = choice(graphics['stone']) - Tile((x, y), [self.visible_sprites, self.obstacle_sprites, self.attackable_sprites], - 'stone', random_grass_image) - if style == 'entities': - if col == '2': - self.player = Player((x, y), [self.visible_sprites], self.obstacle_sprites, - self.destroy_attack, self.create_attack) - else: - Enemy("ninja", (x, y), [self.visible_sprites, self.attackable_sprites], - self.obstacle_sprites, self.damage_player, self.add_exp) - - def create_attack(self): # создание атаки и спрайта оружия - self.current_attack = Weapon(self.player, [self.visible_sprites, self.attack_sprites]) - - def destroy_attack(self): # разрушение разрушаемых объектов - if self.current_attack: - self.current_attack.kill() - self.current_attack = None - - def player_attack(self): # удары по другим объектам - if self.attack_sprites: - for attack_sprite in self.attack_sprites: - collision_sprites = pygame.sprite.spritecollide(attack_sprite, self.attackable_sprites, False) - if collision_sprites: - for target_sprite in collision_sprites: - if target_sprite.sprite_type == 'stone': # камни разрушаются с одного удара - target_sprite.kill() - else: # мобы получают урон - target_sprite.get_damage(self.player) - - def damage_player(self, amount): - self.player.health -= amount - self.player.hurt_time = pygame.time.get_ticks() - - def add_exp(self, amount): - self.player.exp += amount - - def run(self): - self.visible_sprites.custom_draw(self.player) - self.ui.display(self.player) - - self.visible_sprites.update() - self.visible_sprites.enemy_update(self.player) - self.player_attack() - - -# КАМЕРА СЛЕДЯЩАЯ ЗА ПЕРСОНАЖЕМ -class Camera(pygame.sprite.Group): - def __init__(self): - super().__init__() - self.display_surface = pygame.display.get_surface() - self.half_width = self.display_surface.get_size()[0] // 2 - self.half_height = self.display_surface.get_size()[1] // 2 - self.offset = pygame.math.Vector2() - - # отрисовка основной карты - self.floor_surf = pygame.image.load('../data/map/map.png').convert() - self.floor_rect = self.floor_surf.get_rect(topleft=(0, 0)) - - def custom_draw(self, player): - self.offset.x = player.rect.centerx - self.half_width # ПОЛУЧЕНИЕ ПОЗИЦИИ ИГРОКА - self.offset.y = player.rect.centery - self.half_height - - floor_offset_pos = self.floor_rect.topleft - self.offset # ОТРИСОВКА ОСНОВНОЙ КАРТЫ ПОД ИГРОКОМ - self.display_surface.blit(self.floor_surf, floor_offset_pos) - - for sprite in sorted(self.sprites(), key=lambda sprite: sprite.rect.centery): - offset_pos = sprite.rect.topleft - self.offset - self.display_surface.blit(sprite.image, offset_pos) - - def enemy_update(self, player): # ОТРИСОВКА МОБОВ - enemy_sprites = [] - for sprite in self.sprites(): - if hasattr(sprite, 'sprite_type') and sprite.sprite_type == 'enemy': - enemy_sprites.append(sprite) - - for enemy in enemy_sprites: - enemy.enemy_update(player) - - -# КЛАСС ОТРИСОВКИ ПЛИТОК -class Tile(pygame.sprite.Sprite): - def __init__(self, pos, groups, sprite_type, surface=pygame.Surface((TILESIZE, TILESIZE))): - super().__init__(groups) - self.sprite_type = sprite_type - y_offset = HITBOX[sprite_type] - self.image = surface - - if sprite_type == 'object': - self.rect = self.image.get_rect(topleft=(pos[0], pos[1] - TILESIZE)) - else: - self.rect = self.image.get_rect(topleft=pos) - - self.hitbox = self.rect.inflate(0, y_offset) - - -# КЛАСС ОРУЖИЯ -class Weapon(pygame.sprite.Sprite): - def __init__(self, player, groups): - super().__init__(groups) - self.sprite_type = 'weapon' - self.image = pygame.image.load(f'../data/textures/sword/{player.status.split("_")[0]}.png').convert_alpha() - - # отрисовка оружия по отношению к герою - if player.status == 'right': - self.rect = self.image.get_rect(midleft=player.rect.midright + pygame.math.Vector2(-3, 16)) - elif player.status == 'left': - self.rect = self.image.get_rect(midright=player.rect.midleft + pygame.math.Vector2(3, 16)) - elif player.status == 'down': - self.rect = self.image.get_rect(midtop=player.rect.midbottom + pygame.math.Vector2(-10, 0)) - else: - self.rect = self.image.get_rect(midbottom=player.rect.midtop + pygame.math.Vector2(-10, 20)) - - -# КЛАСС ИНТРЕФЕЙСА -class UI: - def __init__(self): - self.screen = pygame.display.get_surface() - self.font = pygame.font.SysFont("arial", 24) # размер и формат чисел опыта - - def health_bar(self, player): - pygame.draw.rect(self.screen, '#222222', pygame.Rect(10, 10, 120, 30)) - - # перевод кол-ва здоровья в полоску - ratio = player.health / player.stats['health'] - current_width = pygame.Rect(10, 10, 120, 30).width * ratio - current_rect = pygame.Rect(10, 10, 120, 30).copy() - current_rect.width = current_width - - pygame.draw.rect(self.screen, 'red', current_rect) - pygame.draw.rect(self.screen, 'black', pygame.Rect(10, 10, 120, 30), 3) - - def show_exp(self, exp): - text_surf = self.font.render(str(int(exp)), False, 'white') - x = self.screen.get_size()[0] - 20 # место ячейки опыта - y = self.screen.get_size()[1] - 1040 - text_rect = text_surf.get_rect(bottomright=(x, y)) - - pygame.draw.rect(self.screen, '#222222', text_rect.inflate(20, 20)) # отрисовка опыта и серого фона - self.screen.blit(text_surf, text_rect) - - pygame.draw.rect(self.screen, 'black', text_rect.inflate(20, 20), 3) # отрисовка чёрной рамки - - def display(self, player): - self.health_bar(player) - self.show_exp(player.exp) diff --git a/code/main.py b/code/main.py deleted file mode 100644 index 60d07cb..0000000 --- a/code/main.py +++ /dev/null @@ -1,39 +0,0 @@ -import sys - -from data import * -from level import Level - - -class Game: - def __init__(self): - pygame.init() - self.screen = pygame.display.set_mode((WIDTH, HEIGTH)) - pygame.display.set_caption('Pixel Gamble') - self.clock = pygame.time.Clock() - - self.level = Level() - - main_sound = pygame.mixer.Sound('../data/audio/main.wav') - main_sound.set_volume(0.5) - main_sound.play(loops=-1) - - def run(self): - while True: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - pygame.quit() - sys.exit() - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: - pygame.quit() - sys.exit() - - self.screen.fill('black') - self.level.run() - pygame.display.update() - self.clock.tick(FPS) - - -if __name__ == '__main__': - game = Game() - game.run() diff --git a/code/player.py b/code/player.py deleted file mode 100644 index 83e2385..0000000 --- a/code/player.py +++ /dev/null @@ -1,128 +0,0 @@ -from data import * -from entity import Entity - - -class Player(Entity): - def __init__(self, pos, groups, obstacle_sprites, destroy_attack, create_attack): - super().__init__(groups) - self.image = pygame.image.load('../data/textures/player/player.png').convert_alpha() - self.rect = self.image.get_rect(topleft=pos) - self.hitbox = self.rect.inflate(-6, HITBOX['player']) - - # стартовое положение спрайта - self.import_player_assets() - self.status = 'down' - - # параметры героя - self.attacking = False - self.attack_cooldown = 400 - self.attack_time = None - self.obstacle_sprites = obstacle_sprites - - # оружик героя - self.destroy_attack = destroy_attack - self.weapon_index = 0 - self.create_attack = create_attack - self.weapon = list(weapon_data.keys())[self.weapon_index] - self.can_switch_weapon = True - self.weapon_switch_time = None - self.switch_duration_cooldown = 200 - - # характеристики героя - self.stats = {'health': 300, 'attack': 10, 'speed': 5} - self.max_stats = {'health': 300, 'attack': 20, 'speed': 10} - self.health = self.stats['health'] - self.speed = self.stats['speed'] - self.exp = 0 - - # import a sound - self.weapon_attack_sound = pygame.mixer.Sound('../data/audio/sword.wav') - self.weapon_attack_sound.set_volume(0.4) - - def import_player_assets(self): # получение всех картинок героя - self.animations = {'up': [], 'down': [], 'left': [], 'right': [], - 'right_stand': [], 'left_stand': [], 'up_stand': [], 'down_stand': [], - 'right_attack': [], 'left_attack': [], 'up_attack': [], 'down_attack': []} - - for animation in self.animations.keys(): - full_path = '../data/textures/player/' + animation - self.animations[animation] = import_folder(full_path) - - def input(self): # кнопки - if not self.attacking: - keys = pygame.key.get_pressed() - mouse = pygame.mouse.get_pressed() - - if keys[pygame.K_w]: - self.direction.y = -1 - self.status = 'up' - elif keys[pygame.K_s]: - self.direction.y = 1 - self.status = 'down' - else: - self.direction.y = 0 - - if keys[pygame.K_d]: - self.direction.x = 1 - self.status = 'right' - elif keys[pygame.K_a]: - self.direction.x = -1 - self.status = 'left' - else: - self.direction.x = 0 - - if mouse[0]: - self.attacking = True - self.attack_time = pygame.time.get_ticks() - self.create_attack() - self.weapon_attack_sound.play() - - def get_status(self): # положение героя на поле - if self.direction.x == 0 and self.direction.y == 0: - if not 'stand' in self.status and not 'attack' in self.status: - self.status = self.status + '_stand' - - if self.attacking: - self.direction.x = 0 - self.direction.y = 0 - if not 'attack' in self.status: - if 'stand' in self.status: - self.status = self.status.replace('_stand', '_attack') - else: - self.status = self.status + '_attack' - else: - if 'attack' in self.status: - self.status = self.status.replace('_attack', '') - - def cooldowns(self): # таймер востановления удара - current_time = pygame.time.get_ticks() - - if self.attacking: - if current_time - self.attack_time >= self.attack_cooldown + weapon_data[self.weapon]['cooldown']: - self.attacking = False - self.destroy_attack() - - if not self.can_switch_weapon: - if current_time - self.weapon_switch_time >= self.switch_duration_cooldown: - self.can_switch_weapon = True - - def animate(self): # анимирование героя - animation = self.animations[self.status] - self.frame_index += self.animation_speed - if self.frame_index >= len(animation): - self.frame_index = 0 - - self.image = animation[int(self.frame_index)] - self.rect = self.image.get_rect(center=self.hitbox.center) - - def get_full_weapon_damage(self): # нанесение врагу урона - base_damage = self.stats['attack'] - weapon_damage = weapon_data[self.weapon]['damage'] - return base_damage + weapon_damage - - def update(self): - self.input() - self.cooldowns() - self.get_status() - self.animate() - self.move(self.stats['speed']) diff --git a/data/audio/death.wav b/data/audio/death.wav deleted file mode 100644 index 08641f3..0000000 Binary files a/data/audio/death.wav and /dev/null differ diff --git a/data/audio/hit.wav b/data/audio/hit.wav deleted file mode 100644 index 765b97b..0000000 Binary files a/data/audio/hit.wav and /dev/null differ diff --git a/data/audio/main.wav b/data/audio/main.wav deleted file mode 100644 index c7c3474..0000000 Binary files a/data/audio/main.wav and /dev/null differ diff --git a/data/audio/sword.wav b/data/audio/sword.wav deleted file mode 100644 index 2e962d0..0000000 Binary files a/data/audio/sword.wav and /dev/null differ diff --git a/data/map/map.png b/data/map/map.png deleted file mode 100644 index 7fce7d2..0000000 Binary files a/data/map/map.png and /dev/null differ diff --git a/data/map/барьеры.csv b/data/map/барьеры.csv deleted file mode 100644 index 1213d46..0000000 --- a/data/map/барьеры.csv +++ /dev/null @@ -1,17 +0,0 @@ --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,-1 --1,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,-1 --1,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,395,-1 --1,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,395,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 diff --git a/data/map/камни.csv b/data/map/камни.csv deleted file mode 100644 index 3469c04..0000000 --- a/data/map/камни.csv +++ /dev/null @@ -1,17 +0,0 @@ --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 diff --git a/data/map/мобы.csv b/data/map/мобы.csv deleted file mode 100644 index f3f6f95..0000000 --- a/data/map/мобы.csv +++ /dev/null @@ -1,17 +0,0 @@ --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,2,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 --1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 diff --git a/data/textures/mobs/ninja/attack/0.png b/data/textures/mobs/ninja/attack/0.png deleted file mode 100644 index 66e15a1..0000000 Binary files a/data/textures/mobs/ninja/attack/0.png and /dev/null differ diff --git a/data/textures/mobs/ninja/attack/1.png b/data/textures/mobs/ninja/attack/1.png deleted file mode 100644 index 2982075..0000000 Binary files a/data/textures/mobs/ninja/attack/1.png and /dev/null differ diff --git a/data/textures/mobs/ninja/attack/2.png b/data/textures/mobs/ninja/attack/2.png deleted file mode 100644 index 67c0ccf..0000000 Binary files a/data/textures/mobs/ninja/attack/2.png and /dev/null differ diff --git a/data/textures/mobs/ninja/idle/0.png b/data/textures/mobs/ninja/idle/0.png deleted file mode 100644 index 66e15a1..0000000 Binary files a/data/textures/mobs/ninja/idle/0.png and /dev/null differ diff --git a/data/textures/mobs/ninja/idle/1.png b/data/textures/mobs/ninja/idle/1.png deleted file mode 100644 index 2982075..0000000 Binary files a/data/textures/mobs/ninja/idle/1.png and /dev/null differ diff --git a/data/textures/mobs/ninja/idle/2.png b/data/textures/mobs/ninja/idle/2.png deleted file mode 100644 index 67c0ccf..0000000 Binary files a/data/textures/mobs/ninja/idle/2.png and /dev/null differ diff --git a/data/textures/mobs/ninja/move/0.png b/data/textures/mobs/ninja/move/0.png deleted file mode 100644 index 66e15a1..0000000 Binary files a/data/textures/mobs/ninja/move/0.png and /dev/null differ diff --git a/data/textures/mobs/ninja/move/1.png b/data/textures/mobs/ninja/move/1.png deleted file mode 100644 index 2982075..0000000 Binary files a/data/textures/mobs/ninja/move/1.png and /dev/null differ diff --git a/data/textures/mobs/ninja/move/2.png b/data/textures/mobs/ninja/move/2.png deleted file mode 100644 index 67c0ccf..0000000 Binary files a/data/textures/mobs/ninja/move/2.png and /dev/null differ diff --git a/data/textures/player/(1).png b/data/textures/player/(1).png deleted file mode 100644 index 6ffc676..0000000 Binary files a/data/textures/player/(1).png and /dev/null differ diff --git a/data/textures/player/(10).png b/data/textures/player/(10).png deleted file mode 100644 index b2261b9..0000000 Binary files a/data/textures/player/(10).png and /dev/null differ diff --git a/data/textures/player/(11).png b/data/textures/player/(11).png deleted file mode 100644 index d0b9462..0000000 Binary files a/data/textures/player/(11).png and /dev/null differ diff --git a/data/textures/player/(12).png b/data/textures/player/(12).png deleted file mode 100644 index 2fc2302..0000000 Binary files a/data/textures/player/(12).png and /dev/null differ diff --git a/data/textures/player/(13).png b/data/textures/player/(13).png deleted file mode 100644 index b75d3c8..0000000 Binary files a/data/textures/player/(13).png and /dev/null differ diff --git a/data/textures/player/(14).png b/data/textures/player/(14).png deleted file mode 100644 index fa733fa..0000000 Binary files a/data/textures/player/(14).png and /dev/null differ diff --git a/data/textures/player/(15).png b/data/textures/player/(15).png deleted file mode 100644 index cd11b09..0000000 Binary files a/data/textures/player/(15).png and /dev/null differ diff --git a/data/textures/player/(16).png b/data/textures/player/(16).png deleted file mode 100644 index 47ec0b6..0000000 Binary files a/data/textures/player/(16).png and /dev/null differ diff --git a/data/textures/player/(17).png b/data/textures/player/(17).png deleted file mode 100644 index d2bd4c1..0000000 Binary files a/data/textures/player/(17).png and /dev/null differ diff --git a/data/textures/player/(18).png b/data/textures/player/(18).png deleted file mode 100644 index 952805f..0000000 Binary files a/data/textures/player/(18).png and /dev/null differ diff --git a/data/textures/player/(19).png b/data/textures/player/(19).png deleted file mode 100644 index 8f6e8d3..0000000 Binary files a/data/textures/player/(19).png and /dev/null differ diff --git a/data/textures/player/(2).png b/data/textures/player/(2).png deleted file mode 100644 index a02e714..0000000 Binary files a/data/textures/player/(2).png and /dev/null differ diff --git a/data/textures/player/(20).png b/data/textures/player/(20).png deleted file mode 100644 index ce9c70c..0000000 Binary files a/data/textures/player/(20).png and /dev/null differ diff --git a/data/textures/player/(21).png b/data/textures/player/(21).png deleted file mode 100644 index 984bee0..0000000 Binary files a/data/textures/player/(21).png and /dev/null differ diff --git a/data/textures/player/(22).png b/data/textures/player/(22).png deleted file mode 100644 index f8c6f3d..0000000 Binary files a/data/textures/player/(22).png and /dev/null differ diff --git a/data/textures/player/(23).png b/data/textures/player/(23).png deleted file mode 100644 index 4969fd7..0000000 Binary files a/data/textures/player/(23).png and /dev/null differ diff --git a/data/textures/player/(24).png b/data/textures/player/(24).png deleted file mode 100644 index e111806..0000000 Binary files a/data/textures/player/(24).png and /dev/null differ diff --git a/data/textures/player/(25).png b/data/textures/player/(25).png deleted file mode 100644 index d2f84f4..0000000 Binary files a/data/textures/player/(25).png and /dev/null differ diff --git a/data/textures/player/(26).png b/data/textures/player/(26).png deleted file mode 100644 index ac438a8..0000000 Binary files a/data/textures/player/(26).png and /dev/null differ diff --git a/data/textures/player/(27).png b/data/textures/player/(27).png deleted file mode 100644 index 7061805..0000000 Binary files a/data/textures/player/(27).png and /dev/null differ diff --git a/data/textures/player/(28).png b/data/textures/player/(28).png deleted file mode 100644 index 261cb9d..0000000 Binary files a/data/textures/player/(28).png and /dev/null differ diff --git a/data/textures/player/(29).png b/data/textures/player/(29).png deleted file mode 100644 index d2f84f4..0000000 Binary files a/data/textures/player/(29).png and /dev/null differ diff --git a/data/textures/player/(3).png b/data/textures/player/(3).png deleted file mode 100644 index 072c95e..0000000 Binary files a/data/textures/player/(3).png and /dev/null differ diff --git a/data/textures/player/(30).png b/data/textures/player/(30).png deleted file mode 100644 index 09c948b..0000000 Binary files a/data/textures/player/(30).png and /dev/null differ diff --git a/data/textures/player/(31).png b/data/textures/player/(31).png deleted file mode 100644 index 9f0585c..0000000 Binary files a/data/textures/player/(31).png and /dev/null differ diff --git a/data/textures/player/(32).png b/data/textures/player/(32).png deleted file mode 100644 index fb3dfde..0000000 Binary files a/data/textures/player/(32).png and /dev/null differ diff --git a/data/textures/player/(4).png b/data/textures/player/(4).png deleted file mode 100644 index 63ab5bf..0000000 Binary files a/data/textures/player/(4).png and /dev/null differ diff --git a/data/textures/player/(5).png b/data/textures/player/(5).png deleted file mode 100644 index 42b3a85..0000000 Binary files a/data/textures/player/(5).png and /dev/null differ diff --git a/data/textures/player/(6).png b/data/textures/player/(6).png deleted file mode 100644 index ca431fa..0000000 Binary files a/data/textures/player/(6).png and /dev/null differ diff --git a/data/textures/player/(7).png b/data/textures/player/(7).png deleted file mode 100644 index c04dfa8..0000000 Binary files a/data/textures/player/(7).png and /dev/null differ diff --git a/data/textures/player/(8).png b/data/textures/player/(8).png deleted file mode 100644 index 3e5e1ec..0000000 Binary files a/data/textures/player/(8).png and /dev/null differ diff --git a/data/textures/player/(9).png b/data/textures/player/(9).png deleted file mode 100644 index a411ac0..0000000 Binary files a/data/textures/player/(9).png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_0.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_0.png deleted file mode 100644 index 6ffc676..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_0.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_1.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_1.png deleted file mode 100644 index a02e714..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_1.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_2.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_2.png deleted file mode 100644 index 072c95e..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_2.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_3.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_3.png deleted file mode 100644 index 63ab5bf..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_3.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_4.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_4.png deleted file mode 100644 index 42b3a85..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_4.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_5.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_5.png deleted file mode 100644 index ca431fa..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_5.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_6.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_6.png deleted file mode 100644 index c04dfa8..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_6.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_7.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_7.png deleted file mode 100644 index 3e5e1ec..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Down_7.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_0.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_0.png deleted file mode 100644 index a411ac0..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_0.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_1.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_1.png deleted file mode 100644 index b2261b9..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_1.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_2.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_2.png deleted file mode 100644 index d0b9462..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_2.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_3.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_3.png deleted file mode 100644 index 2fc2302..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_3.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_4.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_4.png deleted file mode 100644 index b75d3c8..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_4.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_5.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_5.png deleted file mode 100644 index fa733fa..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_5.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_6.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_6.png deleted file mode 100644 index cd11b09..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_6.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_7.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_7.png deleted file mode 100644 index 47ec0b6..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Left_7.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_0.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_0.png deleted file mode 100644 index d2bd4c1..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_0.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_1.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_1.png deleted file mode 100644 index 952805f..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_1.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_2.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_2.png deleted file mode 100644 index 8f6e8d3..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_2.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_3.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_3.png deleted file mode 100644 index ce9c70c..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_3.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_4.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_4.png deleted file mode 100644 index 984bee0..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_4.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_5.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_5.png deleted file mode 100644 index f8c6f3d..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_5.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_6.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_6.png deleted file mode 100644 index 4969fd7..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_6.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_7.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_7.png deleted file mode 100644 index e111806..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Right_7.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_0.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_0.png deleted file mode 100644 index d2f84f4..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_0.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_1.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_1.png deleted file mode 100644 index ac438a8..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_1.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_2.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_2.png deleted file mode 100644 index 7061805..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_2.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_3.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_3.png deleted file mode 100644 index 261cb9d..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_3.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_4.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_4.png deleted file mode 100644 index d2f84f4..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_4.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_5.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_5.png deleted file mode 100644 index 09c948b..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_5.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_6.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_6.png deleted file mode 100644 index 9f0585c..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_6.png and /dev/null differ diff --git a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_7.png b/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_7.png deleted file mode 100644 index fb3dfde..0000000 Binary files a/data/textures/player/Elliot/08_Elliot_RPG_Walk Up_7.png and /dev/null differ diff --git a/data/textures/player/down/down_1.png b/data/textures/player/down/down_1.png deleted file mode 100644 index 9904732..0000000 Binary files a/data/textures/player/down/down_1.png and /dev/null differ diff --git a/data/textures/player/down/down_2.png b/data/textures/player/down/down_2.png deleted file mode 100644 index d8f379f..0000000 Binary files a/data/textures/player/down/down_2.png and /dev/null differ diff --git a/data/textures/player/down/down_3.png b/data/textures/player/down/down_3.png deleted file mode 100644 index fce977e..0000000 Binary files a/data/textures/player/down/down_3.png and /dev/null differ diff --git a/data/textures/player/down/down_4.png b/data/textures/player/down/down_4.png deleted file mode 100644 index 6649d40..0000000 Binary files a/data/textures/player/down/down_4.png and /dev/null differ diff --git a/data/textures/player/down/down_5.png b/data/textures/player/down/down_5.png deleted file mode 100644 index 93a3e47..0000000 Binary files a/data/textures/player/down/down_5.png and /dev/null differ diff --git a/data/textures/player/down/down_6.png b/data/textures/player/down/down_6.png deleted file mode 100644 index 9df67cd..0000000 Binary files a/data/textures/player/down/down_6.png and /dev/null differ diff --git a/data/textures/player/down/down_7.png b/data/textures/player/down/down_7.png deleted file mode 100644 index 7e14dc2..0000000 Binary files a/data/textures/player/down/down_7.png and /dev/null differ diff --git a/data/textures/player/down/down_8.png b/data/textures/player/down/down_8.png deleted file mode 100644 index a63e567..0000000 Binary files a/data/textures/player/down/down_8.png and /dev/null differ diff --git a/data/textures/player/down_attack/attack_down.png b/data/textures/player/down_attack/attack_down.png deleted file mode 100644 index fce977e..0000000 Binary files a/data/textures/player/down_attack/attack_down.png and /dev/null differ diff --git a/data/textures/player/down_stand/stand_down.png b/data/textures/player/down_stand/stand_down.png deleted file mode 100644 index 9904732..0000000 Binary files a/data/textures/player/down_stand/stand_down.png and /dev/null differ diff --git a/data/textures/player/left/left_1.png b/data/textures/player/left/left_1.png deleted file mode 100644 index f182fa2..0000000 Binary files a/data/textures/player/left/left_1.png and /dev/null differ diff --git a/data/textures/player/left/left_2.png b/data/textures/player/left/left_2.png deleted file mode 100644 index 30550e8..0000000 Binary files a/data/textures/player/left/left_2.png and /dev/null differ diff --git a/data/textures/player/left/left_3.png b/data/textures/player/left/left_3.png deleted file mode 100644 index 0d6c93b..0000000 Binary files a/data/textures/player/left/left_3.png and /dev/null differ diff --git a/data/textures/player/left/left_4.png b/data/textures/player/left/left_4.png deleted file mode 100644 index 47ae804..0000000 Binary files a/data/textures/player/left/left_4.png and /dev/null differ diff --git a/data/textures/player/left/left_5.png b/data/textures/player/left/left_5.png deleted file mode 100644 index 5c7846b..0000000 Binary files a/data/textures/player/left/left_5.png and /dev/null differ diff --git a/data/textures/player/left/left_6.png b/data/textures/player/left/left_6.png deleted file mode 100644 index b60a134..0000000 Binary files a/data/textures/player/left/left_6.png and /dev/null differ diff --git a/data/textures/player/left/left_7.png b/data/textures/player/left/left_7.png deleted file mode 100644 index 2e40a72..0000000 Binary files a/data/textures/player/left/left_7.png and /dev/null differ diff --git a/data/textures/player/left/left_8.png b/data/textures/player/left/left_8.png deleted file mode 100644 index 4dc468c..0000000 Binary files a/data/textures/player/left/left_8.png and /dev/null differ diff --git a/data/textures/player/left_attack/attack_left.png b/data/textures/player/left_attack/attack_left.png deleted file mode 100644 index 0d6c93b..0000000 Binary files a/data/textures/player/left_attack/attack_left.png and /dev/null differ diff --git a/data/textures/player/left_stand/stand_left.png b/data/textures/player/left_stand/stand_left.png deleted file mode 100644 index f182fa2..0000000 Binary files a/data/textures/player/left_stand/stand_left.png and /dev/null differ diff --git a/data/textures/player/player.png b/data/textures/player/player.png deleted file mode 100644 index 4c13c19..0000000 Binary files a/data/textures/player/player.png and /dev/null differ diff --git a/data/textures/player/right/right_1.png b/data/textures/player/right/right_1.png deleted file mode 100644 index c0aa317..0000000 Binary files a/data/textures/player/right/right_1.png and /dev/null differ diff --git a/data/textures/player/right/right_2.png b/data/textures/player/right/right_2.png deleted file mode 100644 index d4a4c9b..0000000 Binary files a/data/textures/player/right/right_2.png and /dev/null differ diff --git a/data/textures/player/right/right_3.png b/data/textures/player/right/right_3.png deleted file mode 100644 index 4b7fc89..0000000 Binary files a/data/textures/player/right/right_3.png and /dev/null differ diff --git a/data/textures/player/right/right_4.png b/data/textures/player/right/right_4.png deleted file mode 100644 index 846d11b..0000000 Binary files a/data/textures/player/right/right_4.png and /dev/null differ diff --git a/data/textures/player/right/right_5.png b/data/textures/player/right/right_5.png deleted file mode 100644 index 97b5bfa..0000000 Binary files a/data/textures/player/right/right_5.png and /dev/null differ diff --git a/data/textures/player/right/right_6.png b/data/textures/player/right/right_6.png deleted file mode 100644 index 9430cd1..0000000 Binary files a/data/textures/player/right/right_6.png and /dev/null differ diff --git a/data/textures/player/right/right_7.png b/data/textures/player/right/right_7.png deleted file mode 100644 index 81ede9e..0000000 Binary files a/data/textures/player/right/right_7.png and /dev/null differ diff --git a/data/textures/player/right/right_8.png b/data/textures/player/right/right_8.png deleted file mode 100644 index 7d195fa..0000000 Binary files a/data/textures/player/right/right_8.png and /dev/null differ diff --git a/data/textures/player/right_attack/attack_right.png b/data/textures/player/right_attack/attack_right.png deleted file mode 100644 index 4b7fc89..0000000 Binary files a/data/textures/player/right_attack/attack_right.png and /dev/null differ diff --git a/data/textures/player/right_stand/stand_right.png b/data/textures/player/right_stand/stand_right.png deleted file mode 100644 index c0aa317..0000000 Binary files a/data/textures/player/right_stand/stand_right.png and /dev/null differ diff --git a/data/textures/player/up/up_1.png b/data/textures/player/up/up_1.png deleted file mode 100644 index 04584f5..0000000 Binary files a/data/textures/player/up/up_1.png and /dev/null differ diff --git a/data/textures/player/up/up_2.png b/data/textures/player/up/up_2.png deleted file mode 100644 index 7f46146..0000000 Binary files a/data/textures/player/up/up_2.png and /dev/null differ diff --git a/data/textures/player/up/up_3.png b/data/textures/player/up/up_3.png deleted file mode 100644 index a887fb6..0000000 Binary files a/data/textures/player/up/up_3.png and /dev/null differ diff --git a/data/textures/player/up/up_4.png b/data/textures/player/up/up_4.png deleted file mode 100644 index 4c34204..0000000 Binary files a/data/textures/player/up/up_4.png and /dev/null differ diff --git a/data/textures/player/up/up_5.png b/data/textures/player/up/up_5.png deleted file mode 100644 index d2a81fc..0000000 Binary files a/data/textures/player/up/up_5.png and /dev/null differ diff --git a/data/textures/player/up/up_6.png b/data/textures/player/up/up_6.png deleted file mode 100644 index abf0629..0000000 Binary files a/data/textures/player/up/up_6.png and /dev/null differ diff --git a/data/textures/player/up/up_7.png b/data/textures/player/up/up_7.png deleted file mode 100644 index d8d941b..0000000 Binary files a/data/textures/player/up/up_7.png and /dev/null differ diff --git a/data/textures/player/up/up_8.png b/data/textures/player/up/up_8.png deleted file mode 100644 index 4ab408e..0000000 Binary files a/data/textures/player/up/up_8.png and /dev/null differ diff --git a/data/textures/player/up_attack/attack_up.png b/data/textures/player/up_attack/attack_up.png deleted file mode 100644 index a887fb6..0000000 Binary files a/data/textures/player/up_attack/attack_up.png and /dev/null differ diff --git a/data/textures/player/up_stand/stand_up.png b/data/textures/player/up_stand/stand_up.png deleted file mode 100644 index 04584f5..0000000 Binary files a/data/textures/player/up_stand/stand_up.png and /dev/null differ diff --git a/data/textures/stone/(1).png b/data/textures/stone/(1).png deleted file mode 100644 index f389b5b..0000000 Binary files a/data/textures/stone/(1).png and /dev/null differ diff --git a/data/textures/stone/(2).png b/data/textures/stone/(2).png deleted file mode 100644 index 8b3d9a5..0000000 Binary files a/data/textures/stone/(2).png and /dev/null differ diff --git a/data/textures/sword/down.png b/data/textures/sword/down.png deleted file mode 100644 index ebcfa93..0000000 Binary files a/data/textures/sword/down.png and /dev/null differ diff --git a/data/textures/sword/left.png b/data/textures/sword/left.png deleted file mode 100644 index 34abbfa..0000000 Binary files a/data/textures/sword/left.png and /dev/null differ diff --git a/data/textures/sword/right.png b/data/textures/sword/right.png deleted file mode 100644 index fd96c2c..0000000 Binary files a/data/textures/sword/right.png and /dev/null differ diff --git a/data/textures/sword/up.png b/data/textures/sword/up.png deleted file mode 100644 index 5cac8d4..0000000 Binary files a/data/textures/sword/up.png and /dev/null differ diff --git a/docs/gameplay.gif b/docs/gameplay.gif new file mode 100644 index 0000000..9e4b90b Binary files /dev/null and b/docs/gameplay.gif differ diff --git a/project.pptx b/docs/project.pptx similarity index 100% rename from project.pptx rename to docs/project.pptx diff --git a/docs/screenshots/boss.png b/docs/screenshots/boss.png new file mode 100644 index 0000000..8a65067 Binary files /dev/null and b/docs/screenshots/boss.png differ diff --git a/docs/screenshots/upgrades.png b/docs/screenshots/upgrades.png new file mode 100644 index 0000000..c81d714 Binary files /dev/null and b/docs/screenshots/upgrades.png differ diff --git a/game/assets/LICENSE-ninja-adventure.txt b/game/assets/LICENSE-ninja-adventure.txt new file mode 100644 index 0000000..09b6e49 --- /dev/null +++ b/game/assets/LICENSE-ninja-adventure.txt @@ -0,0 +1,117 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + + \ No newline at end of file diff --git a/game/assets/fonts/NinjaAdventure.ttf b/game/assets/fonts/NinjaAdventure.ttf new file mode 100644 index 0000000..284f684 Binary files /dev/null and b/game/assets/fonts/NinjaAdventure.ttf differ diff --git a/game/assets/fonts/OFL.txt b/game/assets/fonts/OFL.txt new file mode 100644 index 0000000..3451153 --- /dev/null +++ b/game/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022-2024 The Tiny5 Project Authors (https://github.com/Gissio/font_tiny5) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/game/assets/fonts/Tiny5-Regular.ttf b/game/assets/fonts/Tiny5-Regular.ttf new file mode 100644 index 0000000..50e9fda Binary files /dev/null and b/game/assets/fonts/Tiny5-Regular.ttf differ diff --git a/game/assets/images/chars/bat.png b/game/assets/images/chars/bat.png new file mode 100644 index 0000000..eb9db8d Binary files /dev/null and b/game/assets/images/chars/bat.png differ diff --git a/game/assets/images/chars/boss_attack_l.png b/game/assets/images/chars/boss_attack_l.png new file mode 100644 index 0000000..b5319f7 Binary files /dev/null and b/game/assets/images/chars/boss_attack_l.png differ diff --git a/game/assets/images/chars/boss_attack_r.png b/game/assets/images/chars/boss_attack_r.png new file mode 100644 index 0000000..d5ee5e5 Binary files /dev/null and b/game/assets/images/chars/boss_attack_r.png differ diff --git a/game/assets/images/chars/boss_charge_l.png b/game/assets/images/chars/boss_charge_l.png new file mode 100644 index 0000000..12ca88f Binary files /dev/null and b/game/assets/images/chars/boss_charge_l.png differ diff --git a/game/assets/images/chars/boss_charge_r.png b/game/assets/images/chars/boss_charge_r.png new file mode 100644 index 0000000..22e4111 Binary files /dev/null and b/game/assets/images/chars/boss_charge_r.png differ diff --git a/game/assets/images/chars/boss_face.png b/game/assets/images/chars/boss_face.png new file mode 100644 index 0000000..228d976 Binary files /dev/null and b/game/assets/images/chars/boss_face.png differ diff --git a/game/assets/images/chars/boss_hit.png b/game/assets/images/chars/boss_hit.png new file mode 100644 index 0000000..b46f737 Binary files /dev/null and b/game/assets/images/chars/boss_hit.png differ diff --git a/game/assets/images/chars/boss_idle.png b/game/assets/images/chars/boss_idle.png new file mode 100644 index 0000000..993e13b Binary files /dev/null and b/game/assets/images/chars/boss_idle.png differ diff --git a/game/assets/images/chars/boss_walk.png b/game/assets/images/chars/boss_walk.png new file mode 100644 index 0000000..349c9d4 Binary files /dev/null and b/game/assets/images/chars/boss_walk.png differ diff --git a/game/assets/images/chars/cyclop_hit.png b/game/assets/images/chars/cyclop_hit.png new file mode 100644 index 0000000..a60556c Binary files /dev/null and b/game/assets/images/chars/cyclop_hit.png differ diff --git a/game/assets/images/chars/cyclop_walk.png b/game/assets/images/chars/cyclop_walk.png new file mode 100644 index 0000000..b874d66 Binary files /dev/null and b/game/assets/images/chars/cyclop_walk.png differ diff --git a/game/assets/images/chars/ninja.png b/game/assets/images/chars/ninja.png new file mode 100644 index 0000000..ad5b8d2 Binary files /dev/null and b/game/assets/images/chars/ninja.png differ diff --git a/game/assets/images/chars/player.png b/game/assets/images/chars/player.png new file mode 100644 index 0000000..aa8806f Binary files /dev/null and b/game/assets/images/chars/player.png differ diff --git a/game/assets/images/chars/player_face.png b/game/assets/images/chars/player_face.png new file mode 100644 index 0000000..f79f6ea Binary files /dev/null and b/game/assets/images/chars/player_face.png differ diff --git a/game/assets/images/chars/shadow.png b/game/assets/images/chars/shadow.png new file mode 100644 index 0000000..46ea56d Binary files /dev/null and b/game/assets/images/chars/shadow.png differ diff --git a/game/assets/images/chars/skeleton.png b/game/assets/images/chars/skeleton.png new file mode 100644 index 0000000..fcb6c02 Binary files /dev/null and b/game/assets/images/chars/skeleton.png differ diff --git a/game/assets/images/chars/slime.png b/game/assets/images/chars/slime.png new file mode 100644 index 0000000..85eb450 Binary files /dev/null and b/game/assets/images/chars/slime.png differ diff --git a/game/assets/images/fx/arc.png b/game/assets/images/fx/arc.png new file mode 100644 index 0000000..1cbfbf6 Binary files /dev/null and b/game/assets/images/fx/arc.png differ diff --git a/game/assets/images/fx/energy_ball.png b/game/assets/images/fx/energy_ball.png new file mode 100644 index 0000000..5622ead Binary files /dev/null and b/game/assets/images/fx/energy_ball.png differ diff --git a/game/assets/images/fx/leaf.png b/game/assets/images/fx/leaf.png new file mode 100644 index 0000000..536d2ad Binary files /dev/null and b/game/assets/images/fx/leaf.png differ diff --git a/game/assets/images/fx/rock.png b/game/assets/images/fx/rock.png new file mode 100644 index 0000000..bc692df Binary files /dev/null and b/game/assets/images/fx/rock.png differ diff --git a/game/assets/images/fx/shuriken.png b/game/assets/images/fx/shuriken.png new file mode 100644 index 0000000..d0909e1 Binary files /dev/null and b/game/assets/images/fx/shuriken.png differ diff --git a/game/assets/images/fx/smoke.png b/game/assets/images/fx/smoke.png new file mode 100644 index 0000000..491d9e4 Binary files /dev/null and b/game/assets/images/fx/smoke.png differ diff --git a/game/assets/images/icons/damage.png b/game/assets/images/icons/damage.png new file mode 100644 index 0000000..84b0aed Binary files /dev/null and b/game/assets/images/icons/damage.png differ diff --git a/game/assets/images/icons/haste.png b/game/assets/images/icons/haste.png new file mode 100644 index 0000000..8c33039 Binary files /dev/null and b/game/assets/images/icons/haste.png differ diff --git a/game/assets/images/icons/heal.png b/game/assets/images/icons/heal.png new file mode 100644 index 0000000..2c1e4de Binary files /dev/null and b/game/assets/images/icons/heal.png differ diff --git a/game/assets/images/icons/heart.png b/game/assets/images/icons/heart.png new file mode 100644 index 0000000..7ebaff5 Binary files /dev/null and b/game/assets/images/icons/heart.png differ diff --git a/game/assets/images/icons/knockback.png b/game/assets/images/icons/knockback.png new file mode 100644 index 0000000..e4c1c68 Binary files /dev/null and b/game/assets/images/icons/knockback.png differ diff --git a/game/assets/images/icons/lance.png b/game/assets/images/icons/lance.png new file mode 100644 index 0000000..cb5f5dd Binary files /dev/null and b/game/assets/images/icons/lance.png differ diff --git a/game/assets/images/icons/multishot.png b/game/assets/images/icons/multishot.png new file mode 100644 index 0000000..785530c Binary files /dev/null and b/game/assets/images/icons/multishot.png differ diff --git a/game/assets/images/icons/reach.png b/game/assets/images/icons/reach.png new file mode 100644 index 0000000..6ea577a Binary files /dev/null and b/game/assets/images/icons/reach.png differ diff --git a/game/assets/images/icons/speed.png b/game/assets/images/icons/speed.png new file mode 100644 index 0000000..78f4872 Binary files /dev/null and b/game/assets/images/icons/speed.png differ diff --git a/game/assets/images/icons/vampire.png b/game/assets/images/icons/vampire.png new file mode 100644 index 0000000..16b87a9 Binary files /dev/null and b/game/assets/images/icons/vampire.png differ diff --git a/game/assets/images/icons/wand.png b/game/assets/images/icons/wand.png new file mode 100644 index 0000000..67d806b Binary files /dev/null and b/game/assets/images/icons/wand.png differ diff --git a/game/assets/images/items/heart.png b/game/assets/images/items/heart.png new file mode 100644 index 0000000..89593ea Binary files /dev/null and b/game/assets/images/items/heart.png differ diff --git a/game/assets/images/items/lance.png b/game/assets/images/items/lance.png new file mode 100644 index 0000000..dc6b6dd Binary files /dev/null and b/game/assets/images/items/lance.png differ diff --git a/game/assets/images/items/lance_hand.png b/game/assets/images/items/lance_hand.png new file mode 100644 index 0000000..d82b763 Binary files /dev/null and b/game/assets/images/items/lance_hand.png differ diff --git a/game/assets/images/items/sword.png b/game/assets/images/items/sword.png new file mode 100644 index 0000000..b9c7af2 Binary files /dev/null and b/game/assets/images/items/sword.png differ diff --git a/game/assets/images/items/sword_hand.png b/game/assets/images/items/sword_hand.png new file mode 100644 index 0000000..d18beb9 Binary files /dev/null and b/game/assets/images/items/sword_hand.png differ diff --git a/game/assets/images/items/wand.png b/game/assets/images/items/wand.png new file mode 100644 index 0000000..ca36cc8 Binary files /dev/null and b/game/assets/images/items/wand.png differ diff --git a/game/assets/images/items/wand_hand.png b/game/assets/images/items/wand_hand.png new file mode 100644 index 0000000..31e4f7e Binary files /dev/null and b/game/assets/images/items/wand_hand.png differ diff --git a/game/assets/images/tiles/floor.png b/game/assets/images/tiles/floor.png new file mode 100644 index 0000000..abba7bf Binary files /dev/null and b/game/assets/images/tiles/floor.png differ diff --git a/game/assets/images/tiles/nature.png b/game/assets/images/tiles/nature.png new file mode 100644 index 0000000..fba9cb6 Binary files /dev/null and b/game/assets/images/tiles/nature.png differ diff --git a/game/assets/images/ui/hearts.png b/game/assets/images/ui/hearts.png new file mode 100644 index 0000000..006472f Binary files /dev/null and b/game/assets/images/ui/hearts.png differ diff --git a/game/assets/music/battle.ogg b/game/assets/music/battle.ogg new file mode 100644 index 0000000..4d39817 Binary files /dev/null and b/game/assets/music/battle.ogg differ diff --git a/game/assets/music/boss.ogg b/game/assets/music/boss.ogg new file mode 100644 index 0000000..a566d1b Binary files /dev/null and b/game/assets/music/boss.ogg differ diff --git a/game/assets/music/menu.ogg b/game/assets/music/menu.ogg new file mode 100644 index 0000000..16ea264 Binary files /dev/null and b/game/assets/music/menu.ogg differ diff --git a/game/assets/sounds/boss_appear.ogg b/game/assets/sounds/boss_appear.ogg new file mode 100644 index 0000000..257b7bb Binary files /dev/null and b/game/assets/sounds/boss_appear.ogg differ diff --git a/game/assets/sounds/boss_slash.ogg b/game/assets/sounds/boss_slash.ogg new file mode 100644 index 0000000..1328fd5 Binary files /dev/null and b/game/assets/sounds/boss_slash.ogg differ diff --git a/game/assets/sounds/boss_stomp.ogg b/game/assets/sounds/boss_stomp.ogg new file mode 100644 index 0000000..cf4ff54 Binary files /dev/null and b/game/assets/sounds/boss_stomp.ogg differ diff --git a/game/assets/sounds/enemy_die.ogg b/game/assets/sounds/enemy_die.ogg new file mode 100644 index 0000000..ddad273 Binary files /dev/null and b/game/assets/sounds/enemy_die.ogg differ diff --git a/game/assets/sounds/enemy_hit.ogg b/game/assets/sounds/enemy_hit.ogg new file mode 100644 index 0000000..da1c469 Binary files /dev/null and b/game/assets/sounds/enemy_hit.ogg differ diff --git a/game/assets/sounds/enemy_shoot.ogg b/game/assets/sounds/enemy_shoot.ogg new file mode 100644 index 0000000..604e70d Binary files /dev/null and b/game/assets/sounds/enemy_shoot.ogg differ diff --git a/game/assets/sounds/game_over.ogg b/game/assets/sounds/game_over.ogg new file mode 100644 index 0000000..238e6b0 Binary files /dev/null and b/game/assets/sounds/game_over.ogg differ diff --git a/game/assets/sounds/heal.ogg b/game/assets/sounds/heal.ogg new file mode 100644 index 0000000..20a25a3 Binary files /dev/null and b/game/assets/sounds/heal.ogg differ diff --git a/game/assets/sounds/lance.ogg b/game/assets/sounds/lance.ogg new file mode 100644 index 0000000..0b8ea16 Binary files /dev/null and b/game/assets/sounds/lance.ogg differ diff --git a/game/assets/sounds/level_up.ogg b/game/assets/sounds/level_up.ogg new file mode 100644 index 0000000..c04eec5 Binary files /dev/null and b/game/assets/sounds/level_up.ogg differ diff --git a/game/assets/sounds/menu_accept.ogg b/game/assets/sounds/menu_accept.ogg new file mode 100644 index 0000000..8807f03 Binary files /dev/null and b/game/assets/sounds/menu_accept.ogg differ diff --git a/game/assets/sounds/menu_back.ogg b/game/assets/sounds/menu_back.ogg new file mode 100644 index 0000000..218ec79 Binary files /dev/null and b/game/assets/sounds/menu_back.ogg differ diff --git a/game/assets/sounds/menu_move.ogg b/game/assets/sounds/menu_move.ogg new file mode 100644 index 0000000..46a776d Binary files /dev/null and b/game/assets/sounds/menu_move.ogg differ diff --git a/game/assets/sounds/player_hurt.ogg b/game/assets/sounds/player_hurt.ogg new file mode 100644 index 0000000..e9849f3 Binary files /dev/null and b/game/assets/sounds/player_hurt.ogg differ diff --git a/game/assets/sounds/power_up.ogg b/game/assets/sounds/power_up.ogg new file mode 100644 index 0000000..7408fd1 Binary files /dev/null and b/game/assets/sounds/power_up.ogg differ diff --git a/game/assets/sounds/rock_break.ogg b/game/assets/sounds/rock_break.ogg new file mode 100644 index 0000000..b8ebce7 Binary files /dev/null and b/game/assets/sounds/rock_break.ogg differ diff --git a/game/assets/sounds/switch.ogg b/game/assets/sounds/switch.ogg new file mode 100644 index 0000000..2a89e0b Binary files /dev/null and b/game/assets/sounds/switch.ogg differ diff --git a/game/assets/sounds/sword.ogg b/game/assets/sounds/sword.ogg new file mode 100644 index 0000000..da06e8c Binary files /dev/null and b/game/assets/sounds/sword.ogg differ diff --git a/game/assets/sounds/victory.ogg b/game/assets/sounds/victory.ogg new file mode 100644 index 0000000..a46533a Binary files /dev/null and b/game/assets/sounds/victory.ogg differ diff --git a/game/assets/sounds/wand.ogg b/game/assets/sounds/wand.ogg new file mode 100644 index 0000000..4710810 Binary files /dev/null and b/game/assets/sounds/wand.ogg differ diff --git a/game/assets/sounds/wave_clear.ogg b/game/assets/sounds/wave_clear.ogg new file mode 100644 index 0000000..9ed99aa Binary files /dev/null and b/game/assets/sounds/wave_clear.ogg differ diff --git a/game/assets/sounds/wave_start.ogg b/game/assets/sounds/wave_start.ogg new file mode 100644 index 0000000..fda120c Binary files /dev/null and b/game/assets/sounds/wave_start.ogg differ diff --git a/game/main.py b/game/main.py new file mode 100644 index 0000000..11eb5e0 --- /dev/null +++ b/game/main.py @@ -0,0 +1,81 @@ +"""Pixel Gamble — точка входа (рабочий стол и браузерная сборка pygbag). + + python main.py обычная игра + python main.py --bot --frames 6000 автоигрок без участия человека (для проверок) +""" + +import argparse +import asyncio +import sys + +# в браузерной сборке (pygame-ce) подмодули сами не подгружаются — импортируем явно +import pygame.font # noqa: F401 +import pygame.image # noqa: F401 +import pygame.mask # noqa: F401 +import pygame.sprite # noqa: F401 +import pygame.transform # noqa: F401 + +try: + import pygame.mixer # noqa: F401 +except ImportError: # без SDL_mixer игра просто молчит + pass + +from pixelgamble.app import App + + +def parse_args(): + parser = argparse.ArgumentParser(description="Pixel Gamble") + parser.add_argument("--bot", action="store_true", help="играет автоигрок") + parser.add_argument("--frames", type=int, default=0, help="остановиться через N кадров") + parser.add_argument("--seed", type=int, default=1) + parser.add_argument("--wave", type=int, default=1, help="начать с волны N (для проверок)") + parser.add_argument("--fast", action="store_true", help="без ожидания кадров: фиксированный шаг 1/60") + parser.add_argument("--record", help="записать GIF (нужен Pillow)") + return parser.parse_args([] if sys.platform == "emscripten" else None) + + +async def main(): + args = parse_args() + bot = None + if args.bot: + from pixelgamble.bot import Bot + bot = Bot(args.seed) + app = App(bot) + if args.wave > 1: + app.new_game() + app.game.wave = args.wave + app.game.start_wave() + recorder = None + if args.record: + from pixelgamble.record import Recorder + recorder = Recorder(args.record) + frame = 0 + while app.running: + app.frame(1 / 60 if args.fast else None) + if recorder: + recorder.capture(app.screen, frame) + frame += 1 + if bot and args.fast and frame % 3600 == 0 and app.game: # раз в минуту игрового времени + g = app.game + foes = [(e.kind, e.state, round(e.pos.x), round(e.pos.y)) for e in g.enemies] + print(f"[{frame}] wave={g.wave} state={g.state} hp={g.player.hp} queue={len(g.spawner.queue)} " + f"player=({round(g.player.pos.x)},{round(g.player.pos.y)}) foes={foes[:6]}", flush=True) + if args.frames and frame >= args.frames: + break + if bot and bot.finished: + break + await asyncio.sleep(0) + if recorder: + recorder.save() + if bot: + game = app.game + result = bot.finished[-1] if bot.finished else None + outcome = "running" if result is None else "victory" if result.victory else "dead" + print(f"frames={frame} wave={game.wave if game else '-'} kills={game.kills if game else 0} " + f"score={game.score if game else 0} result={outcome}") + if game: + print("waves:", game.log, "level:", game.player.level, "weapons:", game.player.weapons, + "upgrades:", game.taken) + + +asyncio.run(main()) diff --git a/game/pixelgamble/__init__.py b/game/pixelgamble/__init__.py new file mode 100644 index 0000000..7b08d27 --- /dev/null +++ b/game/pixelgamble/__init__.py @@ -0,0 +1 @@ +"""Pixel Gamble — экшен на выживание: 10 волн врагов и босс.""" diff --git a/game/pixelgamble/app.py b/game/pixelgamble/app.py new file mode 100644 index 0000000..2e8121b --- /dev/null +++ b/game/pixelgamble/app.py @@ -0,0 +1,284 @@ +"""Окно, главный цикл и экраны: меню, настройки, управление, итоги партии.""" + +import math + +import pygame + +from . import assets, save, ui +from .game import Game +from .i18n import Lang +from .settings import FPS, GOLD, GREY, HEIGHT, RED, SCREEN_H, SCREEN_W, TITLE, VERSION, WAVES_TOTAL, WEB, WHITE, WIDTH +from .world import World + + +class Backdrop: + """Арена, медленно проплывающая за меню.""" + + def __init__(self): + self.world = World() + self.t = 0.0 + + def draw(self, view, dt): + self.t += dt + max_x, max_y = self.world.width - WIDTH, self.world.height - HEIGHT + ox = round(max_x / 2 + math.sin(self.t * 0.07) * max_x / 2) + oy = round(max_y / 2 + math.sin(self.t * 0.05) * max_y / 2) + view.blit(self.world.floor, (-ox, -oy)) + for prop in sorted(self.world.props, key=lambda p: p.sort_y): + view.blit(prop.image, prop.rect.move(-ox, -oy)) + ui.dim(view, 110) + + +class Scene: + """Экран: мир рисуется в маленький view, интерфейс — поверх на screen.""" + + def __init__(self, app): + self.app = app + + def handle(self, event): + pass + + def update(self, dt): + pass + + def draw_world(self, view, dt): + self.app.backdrop.draw(view, dt) + + def draw_ui(self, screen): + pass + + +class MenuScene(Scene): + def __init__(self, app): + super().__init__(app) + items = [("play", app.lang("play")), ("controls", app.lang("controls")), ("settings", app.lang("settings"))] + if not WEB: + items.append(("quit", app.lang("quit"))) + self.menu = ui.Menu(items, (SCREEN_W // 2, 344)) + app.audio.music("menu") + + def handle(self, event): + choice = self.menu.handle(event, self.app.audio) + if choice == "play": + self.app.new_game() + elif choice == "controls": + self.app.scene = ControlsScene(self.app) + elif choice == "settings": + self.app.scene = SettingsScene(self.app) + elif choice == "quit": + self.app.running = False + + def draw_ui(self, screen): + lang = self.app.lang + bob = round(math.sin(self.app.backdrop.t * 2) * 4) + title = ui.text(screen, "PIXEL GAMBLE", (SCREEN_W // 2, 112 + bob), 64, GOLD, "center", face="title") + ui.text(screen, lang("subtitle"), (SCREEN_W // 2, 176), ui.SMALL, WHITE, "center") + hero = ui.big(assets.image("chars/player_face.png")) + screen.blit(hero, hero.get_rect(midright=(title.left - 24, 112 - bob))) + boss = ui.big(assets.image("chars/boss_face.png")) + screen.blit(boss, boss.get_rect(midleft=(title.right + 24, 112 + bob))) + if self.app.data["best"]: + ui.text(screen, lang("best", score=self.app.data["best"]), (SCREEN_W // 2, 216), ui.SMALL, GOLD, "center") + self.menu.draw(screen) + ui.text(screen, lang("credits"), (SCREEN_W // 2, SCREEN_H - 8), ui.SMALL, GREY, "midbottom") + ui.text(screen, f"v{VERSION}", (SCREEN_W - 8, SCREEN_H - 8), ui.SMALL, GREY, "bottomright") + + +class ControlsScene(Scene): + def __init__(self, app): + super().__init__(app) + self.menu = ui.Menu([("back", app.lang("back"))], (SCREEN_W // 2, SCREEN_H - 56)) + + def handle(self, event): + back = event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE + if self.menu.handle(event, self.app.audio) == "back" or back: + self.app.scene = MenuScene(self.app) + + def draw_ui(self, screen): + rect = pygame.Rect(140, 36, SCREEN_W - 280, SCREEN_H - 120) + ui.panel(screen, rect) + ui.text(screen, self.app.lang("controls"), (SCREEN_W // 2, 70), ui.LARGE, GOLD, "center") + for i, line in enumerate(self.app.lang("controls_text")): + ui.text(screen, line, (rect.left + 36, 104 + i * 30), ui.SMALL, WHITE) + self.menu.draw(screen) + + +class SettingsScene(Scene): + def __init__(self, app): + super().__init__(app) + self.build() + + def build(self, index=0): + lang, data = self.app.lang, self.app.data + items = [("music", lang("music", value=round(data["music"] * 100))), + ("sounds", lang("sounds", value=round(data["sounds"] * 100))), + ("lang", lang("language"))] + if not WEB: + items.append(("fullscreen", lang("fullscreen", value=lang("on") if data["fullscreen"] else lang("off")))) + items.append(("back", lang("back"))) + self.menu = ui.Menu(items, (SCREEN_W // 2, 300), width=380) + self.menu.index = index + + def change(self, key, step): + data = self.app.data + if key in ("music", "sounds"): + data[key] = round(min(1.0, max(0.0, data[key] + 0.1 * step)), 1) + self.app.audio.set_volumes(data["music"], data["sounds"]) + self.app.audio.play("menu_move") + elif key == "lang": + self.app.lang.toggle() + data["lang"] = self.app.lang.code + elif key == "fullscreen": + self.app.toggle_fullscreen() + save.store(data) + self.build(self.menu.index) + + def handle(self, event): + key = self.menu.items[self.menu.index][0] + if event.type == pygame.KEYDOWN and event.key in (pygame.K_LEFT, pygame.K_a, pygame.K_RIGHT, pygame.K_d): + if key != "back": + self.change(key, -1 if event.key in (pygame.K_LEFT, pygame.K_a) else 1) + return + if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: + self.app.scene = MenuScene(self.app) + return + choice = self.menu.handle(event, self.app.audio) + if choice == "back": + self.app.scene = MenuScene(self.app) + elif choice in ("music", "sounds"): + # Enter/клик по громкости — шаг вверх по кругу 0…100% + if self.app.data[choice] >= 1.0: + self.app.data[choice] = -0.1 + self.change(choice, 1) + elif choice: + self.change(choice, 1) + + def draw_ui(self, screen): + ui.text(screen, self.app.lang("settings"), (SCREEN_W // 2, 140), ui.LARGE, GOLD, "center") + self.menu.draw(screen) + ui.text(screen, "< >", (SCREEN_W // 2, SCREEN_H - 60), ui.SMALL, GREY, "center") + + +class PlayScene(Scene): + def __init__(self, app, game): + super().__init__(app) + self.game = game + + def handle(self, event): + self.game.handle(event) + + def update(self, dt): + self.game.update(dt, self.app.bot.controls(self.game) if self.app.bot else None) + + def draw_world(self, view, dt): + self.game.draw_world(view, dt) + + def draw_ui(self, screen): + self.game.draw_ui(screen) + + +class ResultsScene(Scene): + def __init__(self, app, game): + super().__init__(app) + self.victory = game.result == "victory" + self.record = game.score > app.data["best"] + if self.record: + app.data["best"] = game.score + save.store(app.data) + minutes, seconds = divmod(int(game.time), 60) + lang = app.lang + self.rows = [(lang("stat_wave"), f"{game.wave}/{WAVES_TOTAL}"), (lang("stat_kills"), game.kills), + (lang("stat_level"), game.player.level), (lang("stat_time"), f"{minutes}:{seconds:02d}"), + (lang("stat_score"), game.score)] + self.menu = ui.Menu([("again", lang("again")), ("to_menu", lang("to_menu"))], (SCREEN_W // 2, 444)) + app.audio.music("menu") + + def handle(self, event): + choice = self.menu.handle(event, self.app.audio) + if choice == "again": + self.app.new_game() + elif choice == "to_menu": + self.app.to_menu() + + def draw_ui(self, screen): + lang = self.app.lang + title = lang("victory") if self.victory else lang("game_over") + ui.text(screen, title, (SCREEN_W // 2, 72), ui.HUGE, GOLD if self.victory else RED, "center") + rect = pygame.Rect(SCREEN_W // 2 - 220, 124, 440, 240) + ui.panel(screen, rect) + for i, (label, value) in enumerate(self.rows): + y = rect.top + 24 + i * 40 + ui.text(screen, label, (rect.left + 28, y), ui.SMALL, WHITE) + ui.text(screen, value, (rect.right - 28, y), ui.SMALL, GOLD, "topright") + if self.record: + ui.text(screen, lang("new_record"), (SCREEN_W // 2, rect.bottom + 20), ui.SMALL, GOLD, "center") + self.menu.draw(screen) + + +class App: + def __init__(self, bot=None): + pygame.init() + try: + pygame.mixer.init() + except (pygame.error, NotImplementedError): # нет звуковой карты или SDL_mixer + pass + self.data = save.load() + self.bot = bot + flags = 0 if WEB else pygame.SCALED | pygame.RESIZABLE + try: + self.screen = pygame.display.set_mode((SCREEN_W, SCREEN_H), flags) + except pygame.error: + self.screen = pygame.display.set_mode((SCREEN_W, SCREEN_H)) + self.view = pygame.Surface((WIDTH, HEIGHT)).convert() + pygame.display.set_caption(TITLE) + pygame.display.set_icon(assets.image("chars/player_face.png")) + if self.data["fullscreen"] and not WEB and not bot: + pygame.display.toggle_fullscreen() + self.clock = pygame.time.Clock() + self.audio = assets.Audio(self.data["music"], self.data["sounds"]) + self.lang = Lang(self.data["lang"]) + self.backdrop = Backdrop() + self.running = True + self.scene = MenuScene(self) + self.game = None + + def new_game(self): + self.game = Game(self, rng=self.bot.rng if self.bot else None) + self.scene = PlayScene(self, self.game) + + def to_menu(self): + self.game = None + self.scene = MenuScene(self) + + def show_results(self, game): + self.scene = ResultsScene(self, game) + + def toggle_fullscreen(self): + if WEB: + return + try: + pygame.display.toggle_fullscreen() + self.data["fullscreen"] = not self.data["fullscreen"] + except pygame.error: + pass + + def frame(self, dt=None): + if dt is None: + dt = min(self.clock.tick(FPS) / 1000, 1 / 20) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self.running = False + elif event.type == pygame.KEYDOWN and event.key == pygame.K_F11: + self.toggle_fullscreen() + save.store(self.data) + elif event.type in (pygame.WINDOWFOCUSLOST,) and isinstance(self.scene, PlayScene) and not self.bot: + self.game.paused = self.game.paused or not self.game.result + else: + self.scene.handle(event) + if self.bot: + self.bot.drive(self) + self.scene.update(dt) + self.scene.draw_world(self.view, dt) + pygame.transform.scale(self.view, self.screen.get_size(), self.screen) + self.scene.draw_ui(self.screen) + pygame.display.flip() diff --git a/game/pixelgamble/assets.py b/game/pixelgamble/assets.py new file mode 100644 index 0000000..0f8f201 --- /dev/null +++ b/game/pixelgamble/assets.py @@ -0,0 +1,121 @@ +"""Загрузка и нарезка ресурсов: спрайты, звуки, музыка, шрифты.""" + +import pygame + +from .settings import ASSETS, TILE + +DIRECTIONS = ("down", "up", "left", "right") # порядок столбцов в листах Ninja Adventure + +_images = {} + + +def image(name): + if name not in _images: + _images[name] = pygame.image.load(str(ASSETS / "images" / name)).convert_alpha() + return _images[name] + + +def strip(name, frame_w, frame_h=None): + """Кадры, идущие в один ряд.""" + sheet = image(name) + frame_h = frame_h or sheet.get_height() + return [sheet.subsurface((x, 0, frame_w, frame_h)) for x in range(0, sheet.get_width() - frame_w + 1, frame_w)] + + +def grid(name, size=16): + sheet = image(name) + return [[sheet.subsurface((x, y, size, size)) for x in range(0, sheet.get_width(), size)] + for y in range(0, sheet.get_height(), size)] + + +def character(name): + """Лист персонажа 4×7: столбцы — направления, строки 0-3 — шаги, строка 4 — удар.""" + rows = grid(f"chars/{name}") + return {d: {"walk": [rows[r][c] for r in range(4)], "attack": [rows[4][c]]} + for c, d in enumerate(DIRECTIONS)} + + +def monster(name): + """Лист монстра 4×4: столбцы — направления, строки — кадры движения.""" + rows = grid(f"chars/{name}") + return {d: {"walk": [rows[r][c] for r in range(4)]} for c, d in enumerate(DIRECTIONS)} + + +def tile(sheet, col, row, w=1, h=1): + return image(f"tiles/{sheet}").subsurface((col * TILE, row * TILE, w * TILE, h * TILE)) + + +def flipped(frames): + return [pygame.transform.flip(f, True, False) for f in frames] + + +_white = {} + + +def white(surface): + """Белый силуэт спрайта для вспышки при попадании.""" + key = id(surface) + if key not in _white: + mask = pygame.mask.from_surface(surface) + _white[key] = mask.to_surface(setcolor=(255, 255, 255, 255), unsetcolor=(0, 0, 0, 0)) + return _white[key] + + +_fonts = {} + + +FONTS = {"text": "Tiny5-Regular.ttf", "title": "NinjaAdventure.ttf"} # у второго только латиница + + +def font(size, face="text"): + key = (size, face) + if key not in _fonts: + _fonts[key] = pygame.font.Font(str(ASSETS / "fonts" / FONTS[face]), size) + return _fonts[key] + + +class Audio: + """Звуки и музыка. Если звуковой карты нет, игра просто молчит.""" + + VOLUMES = {"enemy_hit": 0.5, "sword": 0.5, "lance": 0.5, "wand": 0.45, "enemy_shoot": 0.4, + "menu_move": 0.4, "switch": 0.5, "enemy_die": 0.55, "rock_break": 0.5} + + def __init__(self, music=0.5, sounds=0.7): + self.music_volume = music + self.sound_volume = sounds + try: + self.enabled = pygame.mixer.get_init() is not None + except NotImplementedError: + self.enabled = False + self.sounds = {} + self.track = None + if self.enabled: + for path in sorted((ASSETS / "sounds").glob("*.ogg")): + self.sounds[path.stem] = pygame.mixer.Sound(str(path)) + + def play(self, name): + sound = self.sounds.get(name) + if sound: + sound.set_volume(self.sound_volume * self.VOLUMES.get(name, 0.7)) + sound.play() + + def music(self, track): + if not self.enabled or track == self.track: + return + self.track = track + try: + pygame.mixer.music.load(str(ASSETS / "music" / f"{track}.ogg")) + pygame.mixer.music.set_volume(self.music_volume * 0.6) + pygame.mixer.music.play(-1, fade_ms=600) + except pygame.error: + self.track = None + + def fadeout(self, ms): + if self.enabled: + pygame.mixer.music.fadeout(ms) + self.track = None + + def set_volumes(self, music, sounds): + self.music_volume, self.sound_volume = music, sounds + if self.enabled: + pygame.mixer.music.set_volume(music * 0.6) diff --git a/game/pixelgamble/boss.py b/game/pixelgamble/boss.py new file mode 100644 index 0000000..bb071df --- /dev/null +++ b/game/pixelgamble/boss.py @@ -0,0 +1,168 @@ +"""Босс финальной волны — Красный самурай. Три фазы по уровню здоровья.""" + +import math +import random + +import pygame + +from . import assets +from .enemies import Enemy +from .entity import Entity, Vector + +BOSS_HP = 2600 +# в кадрах 96×96 ноги нарисованы выше нижнего края, чем в кадрах 96×48 — опускаем их до тени +FEET = {"attack": 15, "charge": 19} + + +class Boss(Enemy): + boss = True + heavy = True + + def __init__(self, pos, hp_mult=1.0): + Entity.__init__(self, pos, 30, 16) # минуя Enemy.__init__: босса нет в таблице KINDS + self.kind = "boss" + self.max_hp = self.hp = BOSS_HP * hp_mult + self.speed = 34 + self.contact = 2 + self.xp, self.score = 0, 3000 + self.state, self.timer = "spawn", 1.4 + self.cooldown = 1.5 + self.dash_cooldown = 4.0 + self.summon_cooldown = 6.0 + self.burst_cooldown = 5.0 + self.dir = Vector() + self.side = "right" + self.phase = 1 + self.slash_hit = False + self.load_frames() + + def load_frames(self): + self.frames = { + "walk": assets.strip("chars/boss_walk.png", 96), + "idle": assets.strip("chars/boss_idle.png", 96), + "hit": assets.strip("chars/boss_hit.png", 96), + "attack_left": assets.strip("chars/boss_attack_l.png", 96), + "attack_right": assets.strip("chars/boss_attack_r.png", 96), + "charge_left": assets.strip("chars/boss_charge_l.png", 96), + "charge_right": assets.strip("chars/boss_charge_r.png", 96), + } + + @property + def active(self): + return self.state not in ("spawn", "roar") + + def hurt(self, amount, source_pos, knock): + if not self.active: + return False + self.hp -= amount + self.flash = 0.08 + new_phase = 1 if self.hp > self.max_hp * 0.6 else 2 if self.hp > self.max_hp * 0.3 else 3 + if new_phase > self.phase and self.hp > 0: + self.phase = new_phase + self.state, self.timer = "roar", 1.0 + return True + + def slash_box(self): + box = pygame.Rect(0, 0, 58, 44) + if self.side == "right": + box.midleft = (self.hitbox.centerx - 6, self.hitbox.centery - 12) + else: + box.midright = (self.hitbox.centerx + 6, self.hitbox.centery - 12) + return box + + def burst(self, game, count): + start = random.random() * math.tau + for i in range(count): + angle = start + math.tau * i / count + game.enemy_shot(self.pos + Vector(0, -10), Vector(math.cos(angle), math.sin(angle)) * 95, 1) + game.audio.play("enemy_shoot") + + def think(self, dt, game, obstacles): + player = game.player + dist = self.distance_to(player) + to_player = self.direction_to(player) + self.dash_cooldown -= dt + self.summon_cooldown -= dt + self.burst_cooldown -= dt + if to_player.x: + facing_side = "right" if to_player.x > 0 else "left" + else: + facing_side = self.side + + if self.state == "roar": + self.timer -= dt + game.shake.add(1.5) + if self.timer <= 0: + self.state = "move" + game.audio.play("boss_appear") + return + + if self.state == "move": + self.side = facing_side + speed = self.speed * (1.3 if self.phase == 3 else 1.0) + if dist > 34: + self.chase(dt, game, obstacles, speed=speed) + if dist < 52 and self.cooldown <= 0: + self.state, self.timer = "slash_windup", 0.45 + elif self.phase >= 2 and dist > 90 and self.dash_cooldown <= 0: + self.state, self.timer = "dash_windup", 0.6 + self.dir = Vector(to_player) + elif self.phase == 3 and self.summon_cooldown <= 0: + self.summon_cooldown = 9.0 + game.summon(["ninja", "bat", "bat"], self.pos) + elif self.phase == 3 and self.burst_cooldown <= 0: + self.burst_cooldown = 5.0 + self.burst(game, 12) + elif self.state == "slash_windup": + self.timer -= dt + if self.timer <= 0: + self.state, self.timer, self.slash_hit = "slash", 0.36, False + game.audio.play("boss_slash") + elif self.state == "slash": + self.timer -= dt + if not self.slash_hit and self.timer < 0.28 and self.slash_box().colliderect(player.hitbox): + self.slash_hit = game.damage_player(3, self.pos) + if self.timer <= 0: + self.state, self.cooldown = "move", 1.3 if self.phase < 3 else 0.9 + elif self.state == "dash_windup": + self.timer -= dt + self.side = "right" if self.dir.x >= 0 else "left" + if self.timer <= 0: + self.state, self.timer = "dash", 0.55 + game.audio.play("boss_stomp") + elif self.state == "dash": + self.timer -= dt + before = Vector(self.pos) + self.move(self.dir * 260 * dt, obstacles) + if self.timer <= 0 or self.pos.distance_squared_to(before) < 0.5: + self.state, self.dash_cooldown = "move", 4.5 if self.phase == 2 else 3.2 + game.shake.add(3) + self.burst(game, 8) + + def animate(self, dt): + self.anim += dt + if self.state == "spawn": + self.image = assets.white(self.frames["idle"][0]) if int(self.timer * 10) % 2 else self.frames["idle"][0] + self.rect = self.image.get_rect(midbottom=(self.hitbox.centerx, self.hitbox.bottom + 4)) + return + if self.state == "roar": + frames = self.frames["hit"] + elif self.state in ("slash_windup", "dash_windup"): + frames = self.frames[f"charge_{self.side}"] + image = frames[min(len(frames) - 1, int(self.anim * 9) % len(frames))] + self.place_image(image, FEET["charge"]) + if self.state == "dash_windup" and int(self.timer * 20) % 2: + self.image = assets.white(image) + return + elif self.state == "slash": + frames = self.frames[f"attack_{self.side}"] + image = frames[min(len(frames) - 1, int((0.36 - self.timer) / 0.36 * len(frames)))] + self.place_image(image, FEET["attack"]) + return + elif self.state == "dash": + frames = self.frames["walk"] + else: + frames = self.frames["walk"] if self.dir.length_squared() else self.frames["idle"] + speed = 18 if self.state == "dash" else 9 + image = frames[int(self.anim * speed) % len(frames)] + self.place_image(image, 4) diff --git a/game/pixelgamble/bot.py b/game/pixelgamble/bot.py new file mode 100644 index 0000000..34eed14 --- /dev/null +++ b/game/pixelgamble/bot.py @@ -0,0 +1,92 @@ +"""Автоигрок для проверок без окна (CI) и записи GIF: не идеален, но доходит далеко.""" + +import random + +from .app import MenuScene, PlayScene, ResultsScene +from .entity import Vector +from .player import WEAPONS + + +class Bot: + def __init__(self, seed=1, restart=False): + self.rng = random.Random(seed) + self.restart = restart + self.finished = [] # итоги сыгранных партий + self.stuck_time = 0.0 + self.escape = Vector() + self.escape_time = 0.0 + self.last_pos = None + self.last_foe, self.last_hp, self.no_progress = None, 0, 0.0 + + def drive(self, app): + scene = app.scene + if isinstance(scene, MenuScene): + app.new_game() + elif isinstance(scene, ResultsScene): + if not self.finished or self.finished[-1] is not scene: + self.finished.append(scene) + if self.restart: + app.new_game() + elif isinstance(scene, PlayScene) and scene.game.state == "upgrade": + scene.game.choose(self.pick(scene.game.offer)) + + def pick(self, offer): + order = ("wand", "heart", "damage", "haste", "heal", "lance", "multishot", "speed", "reach", "vampire", + "knockback") + return min(range(len(offer)), key=lambda i: order.index(offer[i])) + + def controls(self, game, dt=1 / 60): + player = game.player + foes = [e for e in game.enemies if e.active] + move = Vector() + target = None + + # здоровье на исходе — к ближайшему сердечку + if game.hearts and player.hp <= player.max_hp // 2: + heart = min(game.hearts, key=lambda h: h.pos.distance_squared_to(player.pos)) + move = heart.pos - player.pos + + if foes: + foe = min(foes, key=lambda e: e.pos.distance_squared_to(player.pos)) + delta = foe.pos - player.pos + dist = delta.length() + ranged = WEAPONS[player.weapon]["kind"] == "bolt" + want = 70 if ranged else (20 if player.weapon == "sword" else 30) + if not move: + if dist > want + 6: + move = delta + elif dist < want - 12 or foe.boss: + move = -delta + Vector(-delta.y, delta.x) * 0.8 + if dist < (130 if ranged else want + 14): + target = foe.pos + # стреляем, а урона нет (мешает камень) — подходим с другой стороны + if target is not None and foe is self.last_foe and foe.hp >= self.last_hp: + self.no_progress += dt + else: + self.no_progress = 0.0 + self.last_foe, self.last_hp = foe, foe.hp + if self.no_progress > 2.5: + move = Vector(delta).rotate(70 * getattr(self, "side", 1)) + if player.weapon != "wand" and "wand" in player.weapons and len(foes) > 3: + player.switch(index=player.weapons.index("wand")) + + for shot in game.enemy_shots: + to_me = player.pos - shot.pos + if to_me.length_squared() < 40 ** 2 and shot.vel.dot(to_me) > 0: + move = Vector(-shot.vel.y, shot.vel.x) + break + + # упёрся в дерево или камень — обходим вбок, каждый раз с другой стороны + if self.escape_time > 0: + self.escape_time -= dt + move = self.escape + elif move and self.last_pos is not None and player.pos.distance_squared_to(self.last_pos) < 0.05: + self.stuck_time += dt + if self.stuck_time > 0.25: + self.side = -getattr(self, "side", 1) + self.escape = Vector(move).rotate(80 * self.side) + self.escape_time, self.stuck_time = 0.6 + self.rng.random() * 0.4, 0.0 + else: + self.stuck_time = 0.0 + self.last_pos = Vector(player.pos) + return move, target is not None, target diff --git a/game/pixelgamble/effects.py b/game/pixelgamble/effects.py new file mode 100644 index 0000000..d286252 --- /dev/null +++ b/game/pixelgamble/effects.py @@ -0,0 +1,148 @@ +"""Анимации-эффекты, снаряды, подбираемые сердечки, всплывающие надписи.""" + +import math +import random + +import pygame + +from . import assets, ui +from .entity import Vector + + +class Effect(pygame.sprite.Sprite): + """Проигрывается один раз и исчезает.""" + + def __init__(self, frames, pos, fps=16, sort_y=None, anchor="center"): + super().__init__() + self.frames, self.fps, self.t = frames, fps, 0.0 + self.pos, self.anchor = Vector(pos), anchor + self.sort_y = sort_y if sort_y is not None else pos[1] + self.image = frames[0] + self.rect = self.image.get_rect(**{anchor: (round(pos[0]), round(pos[1]))}) + + def update(self, dt): + self.t += dt + i = int(self.t * self.fps) + if i >= len(self.frames): + self.kill() + return + self.image = self.frames[i] + self.rect = self.image.get_rect(**{self.anchor: (round(self.pos.x), round(self.pos.y))}) + + +class Particle(pygame.sprite.Sprite): + def __init__(self, image, pos, velocity, life=0.5): + super().__init__() + self.image, self.pos, self.vel, self.life = image, Vector(pos), Vector(velocity), life + self.rect = image.get_rect(center=pos) + self.sort_y = pos[1] + 4 + + def update(self, dt): + self.life -= dt + if self.life <= 0: + self.kill() + return + self.pos += self.vel * dt + self.vel *= max(0.0, 1 - 4 * dt) + self.vel.y += 120 * dt + self.rect.center = (round(self.pos.x), round(self.pos.y)) + + +class Projectile(pygame.sprite.Sprite): + def __init__(self, frames, pos, velocity, damage, owner, life=1.2, knock=60, radius=4, spin=False): + super().__init__() + self.frames, self.pos, self.vel = frames, Vector(pos), Vector(velocity) + self.damage, self.owner, self.life, self.knock = damage, owner, life, knock + self.radius, self.spin = radius, spin + self.t = 0.0 + self.hit = set() + if not spin: # спрайт снаряда нарисован летящим вниз — поворачиваем один раз + angle = -math.degrees(math.atan2(self.vel.y, self.vel.x)) + 90 + self.frames = [pygame.transform.rotate(f, angle) for f in frames] + self._frame() + + @property + def sort_y(self): + return self.pos.y + 6 + + def hitbox(self): + r = self.radius + return pygame.Rect(round(self.pos.x) - r, round(self.pos.y) - r, r * 2, r * 2) + + def _frame(self): + self.image = self.frames[int(self.t * 12) % len(self.frames)] + self.rect = self.image.get_rect(center=(round(self.pos.x), round(self.pos.y))) + + def update(self, dt): + self.t += dt + self.life -= dt + self.pos += self.vel * dt + if self.life <= 0: + self.kill() + return + self._frame() + + +class Heart(pygame.sprite.Sprite): + """Сердечко: лечит на одно сердце, притягивается к игроку вблизи.""" + + LIFETIME = 14.0 + + def __init__(self, pos): + super().__init__() + self.base = assets.image("items/heart.png") + self.pos = Vector(pos) + self.t = random.random() * 3 + self.life = self.LIFETIME + self.image = self.base + self.rect = self.image.get_rect(center=pos) + + @property + def sort_y(self): + return self.pos.y + 4 + + def update(self, dt, player): + self.t += dt + self.life -= dt + if self.life <= 0: + self.kill() + return + delta = player.pos - self.pos + if delta.length_squared() < 36 ** 2 and delta.length_squared() > 0: + self.pos += delta.normalize() * 140 * dt + bob = math.sin(self.t * 5) * 2 + self.image = self.base + if self.life < 3 and int(self.life * 8) % 2: + self.image = pygame.Surface((1, 1), pygame.SRCALPHA) + self.rect = self.image.get_rect(center=(round(self.pos.x), round(self.pos.y + bob))) + + +class FloatText(pygame.sprite.Sprite): + """Всплывающая надпись: позиция в мире, рисуется на слое интерфейса.""" + + def __init__(self, text, pos, color, size=20, life=0.8): + super().__init__() + self.image = ui.outlined(text, size, color).copy() + self.pos, self.life, self.total = Vector(pos), life, life + + def update(self, dt): + self.life -= dt + if self.life <= 0: + self.kill() + return + self.pos.y -= 22 * dt + self.image.set_alpha(int(255 * min(1.0, self.life / (self.total * 0.5)))) + + +class Shake: + def __init__(self): + self.power = 0.0 + + def add(self, power): + self.power = max(self.power, power) + + def offset(self, dt): + self.power = max(0.0, self.power - dt * 18) + if self.power <= 0: + return Vector() + return Vector(random.uniform(-1, 1), random.uniform(-1, 1)) * self.power diff --git a/game/pixelgamble/enemies.py b/game/pixelgamble/enemies.py new file mode 100644 index 0000000..1845402 --- /dev/null +++ b/game/pixelgamble/enemies.py @@ -0,0 +1,217 @@ +"""Враги: слайм, летучая мышь, ниндзя, скелет-метатель, демон-циклоп.""" + +import math +import random + +from . import assets +from .entity import Entity, Vector, direction_name + +KINDS = { + "slime": {"hp": 22, "speed": 34, "contact": 1, "xp": 3, "score": 30, "hit": (12, 8)}, + "bat": {"hp": 12, "speed": 80, "contact": 1, "xp": 3, "score": 35, "hit": (10, 8)}, + "ninja": {"hp": 38, "speed": 54, "contact": 1, "xp": 6, "score": 60, "hit": (10, 8)}, + "skeleton": {"hp": 28, "speed": 42, "contact": 1, "xp": 7, "score": 70, "hit": (10, 8)}, + "cyclop": {"hp": 150, "speed": 27, "contact": 3, "xp": 22, "score": 250, "hit": (24, 12)}, +} + +SPAWN_TIME = 0.7 + + +class Enemy(Entity): + boss = False + flying = False + heavy = False + + def __init__(self, kind, pos, hp_mult=1.0): + data = KINDS[kind] + super().__init__(pos, *data["hit"]) + self.kind = kind + self.max_hp = self.hp = data["hp"] * hp_mult + self.speed = data["speed"] * random.uniform(0.9, 1.1) + self.contact = data["contact"] + self.xp, self.score = data["xp"], data["score"] + self.state, self.timer = "spawn", SPAWN_TIME + self.cooldown = random.uniform(0.5, 1.5) + self.dir = Vector() + self.load_frames() + + # --- общая часть --- + @property + def active(self): + return self.state != "spawn" + + def load_frames(self): + self.frames = assets.monster(f"{self.kind}.png") + + def hurt(self, amount, source_pos, knock): + if not self.active: + return False + self.hp -= amount + self.flash = 0.1 + away = self.pos - Vector(source_pos) + if away.length_squared() > 0: + self.knock = away.normalize() * knock * (0.25 if self.heavy else 1.0) + return True + + def update(self, dt, game): + self.flash = max(0.0, self.flash - dt) + self.cooldown = max(0.0, self.cooldown - dt) + obstacles = game.walls if self.flying else game.obstacles + if self.state == "spawn": + self.timer -= dt + if self.timer <= 0: + self.state = "move" + else: + self.think(dt, game, obstacles) + self.apply_knock(dt, obstacles) + self.animate(dt) + + def chase(self, dt, game, obstacles, speed=None, direction=None): + direction = direction if direction is not None else self.direction_to(game.player) + if direction.length_squared(): + self.facing = direction_name(direction) + self.steer(direction, (speed or self.speed) * dt, obstacles, dt) + self.dir = direction + + def think(self, dt, game, obstacles): + self.chase(dt, game, obstacles) + + def animate(self, dt): + if self.state == "spawn": + self.image = assets.image("chars/shadow.png") if self.timer < SPAWN_TIME * 0.6 else self.image + self.rect = self.image.get_rect(center=self.hitbox.center) + return + self.anim = (self.anim + dt * 7) % 4 + self.place_image(self.frames[self.facing]["walk"][int(self.anim)], 3) + + +class Slime(Enemy): + pass + + +class Bat(Enemy): + """Летает зигзагом и не замечает камней.""" + + flying = True + + def think(self, dt, game, obstacles): + to_player = self.direction_to(game.player) + side = Vector(-to_player.y, to_player.x) * math.sin(self.anim * 2.2 + id(self) % 7) * 0.9 + direction = to_player + side + self.chase(dt, game, obstacles, direction=direction.normalize() if direction.length_squared() else to_player) + + +class Ninja(Enemy): + """Подбегает, замахивается и делает быстрый выпад.""" + + def load_frames(self): + self.frames = assets.character("ninja.png") + + def think(self, dt, game, obstacles): + player = game.player + if self.state == "move": + self.chase(dt, game, obstacles) + if self.distance_to(player) < 30 and self.cooldown <= 0: + self.state, self.timer = "windup", 0.4 + self.dir = self.direction_to(player) + self.facing = direction_name(self.dir) + elif self.state == "windup": + self.timer -= dt + if self.timer <= 0: + self.state, self.timer = "strike", 0.16 + game.audio.play("sword") + elif self.state == "strike": + self.timer -= dt + self.move(self.dir * 230 * dt, obstacles) + if self.hitbox.inflate(6, 6).colliderect(player.hitbox): + game.damage_player(2, self.pos) + if self.timer <= 0: + self.state, self.cooldown = "move", 1.1 + + def animate(self, dt): + if self.state in ("windup", "strike"): + self.place_image(self.frames[self.facing]["attack"][0], 3) + if self.state == "windup" and int(self.timer * 30) % 2: + self.image = assets.white(self.image) + return + super().animate(dt) + + +class Skeleton(Enemy): + """Держит дистанцию и метает сюрикены.""" + + def load_frames(self): + self.frames = assets.character("skeleton.png") + + def think(self, dt, game, obstacles): + player = game.player + dist = self.distance_to(player) + to_player = self.direction_to(player) + cornered = not game.world.inner.inflate(-56, -56).collidepoint(self.pos) + if dist < 85 and not cornered: + direction = -to_player + elif dist < 85: # у края арены не пятится, а уходит вбок к центру + side = Vector(-to_player.y, to_player.x) + if side.dot(game.world.center - self.pos) < 0: + side = -side + direction = side + elif dist > 150: + direction = to_player + else: + direction = Vector(-to_player.y, to_player.x) * (1 if id(self) % 2 else -1) * 0.6 + self.chase(dt, game, obstacles, direction=direction) + self.facing = direction_name(to_player) + if self.cooldown <= 0 and dist < 220: + self.cooldown = random.uniform(2.0, 2.8) + game.enemy_shot(self.pos + Vector(0, -6), to_player * 115, 1) + + +class Cyclop(Enemy): + """Медленный и живучий: предупреждает и бросается тараном.""" + + heavy = True + + def load_frames(self): + walk = assets.strip("chars/cyclop_walk.png", 50) + hit = assets.strip("chars/cyclop_hit.png", 50) + self.frames = {"right": walk, "left": assets.flipped(walk), "hit": hit, "hit_left": assets.flipped(hit)} + + def think(self, dt, game, obstacles): + player = game.player + if self.state == "move": + self.chase(dt, game, obstacles) + if self.distance_to(player) < 120 and self.cooldown <= 0: + self.state, self.timer = "windup", 0.65 + self.dir = self.direction_to(player) + elif self.state == "windup": + self.timer -= dt + if self.timer <= 0: + self.state, self.timer = "charge", 0.7 + game.audio.play("boss_stomp") + game.shake.add(2) + elif self.state == "charge": + self.timer -= dt + before = Vector(self.pos) + self.move(self.dir * 190 * dt, obstacles) + stuck = self.pos.distance_squared_to(before) < 0.5 + if self.timer <= 0 or stuck: + self.state, self.cooldown = "move", 2.4 + + def animate(self, dt): + if self.state == "spawn": + return super().animate(dt) + left = self.dir.x < 0 + if self.state == "windup": + frames = self.frames["hit_left" if left else "hit"] + image = frames[int(self.timer * 12) % len(frames)] + else: + self.anim = (self.anim + dt * (14 if self.state == "charge" else 7)) % 6 + image = self.frames["left" if left else "right"][int(self.anim)] + self.place_image(image, 9) + + +CLASSES = {"slime": Slime, "bat": Bat, "ninja": Ninja, "skeleton": Skeleton, "cyclop": Cyclop} + + +def make(kind, pos, hp_mult=1.0): + return CLASSES[kind](kind, pos, hp_mult) diff --git a/game/pixelgamble/entity.py b/game/pixelgamble/entity.py new file mode 100644 index 0000000..a08b7ce --- /dev/null +++ b/game/pixelgamble/entity.py @@ -0,0 +1,112 @@ +"""Базовая сущность: позиция, столкновения, отбрасывание, вспышка при попадании.""" + +import pygame + +from . import assets + +Vector = pygame.Vector2 + + +def direction_name(vec): + """Ближайшее из четырёх направлений спрайта.""" + if abs(vec.x) > abs(vec.y): + return "right" if vec.x > 0 else "left" + return "down" if vec.y >= 0 else "up" + + +DIR_VECTORS = {"down": Vector(0, 1), "up": Vector(0, -1), "left": Vector(-1, 0), "right": Vector(1, 0)} + + +class Entity(pygame.sprite.Sprite): + def __init__(self, pos, hit_w, hit_h): + super().__init__() + self.pos = Vector(pos) + self.hitbox = pygame.Rect(0, 0, hit_w, hit_h) + self.hitbox.center = (round(self.pos.x), round(self.pos.y)) + self.knock = Vector() + self.flash = 0.0 + self.facing = "down" + self.anim = 0.0 + self.image = pygame.Surface((1, 1), pygame.SRCALPHA) + self.rect = self.image.get_rect() + + @property + def sort_y(self): + return self.hitbox.bottom + + def move(self, delta, obstacles): + """Сдвиг с упором в препятствия: сначала по X, потом по Y.""" + for axis in (0, 1): + if not delta[axis]: + continue + before = self.hitbox.copy() + self.pos[axis] += delta[axis] + self.hitbox.center = (round(self.pos.x), round(self.pos.y)) + hits = self.hitbox.collidelistall(obstacles) + for i in hits: + wall = obstacles[i] + if before.colliderect(wall): + self.push_out(wall) # уже стояли внутри — упор по направлению шага выкинул бы насквозь + elif axis == 0: + if delta.x > 0: + self.hitbox.right = wall.left + else: + self.hitbox.left = wall.right + elif delta.y > 0: + self.hitbox.bottom = wall.top + else: + self.hitbox.top = wall.bottom + if hits: # дробную часть позиции сбрасываем только при упоре + self.pos.update(self.hitbox.center) + + def steer(self, direction, distance, obstacles, dt): + """Шаг с обходом препятствий: если прямо упёрлись — пробуем в обход и держим выбранную сторону.""" + self.detour_time = max(0.0, getattr(self, "detour_time", 0.0) - dt) + if self.detour_time > 0: + direction = direction.rotate(self.detour) + start = Vector(self.pos) + self.move(direction * distance, obstacles) + if distance <= 0 or self.pos.distance_to(start) > distance * 0.4: + return direction + side = getattr(self, "detour_side", 1) + for angle in (45 * side, -45 * side, 90 * side, -90 * side, 135 * side, -135 * side): + self.pos.update(start) + self.hitbox.center = (round(start.x), round(start.y)) + self.move(direction.rotate(angle) * distance, obstacles) + if self.pos.distance_to(start) > distance * 0.4: + self.detour, self.detour_time = angle, 0.6 + self.detour_side = 1 if angle > 0 else -1 + return direction.rotate(angle) + return direction + + def push_out(self, wall): + """Вытолкнуть из препятствия по кратчайшему пути.""" + box = self.hitbox + shifts = [(wall.left - box.right, 0), (wall.right - box.left, 0), + (0, wall.top - box.bottom), (0, wall.bottom - box.top)] + dx, dy = min(shifts, key=lambda s: abs(s[0]) + abs(s[1])) + box.move_ip(dx, dy) + + def keep_inside(self, area): + """Страховка: не выходить за пределы арены.""" + if not area.contains(self.hitbox): + self.hitbox.clamp_ip(area) + self.pos.update(self.hitbox.center) + + def apply_knock(self, dt, obstacles): + if self.knock.length_squared() > 1: + self.move(self.knock * dt, obstacles) + self.knock *= max(0.0, 1 - 10 * dt) + else: + self.knock.update(0, 0) + + def place_image(self, image, offset_y=0): + self.image = assets.white(image) if self.flash > 0 else image + self.rect = self.image.get_rect(midbottom=(self.hitbox.centerx, self.hitbox.bottom + offset_y)) + + def distance_to(self, other): + return self.pos.distance_to(other.pos) + + def direction_to(self, other): + delta = other.pos - self.pos + return delta.normalize() if delta.length_squared() > 0 else Vector() diff --git a/game/pixelgamble/game.py b/game/pixelgamble/game.py new file mode 100644 index 0000000..1e2216f --- /dev/null +++ b/game/pixelgamble/game.py @@ -0,0 +1,496 @@ +"""Партия: волны, бой, улучшения между волнами, камера и интерфейс.""" + +import math +import random + +import pygame + +from . import assets, enemies, ui, upgrades +from .boss import Boss +from .effects import Effect, FloatText, Heart, Particle, Projectile, Shake +from .entity import Vector +from .player import WEAPONS, Player +from .settings import ( + BLUE, + GOLD, + GREEN, + GREY, + HEART_DROP_CHANCE, + HEIGHT, + RED, + SCALE, + SCREEN_H, + SCREEN_W, + WAVE_BREAK, + WAVES_TOTAL, + WHITE, + WIDTH, + XP_BASE, + XP_STEP, +) +from .waves import Spawner, hp_multiplier +from .world import World + +# поворот спрайта взмаха (нарисован для удара вниз) под направление удара +ARC_ANGLES = {(0, 1): 0, (0, -1): 180, (1, 0): 90, (-1, 0): -90} + + +def xp_needed(level): + return XP_BASE + XP_STEP * (level - 1) + + +class Game: + def __init__(self, app, start_wave=1, rng=None): + self.app, self.audio, self.lang = app, app.audio, app.lang + self.rng = rng or random.Random() + self.world = World() + self.walls = self.world.walls + self.obstacles = self.world.obstacles() + self.player = Player(self.world.center) + self.enemies = pygame.sprite.Group() + self.shots = pygame.sprite.Group() # снаряды игрока + self.enemy_shots = pygame.sprite.Group() + self.effects = pygame.sprite.Group() + self.hearts = pygame.sprite.Group() + self.texts = pygame.sprite.Group() + self.shake = Shake() + self.camera = Vector(self.player.pos) - Vector(WIDTH / 2, HEIGHT / 2) + self.clamp_camera() + self.drawn_offset = (0, 0) + + self.wave = start_wave + self.taken = {} + self.kills = 0 + self.score = 0 + self.log = [] # (волна, секунд на волну, здоровье в конце) — для проверок баланса + self.time = 0.0 + self.wave_time = 0.0 + self.boss = None + self.result = None # "dead" / "victory" — когда партия закончилась + self.end_timer = 0.0 + self.offer = [] + self.card = 0 + self.paused = False + self.pause_menu = ui.Menu([("resume", self.lang("resume")), ("to_menu", self.lang("to_menu"))], + (SCREEN_W // 2, 300)) + + self.smoke = assets.strip("fx/smoke.png", 32) + self.arc = assets.strip("fx/arc.png", 38) + self.ball = assets.strip("fx/energy_ball.png", 16) + self.shuriken = assets.strip("fx/shuriken.png", 16) + self.rock_bits = assets.strip("fx/rock.png", 16) + self.shadow = assets.image("chars/shadow.png").copy() + self.shadow.set_alpha(120) + self.big_shadow = pygame.transform.scale_by(self.shadow, 2) + self.big_shadow.set_alpha(120) + self.start_wave() + + # ------------------------------------------------------------------ волны + def start_wave(self): + self.state, self.timer = "intro", 1.8 + self.spawner = Spawner(self.wave, self.rng) + self.wave_time = 0.0 + self.audio.music("boss" if self.wave == WAVES_TOTAL else "battle") + self.audio.play("boss_appear" if self.wave == WAVES_TOTAL else "wave_start") + + def finish_wave(self): + self.log.append((self.wave, round(self.wave_time, 1), self.player.hp)) + bonus = 100 * self.wave + max(0, int(90 - self.wave_time)) * 3 + self.score += bonus + self.texts.add(FloatText(f"+{bonus}", self.player.pos + (0, -30), GOLD, 20, 1.4)) + if self.wave == WAVES_TOTAL: + self.end("victory") + return + self.state, self.timer = "clear", WAVE_BREAK + self.audio.play("wave_clear") + + def open_upgrades(self): + self.offer = upgrades.offer(self.player, self.taken, self.wave + 1, self.rng) + self.card = 0 + self.state = "upgrade" if self.offer else "next" + + def choose(self, index): + if 0 <= index < len(self.offer): + upgrades.apply(self.offer[index], self.player, self.taken) + self.audio.play("power_up") + self.wave += 1 + self.start_wave() + + def end(self, result): + if self.result: + return + self.result = result + self.end_timer = 2.0 + if result == "victory": + self.score += 2000 + self.player.hp * 50 + self.audio.play("victory") + else: + self.audio.play("game_over") + self.effects.add(Effect(self.smoke, self.player.pos + (0, -6), 12)) + self.audio.fadeout(1500) + + # ------------------------------------------------------------ для врагов + def damage_player(self, amount, source_pos): + if self.result: + return False + if self.player.hurt(amount, source_pos): + self.audio.play("player_hurt") + self.shake.add(3) + if self.player.dead: + self.end("dead") + return True + return False + + def enemy_shot(self, pos, velocity, damage): + self.enemy_shots.add(Projectile(self.shuriken, pos, velocity, damage, "enemy", life=2.6, radius=3, spin=True)) + self.audio.play("enemy_shoot") + + def summon(self, kinds, near): + for kind in kinds: + angle = self.rng.random() * math.tau + pos = Vector(near) + Vector(math.cos(angle), math.sin(angle)) * 40 + pos.x = min(max(pos.x, self.world.inner.left + 8), self.world.inner.right - 8) + pos.y = min(max(pos.y, self.world.inner.top + 8), self.world.inner.bottom - 8) + self.add_enemy(kind, pos) + + def add_enemy(self, kind, pos): + if kind == "boss": + enemy = Boss(pos) + self.boss = enemy + else: + enemy = enemies.make(kind, pos, hp_multiplier(self.wave)) + self.enemies.add(enemy) + self.effects.add(Effect(self.smoke, (pos[0], pos[1] - 4), 10)) + + # ------------------------------------------------------------- события + def handle(self, event): + if self.paused: + choice = self.pause_menu.handle(event, self.audio) + if choice == "resume" or (event.type == pygame.KEYDOWN and event.key in (pygame.K_ESCAPE, pygame.K_p)): + self.paused = False + elif choice == "to_menu": + self.app.to_menu() + return + if event.type == pygame.KEYDOWN: + if event.key in (pygame.K_ESCAPE, pygame.K_p) and not self.result: + self.paused = True + self.pause_menu.index = 0 + self.audio.play("menu_back") + elif self.state == "upgrade": + if event.key in (pygame.K_1, pygame.K_2, pygame.K_3, pygame.K_KP1, pygame.K_KP2, pygame.K_KP3): + self.choose({pygame.K_1: 0, pygame.K_2: 1, pygame.K_3: 2, pygame.K_KP1: 0, + pygame.K_KP2: 1, pygame.K_KP3: 2}[event.key]) + elif event.key in (pygame.K_LEFT, pygame.K_a): + self.card = (self.card - 1) % len(self.offer) + self.audio.play("menu_move") + elif event.key in (pygame.K_RIGHT, pygame.K_d): + self.card = (self.card + 1) % len(self.offer) + self.audio.play("menu_move") + elif event.key in (pygame.K_RETURN, pygame.K_SPACE, pygame.K_KP_ENTER): + self.choose(self.card) + elif event.key in (pygame.K_q, pygame.K_TAB): + if self.player.switch(1): + self.audio.play("switch") + elif event.key in (pygame.K_1, pygame.K_2, pygame.K_3): + if self.player.switch(index=event.key - pygame.K_1): + self.audio.play("switch") + elif event.type == pygame.MOUSEWHEEL and self.state != "upgrade": + if self.player.switch(-1 if event.y > 0 else 1): + self.audio.play("switch") + elif self.state == "upgrade" and event.type == pygame.MOUSEMOTION: + for i, rect in enumerate(ui.card_rects(len(self.offer))): + if rect.collidepoint(event.pos): + self.card = i + elif self.state == "upgrade" and event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + for i, rect in enumerate(ui.card_rects(len(self.offer))): + if rect.collidepoint(event.pos): + self.choose(i) + + def read_controls(self): + """Движение и атака с клавиатуры/мыши (бот подменяет этот метод).""" + keys = pygame.key.get_pressed() + move = Vector((keys[pygame.K_d] or keys[pygame.K_RIGHT]) - (keys[pygame.K_a] or keys[pygame.K_LEFT]), + (keys[pygame.K_s] or keys[pygame.K_DOWN]) - (keys[pygame.K_w] or keys[pygame.K_UP])) + mouse = pygame.mouse.get_pressed()[0] and pygame.mouse.get_focused() + attack = mouse or keys[pygame.K_SPACE] or keys[pygame.K_j] + target = Vector(pygame.mouse.get_pos()) / SCALE + self.camera if mouse else None + return move, attack, target + + # ------------------------------------------------------------- кадр + def update(self, dt, controls=None): + if self.paused: + return + if self.result: + self.end_timer -= dt + dt *= 0.35 # замедление в конце партии + if self.end_timer <= 0: + self.app.show_results(self) + return + self.time += dt + self.wave_time += dt + + move, attack, target = controls or self.read_controls() + busy = self.state == "upgrade" or self.result + if busy: + move, attack = Vector(), False + self.player.update(dt, move, self.obstacles) + if attack: + self.attack(target) + + if self.state == "intro": + self.timer -= dt + if self.timer <= 0: + self.state = "fight" + elif self.state == "fight": + alive = sum(1 for e in self.enemies if not e.boss) + for kind, pos in self.spawner.update(dt, alive, self.player.pos, self.world.spawns): + self.add_enemy(kind, pos) + if self.spawner.done() and not self.enemies and not self.result: + self.finish_wave() + elif self.state == "clear": + self.timer -= dt + if self.timer <= 0: + self.open_upgrades() + elif self.state == "next": + self.wave += 1 + self.start_wave() + + for enemy in list(self.enemies): + enemy.update(dt, self) + self.separate() + for actor in [self.player, *self.enemies]: + actor.keep_inside(self.world.inner) + self.contact_damage() + self.update_shots(dt) + self.effects.update(dt) + self.texts.update(dt) + for heart in list(self.hearts): + heart.update(dt, self.player) + if heart.alive() and heart.rect.colliderect(self.player.hitbox.inflate(6, 10)): + if self.player.heal(4): # при полном здоровье сердечко остаётся лежать + self.audio.play("heal") + heart.kill() + self.follow(dt) + + def attack(self, target): + direction = self.player.try_attack(target) + if direction is None: + return + player = self.player + weapon = WEAPONS[player.weapon] + self.audio.play(player.weapon) + if weapon["kind"] == "bolt": + spread = (-14, 0, 14) if player.multishot else (0,) + for angle in spread: + vel = direction.rotate(angle) * weapon["speed"] + start = player.pos + Vector(0, -6) + direction * 6 + damage = player.damage() * (1.0 if angle == 0 else 0.6) # боковые заряды слабее + self.shots.add(Projectile(self.ball, start, vel, damage, "player", weapon["life"], + weapon["knock"] * player.knock_mult)) + return + box = player.melee_box(direction) + self.melee_effect(direction, box, weapon["kind"]) + for enemy in list(self.enemies): + body = enemy.hitbox.inflate(6, 12).move(0, -5) + if box.colliderect(body) and enemy.hurt(player.damage(), player.pos, weapon["knock"] * player.knock_mult): + self.on_enemy_hit(enemy) + for prop in list(self.world.props): + if prop.breakable and box.colliderect(prop.hitbox): + self.break_rock(prop) + + def melee_effect(self, direction, box, kind): + if kind == "arc": + angle = ARC_ANGLES[(round(direction.x), round(direction.y))] + frames = [pygame.transform.rotate(f, angle) for f in self.arc] + self.effects.add(Effect(frames, box.center, 34, sort_y=box.centery + 10)) + else: + image = assets.image("items/lance_hand.png") + angle = -math.degrees(math.atan2(direction.y, direction.x)) - 90 + spear = pygame.transform.rotate(pygame.transform.flip(image, False, True), angle) + hand = self.player.pos + Vector(0, -5) + effect = Effect([spear] * 5, hand + direction * 16, 30, sort_y=self.player.sort_y + 1) + effect.thrust = (Vector(hand), direction) + self.effects.add(effect) + quarter = ARC_ANGLES[(round(direction.x), round(direction.y))] + tip = [pygame.transform.rotate(f, quarter) for f in self.arc[2:]] + self.effects.add(Effect(tip, box.center + direction * 6, 40, sort_y=box.centery + 10)) + + def on_enemy_hit(self, enemy): + self.audio.play("enemy_hit") + if enemy.hp <= 0: + self.kill_enemy(enemy) + + def kill_enemy(self, enemy): + enemy.kill() + self.kills += 1 + self.score += enemy.score + self.effects.add(Effect(self.smoke, enemy.hitbox.center, 14)) + self.audio.play("enemy_die") + if enemy.boss: + self.boss = None + self.shake.add(5) + for other in list(self.enemies): + other.kill() + self.effects.add(Effect(self.smoke, other.hitbox.center, 14)) + return + levels = self.player.gain_xp(enemy.xp, xp_needed) + if levels: + self.player.damage_mult *= 1.02 ** levels + self.player.heal(3 * levels) + self.audio.play("level_up") + self.texts.add(FloatText(self.lang("level_up"), self.player.pos + (0, -24), GOLD, 20, 1.2)) + if self.player.vampire and self.rng.random() < self.player.vampire: + if self.player.heal(1): + self.texts.add(FloatText("+", self.player.pos + (0, -18), GREEN)) + chance = 0.5 if enemy.kind == "cyclop" else HEART_DROP_CHANCE + if self.rng.random() < chance: + self.hearts.add(Heart(enemy.pos)) + + def break_rock(self, prop): + prop.kill() + self.obstacles = self.world.obstacles() + self.audio.play("rock_break") + self.score += 5 + for _ in range(6): + angle = self.rng.random() * math.tau + self.effects.add(Particle(self.rng.choice(self.rock_bits), prop.hitbox.center, + Vector(math.cos(angle), math.sin(angle) - 0.8) * self.rng.uniform(30, 70))) + if self.rng.random() < 0.2: + self.hearts.add(Heart(prop.hitbox.center)) + + def separate(self): + """Враги не слипаются в одну точку.""" + crowd = [e for e in self.enemies if e.active and not e.boss] + for i, a in enumerate(crowd): + for b in crowd[i + 1:]: + delta = a.pos - b.pos + dist2 = delta.length_squared() + if 0 < dist2 < 144: + push = delta.normalize() * (12 - math.sqrt(dist2)) * 0.5 + a.move(push, self.walls if a.flying else self.obstacles) + b.move(-push, self.walls if b.flying else self.obstacles) + + def contact_damage(self): + player = self.player + for enemy in self.enemies: + if enemy.active and enemy.hitbox.colliderect(player.hitbox.inflate(2, 2)): + damage = enemy.contact + if getattr(enemy, "state", "") in ("charge", "dash"): + damage += 1 + self.damage_player(damage, enemy.pos) + + def update_shots(self, dt): + self.shots.update(dt) + self.enemy_shots.update(dt) + for shot in list(self.shots): + box = shot.hitbox() + for enemy in self.enemies: + if enemy.active and box.colliderect(enemy.hitbox.inflate(6, 12).move(0, -5)): + if enemy.hurt(shot.damage, shot.pos - shot.vel.normalize() * 8, shot.knock): + self.on_enemy_hit(enemy) + shot.kill() + break + if not shot.alive(): + continue + if box.collidelist(self.walls) != -1: + shot.kill() + continue + for prop in self.world.props: + if box.colliderect(prop.hitbox): + if prop.breakable: + self.break_rock(prop) + shot.kill() + break + for shot in list(self.enemy_shots): + box = shot.hitbox() + if box.colliderect(self.player.hitbox.inflate(2, 6)): + self.damage_player(shot.damage, shot.pos) + shot.kill() + elif box.collidelist(self.obstacles) != -1: + shot.kill() + + # ------------------------------------------------------------- камера + def clamp_camera(self): + self.camera.x = min(max(self.camera.x, 0), self.world.width - WIDTH) + self.camera.y = min(max(self.camera.y, 0), self.world.height - HEIGHT) + + def follow(self, dt): + goal = self.player.pos - Vector(WIDTH / 2, HEIGHT / 2) + self.camera += (goal - self.camera) * min(1.0, dt * 8) + self.clamp_camera() + + # ------------------------------------------------------------- рисование + def draw_world(self, view, dt): + shake = self.shake.offset(dt) + ox, oy = round(self.camera.x - shake.x), round(self.camera.y - shake.y) + self.drawn_offset = (ox, oy) + view.blit(self.world.floor, (-ox, -oy)) + area = pygame.Rect(ox - 48, oy - 48, WIDTH + 96, HEIGHT + 96) + + actors = [e for e in self.enemies if e.active] + if not self.player.dead: + actors.append(self.player) + for actor in actors: + shadow = self.big_shadow if actor.heavy else self.shadow + view.blit(shadow, shadow.get_rect(center=(actor.hitbox.centerx - ox, actor.hitbox.bottom - oy))) + + drawables = [p for p in self.world.props if area.colliderect(p.rect)] + drawables += [e for e in self.enemies if area.colliderect(e.rect)] + drawables += list(self.hearts) + list(self.shots) + list(self.enemy_shots) + list(self.effects) + if not self.player.dead: + drawables.append(self.player) + for sprite in sorted(drawables, key=lambda s: s.sort_y): + thrust = getattr(sprite, "thrust", None) + if thrust: # копьё выдвигается вперёд за время удара + base, direction = thrust + pos = base + direction * (8 + 18 * min(1.0, sprite.t * 10)) + rect = sprite.image.get_rect(center=(round(pos.x), round(pos.y))) + view.blit(sprite.image, rect.move(-ox, -oy)) + else: + view.blit(sprite.image, sprite.rect.move(-ox, -oy)) + + def draw_ui(self, screen): + lang, player = self.lang, self.player + ox, oy = self.drawn_offset + for sprite in self.texts: + pos = ((sprite.pos.x - ox) * SCALE, (sprite.pos.y - oy) * SCALE) + screen.blit(sprite.image, sprite.image.get_rect(center=(round(pos[0]), round(pos[1])))) + + ui.hearts(screen, player, (12, 10)) + ui.text(screen, lang("level", n=player.level), (12, 46), ui.SMALL, GOLD) + ui.bar(screen, (80, 52, 128, 12), player.xp / xp_needed(player.level), BLUE) + ui.weapon_slots(screen, player, (12, SCREEN_H - 66)) + if len(player.weapons) > 1: + ui.text(screen, "Q", (12 + len(player.weapons) * 44 + 4, SCREEN_H - 40), ui.SMALL, GREY) + + ui.text(screen, lang("wave", n=self.wave, total=WAVES_TOTAL), (SCREEN_W // 2, 10), ui.SMALL, WHITE, "midtop") + if self.state == "fight" and not self.boss: + left = len(self.spawner.queue) + len(self.enemies) + ui.text(screen, lang("enemies_left", n=left), (SCREEN_W // 2, 34), ui.SMALL, GREY, "midtop") + ui.text(screen, f"{self.score:,}".replace(",", " "), (SCREEN_W - 12, 10), ui.SMALL, GOLD, "topright") + + if self.boss and self.boss.alive(): + rect = pygame.Rect(SCREEN_W // 2 - 220, SCREEN_H - 40, 440, 18) + ui.bar(screen, rect, max(0.0, self.boss.hp / self.boss.max_hp), RED) + ui.text(screen, lang("boss_name"), (SCREEN_W // 2, rect.top - 4), ui.SMALL, WHITE, "midbottom") + face = ui.big(assets.image("chars/boss_face.png")) + screen.blit(face, face.get_rect(midright=(rect.left - 8, rect.centery - 12))) + + middle = (SCREEN_W // 2, SCREEN_H // 2 - 60) + if self.state == "intro": + label = lang("boss_wave") if self.wave == WAVES_TOTAL else lang("wave_big", n=self.wave) + ui.text(screen, label, middle, ui.HUGE, GOLD, "center") + elif self.state == "clear": + ui.text(screen, lang("wave_clear"), middle, ui.LARGE, GREEN, "center") + elif self.state == "upgrade": + ui.dim(screen, 140) + ui.text(screen, lang("choose"), (SCREEN_W // 2, 80), ui.LARGE, GOLD, "center") + ui.cards(screen, self.offer, lang, self.card) + ui.text(screen, lang("choose_hint"), (SCREEN_W // 2, SCREEN_H - 52), ui.SMALL, GREY, "center") + if self.result: + label = lang("victory") if self.result == "victory" else lang("game_over") + ui.text(screen, label, middle, ui.HUGE, GOLD if self.result == "victory" else RED, "center") + if self.paused: + ui.dim(screen, 150) + ui.text(screen, lang("pause"), (SCREEN_W // 2, 200), ui.LARGE, WHITE, "center") + self.pause_menu.draw(screen) diff --git a/game/pixelgamble/i18n.py b/game/pixelgamble/i18n.py new file mode 100644 index 0000000..b004cb5 --- /dev/null +++ b/game/pixelgamble/i18n.py @@ -0,0 +1,148 @@ +"""Тексты интерфейса на русском и английском.""" + +TEXTS = { + "ru": { + "play": "Играть", + "controls": "Управление", + "settings": "Настройки", + "quit": "Выход", + "back": "Назад", + "resume": "Продолжить", + "to_menu": "В меню", + "again": "Ещё раз", + "best": "Рекорд: {score}", + "subtitle": "выживи 10 волн и победи самурая", + "music": "Музыка: {value}%", + "sounds": "Звуки: {value}%", + "language": "Язык: русский", + "fullscreen": "Полный экран: {value}", + "on": "вкл", + "off": "выкл", + "pause": "Пауза", + "wave": "Волна {n}/{total}", + "wave_big": "Волна {n}", + "boss_wave": "Финальная волна", + "enemies_left": "Врагов: {n}", + "wave_clear": "Волна пройдена!", + "choose": "Выбери улучшение", + "choose_hint": "1, 2, 3 или клик", + "level": "Ур. {n}", + "level_up": "Новый уровень!", + "game_over": "Вы погибли", + "victory": "Победа!", + "new_record": "Новый рекорд!", + "stat_wave": "Волна", + "stat_kills": "Врагов побеждено", + "stat_time": "Время", + "stat_level": "Уровень", + "stat_score": "Очки", + "boss_name": "Красный самурай", + "controls_text": [ + "WASD / стрелки — движение", + "ЛКМ / пробел / J — удар", + "Q / колесо / 1-3 — сменить оружие", + "Удар мышью — в сторону курсора", + "Esc / P — пауза", + "F11 — полный экран", + "", + "Сердечки из врагов и камней лечат.", + "Опыт за врагов повышает уровень,", + "а между волнами — выбор улучшения.", + ], + "credits": "Графика и звук: Ninja Adventure (pixel-boy, CC0)", + }, + "en": { + "play": "Play", + "controls": "Controls", + "settings": "Settings", + "quit": "Quit", + "back": "Back", + "resume": "Resume", + "to_menu": "Main menu", + "again": "Play again", + "best": "Best: {score}", + "subtitle": "survive 10 waves and defeat the samurai", + "music": "Music: {value}%", + "sounds": "Sounds: {value}%", + "language": "Language: English", + "fullscreen": "Fullscreen: {value}", + "on": "on", + "off": "off", + "pause": "Paused", + "wave": "Wave {n}/{total}", + "wave_big": "Wave {n}", + "boss_wave": "Final wave", + "enemies_left": "Enemies: {n}", + "wave_clear": "Wave cleared!", + "choose": "Choose an upgrade", + "choose_hint": "1, 2, 3 or click", + "level": "Lv {n}", + "level_up": "Level up!", + "game_over": "You died", + "victory": "Victory!", + "new_record": "New record!", + "stat_wave": "Wave", + "stat_kills": "Enemies defeated", + "stat_time": "Time", + "stat_level": "Level", + "stat_score": "Score", + "boss_name": "Red Samurai", + "controls_text": [ + "WASD / arrows — move", + "LMB / Space / J — attack", + "Q / wheel / 1-3 — switch weapon", + "Mouse attacks aim at the cursor", + "Esc / P — pause", + "F11 — fullscreen", + "", + "Hearts from enemies and rocks heal.", + "Enemies give XP to level up,", + "and you pick an upgrade between waves.", + ], + "credits": "Art & sound: Ninja Adventure (pixel-boy, CC0)", + }, +} + +UPGRADES = { + "ru": { + "heart": ("Крепкое сердце", "+1 сердце и лечение"), + "damage": ("Острое оружие", "+15% урона"), + "speed": ("Лёгкие ноги", "+12% скорости"), + "haste": ("Быстрые руки", "удары на 12% чаще"), + "heal": ("Перевязка", "полное лечение"), + "reach": ("Длинный замах", "+20% дальности удара"), + "lance": ("Копьё", "бьёт дальше и насквозь"), + "wand": ("Магический посох", "стреляет по курсору"), + "multishot": ("Тройной заряд", "посох бьёт веером"), + "vampire": ("Жажда боя", "шанс вылечиться за победу"), + "knockback": ("Тяжёлая рука", "враги отлетают дальше"), + }, + "en": { + "heart": ("Strong heart", "+1 heart and heal"), + "damage": ("Sharp weapons", "+15% damage"), + "speed": ("Light feet", "+12% speed"), + "haste": ("Quick hands", "attack 12% faster"), + "heal": ("Bandage", "full heal"), + "reach": ("Long swing", "+20% melee reach"), + "lance": ("Lance", "longer, pierces enemies"), + "wand": ("Magic staff", "shoots at the cursor"), + "multishot": ("Triple shot", "staff fires a fan"), + "vampire": ("Battle thirst", "chance to heal on kill"), + "knockback": ("Heavy hand", "enemies fly further"), + }, +} + + +class Lang: + def __init__(self, code="ru"): + self.code = code if code in TEXTS else "ru" + + def toggle(self): + self.code = "en" if self.code == "ru" else "ru" + + def __call__(self, key, **kwargs): + text = TEXTS[self.code].get(key, key) + return text.format(**kwargs) if kwargs and isinstance(text, str) else text + + def upgrade(self, key): + return UPGRADES[self.code][key] diff --git a/game/pixelgamble/player.py b/game/pixelgamble/player.py new file mode 100644 index 0000000..aab0e2e --- /dev/null +++ b/game/pixelgamble/player.py @@ -0,0 +1,155 @@ +"""Игрок и его оружие.""" + +import pygame + +from . import assets +from .entity import DIR_VECTORS, Entity, Vector, direction_name +from .settings import PLAYER_HEARTS, PLAYER_INVULNERABLE, PLAYER_SPEED + +WEAPONS = { + # kind: arc — широкий взмах перед собой, thrust — длинный узкий укол насквозь, bolt — снаряд + "sword": {"kind": "arc", "damage": 12, "cooldown": 0.36, "reach": 20, "width": 34, "knock": 160}, + "lance": {"kind": "thrust", "damage": 17, "cooldown": 0.52, "reach": 38, "width": 12, "knock": 120}, + "wand": {"kind": "bolt", "damage": 9, "cooldown": 0.40, "speed": 240, "life": 0.85, "knock": 70}, +} +WEAPON_ORDER = ("sword", "lance", "wand") + + +class Player(Entity): + heavy = False + + def __init__(self, pos): + super().__init__(pos, 10, 8) + self.frames = assets.character("player.png") + self.max_hp = PLAYER_HEARTS * 4 # здоровье в четвертях сердечка + self.hp = self.max_hp + self.speed = PLAYER_SPEED + self.damage_mult = 1.0 + self.cooldown_mult = 1.0 + self.reach_mult = 1.0 + self.knock_mult = 1.0 + self.vampire = 0.0 + self.multishot = False + self.weapons = ["sword"] + self.weapon = "sword" + self.cooldown = 0.0 + self.attack_pose = 0.0 + self.invulnerable = 0.0 + self.aim = Vector(0, 1) + self.moving = False + self.level = 1 + self.xp = 0 + self.dead = False + + # --- характеристики --- + def damage(self): + return WEAPONS[self.weapon]["damage"] * self.damage_mult + + def heal(self, amount): + before = self.hp + self.hp = min(self.max_hp, self.hp + amount) + return self.hp > before + + def hurt(self, amount, source_pos): + if self.invulnerable > 0 or self.dead: + return False + self.hp = max(0, self.hp - amount) + self.invulnerable = PLAYER_INVULNERABLE + self.flash = 0.12 + away = self.pos - Vector(source_pos) + if away.length_squared() > 0: + self.knock = away.normalize() * 170 + if self.hp == 0: + self.dead = True + return True + + def switch(self, step=1, index=None): + if index is not None: + if index < len(self.weapons): + self.weapon = self.weapons[index] + return True + return False + if len(self.weapons) < 2: + return False + i = (self.weapons.index(self.weapon) + step) % len(self.weapons) + self.weapon = self.weapons[i] + return True + + def unlock(self, weapon): + if weapon not in self.weapons: + self.weapons.append(weapon) + self.weapons.sort(key=WEAPON_ORDER.index) + self.weapon = weapon + + # --- кадр --- + def update(self, dt, move, obstacles): + self.cooldown = max(0.0, self.cooldown - dt) + self.attack_pose = max(0.0, self.attack_pose - dt) + self.invulnerable = max(0.0, self.invulnerable - dt) + self.flash = max(0.0, self.flash - dt) + + self.moving = move.length_squared() > 0 + if self.moving: + move = move.normalize() + self.aim = Vector(move) + if self.attack_pose <= 0: + self.facing = direction_name(move) + speed = self.speed * (0.45 if self.attack_pose > 0 else 1.0) + self.move(move * speed * dt, obstacles) + self.apply_knock(dt, obstacles) + self.animate(dt) + + def try_attack(self, target=None): + """Начать атаку. target — точка в мире (курсор) или None (по направлению взгляда).""" + if self.cooldown > 0 or self.dead: + return None + weapon = WEAPONS[self.weapon] + if target is not None: + aim = Vector(target) - self.pos + if aim.length_squared() > 0: + self.aim = aim.normalize() + if weapon["kind"] == "bolt": + direction = Vector(self.aim) + else: + self.facing = direction_name(self.aim) + direction = DIR_VECTORS[self.facing] + self.facing = direction_name(direction) + self.cooldown = weapon["cooldown"] * self.cooldown_mult + self.attack_pose = 0.18 + return direction + + def melee_box(self, direction): + weapon = WEAPONS[self.weapon] + reach = weapon["reach"] * self.reach_mult + width = weapon["width"] * (self.reach_mult if weapon["kind"] == "arc" else 1.0) + center = self.pos + Vector(0, -4) + direction * (reach / 2 + 4) + if direction.x: + box = pygame.Rect(0, 0, reach, width) + else: + box = pygame.Rect(0, 0, width, reach) + box.center = (round(center.x), round(center.y)) + return box + + def animate(self, dt): + frames = self.frames[self.facing] + if self.attack_pose > 0: + image = frames["attack"][0] + elif self.moving: + self.anim = (self.anim + dt * 8) % 4 + image = frames["walk"][int(self.anim)] + else: + image = frames["walk"][0] + self.place_image(image, 3) + if self.invulnerable > 0 and int(self.invulnerable * 20) % 2 and self.flash <= 0: + self.image = self.image.copy() + self.image.set_alpha(110) + + def gain_xp(self, amount, need): + """Возвращает число новых уровней.""" + self.xp += amount + levels = 0 + while self.xp >= need(self.level): + self.xp -= need(self.level) + self.level += 1 + levels += 1 + return levels diff --git a/game/pixelgamble/record.py b/game/pixelgamble/record.py new file mode 100644 index 0000000..e86236f --- /dev/null +++ b/game/pixelgamble/record.py @@ -0,0 +1,24 @@ +"""Запись GIF с экрана игры (для README).""" + +import pygame + + +class Recorder: + def __init__(self, path, every=3, scale=1, skip=0, limit=900): + self.path, self.every, self.scale, self.skip, self.limit = path, every, scale, skip, limit + self.frames = [] + + def capture(self, surface, frame): + if frame < self.skip or frame % self.every or len(self.frames) >= self.limit: + return + image = pygame.transform.scale_by(surface, self.scale) + self.frames.append(pygame.image.tobytes(image, "RGB")) + self.size = image.get_size() + + def save(self): + from PIL import Image + + images = [Image.frombytes("RGB", self.size, raw).quantize(colors=128, method=Image.Quantize.MEDIANCUT) + for raw in self.frames] + images[0].save(self.path, save_all=True, append_images=images[1:], duration=1000 * self.every // 60, + loop=0, optimize=True) diff --git a/game/pixelgamble/save.py b/game/pixelgamble/save.py new file mode 100644 index 0000000..93d17f1 --- /dev/null +++ b/game/pixelgamble/save.py @@ -0,0 +1,51 @@ +"""Рекорд и настройки: файл в профиле пользователя или localStorage в браузере.""" + +import json +import os +import sys +from pathlib import Path + +from .settings import WEB + +DEFAULTS = {"best": 0, "music": 0.5, "sounds": 0.7, "lang": "ru", "fullscreen": False} +KEY = "pixel_gamble" + + +def _path(): + if os.environ.get("PIXEL_GAMBLE_SAVE"): + return Path(os.environ["PIXEL_GAMBLE_SAVE"]) + if sys.platform == "win32": + root = Path(os.environ.get("APPDATA", Path.home())) + else: + root = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) + return root / "pixel_gamble" / "save.json" + + +def load(): + data = dict(DEFAULTS) + try: + if WEB: + import platform + raw = platform.window.localStorage.getItem(KEY) + else: + path = _path() + raw = path.read_text(encoding="utf-8") if path.exists() else None + if raw: + data.update({k: v for k, v in json.loads(raw).items() if k in DEFAULTS}) + except Exception: # испорченный файл или недоступное хранилище — играем с настройками по умолчанию + pass + return data + + +def store(data): + raw = json.dumps({k: data[k] for k in DEFAULTS}) + try: + if WEB: + import platform + platform.window.localStorage.setItem(KEY, raw) + else: + path = _path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(raw, encoding="utf-8") + except Exception: + pass diff --git a/game/pixelgamble/settings.py b/game/pixelgamble/settings.py new file mode 100644 index 0000000..af2cb66 --- /dev/null +++ b/game/pixelgamble/settings.py @@ -0,0 +1,46 @@ +"""Константы игры: экран, мир, баланс.""" + +import sys +from pathlib import Path + +WEB = sys.platform == "emscripten" +BASE = Path(__file__).resolve().parent.parent +ASSETS = BASE / "assets" + +# Мир рисуется в маленьком «пиксельном» разрешении и масштабируется в 2 раза, +# интерфейс — поверх, в разрешении экрана (так текст остаётся чётким). +WIDTH, HEIGHT = 480, 270 +SCALE = 2 +SCREEN_W, SCREEN_H = WIDTH * SCALE, HEIGHT * SCALE +FPS = 60 +TILE = 16 +TITLE = "Pixel Gamble" +VERSION = "2.0.0" + +# Цвета интерфейса +WHITE = (255, 255, 255) +BLACK = (20, 16, 24) +GOLD = (255, 214, 92) +RED = (230, 72, 72) +GREEN = (120, 220, 110) +BLUE = (110, 180, 255) +GREY = (150, 150, 160) +PANEL = (44, 32, 40) +PANEL_LIGHT = (92, 64, 70) +PANEL_EDGE = (16, 10, 16) + +# Игрок. Здоровье считается в четвертях сердечка, как в классических Zelda. +PLAYER_HEARTS = 3 +PLAYER_MAX_HEARTS = 8 +PLAYER_SPEED = 92 +PLAYER_INVULNERABLE = 0.9 + +# Опыт до следующего уровня: XP_BASE + XP_STEP * (уровень - 1) +XP_BASE = 12 +XP_STEP = 8 + +WAVES_TOTAL = 10 +WAVE_BREAK = 2.5 # пауза перед выбором улучшения +SPAWN_SAFE_RADIUS = 110 # враги не появляются вплотную к игроку +MAX_ALIVE = 10 # одновременно живых врагов (кроме босса) +HEART_DROP_CHANCE = 0.07 diff --git a/game/pixelgamble/ui.py b/game/pixelgamble/ui.py new file mode 100644 index 0000000..0c139b7 --- /dev/null +++ b/game/pixelgamble/ui.py @@ -0,0 +1,170 @@ +"""Элементы интерфейса в экранных координатах (960×540): текст, панели, меню, сердечки, карточки.""" + +import pygame + +from . import assets +from .settings import BLACK, GOLD, GREY, PANEL, PANEL_EDGE, PANEL_LIGHT, SCALE, SCREEN_H, SCREEN_W, WHITE + +# размеры шрифта: Tiny5 чёткий на кратных 10 +SMALL, MEDIUM, LARGE, HUGE = 20, 30, 40, 60 + +_text_cache = {} + + +def outlined(value, size, color, outline=(20, 16, 24), face="text"): + """Текст с контуром — читается на любом фоне. Результат кэшируется.""" + key = (value, size, color, outline, face) + image = _text_cache.get(key) + if image is None: + font = assets.font(size, face) + base = font.render(value, False, color) + edge = font.render(value, False, outline) + w = max(2, size // 10) + image = pygame.Surface((base.get_width() + 2 * w, base.get_height() + 2 * w), pygame.SRCALPHA) + for dx in (0, w, 2 * w): + for dy in (0, w, 2 * w): + image.blit(edge, (dx, dy)) + image.blit(base, (w, w)) + if len(_text_cache) > 600: + _text_cache.clear() + _text_cache[key] = image + return image + + +def text(surface, value, pos, size=SMALL, color=WHITE, anchor="topleft", face="text"): + image = outlined(str(value), size, color, face=face) + rect = image.get_rect(**{anchor: pos}) + surface.blit(image, rect) + return rect + + +def big(image, factor=SCALE): + return pygame.transform.scale_by(image, factor) + + +def panel(surface, rect, color=PANEL, edge=PANEL_EDGE, light=PANEL_LIGHT): + rect = pygame.Rect(rect) + pygame.draw.rect(surface, edge, rect, border_radius=6) + pygame.draw.rect(surface, light, rect.inflate(-4, -4), border_radius=5) + pygame.draw.rect(surface, color, rect.inflate(-8, -8), border_radius=4) + + +def dim(surface, alpha=150): + shade = pygame.Surface(surface.get_size(), pygame.SRCALPHA) + shade.fill((10, 8, 14, alpha)) + surface.blit(shade, (0, 0)) + + +class Menu: + """Вертикальный список кнопок: стрелки/WS, Enter/пробел, мышь.""" + + def __init__(self, items, center, width=300, gap=40): + self.items = items # [(id, подпись)] + self.center = center + self.width, self.gap = width, gap + self.index = 0 + + def rects(self): + top = self.center[1] - (len(self.items) * self.gap) // 2 + return [pygame.Rect(self.center[0] - self.width // 2, top + i * self.gap, self.width, self.gap - 8) + for i in range(len(self.items))] + + def handle(self, event, audio): + """Возвращает id выбранной кнопки или None.""" + if event.type == pygame.KEYDOWN: + if event.key in (pygame.K_UP, pygame.K_w): + self.index = (self.index - 1) % len(self.items) + audio.play("menu_move") + elif event.key in (pygame.K_DOWN, pygame.K_s): + self.index = (self.index + 1) % len(self.items) + audio.play("menu_move") + elif event.key in (pygame.K_RETURN, pygame.K_SPACE, pygame.K_KP_ENTER): + audio.play("menu_accept") + return self.items[self.index][0] + elif event.type == pygame.MOUSEMOTION: + for i, rect in enumerate(self.rects()): + if rect.collidepoint(event.pos) and i != self.index: + self.index = i + audio.play("menu_move") + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + for i, rect in enumerate(self.rects()): + if rect.collidepoint(event.pos): + self.index = i + audio.play("menu_accept") + return self.items[i][0] + return None + + def draw(self, surface): + for i, (rect, (_, label)) in enumerate(zip(self.rects(), self.items, strict=True)): + active = i == self.index + panel(surface, rect, color=(86, 58, 52) if active else PANEL) + text(surface, label, rect.center, SMALL, GOLD if active else WHITE, "center") + if active: + text(surface, ">", (rect.left - 12, rect.centery), SMALL, GOLD, "midright") + + +_hearts = None + + +def hearts(surface, player, pos): + global _hearts + if _hearts is None: + _hearts = [big(f) for f in assets.strip("ui/hearts.png", 16)] # пусто, 1/4, 1/2, 3/4, целое + x, y = pos + for i in range(player.max_hp // 4): + quarters = max(0, min(4, player.hp - i * 4)) + surface.blit(_hearts[quarters], (x + i * 26, y)) + + +def bar(surface, rect, ratio, color, back=(40, 30, 40)): + rect = pygame.Rect(rect) + pygame.draw.rect(surface, BLACK, rect) + inner = rect.inflate(-4, -4) + pygame.draw.rect(surface, back, inner) + if ratio > 0: + pygame.draw.rect(surface, color, (inner.x, inner.y, round(inner.width * min(1.0, ratio)), inner.height)) + + +def weapon_slots(surface, player, pos): + x, y = pos + for i, weapon in enumerate(player.weapons): + rect = pygame.Rect(x + i * 44, y, 40, 52) + active = weapon == player.weapon + panel(surface, rect, color=(86, 58, 52) if active else PANEL, light=GOLD if active else PANEL_LIGHT) + icon = big(assets.image(f"items/{weapon}.png")) + surface.blit(icon, icon.get_rect(center=(rect.centerx, rect.centery - 2))) + text(surface, i + 1, (rect.right - 3, rect.bottom + 2), SMALL, GREY, "bottomright") + + +def card_rects(count=3): + w, h, gap = 232, 240, 24 + left = SCREEN_W // 2 - (count * w + (count - 1) * gap) // 2 + return [pygame.Rect(left + i * (w + gap), SCREEN_H // 2 - h // 2 + 20, w, h) for i in range(count)] + + +def cards(surface, keys, lang, selected): + for i, (key, rect) in enumerate(zip(keys, card_rects(len(keys)), strict=True)): + if i == selected: + rect = rect.move(0, -6) + active = i == selected + panel(surface, rect, color=(86, 58, 52) if active else PANEL, light=GOLD if active else PANEL_LIGHT) + icon = big(assets.image(f"icons/{key}.png"), 3) + surface.blit(icon, icon.get_rect(center=(rect.centerx, rect.top + 64))) + title, desc = lang.upgrade(key) + text(surface, title, (rect.centerx, rect.top + 118), SMALL, GOLD, "midtop") + for j, line in enumerate(wrap(desc, 20)): + text(surface, line, (rect.centerx, rect.top + 152 + j * 22), SMALL, WHITE, "midtop") + text(surface, i + 1, (rect.centerx, rect.bottom - 8), SMALL, GREY, "midbottom") + + +def wrap(value, width): + words, lines, line = value.split(), [], "" + for word in words: + if len(line) + len(word) + 1 > width and line: + lines.append(line) + line = word + else: + line = f"{line} {word}".strip() + if line: + lines.append(line) + return lines diff --git a/game/pixelgamble/upgrades.py b/game/pixelgamble/upgrades.py new file mode 100644 index 0000000..5e2fa82 --- /dev/null +++ b/game/pixelgamble/upgrades.py @@ -0,0 +1,74 @@ +"""Улучшения между волнами: три случайные карточки, игрок выбирает одну.""" + +import random + +from .settings import PLAYER_MAX_HEARTS + +# id: (максимум раз за партию, с какой волны предлагать); иконка — images/icons/.png +POOL = { + "heart": (4, 1), + "damage": (4, 1), + "speed": (3, 1), + "haste": (3, 1), + "heal": (99, 2), + "reach": (3, 1), + "lance": (1, 2), + "wand": (1, 3), + "multishot": (1, 4), + "vampire": (2, 3), + "knockback": (2, 2), +} + + +def available(player, taken, wave): + result = [] + for key, (limit, from_wave) in POOL.items(): + if taken.get(key, 0) >= limit or wave < from_wave: + continue + if key == "heal" and player.hp >= player.max_hp: + continue + if key == "heart" and player.max_hp >= PLAYER_MAX_HEARTS * 4: + continue + if key == "multishot" and "wand" not in player.weapons: + continue + result.append(key) + return result + + +def offer(player, taken, wave, rng=random): + keys = available(player, taken, wave) + # новое оружие показываем охотнее: оно меняет то, как играется партия + weights = [3 if k in ("lance", "wand") else 1 for k in keys] + picked = [] + while keys and len(picked) < 3: + k = rng.choices(keys, weights)[0] + i = keys.index(k) + keys.pop(i) + weights.pop(i) + picked.append(k) + return picked + + +def apply(key, player, taken): + taken[key] = taken.get(key, 0) + 1 + if key == "heart": + player.max_hp += 4 + player.heal(4) + elif key == "damage": + player.damage_mult *= 1.15 + elif key == "speed": + player.speed *= 1.12 + elif key == "haste": + player.cooldown_mult *= 0.88 + elif key == "heal": + player.heal(player.max_hp) + elif key == "reach": + player.reach_mult *= 1.2 + elif key in ("lance", "wand"): + player.unlock(key) + elif key == "multishot": + player.multishot = True + elif key == "vampire": + player.vampire += 0.12 + elif key == "knockback": + player.knock_mult *= 1.4 diff --git a/game/pixelgamble/waves.py b/game/pixelgamble/waves.py new file mode 100644 index 0000000..f523e1e --- /dev/null +++ b/game/pixelgamble/waves.py @@ -0,0 +1,53 @@ +"""Состав волн и очередь появления врагов.""" + +import random + +from .settings import MAX_ALIVE, SPAWN_SAFE_RADIUS + +WAVES = [ + {"slime": 8}, + {"slime": 10, "bat": 5}, + {"slime": 8, "ninja": 6}, + {"bat": 8, "ninja": 7, "skeleton": 3}, + {"slime": 10, "ninja": 6, "skeleton": 5, "cyclop": 1}, + {"bat": 10, "ninja": 9, "skeleton": 6}, + {"slime": 14, "bat": 8, "skeleton": 5, "cyclop": 2}, + {"ninja": 12, "skeleton": 8, "bat": 8, "cyclop": 1}, + {"slime": 10, "bat": 8, "ninja": 12, "skeleton": 8, "cyclop": 3}, + {"boss": 1}, +] + + +def hp_multiplier(wave): + return 1 + 0.08 * (wave - 1) + + +class Spawner: + def __init__(self, wave, rng=random): + self.wave = wave + plan = [] + for kind, count in WAVES[wave - 1].items(): + plan += [kind] * count + rng.shuffle(plan) + # тяжёлые враги — во второй половине волны, чтобы начало было разогревом + plan.sort(key=lambda k: k == "cyclop") + self.queue = plan + self.total = len(plan) + self.timer = 0.6 + self.rng = rng + + def done(self): + return not self.queue + + def update(self, dt, alive, player_pos, spawn_points): + """Возвращает список (вид, точка) для появления в этом кадре.""" + self.timer -= dt + out = [] + if self.queue and self.timer <= 0 and alive < MAX_ALIVE: + self.timer = max(0.35, 1.3 - 0.08 * self.wave) * self.rng.uniform(0.7, 1.3) + far = [p for p in spawn_points if p.distance_to(player_pos) > SPAWN_SAFE_RADIUS] or spawn_points + group = 1 if self.queue[0] in ("cyclop", "boss") else min(len(self.queue), self.rng.choice((1, 1, 2, 3))) + point = self.rng.choice(far) + for _ in range(group): + out.append((self.queue.pop(0), point + (self.rng.uniform(-10, 10), self.rng.uniform(-10, 10)))) + return out diff --git a/game/pixelgamble/world.py b/game/pixelgamble/world.py new file mode 100644 index 0000000..7821350 --- /dev/null +++ b/game/pixelgamble/world.py @@ -0,0 +1,111 @@ +"""Арена: пол, деревья, камни, точки появления врагов.""" + +import random + +import pygame + +from . import assets +from .settings import TILE + +COLS, ROWS = 52, 34 +SEED = 2022 # раскладка камней и кустов одна и та же в каждой партии + +GROVES = [(9, 7), (11, 8), (40, 6), (42, 7), (8, 25), (10, 26), (41, 24), (39, 25), (25, 5), (26, 27)] +STUMPS = [(17, 12), (34, 12), (17, 22), (34, 22), (25, 16), (21, 9), (30, 25)] + + +class Prop(pygame.sprite.Sprite): + """Дерево или камень: рисуется с сортировкой по глубине и мешает проходу.""" + + def __init__(self, image, topleft, hitbox, breakable=False): + super().__init__() + self.image = image + self.rect = image.get_rect(topleft=topleft) + self.hitbox = hitbox + self.breakable = breakable + self.sort_y = self.hitbox.bottom + + +class World: + def __init__(self): + self.width, self.height = COLS * TILE, ROWS * TILE + self.rect = pygame.Rect(0, 0, self.width, self.height) + self.props = pygame.sprite.Group() + edge = 2 * TILE # толщина кольца деревьев + self.walls = [pygame.Rect(0, 0, self.width, edge), pygame.Rect(0, self.height - edge, self.width, edge), + pygame.Rect(0, 0, edge, self.height), pygame.Rect(self.width - edge, 0, edge, self.height)] + self.inner = pygame.Rect(2 * TILE, 2 * TILE, self.width - 4 * TILE, self.height - 4 * TILE) + self.center = pygame.Vector2(self.width / 2, self.height / 2 + TILE) + self.floor = pygame.Surface((self.width, self.height)).convert() + self._build() + + def _build(self): + rnd = random.Random(SEED) + grass = assets.tile("floor.png", 0, 12) + decor = [assets.tile("floor.png", c, r) for c, r in ((1, 12), (2, 12), (3, 12), (4, 12), (3, 11))] + bushes = [assets.tile("nature.png", c, 10) for c in range(4)] + trees = [assets.tile("nature.png", 0, 0, 2, 2), assets.tile("nature.png", 16, 0, 2, 2), + assets.tile("nature.png", 18, 0, 2, 2)] + rock = assets.tile("nature.png", 7, 12) + stump = assets.tile("nature.png", 15, 9) + + for y in range(ROWS): + for x in range(COLS): + self.floor.blit(grass, (x * TILE, y * TILE)) + if rnd.random() < 0.13: + self.floor.blit(rnd.choice(decor), (x * TILE, y * TILE)) + + taken = set() + + def tree(cx, cy): + for dx in (0, 1): + for dy in (0, 1): + taken.add((cx + dx, cy + dy)) + px, py = cx * TILE, cy * TILE + self.props.add(Prop(rnd.choice(trees), (px, py), pygame.Rect(px + 8, py + 20, 16, 10))) + + # Кольцо деревьев по краю: стена — отдельные прямоугольники self.walls. + for x in range(0, COLS, 2): + tree(x, 0) + tree(x, ROWS - 2) + for y in range(2, ROWS - 2, 2): + tree(0, y) + tree(COLS - 2, y) + for cx, cy in GROVES: + tree(cx, cy) + + def free(x, y): + near_center = abs(x - COLS / 2) < 6 and abs(y - ROWS / 2) < 5 + return (x, y) not in taken and not near_center and 3 <= x < COLS - 3 and 3 <= y < ROWS - 3 + + for x, y in STUMPS: + taken.add((x, y)) + px, py = x * TILE, y * TILE + self.props.add(Prop(stump, (px, py), pygame.Rect(px + 2, py + 5, 12, 10))) + + placed = 0 + while placed < 26: + x, y = rnd.randrange(3, COLS - 3), rnd.randrange(3, ROWS - 3) + if free(x, y): + taken.add((x, y)) + px, py = x * TILE, y * TILE + self.props.add(Prop(rock, (px, py), pygame.Rect(px + 2, py + 4, 12, 11), breakable=True)) + placed += 1 + + for _ in range(40): + x, y = rnd.randrange(2, COLS - 2), rnd.randrange(2, ROWS - 2) + if (x, y) not in taken: + self.floor.blit(rnd.choice(bushes), (x * TILE, y * TILE)) + + self.spawns = [] + for x in range(4, COLS - 4, 5): + self.spawns += [pygame.Vector2(x * TILE + 8, 3 * TILE), pygame.Vector2(x * TILE + 8, (ROWS - 3) * TILE)] + for y in range(6, ROWS - 5, 5): + self.spawns += [pygame.Vector2(3 * TILE, y * TILE + 8), pygame.Vector2((COLS - 3) * TILE, y * TILE + 8)] + self.spawns = [p for p in self.spawns if not self.blocked(pygame.Rect(p.x - 8, p.y - 6, 16, 12))] + + def obstacles(self): + return self.walls + [p.hitbox for p in self.props] + + def blocked(self, rect): + return rect.collidelist(self.walls) != -1 or any(rect.colliderect(p.hitbox) for p in self.props) diff --git a/icon.ico b/icon.ico deleted file mode 100644 index 7f108f0..0000000 Binary files a/icon.ico and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b6428e8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..2ae367a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest +ruff +pillow diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..18caa77 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pygame>=2.5 diff --git a/requrements.txt b/requrements.txt deleted file mode 100644 index 82a4b4d..0000000 --- a/requrements.txt +++ /dev/null @@ -1,5 +0,0 @@ -pygame -random2 -math -csv -os \ No newline at end of file diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100644 index 0000000..2e22c71 --- /dev/null +++ b/tests/test_game.py @@ -0,0 +1,171 @@ +"""Проверки без окна: SDL_VIDEODRIVER=dummy, автоигрок вместо человека.""" + +import os +import random +import sys +from pathlib import Path + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") +GAME = Path(__file__).resolve().parent.parent / "game" +sys.path.insert(0, str(GAME)) + +import pytest # noqa: E402 +from pixelgamble import save, upgrades # noqa: E402 +from pixelgamble.app import App, PlayScene, ResultsScene # noqa: E402 +from pixelgamble.bot import Bot # noqa: E402 +from pixelgamble.game import Game # noqa: E402 +from pixelgamble.player import Player # noqa: E402 +from pixelgamble.waves import WAVES, Spawner # noqa: E402 + + +@pytest.fixture +def app(tmp_path, monkeypatch): + monkeypatch.setenv("PIXEL_GAMBLE_SAVE", str(tmp_path / "save.json")) + return App(Bot(seed=3)) + + +def run(app, frames): + for _ in range(frames): + app.frame(1 / 60) + + +def test_bot_clears_first_waves(app): + run(app, 60 * 60) # минута игрового времени + game = app.game + assert game.kills >= 10 + assert game.wave >= 2 + assert game.log and game.log[0][0] == 1 + + +def test_menus_render(app): + app.bot = None + for scene in ("controls", "settings"): + app.to_menu() + app.scene.menu.index = [i for i, _ in app.scene.menu.items].index(scene) + app.scene.handle(_key("return")) + run(app, 3) + app.lang.toggle() + run(app, 3) + + +def test_boss_goes_through_phases(app): + app.new_game() + game = app.game + game.wave = 10 + game.start_wave() + game.player.invulnerable = 10 ** 6 # проверяем босса, а не умение бота уворачиваться + run(app, 60 * 6) + assert game.boss is not None + boss = game.boss + for phase in (2, 3): + boss.state = "move" + boss.hurt(boss.max_hp * 0.4, game.player.pos, 0) + assert boss.phase == phase + run(app, 60 * 3) + boss.state = "move" + boss.hurt(boss.max_hp * 2, game.player.pos, 0) + game.on_enemy_hit(boss) + run(app, 2) + assert game.boss is None and game.result == "victory" + run(app, 60 * 6) + assert isinstance(app.scene, ResultsScene) + assert app.data["best"] == game.score > 2000 + + +def test_death_shows_results(app): + app.new_game() + game = app.game + game.player.invulnerable = 0 + game.damage_player(game.player.max_hp, game.player.pos) + assert game.result == "dead" + run(app, 60 * 6) + assert isinstance(app.scene, ResultsScene) + + +def test_upgrades_apply(app): + app.new_game() + player, taken = app.game.player, {} + for key in upgrades.POOL: + upgrades.apply(key, player, taken) + assert player.weapons == ["sword", "lance", "wand"] + assert player.max_hp == 16 and player.hp == player.max_hp + assert player.multishot and player.damage_mult > 1 + offer = upgrades.offer(player, {}, 1, random.Random(1)) + assert len(offer) == 3 and len(set(offer)) == 3 + + +def test_every_wave_spawns_its_plan(): + for number, plan in enumerate(WAVES, start=1): + spawner = Spawner(number, random.Random(number)) + assert spawner.total == sum(plan.values()) + spawned = [] + points = [pygame_vector(500, 500), pygame_vector(40, 40)] + while not spawner.done(): + spawned += spawner.update(1.0, 0, pygame_vector(0, 0), points) + assert sorted(k for k, _ in spawned) == sorted(k for k, c in plan.items() for _ in range(c)) + + +def test_save_roundtrip(tmp_path, monkeypatch): + monkeypatch.setenv("PIXEL_GAMBLE_SAVE", str(tmp_path / "s.json")) + data = save.load() + assert data["best"] == 0 + data.update(best=1234, lang="en") + save.store(data) + assert save.load()["best"] == 1234 and save.load()["lang"] == "en" + (tmp_path / "s.json").write_text("{broken", encoding="utf-8") + assert save.load()["best"] == 0 + + +def test_player_dies_once(app): + player = Player((100, 100)) + assert player.hurt(5, (90, 100)) + assert not player.hurt(5, (90, 100)) # неуязвимость после удара + player.invulnerable = 0 + player.hurt(100, (90, 100)) + assert player.dead and player.hp == 0 + + +def test_rock_breaks(app): + app.new_game() + game = app.game + rock = next(p for p in game.world.props if p.breakable) + before = len(game.obstacles) + game.break_rock(rock) + assert len(game.obstacles) == before - 1 + + +def pygame_vector(x, y): + import pygame + return pygame.Vector2(x, y) + + +def _key(name): + import pygame + return pygame.event.Event(pygame.KEYDOWN, key=pygame.key.key_code(name), mod=0, unicode="") + + +def test_play_scene_is_active(app): + run(app, 2) + assert isinstance(app.scene, PlayScene) and isinstance(app.game, Game) + + +def test_entity_never_tunnels_through_walls(app): + app.new_game() + game = app.game + player = game.player + wall = game.walls[2] # левая стена + player.pos.update(wall.right - 2, 300) # стоим чуть внутри стены + player.hitbox.center = (round(player.pos.x), round(player.pos.y)) + for step in ((3, 0), (-3, 0), (0, 3), (3, 3)): + player.move(pygame_vector(*step), game.obstacles) + assert player.hitbox.left >= wall.right - 1 + + +def test_slow_movement_accumulates(app): + app.new_game() + player = app.game.player + start = player.pos.x + for _ in range(60): + player.move(pygame_vector(0.3, 0), []) + assert player.pos.x - start > 15 diff --git a/tools/import_assets.py b/tools/import_assets.py new file mode 100644 index 0000000..52d129f --- /dev/null +++ b/tools/import_assets.py @@ -0,0 +1,127 @@ +"""Копирует нужные файлы из набора Ninja Adventure (CC0) в game/assets. + +Набор: https://pixel-boy.itch.io/ninja-adventure-asset-pack — архив +«Ninja Adventure - Asset Pack.zip». Звуки перекодируются в OGG (нужен ffmpeg), +потому что браузерная сборка (pygbag) надёжно играет только OGG. + + python tools/import_assets.py "путь/к/Ninja Adventure - Asset Pack" +""" + +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEST = ROOT / "game" / "assets" + +IMAGES = { + "chars/player.png": "Actor/Character/NinjaGreen/SpriteSheet.png", + "chars/player_face.png": "Actor/Character/NinjaGreen/Faceset.png", + "chars/ninja.png": "Actor/Character/NinjaGray/SpriteSheet.png", + "chars/skeleton.png": "Actor/Character/Skeleton/SpriteSheet.png", + "chars/slime.png": "Actor/Monster/Slime/Slime.png", + "chars/bat.png": "Actor/Monster/BlueBat/SpriteSheet.png", + "chars/cyclop_walk.png": "Actor/Boss/DemonCyclop/Walk.png", + "chars/cyclop_hit.png": "Actor/Boss/DemonCyclop/Hit.png", + "chars/boss_walk.png": "Actor/Boss/GiantRedSamurai/Walk.png", + "chars/boss_idle.png": "Actor/Boss/GiantRedSamurai/Idle.png", + "chars/boss_attack_l.png": "Actor/Boss/GiantRedSamurai/AttackLeft.png", + "chars/boss_attack_r.png": "Actor/Boss/GiantRedSamurai/AttackRight.png", + "chars/boss_charge_l.png": "Actor/Boss/GiantRedSamurai/ChargeLeft.png", + "chars/boss_charge_r.png": "Actor/Boss/GiantRedSamurai/ChargeRight.png", + "chars/boss_hit.png": "Actor/Boss/GiantRedSamurai/Hit.png", + "chars/boss_face.png": "Actor/Boss/GiantRedSamurai/Faceset.png", + "chars/shadow.png": "Actor/Character/Shadow.png", + "tiles/floor.png": "Backgrounds/Tilesets/TilesetFloor.png", + "tiles/nature.png": "Backgrounds/Tilesets/TilesetNature.png", + "fx/arc.png": "FX/Slash/SpriteSheetArc.png", + "fx/smoke.png": "FX/Smoke/Smoke/SpriteSheet.png", + "fx/energy_ball.png": "FX/Projectile/EnergyBall.png", + "fx/shuriken.png": "FX/Projectile/Shuriken.png", + "fx/rock.png": "FX/Particle/Rock.png", + "fx/leaf.png": "FX/Particle/Leaf.png", + "items/heart.png": "Items/Potion/Heart.png", + "items/sword.png": "Items/Weapons/Sword/Sprite.png", + "items/lance.png": "Items/Weapons/Lance/Sprite.png", + "items/wand.png": "Items/Weapons/MagicWand/Sprite.png", + "items/sword_hand.png": "Items/Weapons/Sword/SpriteInHand.png", + "items/lance_hand.png": "Items/Weapons/Lance/SpriteInHand.png", + "items/wand_hand.png": "Items/Weapons/MagicWand/SpriteInHand.png", + "ui/hearts.png": "Ui/Receptacle/Heart.png", + "icons/heart.png": "Ui/Skill Icon/Spell/DefenseUpgrade.png", + "icons/damage.png": "Ui/Skill Icon/Spell/AttackUpgrade.png", + "icons/speed.png": "Ui/Skill Icon/Items & Weapon/Boot.png", + "icons/haste.png": "Ui/Skill Icon/Spell/Cut.png", + "icons/heal.png": "Ui/Skill Icon/Spell/Heal.png", + "icons/reach.png": "Ui/Skill Icon/Items & Weapon/Hook.png", + "icons/lance.png": "Ui/Skill Icon/Items & Weapon/Arrow.png", + "icons/wand.png": "Ui/Skill Icon/Spell/MagicWeapon.png", + "icons/multishot.png": "Ui/Skill Icon/Items & Weapon/Shuriken.png", + "icons/vampire.png": "Ui/Skill Icon/Spell/Necromancy.png", + "icons/knockback.png": "Ui/Skill Icon/Job & Action/Punch.png", +} + +SOUNDS = { + "sword": "Audio/Sounds/Whoosh & Slash/Slash.wav", + "lance": "Audio/Sounds/Whoosh & Slash/Whoosh.wav", + "wand": "Audio/Sounds/Magic & Skill/Magic1.wav", + "enemy_hit": "Audio/Sounds/Hit & Impact/Hit1.wav", + "enemy_die": "Audio/Sounds/Hit & Impact/Impact3.wav", + "player_hurt": "Audio/Sounds/Hit & Impact/Hit5.wav", + "rock_break": "Audio/Sounds/Hit & Impact/Impact2.wav", + "enemy_shoot": "Audio/Sounds/Whoosh & Slash/Launch.wav", + "boss_slash": "Audio/Sounds/Whoosh & Slash/Slash5.wav", + "boss_stomp": "Audio/Sounds/Hit & Impact/Impact5.wav", + "boss_appear": "Audio/Sounds/Alert/Alert5.wav", + "heal": "Audio/Sounds/Magic & Skill/Heal.wav", + "level_up": "Audio/Jingles/LevelUp1.wav", + "power_up": "Audio/Sounds/Bonus/PowerUp1.wav", + "wave_start": "Audio/Sounds/Alert/Alert.wav", + "wave_clear": "Audio/Jingles/Success1.wav", + "game_over": "Audio/Jingles/GameOver.wav", + "victory": "Audio/Jingles/Success3.wav", + "menu_move": "Audio/Sounds/Menu/Move1.wav", + "menu_accept": "Audio/Sounds/Menu/Accept6.wav", + "menu_back": "Audio/Sounds/Menu/Cancel2.wav", + "switch": "Audio/Sounds/Menu/Menu8.wav", +} + +MUSIC = { + "menu": "Audio/Musics/1 - Adventure Begin.ogg", + "battle": "Audio/Musics/17 - Fight.ogg", + "boss": "Audio/Musics/28 - Tension.ogg", +} + + +def main(): + if len(sys.argv) != 2: + sys.exit(__doc__) + pack = Path(sys.argv[1]) + if not (pack / "Actor").is_dir(): + sys.exit(f"не похоже на папку набора: {pack}") + + for dest, src in IMAGES.items(): + target = DEST / "images" / dest + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(pack / src, target) + + sounds = DEST / "sounds" + sounds.mkdir(parents=True, exist_ok=True) + for name, src in SOUNDS.items(): + subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(pack / src), + "-c:a", "libvorbis", "-q:a", "4", str(sounds / f"{name}.ogg")], check=True) + + music = DEST / "music" + music.mkdir(parents=True, exist_ok=True) + for name, src in MUSIC.items(): + shutil.copyfile(pack / src, music / f"{name}.ogg") + + (DEST / "fonts").mkdir(parents=True, exist_ok=True) + shutil.copyfile(pack / "Ui/Font/NormalFont.ttf", DEST / "fonts" / "NinjaAdventure.ttf") # латиница, для заголовка + shutil.copyfile(pack / "LICENSE.txt", DEST / "LICENSE-ninja-adventure.txt") + print(f"готово: {len(IMAGES)} картинок, {len(SOUNDS)} звуков, {len(MUSIC)} мелодий") + + +if __name__ == "__main__": + main() diff --git a/web/template.tmpl b/web/template.tmpl new file mode 100644 index 0000000..2e2eb05 --- /dev/null +++ b/web/template.tmpl @@ -0,0 +1,445 @@ + + + {{cookiecutter.title}} + + + + + + + + + + + + + + + + + + + + +
+ +
Downloading...
+
+ +
+
+ + + + + + + + +
Loading, please wait ...
+ +
+ +
+ + + + + +
+ + + + +
+
+
+ + + + +