从Unity到libGDX:用Java与ECS架构从零构建跨平台Roguelike游戏

从Unity到libGDX:用Java与ECS架构从零构建跨平台Roguelike游戏

1. 项目概述:为什么选择libGDX?

最近几年,游戏引擎领域可以说是Unity一家独大,尤其是在独立游戏和小型工作室里,它的可视化编辑器和庞大的资源商店让快速原型开发变得异常简单。但不知道你有没有这种感觉,当你的项目稍微复杂一点,或者你想对底层有更多控制时,Unity那套“黑盒”操作有时候会让人有点使不上劲。再加上这几年Unity的收费政策变动,让不少开发者心里犯嘀咕,开始寻找更透明、更可控的备选方案。

这就是我这次想聊聊libGDX的原因。简单来说,libGDX是一个基于Java的、开源的跨平台游戏开发框架。注意,我强调的是“框架”而不是“引擎”。这意味着它不会给你一个现成的、拖拖拽拽就能出成品的编辑器,但它给了你一套极其强大和灵活的工具箱,让你能从更底层的地方开始构建你的游戏逻辑。你可以把它想象成乐高积木,而不是一个已经拼好的模型。你需要自己设计结构、组装模块,但最终成品的每一个细节都完全由你掌控。

我这次的目标很明确:不用Unity,就用libGDX和纯Java,从零开始撸一个跨平台的小游戏。我选择做一个简化版的、带有Roguelike元素的动作游戏,类似元气骑士那种俯视角地牢射击的雏形。为什么选这个类型?因为它麻雀虽小五脏俱全,涵盖了2D图形渲染、精灵动画、物理碰撞(哪怕是简单的)、输入处理、状态管理、随机地图生成等核心游戏开发概念,非常适合用来展示libGDX的能力。

这个项目最终会生成一个完整的、可运行的桌面(Windows/macOS/Linux)版本,并且,得益于libGDX的跨平台设计,同样的代码几乎可以无缝地打包成Android APK、iOS应用甚至HTML5网页游戏。我会把完整的源码附在最后,你可以直接拿去运行、修改,甚至作为你自己项目的起点。

2. 环境搭建与项目初始化

2.1 工具链准备:不止是JDK

工欲善其事,必先利其器。用libGDX开发,基础环境其实很简单,但有几个关键点需要注意。

首先,你需要安装Java开发工具包(JDK)。我强烈推荐使用JDK 17 LTS版本。这是目前长期支持版本中在性能和兼容性上比较均衡的选择。太老的版本(比如JDK 8)可能会缺失一些新特性,而太新的非LTS版本(比如JDK 21)则可能在个别依赖库上遇到兼容性问题。去Oracle官网或者Adoptium(Eclipse Temurin)下载安装即可。安装后,记得在命令行输入java -versionjavac -version来确认安装成功。

其次,你需要一个构建工具。libGDX官方推荐使用Gradle。Gradle负责管理项目依赖(比如libGDX自身以及各种扩展库)、编译代码、打包成品。你不需要单独安装Gradle,因为libGDX的项目模板会自带一个Gradle Wrapper(gradlewgradlew.bat),它会自动下载并使用正确版本的Gradle。这对团队协作和跨环境构建至关重要,确保每个人用的构建工具版本一致。

最后是集成开发环境(IDE)。IntelliJ IDEA(社区版免费)是对Java和Gradle支持最好的IDE,没有之一。它的智能提示、代码导航和Gradle任务集成能极大提升开发效率。当然,如果你习惯用Eclipse或VS Code,配合相应插件也能工作,但IDEA的体验是最流畅的。

注意:尽量避免使用IDE自带的、过于“智能”的Gradle导入功能。我建议的做法是,先用命令行或脚本生成项目,然后用IDE的“Open”或“Import”功能,选择项目根目录下的build.gradle文件来打开项目。让IDE识别这是一个Gradle项目,而不是自己乱创建项目结构。

2.2 使用gdx-setup快速生成项目

