Java — Java Survivors
О практикуме
Vampire Survivors и его клоны (survivor-like, horde survival) — арена сверху, волны врагов, автоатаки без прицеливания, опыт с поля и пауза на выбор улучшений. Полная реализация с десятками оружий, персонажами и сохранениями — в репозитории Java Survivors (локально: F:\Projects\JVM\Java\Java Survivors). Стек там — Java 17, Swing, Java2D, без сторонних игровых движков.
В этом практикуме соберём узнаваемый прототип с нуля: от пустого окна до врагов, магического болта, опыта, экрана прокачки, нескольких оружий, HUD и меню. Графика — круги и прямоугольники (как в ранней версии до спрайтов); в конце — карта расширений до уровня полного проекта.
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 | Выход |
Маршрут чтения
- Архитектура — как устроен проект до первой строки кода.
- Зависимости и структура — JDK, Maven, пакеты.
- Этап 0 — минимальный запуск — окно и игровой поток.
- Этапы 1–18 — по одной (или паре) механик за шаг.
- Итоговая самопроверка — чек-лист и связь с полным репозиторием.
Что должно получиться к этапу 18
| Механика | Описание |
|---|---|
| Цикл | Отдельный поток + dt, отрисовка в paintComponent |
| Игрок | Круг, HP, реген, броня, движение по WASD |
| Враги | Спавн с краёв экрана, преследование, рост сложности |
| Оружие | Авто «магический болт», тройной залп, кольцо импульса |
| Опыт | Сферы XP, магнит, уровни, пауза на 3 улучшения |
| Урон | Снаряды, контакт, рывок с неуязвимостью |
| HUD | HP, полоска XP, время, счёт, волна |
| Состояния | Меню, игра, game over |
com.survivors с com.diabloid в репозитории.Сводка этапов
| Этап | Тема | Новое в запуске |
|---|---|---|
| 0 | Цикл | Окно, поток, dt, repaint |
| 1 | FSM | GameState, меню, Esc |
| 2 | Игрок | WASD, круг на арене |
| 3 | Враги | Спавн с краёв, преследование |
| 4 | Снаряды | Авто «магический болт» |
| 5 | Урон | Попадания, счёт, смерть врага |
| 6 | XP | Орбы, магнит, уровень |
| 7 | Перки | Пауза, 3 выбора, 1 2 3 |
| 8 | HUD | HP, XP, время, волна |
| 9 | Оружия | WeaponType, залп, кольцо |
| 10 | Выживание | Контакт, броня, реген |
| 11 | Рывок | Dash, i-frames |
| 12 | Juice | Цифры урона, частицы |
| 13 | Мета-ран | Game over, resetRun |
| 14 | Архетипы | SPEEDER, TANK, SHOOTER |
| 15 | Босс | Сложность по времени |
| 16 | Модули | WeaponManager, файлы |
| 17 | Арт | PNG, MediaTracker |
| 18 | Референс | Таблица фич полной игры |
Чем survivor-like отличается от «обычного» шутера
| Аспект | Классический twin-stick | Survivor-like (VS, Java Survivors) |
|---|---|---|
| Прицеливание | Игрок целится | Оружие само выбирает цель / паттерн |
| Прогресс в ране | Подбор на карте | Level-up с выбором из 3 карт |
| Давление | Волны по скрипту | Непрерывный спавн + рост difficulty по worldTime |
| Поражение | Жизни / чекпоинты | Один HP-бар, короткий рестарт |
| Сессия | 5–15 мин уровень | 10–30 мин один ран до смерти |
Архитектура
Прежде чем писать код, зафиксируем что из чего состоит и как данные текут по кадру. Референсная архитектура совпадает с Java Survivors: центральная панель владеет списками сущностей и вызывает обновление/отрисовку.
Жанр и петля геймплея
Survivor-like строится на положительной обратной связи:
- Враги приходят волнами → игрок убивает автооружием.
- Выпадает опыт → уровень → выбор перка (сильнее).
- Сложность растёт по времени выживания → нужны новые перки.
- Смерть → короткий цикл «ещё раз».
Игровой цикл (Swing)
В desktop-играх на Swing нельзя долго блокировать EDT (Event Dispatch Thread). В Java Survivors логика крутится в отдельном потоке, а перерисовка — через repaint() → paintComponent:
На каждом шаге update (если не меню и не пауза прокачки):
- Увеличить
worldTime, таймеры спавна и волн. - Движение игрока, реген HP.
- Спавн врагов, ИИ преследования.
- Кулдауны оружия → новые снаряды.
- Движение снарядов, столкновения, смерть врагов → XP.
- Магнит XP, проверка level-up.
- Контактный урон, проверка game over.
Слои приложения
| Слой | Ответственность | Примеры в полном проекте |
|---|---|---|
| Ввод | Клавиши, мышь на экране улучшений | KeyHandler, MouseHandler |
| Мир | Размер арены, время, волны | WIDTH, HEIGHT, worldTime, wave |
| Акторы | Игрок, враги, снаряды, XP | Player, 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.java | JFrame, вложенный 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»).
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
- File → Open — папка с
pom.xml. - Дождитесь индексации Maven (импорт JDK 17).
- ПКМ по
JavaSurvivors.java→ Run 'JavaSurvivors.main()'. - Working directory — корень проекта (для
assets/на этапе 17).
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).
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));
}
}
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/poison | StatusEffect, 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).
Ветки перков в полной игре
В референсе улучшения разбиты по PerkBranch — ATTACK, 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() в репозитории.
Итоговая самопроверка проекта
| # | Критерий | Да / нет |
|---|---|---|
| 1 | Maven-сборка, main в манифесте shade | |
| 2 | Игровой цикл в отдельном потоке, capped dt | |
| 3 | Игрок двигается, не выходит за экран | |
| 4 | Враги спавнятся с краёв и преследуют | |
| 5 | Автоатака без клика по врагам | |
| 6 | XP, уровень, экран выбора 3 перков | |
| 7 | Минимум 2 дополнительных оружия через перки | |
| 8 | HUD — 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 | Удаление из списка во вложенном for | Iterator.remove() |
| Кракозябры в HUD | CP1251 вместо UTF-8 | UTF-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 |
Связанные материалы
- Практикум разработки игр — о разделе — другие треки (Python, TypeScript).
- Java — о разделе — синтаксис, JVM, инструменты.
- Java Survivors на GitHub — полная версия практикума.
- TypeScript — TypeScript Survivors — тот же жанр в браузере (в подготовке).
- Игроведение — о разделе — жанры, механики, контекст индустрии.
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Пошаговый практикум — Battle City на Python и Pygame: архитектура, 16 этапов, полные листинги, сравнение с NES-оригиналом, отладка и расширения. Пошаговый практикум Match-3 на Python и Pygame — архитектура, 14 этапов, консольный прототип, отладка, тесты, подсказки, анимация и спец-фишки. Пошаговый практикум — аркада Ping Pong (Pong) на Python и Pygame: архитектура, баланс, зависимости, 14 этапов до прототипа, бонус — substeps и звук. Пошаговый практикум — гоночная мини-игра на Python и Pygame: архитектура, физика, зависимости, 16 этапов до заезда с кругами, таймером, соперниками и полировкой. Пошаговый практикум — Tetris (тетрис) на Python и Pygame: архитектура, 7 тетромино, вращение, линии, очки, уровни, ghost, 7-bag, hold и 20 этапов до играбельного прототипа. Пошаговый практикум — hack and slash в духе Diablo на Python и Pygame: архитектура, гейм-дизайн, зависимости, 18 обязательных этапов и 4 бонусных до полноценного ARPG-прототипа. Пошаговый практикум — карточный roguelike на Python и Pygame: архитектура, формулы боя, 17 этапов кода, моддинг JSON и сверка с AutoBattler (Тени Шпиля). Пошаговый практикум — карточный roguelike в браузере на TypeScript, React и Vite: архитектура, dispatch, 16 этапов, cardEffects, PWA и деплой. Эталон — OnlineCardGame («Приключения Урала Батыра»).Python — Battle City
Python — Match3
Python — Ping Pong
Python — Racing
Python — Tetris
Python — диаблоид
Python — карточная стратегия
TypeScript — OnlineCardGame