Перейти к основному содержимому

Java — Java Survivors

Разработчику Средний уровень

О практикуме

Vampire Survivors и его клоны (survivor-like, horde survival) — арена сверху, волны врагов, автоатаки без прицеливания, опыт с поля и пауза на выбор улучшений. Полная реализация с десятками оружий, персонажами и сохранениями — в репозитории Java Survivors (локально: F:\Projects\JVM\Java\Java Survivors). Стек там — Java 17, Swing, Java2D, без сторонних игровых движков.

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

Для кого материал
Нужны базовый Java (классы, enum, коллекции, List, Iterator) и понимание ООП. Каждый этап — запускаемый код: после шага проект компилируется и показывает новую механику. Сборка — Maven; IDE — IntelliJ IDEA, VS Code с Extension Pack for Java или Cursor.

Управление в финальной версии практикума

КлавишаДействие
W A S D или стрелкиДвижение
ПробелРывок (dash)
1 2 3Выбор улучшения на экране level-up
EnterСтарт с меню
RПерезапуск после поражения
EscВыход

Маршрут чтения

  1. Архитектура — как устроен проект до первой строки кода.
  2. Зависимости и структура — JDK, Maven, пакеты.
  3. Этап 0 — минимальный запуск — окно и игровой поток.
  4. Этапы 1–18 — по одной (или паре) механик за шаг.
  5. Итоговая самопроверка — чек-лист и связь с полным репозиторием.

Что должно получиться к этапу 18

МеханикаОписание
ЦиклОтдельный поток + dt, отрисовка в paintComponent
ИгрокКруг, HP, реген, броня, движение по WASD
ВрагиСпавн с краёв экрана, преследование, рост сложности
ОружиеАвто «магический болт», тройной залп, кольцо импульса
ОпытСферы XP, магнит, уровни, пауза на 3 улучшения
УронСнаряды, контакт, рывок с неуязвимостью
HUDHP, полоска XP, время, счёт, волна
СостоянияМеню, игра, game over

Готовый проект
Полная игра с персонажами, магазином и сохранениями — в Java Survivors. Практикум ниже объясняет, как такой проект собирается с нуля; после этапа 18 можно построчно сравнить свой com.survivors с com.diabloid в репозитории.

Сводка этапов

ЭтапТемаНовое в запуске
0ЦиклОкно, поток, dt, repaint
1FSMGameState, меню, Esc
2ИгрокWASD, круг на арене
3ВрагиСпавн с краёв, преследование
4СнарядыАвто «магический болт»
5УронПопадания, счёт, смерть врага
6XPОрбы, магнит, уровень
7ПеркиПауза, 3 выбора, 1 2 3
8HUDHP, XP, время, волна
9ОружияWeaponType, залп, кольцо
10ВыживаниеКонтакт, броня, реген
11РывокDash, i-frames
12JuiceЦифры урона, частицы
13Мета-ранGame over, resetRun
14АрхетипыSPEEDER, TANK, SHOOTER
15БоссСложность по времени
16МодулиWeaponManager, файлы
17АртPNG, MediaTracker
18РеференсТаблица фич полной игры

Чем survivor-like отличается от «обычного» шутера

АспектКлассический twin-stickSurvivor-like (VS, Java Survivors)
ПрицеливаниеИгрок целитсяОружие само выбирает цель / паттерн
Прогресс в ранеПодбор на картеLevel-up с выбором из 3 карт
ДавлениеВолны по скриптуНепрерывный спавн + рост difficulty по worldTime
ПоражениеЖизни / чекпоинтыОдин HP-бар, короткий рестарт
Сессия5–15 мин уровень10–30 мин один ран до смерти

Архитектура

Прежде чем писать код, зафиксируем что из чего состоит и как данные текут по кадру. Референсная архитектура совпадает с Java Survivors: центральная панель владеет списками сущностей и вызывает обновление/отрисовку.

Жанр и петля геймплея

Survivor-like строится на положительной обратной связи:

  1. Враги приходят волнами → игрок убивает автооружием.
  2. Выпадает опыт → уровень → выбор перка (сильнее).
  3. Сложность растёт по времени выживания → нужны новые перки.
  4. Смерть → короткий цикл «ещё раз».

Игровой цикл (Swing)

В desktop-играх на Swing нельзя долго блокировать EDT (Event Dispatch Thread). В Java Survivors логика крутится в отдельном потоке, а перерисовка — через repaint()paintComponent:

На каждом шаге update (если не меню и не пауза прокачки):

  1. Увеличить worldTime, таймеры спавна и волн.
  2. Движение игрока, реген HP.
  3. Спавн врагов, ИИ преследования.
  4. Кулдауны оружия → новые снаряды.
  5. Движение снарядов, столкновения, смерть врагов → XP.
  6. Магнит XP, проверка level-up.
  7. Контактный урон, проверка game over.

Слои приложения

СлойОтветственностьПримеры в полном проекте
ВводКлавиши, мышь на экране улучшенийKeyHandler, MouseHandler
МирРазмер арены, время, волныWIDTH, HEIGHT, worldTime, wave
АкторыИгрок, враги, снаряды, XPPlayer, Enemy, Projectile, XpOrb
ПравилаУрон, опыт, перки, оружиеdamageEnemy, addXp, rollUpgradeChoices
ПредставлениеJava2D, HUD, оверлеиpaintComponent, Graphics2D
МетаСохранения, персонажи, магазинSaveSystem, CharacterDef (этап 18+)

Слой правил не рисует напрямую — он меняет поля объектов; paintComponent только читает состояние.

Координаты и коллизии

Арена — пиксели, начало (0, 0) в левом верхнем углу. Игрок и враги — круги (x, y, radius). Столкновение двух кругов:

static boolean circlesHit(double x1, double y1, double r1,
double x2, double y2, double r2) {
double dx = x1 - x2;
double dy = y1 - y2;
double sum = r1 + r2;
return dx * dx + dy * dy <= sum * sum;
}

Для производительности в горячих циклах используют distanceSq без Math.sqrt.

Шаг времени dt и cap

dt — секунды с прошлого кадра. Без ограничения свёрнутое окно даёт скачок dt на секунды, и игрок «телепортируется» сквозь врагов:

dt = Math.min(dt, 0.033); // не больше ~2 кадров при 60 FPS

Все перемещения и кулдауны записываются в форме x += speed * dt, cooldown -= dt — так игра ведёт себя одинаково на 60 и 120 Гц монитора.

Порядок update на зрелом этапе

Фиксированный порядок снижает баги «снаряд попал до спавна врага»:

Карта репозитория Java Survivors

