原生Canvas 2D像素农场小游戏:主循环、状态机与存档设计 简介这套课程设计项目以“麦田物语”为主题面向计算机、数字媒体及游戏开发相关专业的学生与自学者可作为毕业设计选题、课程作业实现和完整项目复盘参考。压缩包共2007个文件、约109.8MB主体为meta、png、asset、anim等Unity常见资源与动画文件并包含wav/ogg音频、txt/json说明与配置、cs脚本、预制体、控制器及混音器设置覆盖贴图素材、动画片段、场景资源和工程配置等模块。内容中可见角色坠落、左右旋转以及树木黄色/绿色变化等动画资源能够帮助读者理解资源目录组织、动画状态衔接和素材引用方式。已有287人学习适合需要参照完整工程结构、梳理素材与动画配置流程、补充课程设计实践思路的读者。1. 从一块能种地的像素田说起这个课程设计项目到底做了什么打开浏览器用方向键走到田埂上按空格锄地、按 1 播种、按 2 浇水几天后麦穗变金黄割下来塞进背包去村口的小摊卖掉换种子钱——这就是这个「麦田物语」主题的课程设计项目全部的第一印象。它是一份典型的毕业设计级别的作品题材取自农场经营玩法核心循环是「开垦→播种→浇水→生长→收获→交易→再投资」外壳是像素风、俯视角、单机离线技术上是纯前端的 Canvas 2D 小游戏没有任何后端和账号体系。适合三类人拿来用需要一份能当场演示、代码量可控、讲得清架构的毕业设计底稿的同学想从零手写一遍游戏主循环和状态机的自学者以及想找个干净的小项目练手二次开发的人。它不追求画面追求的是整套逻辑闭环能被读懂、被改动。2. 技术栈与目录结构为什么一个像素农场该用原生 Canvas 2D 而不是游戏引擎2.1 技术选型零依赖是这份课程设计最大的优势课程设计项目和商业项目在选型上的判断标准完全不同。商业项目看长期维护成本课程设计看的是「能不能在答辩机房里跑起来、能不能让评审在五分钟内看懂你在干什么」。基于这个前提这套项目选的是 HTML5 CSS3 原生 ES Module JavaScript Canvas 2D全程零第三方依赖没有 webpack、没有 vite、没有 npm install。这么选有三个实打实的好处。第一是部署成本为零把整个文件夹丢到任意静态服务器上或者本地起一个 http-server 就能跑不用等依赖下载也不怕机房断网。第二是黑匣子少游戏主循环、渲染、输入、存档全部在你自己的代码里答辩时被问到「这个动画怎么动起来的」你能直接翻到 requestAnimationFrame 那一行而不是回答「引擎内部处理的」。第三是代码量可控这类项目整体逻辑代码通常在一千五到两千行量级一个人两三周能写完并讲透。代价也要说清楚没有场景编辑器地图是手写二维数组没有物理引擎碰撞就是矩形相交判断没有资源管线精灵图靠程序化绘制或者一张 sprite sheet 手工切。对课程设计来说这些代价是可接受的反而是加分项——手写的东西才讲得出细节。常见做法是按职责分文件而不是按「类」分文件因为小项目里过度面向对象会让调用链变得难追。我一般会把「能跑的」和「能改的」彻底分开data/放配置表改数值不用碰逻辑core/放引擎级代码主循环、输入、摄像机systems/放业务系统种植、经济、存档scenes/放场景。这样二次开发的人第一眼就知道该改哪儿。提示如果指导老师明确要求使用某个引擎或框架那就按老师的要求来选型服从评审规则本文这套结构适合「自选技术栈」的场景。2.2 目录结构与各模块职责下面这份目录是这个项目落地后比较稳的形态文件数量不多但职责边界清楚farm-story/ ├── index.html # 唯一的页面入口挂 canvas 和 HUD 容器 ├── css/ │ └── style.css # 画布居中、像素化渲染、HUD 定位 ├── assets/ │ ├── tiles.png # 地形图集草地/泥土/水/田垄 │ └── actors.png # 角色与作物图集 ├── src/ │ ├── main.js # 启动入口初始化、加载、进场景 │ ├── core/ │ │ ├── loop.js # 固定步长主循环 │ │ ├── input.js # 键盘状态表 边沿触发 │ │ ├── camera.js # 摄像机跟随与边界钳制 │ │ └── assets.js # 图片预加载统一返回 Promise │ ├── systems/ │ │ ├── tilemap.js # 瓦片地图、坐标换算、可交互判定 │ │ ├── farming.js # 锄地/播种/浇水/收获 │ │ ├── growth.js # 作物生长推进 │ │ ├── inventory.js # 背包增删查 │ │ ├── economy.js # 买卖、日结算 │ │ └── save.js # localStorage 读写与版本迁移 │ ├── entities/ │ │ └── player.js # 玩家状态机与动画帧 │ ├── scenes/ │ │ ├── farmScene.js # 主玩法场景 │ │ └── hud.js # 背包条、金币、日期显示 │ └── data/ │ ├── crops.js # 作物配置表 │ └── map.js # 地图二维数组 └── README.md # 启动步骤与操作说明这里有个容易被忽略的细节data/map.js里的地图是一张纯数字二维数组0 是草地、1 是泥土、2 是水、3 是已开垦的田垄。这么设计的目的是让「地形」和「状态」解耦——地形是静态的玩家开垦只是把某个格子的地形从 1 改成 3不需要新增图层也不需要额外的碰撞体。地图尺寸建议控制在 40×30 格、单格 32px这样整张地图 1280×960 像素一屏画不完正好需要摄像机跟随答辩演示时有画面感。目录职责二次开发时的改动频率data/数值、地图、对话文本最高改平衡性只动这里systems/玩法规则中加新玩法才动core/循环、输入、渲染基础设施最低基本一次写好scenes/场景组织与 UI中加新场景才动2.3 启动链路与固定步长主循环从index.html进来之后的调用链是DOMContentLoaded→main.js里loadAssets()返回 Promise → 预加载完成 → 创建Player、Tilemap、读存档 → 进入farmScene→ 启动loop。主循环这块是最值得讲清楚的一段因为很多同学第一次写游戏时最容易在这里翻车切个标签页回来人物直接瞬移出地图。// src/core/loop.js const STEP 1000 / 60; // 逻辑帧间隔单位毫秒固定 60 帧 const MAX_DELTA 250; // 单帧最大补偿时间防止切回页面后一次性补上千帧 let lastTime 0; let accumulator 0; export function startLoop(update, render) { lastTime performance.now(); function frame(now) { let delta now - lastTime; lastTime now; if (delta MAX_DELTA) delta MAX_DELTA; // 给 delta 上闸这是救命的 accumulator delta; // 逻辑固定步长推进保证不同刷新率下手感一致 while (accumulator STEP) { update(STEP / 1000); // 业务层统一用「秒」做时间单位 accumulator - STEP; } render(accumulator / STEP); // 传 0~1 的插值系数用于渲染平滑 requestAnimationFrame(frame); } requestAnimationFrame(frame); }update(dt)里做的是纯逻辑玩家位移、作物生长计时、交互判定。render(alpha)里只读数据画图绝不修改状态。这条「更新与渲染分离」的规矩看起来教条但它是这个项目能扩展到两三千行还不乱的根本原因。STEP取 16.67 毫秒是行业惯例取 1000/30 也能跑但浇水动画会明显发顿。MAX_DELTA这个钳制是血泪经验浏览器在标签页切到后台时会暂停 rAF切回来那一帧的delta可能是几万毫秒如果不钳制while循环会一口气跑上千次update玩家的位移累加直接把他送出地图表现出来就是「切个窗口回来人没了」。这个坑我在早期版本上踩过一次后来所有带帧循环的项目都会强制加闸。assets.js的预加载也值得留一行说明用Promise.all包住所有Image.onload加载失败时reject掉并给出具体文件名比「白屏但控制台没报错」友好太多。3. 核心模块拆解一次「锄地」动作如何串起坐标、状态机与生长计时3.1 瓦片地图与三套坐标的换算这个项目里同时存在三套坐标像素坐标画布上的 x、y、瓦片坐标第几行第几列、数组索引ty * cols tx。新手最容易犯的错误是把三者混着用结果人物走到某一列之后开始往上飘。// src/systems/tilemap.js export const TILE_SIZE 32; // 像素坐标 - 瓦片坐标 export function worldToTile(x, y) { return { tx: Math.floor(x / TILE_SIZE), ty: Math.floor(y / TILE_SIZE) }; } // 瓦片坐标 - 该格中心点的像素坐标角色站位用中心点更方便 export function tileToWorldCenter(tx, ty) { return { x: tx * TILE_SIZE TILE_SIZE / 2, y: ty * TILE_SIZE TILE_SIZE / 2 }; } // 二维 - 一维索引地图用一维数组存更省内存也更好遍历 export function tileIndex(tx, ty, cols) { return ty * cols tx; } // 判断玩家脚下这一格能不能交互 export function getFacingTile(player, map) { const { tx, ty } worldToTile(player.x, player.y); const offset { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; const [dx, dy] offset[player.facing] || [0, 0]; const ftx tx dx; const fty ty dy; if (ftx 0 || fty 0 || ftx map.cols || fty map.rows) return null; return { tx: ftx, ty: fty, tile: map.cells[tileIndex(ftx, fty, map.cols)] }; }getFacingTile是整套交互的核心玩家按空格时程序不看鼠标、不看点击位置而是看角色朝向的那一格。facing是玩家状态机里维护的字符串取值up/down/left/right。返回值里带上tile地形编号上层逻辑就能判断「这一格是草地还是田垄」草地允许开垦、田垄允许播种、成熟作物允许收获。参数说明TILE_SIZE必须全局唯一渲染、碰撞、交互三处都引用同一个常量任何一个地方写死32都是隐患。地图越界判断必须做否则玩家走到地图边缘按空格map.cells[负数索引]返回undefined后面的switch会静默走进default分支表现是「边缘地块按了没反应」很难查。3.2 玩家状态机与输入层的边沿触发输入最容易翻车的地方是「按住空格一秒钟锄了六十次地」。原因是每帧都在检测keydown状态而玩家期待的是「按一次触发一次」。解决办法是状态表加边沿触发。// src/core/input.js const keys new Set(); // 当前按住的键 const pressed new Set(); // 本帧刚按下的键边沿 export function bindInput() { window.addEventListener(keydown, (e) { if (!keys.has(e.code)) pressed.add(e.code); // 只在从松到按时记一次 keys.add(e.code); if ([Space, ArrowUp, ArrowDown].includes(e.code)) e.preventDefault(); }); window.addEventListener(keyup, (e) keys.delete(e.code)); } export const isDown (code) keys.has(code); export const wasPressed (code) pressed.has(code); // 每帧逻辑末尾必须清空否则「刚按下」会一直为真 export function endFrame() { pressed.clear(); }// src/entities/player.js export const PlayerState { IDLE: idle, WALK: walk, ACTION: action }; export function updatePlayer(player, dt, input, map) { // 动作状态期间锁住移动播完再交还控制权 if (player.state PlayerState.ACTION) { player.actionTimer - dt; if (player.actionTimer 0) player.state PlayerState.IDLE; return; } let dx 0, dy 0; if (input.isDown(ArrowLeft)) { dx -1; player.facing left; } if (input.isDown(ArrowRight)) { dx 1; player.facing right; } if (input.isDown(ArrowUp)) { dy -1; player.facing up; } if (input.isDown(ArrowDown)) { dy 1; player.facing down; } if (dx || dy) { player.state PlayerState.WALK; const len Math.hypot(dx, dy) || 1; // 斜向归一化否则斜着走快 41% const speed 96; // 像素/秒 const nx player.x (dx / len) * speed * dt; const ny player.y (dy / len) * speed * dt; if (isWalkable(nx, player.y, map)) player.x nx; if (isWalkable(player.x, ny, map)) player.y ny; } else { player.state PlayerState.IDLE; } }斜向归一化这一行是很多人漏掉的同时按上和右位移是(1, 1)模长 1.414实际速度比直线快四成玩起来能明显感觉到「斜着跑更快」。isWalkable分别对 x、y 单独判定是为了实现贴墙滑动——被水挡住时还能沿墙平移手感比「两个方向一起判、撞了就全停」好一个档次。PlayerState.ACTION这个状态的存在意义是给动作加锁锄地动画 0.3 秒这 0.3 秒内玩家不能移动、不能再次触发交互避免动画和逻辑打架。3.3 作物生长用「累计秒数」而不是「日期轮询」作物生长的实现有两种常见写法一种是每天结算时统一遍历所有地块另一种是每块地自己记累计时间。这个项目选后者因为它不依赖「天」这个游戏内单位玩家在一天里挂机也能看到作物长一阶段反馈更连续。// src/data/crops.js // stageSeconds 是每一阶段需要的现实秒数做成表方便调平衡 export const CROP_DB { wheat: { name: 麦子, stages: 4, stageSeconds: 20, seedPrice: 10, sellPrice: 38 }, carrot: { name: 胡萝卜, stages: 3, stageSeconds: 14, seedPrice: 6, sellPrice: 18 }, pumpkin:{ name: 南瓜, stages: 5, stageSeconds: 40, seedPrice: 30, sellPrice: 150 } };// src/systems/growth.js import { CROP_DB } from ../data/crops.js; export function registerPlant(tile, cropId) { tile.crop { id: cropId, stage: 1, elapsed: 0, watered: false, mature: false }; } export function updateGrowth(tilemap, dt) { for (const tile of tilemap.cells) { const crop tile.crop; if (!crop || crop.mature) continue; // 只有浇过水的地块才推进生长 if (!crop.watered) continue; crop.elapsed dt; const cfg CROP_DB[crop.id]; const targetStage Math.min( cfg.stages, Math.floor(crop.elapsed / cfg.stageSeconds) 1 ); if (targetStage ! crop.stage) { crop.stage targetStage; if (crop.stage cfg.stages) crop.mature true; } } } export function harvest(tile, inventory) { if (!tile.crop || !tile.crop.mature) return false; const cfg CROP_DB[tile.crop.id]; inventory.add(tile.crop.id, 1); tile.crop null; // 收完清空地块保留田垄状态 return true; }updateGrowth是按「秒」累加的dt来自固定步长主循环所以不论显示器是 60Hz 还是 144Hz作物成熟的总时长都是恒定的不会出现「高刷屏种地更快」的玄学现象。watered这个字段是设计上的取舍浇水只影响「当天是否推进生长」日结算时统一重置为false。这样一来玩家每天的节奏是「起床→浇水→干别的→睡觉」形成稳定的日循环。如果你想让玩法更硬核可以把它改成「水分值」浇一次加 50 点、每秒衰减 1 点、归零停止生长代码结构不用动只改updateGrowth里的判断。tile.crop直接挂在瓦片对象上好处是收获、渲染、存档三处都能从同一个位置拿数据坏处是瓦片对象会越来越胖等你要加「杂草」「害虫」这些状态时建议拆成tile.terrain、tile.crop、tile.deco三个字段而不是全塞进去。3.4 渲染分层与摄像机渲染顺序是固定的四层地形层 → 作物层 → 角色层 → HUD 层。前两层跟随摄像机偏移HUD 层固定屏幕坐标这是所有 2D 游戏的通用做法。// src/core/camera.js export function updateCamera(cam, target, map, viewW, viewH) { cam.x target.x - viewW / 2; cam.y target.y - viewH / 2; // 钳制在地图范围内避免镜头露出画布外的空白 cam.x Math.max(0, Math.min(cam.x, map.cols * 32 - viewW)); cam.y Math.max(0, Math.min(cam.y, map.rows * 32 - viewH)); }// src/scenes/farmScene.js 中的绘制片段 export function render(ctx, map, player, cam, alpha) { ctx.imageSmoothingEnabled false; // 像素风必开否则放大后一片糊 ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // 只画视野内的瓦片40x30 的地图全画会白白浪费一半性能 const startX Math.floor(cam.x / 32); const endX Math.min(map.cols, startX ctx.canvas.width / 32 2); for (let ty 0; ty map.rows; ty) { for (let tx startX; tx endX; tx) { const sx tx * 32 - cam.x; const sy ty * 32 - cam.y; drawTile(ctx, map.cells[ty * map.cols tx], sx, sy); } } drawPlayer(ctx, player, cam); }imageSmoothingEnabled false是像素风的开关忘了这一行整张图会被浏览器双线性插值糊成马赛克。渲染做视口裁剪是因为drawImage调用次数直接影响帧率全图 1200 格每帧都画在低配机房电脑上能掉到 40 帧裁掉视野外之后稳定 60。endX多加 2 格是防止镜头移动时边缘露出 1 像素缝隙这个偏移量按理说加 1 就够加 2 是保守写法。4. 存档与经济系统让课程设计从「能动」变成「能玩」4.1 localStorage 存档结构设计与版本迁移课程设计项目不需要服务端但一定要有存档否则答辩演示到一半刷新页面所有进度归零场面很难看。localStorage够用但有两个必须提前处理的问题数据格式演进以及写入时机。// src/systems/save.js const SAVE_KEY farm.save; const SAVE_VERSION 2; // 每次改数据结构必须 1 export function saveGame(state) { const payload { version: SAVE_VERSION, savedAt: Date.now(), day: state.day, gold: state.gold, player: { x: state.player.x, y: state.player.y, facing: state.player.facing }, inventory: [...state.inventory.entries()], // 只存有作物的地块没作物的地块靠地形数组就能还原 crops: state.tilemap.cells .map((t, i) (t.crop ? { i, ...t.crop } : null)) .filter(Boolean) }; try { localStorage.setItem(SAVE_KEY, JSON.stringify(payload)); } catch (e) { console.warn(存档写入失败可能是容量超限或被禁用, e); } } export function loadGame() { const raw localStorage.getItem(SAVE_KEY); if (!raw) return null; let data; try { data JSON.parse(raw); } catch { localStorage.removeItem(SAVE_KEY); // 坏档直接丢弃别让玩家卡在报错上 return null; } return migrate(data); } function migrate(data) { // 版本 1 没有 watered 字段迁移时补默认值 if (data.version 2) { data.crops (data.crops || []).map((c) ({ ...c, watered: false })); data.version 2; } return data; }crops只存有作物的格子是空间上的取舍——1200 格全存 JSON 大概 200KB只存几十株作物只有几 KBlocalStorage通常 5MB 上限省着用没坏处。version字段是后悔药没有它你改一次数据结构就得让所有人清缓存重来答辩现场不可能干这事。写入时机选在三处每天结算完、玩家退出场景时、visibilitychange变成hidden时。最后一个是关键很多人只在「睡觉」时存档玩家关标签页就丢一天进度。4.2 背包与作物配置表的配合背包用MapCropId, count存比数组清爽查询是 O(1)。// src/systems/inventory.js export class Inventory { constructor() { this.items new Map(); } add(id, n 1) { this.items.set(id, (this.items.get(id) || 0) n); } remove(id, n 1) { const cur this.items.get(id) || 0; if (cur n) return false; if (cur n) this.items.delete(id); else this.items.set(id, cur - n); return true; } count(id) { return this.items.get(id) || 0; } total() { return [...this.items.values()].reduce((a, b) a b, 0); } }作物表里我把「种子价」和「收获价」放在同一行这是有意的因为经济平衡的计算必须在同一处可见。判断一个作物值不值得种看的是「单秒收益」而不是绝对利润作物成熟耗时秒种子成本收获售价单株净利每分钟净利胡萝卜4261812约 17麦子80103828约 21南瓜20030150120约 36这张表就是玩法设计的全部秘密南瓜单位时间收益最高但前期买不起种子、占地时间长形成「先种胡萝卜回本、再滚雪球买南瓜」的节奏。答辩时能把这张表讲出来比说「我做了个种田游戏」有说服力得多。4.3 日结算与经济循环日结算做三件事重置浇水状态、扣除每日维护费可选、推进日期并刷新天气。// src/systems/economy.js import { CROP_DB } from ../data/crops.js; export function sellAll(inventory, wallet) { let income 0; for (const [id, n] of inventory.items) { const cfg CROP_DB[id]; if (!cfg) continue; income cfg.sellPrice * n; } inventory.items.clear(); wallet.gold income; return income; } export function endDay(state) { // 1. 未浇水的作物重置新的循环开始 for (const tile of state.tilemap.cells) { if (tile.crop) tile.crop.watered false; } // 2. 日期推进 state.day 1; // 3. 自动存档避免忘存 saveGame(state); return { day: state.day, gold: state.gold }; }经济循环的闭环在这里收口卖作物拿钱、用钱买种子、买种子的钱又变成作物。只要保证「任一作物的净利为正」这个循环就不会崩。如果要在答辩里演示数值调优最省事的做法是让CROP_DB里的价格参数可以在控制台动态改改完立刻看到收益变化评审会觉得你对「参数化设计」这件事有感觉。5. 常见问题排查跑不起来、人物飘移、存档消失的现场诊断5.1 启动与环境类问题现象双击index.html打开控制台报Access to script at file:///... from origin null has been blocked by CORS policy画面全黑。原因这个项目用的是 ES Modulescript typemodule在file://协议下会被浏览器的同源策略拦掉跟代码写错没关系。这是最高频的「跑不起来」几乎每个第一次拿到源码的人都要撞一次。解决别双击起一个静态服务器。最简单的是在项目根目录执行python -m http.server 8080然后浏览器打开http://localhost:8080装了 Node 的话npx serve也行。如果坚持要用file://那就得把所有import/export改成挂window的全局脚本代价太大不推荐。现象页面能打开但画布一片空白控制台没有任何报错。原因图片资源路径写错了或者assets.js的预加载 Promise 没被await就进场景了。图片没加载完就drawImage浏览器不会报错只是什么都不画。解决在loadAssets里给每个img.onerror挂上console.error(资源加载失败:, src)然后在main.js用await loadAssets()再启动循环。路径统一用相对路径别用/assets/tiles.png这种以根开头的写法部署到子目录会全挂。5.2 渲染与状态类问题现象按住方向键走一会儿人物开始偏离格子中心越走越歪最后视觉上「浮」在田埂上。原因位移用的是player.x speed * dt浮点数持续累加坐标不再是 32 的整数倍。而渲染时用worldToTile做Math.floor取整视觉上就出现了半格偏移。解决逻辑坐标允许浮点但所有「对齐判断」必须走Math.floor渲染时用Math.round而不是直接截断。更彻底的做法是渲染时对摄像机偏移也做Math.round(cam.x)让像素始终落在整数像素上这是像素风避免抖动的标准操作。现象切到别的标签页再切回来人物瞬移到地图外或者画面卡住不动。原因requestAnimationFrame在后台会暂停恢复时delta极大如果主循环没有MAX_DELTA钳制while会一次性补几百上千次逻辑帧。解决按 2.3 节的写法加上MAX_DELTA 250的闸门。这个坑属于典型的「不测不知道、一测吓一跳」我现在的习惯是任何带accumulator的循环都先写上钳制再写别的。5.3 存档与数据类问题现象改了一次save.js的字段名老玩家一进游戏就白屏控制台报Cannot read properties of undefined。原因旧的localStorage数据还躺在浏览器里字段结构和新代码对不上读取时data.crops是undefined后面.map直接炸。解决给存档加version字段和migrate函数读到旧版本先补齐缺失字段再返回。开发期还可以加一个「URL 带?reset1就清档」的开关省得每次手动开 DevTools。这里千万不能偷懒一旦数据结构变过两次以上没有版本号就彻底失控了。现象作物一直在长但玩家不浇水它也长浇水系统形同虚设。原因updateGrowth里漏了if (!crop.watered) continue;这行或者watered在日结算时被重置但浇水动作没真正写进地块对象。解决在地块上打console.log看watered的实际值确认「浇水 → 写入 tile.crop.watered → 生长判断」这条链没断。这类问题不要靠读代码猜直接打印三个关键点最快。6. 二次开发把课堂 Demo 改造成能撑住答辩的作品课程设计项目最容易死在一个地方功能都能跑但没有任何「你做的」痕迹评审一问「这跟网上的教程有什么区别」就没词了。我的建议是从四个方向里挑一个做深不要全铺开。第一个方向是存档迁移与多存档位。把现在的单键farm.save改成farm.save.slot0/1/2加一个简单的存档选择界面再补一版migrate逻辑。这部分改动代码量小、展示效果好答辩时可以现场演示「读旧档、自动升级、数据不丢」是个很稳的加分点。第二个方向是把作物生长从「秒」换成「游戏内时钟」。加一个gameClock1 现实秒 1 游戏分钟昼夜切换时改环境光给画布叠一层半透明矩形即可冬天所有作物生长速度减半。这样项目就有了「季节系统」这个概念讲故事的空间立刻变大。第三个方向是做最简的 NPC 对话和任务。不需要复杂的对话树一个dialogues.js存 JSON按day和已完成的交易次数分支给玩家一个「卖出 10 个南瓜后解锁新种子」的目标。有目标感的小游戏和没目标感的小游戏玩起来的差别是数量级的。第四个方向是移动端适配。把键盘输入抽成一个InputAdapter接口键盘实现一套、虚拟摇杆实现一套剩下的逻辑完全不用改。这个改动最能体现架构意识也最容易被问到「为什么要抽接口」答「为了换输入源不动业务逻辑」就够了。改完之后怎么验证没改坏我一般走三步第一步清空localStorage从零开局完整走一遍「开垦→播种→浇水→睡觉→收获→卖出」第二步中途刷新页面确认存档能恢复第三步把CROP_DB里某个stageSeconds改成 1十秒内看完整条生长链是否正常推进、成熟判定是否只触发一次。这三步能覆盖八成的回归问题比漫无目的地翻代码高效得多。从那以后我每次接这类课程设计项目都会在动手写玩法之前先把主循环、存档版本号、资源加载这三件事钉死因为它们一旦返工上面所有的玩法代码都得跟着重写。玩法可以慢慢加地基只有一次机会。希望帮到你。本文还有配套的精品资源点击获取