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**
+
+[](https://github.com/EDeev/pixel_gamble/actions/workflows/ci.yml)
+[](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)
+
+
+
+**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 |
+|---|---|
+|  |  |
+
+## 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
-[](https://python.org)
-[](https://pygame.org)
-[](LICENSE)
+**Русский** · [English](README.en.md)
-## Описание проекта
+[](https://github.com/EDeev/pixel_gamble/actions/workflows/ci.yml)
+[](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-основанная система генерации игрового мира
-- **Аудиосистема**: Интегрированное воспроизведение звуковых эффектов
+
-## Технологический стек
+**Стек:** 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 улучшений:** между волнами нужно выбрать одно из трёх — сердца, урон, скорость, новое оружие,
+ тройной заряд, лечение за победы и другие.
+- **Опыт и уровни.** Сердечки выпадают из врагов и разбитых камней и лечат.
+- **Меню, пауза, настройки** (громкость, язык, полный экран), итоги партии и рекорд.
+- **Два языка:** русский и английский.
-### Процесс установки
+| Выбор улучшения | Босс |
+|---|---|
+|  |  |
+
+## Быстрый старт
+
+Проще всего — [играть в браузере](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}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading, please wait ...
+
+
+
+
+
+
+
+ AIO ⏏︎
+ AIO ■
+ AIO ▶
+ Music ■
+
+
+
+
+
(battery level unknown)
+
(charging state unknown)
+
+
+
+
+
+
+
+
+
+
+ Select files
+
+
+
+
+
+
+
+