ФайлРоль
JavaSurvivors.javaТочка входа → DiabloidGame
DiabloidGame.javaJFrame, вложенный GamePanel, цикл, отрисовка, 90% логики рана
Player.javaСтаты, кулдауны всех оружий, reset()
Enemy.java / EnemyKind.javaВраг и архетип
Projectile.javaСнаряд игрока/врага, pierce, статусы
WeaponType.javaВсе виды оружия (болт, молния, стихии…)
WeaponManager.javaДелегат updateWeapons
EnemySpawner.javaДелегат spawnEnemies
SaveSystem.java / SaveData.javaМета-прогресс между ранами
ParticleSystem.javaЧастицы и следы

Учебный проект намеренно не копирует 1700 строк сразу — вы повторяете те же списки + enum + пауза на upgrade, расширяя по этапам.

Конечный автомат (упрощённый)

В полном Java Survivors добавлены UpgradeState.PAUSED_FOR_SHOP, выбор персонажа и сохранение — см. этап 18.

Списки сущностей на кадре

Целевая структура файлов

К этапу 6 достаточно одного SurvivorsGame.java с вложенным GamePanel. Дальше выносим классы — как в репозитории com.diabloid:

java-survivors-lab/
├── pom.xml
└── src/main/java/com/survivors/
├── JavaSurvivors.java # main
├── SurvivorsGame.java # JFrame + GamePanel (этапы 0–10)
├── GameState.java
├── UpgradeState.java
├── WeaponType.java
├── Player.java # этап 14+
├── Enemy.java
├── EnemyKind.java
├── Projectile.java
├── XpOrb.java
└── DamageNumber.java

Пакет в учебнике — com.survivors; в полном проектеcom.diabloid (историческое имя «Diabloid»).

Почему Swing, а не LibGDX
Референсный репозиторий использует встроенный Java2D — нулевые внешние зависимости, один JAR после mvn package. LibGDX уместен для кроссплатформы и GPU-спрайтов; для понимания survivor-like логики Swing достаточен.


Зависимости и подготовка окружения

Требования

  • JDK 17+ (как в pom.xml репозитория).
  • Maven 3.8+ (или встроенный Maven в IDE).
  • Опционально — Git для клонирования референса.

Создание проекта

mkdir java-survivors-lab && cd java-survivors-lab

pom.xml (совпадает по духу с Java Survivors):

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.survivors</groupId>
<artifactId>java-survivors-lab</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.12.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.survivors.JavaSurvivors</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

Папки:

src/main/java/com/survivors/

Сборка и запуск

mvn -q compile exec:java -Dexec.mainClass="com.survivors.JavaSurvivors"

Или после упаковки:

mvn -q package
java -jar target/java-survivors-lab-1.0-SNAPSHOT.jar

IntelliJ IDEA / Cursor

  1. File → Open — папка с pom.xml.
  2. Дождитесь индексации Maven (импорт JDK 17).
  3. ПКМ по JavaSurvivors.javaRun 'JavaSurvivors.main()'.
  4. Working directory — корень проекта (для assets/ на этапе 17).

Потоки и EDT
Если поставить весь while (running) в main без отдельного потока, Swing «замрёт». В практикуме логика — в Thread с именем game-loop, отрисовка — только через repaint() на EDT.

Палитра (единые цвета)

Вынесите цвета в GameColors.java, чтобы HUD и сущности не расходились:

package com.survivors;

import java.awt.Color;

public final class GameColors {
public static final Color BG = new Color(17, 17, 20);
public static final Color PLAYER = new Color(90, 160, 255);
public static final Color ENEMY = new Color(210, 70, 70);
public static final Color ENEMY_FAST = new Color(255, 140, 60);
public static final Color ENEMY_TANK = new Color(140, 40, 50);
public static final Color PROJECTILE = new Color(255, 220, 90);
public static final Color XP = new Color(80, 220, 180);
public static final Color HUD_TEXT = new Color(230, 230, 240);

private GameColors() {}
}

Структура assets/ (этап 17)

java-survivors-lab/
├── assets/
│ ├── player.png
│ ├── enemy_normal.png
│ ├── enemy_tank.png
│ └── background.jpg
└── src/main/java/...

Запускайте JAR из корня, где лежит assets/, иначе Toolkit.getImage("assets/...") вернёт пустую картинку.

Кодировка исходников
Сохраняйте .java в UTF-8. Русские строки в HUD и улучшениях иначе превратятся в «кракозябры» при сборке на Windows с неверной кодировкой по умолчанию.


Этап 0 — минимальный запускаемый код

ЦельJFrame, тёмная панель, поток игрового цикла, стабильный dt, выход по закрытию окна.

src/main/java/com/survivors/JavaSurvivors.java:

package com.survivors;

import javax.swing.SwingUtilities;

public final class JavaSurvivors {
public static void main(String[] args) {
SwingUtilities.invokeLater(SurvivorsGame::new);
}
}

src/main/java/com/survivors/SurvivorsGame.java:

package com.survivors;

import javax.swing.JFrame;
import javax.swing.JPanel;
import java.awt.*;

public final class SurvivorsGame {
private static final int WIDTH = 960;
private static final int HEIGHT = 540;

public SurvivorsGame() {
JFrame frame = new JFrame("Java Survivors — этап 0");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setResizable(false);
frame.setContentPane(new GamePanel());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}

static final class GamePanel extends JPanel implements Runnable {
private volatile boolean running = true;
private final Thread loop = new Thread(this, "game-loop");

GamePanel() {
setPreferredSize(new Dimension(WIDTH, HEIGHT));
setBackground(new Color(17, 17, 20));
setFocusable(true);
loop.start();
}

@Override
public void run() {
long prev = System.nanoTime();
while (running) {
long now = System.nanoTime();
double dt = (now - prev) / 1_000_000_000.0;
prev = now;
dt = Math.min(dt, 0.033);

update(dt);
repaint();

try {
Thread.sleep(8);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
running = false;
}
}
}

private void update(double dt) {
// пока пусто
}

@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
g.setColor(new Color(28, 28, 36));
g.fillRect(0, 0, getWidth(), getHeight());
g.setColor(new Color(200, 200, 210));
g.drawString("Этап 0 — игровой цикл работает", 24, 32);
}
}
}

Самопроверка этапа 0

  • mvn compile без ошибок.
  • Окно 960×540, тёмный фон, подпись на экране.
  • Нет зависаний при перетаскивании окна (логика не в EDT).

Отладка FPS
Временно рисуйте 1.div(dt) в углу экрана — если значение скачет ниже 30 при пустой сцене, ищите тяжёлую работу в paintComponent (там должна быть только отрисовка).


Этап 1 — состояния игры и ввод

Цельenum GameState, меню с Enter, выход Esc, заготовка PLAYING.