libGDX官方提供了一个非常方便的项目生成工具。以前是个Jar包,现在更主流的方式是使用它的在线生成器或者一个轻量级的工具。

最省事的方法是访问官方的 gdx-setup 网页生成器 。在网页上,你可以像填表单一样配置你的项目:

  • Name: 你的游戏名称,比如MyRoguelikeGame
  • Package: 你的Java包名,遵循反向域名规则,如com.mygame.roguelike
  • Game Class: 你的主游戏类名,比如RogueLikeGame
  • Destination: 选择项目生成到本地哪个文件夹。
  • libGDX Version: 选择最新的稳定版(如1.14.2)。
  • Sub Projects: 这是关键!libGDX采用多模块项目结构。你需要勾选你目标发布的平台。
    • Desktop: 必选,用于开发调试和发布到Windows/macOS/Linux。
    • Android: 如果你要发布到安卓手机。
    • iOS: 如果你有Mac电脑并想发布到iPhone/iPad(需要额外配置)。
    • Html: 如果你想发布成网页游戏(运行在浏览器中)。
  • Extensions: 扩展库。对于我们的2D游戏,我强烈建议勾选Box2D(用于更真实的物理碰撞)和Ashley(一个轻量级且高效的实体组件系统框架,ECS)。其他如Freetype(支持TTF字体)和Controllers(手柄支持)可以根据需要添加。

点击生成后,你会下载到一个Zip包,解压后就是一个完整的、配置好的Gradle多模块项目。

如果你更喜欢命令行,也可以使用官方的gdx-liftoff工具,但网页生成器对新手来说更直观。

2.3 项目结构深度解析

解压生成的项目后,你会看到类似下面的目录结构。理解这个结构对后续开发至关重要:

MyRoguelikeGame/ ├── core/ # 核心模块 - 你的主要游戏逻辑代码都写在这里! │ ├── build.gradle # Core模块的Gradle配置 │ └── src/com/mygame/roguelike/ │ ├── RogueLikeGame.java # 你的主游戏类,实现ApplicationListener │ └── ... (其他你创建的类) ├── desktop/ # 桌面平台模块 │ ├── build.gradle │ └── src/com/mygame/roguelike/desktop/ │ └── DesktopLauncher.java # 桌面程序入口,负责启动Core模块的游戏 ├── android/ # 安卓平台模块(如果勾选了) │ ├── build.gradle │ ├── assets/ # 安卓资源目录(图片、声音等) │ └── src/.../AndroidLauncher.java ├── html/ # HTML5/GWT模块(如果勾选了) │ └── ... (配置较复杂,涉及GWT编译) ├── ios/ # iOS模块(如果勾选了) │ └── ... (需要Xcode和RoboVM配置) ├── build.gradle # 根项目的Gradle配置,定义了所有子模块的公共依赖 └── settings.gradle # 声明包含哪些子模块

核心设计思想core模块是平台无关的纯游戏逻辑。它不知道也不关心自己最终是运行在Windows上还是手机上。desktopandroid等平台模块只负责两件事:1. 提供该平台特定的启动入口(Launcher);2. 将core模块打包成该平台可执行的形式。所有图片、声音等资源,理论上也应该放在core模块能访问到的路径(通常约定放在android/assets/目录下,然后在桌面和HTML5模块中创建符号链接或复制过去)。这种设计完美体现了“一次编写,到处运行”的跨平台理念。

现在,你可以在IDE里打开项目,找到desktop/src/.../DesktopLauncher.java,运行它的main方法。如果一切顺利,你应该能看到一个弹出一个黑色的窗口——恭喜,你的libGDX项目骨架已经成功运行了!

3. 核心架构设计:告别“面条代码”

在真正动手写代码前,花点时间设计架构是绝对值得的。很多新手(包括当年的我)容易犯的错误是,把所有逻辑都塞进主游戏类RogueLikeGame.java里,很快代码就会变成一团乱麻,难以维护和扩展。libGDX不强制你使用某种架构,但这正是考验开发者功力的地方。

