简介:这份资源是经典社交休闲游戏QQ农场的Java完整实现,面向Java初学者、课程设计学生及想通过实战理解面向对象开发的开发者,帮助解决缺少可运行小游戏项目练手的问题。压缩包共303个文件,约6.93MB,其中28个java源文件与61个class文件构成核心逻辑,162个png、21个gif和1张jpg提供作物、动物与界面素材,另有19个txt、5个xml及若干配置与说明文件,覆盖种植、养殖、互动、升级等模块。已有1276人学习下载,说明其作为教学案例具备一定参考价值。读者可从中获得完整的农场经营游戏源码,理解虚拟经济、作物生长与社交互动等机制在Java中的落地方式,并借助清晰的目录结构快速定位农场、土地、商店、用户数据等类,适合用于课程设计参考、二次开发或Java面向对象练习。
1. 用 Java 复刻 QQ 农场:从种下一颗种子到收获的完整闭环
很多人第一次接触「Java 小游戏」都是从 QQ 农场这类养成玩法开始的。它看起来简单——种地、浇水、施肥、收获、偷菜,但真正动手写才发现,背后是一整套状态机、定时器、数据持久化和事件驱动的组合拳。这个标题要解决的核心问题是:如何用纯 Java(不依赖游戏引擎)实现一个可运行、可扩展的农场小游戏,并且把源码结构讲清楚,让你能直接跑起来、改起来。适合有 Java 基础、想通过一个完整项目串联面向对象编程、集合框架、Swing/JavaFX 界面和文件存储的开发者。如果你正在找 Java 课程设计案例源码,或者想用一个小游戏把 Java 基础面试题里的知识点落地,这个方向比刷题更有效。接下来我会按「数据模型 → 核心逻辑 → 界面交互 → 存档读档 → 避坑」的顺序,把每一步的参数和代码都摊开讲。
2. 农场的数据模型:作物、地块与玩家状态怎么设计
2.1 为什么先定模型再写界面
我见过太多人一上来就拖按钮、画格子,结果写到一半发现「成熟时间」没地方存、「偷菜记录」和「地块状态」耦合在一起,最后只能推倒重来。农场游戏的核心不是界面,而是状态。作物有生长阶段,地块有归属和锁定状态,玩家有金币、等级、经验。这些状态在任意时刻都必须能完整描述当前局面,并且能序列化到磁盘。所以第一步是把实体类定清楚,让后续所有操作都围绕这些对象展开。
常见做法是三个核心类:Crop(作物模板)、Plot(地块实例)、Player(玩家)。Crop定义静态属性——名称、种子价格、成熟所需秒数、各阶段售价;Plot持有当前种植的Crop引用、种植时间戳、是否被偷过;Player持有金币、经验、地块数组。这样设计的好处是,作物模板可以配置多份(萝卜、白菜、草莓),地块只关心「我种了什么、什么时候种的」,玩家只关心「我有多少钱、多少地」。
2.2 作物与地块的字段定义
下面是最小可运行的数据模型,字段都带注释,你可以直接抄进项目。
import java.io.Serializable; import java.time.LocalDateTime; // 作物模板:一种作物对应一个实例,全局共享 public class Crop implements Serializable { private String name; // 作物名称,如"萝卜" private int seedCost; // 种子价格 private int matureSeconds; // 从种植到成熟所需秒数 private int sellPrice; // 成熟后售价 private int expReward; // 收获获得经验 public Crop(String name, int seedCost, int matureSeconds, int sellPrice, int expReward) { this.name = name; this.seedCost = seedCost; this.matureSeconds = matureSeconds; this.sellPrice = sellPrice; this.expReward = expReward; } // getter/setter 省略,实际项目用 Lombok 或手写 public String getName() { return name; } public int getSeedCost() { return seedCost; } public int getMatureSeconds() { return matureSeconds; } public int getSellPrice() { return sellPrice; } public int getExpReward() { return expReward; } }import java.io.Serializable; import java.time.LocalDateTime; // 地块:每个格子一个实例,记录种植状态 public class Plot implements Serializable { private Crop plantedCrop; // 当前种植的作物,null 表示空地 private LocalDateTime plantTime; // 种植时间 private boolean stolen; // 是否已被偷过(防止重复偷) public boolean isEmpty() { return plantedCrop == null; } // 判断是否成熟:当前时间 - 种植时间 >= 成熟秒数 public boolean isMature() { if (isEmpty() || plantTime == null) return false; return LocalDateTime.now() .isAfter(plantTime.plusSeconds(plantedCrop.getMatureSeconds())); } // 种植:写入作物和时间,重置偷取标记 public void plant(Crop crop) { this.plantedCrop = crop; this.plantTime = LocalDateTime.now(); this.stolen = false; } // 收获:清空地块,返回作物供结算 public Crop harvest() { Crop c = this.plantedCrop; this.plantedCrop = null; this.plantTime = null; this.stolen = false; return c; } // getter/setter 省略 public Crop getPlantedCrop() { return plantedCrop; } public boolean isStolen() { return stolen; } public void setStolen(boolean stolen) { this.stolen = stolen; } }Crop用Serializable是为了后续存档直接写对象流;Plot.isMature()用LocalDateTime做时间差,比用System.currentTimeMillis()更直观,也方便调试时打印。注意matureSeconds不要设得太短,测试阶段可以设 10 秒,正式玩法定 60 到 300 秒比较合理。
2.3 玩家与农场管理器的职责划分
Player只存数据:金币、经验、等级、地块数组。真正的业务逻辑放在FarmManager里,比如「购买种子」「种植」「收获」「偷菜」。这样做的原因是,界面层只调用FarmManager的方法,不直接改Plot状态,避免逻辑散落在按钮事件里。
import java.io.Serializable; public class Player implements Serializable { private String name; private int coins; private int exp; private int level; private Plot[] plots; // 地块数组,长度即土地数量 public Player(String name, int plotCount) { this.name = name; this.coins = 100; // 初始金币 this.exp = 0; this.level = 1; this.plots = new Plot[plotCount]; for (int i = 0; i < plotCount; i++) { plots[i] = new Plot(); } } // getter/setter 省略 public int getCoins() { return coins; } public void setCoins(int coins) { this.coins = coins; } public Plot[] getPlots() { return plots; } public int getExp() { return exp; } public void setExp(int exp) { this.exp = exp; } public int getLevel() { return level; } public void setLevel(int level) { this.level = level; } }参数说明:plotCount建议初始给 6 或 9,对应 2×3 或 3×3 的网格,界面好排。coins初始 100 够买几颗便宜种子,让玩家有启动资金。level暂时只做展示,后续可以加「等级解锁新作物」的逻辑。
3. 核心玩法逻辑:种植、生长、收获与偷菜的事件流
3.1 种植与收获的完整方法
FarmManager是业务入口,所有操作都从这里走。下面给出种植和收获两个关键方法,带参数校验和结算逻辑。
public class FarmManager { private Player player; private List<Crop> cropCatalog; // 可购买作物列表 public FarmManager(Player player, List<Crop> cropCatalog) { this.player = player; this.cropCatalog = cropCatalog; } // 种植:检查金币、地块是否为空 public boolean plant(int plotIndex, String cropName) { Plot plot = player.getPlots()[plotIndex]; if (!plot.isEmpty()) { System.out.println("这块地已经种了东西"); return false; } Crop crop = findCrop(cropName); if (crop == null) { System.out.println("没有这种作物"); return false; } if (player.getCoins() < crop.getSeedCost()) { System.out.println("金币不足,买不起种子"); return false; } player.setCoins(player.getCoins() - crop.getSeedCost()); plot.plant(crop); return true; } // 收获:检查成熟、结算金币和经验 public boolean harvest(int plotIndex) { Plot plot = player.getPlots()[plotIndex]; if (plot.isEmpty()) { System.out.println("空地不能收获"); return false; } if (!plot.isMature()) { System.out.println("还没成熟,再等等"); return false; } Crop crop = plot.harvest(); player.setCoins(player.getCoins() + crop.getSellPrice()); player.setExp(player.getExp() + crop.getExpReward()); checkLevelUp(); return true; } private Crop findCrop(String name) { return cropCatalog.stream() .filter(c -> c.getName().equals(name)) .findFirst().orElse(null); } private void checkLevelUp() { // 简单规则:每 100 经验升一级 int newLevel = player.getExp() / 100 + 1; if (newLevel > player.getLevel()) { player.setLevel(newLevel); System.out.println("升级了!当前等级:" + newLevel); } } }逻辑说明:plant先校验地块为空、作物存在、金币足够,三个条件都通过才扣钱并写入地块。harvest先校验非空和成熟,然后调用plot.harvest()清空地块,再结算金币和经验。checkLevelUp用经验除以 100 取整加一,简单但够用。参数方面,plotIndex从 0 开始,界面传参时注意和网格坐标对应。
3.2 偷菜逻辑与防重复机制
偷菜是 QQ 农场的灵魂,但实现时最容易出 bug——同一个人反复偷同一块地,或者偷完不标记导致无限偷。正确做法是在Plot里加stolen标记,偷之前检查,偷之后置位。
// 偷菜:只能偷别人成熟且未被偷过的地 public boolean steal(Player target, int plotIndex) { Plot plot = target.getPlots()[plotIndex]; if (plot.isEmpty() || !plot.isMature()) { System.out.println("这块地没有可偷的作物"); return false; } if (plot.isStolen()) { System.out.println("已经被偷过了,来晚了"); return false; } Crop crop = plot.getPlantedCrop(); int stealCoins = crop.getSellPrice() / 2; // 偷菜收益为售价一半 player.setCoins(player.getCoins() + stealCoins); plot.setStolen(true); System.out.println("偷到 " + crop.getName() + ",获得 " + stealCoins + " 金币"); return true; }参数说明:stealCoins取售价的一半,避免偷菜收益超过自己种。plot.setStolen(true)是关键,没有这一步就会出现「一块地偷十次」的玄学现象。注意偷菜不改变地块的种植状态,作物还在,只是标记为已偷,主人收获时仍然可以正常收。
3.3 生长阶段与定时刷新
作物生长不是瞬间完成的,界面需要定时刷新显示当前阶段。常见做法是用javax.swing.Timer每 1 秒触发一次重绘,根据plantTime和matureSeconds计算进度百分比。
// 在界面初始化时启动定时器 Timer timer = new Timer(1000, e -> { for (int i = 0; i < player.getPlots().length; i++) { Plot plot = player.getPlots()[i]; if (!plot.isEmpty()) { long elapsed = java.time.Duration.between( plot.getPlantTime(), LocalDateTime.now()).getSeconds(); long total = plot.getPlantedCrop().getMatureSeconds(); int percent = (int) Math.min(100, elapsed * 100 / total); // 更新对应格子的进度显示 plotLabels[i].setText(plot.getPlantedCrop().getName() + " " + percent + "%"); } } }); timer.start();逻辑说明:Timer每秒执行一次,遍历所有地块,计算已过秒数占总成熟秒数的百分比,更新标签文字。Math.min(100, ...)防止超过 100%。注意plot.getPlantTime()在Plot里要有 getter,否则这里拿不到。这个定时器只做界面刷新,不改变游戏状态,状态变化仍然由玩家操作触发。
4. 界面与交互:用 Swing 搭一个能点的农场网格
4.1 网格布局与按钮绑定
Swing 虽然老,但做课程设计足够,而且不用配环境。核心是用GridLayout排地块,每个地块一个JButton,点击时根据当前状态决定是种植还是收获。
import javax.swing.*; import java.awt.*; public class FarmFrame extends JFrame { private FarmManager manager; private JButton[] plotButtons; private JLabel coinLabel; public FarmFrame(FarmManager manager) { this.manager = manager; int plotCount = manager.getPlayer().getPlots().length; int cols = 3; int rows = (int) Math.ceil(plotCount / (double) cols); setLayout(new BorderLayout()); JPanel gridPanel = new JPanel(new GridLayout(rows, cols, 5, 5)); plotButtons = new JButton[plotCount]; for (int i = 0; i < plotCount; i++) { final int index = i; plotButtons[i] = new JButton("空地"); plotButtons[i].addActionListener(e -> onPlotClick(index)); gridPanel.add(plotButtons[i]); } coinLabel = new JLabel("金币:" + manager.getPlayer().getCoins()); add(coinLabel, BorderLayout.NORTH); add(gridPanel, BorderLayout.CENTER); setTitle("Java QQ农场"); setSize(400, 400); setDefaultCloseOperation(EXIT_ON_CLOSE); setVisible(true); } private void onPlotClick(int index) { Plot plot = manager.getPlayer().getPlots()[index]; if (plot.isEmpty()) { // 空地:弹出作物选择 String[] options = {"萝卜", "白菜", "草莓"}; String choice = (String) JOptionPane.showInputDialog( this, "选择种子", "种植", JOptionPane.QUESTION_MESSAGE, null, options, options[0]); if (choice != null) { manager.plant(index, choice); } } else if (plot.isMature()) { manager.harvest(index); } else { JOptionPane.showMessageDialog(this, "还没成熟"); } refreshUI(); } private void refreshUI() { coinLabel.setText("金币:" + manager.getPlayer().getCoins()); for (int i = 0; i < plotButtons.length; i++) { Plot plot = manager.getPlayer().getPlots()[i]; if (plot.isEmpty()) { plotButtons[i].setText("空地"); } else { plotButtons[i].setText(plot.getPlantedCrop().getName() + (plot.isMature() ? " 可收获" : " 生长中")); } } } }逻辑说明:onPlotClick根据地块状态分流——空地弹选择框,成熟直接收获,未成熟提示等待。refreshUI在每次操作后刷新按钮文字和金币显示。参数方面,cols设 3 是为了网格好看,你可以改成 2 或 4。注意final int index = i是 Java 闭包捕获局部变量的要求,少了这个编译不过。
4.2 存档与读档:用对象流保存进度
游戏关掉再打开,进度不能丢。最简单的方式是用ObjectOutputStream把Player整个写文件,读的时候反序列化回来。
import java.io.*; public class SaveManager { private static final String SAVE_FILE = "farm_save.dat"; public static void save(Player player) { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(SAVE_FILE))) { oos.writeObject(player); System.out.println("存档成功"); } catch (IOException e) { e.printStackTrace(); } } public static Player load() { File file = new File(SAVE_FILE); if (!file.exists()) return null; try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) { return (Player) ois.readObject(); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); return null; } } }逻辑说明:save把Player对象序列化到farm_save.dat,load反序列化返回。注意Player、Plot、Crop都必须实现Serializable,否则运行时报NotSerializableException。LocalDateTime本身是可序列化的,不用额外处理。存档文件放在项目根目录,测试时删掉就能重新开始。
5. 避坑与排查:农场小游戏最容易翻车的 5 个点
5.1 现象:收获后金币没变,但作物消失了
原因:harvest方法里先调用了plot.harvest()清空地块,然后才结算金币,如果结算前抛异常,作物就白没了。解决:把结算逻辑放在清空之前,或者用事务式写法——先算好收益,再清地块,最后加钱。
5.2 现象:偷菜后主人收获,收益还是全额
原因:偷菜只标记了stolen,但主人收获时没有检查这个标记。解决:在harvest里判断plot.isStolen(),如果被偷过,主人收益减半,或者按比例扣减。
5.3 现象:定时器刷新时界面卡死
原因:在Timer的actionPerformed里做了耗时操作,比如读写文件。解决:定时器只做界面刷新,存档用单独的按钮触发,或者放到后台线程。
5.4 现象:读档后地块显示为空,但存档前明明种了东西
原因:Plot里的plantedCrop是Crop引用,如果Crop没有实现Serializable,序列化时会静默失败或抛异常。解决:确保Crop、Plot、Player三个类都实现Serializable,并且serialVersionUID保持一致。
5.5 现象:点击按钮没反应,控制台也没报错
原因:ActionListener没绑上,或者final int index忘了写导致编译错误被忽略。解决:检查addActionListener是否在循环里正确调用,确认index是 final 或 effectively final。
6. 进阶技巧:用配置表驱动作物,不改代码就能加新种子
写到后面你会发现,每次加一种新作物都要改FarmManager里的硬编码列表,很烦。我一般会把作物配置抽到一个crops.properties文件里,启动时加载,这样加新种子只需要改配置。
# crops.properties crop.0.name=萝卜 crop.0.seedCost=10 crop.0.matureSeconds=30 crop.0.sellPrice=20 crop.0.expReward=5 crop.1.name=白菜 crop.1.seedCost=20 crop.1.matureSeconds=60 crop.1.sellPrice=45 crop.1.expReward=10 crop.2.name=草莓 crop.2.seedCost=50 crop.2.matureSeconds=120 crop.2.sellPrice=120 crop.2.expReward=25import java.io.InputStream; import java.util.*; public class CropLoader { public static List<Crop> load() { List<Crop> list = new ArrayList<>(); Properties props = new Properties(); try (InputStream in = CropLoader.class .getClassLoader().getResourceAsStream("crops.properties")) { props.load(in); } catch (Exception e) { e.printStackTrace(); return list; } int i = 0; while (props.containsKey("crop." + i + ".name")) { String name = props.getProperty("crop." + i + ".name"); int seedCost = Integer.parseInt( props.getProperty("crop." + i + ".seedCost")); int mature = Integer.parseInt( props.getProperty("crop." + i + ".matureSeconds")); int sell = Integer.parseInt( props.getProperty("crop." + i + ".sellPrice")); int exp = Integer.parseInt( props.getProperty("crop." + i + ".expReward")); list.add(new Crop(name, seedCost, mature, sell, exp)); i++; } return list; } }逻辑说明:CropLoader.load()从 classpath 读crops.properties,按crop.0、crop.1的顺序解析,遇到不存在的索引就停止。这样加新作物只需要在文件末尾追加crop.3开头的几行,不用动 Java 代码。参数方面,matureSeconds建议按「秒」为单位,测试时改小,上线时改大。注意getResourceAsStream的路径是相对于resources目录的,Maven 项目放在src/main/resources下即可。
验证配置是否生效,可以在main方法里打印加载结果:
public static void main(String[] args) { List<Crop> crops = CropLoader.load(); crops.forEach(c -> System.out.println( c.getName() + " 种子价:" + c.getSeedCost() + " 成熟:" + c.getMatureSeconds() + "秒")); }如果控制台输出三种作物信息,说明配置读取成功。如果报NullPointerException,检查crops.properties是否在 classpath 下,文件名大小写是否一致。
我自己的习惯是,每加一种新作物,先改配置,再跑一遍main确认加载无误,最后才开界面测试。这样能把「配置错误」和「逻辑错误」分开排查,省掉很多来回翻车的功夫。希望帮到你。
本文还有配套的精品资源,点击获取