Добавьте GameState.java:

package com.survivors;

public enum GameState {
MENU, PLAYING, GAME_OVER
}

В GamePanel — поля и полный обработчик клавиш (на этапе 2 понадобятся keyReleased):

import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;

GameState gameState = GameState.MENU;

private void setMoveKey(int code, boolean down) {
switch (code) {
case KeyEvent.VK_W, KeyEvent.VK_UP -> up = down;
case KeyEvent.VK_S, KeyEvent.VK_DOWN -> down = down;
case KeyEvent.VK_A, KeyEvent.VK_LEFT -> left = down;
case KeyEvent.VK_D, KeyEvent.VK_RIGHT -> right = down;
default -> { }
}
}

GamePanel() {
// ... после setFocusable(true):
addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
setMoveKey(e.getKeyCode(), true);
if (e.getKeyCode() == KeyEvent.VK_ESCAPE) {
running = false;
loop.interrupt();
System.exit(0);
}
if (gameState == GameState.MENU && e.getKeyCode() == KeyEvent.VK_ENTER) {
gameState = GameState.PLAYING;
}
if (gameState == GameState.GAME_OVER && e.getKeyCode() == KeyEvent.VK_R) {
resetRun();
}
}

@Override
public void keyReleased(KeyEvent e) {
setMoveKey(e.getKeyCode(), false);
}
});
requestFocusInWindow();
loop.start();
}

private void update(double dt) {
if (gameState != GameState.PLAYING) {
return;
}
}

В paintComponent — текст меню:

if (gameState == GameState.MENU) {
g.drawString("JAVA SURVIVORS — Enter: старт", 280, HEIGHT / 2);
}

Самопроверка

  • На старте видно меню; Enter переключает на пустую арену.
  • Esc закрывает игру.

Этап 2 — игрок и движение

Цель — класс Player, WASD/стрелки, ограничение внутри экрана.

Player.java:

package com.survivors;

public final class Player {
public double x = 480;
public double y = 270;
public double radius = 16;
public double maxHp = 100;
public double hp = 100;
public double moveSpeed = 220;

public void updateMovement(double dt, boolean up, boolean down,
boolean left, boolean right, int maxW, int maxH,
double speedMultiplier) {
double dx = 0, dy = 0;
if (up) dy -= 1;
if (down) dy += 1;
if (left) dx -= 1;
if (right) dx += 1;
if (dx == 0 && dy == 0) return;
double len = Math.hypot(dx, dy);
x += (dx / len) * moveSpeed * speedMultiplier * dt;
y += (dy / len) * moveSpeed * speedMultiplier * dt;
x = Math.max(radius, Math.min(maxW - radius, x));
y = Math.max(radius, Math.min(maxH - radius, y));
}
}

В GamePanel — флаги клавиш и отрисовка круга:

final Player player = new Player();
boolean up, down, left, right;

// keyPressed / keyReleased — выставляйте флаги для WASD и стрелок

private void update(double dt) {
if (gameState != GameState.PLAYING) return;
player.updateMovement(dt, up, down, left, right, WIDTH, HEIGHT, 1.0);
}

@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g;
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(new Color(17, 17, 20));
g2.fillRect(0, 0, WIDTH, HEIGHT);
if (gameState == GameState.PLAYING) {
g2.setColor(new Color(90, 160, 255));
g2.fill(new java.awt.geom.Ellipse2D.Double(
player.x - player.radius, player.y - player.radius,
player.radius * 2, player.radius * 2));
} else if (gameState == GameState.MENU) {
g2.drawString("Enter — старт", 400, HEIGHT / 2);
}
}

Самопроверка

  • Синий круг двигается плавно, не выходит за края.

Этап 3 — враги и спавн с краёв

ЦельEnemy, список enemies, спавн по таймеру, преследование игрока.

Enemy.java (минимум):

package com.survivors;

public final class Enemy {
public double x, y;
public final double radius;
public double hp;
public final double speed;
public final int xpValue;

public Enemy(double x, double y, double radius, double hp, double speed, int xpValue) {
this.x = x;
this.y = y;
this.radius = radius;
this.hp = hp;
this.speed = speed;
this.xpValue = xpValue;
}
}

В GamePanel:

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

final List<Enemy> enemies = new ArrayList<>();
final Random rng = new Random();
double spawnTimer = 0;
double worldTime = 0;

private void update(double dt) {
if (gameState != GameState.PLAYING) return;
worldTime += dt;
player.updateMovement(dt, up, down, left, right, WIDTH, HEIGHT, 1.0);

spawnTimer -= dt;
if (spawnTimer <= 0) {
spawnTimer = Math.max(0.15, 0.9 - worldTime * 0.004);
enemies.add(spawnEnemy());
}
updateEnemies(dt);
}

private Enemy spawnEnemy() {
double side = rng.nextDouble();
double x, y;
if (side < 0.25) { x = -40; y = rng.nextDouble() * HEIGHT; }
else if (side < 0.5) { x = WIDTH + 40; y = rng.nextDouble() * HEIGHT; }
else if (side < 0.75) { x = rng.nextDouble() * WIDTH; y = -40; }
else { x = rng.nextDouble() * WIDTH; y = HEIGHT + 40; }
double diff = 1.0 + worldTime * 0.02;
return new Enemy(x, y, 14, 35 * diff, 90 + diff * 10, 2);
}

private void updateEnemies(double dt) {
for (Enemy e : enemies) {
double dx = player.x - e.x;
double dy = player.y - e.y;
double len = Math.hypot(dx, dy);
if (len > 0.001) {
e.x += (dx / len) * e.speed * dt;
e.y += (dy / len) * e.speed * dt;
}
}
}

Отрисовка врагов — красные круги.

Самопроверка

  • Каждые ~0.5–1 с появляется новый враг с края.
  • Враги сходятся к игроку.

Этап 4 — снаряды и магический болт

ЦельProjectile, автоатака по ближайшему врагу с кулдауном.

Projectile.java:

package com.survivors;

public final class Projectile {
public double x, y;
public final double vx, vy;
public double damage;
public double radius;
public double life = 1.5;

public Projectile(double x, double y, double vx, double vy, double damage, double radius) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.damage = damage;
this.radius = radius;
}
}

В Player добавьте double shotCooldown = 0.2;.

В GamePanel:

final List<Projectile> projectiles = new ArrayList<>();

// в update после врагов:
player.shotCooldown -= dt;
if (player.shotCooldown <= 0 && !enemies.isEmpty()) {
shootMagicBolt();
player.shotCooldown = 0.35;
}