3.1 采用实体组件系统(ECS)—— Ashley

我强烈推荐使用ECS架构,特别是对于哪怕是小型的、但逻辑稍复杂的游戏。libGDX社区广泛使用的ECS库是Ashley。我们在项目生成时已经勾选了它。

ECS的核心思想是将游戏对象(实体)的数据(组件)和行为(系统)分离。

  • 实体(Entity):只是一个ID,它本身不包含任何逻辑或数据。比如一个“玩家”、一个“子弹”、一个“敌人”。
  • 组件(Component):纯粹的数据结构。比如PositionComponent(x, y坐标)、VelocityComponent(速度向量)、SpriteComponent(纹理引用)、HealthComponent(生命值)。
  • 系统(System):包含逻辑的类,它只关心拥有特定组件组合的实体。比如MovementSystem会遍历所有拥有PositionComponentVelocityComponent的实体,在每帧更新他们的位置。

为什么用ECS?假设你要给游戏增加一个“中毒后屏幕变绿”的效果。在传统面向对象里,你可能要在Player类、Enemy类里都添加中毒状态和渲染变绿的代码。而在ECS里,你只需要创建一个PoisonedComponent(记录中毒时长),然后修改RenderSystem,让它检查实体是否有PoisonedComponent,如果有,就用绿色色调渲染。所有实体(玩家、敌人、甚至NPC)都能自动获得这个功能,代码耦合度极低。

在我们的Roguelike游戏中,我们可以设计以下组件和系统:

  • 组件TransformCom(位置、旋转、缩放),VelocityCom,SpriteCom,PlayerCom,EnemyCom,BulletCom,CollisionCom(碰撞形状),HealthCom,WeaponCom(攻击力、攻击间隔)。
  • 系统PlayerInputSystem(处理玩家键盘/触摸输入),MovementSystem(根据速度更新位置),EnemyAISystem(简单的敌人寻路逻辑),CollisionSystem(检测和处理碰撞),RenderingSystem(负责将所有SpriteCom绘制到屏幕上),WeaponSystem(处理攻击冷却和生成子弹实体)。

3.2 游戏状态管理与场景切换

即使是小游戏,也通常有多个“场景”,比如主菜单、游戏进行中、暂停界面、游戏结束界面。我们需要一个清晰的方式来管理它们。

一个简单有效的方法是创建一个GameStateManager(GSM)。它维护一个状态栈(Stack)。每个游戏状态(如MenuState,PlayState,PauseState)都是一个独立的类,实现统一的接口,例如:

