刻《捕魚達(dá)人》:從游戲循環(huán)到碰撞檢測的實(shí)戰(zhàn)解析)
1. 項(xiàng)目緣起為什么用Java Swing重寫《捕魚達(dá)人》幾年前我在面試一個(gè)初級Java開發(fā)崗位時(shí)面試官問了一個(gè)讓我印象很深的問題“除了Spring Boot和微服務(wù)你用過Java做過什么有意思的東西嗎” 我當(dāng)時(shí)腦子里全是各種框架和八股文一時(shí)語塞。后來我意識到很多Java開發(fā)者包括我自己都陷入了“業(yè)務(wù)開發(fā)”的舒適區(qū)對Java在圖形界面、游戲邏輯、實(shí)時(shí)交互這些更底層的應(yīng)用場景缺乏實(shí)踐。這就像只學(xué)了語法卻從沒寫過一篇完整的文章。于是我決定挑戰(zhàn)自己用最“原始”的Java技術(shù)?!猄wing來復(fù)刻一個(gè)經(jīng)典的休閑游戲《捕魚達(dá)人》。這個(gè)選擇聽起來有點(diǎn)“復(fù)古”甚至有點(diǎn)“低端”畢竟現(xiàn)在都是Unity、UE4或者H5 Canvas的天下了。但恰恰是這種“復(fù)古”能讓我們拋開繁復(fù)的引擎和框架直面游戲開發(fā)最核心的幾個(gè)問題圖形渲染、對象管理、碰撞檢測、事件驅(qū)動和狀態(tài)同步。這對于理解游戲循環(huán)、面向?qū)ο笤O(shè)計(jì)模式比如觀察者、工廠、狀態(tài)模式以及多線程編程是一次絕佳的實(shí)戰(zhàn)演練。它不追求炫酷的3D效果而是聚焦于用純粹的Java代碼構(gòu)建一個(gè)穩(wěn)定、可玩、邏輯完整的應(yīng)用程序這本身就是對Java基礎(chǔ)能力的一次深度檢驗(yàn)。2. 技術(shù)選型與架構(gòu)設(shè)計(jì)為什么是Swing而不是其他提到Java做游戲很多人會想到LibGDX、jMonkeyEngine等專業(yè)游戲框架。我選擇Swing主要基于以下幾點(diǎn)考量2.1 核心訴求聚焦邏輯而非渲染引擎我的目標(biāo)是深入理解游戲機(jī)制本身而不是學(xué)習(xí)一個(gè)特定游戲引擎的API。Swing作為Java標(biāo)準(zhǔn)庫的一部分提供了足夠的基礎(chǔ)繪圖Graphics2D和事件處理能力讓我能從零開始搭建游戲循環(huán)、管理精靈Sprite、處理用戶輸入。這迫使我去思考“每一幀畫面是如何畫出來的”、“子彈和魚的碰撞如何判斷”這些根本性問題。2.2 開發(fā)與學(xué)習(xí)成本對于Java開發(fā)者而言Swing幾乎沒有額外的學(xué)習(xí)成本。JFrame,JPanel,KeyListener,MouseListener這些都是內(nèi)置類文檔齊全。我可以把全部精力投入到游戲業(yè)務(wù)邏輯的設(shè)計(jì)上而不是耗費(fèi)在配置復(fù)雜的開發(fā)環(huán)境或理解引擎特有的概念上。2.3 輕量與可控Swing程序打包后就是一個(gè)獨(dú)立的JAR文件依賴極小啟動快。整個(gè)游戲的結(jié)構(gòu)完全由我掌控從主循環(huán)的計(jì)時(shí)器javax.swing.Timer到每一顆子彈的運(yùn)動軌跡代碼清晰可見便于調(diào)試和優(yōu)化。這對于教學(xué)和原理剖析來說是巨大的優(yōu)勢。2.4 架構(gòu)設(shè)計(jì)草圖基于以上我設(shè)計(jì)了如下的核心類結(jié)構(gòu)這是一個(gè)典型的面相對象游戲架構(gòu)GameMain: 程序入口繼承JFrame是游戲的主窗口。GamePanel: 游戲畫板繼承JPanel是游戲渲染的核心。它重寫paintComponent(Graphics g)方法負(fù)責(zé)繪制背景、魚群、子彈、炮臺、分?jǐn)?shù)等所有游戲元素。GameLoop: 游戲循環(huán)控制器。我使用一個(gè)javax.swing.Timer以固定的時(shí)間間隔如每秒60幀即16毫秒觸發(fā)重繪和游戲狀態(tài)更新。Sprite基類: 所有可繪制、可移動游戲?qū)ο蟮幕惗x位置x, y、速度、圖像、是否存活等屬性和更新(update)、繪制(draw)方法。Fish類 (繼承Sprite): 代表各種魚類。有不同的類型小魚、中魚、大魚、生命值、移動路徑和對應(yīng)的分?jǐn)?shù)。Bullet類 (繼承Sprite): 代表炮彈。有發(fā)射角度、速度、威力等屬性。Cannon類: 炮臺。管理炮臺等級、發(fā)射角度跟隨鼠標(biāo)、子彈類型和發(fā)射冷卻。GameManager: 游戲管理器單例模式。它是游戲的大腦負(fù)責(zé)創(chuàng)建和銷毀Fish和Bullet對象進(jìn)行碰撞檢測更新分?jǐn)?shù)和金幣管理游戲狀態(tài)開始、進(jìn)行中、結(jié)束。這個(gè)架構(gòu)清晰地將渲染View、邏輯Controller/Model分離雖然簡單但體現(xiàn)了良好的設(shè)計(jì)思想。3. 核心實(shí)現(xiàn)細(xì)節(jié)拆解從靜態(tài)畫面到動態(tài)游戲有了架構(gòu)接下來就是填充血肉。下面我挑幾個(gè)最關(guān)鍵的技術(shù)點(diǎn)分享我的實(shí)現(xiàn)思路和踩過的坑。3.1 游戲主循環(huán)與雙緩沖游戲的核心是一個(gè)永不停止的循環(huán)處理輸入 - 更新邏輯 - 渲染畫面。在Swing中我們不能在一個(gè)while(true)循環(huán)里直接調(diào)用repaint()這會阻塞事件分發(fā)線程EDT導(dǎo)致界面卡死。注意Swing的繪圖必須在事件分發(fā)線程EDT上進(jìn)行所有對UI組件的修改如repaint()都應(yīng)通過SwingUtilities.invokeLater()提交到EDT或者在Timer的回調(diào)中執(zhí)行因?yàn)門imer的回調(diào)默認(rèn)就在EDT中。我的解決方案是使用javax.swing.Timer。public class GamePanel extends JPanel { private Timer gameTimer; public GamePanel() { gameTimer new Timer(16, new ActionListener() { // ~60 FPS Override public void actionPerformed(ActionEvent e) { updateGameState(); // 更新所有對象狀態(tài) repaint(); // 請求重繪觸發(fā)paintComponent } }); gameTimer.start(); } Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 清空畫布 Graphics2D g2d (Graphics2D) g; // 1. 繪制背景 g2d.drawImage(backgroundImage, 0, 0, getWidth(), getHeight(), this); // 2. 繪制所有魚 for (Fish fish : gameManager.getAllFish()) { fish.draw(g2d); } // 3. 繪制所有子彈 for (Bullet bullet : gameManager.getAllBullets()) { bullet.draw(g2d); } // 4. 繪制炮臺和UI cannon.draw(g2d); drawUI(g2d); } }這里有一個(gè)至關(guān)重要的技巧雙緩沖。如果直接在paintComponent中繪制復(fù)雜的場景會導(dǎo)致嚴(yán)重的閃爍。Swing的JPanel默認(rèn)不是雙緩沖的。解決方法很簡單在構(gòu)造函數(shù)中調(diào)用setDoubleBuffered(true);或者自定義一個(gè)繼承自JPanel的類并重寫isDoubleBuffered()返回true。雙緩沖的原理是在內(nèi)存中先完成一整幀的繪制然后一次性拷貝到屏幕消除了繪制過程中的中間狀態(tài)畫面變得非常平滑。3.2 精靈Sprite的運(yùn)動與動畫魚和子彈都需要運(yùn)動。我讓Sprite基類有一個(gè)update()方法在游戲循環(huán)的updateGameState()中被調(diào)用。public abstract class Sprite { protected int x, y; // 位置 protected int vx, vy; // 速度向量 protected BufferedImage image; protected boolean alive true; public void update() { // 基礎(chǔ)運(yùn)動根據(jù)速度更新位置 x vx; y vy; // 邊界檢查如果移出屏幕標(biāo)記為未存活 if (x -image.getWidth() || x GamePanel.WIDTH || y -image.getHeight() || y GamePanel.HEIGHT) { alive false; } } public void draw(Graphics2D g2d) { if (alive image ! null) { g2d.drawImage(image, x, y, null); } } }對于魚類可以擴(kuò)展update()方法實(shí)現(xiàn)更復(fù)雜的運(yùn)動路徑比如正弦波移動public class Fish extends Sprite { private double time 0; // 用于正弦函數(shù)的時(shí)間變量 private double amplitude; // 振幅 private double frequency; // 頻率 Override public void update() { super.update(); // 先執(zhí)行基礎(chǔ)的直線運(yùn)動 // 疊加正弦運(yùn)動 y (int) (amplitude * Math.sin(frequency * time)); time 0.1; // 時(shí)間遞增 } }3.3 碰撞檢測子彈與魚的“親密接觸”這是游戲邏輯的核心。當(dāng)子彈擊中魚魚要掉血或死亡玩家要加分。我采用了最常用且高效的矩形碰撞檢測。雖然魚和子彈的圖形可能是不規(guī)則的但用其外接矩形進(jìn)行初步判斷在性能和精度上是一個(gè)很好的折中。在GameManager中每一幀都會檢查所有存活的子彈和所有存活的魚public void checkCollisions() { ListBullet bullets getAllBullets(); ListFish fishes getAllFish(); for (Bullet bullet : new ArrayList(bullets)) { // 遍歷副本避免并發(fā)修改 if (!bullet.isAlive()) continue; Rectangle bulletRect bullet.getBounds(); // 獲取子彈的矩形區(qū)域 for (Fish fish : new ArrayList(fishes)) { if (!fish.isAlive()) continue; Rectangle fishRect fish.getBounds(); // 獲取魚的矩形區(qū)域 if (bulletRect.intersects(fishRect)) { // 發(fā)生碰撞 onCollision(bullet, fish); break; // 一顆子彈通常只擊中一條魚 } } } } private void onCollision(Bullet bullet, Fish fish) { // 子彈命中效果 bullet.setAlive(false); // 子彈消失 // 魚受到傷害 fish.takeDamage(bullet.getPower()); if (!fish.isAlive()) { // 魚死亡增加分?jǐn)?shù) currentScore fish.getScore(); // 可以在這里播放死亡動畫或音效如果實(shí)現(xiàn)了的話 } }getBounds()方法返回一個(gè)Rectangle對象代表精靈圖像的邊界。intersects()是JavaRectangle類自帶的方法用于判斷兩個(gè)矩形是否相交。3.4 資源管理與性能優(yōu)化游戲運(yùn)行起來后魚和子彈會不斷地被創(chuàng)建和銷毀。如果不加管理會產(chǎn)生大量的對象導(dǎo)致GC頻繁最終卡頓。對象池模式這是解決之道。我預(yù)先創(chuàng)建一定數(shù)量的Fish和Bullet對象放入兩個(gè)“池子”如List。當(dāng)需要一條新魚時(shí)從“空閑魚池”中取一條重置其狀態(tài)位置、血量、類型后放入“活動魚池”。當(dāng)魚死亡或子彈消失時(shí)不是直接丟棄對象而是將其狀態(tài)標(biāo)記為“空閑”放回池中。這極大地減少了垃圾回收的壓力。圖像加載所有圖片資源魚、子彈、背景、UI元素應(yīng)在游戲初始化時(shí)一次性加載到內(nèi)存中存儲在Map或靜態(tài)變量里避免在游戲循環(huán)中反復(fù)讀取文件。列表遍歷優(yōu)化在碰撞檢測等頻繁操作中使用for-each遍歷ArrayList的副本或者使用迭代器并注意安全刪除避免ConcurrentModificationException。4. 實(shí)戰(zhàn)中的“坑”與解決方案理論很美好但編碼過程才是真正的試金石。下面是我記憶猶新的幾個(gè)坑。4.1 線程安全與Swing事件分發(fā)線程EDT這是我遇到的第一個(gè)大坑。我一開始在GameManager中直接用一個(gè)ArrayList來存魚并在游戲循環(huán)Timer線程本質(zhì)上是EDT中更新同時(shí)在另一個(gè)線程比如一個(gè)自動生成魚的線程里向這個(gè)列表添加新魚。很快游戲就拋出了ConcurrentModificationException。提示盡管javax.swing.Timer的回調(diào)在EDT上執(zhí)行但如果你自己啟動了額外的線程例如用ScheduledExecutorService定時(shí)生成魚那么對這些共享集合魚列表、子彈列表的修改就必須考慮線程安全。解決方案使用線程安全的集合將ArrayList替換為CopyOnWriteArrayList。它在修改時(shí)如add, remove會復(fù)制整個(gè)底層數(shù)組適合讀多寫少的場景。對于游戲來說每幀遍歷讀非常頻繁而增刪寫相對較少這是一個(gè)可接受的方案但要注意寫操作的成本。將修改操作提交到EDT這是更Swing的方式。在任何非EDT線程中如果需要對游戲狀態(tài)如列表進(jìn)行修改都通過SwingUtilities.invokeLater()來執(zhí)行。// 在另一個(gè)線程中 executor.scheduleAtFixedRate(() - { SwingUtilities.invokeLater(() - { gameManager.spawnNewFish(); }); }, 0, 2, TimeUnit.SECONDS);我最終采用的方案為了簡化我放棄了獨(dú)立的生成線程。我將魚的生成邏輯也放在了游戲主循環(huán)Timer回調(diào)里通過一個(gè)計(jì)數(shù)器來控制生成頻率。這樣所有邏輯都在EDT上順序執(zhí)行徹底避免了線程安全問題。雖然理論上不如多線程“高級”但對于這個(gè)規(guī)模的游戲完全夠用且穩(wěn)定。4.2 圖像縮放與失真從網(wǎng)上下載的魚圖片尺寸不一。直接drawImage會導(dǎo)致魚的大小不符合游戲設(shè)定或者在高分辨率下模糊。解決方案在加載圖片時(shí)就將其縮放到目標(biāo)尺寸并使用高質(zhì)量的縮放算法。private BufferedImage loadAndScaleImage(String path, int targetWidth, int targetHeight) { try { BufferedImage original ImageIO.read(getClass().getResource(path)); BufferedImage scaled new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d scaled.createGraphics(); // 關(guān)鍵設(shè)置渲染提示讓縮放更平滑 g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.drawImage(original, 0, 0, targetWidth, targetHeight, null); g2d.dispose(); // 重要釋放Graphics2D對象 return scaled; } catch (IOException e) { e.printStackTrace(); return null; } }記住用完Graphics2D對象一定要dispose()這是一個(gè)好習(xí)慣可以及時(shí)釋放原生資源。4.3 鼠標(biāo)跟蹤與炮臺旋轉(zhuǎn)炮臺需要實(shí)時(shí)指向鼠標(biāo)位置。這里涉及坐標(biāo)轉(zhuǎn)換。鼠標(biāo)事件得到的坐標(biāo)是相對于整個(gè)JFrame的而炮臺繪制在GamePanel上其坐標(biāo)原點(diǎn)是面板的左上角。解決方案在GamePanel上添加MouseMotionListener在mouseMoved事件中將鼠標(biāo)的絕對坐標(biāo)轉(zhuǎn)換為相對于GamePanel的坐標(biāo)然后計(jì)算炮臺指向該點(diǎn)的角度。addMouseMotionListener(new MouseAdapter() { Override public void mouseMoved(MouseEvent e) { // e.getX()和e.getY()已經(jīng)是相對于GamePanel的坐標(biāo)了 int mouseX e.getX(); int mouseY e.getY(); // 計(jì)算炮臺中心到鼠標(biāo)點(diǎn)的角度 int centerX getWidth() / 2; int centerY getHeight() - 50; // 炮臺大概在底部中央 double deltaX mouseX - centerX; double deltaY mouseY - centerY; // Math.atan2(y, x) 返回的是從x軸正方向逆時(shí)針旋轉(zhuǎn)到點(diǎn)(x,y)的角度弧度 double angle Math.atan2(deltaY, deltaX); cannon.setAngle(angle); // 更新炮臺角度 } });在繪制炮臺時(shí)使用Graphics2D的rotate(angle, pivotX, pivotY)方法圍繞炮臺底座中心進(jìn)行旋轉(zhuǎn)就能實(shí)現(xiàn)平滑的瞄準(zhǔn)效果。5. 功能擴(kuò)展與游戲性打磨基礎(chǔ)版本完成后可以添加更多功能讓它更像一個(gè)完整的游戲。5.1 多種魚類與行為模式不要只做一種直線游的魚??梢远x枚舉類FishType。public enum FishType { SMALL(10, 1, “small_fish.png”), // 分?jǐn)?shù)生命值圖片 MEDIUM(30, 3, “medium_fish.png”), LARGE(100, 10, “l(fā)arge_fish.png”), BOSS(500, 50, “boss_fish.png”); // ... 構(gòu)造函數(shù)和getter }在Fish類中可以根據(jù)類型決定移動策略直線、正弦、圓周、折線逃跑等。這可以用狀態(tài)模式或簡單的策略模式來實(shí)現(xiàn)讓代碼更清晰。5.2 炮臺升級與多種子彈炮臺可以有等級隨著玩家金幣增多而升級。升級后子彈威力、射速、甚至子彈形態(tài)如從單發(fā)變?yōu)樯⑸涠伎梢愿淖儭_@需要設(shè)計(jì)一個(gè)CannonLevel的數(shù)據(jù)結(jié)構(gòu)并在Cannon類中管理當(dāng)前等級和對應(yīng)的屬性。5.3 游戲狀態(tài)與UI實(shí)現(xiàn)開始菜單、游戲進(jìn)行中、暫停、結(jié)束等狀態(tài)。這可以通過一個(gè)GameState枚舉和GameManager中的狀態(tài)機(jī)來管理。UI繪制也要根據(jù)狀態(tài)變化例如游戲結(jié)束時(shí)在屏幕中央繪制“Game Over”和最終得分。5.4 音效與粒子效果進(jìn)階雖然Swing對多媒體支持有限但可以播放簡單的WAV音效使用javax.sound.sampled.Clip。粒子效果如子彈擊中后的水花、魚死亡后的金幣飛濺可以用一堆小的、有生命周期的Sprite對象來模擬它們按照一定的物理規(guī)則重力、消散運(yùn)動為游戲增添不少動感。完成這個(gè)項(xiàng)目后我最大的體會是編程語言和框架只是工具解決問題的思路和將復(fù)雜系統(tǒng)分解、建模的能力才是核心。用Java Swing寫《捕魚達(dá)人》就像用最基礎(chǔ)的木工工具做一把椅子過程可能比用電動機(jī)器繁瑣但你對“椅子”每個(gè)部件的連接、承重、比例會有更深刻的理解。這份理解會讓你在未來使用任何高級游戲引擎時(shí)都更加得心應(yīng)手。如果你正在學(xué)習(xí)Java并且對“八股文”之外的應(yīng)用感到好奇我強(qiáng)烈建議你親手實(shí)現(xiàn)一個(gè)這樣的小項(xiàng)目它帶給你的收獲遠(yuǎn)比死記硬背十個(gè)設(shè)計(jì)模式要大得多。