private void shootMagicBolt() {
Enemy target = findNearestEnemy();
if (target == null) return;
double dx = target.x - player.x;
double dy = target.y - player.y;
double len = Math.hypot(dx, dy);
if (len < 0.001) return;
double speed = 480;
projectiles.add(new Projectile(
player.x, player.y,
(dx / len) * speed, (dy / len) * speed,
18, 6));
}

private Enemy findNearestEnemy() {
Enemy best = null;
double bestD = Double.MAX_VALUE;
for (Enemy e : enemies) {
double d = distSq(player.x, player.y, e.x, e.y);
if (d < bestD) { bestD = d; best = e; }
}
return best;
}

static double distSq(double x1, double y1, double x2, double y2) {
double dx = x1 - x2, dy = y1 - y2;
return dx * dx + dy * dy;
}

updateProjectiles и отрисовка (этап 4–5):

private void updateProjectiles(double dt) {
Iterator<Projectile> it = projectiles.iterator();
while (it.hasNext()) {
Projectile p = it.next();
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0 || p.x < -80 || p.x > WIDTH + 80 || p.y < -80 || p.y > HEIGHT + 80) {
it.remove();
}
}
}

private void drawProjectiles(Graphics2D g2) {
g2.setColor(GameColors.PROJECTILE);
for (Projectile p : projectiles) {
g2.fill(new java.awt.geom.Ellipse2D.Double(
p.x - p.radius, p.y - p.radius, p.radius * 2, p.radius * 2));
}
}

Почему «ближайший» враг
В Java Survivors большинство снарядов летит к findNearestEnemy() — простая эвристика, которая ощущается как прицеливание без мыши. Паттерны вроде «кольцо импульса» стреляют во все стороны и не используют цель.

Самопроверка

  • Снаряды летят к ближайшему врагу без клика мыши.

Этап 5 — урон, смерть врага, очки

Цель — столкновение снаряд–враг, единая точка урона damageEnemy, счёт.

private int score = 0;
final List<XpOrb> xpOrbs = new ArrayList<>();

private void updateProjectiles(double dt) {
Iterator<Projectile> it = projectiles.iterator();
while (it.hasNext()) {
Projectile p = it.next();
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0 || p.x < -80 || p.x > WIDTH + 80 || p.y < -80 || p.y > HEIGHT + 80) {
it.remove();
continue;
}
Enemy hit = null;
for (Enemy e : enemies) {
if (circlesHit(p.x, p.y, p.radius, e.x, e.y, e.radius)) {
hit = e;
break;
}
}
if (hit != null) {
damageEnemy(hit, p.damage);
it.remove();
}
}
}

private void damageEnemy(Enemy enemy, double damage) {
enemy.hp -= damage;
if (enemy.hp <= 0) {
enemies.remove(enemy);
score += enemy.xpValue * 3;
xpOrbs.add(new XpOrb(enemy.x, enemy.y, enemy.xpValue));
}
}

static boolean circlesHit(double x1, double y1, double r1, double x2, double y2, double r2) {
double sum = r1 + r2;
return distSq(x1, y1, x2, y2) <= sum * sum;
}

Самопроверка

  • Враги исчезают от попаданий, счёт растёт.

Этап 6 — опыт, магнит, уровень

ЦельXpOrb, выпадение при смерти, притягивание, level / xpToNext.

XpOrb.java:

package com.survivors;

public final class XpOrb {
public double x, y;
public final int value;
public XpOrb(double x, double y, int value) {
this.x = x; this.y = y; this.value = value;
}
}

В Player:

public int level = 1;
public int xp = 0;
public int xpToNext = 10;
public double magnetRadius = 90;

При убийстве врага: xpOrbs.add(new XpOrb(e.x, e.y, e.xpValue));

private void updateXpOrbs(double dt) {
Iterator<XpOrb> it = xpOrbs.iterator();
while (it.hasNext()) {
XpOrb o = it.next();
double dx = player.x - o.x;
double dy = player.y - o.y;
double d = Math.hypot(dx, dy);
if (d < player.magnetRadius && d > 1) {
double pull = 320 * dt;
o.x += (dx / d) * pull;
o.y += (dy / d) * pull;
}
if (d < player.radius + 8) {
addXp(o.value);
it.remove();
}
}
}

private void addXp(int amount) {
player.xp += amount;
while (player.xp >= player.xpToNext) {
player.xp -= player.xpToNext;
player.level++;
player.xpToNext = (int) (player.xpToNext * 1.25) + 4;
pendingLevelUps++;
}
}

Поле int pendingLevelUps — используем на следующем этапе.

Самопроверка

  • Зелёные/бирюзовые точки XP тянутся к игроку и исчезают при подборе.
  • В консоли или HUD позже видно рост level.

Этап 7 — пауза на выбор улучшений

ЦельUpgradeState, три случайных перка, клавиши 1 2 3.

UpgradeState.java:

package com.survivors;

public enum UpgradeState {
NONE, PAUSED_FOR_UPGRADE
}
UpgradeState upgradeState = UpgradeState.NONE;
final List<String> upgradeChoices = new ArrayList<>();
int pendingLevelUps = 0;

// в конце update, если pendingLevelUps > 0 и upgradeState == NONE:
// upgradeState = PAUSED_FOR_UPGRADE;
// rollUpgradeChoices();

private void rollUpgradeChoices() {
upgradeChoices.clear();
List<String> pool = List.of(
"Сила +20%", "Скорость атаки +20%", "Макс. HP +25",
"Магнит +25%", "Скорость движения +15%", "Урон +5");
List<String> copy = new ArrayList<>(pool);
for (int i = 0; i < 3 && !copy.isEmpty(); i++) {
int idx = rng.nextInt(copy.size());
upgradeChoices.add(copy.remove(idx));
}
}

private void applyUpgrade(String choice) {
switch (choice) {
case "Сила +20%" -> player.damageMultiplier *= 1.2;
case "Скорость атаки +20%" -> player.attackSpeedMultiplier *= 1.2;
case "Макс. HP +25" -> { player.maxHp += 25; player.hp += 25; }
case "Магнит +25%" -> player.magnetRadius *= 1.25;
case "Скорость движения +15%" -> player.moveSpeed *= 1.15;
case "Урон +5" -> player.flatDamageBonus += 5;
default -> { }
}
}

В Player добавьте damageMultiplier, attackSpeedMultiplier, flatDamageBonus (по умолчанию 1.0 и 0).

В update в начале:

if (upgradeState == UpgradeState.PAUSED_FOR_UPGRADE) {
return;
}

В keyPressed:

if (upgradeState == UpgradeState.PAUSED_FOR_UPGRADE) {
if (e.getKeyCode() == KeyEvent.VK_1 && upgradeChoices.size() > 0) pickUpgrade(0);
if (e.getKeyCode() == KeyEvent.VK_2 && upgradeChoices.size() > 1) pickUpgrade(1);
if (e.getKeyCode() == KeyEvent.VK_3 && upgradeChoices.size() > 2) pickUpgrade(2);
}