public interface GameState { void enter(); // 进入该状态时调用 void update(float deltaTime); // 每帧更新逻辑 void render(SpriteBatch batch); // 每帧渲染 void exit(); // 退出该状态时调用 void handleInput(); // 处理输入 }

然后,在RogueLikeGame类的render方法中,我们不再直接写游戏逻辑,而是委托给GSM:

@Override public void render() { // 清屏 Gdx.gl.glClearColor(0, 0, 0, 1); Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); float deltaTime = Gdx.graphics.getDeltaTime(); // 更新当前顶部的游戏状态 stateManager.update(deltaTime); // 渲染当前顶部的游戏状态 stateManager.render(batch); }

当玩家点击“开始游戏”,我们就pushState(new PlayState());游戏暂停时,就pushState(new PauseState());从暂停返回游戏,就popState()。这样,每个状态的资源加载/卸载、逻辑、渲染都封装得很好,互不干扰。

3.3 资源管理:AssetManager的正确姿势

游戏离不开图片、声音、字体等资源。最糟糕的做法是在需要的时候直接new Texture(“image.png”),这会导致卡顿和内存管理混乱。libGDX提供了AssetManager来集中、异步地加载资源。

正确的做法是,在游戏启动时(比如在LoadingState中),预先声明所有需要加载的资源:

assetManager.load(“player.png”, Texture.class); assetManager.load(“enemy.png”, Texture.class); assetManager.load(“shot.wav”, Sound.class); assetManager.load(“game_font.ttf”, BitmapFont.class); // 需要freetype扩展

然后,在update方法中检查加载进度assetManager.update()。加载完成后,在任何需要的地方通过assetManager.get(“player.png”, Texture.class)来获取资源。AssetManager会确保相同的资源只加载一次,并在游戏结束时(或你手动调用assetManager.unload())统一释放。

实操心得:为不同类型的资源创建专门的“Holder”或“常量类”是个好习惯。比如创建一个Assets类,里面定义所有资源的路径常量,如public static final String PLAYER_TEXTURE = “player.png”;。这样,你在代码中引用资源时用的是Assets.PLAYER_TEXTURE,而不是散落在各处的魔法字符串,以后修改文件名只需改一个地方。

4. 从零实现核心游戏循环与模块

架构搭好了,现在我们开始填充血肉,实现一个简化版元气骑士的核心玩法。

4.1 渲染基石:SpriteBatch与相机(Camera)

libGDX的2D渲染核心是SpriteBatchCamera。你可以把SpriteBatch想象成一个高效的画师,它负责将纹理(图片)绘制到屏幕上。而Camera就是画师的眼睛,决定了你看到游戏世界的哪一部分。

在我们的俯视角游戏中,通常使用OrthographicCamera(正交相机)。它没有透视效果,物体大小不会随距离改变,非常适合2D游戏。

// 在游戏主类或PlayState中初始化 public static final float WORLD_WIDTH = 20; // 我们的游戏世界逻辑宽度为20个单位 public static final float WORLD_HEIGHT = 15; // 逻辑高度为15个单位 OrthographicCamera gameCamera; SpriteBatch batch; @Override public void create() { batch = new SpriteBatch(); gameCamera = new OrthographicCamera(); // 设置相机视口,使其能完整看到我们定义的逻辑世界大小 gameCamera.setToOrtho(false, WORLD_WIDTH, WORLD_HEIGHT); // 初始将相机对准世界中心 gameCamera.position.set(WORLD_WIDTH / 2, WORLD_HEIGHT / 2, 0); gameCamera.update(); }

在渲染时,我们需要告诉SpriteBatch使用哪个相机的变换矩阵:

@Override public void render() { gameCamera.update(); // 如果相机移动了,需要先更新 batch.setProjectionMatrix(gameCamera.combined); batch.begin(); // 在此处绘制所有精灵 // 例如:batch.draw(playerTexture, playerX, playerY); batch.end(); }

这种“逻辑单位”的概念很重要。我们的游戏逻辑(位置、速度)都基于这个虚拟的世界单位(比如米),而SpriteBatch和相机负责将这些逻辑坐标映射到实际不同分辨率的屏幕上。这保证了游戏在不同设备上看起来比例一致。

4.2 玩家与实体创建

现在,让我们用Ashley ECS来创建玩家实体。首先,定义组件:

// 位置组件 public class TransformComponent implements Component { public final Vector2 position = new Vector2(); public float rotation = 0; } // 精灵渲染组件 public class SpriteComponent implements Component { public TextureRegion region; public float width = 1.0f; // 逻辑宽度 public float height = 1.0f; // 逻辑高度 } // 玩家标签组件(空组件,仅用于标记) public class PlayerComponent implements Component { } // 移动组件 public class VelocityComponent implements Component { public final Vector2 velocity = new Vector2(); public float speed = 5.0f; // 最大速度 }

然后,创建一个EntityFactory工具类来组装实体:

public class EntityFactory { public static Entity createPlayer(Engine engine, float x, float y) { Entity entity = engine.createEntity(); TransformComponent pos = engine.createComponent(TransformComponent.class); pos.position.set(x, y); SpriteComponent sprite = engine.createComponent(SpriteComponent.class); sprite.region = new TextureRegion(assetManager.get(“player.png”, Texture.class)); sprite.width = 0.8f; sprite.height = 0.8f; VelocityComponent vel = engine.createComponent(VelocityComponent.class); PlayerComponent player = engine.createComponent(PlayerComponent.class); entity.add(pos); entity.add(sprite); entity.add(vel); entity.add(player); engine.addEntity(entity); return entity; } }

PlayState的初始化中,我们创建Ashley的Engine实例,并添加必要的系统,然后调用EntityFactory.createPlayer(...)来生成玩家。

4.3 输入处理与玩家移动

输入处理我们放在一个专门的PlayerInputSystem里。这个系统只处理拥有PlayerComponentVelocityComponent的实体。

public class PlayerInputSystem extends IteratingSystem { // 构造函数,定义这个系统关心哪些组件 public PlayerInputSystem() { super(Family.all(PlayerComponent.class, VelocityComponent.class, TransformComponent.class).get()); } @Override protected void processEntity(Entity entity, float deltaTime) { VelocityComponent vel = velocityMapper.get(entity); // 每帧先将速度归零,基于当前输入重新计算 vel.velocity.set(0, 0); // 使用libGDX的输入模块 if (Gdx.input.isKeyPressed(Input.Keys.LEFT) || Gdx.input.isKeyPressed(Input.Keys.A)) { vel.velocity.x = -1; } if (Gdx.input.isKeyPressed(Input.Keys.RIGHT) || Gdx.input.isKeyPressed(Input.Keys.D)) { vel.velocity.x = 1; } if (Gdx.input.isKeyPressed(Input.Keys.UP) || Gdx.input.isKeyPressed(Input.Keys.W)) { vel.velocity.y = 1; } if (Gdx.input.isKeyPressed(Input.Keys.DOWN) || Gdx.input.isKeyPressed(Input.Keys.S)) { vel.velocity.y = -1; } // 标准化向量,防止斜向移动更快 if (!vel.velocity.isZero()) { vel.velocity.nor().scl(vel.speed); // 将方向向量标准化,然后乘以速度 } // 处理鼠标/触摸瞄准(略) } }

然后,需要一个MovementSystem来根据速度更新位置:

public class MovementSystem extends IteratingSystem { public MovementSystem() { super(Family.all(TransformComponent.class, VelocityComponent.class).get()); } @Override protected void processEntity(Entity entity, float deltaTime) { TransformComponent pos = transformMapper.get(entity); VelocityComponent vel = velocityMapper.get(entity); // 位置 = 原位置 + 速度 * 时间增量 pos.position.mulAdd(vel.velocity, deltaTime); // 简单的边界检查(防止玩家走出世界) pos.position.x = MathUtils.clamp(pos.position.x, 0, RogueLikeGame.WORLD_WIDTH - 1); pos.position.y = MathUtils.clamp(pos.position.y, 0, RogueLikeGame.WORLD_HEIGHT - 1); } }

最后,一个RenderingSystem负责将所有带SpriteComponent的实体画出来:

public class RenderingSystem extends IteratingSystem { private SpriteBatch batch; private ComponentMapper<SpriteComponent> spriteMapper; private ComponentMapper<TransformComponent> transformMapper; public RenderingSystem(SpriteBatch batch) { super(Family.all(SpriteComponent.class, TransformComponent.class).get()); this.batch = batch; spriteMapper = ComponentMapper.getFor(SpriteComponent.class); transformMapper = ComponentMapper.getFor(TransformComponent.class); } @Override protected void processEntity(Entity entity, float deltaTime) { SpriteComponent sprite = spriteMapper.get(entity); TransformComponent transform = transformMapper.get(entity); // 计算绘制位置(将实体中心对齐到逻辑坐标) float drawX = transform.position.x - sprite.width / 2; float drawY = transform.position.y - sprite.height / 2; batch.draw(sprite.region, drawX, drawY, // 位置 sprite.width / 2, sprite.height / 2, // 旋转原点(中心) sprite.width, sprite.height, // 宽高 1, 1, // 缩放 transform.rotation); // 旋转 } }

PlayStaterender方法中,我们按顺序执行:engine.update(deltaTime)(这会触发所有系统的updateprocessEntity),然后在batch.begin()batch.end()之间,调用负责渲染的系统(或者直接让RenderingSystemprocessEntity里画,但更常见的做法是单独一个渲染阶段)。

4.4 碰撞检测与战斗逻辑

对于简单的2D矩形碰撞,libGDX自带的Rectangle类就够用了。我们可以创建一个CollisionComponent,里面包含一个Rectangle边界框。

public class CollisionComponent implements Component { public final Rectangle bounds = new Rectangle(); public short categoryBits = 0x0001; // 碰撞类别(用于位掩码过滤) public short maskBits = 0xFFFF; // 能与哪些类别碰撞 }

MovementSystem更新位置后,我们需要同步更新碰撞框的位置。然后,创建一个CollisionSystem。这个系统不一定需要继承IteratingSystem,因为它需要检查任意两个实体之间的关系。一种简单(但低效,实体多时需优化)的实现是:

public class CollisionSystem extends EntitySystem { @Override public void update(float deltaTime) { // 获取所有带碰撞组件的实体 ImmutableArray<Entity> entities = getEngine().getEntitiesFor(Family.all(CollisionComponent.class).get()); for (int i = 0; i < entities.size(); ++i) { Entity entityA = entities.get(i); CollisionComponent colA = collisionMapper.get(entityA); // 更新碰撞框位置(假设碰撞框中心与实体Transform中心一致) TransformComponent transA = transformMapper.get(entityA); colA.bounds.setCenter(transA.position.x, transA.position.y); for (int j = i + 1; j < entities.size(); ++j) { Entity entityB = entities.get(j); CollisionComponent colB = collisionMapper.get(entityB); // 使用位掩码进行初步过滤 if ((colA.maskBits & colB.categoryBits) != 0 && (colB.maskBits & colA.categoryBits) != 0) { if (colA.bounds.overlaps(colB.bounds)) { // 发生碰撞!这里可以触发事件,比如减少生命值 handleCollision(entityA, entityB); } } } } } private void handleCollision(Entity a, Entity b) { // 判断实体类型并处理 if (a.getComponent(PlayerComponent.class) != null && b.getComponent(EnemyComponent.class) != null) { // 玩家撞到敌人 HealthComponent health = healthMapper.get(a); if (health != null) { health.currentHealth -= 10; if (health.currentHealth <= 0) { getEngine().removeEntity(a); // 玩家死亡 } } } // 处理子弹碰撞等... } }

对于子弹的发射,我们可以创建一个WeaponSystem。它遍历所有带有WeaponComponent的实体(比如玩家),根据攻击间隔(cooldown)计时,当玩家按下攻击键且冷却完毕时,就在玩家位置和朝向创建一个新的“子弹”实体,这个实体拥有VelocityComponent(朝鼠标方向飞行)和CollisionComponent,以及一个BulletComponent标签。CollisionSystem会检测子弹与敌人的碰撞,并触发伤害计算。

4.5 地图生成与游戏对象管理

一个简单的Roguelike地牢可以使用“随机房间+走廊”的算法。这里简述一个非常基础的实现思路:

  1. 在世界中随机生成若干个不重叠的矩形房间。
  2. 使用德劳内三角剖分或最小生成树算法,将这些房间的中心点连接起来,形成走廊路径。
  3. 将走廊“膨胀”成可通行的单元格(Tile)。
  4. 将房间和走廊的单元格标记为“地板”,其他区域标记为“墙壁”。

我们可以用一个二维数组int[][] map来表示地图格子,用不同的整数值代表地板、墙壁、门等。在渲染时,我们可以使用一个瓦片集(Tileset)纹理,根据map数组的值来绘制对应的瓦片。libGDX有TiledMap和相关类支持更复杂的地图,但对于入门,自己管理一个简单的网格也足够。

游戏对象(敌人、道具、门)可以在地图生成后,被放置在地板格子上。例如,在每个房间内随机选择几个地板格子,在上面生成敌人实体或宝箱实体。

5. 性能优化、调试与跨平台打包

5.1 性能优化要点

用Java写游戏,最需要警惕的就是垃圾回收(GC)引起的卡顿。libGDX在设计上就极力避免产生短期临时对象。

  • 重用对象:不要在渲染循环(render方法)或系统更新的processEntity方法中频繁创建新对象(如new Vector2(),new Rectangle())。应该将这些对象作为组件的字段,或者使用对象池(Pool)。libGDX提供了Pool类,常用于子弹、特效等需要频繁创建和销毁的实体。
  • 使用静态工厂方法:对于ComponentMapper,应该在系统内部声明为静态字段,通过ComponentMapper.getFor(XXXComponent.class)获取,而不是每次处理实体都去获取。
  • 纹理图集(Texture Atlas):不要为每个小图片单独加载一个Texture。应该使用纹理打包工具(如libGDX官方工具TexturePacker)将许多小图片打包成一张大图和一个.atlas描述文件。这样能减少GPU状态切换,显著提升渲染性能。AssetManager可以直接加载.atlas文件。
  • 视锥体裁剪(Frustum Culling):只渲染在相机视野内的物体。在我们的RenderingSystem中,在绘制前可以先判断实体的位置是否在相机视野内,如果不是则跳过batch.draw调用。
  • 固定时间步长(Fixed Timestep):对于物理模拟等需要稳定更新的逻辑,建议使用固定时间步长,而不是直接使用Gdx.graphics.getDeltaTime()。这能防止帧率波动导致物理行为不一致。实现方式是在update方法中累积时间,然后以固定间隔(如1/60秒)执行多次物理更新。

5.2 调试技巧

  • 使用DebugRenderer(Box2D):如果你使用了Box2D物理引擎,它的DebugRenderer可以将碰撞体形状实时画出来,对于调试碰撞问题 invaluable。
  • 自定义调试渲染:即使不用Box2D,你也可以在render方法的最后,用一个独立的ShapeRenderer来绘制碰撞框、路径点、射线检测等调试图形。记得在batch.end()之后调用。
  • 日志输出:libGDX使用自己的Gdx.app.log(String tag, String message)来输出日志。它在不同平台(桌面、安卓)上都能正确工作。合理使用日志级别(LOG, DEBUG, ERROR)来追踪游戏状态。
  • 性能分析:桌面版可以使用JVisualVM或YourKit等Java分析器。libGDX也提供了一个简单的内置性能监控工具,可以通过Gdx.app.getGraphics().getFramesPerSecond()获取帧率,并在屏幕上绘制出来。

5.3 跨平台打包实战

这是libGDX最迷人的地方之一。你的核心游戏代码 (core模块) 不需要任何修改。

  • 打包桌面版 (JAR)

    1. 在IDE中,找到desktop模块下的DesktopLauncher类,右键运行即可调试。
    2. 要生成可分发JAR,可以使用Gradle任务。在终端进入项目根目录,运行:
      ./gradlew desktop:dist
      (Windows下是gradlew.bat desktop:dist
    3. 打包完成后,在desktop/build/libs/目录下会生成一个可执行的JAR文件(如desktop-1.0.jar)。这个JAR文件包含了所有依赖,可以在任何安装了对应版本JRE的Windows、macOS、Linux电脑上运行。
  • 打包安卓版 (APK)

    1. 确保你已经安装了Android SDK,并且环境变量配置正确。
    2. 用USB线连接安卓手机,并开启“开发者选项”和“USB调试”。
    3. 在终端运行:
      ./gradlew android:installDebug
    4. Gradle会自动编译、打包,并将APK安装到连接的手机上。你也可以运行./gradlew android:assembleRelease来生成签名的发布版APK。
  • 打包HTML5版 (WebGL)

    1. HTML5打包依赖于GWT(Google Web Toolkit),它会把Java代码编译成JavaScript。这个过程可能比较慢,且对Java代码的写法有一些限制(比如反射使用受限)。
    2. 运行打包命令:
      ./gradlew html:dist
    3. 编译产物在html/build/dist/目录下。你可以把这个目录下的所有文件部署到任何静态网站服务器(如Nginx, GitHub Pages)。核心是一个index.html文件和一个包含编译后代码的.js文件。

踩坑记录:HTML5打包最容易出问题。常见错误包括使用了GWT不支持的Java API(如java.nio的某些部分),或者在核心模块 (core) 直接引用了平台特定的类(如java.awt)。记住,所有放在core模块的代码,都必须保证能被GWT编译。如果有必须的平台相关代码,应该放在各平台模块(desktop,android等)中,并通过接口抽象,在core中调用。

6. 完整源码结构与运行指南

由于篇幅限制,我无法在这里贴出数万行的完整代码。但我会提供一个清晰的项目源码结构树和关键代码片段,你可以在文章附带的GitHub仓库链接中找到全部源码。

项目源码核心结构:

src/com/mygame/roguelike/ ├── core/ │ ├── RogueLikeGame.java (主类,游戏入口) │ ├── manager/ │ │ ├── GameStateManager.java (游戏状态管理器) │ │ └── AssetManager.java (资源管理器封装) │ ├── states/ │ │ ├── GameState.java (接口) │ │ ├── MenuState.java │ │ ├── PlayState.java (主游戏状态) │ │ └── PauseState.java │ ├── ecs/ │ │ ├── components/ (所有组件定义) │ │ │ ├── TransformComponent.java │ │ │ ├── SpriteComponent.java │ │ │ ├── VelocityComponent.java │ │ │ ├── PlayerComponent.java │ │ │ ├── EnemyComponent.java │ │ │ ├── HealthComponent.java │ │ │ ├── CollisionComponent.java │ │ │ └── WeaponComponent.java │ │ ├── systems/ (所有系统定义) │ │ │ ├── PlayerInputSystem.java │ │ │ ├── MovementSystem.java │ │ │ ├── EnemyAISystem.java │ │ │ ├── CollisionSystem.java │ │ │ ├── WeaponSystem.java │ │ │ └── RenderingSystem.java │ │ └── EntityFactory.java (实体创建工厂) │ ├── world/ │ │ ├── MapGenerator.java (随机地图生成器) │ │ └── TileType.java (地图格子类型枚举) │ └── utils/ (工具类,如常量、数学工具等) ├── desktop/ (桌面启动器) ├── android/ (安卓模块,含资源) └── html/ (HTML5模块)

如何运行:

  1. 克隆或下载项目源码
  2. 使用IntelliJ IDEA打开:选择项目根目录下的build.gradle文件。
  3. 等待Gradle同步完成:IDEA会自动下载所有依赖。
  4. 运行桌面版:在IDEA右侧的Gradle工具栏中,展开desktop->Tasks->run,双击run。或者直接运行DesktopLauncher类的main方法。
  5. 控制:WASD或方向键移动,鼠标瞄准并点击左键射击。
  6. 打包:在终端中,于项目根目录执行相应的Gradle命令(如./gradlew desktop:dist)。

这个项目实现了一个最基本的框架:一个玩家,几种敌人,简单的碰撞和射击,随机生成的地图房间。它最大的价值在于展示了一个清晰、可扩展的libGDX项目应该如何组织,尤其是ECS架构的运用。你可以在此基础上轻松地添加更多敌人类型、武器系统、技能树、房间事件、道具等等,把它扩展成一个真正有趣的游戏。

从Unity转向libGDX这样的框架,最初可能会觉得少了些“便利”,但换来的是对游戏运行脉络的彻底掌控和代码的极度清晰。当你看到同一份Java代码流畅地跑在电脑、手机和浏览器上时,那种成就感是无与伦比的。希望这个项目和文章,能成为你探索游戏开发底层乐趣的一块敲门砖。