尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Java游戏开发实战:从零实现《捕鱼达人》核心架构与优化

Java游戏开发实战:从零实现《捕鱼达人》核心架构与优化 1. 项目概述用JAVA重温经典《捕鱼达人》最近在整理自己的技术项目库翻到了一个几年前用纯JAVA实现的《捕鱼达人》游戏。这个项目虽然不大但麻雀虽小五脏俱全它几乎涵盖了从游戏循环、物理碰撞、资源管理到状态同步等游戏开发的核心模块。对于想从业务开发转向游戏开发或者单纯想用JAVA练手图形界面和复杂逻辑的朋友来说这是一个绝佳的练手项目。它不像大型3A游戏那样需要庞大的引擎知识也不像简单控制台程序那样缺乏挑战性正好卡在中间能让你深刻理解一个可交互的图形应用是如何从零搭建起来的。《捕鱼达人》的核心玩法大家都不陌生玩家操控一门炮台在屏幕上移动准星发射渔网捕捉游动的鱼群。不同的鱼有不同的分值、移动速度和生命值即需要被捕捉的次数。游戏的核心乐趣在于用低成本的渔网去捕捉高分值的大鱼实现“以小博大”。用JAVA来实现它意味着我们将抛开Unity、Cocos等成熟游戏引擎从最基础的JFrame、JPanel和Graphics2D开始亲手构建游戏世界。这个过程会让你对“帧”、“事件”、“渲染”、“碰撞”这些游戏开发的基础概念有肌肉记忆般的理解。接下来我就把这个项目的完整实现思路、关键代码和踩过的坑毫无保留地分享出来。2. 游戏整体架构与核心模块设计在动手写第一行代码之前我们必须先想清楚整个游戏的架构。一个结构清晰的架构是项目成功的一半它能避免后期代码变成一团乱麻。对于这个《捕鱼达人》项目我采用了经典的分层与模块化设计思想。2.1 MVC模式在游戏中的变体应用虽然标准的MVCModel-View-Controller模式在Web开发中更常见但它的思想在游戏开发中同样适用我们可以做一个变体。在这里Model模型就是游戏的核心数据与逻辑比如鱼的位置、速度、生命值炮台的等级、角度玩家的金币、分数等。这些数据是游戏的“状态”它们独立于如何显示View和如何操作Controller。View视图就是负责把Model中的数据“画”到屏幕上的部分在JAVA中这通常由一个继承自JPanel并重写了paintComponent方法的类来完成。Controller控制器则负责接收用户的输入鼠标移动、点击并根据这些输入来更新Model的状态。但游戏有一个特殊之处它有一个持续运行的游戏主循环。这个循环不属于标准的MVC它是驱动整个游戏运行的引擎。在我的设计中我将主循环和游戏核心逻辑如鱼的AI、碰撞检测放在了Model层的一个核心管理类中。这样View只负责渲染当前帧的状态Controller只处理离散的输入事件而主循环则驱动Model不断向前演化三者通过清晰的接口进行通信。2.2 核心类与职责划分基于上述架构我设计了以下几个核心类GameMain程序的入口点负责创建游戏主窗口JFrame初始化游戏核心组件并启动游戏线程。GamePanel继承自JPanel是游戏的“画布”。它持有GameModel的引用在paintComponent方法中调用GameModel获取所有需要渲染的对象鱼、炮台、渔网、UI文字并使用Graphics2D将它们绘制出来。它同时也作为鼠标监听器将鼠标事件转发给控制器。GameModel游戏的核心大脑。它是一个单例类管理着游戏所有的状态和逻辑。数据管理维护ListFish所有鱼的集合、ListNet所有渔网的集合、Cannon炮台、Player玩家信息等。逻辑驱动包含游戏主循环的逻辑通常在一个独立的线程中运行每帧更新所有鱼的位置、渔网的位置进行碰撞检测计算得分等。工厂方法提供创建新鱼、新渔网的方法。Fish鱼的实体类。属性包括坐标(x, y)、移动速度(vx, vy)、生命值(hp)、类型对应不同的图片和分值、当前帧的动画索引等。有一个update()方法用于根据速度更新自身位置并处理边界反弹等简单AI。Net渔网的实体类。属性包括发射起点、当前坐标、移动速度、捕捉半径、威力对应能扣除鱼的生命值等。有一个update()方法用于移动并有一个isAlive标志来判断渔网是否已到达最大射程或持续时间结束需要被回收。Cannon炮台类。属性包括炮口位置、当前旋转角度跟随鼠标、当前等级、对应渔网的威力等。Player玩家类。属性包括金币数量、当前分数、当前炮台等级等。ResourceManager资源管理器。这是一个非常关键的工具类负责以高效的方式加载和管理游戏中的所有图片资源。它通常使用ImageIO.read()加载图片并存储在一个MapString, BufferedImage中键是资源名如“fish1”“cannon2”值是图片对象。采用单例模式确保图片只加载一次全局共享。注意资源管理是桌面游戏开发中一个容易忽视但至关重要的一环。如果不加管理每次绘制都从磁盘读取图片或者创建大量重复的Image对象会迅速消耗内存并导致游戏卡顿。一个集中式的ResourceManager是必备的。2.3 线程模型渲染线程与逻辑线程在Swing/AWT中有一个特殊的线程叫做事件分发线程所有UI的创建、更新和渲染都必须在它上面执行否则会导致界面异常。但是我们的游戏主循环逻辑更新如果也放在这个线程里一旦逻辑计算复杂或耗时就会阻塞UI渲染导致界面“卡死”。因此标准的做法是分离逻辑线程与渲染线程。逻辑线程我们创建一个独立的Thread或使用SwingWorker来运行游戏主循环。在这个线程里我们以固定的频率比如每秒60次调用GameModel.update()更新所有游戏对象的状态。渲染线程即Swing的事件分发线程。我们通过调用GamePanel.repaint()方法来请求重绘界面。repaint()是非阻塞的它会安排一个重绘事件到事件队列由Swing在合适的时机通常是逻辑线程更新完一帧后调用GamePanel.paintComponent()。这两个线程之间需要同步。通常我们在逻辑线程更新完GameModel的数据后简单地通知渲染线程重绘即可。更复杂的情况可能需要使用线程安全的数据结构但在这个规模的游戏中只要注意不在渲染时修改正在被逻辑线程使用的数据问题不大。一个简单的技巧是让GameModel在update()方法中生成一份当前帧数据的“快照”供渲染使用或者使用CopyOnWriteArrayList这类线程安全的集合来存储鱼和渔网。3. 核心功能实现细节与难点解析架构搭好了接下来就是填充血肉。实现以下几个核心功能时有一些细节和坑需要特别注意。3.1 图形渲染与动画系统游戏看起来流畅核心在于渲染。我们使用Graphics2D进行所有绘制。1. 图片加载与绘制// 在ResourceManager中 public BufferedImage getImage(String key) { BufferedImage img imageMap.get(key); if (img null) { try { img ImageIO.read(new File(assets/ key .png)); imageMap.put(key, img); } catch (IOException e) { e.printStackTrace(); // 返回一个默认的错误图片或null } } return img; } // 在GamePanel的paintComponent中 Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 重要清空画布 Graphics2D g2d (Graphics2D) g; // 开启抗锯齿让边缘更平滑 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 1. 绘制背景 g2d.drawImage(resourceManager.getImage(background), 0, 0, getWidth(), getHeight(), null); // 2. 绘制所有鱼 for (Fish fish : gameModel.getFishListForRender()) { BufferedImage fishImg resourceManager.getImage(fish.getType()); // 计算绘制中心点并考虑鱼的旋转如果需要朝向移动方向 int drawX fish.getX() - fishImg.getWidth() / 2; int drawY fish.getY() - fishImg.getHeight() / 2; g2d.drawImage(fishImg, drawX, drawY, null); } // 3. 绘制炮台需要旋转 Cannon cannon gameModel.getCannon(); BufferedImage cannonImg resourceManager.getImage(cannon cannon.getLevel()); // 将坐标系平移到炮台旋转中心 g2d.translate(cannon.getPivotX(), cannon.getPivotY()); g2d.rotate(Math.toRadians(cannon.getAngle())); // 旋转 // 绘制炮台注意此时原点在旋转中心需要偏移图片使其绕中心旋转 g2d.drawImage(cannonImg, -cannonImg.getWidth()/2, -cannonImg.getHeight()/2, null); // 旋转和平移操作会影响后续绘制必须恢复 g2d.rotate(-Math.toRadians(cannon.getAngle())); g2d.translate(-cannon.getPivotX(), -cannon.getPivotY()); // 4. 绘制渔网通常是半透明的圆形或图片 for (Net net : gameModel.getNetListForRender()) { g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.7f)); // 设置透明度 g2d.setColor(Color.CYAN); g2d.fillOval(net.getX() - net.getRadius(), net.getY() - net.getRadius(), 2 * net.getRadius(), 2 * net.getRadius()); g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f)); // 恢复不透明 } // 5. 绘制UI分数、金币 g2d.setColor(Color.WHITE); g2d.setFont(new Font(Arial, Font.BOLD, 24)); g2d.drawString(Score: gameModel.getPlayer().getScore(), 20, 40); g2d.drawString(Coin: gameModel.getPlayer().getCoin(), 20, 70); }2. 动画实现鱼的游动动画通常是多张图片循环播放。我们在Fish类中增加一个frameIndex属性和一个frameTimer。public void update(long deltaTime) { // 更新位置 x vx; y vy; // 边界检查与反弹... // 更新动画帧 animationTimer deltaTime; if (animationTimer frameInterval) { animationTimer 0; frameIndex (frameIndex 1) % totalFrames; // 循环播放 } }在绘制时根据frameIndex从一张包含所有动画帧的精灵图Sprite Sheet中裁剪出对应部分或者直接加载fish_type_frameIndex对应的图片。实操心得Graphics2D的变换translate,rotate,scale是栈式的。每次变换后最好立即思考如何恢复或者在save/restore通过getTransform和setTransform中操作。否则一个没恢复的旋转会导致后面所有绘制都歪掉。对于炮台这种需要频繁旋转的对象在绘制前后配对使用变换和反变换是最清晰的做法。3.2 游戏主循环与时间控制游戏主循环的核心目标是以稳定的时间间隔更新游戏逻辑并尽可能快地请求渲染。这里的关键是解决“固定时间步长”的问题避免游戏速度受机器性能影响。public void runGameLoop() { long lastTime System.nanoTime(); double nsPerUpdate 1000000000.0 / TARGET_UPS; // 例如 TARGET_UPS 60.0 double delta 0; long timer System.currentTimeMillis(); int frames 0; int updates 0; while (running) { long now System.nanoTime(); delta (now - lastTime) / nsPerUpdate; lastTime now; // 累积时间足够一次更新时才进行逻辑更新 while (delta 1) { updateGame(); // 更新游戏逻辑 updates; delta--; } // 每次循环都请求重绘但实际渲染频率由Swing控制 gamePanel.repaint(); frames; // 每秒输出一次帧率和更新率调试用 if (System.currentTimeMillis() - timer 1000) { timer 1000; System.out.printf(UPS: %d, FPS: %d%n, updates, frames); frames 0; updates 0; } // 简单休眠避免空转消耗过多CPU try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } } }updateGame()方法里调用GameModel.update()传入一个固定的时间步长如1.0 / TARGET_UPS秒这样无论帧率高低鱼和渔网的移动速度在游戏世界里都是恒定的。3.3 碰撞检测渔网与鱼的捕捉逻辑这是游戏的核心交互逻辑。我们需要判断每一个渔网是否捕捉到了某条鱼。由于鱼和渔网都比较多使用两两遍历的O(n²)算法在后期可能会有效率问题但对于初学者项目先实现正确性更重要。基础圆形碰撞检测假设渔网有一个捕捉半径netRadius鱼有一个碰撞半径fishRadius。public boolean isCollidingWith(Fish fish) { if (!this.isAlive || !fish.isAlive()) { return false; } double dx this.x - fish.getX(); double dy this.y - fish.getY(); double distance Math.sqrt(dx * dx dy * dy); return distance (this.radius fish.getCollisionRadius()); }在GameModel.update()中// 遍历所有渔网和所有鱼 for (Net net : netList) { for (Fish fish : fishList) { if (net.isCollidingWith(fish)) { // 碰撞处理 fish.takeDamage(net.getPower()); // 鱼受到伤害 if (fish.getHp() 0) { // 鱼被捕获 player.addScore(fish.getScore()); player.addCoin(fish.getCoinReward()); fish.setAlive(false); // 标记为待移除 // 可以在这里触发死亡动画或特效 } // 渔网命中后可以立即消失或减少威力 net.setAlive(false); break; // 一个渔网通常只捕捉一条鱼命中后跳出内层循环 } } } // 移除所有已死亡的鱼和渔网 fishList.removeIf(fish - !fish.isAlive()); netList.removeIf(net - !net.isAlive());优化思路当对象数量很多时可以使用空间划分算法来优化如网格法。将游戏屏幕划分为一个个小格子每个对象根据其坐标放入对应的格子。检测碰撞时只需检测对象所在格子及相邻格子的对象即可大幅减少检测次数。3.4 用户交互炮台旋转与射击炮台需要跟随鼠标旋转并在点击时发射渔网。1. 炮台旋转在GamePanel中监听鼠标移动事件。Override public void mouseMoved(MouseEvent e) { Cannon cannon gameModel.getCannon(); // 计算炮台旋转中心到鼠标点的角度 int centerX cannon.getPivotX(); int centerY cannon.getPivotY(); int mouseX e.getX(); int mouseY e.getY(); // Math.atan2(y, x) 返回的是从x轴到点(x,y)的角度弧度范围(-PI, PI] double angleRad Math.atan2(mouseY - centerY, mouseX - centerX); double angleDeg Math.toDegrees(angleRad); // 将角度设置给炮台View在渲染时会使用这个角度 cannon.setAngle(angleDeg); // 触发重绘让炮台实时跟随鼠标 repaint(); }2. 发射渔网在GamePanel中监听鼠标点击事件。Override public void mousePressed(MouseEvent e) { if (e.getButton() MouseEvent.BUTTON1) { // 左键 Cannon cannon gameModel.getCannon(); Player player gameModel.getPlayer(); // 检查金币是否足够发射当前等级的炮 int cost cannon.getCurrentLevelFireCost(); if (player.getCoin() cost) { // 扣钱 player.addCoin(-cost); // 根据炮台角度和位置计算渔网的初始速度和方向 double angleRad Math.toRadians(cannon.getAngle()); double speed 15.0; // 渔网速度 double vx speed * Math.cos(angleRad); double vy speed * Math.sin(angleRad); // 创建渔网对象并添加到GameModel的管理列表中 Net net new Net(cannon.getPivotX(), cannon.getPivotY(), vx, vy, cannon.getPower()); gameModel.addNet(net); // 可以在这里添加一个射击音效 } else { // 金币不足给玩家一个提示比如UI闪烁 System.out.println(金币不足); } } }注意事项鼠标坐标是相对于GamePanel的而我们的游戏世界坐标可能和面板坐标是1:1对应的。如果游戏世界有滚动或缩放需要进行坐标转换。在这个简单项目中我们假设两者一致。4. 游戏资源管理与性能优化一个流畅的游戏离不开高效的资源管理和性能优化。以下是几个关键点。4.1 图像资源的加载与缓存前面提到的ResourceManager是基础。此外对于大量小图如爆炸特效帧可以考虑将它们合并到一张大图纹理图集中。这样在绘制时只需要绑定一次纹理通过切换纹理坐标来绘制不同部分能显著减少绘制调用Draw Call提升渲染效率。虽然纯Java 2D对此优化不如OpenGL明显但养成良好的习惯很重要。加载时机也很关键。最好在游戏启动时显示一个加载界面在后台线程中预加载所有资源避免在游戏过程中因加载资源导致卡顿。4.2 对象池技术避免频繁创建与垃圾回收在游戏中渔网和鱼是频繁创建和销毁的对象。如果每一发炮弹都new Net()每一条鱼死亡又new Fish()会产生大量的临时对象给Java的垃圾回收器带来巨大压力可能导致游戏间歇性卡顿。对象池就是为了解决这个问题。其核心思想是预先创建一定数量的对象放在一个“池子”里需要时从池中取用用完后不直接丢弃而是重置状态后放回池中。public class NetPool { private static final int MAX_SIZE 50; private ListNet pool new ArrayList(); public NetPool() { for (int i 0; i MAX_SIZE; i) { pool.add(new Net()); // 创建初始对象 } } public Net obtain(float startX, float startY, float vx, float vy, int power) { Net net; if (pool.isEmpty()) { // 池空了临时创建一个或选择丢弃最旧的对象 net new Net(); } else { net pool.remove(pool.size() - 1); } // 重置并初始化这个渔网对象 net.reset(startX, startY, vx, vy, power); net.setAlive(true); return net; } public void free(Net net) { if (pool.size() MAX_SIZE) { net.setAlive(false); // 标记为非活跃 // 可以在这里重置net的其他状态 pool.add(net); } // 如果池已满则不再回收让GC处理 } }在GameModel中发射渔网时调用netPool.obtain(...)渔网生命周期结束时调用netPool.free(net)。对Fish也可以采用类似的池化管理。4.3 渲染优化脏矩形与局部重绘默认情况下repaint()会重绘整个JPanel。如果游戏画面中只有一小部分区域在变化比如只有几条鱼在游重绘整个屏幕是浪费的。我们可以通过脏矩形技术只重绘发生变化的那部分区域。在Swing中可以调用repaint(int x, int y, int width, int height)来指定需要重绘的矩形区域。我们需要在逻辑更新时计算出所有移动对象在本帧中“污染”到的区域通常是对象上一帧的包围盒和当前帧包围盒的并集然后将这个区域作为脏矩形传递给repaint。然而在实践中对于这个《捕鱼达人》项目由于鱼和渔网遍布全屏且UI信息分数也在不断更新计算脏矩形的收益可能不大甚至因为计算开销而得不偿失。优化黄金法则先测量后优化。如果游戏在目标机器上已经能达到60FPS就不需要过度优化。只有当性能成为瓶颈时才考虑引入更复杂的优化手段。5. 游戏功能扩展与高级特性实现基础版本完成后可以添加更多功能让游戏变得更好玩。5.1 多种鱼类与特殊行为鱼类设计创建不同的Fish子类或通过FishType枚举来区分。小黄鱼低分值低生命值速度快。河豚中等分值被击中后会短暂加速逃跑。鲨鱼高分值高生命值移动路径有规律如正弦波。灯笼鱼被捕获后有一定几率爆出大量金币。实现特殊行为在Fish的update方法中根据类型执行不同的逻辑。public void update(long deltaTime) { switch (this.type) { case PUFFERFISH: if (isUnderAttack) { // 被击中状态 speedMultiplier 2.0f; // 加速 underAttackTimer - deltaTime; if (underAttackTimer 0) { isUnderAttack false; speedMultiplier 1.0f; } } break; case SHARK: // 正弦波移动 this.y Math.sin(this.x * 0.05) * 0.5; break; } // 通用移动逻辑 this.x vx * speedMultiplier; this.y vy * speedMultiplier; // ... 边界检查 }5.2 炮台升级与技能系统炮台升级Cannon类拥有等级属性。升级可以增加威力、射速、捕鱼范围或者改变渔网外观。升级需要消耗金币在UI上提供升级按钮。技能系统可以设计主动技能和被动技能。冰冻发射一个特殊渔网命中后冻结一片区域内的鱼一段时间。全屏炸弹消耗大量金币清除屏幕内所有小鱼。金币加成被动技能提升捕获鱼获得的金币比例。 实现技能需要扩展Net类增加技能类型和效果并在碰撞检测后触发相应的效果逻辑。5.3 关卡设计与游戏节奏控制一个好的游戏需要有起伏的节奏。我们可以引入简单的关卡概念。LevelManager管理当前关卡、关卡持续时间、鱼群刷新波次等。波次生成每一关由多波鱼群组成。可以预定义每波鱼群的配置鱼的类型、数量、出现位置、移动方向。public class Wave { private int waveNumber; private ListFishSpawnConfig spawnConfigs; // 鱼群生成配置 private boolean isFinished; private long waveStartTime; public void update(GameModel model, long currentTime) { if (isFinished) return; for (FishSpawnConfig config : spawnConfigs) { if (!config.hasSpawned currentTime - waveStartTime config.delay) { // 生成一群鱼 for (int i 0; i config.count; i) { Fish fish FishFactory.createFish(config.type, config.spawnArea); model.addFish(fish); } config.hasSpawned true; } } // 检查这波所有鱼是否都生成完毕且已被消灭 if (allConfigsSpawned() model.getFishList().isEmpty()) { this.isFinished true; } } }难度曲线随着关卡提升增加鱼的数量、生命值减少鱼群出现的间隔或者引入更多特殊鱼。6. 常见问题排查与调试技巧开发过程中你肯定会遇到各种奇怪的问题。这里记录几个我踩过的坑和解决方法。6.1 游戏画面闪烁严重问题描述游戏运行时画面频繁闪烁。原因分析这是Swing双缓冲的典型问题。JPanel的默认双缓冲可能在某些情况下失效或者我们在paintComponent中绘制太慢导致前后帧交替时看到中间过程。解决方案在GamePanel的构造函数中显式开启双缓冲setDoubleBuffered(true);。确保paintComponent方法中第一行调用了super.paintComponent(g);它会用背景色清除上一帧画面。优化绘制代码。避免在paintComponent中做复杂的计算或IO操作如读取图片。所有资源都应提前加载好。如果使用了自定义的缓冲策略可以尝试createBufferStrategy(2); // 在JFrame或Canvas上 BufferStrategy strategy getBufferStrategy(); // 在游戏循环中 do { do { Graphics g strategy.getDrawGraphics(); render(g); // 你的渲染方法 g.dispose(); } while (strategy.contentsRestored()); strategy.show(); } while (strategy.contentsLost());6.2 鼠标事件响应延迟或不准确问题描述炮台旋转不跟手或者点击发射有延迟。原因分析可能是事件监听器添加的位置不对或者游戏主循环太慢阻塞了事件分发线程。排查步骤确认鼠标监听器是添加在GamePanel上并且GamePanel获得了焦点setFocusable(true); requestFocusInWindow();。在鼠标事件处理方法中打印日志确认事件触发是否及时。检查游戏主循环是否在独立线程中运行。如果逻辑更新GameModel.update()非常耗时它会占用大量CPU时间虽然Swing的事件处理在另一个线程但整体系统响应还是会变慢。需要优化update逻辑。考虑使用MouseMotionListener的mouseDragged事件来替代mouseMoved以获得更连续的跟踪但需要处理鼠标按键状态。6.3 碰撞检测效率低下导致卡顿问题描述当屏幕上有上百条鱼和大量渔网时游戏变得很卡。原因分析两重循环的碰撞检测复杂度是O(N*M)对象数量多时计算量激增。优化方案空间划分如前所述实现一个简单的网格系统。public class SpatialGrid { private int cellSize; private MapGridCoord, ListGameObject grid; public void insert(GameObject obj) { GridCoord coord getCoord(obj.x, obj.y); grid.computeIfAbsent(coord, k - new ArrayList()).add(obj); } public ListGameObject getPotentialCollisions(GameObject obj) { ListGameObject candidates new ArrayList(); GridCoord objCoord getCoord(obj.x, obj.y); // 检查对象所在格子及周围8个格子 for (int dx -1; dx 1; dx) { for (int dy -1; dy 1; dy) { GridCoord checkCoord new GridCoord(objCoord.xdx, objCoord.ydy); candidates.addAll(grid.getOrDefault(checkCoord, Collections.emptyList())); } } // 需要从列表中移除自己 candidates.remove(obj); return candidates; } }在每帧更新时清空网格重新插入所有对象然后每个对象只与getPotentialCollisions返回的列表中的对象进行精确碰撞检测。粗略筛选在精确碰撞检测前先进行“包围盒”检测。比如如果两个对象的AABB轴对齐包围盒都没有相交那它们肯定没有碰撞无需进行更耗时的圆形或像素级检测。分帧检测如果实在无法优化到单帧完成可以考虑将碰撞检测分摊到多帧中进行但这会带来延迟需要谨慎设计。6.4 游戏在不同分辨率下显示异常问题描述在自己电脑上开发得好好的换一台分辨率不同的电脑UI错位物体大小不对。原因分析代码中使用了绝对像素坐标和大小。解决方案引入逻辑坐标与物理坐标的概念。逻辑坐标定义游戏世界的大小比如固定为 800x600。物理坐标实际屏幕的像素坐标。 在渲染时根据当前GamePanel的实际大小计算一个缩放比例和偏移量将逻辑坐标映射到物理坐标。// 在GamePanel中 private float scaleX, scaleY, offsetX, offsetY; private void calculateViewport() { int logicWidth 800; int logicHeight 600; int physWidth getWidth(); int physHeight getHeight(); // 保持宽高比的缩放 float scale Math.min((float)physWidth / logicWidth, (float)physHeight / logicHeight); scaleX scale; scaleY scale; // 计算居中偏移 offsetX (physWidth - logicWidth * scale) / 2; offsetY (physHeight - logicHeight * scale) / 2; } Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d (Graphics2D) g; // 应用变换先平移再缩放 g2d.translate(offsetX, offsetY); g2d.scale(scaleX, scaleY); // 现在所有绘制都使用逻辑坐标 (0,0) 到 (800,600) g2d.drawImage(background, 0, 0, 800, 600, null); // ... 绘制其他对象 // 注意绘制UI文字时可能需要使用物理坐标或者单独处理 g2d.scale(1/scaleX, 1/scaleY); // 恢复缩放以绘制UI g2d.translate(-offsetX, -offsetY); // 恢复平移 g2d.drawString(Score: ..., 20, 40); // 使用物理坐标 }同时鼠标事件传入的坐标是物理坐标需要反向转换回逻辑坐标再用于游戏逻辑计算。public void mouseMoved(MouseEvent e) { // 将物理坐标转换为逻辑坐标 int logicX (int)((e.getX() - offsetX) / scaleX); int logicY (int)((e.getY() - offsetY) / scaleY); // 使用logicX, logicY来计算炮台角度 }6.5 内存泄漏与对象管理问题描述游戏运行一段时间后越来越卡最终可能抛出OutOfMemoryError。原因分析资源未释放虽然Java有GC但如果你持续创建BufferedImage且持有引用或者注册了监听器但未移除会导致内存堆积。对象池使用不当对象池中的对象如果还持有外部引用或者重置不彻底可能导致旧数据残留。集合类只增不减ListFish中的鱼被“杀死”后只是标记为isAlivefalse但从未从列表中移除列表会无限增长。排查与解决使用-Xmx参数限制堆内存并用VisualVM或JProfiler等工具监控堆内存使用情况观察哪个类的实例数量持续增长。确保所有“死亡”的游戏对象都从主列表中移除。gameModel.getFishList().removeIf(fish - !fish.isAlive());检查监听器。如果动态添加了监听器比如为每条鱼添加属性改变监听在鱼被移除时也要移除对应的监听器。对于缓存如ResourceManager可以考虑使用软引用SoftReference让GC在内存不足时自动清理缓存。开发这个JAVA版《捕鱼达人》的过程是一次对桌面应用核心架构的深度实践。从最基础的窗口绘制、事件响应到复杂的多线程协调、碰撞检测、性能优化每一个环节都充满了挑战和收获。它让我明白看似简单的游戏背后是一套严谨的软件工程思想在支撑。如果你能独立完成这样一个项目并且处理好上述提到的各种细节和坑那么你对JAVA的理解、对程序设计的把控能力绝对会上升一个实实在在的台阶。这个项目代码至今仍是我面试中级JAVA开发时非常喜欢拿出来讨论的一个话题。
返回列表