pickUpgrade и очередь нескольких уровней за раз:

private void pickUpgrade(int index) {
if (index < 0 || index >= upgradeChoices.size()) return;
applyUpgrade(upgradeChoices.get(index));
pendingLevelUps--;
if (pendingLevelUps > 0) {
rollUpgradeChoices();
} else {
upgradeState = UpgradeState.NONE;
upgradeChoices.clear();
}
}

// в конце update (после XP), когда pendingLevelUps > 0:
if (pendingLevelUps > 0 && upgradeState == UpgradeState.NONE) {
upgradeState = UpgradeState.PAUSED_FOR_UPGRADE;
rollUpgradeChoices();
}

Оверлей level-up (рисуется поверх арены, логика на паузе):

private void drawUpgradeOverlay(Graphics2D g2) {
g2.setColor(new Color(0, 0, 0, 170));
g2.fillRect(0, 0, WIDTH, HEIGHT);
g2.setFont(g2.getFont().deriveFont(Font.BOLD, 28f));
g2.setColor(Color.WHITE);
g2.drawString("LEVEL UP — выберите улучшение", 260, 120);
g2.setFont(g2.getFont().deriveFont(Font.PLAIN, 20f));
for (int i = 0; i < upgradeChoices.size(); i++) {
g2.drawString((i + 1) + " — " + upgradeChoices.get(i), 280, 200 + i * 48);
}
}

В applyUpgrade добавьте ветки для оружия (этап 9):

case "Оружие: Тройной залп" -> unlockedWeapons.add(WeaponType.TRIPLE_CAST);
case "Оружие: Кольцо импульса" -> unlockedWeapons.add(WeaponType.PULSE_RING);

Самопроверка

  • При level-up игра замирает, видны 3 варианта.
  • После 2 бой продолжается, статы изменились.

Этап 8 — HUD (HP, XP, время, счёт)

Цель — полоски HP/XP, таймер worldTime, волна.

private int wave = 1;
private double waveTimer = 0;

// в update:
waveTimer += dt;
if (waveTimer >= 30) {
waveTimer = 0;
wave++;
}

private void drawHud(Graphics2D g2) {
int barW = 220, barH = 14, x = 16, y = 16;
g2.setColor(new Color(40, 40, 50));
g2.fillRect(x, y, barW, barH);
double hpRatio = player.hp / player.maxHp;
g2.setColor(new Color(200, 60, 60));
g2.fillRect(x, y, (int) (barW * hpRatio), barH);

g2.fillRect(x, y + 24, barW, barH);
double xpRatio = (double) player.xp / player.xpToNext;
g2.setColor(new Color(80, 180, 255));
g2.fillRect(x, y + 24, (int) (barW * xpRatio), barH);

g2.setColor(Color.WHITE);
g2.drawString("LV " + player.level + " SCORE " + score, x, y + 56);
g2.drawString(String.format("TIME %d:%02d WAVE %d", (int) worldTime / 60, (int) worldTime % 60, wave), x, y + 72);
}

В shootMagicBolt учитывайте множители:

double damage = 18 * player.damageMultiplier + player.flatDamageBonus;
player.shotCooldown = Math.max(0.08, 0.35 / player.attackSpeedMultiplier);

Порядок отрисовки в paintComponent (снизу вверх):

@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g;
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(GameColors.BG);
g2.fillRect(0, 0, WIDTH, HEIGHT);

if (gameState == GameState.PLAYING) {
drawXpOrbs(g2);
drawEnemies(g2);
drawProjectiles(g2);
drawParticles(g2);
drawPlayer(g2);
drawDamageNumbers(g2);
drawHud(g2);
if (upgradeState == UpgradeState.PAUSED_FOR_UPGRADE) {
drawUpgradeOverlay(g2);
}
} else if (gameState == GameState.MENU) {
drawMenu(g2);
} else if (gameState == GameState.GAME_OVER) {
drawGameOver(g2);
}
}

Самопроверка

  • HUD читается поверх арены, не перекрывает игрока в центре.

Этап 9 — несколько оружий (enum)

ЦельWeaponType, Set<WeaponType> unlockedWeapons, тройной залп и кольцо импульса.

WeaponType.java:

package com.survivors;

public enum WeaponType {
MAGIC_BOLT, TRIPLE_CAST, PULSE_RING
}
final Set<WeaponType> unlockedWeapons = new EnumSet<>(EnumSet.of(WeaponType.MAGIC_BOLT));

void updateWeapons(double dt) {
if (enemies.isEmpty()) return;
if (unlockedWeapons.contains(WeaponType.MAGIC_BOLT)) {
player.shotCooldown -= dt;
if (player.shotCooldown <= 0) {
shootMagicBolt();
player.shotCooldown = Math.max(0.08, 0.35 / player.attackSpeedMultiplier);
}
}
if (unlockedWeapons.contains(WeaponType.TRIPLE_CAST)) {
player.tripleCooldown -= dt;
if (player.tripleCooldown <= 0) {
shootTripleCast();
player.tripleCooldown = Math.max(0.2, 1.0 / player.attackSpeedMultiplier);
}
}
if (unlockedWeapons.contains(WeaponType.PULSE_RING)) {
player.pulseCooldown -= dt;
if (player.pulseCooldown <= 0) {
shootPulseRing();
player.pulseCooldown = Math.max(0.5, 2.0 / player.attackSpeedMultiplier);
}
}
}

В Player добавьте отдельные кулдауны:

public double tripleCooldown = 0.8;
public double pulseCooldown = 1.2;
public double damageMultiplier = 1.0;
public double attackSpeedMultiplier = 1.0;
public double flatDamageBonus = 0.0;

Полные реализации (адаптация из Java Survivors):

private double totalDamageMultiplier() {
return player.damageMultiplier;
}

private void shootMagicBolt() {
Enemy target = findNearestEnemy();
if (target == null) return;
double dx = target.x - player.x;
double dy = target.y - player.y;
double baseAngle = Math.atan2(dy, dx);
spawnProjectileAtAngle(baseAngle, 18.0, 6.0, 480.0, 0);
}

private void shootTripleCast() {
Enemy target = findNearestEnemy();
if (target == null) return;
double baseAngle = Math.atan2(target.y - player.y, target.x - player.x);
double spread = 0.44;
for (int i = 0; i < 3; i++) {
double offset = (i - 1) * (spread / 2);
spawnProjectileAtAngle(baseAngle + offset, 12.0, 5.5, 540.0, 0);
}
}

