源本科技 | 码上会

JavaFX 飞机大战(源码参考)

2026/02/25
164
0

JavaFX 创建项目

Maven POM

我们使用 Maven 来管理项目,以下为 POM 的参考配置

<?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.lusifer</groupId>
    <artifactId>airplane</artifactId>
    <version>0.0.1</version>

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

        <javafx.version>17.0.2</javafx.version>
    </properties>

    <dependencies>
        <!-- JavaFX -->
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-media</artifactId>
            <version>${javafx.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- 编译插件 -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

module-info.java

src/main/java 目录下创建 module-info.java 文件

module com.lusifer.airplane {
    requires javafx.graphics;
    requires javafx.media;
    requires javafx.controls;

    exports com.lusifer.airplane to javafx.graphics;
}

Application

创建程序的入口程序

package com.lusifer.airplane;

import javafx.application.Application;
import javafx.stage.Stage;

/**
 * 游戏启动器
 *
 * @author Lusifer
 * @since 2024/6/21
 */
public class GameLauncher extends Application {

    @Override
    public void start(Stage stage) throws Exception {
        // 设置标题
        stage.setTitle("地球防卫军");
        // 关闭窗口时退出程序
        stage.setOnCloseRequest(windowEvent -> {
            System.exit(0);
        });
        // 显示窗口
        stage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

实际开发流程

Maven POM

<?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.lusifer</groupId>
    <artifactId>airplane</artifactId>
    <version>1.1.0</version>
​
    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
​
        <slf4j.version>2.0.13</slf4j.version>
        <javafx.version>17.0.2</javafx.version>
        <lombok.version>1.18.20</lombok.version>
        <logback.version>1.4.12</logback.version>
        <snakeyaml.version>2.2</snakeyaml.version>
    </properties>
​
    <dependencies>
        <!-- JavaFX -->
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-media</artifactId>
            <version>${javafx.version}</version>
        </dependency>
​
        <!-- Log -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>${slf4j.version}</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>${logback.version}</version>
        </dependency>
​
        <!-- YAML -->
        <dependency>
            <groupId>org.yaml</groupId>
            <artifactId>snakeyaml</artifactId>
            <version>${snakeyaml.version}</version>
        </dependency>
​
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
​
    <build>
        <plugins>
            <!-- 编译插件 -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                    <!-- 解决使用 MVN 编译时无法编译 Lombok 的问题 -->
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                            <version>${lombok.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
​
            <!-- JavaFX 打包 bat 插件 -->
            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.8</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>run</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <stripDebug>true</stripDebug>
                    <compress>2</compress>
                    <noHeaderFiles>true</noHeaderFiles>
                    <noManPages>true</noManPages>
                    <!-- 启动器名称,此处会生成 .bat 文件 -->
                    <launcher>airplane</launcher>
                    <!-- 此处会生成 airplane 文件夹存放生成后的文件 -->
                    <jlinkImageName>airplane</jlinkImageName>
                    <!-- 此处会生成 .zip 文件 -->
                    <!--<jlinkZipName>airplane</jlinkZipName>-->
                    <mainClass>com.lusifer.airplane.GameLauncher</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
​
</project>

Resources

  • config.yaml

app:
  name: 地球防卫军
  version: 1.1.0
​
display:
  width: 1920
  height: 1080
  fullscreen: true
  • logback.xml

<configuration>
    <!-- 控制台输出的日志格式 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>
​
    <!-- 设置日志级别 -->
    <root name="com.lusifer.airplane" level="debug" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
  • module-info.java

module com.lusifer.airplane {
    requires javafx.graphics;
    requires static lombok;
    requires org.yaml.snakeyaml;
    requires org.slf4j;
    requires javafx.media;
    requires java.desktop;
    requires javafx.controls;

    exports com.lusifer.airplane to javafx.graphics;
    opens com.lusifer.airplane.config to org.yaml.snakeyaml;
}

Application

package com.lusifer.airplane;

import com.lusifer.airplane.component.ReVideoView;
import com.lusifer.airplane.config.AppAssets;
import com.lusifer.airplane.config.AppConfig;
import com.lusifer.airplane.config.ConfigLoader;
import com.lusifer.airplane.game.menu.StartMenuScene;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import lombok.extern.slf4j.Slf4j;

/**
 * 游戏启动器
 *
 * @author Lusifer
 * @since 2024/6/21
 */
@Slf4j
public class GameLauncher extends Application {

    private static final AppConfig config = ConfigLoader.getInstance().getConfig();

    @Override
    public void start(Stage stage) throws Exception {
        log.debug("游戏启动");

        // 设置标题
        stage.setTitle(config.getApp().get("name"));
        // 设置图标
        stage.getIcons().add(new Image(AppAssets.IMAGES.get("logo")));
        // 禁止窗口最大化和拉伸
        stage.setResizable(false);
        // 隐藏全屏后显示【按 ESC 退出全屏】的提示
        stage.setFullScreenExitHint("");
        // 禁止使用 ESC 退出全屏功能
        stage.setFullScreenExitKeyCombination(KeyCodeCombination.NO_MATCH);
        // 关闭窗口时退出程序
        stage.setOnCloseRequest(windowEvent -> {
            log.debug("退出");
            System.exit(0);
        });

        // 设置默认空白场景
        stage.setScene(createScene());

        // 设置分辨率
        int width = Integer.parseInt(config.getDisplay().get("width"));
        int height = Integer.parseInt(config.getDisplay().get("height"));
        boolean isFullscreen = Boolean.parseBoolean(config.getDisplay().get("fullscreen"));
        stage.setFullScreen(isFullscreen);
        if (!isFullscreen) {
            stage.setWidth(width);
            stage.setHeight(height);
        }

        // 播放启动动画
        new ReVideoView(stage, AppAssets.VIDEOS.get("movie_story_start"), () -> {
            new StartMenuScene(stage);
        });

        // 显示窗口
        stage.show();
    }

    /**
     * 创建空白场景
     */
    private Scene createScene() {
        StackPane stackPane = new StackPane();
        Scene scene = new Scene(stackPane);
        scene.setFill(Color.BLACK);
        return scene;
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Java

common

  • CustomEvent.java

package com.lusifer.airplane.common;

/**
 * 回调接口
 * @author Lusifer
 * @since 2024/6/25
 */
public interface CustomEvent {

    void onCallback();

}

component

  • ReLoopImageView.java

package com.lusifer.airplane.component;

import javafx.animation.Animation;
import javafx.animation.Interpolator;
import javafx.animation.TranslateTransition;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Pane;
import javafx.stage.Stage;
import javafx.util.Duration;
import lombok.Getter;

/**
 * 无尽图。
 * 循环滚动图片的自定义组件,用于创建一个具有无限循环滚动效果的图像视图。
 * 该组件可以在指定的容器中创建两个相同的图片视图,使其在垂直方向上交替滚动,以实现连续的滚动效果。
 * 此类中的滚动速度和图片路径可以根据需要进行自定义。
 *
 * @author Lusifer
 * @since 2024/6/26
 */
@Getter
public class ReLoopImageView {

    /**
     * 滚动的间隔时间(秒)
     */
    private static final int SCROLL_SPEED = 20;

    /**
     * 视图的实际宽度
     */
    private final Double fitWidth;

    private final Double translateX;

    /**
     * 初始化无尽图
     *
     * @param stage     窗口
     * @param container 容器,放置无尽图的容器
     * @param filePath  图片路径
     */
    public ReLoopImageView(Stage stage, Pane container, String filePath) {
        Image image = new Image(filePath);

        // 创建两个 ImageView 并设置相同的背景图片
        ImageView topImageView = new ImageView(image);
        ImageView bottomImageView = new ImageView(image);

        // 设置 ImageView 的高度
        topImageView.fitHeightProperty().bind(stage.heightProperty());
        bottomImageView.fitHeightProperty().bind(stage.heightProperty());
        // 设置 preserveRatio 为 true,保持图片的宽高比例不变
        topImageView.setPreserveRatio(true);
        bottomImageView.setPreserveRatio(true);

        // 设置第二张图片的位置在第一张图片的下方
        bottomImageView.setTranslateY(bottomImageView.getFitHeight());

        // 实现背景图片垂直循环滚动
        double scrollDistance = topImageView.getFitHeight(); // 图片滚动的距离
        TranslateTransition topTransition = new TranslateTransition(Duration.seconds(SCROLL_SPEED), topImageView);
        TranslateTransition bottomTransition = new TranslateTransition(Duration.seconds(SCROLL_SPEED), bottomImageView);

        // 设置动画插值器为线性
        topTransition.setInterpolator(Interpolator.LINEAR);
        bottomTransition.setInterpolator(Interpolator.LINEAR);

        // 设置第一张图片的滚动距离(从上往下)
        topTransition.setFromY(-scrollDistance);
        topTransition.setToY(0);

        // 设置第二张图片的滚动距离(从上往下)
        bottomTransition.setFromY(0);
        bottomTransition.setToY(scrollDistance);

        // 设置循环次数为无限次
        topTransition.setCycleCount(Animation.INDEFINITE);
        bottomTransition.setCycleCount(Animation.INDEFINITE);

        // 播放动画
        topTransition.play();
        bottomTransition.play();

        // 将 ImageView 添加到容器中
        container.getChildren().addAll(topImageView, bottomImageView);

        // 计算 ImageView 的实际宽度
        this.fitWidth = calcFitWidth(topImageView);
        this.translateX = topImageView.getTranslateX();
    }

    /**
     * 计算视图的实际宽度
     *
     * @param imageView 图片视图
     * @return 视图的实际宽度
     */
    private Double calcFitWidth(ImageView imageView) {
        Image image = imageView.getImage();
        double originalWidth = image.getWidth(); // 获取原始图像的宽度

        double scaledWidth = imageView.getFitWidth(); // 获取 ImageView 的适应宽度
        double scaledHeight = imageView.getFitHeight(); // 获取 ImageView 的适应高度

        double actualWidth = scaledWidth; // 未设置 preserveRatio,实际宽度等于适应宽度
        if (imageView.isPreserveRatio()) {
            actualWidth = scaledHeight * (originalWidth / image.getHeight()); // 计算实际宽度
        }

        return actualWidth;
    }

}
  • ReVideoView.java

package com.lusifer.airplane.component;

import com.lusifer.airplane.common.CustomEvent;
import com.lusifer.airplane.utils.ScreenUtil;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.StackPane;
import javafx.scene.media.Media;
import javafx.scene.media.MediaPlayer;
import javafx.scene.media.MediaView;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import javafx.util.Duration;
import lombok.extern.slf4j.Slf4j;

import java.net.MalformedURLException;
import java.nio.file.Path;
import java.nio.file.Paths;

/**
 * 自定义组件(视频播放)
 *
 * @author Lusifer
 * @since 2024/6/25
 */
@Slf4j
public class ReVideoView {

    private StackPane container;

    private MediaPlayer mediaPlayer;
    private MediaView mediaView;

    private final Stage stage;
    private final String filePath;
    private final CustomEvent event;

    /**
     * 初始化视频播放组件
     *
     * @param stage    窗口
     * @param filePath 视频路径
     * @param event    回调方法(视频播放放完成后的回调)
     */
    public ReVideoView(Stage stage, String filePath, CustomEvent event) {
        this.stage = stage;
        this.filePath = filePath;
        this.event = event;

        initScene();
    }

    /**
     * 初始化场景
     */
    private void initScene() {
        container = new StackPane();

        Scene scene = new Scene(container);
        scene.setFill(Color.BLACK);
        scene.setOnKeyPressed(event -> {
            if (event.getCode() == KeyCode.ESCAPE) {
                // 手动触发播放完成(跳过动画)
                if (mediaPlayer.getStatus() == MediaPlayer.Status.PLAYING || mediaPlayer.getStatus() ==
                        MediaPlayer.Status.PAUSED) {
                    mediaPlayer.seek(mediaPlayer.getTotalDuration());
                }
            }
        });

        stage.setScene(scene);
        stage.setFullScreen(ScreenUtil.isFullscreen());

        initMediaPlayer();
    }

    // -------------------- 场景细节 Begin --------------------

    /**
     * 初始化播放器
     */
    private void initMediaPlayer() {
        Path path = Paths.get(filePath);
        try {
            Media media = new Media(path.toUri().toURL().toString());
            mediaPlayer = new MediaPlayer(media);
            // 播放视图自适应窗口
            mediaView = new MediaView(mediaPlayer);
            mediaView.fitHeightProperty().bind(stage.widthProperty());
            mediaView.fitHeightProperty().bind(stage.heightProperty());

            // 追加进容器
            container.getChildren().add(mediaView);
            container.setAlignment(Pos.TOP_LEFT);
            mediaPlayer.play(); // 开始播放

            // 播放监听
            mediaPlayer.setOnReady(() -> log.debug("视频准备就绪 {}", filePath));
            mediaPlayer.setOnEndOfMedia(() -> {
                log.debug("视频播放完成");

                // 将视频跳回播放的开头
                Platform.runLater(() -> mediaPlayer.seek(Duration.ZERO));
                mediaPlayer.stop(); // 停止播放

                // 调用回调
                event.onCallback();
            });

            // 捕获播放错误
            mediaPlayer.setOnError(() -> {
                log.error("视频播放出错", mediaPlayer.getError());
                // 如果发生错误,尝试重新播放
                log.warn("尝试重新播放 {}", filePath);
                container.getChildren().remove(mediaView);
                initMediaPlayer(); // 调用自身重新尝试播放
            });
        } catch (MalformedURLException e) {
            throw new RuntimeException(e);
        }
    }

}

config

  • AppAssets.java

package com.lusifer.airplane.config;

import lombok.extern.slf4j.Slf4j;

import java.io.File;
import java.net.URI;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/**
 * 应用资源
 */
@Slf4j
public final class AppAssets {

    private AppAssets() {

    }

    public static final Map<String, String> IMAGES = new HashMap<>();
    public static final Map<String, String> VIDEOS = new HashMap<>();
    public static final Map<String, String> AUDIOS = new HashMap<>();

    static {
        loadResources();
    }

    /**
     * 加载资源
     */
    private static void loadResources() {
        File folder = new File(location());
        File[] files = folder.listFiles();

        if (!Objects.isNull(files)) {
            for (File file : files) {
                if (file.exists() && file.isDirectory()) {
                    loadResourcePath(file);
                }
            }
        }
    }

    /**
     * 加载资源路径
     */
    private static void loadResourcePath(File folder) {
        for (File file : Objects.requireNonNull(folder.listFiles())) {
            String name = file.getName();
            int suffixIndex = name.lastIndexOf(".");
            String fileName = name.substring(0, suffixIndex);

            switch (folder.getName()) {
                case "image" -> {
                    IMAGES.put(fileName, file.getAbsolutePath());
                }
                case "audio" -> {
                    AUDIOS.put(fileName, file.getAbsolutePath());
                }
                case "video" -> {
                    VIDEOS.put(fileName, file.getAbsolutePath());
                }
            }
        }
    }

    private static URI location() {
        return Paths.get("assets").toAbsolutePath().toUri();
    }

}
  • AppConfig.java

package com.lusifer.airplane.config;

import lombok.Data;

import java.util.Map;

/**
 * 应用配置
 * @author Lusifer
 * @since 2024/6/21
 */
@Data
public class AppConfig {

    private Map<String, String> app;

    private Map<String, String> display;

}
  • ConfigLoader.java

package com.lusifer.airplane.config;

import lombok.Getter;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Constructor;

import java.io.InputStream;

/**
 * 应用配置加载器
 * @author Lusifer
 * @since 2024/6/21
 */
@Getter
public class ConfigLoader {

    private static volatile ConfigLoader instance;
    private AppConfig config;

    private ConfigLoader() {
        loadConfig();
    }

    public static ConfigLoader getInstance() {
        if (instance == null) {
            synchronized (ConfigLoader.class) {
                if (instance == null) {
                    instance = new ConfigLoader();
                }
            }
        }
        return instance;
    }

    private void loadConfig() {
        LoaderOptions loaderOptions = new LoaderOptions();
        loaderOptions.setMaxAliasesForCollections(Integer.MAX_VALUE);

        Yaml yaml = new Yaml(new Constructor(AppConfig.class, loaderOptions));
        InputStream inputStream = ConfigLoader.class.getClassLoader().getResourceAsStream("config.yaml");

        if (inputStream == null) {
            throw new RuntimeException("Failed to load config.yaml");
        }

        config = yaml.load(inputStream);
    }

}

game/enemy

  • BossPlane.java

package com.lusifer.airplane.game.enemy;

import com.lusifer.airplane.common.CustomEvent;
import com.lusifer.airplane.component.ReLoopImageView;
import com.lusifer.airplane.config.AppAssets;
import com.lusifer.airplane.game.NodeMetaData;
import com.lusifer.airplane.utils.Mp3Util;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.animation.TranslateTransition;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Pane;
import javafx.util.Duration;
import lombok.extern.slf4j.Slf4j;

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

/**
 * BOSS 飞机类
 *
 * @author Lusifer
 * @since 2024/6/27
 */
@Slf4j
public class BossPlane {

    private final Pane container;
    private final ReLoopImageView levelMapView;
    private final CustomEvent callback;
    private final ImageView bossPlaneView;
    private final Random random = new Random();
    private Timeline moveTimeline;
    private Timeline shootTimeline;

    /**
     * 初始化 BOSS
     *
     * @param container    容器,放置 BOSS 飞机的容器
     * @param levelMapView 关卡地图视图
     * @param callback     回调方法(BOSS 摧毁后的回调)
     */
    public BossPlane(Pane container, ReLoopImageView levelMapView, CustomEvent callback) {
        this.container = container;
        this.levelMapView = levelMapView;
        this.callback = callback;
        this.bossPlaneView = new ImageView(new Image(AppAssets.IMAGES.get("plane_boss_1")));
        initBossPlane();
    }

    private void initBossPlane() {
        log.debug("BOSS 进入战场");
        bossPlaneView.setTranslateX(0);
        bossPlaneView.setTranslateY(-bossPlaneView.getImage().getHeight() - container.getHeight() / 2);
        NodeMetaData nodeMetaData = new NodeMetaData();
        nodeMetaData.setName("bossPlane");
        nodeMetaData.setHealth(30); // 设置 BOSS 初始生命值
        bossPlaneView.setUserData(nodeMetaData);
        container.getChildren().add(bossPlaneView);

        // BOSS 出现动画
        TranslateTransition transition = new TranslateTransition(Duration.seconds(2), bossPlaneView);
        transition.setToY(-bossPlaneView.getImage().getHeight() - 100);
        transition.setOnFinished(event -> startMoveAndShoot());
        transition.play();

        // 启用碰撞检测
        detection();
    }

    /**
     * 移动与发射子弹
     */
    private void startMoveAndShoot() {
        moveTimeline = new Timeline(new KeyFrame(Duration.seconds(1), event -> moveRandomly()));
        moveTimeline.setCycleCount(Timeline.INDEFINITE);
        moveTimeline.play();

        shootTimeline = new Timeline(new KeyFrame(Duration.seconds(0.5), event -> shootBullets()));
        shootTimeline.setCycleCount(Timeline.INDEFINITE);
        shootTimeline.play();
    }

    private void moveRandomly() {
        double minX = levelMapView.getTranslateX() - bossPlaneView.getImage().getWidth() / 2;
        double maxX = levelMapView.getTranslateX() + bossPlaneView.getImage().getWidth() / 2;
        double newX = minX + random.nextDouble() * (maxX - minX);

        double newY = random.nextDouble() * (-container.getHeight() / 2); // 保持在屏幕上半部分

        log.debug("BOSS 坐标,x:{} y:{}", newX, newY);

        TranslateTransition transition = new TranslateTransition(Duration.seconds(1), bossPlaneView);
        transition.setToX(newX);
        transition.setToY(newY);
        transition.play();
    }

    private void shootBullets() {
        for (int i = -2; i <= 2; i++) {
            ImageView bullet = new ImageView(new Image(AppAssets.IMAGES.get("bullet_enemy_1")));
            bullet.setTranslateX(bossPlaneView.getTranslateX() + i * 30);
            bullet.setTranslateY(bossPlaneView.getTranslateY() + bossPlaneView.getImage().getHeight() / 2 + 20);
            NodeMetaData bulletMetaData = new NodeMetaData();
            bulletMetaData.setName("bossBullet");
            bullet.setUserData(bulletMetaData);
            container.getChildren().add(bullet);

            TranslateTransition bulletTransition = new TranslateTransition(Duration.seconds(5), bullet);
            bulletTransition.setToY(container.getHeight());
            bulletTransition.setOnFinished(event -> container.getChildren().remove(bullet));
            bulletTransition.play();
        }
    }

    public void stop() {
        if (moveTimeline != null) {
            moveTimeline.stop();
        }
        if (shootTimeline != null) {
            shootTimeline.stop();
        }
    }

    // -------------------- 碰撞检测 Begin --------------------

    /**
     * 碰撞检测
     */
    private void detection() {
        Timeline collisionCheck = new Timeline(new KeyFrame(Duration.millis(20), event -> {
            List<ImageView> bulletsToRemove = new ArrayList<>();
            for (javafx.scene.Node node : container.getChildren()) {
                if (node instanceof ImageView bullet && node != bossPlaneView) {
                    try {
                        if (!Objects.isNull(bullet.getUserData())) {
                            NodeMetaData bulletUserData = (NodeMetaData) bullet.getUserData();
                            if (!Objects.isNull(bulletUserData) && bulletUserData.getName().equals("playerBullet")) {
                                NodeMetaData enemyUserData = (NodeMetaData) bossPlaneView.getUserData();
                                int enemyHealth = enemyUserData.getHealth();
                                if (enemyHealth > 0) {
                                    if (bossPlaneView.getBoundsInParent().intersects(bullet.getBoundsInParent())) {
                                        bulletsToRemove.add(bullet);
                                        decreaseHealth();
                                        break;
                                    }
                                }
                            }
                        }
                    } catch (Exception ignored) {
                    }
                }
            }
            container.getChildren().removeAll(bulletsToRemove);
        }));
        collisionCheck.setCycleCount(Timeline.INDEFINITE);
        collisionCheck.play();
    }

    private void explode() {
        Mp3Util.playOnce(AppAssets.AUDIOS.get("effect_explosion_type_1"));
        Timeline explosionTimeline = new Timeline();
        for (int i = 1; i <= 6; i++) {
            final int index = i;
            explosionTimeline.getKeyFrames().add(new KeyFrame(Duration.seconds(0.1 * i),
                    e -> bossPlaneView.setImage(new Image(AppAssets.IMAGES.get("explosion" + index)))));
        }
        explosionTimeline.getKeyFrames().add(new KeyFrame(Duration.seconds(0.7),
                e -> container.getChildren().remove(bossPlaneView)));
        explosionTimeline.play();
    }

    public void decreaseHealth() {
        NodeMetaData bossMetaData = (NodeMetaData) bossPlaneView.getUserData();
        int health = bossMetaData.getHealth();
        health--;
        bossMetaData.setHealth(health);
        if (health <= 0) {
            explode();
            stop();
            callback.onCallback();
        }
        bossPlaneView.setUserData(bossMetaData);
    }

}
  • EnemyPlane.java

package com.lusifer.airplane.game.enemy;

import com.lusifer.airplane.common.CustomEvent;
import com.lusifer.airplane.component.ReLoopImageView;
import com.lusifer.airplane.config.AppAssets;
import com.lusifer.airplane.game.NodeMetaData;
import com.lusifer.airplane.utils.Mp3Util;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.animation.TranslateTransition;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Pane;
import javafx.util.Duration;
import lombok.extern.slf4j.Slf4j;

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

/**
 * 敌人飞机类
 *
 * @author Lusifer
 * @since 2024/6/26
 */
@Slf4j
public class EnemyPlane {

    private final Pane container;
    private final ReLoopImageView levelMapView;

    /**
     * 随机数生成器,用于随机化敌机生成的位置和属性
     */
    private final Random random = new Random();

    /**
     * 标记敌机生成位置,控制生成在左侧还是右侧
     */
    private boolean isLeft = false;

    /**
     * 时间轴,用于控制敌机的生成
     */
    private Timeline timeline;

    /**
     * 敌机摧毁后的回调
     */
    private final CustomEvent callback;

    /**
     * 初始化敌人飞机
     *
     * @param container    容器,放置敌人飞机的容器
     * @param levelMapView 关卡地图视图
     * @param callback     回调方法(敌机摧毁后的回调)
     */
    public EnemyPlane(Pane container, ReLoopImageView levelMapView, CustomEvent callback) {
        this.container = container;
        this.levelMapView = levelMapView;
        this.callback = callback;
    }

    // -------------------- 生成敌机 Begin --------------------

    /**
     * 生成敌机
     * 使用一个时间轴定时任务,每隔 2 秒生成一个敌机
     */
    public void startGenerate() {
        this.timeline = new Timeline(new KeyFrame(Duration.seconds(2), event -> createEnemyPlane()));
        timeline.setCycleCount(Timeline.INDEFINITE);
        timeline.play();
    }

    /**
     * 停止生成敌机
     */
    public void stopGenerate() {
        if (timeline != null) {
            timeline.stop();
            log.debug("已停止生成敌机");
        }
    }

    /**
     * 生成敌机
     */
    private void createEnemyPlane() {
        // 确定敌机的生成位置
        double startX = 0;
        if (isLeft) {
            isLeft = false;
            startX = random.nextDouble() * (container.getWidth() / 2 - levelMapView.getFitWidth()); // 右侧
        } else {
            isLeft = true;
            startX = random.nextDouble() * (-container.getWidth() / 2 + levelMapView.getFitWidth()); // 左侧
        }

        // 创建敌机并设置其初始属性
        ImageView enemyPlaneView = new ImageView(new Image(AppAssets.IMAGES.get("plane_enemy_" + (random.nextInt(4) + 1))));
        enemyPlaneView.setTranslateX(startX);
        enemyPlaneView.setTranslateY(-container.getHeight()); // 初始位置在屏幕下方

        NodeMetaData nodeMetaData = new NodeMetaData();
        nodeMetaData.setName("enemyPlane");
        nodeMetaData.setHealth(random.nextInt(6) + 3); // 设置敌机的初始生命值
        enemyPlaneView.setUserData(nodeMetaData);

        // 将敌机添加到容器中
        container.getChildren().add(enemyPlaneView);
        log.debug("生成敌机 x:{}", startX);

        // 设置敌机的移动动画
        TranslateTransition transition = new TranslateTransition(Duration.seconds(random.nextInt(15) + 10), enemyPlaneView);
        transition.setToY(container.getHeight() + 50); // 敌机移动到屏幕顶部外
        transition.setOnFinished(event -> container.getChildren().remove(enemyPlaneView)); // 敌机飞出屏幕后移除
        transition.play();

        // 启用碰撞检测
        detection(enemyPlaneView);
    }

    // -------------------- 碰撞检测 Begin --------------------

    /**
     * 碰撞检测
     */
    private void detection(ImageView enemyPlaneView) {
        Timeline collisionCheck = new Timeline(new KeyFrame(Duration.millis(20), event -> {
            List<ImageView> bulletsToRemove = new ArrayList<>();
            for (javafx.scene.Node node : container.getChildren()) {
                if (node instanceof ImageView bullet && node != enemyPlaneView) {
                    try {
                        if (!Objects.isNull(bullet.getUserData())) {
                            NodeMetaData bulletUserData = (NodeMetaData) bullet.getUserData();
                            if (!Objects.isNull(bulletUserData) && bulletUserData.getName().equals("playerBullet")) {
                                NodeMetaData enemyUserData = (NodeMetaData) enemyPlaneView.getUserData();
                                int enemyHealth = enemyUserData.getHealth();
                                if (enemyHealth > 0) {
                                    if (enemyPlaneView.getBoundsInParent().intersects(bullet.getBoundsInParent())) {
                                        bulletsToRemove.add(bullet);
                                        decreaseHealth(enemyPlaneView);
                                        break;
                                    }
                                }
                            }
                        }
                    } catch (Exception ignored) {
                    }
                }
            }
            container.getChildren().removeAll(bulletsToRemove);
        }));
        collisionCheck.setCycleCount(Timeline.INDEFINITE);
        collisionCheck.play();
    }

    /**
     * 减少敌机生命值
     *
     * @param enemyPlaneView 敌机视图
     */
    private void decreaseHealth(ImageView enemyPlaneView) {
        NodeMetaData enemyUserData = (NodeMetaData) enemyPlaneView.getUserData();
        int enemyHealth = enemyUserData.getHealth();
        enemyHealth--;
        enemyUserData.setHealth(enemyHealth);
        if (enemyHealth <= 0) {
            enemyUserData.setName(""); // 清空名称防止碰撞
            explode(enemyPlaneView);
            callback.onCallback();
        }
        enemyPlaneView.setUserData(enemyUserData);
    }

    /**
     * 敌机爆炸效果
     */
    private void explode(ImageView enemyPlaneView) {
        Mp3Util.playOnce(AppAssets.AUDIOS.get("effect_explosion_type_1"));
        Timeline explosionTimeline = new Timeline(
                new KeyFrame(Duration.ZERO, e -> enemyPlaneView.setImage(new Image(AppAssets.IMAGES.get("explosion1")))),
                new KeyFrame(Duration.seconds(0.1), e -> enemyPlaneView.setImage(new Image(AppAssets.IMAGES.get("explosion2")))),
                new KeyFrame(Duration.seconds(0.2), e -> enemyPlaneView.setImage(new Image(AppAssets.IMAGES.get("explosion3")))),
                new KeyFrame(Duration.seconds(0.3), e -> enemyPlaneView.setImage(new Image(AppAssets.IMAGES.get("explosion4")))),
                new KeyFrame(Duration.seconds(0.4), e -> enemyPlaneView.setImage(new Image(AppAssets.IMAGES.get("explosion5")))),
                new KeyFrame(Duration.seconds(0.5), e -> enemyPlaneView.setImage(new Image(AppAssets.IMAGES.get("explosion6")))),
                new KeyFrame(Duration.seconds(0.6), e -> container.getChildren().remove(enemyPlaneView))
        );
        explosionTimeline.play();
    }

}

game/level

  • GameLevel.java

package com.lusifer.airplane.game.level;

import com.lusifer.airplane.component.ReLoopImageView;
import com.lusifer.airplane.game.enemy.BossPlane;
import com.lusifer.airplane.game.enemy.EnemyPlane;
import com.lusifer.airplane.game.player.PlayerPlane;
import com.lusifer.airplane.game.record.GameScore;
import com.lusifer.airplane.utils.Mp3Util;
import com.lusifer.airplane.utils.ScreenUtil;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.stage.Stage;
import lombok.extern.slf4j.Slf4j;

/**
 * 游戏关卡
 *
 * @author Lusifer
 * @since 2024/6/26
 */
@Slf4j
public abstract class GameLevel {

    protected Stage stage;
    protected StackPane container;
    protected Scene scene;

    protected ReLoopImageView levelMapView;
    protected Mp3Util mp3;
    protected PlayerPlane player;
    protected EnemyPlane enemy;
    protected BossPlane boss;

    protected Label scoreLabel;

    /**
     * 初始化游戏关卡
     *
     * @param stage 窗口
     */
    public GameLevel(Stage stage) {
        log.debug("进入游戏关卡");
        this.stage = stage;

        initScene();
    }

    /**
     * 初始化场景
     */
    private void initScene() {
        container = new StackPane();

        scene = new Scene(container);

        stage.setScene(scene);
        stage.setFullScreen(ScreenUtil.isFullscreen());

        initBackground();
        initLevelMap();
        initBGM();
        initPlayer();
        initRecord();
    }

    // -------------------- 场景细节 Begin --------------------

    /**
     * 初始化背景
     */
    protected abstract void initBackground();

    /**
     * 初始化关卡地图
     */
    protected abstract void initLevelMap();

    /**
     * 初始化背景音乐
     */
    protected abstract void initBGM();

    /**
     * 初始化玩家
     */
    protected abstract void initPlayer();

    /**
     * 初始化游戏计分
     */
    protected void initRecord() {
        scoreLabel = new Label(String.valueOf(GameScore.score));
        scoreLabel.setFont(Font.font("微软雅黑", FontWeight.BOLD, 24));
        scoreLabel.setTextFill(Color.YELLOW);
        scoreLabel.setTranslateX(-levelMapView.getFitWidth() / 2 + 50);
        scoreLabel.setTranslateY(-scene.getHeight() / 2 + 50);
        container.getChildren().add(scoreLabel);
    }

}
  • GameLevel1.java

package com.lusifer.airplane.game.level;

import com.lusifer.airplane.component.ReLoopImageView;
import com.lusifer.airplane.component.ReVideoView;
import com.lusifer.airplane.config.AppAssets;
import com.lusifer.airplane.game.enemy.EnemyPlane;
import com.lusifer.airplane.game.menu.StartMenuScene;
import com.lusifer.airplane.game.player.PlayerPlane;
import com.lusifer.airplane.game.record.GameScore;
import com.lusifer.airplane.utils.Mp3Util;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.stage.Stage;
import javafx.util.Duration;
import lombok.extern.slf4j.Slf4j;

/**
 * 游戏关卡(第一关)
 *
 * @author Lusifer
 * @since 2024/6/26
 */
@Slf4j
public class GameLevel1 extends GameLevel {

    /**
     * 初始化游戏关卡
     *
     * @param stage 窗口
     */
    public GameLevel1(Stage stage) {
        super(stage);
    }

    @Override
    protected void initBackground() {
        Image image = new Image(AppAssets.IMAGES.get("bg_game_1"));
        ImageView imageView = new ImageView(image);

        // 图片大小自适应窗口
        imageView.fitWidthProperty().bind(stage.widthProperty());
        imageView.fitHeightProperty().bind(stage.heightProperty());

        container.getChildren().add(imageView);
    }

    @Override
    protected void initLevelMap() {
        levelMapView = new ReLoopImageView(stage, container, AppAssets.IMAGES.get("map_level_1"));
    }

    @Override
    protected void initBGM() {
        mp3 = new Mp3Util(AppAssets.AUDIOS.get("bgm_level_1"));
        mp3.setLoop(true);
        mp3.play();
    }

    @Override
    protected void initPlayer() {
        // 创建玩家,最后一个参数为玩家被摧毁后的回调
        player = new PlayerPlane(scene, container, levelMapView, () -> {
            log.debug("玩家被摧毁");

            // 回到菜单界面
            Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(3), new EventHandler<ActionEvent>() {
                @Override
                public void handle(ActionEvent actionEvent) {
                    mp3.stop();
                    enemy.stopGenerate();
                    new StartMenuScene(stage);
                }
            }));
            // 设置定时器播放一次后停止
            timeline.setCycleCount(1);
            timeline.play();
        });
        player.in(() -> {
            log.debug("玩家进入战场");
            // 创建敌机,最后一个参数为敌机被摧毁后的回调
            enemy = new EnemyPlane(container, levelMapView, () -> {
                log.debug("敌机被摧毁");
                GameScore.add();
                scoreLabel.setText(String.valueOf(GameScore.score));

                if (GameScore.score == 1000) {
                    player.out(() -> {
                        log.debug("玩家离开战场");
                        mp3.stop();
                        enemy.stopGenerate();

                        // 进入下一关
                        new ReVideoView(stage, AppAssets.VIDEOS.get("movie_story_2"), () -> {
                            new GameLevel2(stage);
                        });
                    });
                }
            });
            enemy.startGenerate(); // 生成敌机
        });
    }
}
  • GameLevel3.java

package com.lusifer.airplane.game.level;

import com.lusifer.airplane.component.ReLoopImageView;
import com.lusifer.airplane.component.ReVideoView;
import com.lusifer.airplane.config.AppAssets;
import com.lusifer.airplane.game.enemy.BossPlane;
import com.lusifer.airplane.game.enemy.EnemyPlane;
import com.lusifer.airplane.game.menu.StartMenuScene;
import com.lusifer.airplane.game.player.PlayerPlane;
import com.lusifer.airplane.game.record.GameScore;
import com.lusifer.airplane.utils.Mp3Util;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.stage.Stage;
import javafx.util.Duration;
import lombok.extern.slf4j.Slf4j;

/**
 * 游戏关卡(第三关)
 *
 * @author Lusifer
 * @since 2024/6/26
 */
@Slf4j
public class GameLevel3 extends GameLevel {

    /**
     * 初始化游戏关卡
     *
     * @param stage 窗口
     */
    public GameLevel3(Stage stage) {
        super(stage);
    }

    @Override
    protected void initBackground() {
        Image image = new Image(AppAssets.IMAGES.get("bg_game_3"));
        ImageView imageView = new ImageView(image);

        // 图片大小自适应窗口
        imageView.fitWidthProperty().bind(stage.widthProperty());
        imageView.fitHeightProperty().bind(stage.heightProperty());

        container.getChildren().add(imageView);
    }

    @Override
    protected void initLevelMap() {
        levelMapView = new ReLoopImageView(stage, container, AppAssets.IMAGES.get("map_level_4"));
    }

    @Override
    protected void initBGM() {
        mp3 = new Mp3Util(AppAssets.AUDIOS.get("bgm_level_3"));
        mp3.setLoop(true);
        mp3.play();
    }

    @Override
    protected void initPlayer() {
        // 创建玩家,最后一个参数为玩家被摧毁后的回调
        player = new PlayerPlane(scene, container, levelMapView, () -> {
            log.debug("玩家被摧毁");

            // 回到菜单界面
            Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(3), new EventHandler<ActionEvent>() {
                @Override
                public void handle(ActionEvent actionEvent) {
                    mp3.stop();
                    enemy.stopGenerate();
                    boss.stop();
                    new StartMenuScene(stage);
                }
            }));
            // 设置定时器播放一次后停止
            timeline.setCycleCount(1);
            timeline.play();
        });
        player.in(() -> {
            log.debug("玩家进入战场");
            // 创建敌机,最后一个参数为敌机被摧毁后的回调
            enemy = new EnemyPlane(container, levelMapView, () -> {
                log.debug("敌机被摧毁");
                GameScore.add();
                scoreLabel.setText(String.valueOf(GameScore.score));

                if (GameScore.score == 3000) {
                    // BOSS 战
                    mp3.stop();
                    mp3.setFilePath(AppAssets.AUDIOS.get("bgm_boss"));
                    mp3.play();
                    boss = new BossPlane(container, levelMapView, () -> {
                        GameScore.score += 5000;
                        player.out(() -> {
                            log.debug("玩家离开战场");
                            mp3.stop();
                            boss.stop();
                            enemy.stopGenerate();

                            // 进入下一关
                            new ReVideoView(stage, AppAssets.VIDEOS.get("movie_story_end"), () -> {
                                new StartMenuScene(stage);
                            });
                        });
                    });
                }
            });
            enemy.startGenerate(); // 生成敌机
        });
    }
}

game/menu

  • StartMenuScene.java

package com.lusifer.airplane.game.menu;

import com.lusifer.airplane.component.ReVideoView;
import com.lusifer.airplane.config.AppAssets;
import com.lusifer.airplane.game.level.GameLevel1;
import com.lusifer.airplane.game.record.GameScore;
import com.lusifer.airplane.utils.Mp3Util;
import com.lusifer.airplane.utils.ScreenUtil;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.stage.Stage;
import lombok.extern.slf4j.Slf4j;

/**
 * 开始菜单
 *
 * @author Lusifer
 * @since 2024/6/25
 */
@Slf4j
public class StartMenuScene {

    private final Stage stage;
    private StackPane container;

    private Mp3Util mp3;

    /**
     * 初始化开始菜单
     *
     * @param stage 窗口
     */
    public StartMenuScene(Stage stage) {
        log.debug("进入启动菜单");
        this.stage = stage;

        initScene();
    }

    /**
     * 初始化场景
     */
    private void initScene() {
        container = new StackPane();

        Scene scene = new Scene(container);

        stage.setScene(scene);
        stage.setFullScreen(ScreenUtil.isFullscreen());

        initBackground();
        initCopyright();
        initMenu();
        initBGM();
    }

    // -------------------- 场景细节 Begin --------------------

    /**
     * 初始化背景
     */
    private void initBackground() {
        Image image = new Image(AppAssets.IMAGES.get("bg_menu_start"));
        ImageView imageView = new ImageView(image);

        // 图片大小自适应窗口
        imageView.fitWidthProperty().bind(stage.widthProperty());
        imageView.fitHeightProperty().bind(stage.heightProperty());

        container.getChildren().add(imageView);
    }

    /**
     * 初始化底部版权
     */
    private void initCopyright() {
        HBox hBox = new HBox();
        hBox.setAlignment(Pos.BOTTOM_CENTER);
        hBox.setPadding(new Insets(20));

        Label label = new Label("Copyright 2024 源本科技李卫民. All Rights Reserved.");
        label.setFont(Font.font("微软雅黑", FontWeight.BOLD, 28));
        label.setTextFill(Color.YELLOW);

        hBox.getChildren().add(label);

        container.getChildren().add(hBox);
    }

    /**
     * 初始化菜单
     */
    private void initMenu() {
        VBox vBox = new VBox();
        vBox.setAlignment(Pos.CENTER);
        vBox.setPadding(new Insets(200));

        vBox.getChildren().add(createButton("拯救地球", mouseEvent -> {
            GameScore.score = 0; // 计分清零
            mp3.stop();

            // 进入游戏关卡
            new ReVideoView(stage, AppAssets.VIDEOS.get("movie_story_1"), () -> {
                new GameLevel1(stage);
            });
        }));
        vBox.getChildren().add(createButton("择日再战", mouseEvent -> {
            System.exit(0);
        }));

        container.getChildren().add(vBox);
    }

    /**
     * 初始化背景音乐
     */
    private void initBGM() {
        mp3 = new Mp3Util(AppAssets.AUDIOS.get("bgm_menu"));
        mp3.setLoop(true);
        mp3.play();
    }

    private Label createButton(String text, EventHandler<MouseEvent> event) {
        Label label = new Label(text);

        label.setStyle("-fx-padding: 0 0 25 0; -fx-effect: dropshadow(one-pass-box, black, 20, 0.0, 0, 0);");
        label.setFont(Font.font("微软雅黑", FontWeight.BOLD, 56));
        label.setTextFill(Color.WHITE);
        label.setOnMouseClicked(event);

        return label;
    }

}

game/player

  • PlayerPlane.java

package com.lusifer.airplane.game.player;

import com.lusifer.airplane.common.CustomEvent;
import com.lusifer.airplane.component.ReLoopImageView;
import com.lusifer.airplane.config.AppAssets;
import com.lusifer.airplane.game.NodeMetaData;
import com.lusifer.airplane.utils.Mp3Util;
import com.lusifer.airplane.utils.ScreenUtil;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.SequentialTransition;
import javafx.animation.Timeline;
import javafx.animation.TranslateTransition;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.Pane;
import javafx.util.Duration;
import lombok.extern.slf4j.Slf4j;

import java.util.Objects;

/**
 * 玩家飞机类
 *
 * @author Lusifer
 * @since 2024/6/26
 */
@Slf4j
public class PlayerPlane {

    private final Scene scene;
    private final Pane container;
    private final ReLoopImageView levelMapView;

    private final Image playerPlaneImage;
    private final ImageView playerPlaneView;

    private final CustomEvent event;

    private boolean spacePressed = false; // 标识空格键是否被按下

    private int health = 1; // 玩家生命值

    private boolean isOut = false; // 是否为离开状态(如果为离开状态则表示无敌不启用碰撞检测)
    private boolean isBroker = false; // 是否为摧毁状态

    /**
     * 飞机的移动速度
     */
    public static final int SPEED = 5;

    /**
     * 初始化玩家飞机
     *
     * @param scene        场景
     * @param container    容器,放置玩家飞机的容器
     * @param levelMapView 关卡地图视图
     * @param event        玩家被摧毁的事件
     */
    public PlayerPlane(Scene scene, Pane container, ReLoopImageView levelMapView, CustomEvent event) {
        this.scene = scene;
        this.container = container;
        this.levelMapView = levelMapView;
        this.event = event;

        this.playerPlaneImage = new Image(AppAssets.IMAGES.get("plane_player_1"));
        this.playerPlaneView = new ImageView(playerPlaneImage);
    }

    // -------------------- 飞机动画 Begin --------------------

    /**
     * 飞机入场动画
     *
     * @param callback 回调方法(飞机入场后的回调)
     */
    public void in(CustomEvent callback) {
        // 播放飞机飞过的声音
        Mp3Util.playOnce(AppAssets.AUDIOS.get("effect_plane_flying_overhead"));

        // 设置初始位置为屏幕下方
        playerPlaneView.setTranslateY(ScreenUtil.getHeight());
        container.getChildren().add(playerPlaneView);

        // 实现飞机从屏幕下方飞到屏幕中间
        TranslateTransition flyInTransition = new TranslateTransition(Duration.seconds(2), playerPlaneView);
        flyInTransition.setFromY(ScreenUtil.getHeight());
        flyInTransition.setToY(0);
        flyInTransition.setInterpolator(Interpolator.EASE_OUT);

        // 实现飞机从屏幕中间飞到底部居中
        TranslateTransition flyDownTransition = new TranslateTransition(Duration.seconds(2), playerPlaneView);
        flyDownTransition.setFromY(0);
        flyDownTransition.setToY(ScreenUtil.getHeight() / 2 - playerPlaneImage.getHeight() / 2 - 20);
        flyDownTransition.setInterpolator(Interpolator.EASE_IN);

        // 创建 SequentialTransition 将两个 TranslateTransition 连接起来
        SequentialTransition sequentialTransition = new SequentialTransition(flyInTransition, flyDownTransition);
        sequentialTransition.setOnFinished(event -> {
            // 初始化键盘控制
            initPlayerControl();
            // 初始化飞机移动
            initPlayerAnimation();
            // 调用回调
            callback.onCallback();
            // 启用碰撞
            detection();
        });

        // 播放动画
        sequentialTransition.play();
    }

    /**
     * 飞机出场动画
     *
     * @param callback 回调方法(飞机出场后的回调)
     */
    public void out(CustomEvent callback) {
        // 飞离状态不启用碰撞检测
        isOut = true;
        // 移除键盘监听
        scene.removeEventHandler(KeyEvent.KEY_PRESSED, keyPressHandler);

        // 获取当前飞机的位置
        double currentY = playerPlaneView.getTranslateY();

        // 飞机飞到屏幕顶部外
        TranslateTransition flyOutTransition = new TranslateTransition(Duration.seconds(2), playerPlaneView);
        flyOutTransition.setFromY(currentY);
        flyOutTransition.setToY(-scene.getHeight() - 100);
        flyOutTransition.setInterpolator(Interpolator.EASE_IN);
        flyOutTransition.setOnFinished(event -> {
            // 移除飞机
            container.getChildren().remove(playerPlaneView);
            // 调用回调
            callback.onCallback();
        });

        // 播放动画
        flyOutTransition.play();
    }

    // -------------------- 飞机控制 Begin --------------------

    /**
     * 键盘按键事件处理程序,处理方向键和空格键的按下事件
     */
    private final EventHandler<KeyEvent> keyPressHandler = new EventHandler<KeyEvent>() {
        @Override
        public void handle(KeyEvent event) {
            KeyCode keyCode = event.getCode();
            if (keyCode == KeyCode.UP) {
                playerPlaneView.setUserData(KeyCode.UP);
            } else if (keyCode == KeyCode.DOWN) {
                playerPlaneView.setUserData(KeyCode.DOWN);
            } else if (keyCode == KeyCode.LEFT) {
                playerPlaneView.setUserData(KeyCode.LEFT);
            } else if (keyCode == KeyCode.RIGHT) {
                playerPlaneView.setUserData(KeyCode.RIGHT);
            } else if (keyCode == KeyCode.SPACE && !spacePressed) {
                spacePressed = true;
            }
        }
    };

    /**
     * 初始化键盘控制,添加键盘事件监听器
     */
    private void initPlayerControl() {
        scene.addEventHandler(KeyEvent.KEY_PRESSED, keyPressHandler);
        scene.setOnKeyReleased(event -> {
            KeyCode keyCode = event.getCode();
            if (keyCode == KeyCode.UP && playerPlaneView.getUserData() == KeyCode.UP) {
                playerPlaneView.setUserData(null);
            } else if (keyCode == KeyCode.DOWN && playerPlaneView.getUserData() == KeyCode.DOWN) {
                playerPlaneView.setUserData(null);
            } else if (keyCode == KeyCode.LEFT && playerPlaneView.getUserData() == KeyCode.LEFT) {
                playerPlaneView.setUserData(null);
            } else if (keyCode == KeyCode.RIGHT && playerPlaneView.getUserData() == KeyCode.RIGHT) {
                playerPlaneView.setUserData(null);
            } else if (keyCode == KeyCode.SPACE) {
                spacePressed = false; // 释放空格键
                fire(); // 发射子弹
            }
        });
    }

    /**
     * 创建飞机移动动画的时间线,根据方向键的按下状态移动飞机
     */
    private void initPlayerAnimation() {
        Timeline timeline = new Timeline(new KeyFrame(Duration.millis(16), event -> {
            KeyCode keyCode = (KeyCode) playerPlaneView.getUserData();
            if (keyCode != null) {
                double newX = playerPlaneView.getTranslateX();
                double newY = playerPlaneView.getTranslateY();
                switch (keyCode) {
                    case UP:
                        newY -= SPEED;
                        break;
                    case DOWN:
                        newY += SPEED;
                        break;
                    case LEFT:
                        newX -= SPEED;
                        break;
                    case RIGHT:
                        newX += SPEED;
                        break;
                    default:
                        break;
                }

                double imageWidth = playerPlaneView.getImage().getWidth();
                double imageHeight = playerPlaneView.getImage().getHeight();
                double minX = -levelMapView.getFitWidth() / 2 + imageWidth / 2;
                double maxX = levelMapView.getFitWidth() / 2 - imageWidth / 2;
                double minY = -ScreenUtil.getHeight() / 2 + imageHeight / 2;
                double maxY = ScreenUtil.getHeight() / 2 - imageHeight / 2;

                if (newX >= minX && newX <= maxX) {
                    playerPlaneView.setTranslateX(newX);
                }
                if (newY >= minY && newY <= maxY) {
                    playerPlaneView.setTranslateY(newY);
                }

                log.debug("玩家坐标 x:{} y:{}", newX, newY);
            }
        }));
        timeline.setCycleCount(Timeline.INDEFINITE); // 设置时间线无限循环
        timeline.play();
    }

    // -------------------- 飞机子弹 Begin --------------------

    /**
     * 发射子弹
     */
    private void fire() {
        if (isBroker) { // 摧毁状态无需发射子弹
            return;
        }

        // 创建子弹的图片视图
        ImageView bullet = new ImageView(new Image(AppAssets.IMAGES.get("bullet_player_type_a_1")));
        double bulletHeight = bullet.getImage().getHeight();

        // 计算子弹的初始位置
        double startX = playerPlaneView.getTranslateX();
        double startY = playerPlaneView.getTranslateY() - (playerPlaneImage.getHeight() / 2) + (bulletHeight / 2) - 25;

        log.debug("玩家子弹坐标 x:{} y:{}", startX, startY);

        // 创建子弹的图片视图
        NodeMetaData nodeMetaData = new NodeMetaData();
        nodeMetaData.setName("playerBullet");
        bullet.setUserData(nodeMetaData);  // 设置用户数据标识为玩家子弹,用于碰撞检测
        bullet.setTranslateX(startX);
        bullet.setTranslateY(startY);
        container.getChildren().add(bullet);  // 将子弹添加到容器中

        // 播放子弹发射的声音
        Mp3Util.playOnce(AppAssets.AUDIOS.get("effect_bullet_hero_type_a_1"));

        // 创建子弹的移动动画
        TranslateTransition transition = new TranslateTransition(Duration.seconds(2), bullet);
        transition.setToY(-400);  // 设置子弹的终点位置
//        transition.setToY(-ScreenUtil.getHeight());  // 设置子弹的终点位置
        transition.setOnFinished(event -> container.getChildren().remove(bullet));  // 子弹飞出屏幕后移除
        transition.play();  // 播放动画
    }

    // -------------------- 碰撞检测 Begin --------------------

    /**
     * 碰撞检测
     */
    private void detection() {
        Timeline collisionCheck = new Timeline(new KeyFrame(Duration.millis(20), event -> {
            for (javafx.scene.Node node : container.getChildren()) {
                if (node instanceof ImageView enemy) {
                    try {
                        if (!Objects.isNull(enemy.getUserData())) {
                            NodeMetaData enemyUserData = (NodeMetaData) enemy.getUserData();
                            if (!Objects.isNull(enemyUserData)
                                    && (
                                    enemyUserData.getName().equals("enemyPlane")
                                            || enemyUserData.getName().equals("bossPlane")
                                            || enemyUserData.getName().equals("bossBullet"))
                                    && !isOut) {
                                if (health > 0) {
                                    if (playerPlaneView.getBoundsInParent().intersects(enemy.getBoundsInParent())) {
                                        decreaseHealth();
                                        break;
                                    }
                                }
                            }
                        }
                    } catch (Exception ignored) {
                    }
                }
            }
        }));
        collisionCheck.setCycleCount(Timeline.INDEFINITE);
        collisionCheck.play();
    }

    /**
     * 减少玩家生命值
     */
    private void decreaseHealth() {
        health--;
        if (health <= 0) {
            // 移除键盘监听
            isBroker = true;
            scene.removeEventHandler(KeyEvent.KEY_PRESSED, keyPressHandler);
            explode();
            event.onCallback();
        }
    }

    /**
     * 玩家爆炸效果
     */
    private void explode() {
        Mp3Util.playOnce(AppAssets.AUDIOS.get("effect_explosion_type_1"));
        Timeline explosionTimeline = new Timeline(
                new KeyFrame(Duration.ZERO, e -> playerPlaneView.setImage(new Image(AppAssets.IMAGES.get("player_bom_1")))),
                new KeyFrame(Duration.seconds(0.1), e -> playerPlaneView.setImage(new Image(AppAssets.IMAGES.get(
                        "player_bom_2")))),
                new KeyFrame(Duration.seconds(0.2), e -> playerPlaneView.setImage(new Image(AppAssets.IMAGES.get(
                        "player_bom_3")))),
                new KeyFrame(Duration.seconds(0.3), e -> playerPlaneView.setImage(new Image(AppAssets.IMAGES.get(
                        "player_bom_4")))),
                new KeyFrame(Duration.seconds(0.6), e -> container.getChildren().remove(playerPlaneView))
        );
        explosionTimeline.play();
    }

}

game/record

  • GameScore.java

package com.lusifer.airplane.game.record;

/**
 * 游戏计分板
 *
 * @author Lusifer
 * @since 2024/6/26
 */
public class GameScore {

    private GameScore() {

    }

    /**
     * 积分默认从 0 开始
     */
    public static int score = 0;

    /**
     * 追加分数
     */
    public static void add() {
        score += 100;
    }

}

game

  • NodeMetaData.java

package com.lusifer.airplane.game;

import lombok.Data;

/**
 * 元素元数据
 *
 * @author Lusifer
 * @since 2024/6/26
 */
@Data
public class NodeMetaData {

    /**
     * 名称
     */
    private String name;

    /**
     * 健康值
     */
    private Integer health;

}

utils

  • Mp3Util.java

package com.lusifer.airplane.utils;

import javafx.scene.media.Media;
import javafx.scene.media.MediaPlayer;
import javafx.util.Duration;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;

import java.io.File;
import java.net.URI;

/**
 * MP3 播放工具
 *
 * @author Lusifer
 * @since 2024/6/26
 */
@Slf4j
@Setter
public class Mp3Util {

    private MediaPlayer player;
    private boolean isPaused;
    private Duration pausedTime;
    private boolean loop;
    private String filePath;

    public Mp3Util(String filePath) {
        this.filePath = filePath;
    }

    public void play() {
        if (isPaused) {
            resume();
            return;
        }

        stop();

        URI uri = new File(filePath).toURI();
        Media media = new Media(uri.toString());
        player = new MediaPlayer(media);

        player.setOnEndOfMedia(() -> {
            if (!isPaused) {
                if (loop) {
                    play();  // Recursive call to replay the audio
                } else {
                    pausedTime = Duration.ZERO;
                }
            }
        });

        player.setOnError(() -> log.debug(player.getError().getMessage()));

        player.play();
    }

    public void pause() {
        if (player != null) {
            isPaused = true;
            pausedTime = player.getCurrentTime();
            player.pause();
        }
    }

    public void resume() {
        if (isPaused) {
            isPaused = false;
            player.seek(pausedTime);
            player.play();
        }
    }

    public void stop() {
        if (player != null) {
            player.stop();
            pausedTime = Duration.ZERO;
            isPaused = false;
        }
    }

    public boolean isPlaying() {
        return player != null && player.getStatus() == MediaPlayer.Status.PLAYING;
    }

    public static void playOnce(String filePath) {
        URI uri = new File(filePath).toURI();
        Media media = new Media(uri.toString());
        MediaPlayer staticPlayer = new MediaPlayer(media);
        staticPlayer.setOnError(() -> log.debug(staticPlayer.getError().getMessage()));
        staticPlayer.play();
    }

}
  • ScreenUtil.java

package com.lusifer.airplane.utils;

import com.lusifer.airplane.config.AppConfig;
import com.lusifer.airplane.config.ConfigLoader;
import javafx.geometry.Rectangle2D;
import javafx.stage.Screen;

/**
 * 屏幕工具
 *
 * @author Lusifer
 * @since 2024/6/25
 */
public class ScreenUtil {

    /**
     * 游戏配置
     */
    private static final AppConfig config = ConfigLoader.getInstance().getConfig();

    /**
     * 存储屏幕主显示器的边界矩形。
     * 此变量用于后续操作,以了解主显示器的尺寸和位置,以便进行窗口布局或其它需要考虑屏幕边界的操作。
     * 通过 Screen.getPrimary().getBounds() 进行初始化,获取的是主显示器的矩形区域,包括宽度、高度和位置。
     */
    public static final Rectangle2D bounds = Screen.getPrimary().getBounds();

    /**
     * 获取屏幕宽度
     *
     * @return 屏幕宽度
     */
    public static double getWidth() {
        if (isFullscreen()) {
            return bounds.getWidth();
        } else {
            return Double.parseDouble(config.getDisplay().get("width"));
        }
    }

    /**
     * 获取屏幕高度
     *
     * @return 屏幕高度
     */
    public static double getHeight() {
        if (isFullscreen()) {
            return bounds.getHeight();
        } else {
            return Double.parseDouble(config.getDisplay().get("height"));
        }
    }

    /**
     * 判断是否是全屏状态
     *
     * @return true 全屏/false 窗口
     */
    public static boolean isFullscreen() {
        return Boolean.parseBoolean(config.getDisplay().get("fullscreen"));
    }

}