private void shootPulseRing() {
int count = 10;
for (int i = 0; i < count; i++) {
double angle = (Math.PI * 2.0 / count) * i;
spawnProjectileAtAngle(angle, 14.0, 5.0, 430.0, 0);
}
}

private void spawnProjectileAtAngle(double angle, double baseDamage, double baseRadius,
double baseSpeed, int pierce) {
double speed = baseSpeed * player.projectileSpeedMultiplier;
double damage = baseDamage * totalDamageMultiplier() + player.flatDamageBonus;
double radius = baseRadius;
double vx = Math.cos(angle) * speed;
double vy = Math.sin(angle) * speed;
projectiles.add(new Projectile(player.x, player.y, vx, vy, damage, radius));
}

Расширьте Projectile полем public int pierce = 0; — на бонусном этапе снаряд с pierce > 0 не удаляется после первого попадания.

В пул rollUpgradeChoices добавьте строки оружия (только если ещё не разблокировано):

if (!unlockedWeapons.contains(WeaponType.TRIPLE_CAST)) {
pool.add("Оружие: Тройной залп");
}
if (!unlockedWeapons.contains(WeaponType.PULSE_RING)) {
pool.add("Оружие: Кольцо импульса");
}

Самопроверка

  • После выбора «Тройной залп» видны дополнительные снаряды с отдельным ритмом.

Этап 10 — контактный урон, броня, реген

Цель — урон при наложении кругов, armorReduction, пассивный хил.

В Player:

public double regen = 1.5;
public double armorReduction = 0.1;

public void heal(double dt) {
hp = Math.min(maxHp, hp + regen * dt);
}
double contactDamageTimer = 0;

// в update:
player.heal(dt);
contactDamageTimer -= dt;
if (contactDamageTimer <= 0) {
double sum = 0;
for (Enemy e : enemies) {
if (circlesHit(player.x, player.y, player.radius, e.x, e.y, e.radius)) {
sum += 3.5;
}
}
if (sum > 0) {
double incoming = sum * (1.0 - player.armorReduction);
player.hp -= incoming;
contactDamageTimer = 0.25;
if (player.hp <= 0) {
player.hp = 0;
gameState = GameState.GAME_OVER;
}
}
}

Самопроверка

  • При «объятии» толпой HP падает ступенями, не каждый кадр.

Этап 11 — рывок (dash)

ЦельПробел, краткая неуязвимость, кулдаун (как DASH_DURATION в референсе).

boolean isDashing = false;
double dashTimer = 0;
double dashCooldownTimer = 0;
static final double DASH_DURATION = 0.15;
static final double DASH_COOLDOWN = 1.4;
static final double DASH_SPEED_MULT = 4.0;

// keyPressed — внутри PLAYING:
if (e.getKeyCode() == KeyEvent.VK_SPACE && !isDashing && dashCooldownTimer <= 0) {
isDashing = true;
dashTimer = DASH_DURATION;
dashCooldownTimer = DASH_COOLDOWN;
}

// в update после движения:
if (isDashing) {
dashTimer -= dt;
if (dashTimer <= 0) {
isDashing = false;
}
} else if (dashCooldownTimer > 0) {
dashCooldownTimer -= dt;
}

// в updateMovement Player или в GamePanel перед вызовом player.updateMovement:
double speedMult = isDashing ? DASH_SPEED_MULT : 1.0;
// передайте speedMult в расчёт смещения (умножьте moveSpeed на speedMult)

// контактный урон — только если !isDashing

Визуально — белая обводка Ellipse2D вокруг игрока на время рывка. В Java Survivors рывок также сбрасывает серию без урона (noDamageTime) — можно добавить для достижений в мета-сохранении.

Самопроверка

  • Рывок проскальзывает сквозь орду без урона на ~0.15 с.

Этап 12 — всплывающий урон и частицы

ЦельDamageNumber, простые Particle при убийстве.

DamageNumber.java:

package com.survivors;

import java.awt.*;

public final class DamageNumber {
public double x, y;
public String text;
public double life = 0.8;
public Color color = new Color(255, 230, 120);

public DamageNumber(double x, double y, String text) {
this.x = x; this.y = y; this.text = text;
}
}

При damageEnemy добавляйте damageNumbers.add(new DamageNumber(e.x, e.y - 10, String.format("%.0f", dmg)));.

Обновление — y -= 40 * dt, life -= dt, удаление при life <= 0. Отрисовка — g2.drawString.

Particle.java и простой спавн при убийстве:

package com.survivors;

public final class Particle {
public double x, y, vx, vy;
public double life = 0.5;
public final java.awt.Color color;

public Particle(double x, double y, double vx, double vy, java.awt.Color color) {
this.x = x; this.y = y; this.vx = vx; this.vy = vy;
this.color = color;
}
}
final List<Particle> particles = new ArrayList<>();

private void spawnKillParticles(double x, double y) {
for (int i = 0; i < 8; i++) {
double a = rng.nextDouble() * Math.PI * 2;
double sp = 80 + rng.nextDouble() * 120;
particles.add(new Particle(x, y,
Math.cos(a) * sp, Math.sin(a) * sp,
GameColors.ENEMY));
}
}

private void updateParticles(double dt) {
Iterator<Particle> it = particles.iterator();
while (it.hasNext()) {
Particle p = it.next();
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0) it.remove();
}
}

В damageEnemy при смерти вызывайте spawnKillParticles(enemy.x, enemy.y).

Самопроверка

  • При попадании видны жёлтые цифры урона.

Этап 13 — меню, game over, перезапуск

Цель — сброс рана по R, экран поражения со статистикой.

private void resetRun() {
gameState = GameState.PLAYING;
upgradeState = UpgradeState.NONE;
worldTime = 0;
wave = 1;
waveTimer = 0;
score = 0;
pendingLevelUps = 0;
enemies.clear();
projectiles.clear();
xpOrbs.clear();
damageNumbers.clear();
unlockedWeapons.clear();
unlockedWeapons.add(WeaponType.MAGIC_BOLT);
player.reset();
}

// keyPressed: GAME_OVER + R → resetRun()

Player.reset() — сброс статов рана (упрощённо):

public void reset() {
x = 480; y = 270;
maxHp = 100; hp = 100;
moveSpeed = 220;
regen = 1.5;
armorReduction = 0.1;
magnetRadius = 90;
damageMultiplier = 1.0;
attackSpeedMultiplier = 1.0;
flatDamageBonus = 0.0;
level = 1; xp = 0; xpToNext = 10;
shotCooldown = 0; tripleCooldown = 0; pulseCooldown = 0;
}

Скелет update к этапу 13

private void update(double dt) {
if (gameState == GameState.MENU || gameState == GameState.GAME_OVER) {
return;
}
if (upgradeState == UpgradeState.PAUSED_FOR_UPGRADE) {
return;
}

worldTime += dt;
updateWaveAndBoss(dt);

if (isDashing) { /* dashTimer */ } else { dashCooldownTimer -= dt; }

player.heal(dt);
player.updateMovement(dt, up, down, left, right, WIDTH, HEIGHT, isDashing ? DASH_SPEED_MULT : 1.0);

updateWeapons(dt);
spawnEnemies(dt);
updateEnemies(dt);
updateProjectiles(dt);
updateXpOrbs(dt);
updateParticles(dt);
updateDamageNumbers(dt);
updateContactDamage(dt);

if (pendingLevelUps > 0 && upgradeState == UpgradeState.NONE) {
upgradeState = UpgradeState.PAUSED_FOR_UPGRADE;
rollUpgradeChoices();
}
}

paintComponent для GAME_OVER — «Поражение», счёт, время, «R — заново».

Самопроверка

  • После смерти R запускает чистый ран.

Этап 14 — типы врагов (enum)

ЦельEnemyKind — NORMAL, SPEEDER, TANK (упрощённо).

EnemyKind.java:

package com.survivors;

public enum EnemyKind {
NORMAL, SPEEDER, TANK, SHOOTER, BOSS
}

Поле public EnemyKind kind = EnemyKind.NORMAL; в Enemy. Пример spawnEnemy с вероятностями (как в референсе, упрощённо):

private Enemy spawnEnemy() {
double side = rng.nextDouble();
double x, y;
if (side < 0.25) { x = -50; y = rng.nextDouble() * HEIGHT; }
else if (side < 0.5) { x = WIDTH + 50; y = rng.nextDouble() * HEIGHT; }
else if (side < 0.75) { x = rng.nextDouble() * WIDTH; y = -50; }
else { x = rng.nextDouble() * WIDTH; y = HEIGHT + 50; }

double t = worldTime;
double difficulty = 1.0 + (t * 0.018) + (Math.pow(t, 1.18) * 0.0012);
double roll = rng.nextDouble();

if (roll < 0.12) {
Enemy e = new Enemy(x, y, 10, 26 * difficulty, 190 + difficulty * 14, 2);
e.kind = EnemyKind.SPEEDER;
return e;
}
if (roll < 0.26) {
Enemy e = new Enemy(x, y, 14, 40 * difficulty, 105 + difficulty * 8, 3);
e.kind = EnemyKind.SHOOTER;
return e;
}
if (roll < 0.48) {
Enemy e = new Enemy(x, y, 22, 95 * difficulty, 62 + difficulty * 5, 4);
e.kind = EnemyKind.TANK;
return e;
}
return new Enemy(x, y, 14, 40 * difficulty, 105 + difficulty * 8, 2);
}
ТипВизуалПоведение
NORMALкрасныйидёт к игроку
SPEEDERоранжевый, меньшебыстрее
TANKтёмно-красный, большемедленный, много HP
SHOOTERрозоватыйдержит дистанцию ~180px, стреляет

ИИ стрелка (в updateEnemies):

if (e.kind == EnemyKind.SHOOTER) {
double preferred = 180;
if (len > preferred + 20) {
e.x += (dx / len) * e.speed * 0.75 * dt;
e.y += (dy / len) * e.speed * 0.75 * dt;
} else if (len < preferred - 20) {
e.x -= (dx / len) * e.speed * 0.75 * dt;
e.y -= (dy / len) * e.speed * 0.75 * dt;
}
if (e.attackCooldown <= 0 && len > 50) {
projectiles.add(enemyProjectileTowardPlayer(e));
e.attackCooldown = 1.3;
}
continue;
}

Добавьте в Enemy поле double attackCooldown = 0; и в Projectile флаг boolean fromEnemy = false. В updateProjectiles обрабатывайте вражеские снаряды отдельно — урон игроку, как в Java Survivors.

Самопроверка

  • На поле смешаны разные силуэты, темп игры разнообразнее.

Этап 15 — усложнение спавна и босс (мини)

Цель — формула difficulty от worldTime, пачки врагов, редкий босс.

Спавн-таймер из референса (ускоряется со временем):

void spawnEnemies(double dt) {
spawnTimer -= dt;
if (spawnTimer > 0) return;

double t = worldTime;
double difficulty = 1.0 + (t * 0.018) + (Math.pow(t, 1.18) * 0.0012);
int batch = 1 + (int) Math.floor(t / 45.0);
if (rng.nextDouble() < Math.min(0.55, 0.20 + t / 260.0)) {
batch++;
}
spawnTimer = Math.max(0.08, 0.78 - (t * 0.0038));

for (int i = 0; i < batch; i++) {
enemies.add(spawnEnemy(difficulty));
}
}

Сигнатуру spawnEnemy измените на spawnEnemy(double difficulty) и используйте множитель в HP/скорости.

double bossTimer = 120;

private void updateWaveAndBoss(double dt) {
waveTimer += dt;
if (waveTimer >= 30) {
waveTimer = 0;
wave++;
}
bossTimer -= dt;
if (bossTimer <= 0) {
bossTimer = 120;
enemies.add(spawnBoss());
}
}

private Enemy spawnBoss() {
Enemy boss = new Enemy(WIDTH / 2.0, -60, 36, 800 + wave * 50, 55, 40);
boss.kind = EnemyKind.BOSS;
return boss;
}

Босс — большой HP, медленный, много XP. Фаза 1 в полной игре — кольцо из 18 вражеских снарядов при attackCooldown <= 0; фаза 2 — дополнительный спавн мелочи. Реализация — в updateEnemies для EnemyKind.BOSS в DiabloidGame.java.

Самопроверка

  • Раз в ~2 минуты появляется крупный враг сверху.

Этап 16 — разнесение по файлам и делегаты

Цель — повторить структуру Java Survivors: вынести Player, Enemy, Projectile, оставить в GamePanel только оркестрацию.

Создайте тонкие обёртки (как в репозитории):

final class EnemySpawner {
private final SurvivorsGame.GamePanel game;
void tick(double dt) { game.spawnEnemies(dt); }
}
final class WeaponManager {
private final SurvivorsGame.GamePanel game;
void tick(double dt) { game.updateWeapons(dt); }
}

В update(dt):

weaponManager.tick(dt);
enemySpawner.tick(dt);

Это тот же приём, что WeaponManager / EnemySpawner в com.diabloidделегирование без преждевременного раздувания одного файла на 1700+ строк.

Самопроверка

  • Поведение идентично этапу 15, DiabloidGame.java / SurvivorsGame.java короче и читаемее.

Этап 17 — спрайты и фон (опционально)

Цель — загрузка PNG из assets/, MediaTracker, fallback на круги.

Загрузка с ожиданием декодирования (как в DiabloidGame.loadAssets):

private static Image playerSprite;
private static Image enemySprite;
private static boolean imagesLoaded;

private void loadAssets() {
if (imagesLoaded) return;
Toolkit toolkit = Toolkit.getDefaultToolkit();
try {
playerSprite = toolkit.getImage("assets/player.png");
enemySprite = toolkit.getImage("assets/enemy_normal.png");
MediaTracker tracker = new MediaTracker(new JPanel());
tracker.addImage(playerSprite, 0);
tracker.addImage(enemySprite, 1);
tracker.waitForAll();
imagesLoaded = true;
} catch (Exception ex) {
System.err.println("Ассеты не загружены: " + ex.getMessage());
imagesLoaded = false;
}
}

private void drawPlayer(Graphics2D g2) {
if (imagesLoaded && playerSprite != null) {
int size = 32;
g2.drawImage(playerSprite, (int) player.x - size / 2, (int) player.y - size / 2, size, size, this);
} else {
g2.setColor(GameColors.PLAYER);
g2.fill(new java.awt.geom.Ellipse2D.Double(
player.x - player.radius, player.y - player.radius,
player.radius * 2, player.radius * 2));
}
}

При ошибке загрузки — imagesLoaded = false, рисуем примитивы. Скопируйте ассеты из клонированного Java Survivors или нарисуйте 32×32 в любом редакторе.

Самопроверка

  • С ассетами — спрайты; без папки assets — игра не падает.

Этап 18 — карта пути к полному Java Survivors

Цель — понять, что уже есть в репозитории и что добавить самостоятельно.

Функция в Java SurvivorsКласс / зонаВ практикуме
10+ персонажей с разным стартовым оружиемCharacterDef, выбор в менюэтап 18+
Десятки WeaponType, стихииWeaponType, fireExtraWeaponчастично (этап 9)
Цепная молния, пилаLightningEffect, SawBladeEffectсамостоятельно
Статусы burn/slow/poisonStatusEffect, StatusEffectTypeсамостоятельно
Магазин между волнамиPAUSED_FOR_SHOP, rollShopChoicesсамостоятельно
Сохранение мета-прогрессаSaveSystem, SaveDataсамостоятельно
Монеты на картеCoinPickup, runCoinsсамостоятельно

Клонирование референса:

git clone https://github.com/Spirzen/Java-Survivors.git
cd Java-Survivors
mvn -q package
java -jar target/java-survivors-1.0-SNAPSHOT.jar

Сравните свой com.survivors с com.diabloid — совпадают имена паттернов (списки, Iterator, enum оружия, пауза на upgrade).

Ветки перков в полной игре

В референсе улучшения разбиты по PerkBranchATTACK, DEFENSE, SUPPORT; на level-up предлагается по одному варианту с каждой ветки (до трёх строк на экране). Метод rollUpgradeChoices() в DiabloidGame — образец для расширения учебного пула.

Магазин каждые 3 волны

UpgradeState.PAUSED_FOR_SHOP открывается при wave % 3 == 0 — трата runCoins на постоянные бонусы (permanentShopUpgrades). Это мета-слой внутри рана, между «чистым» survivor-like и roguelike.


Бонус — пронзание, аура, цепная молния

После этапа 18 можно добавить три узнаваемых механики из Java Survivors без переписывания архитектуры.

Пронзающие снаряды (pierce)

В updateProjectiles после damageEnemy:

if (hit != null) {
damageEnemy(hit, p.damage);
if (p.pierce > 0) {
p.pierce--;
} else {
it.remove();
}
}

shootPierceLance — один луч с pierce = 2 и повышенным уроном.

Аура урона (DAMAGE_AURA)

Тик раз в ~0.28 с, урон всем врагам в радиусе auraRadius без снарядов:

private void updateDamageAura(double dt) {
player.auraTickCooldown -= dt;
if (player.auraTickCooldown > 0) return;
player.auraTickCooldown = 0.28;
double r = player.auraRadius;
double dmg = 7.5 * totalDamageMultiplier();
for (Enemy e : new ArrayList<>(enemies)) {
if (distSq(player.x, player.y, e.x, e.y) <= r * r) {
damageEnemy(e, dmg);
}
}
}

Цепная молния

Мгновенный урон по цепочке до трёх врагов + класс LightningEffect только для отрисовки линий между ними на 0.2 с. Логика выбора second/third в радиусе 210px — см. castChainLightning() в репозитории.


Итоговая самопроверка проекта

#КритерийДа / нет
1Maven-сборка, main в манифесте shade
2Игровой цикл в отдельном потоке, capped dt
3Игрок двигается, не выходит за экран
4Враги спавнятся с краёв и преследуют
5Автоатака без клика по врагам
6XP, уровень, экран выбора 3 перков
7Минимум 2 дополнительных оружия через перки
8HUD — HP, XP, время, счёт
9Контактный урон, рывок, game over + restart
10Код разнесён по нескольким .java

Типичные ошибки

СимптомВероятная причинаЧто сделать
Чёрное окно, нет отрисовкиЛогика только в потоке, забыли repaint()После update вызывайте repaint()
Зависание при закрытииSystem.exit без остановки потокаrunning = false, interrupt потока
Враги не умираютНет break после попадания снарядаОдин снаряд — один враг за шаг итератора
Level-up без паузыНе проверяете upgradeState в начале updateРанний return при PAUSED_FOR_UPGRADE
ConcurrentModificationExceptionУдаление из списка во вложенном forIterator.remove()
Кракозябры в HUDCP1251 вместо UTF-8UTF-8 в IDE и project.build.sourceEncoding

Идеи для расширения

  • Мультивыстрел — поле multishotMultiplier увеличивает число снарядов в shootMagicBolt (веер).
  • СохраненияSaveData + ObjectOutputStream в save.json (как SaveSystem в репозитории).
  • ПерсонажиCharacterDef со стартовым WeaponType и ценой разблокировки в монетах.
  • Звукjavax.sound.sampled, короткие WAV в assets/sfx/.
  • Порт на JavaFX или LibGDX — те же списки enemies / projectiles, другой рендер.

Чек-лист «ощущение Vampire Survivors»

ОщущениеРеализовано в практикуме?
Толпа нарастает со временемЭтапы 3, 15 (batch, difficulty)
Становишься сильнее быстрее враговЭтапы 6–9 (XP + оружие)
Решения на level-upЭтап 7
Одна ошибка — наказаниеЭтап 10
«Ещё один ран»Этап 13
Визуальный шум (juice)Этап 12

Связанные материалы


См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").

Освоение главы0%