Phaser 农场模拟系统:昼夜循环、作物生长和离线收益要先定规则

从 Phaser 客户端实现角度讲解农场模拟的时间模型、作物状态机、离线收益、可视反馈和作弊边界。

农场游戏的温柔外表下面是时间系统

农场模拟看上去比动作游戏轻松:种地、浇水、收获、卖钱,再买更多种子。可一旦你开始实现,就会发现真正难的是时间。作物生长需要时间,昼夜变化需要时间,订单刷新需要时间,离线收益也需要时间。玩家今天晚上种下番茄,明天早上打开游戏应该看到什么?如果手机时间被改到三天后怎么办?如果玩家离线期间活动结束,作物成熟但仓库满了,收益如何计算?这些问题不先定规则,代码会很快变成到处判断“当前时间”的补丁集合。

Phaser 负责画面和交互很合适:网格田地、角色走动、粒子、UI 弹窗都能快速搭建。但农场模拟的核心不应绑死在 Scene 的 update 里。我们需要一个独立的时间与生产模型,让作物状态可以在在线和离线时用同一套规则推进。画面只是状态的呈现,玩家点击只是向模型提交动作。这样做的好处是明显的:你可以测试一天后的农场状态,可以修复离线收益 bug,可以把同一套规则迁移到服务端校验。

定义游戏时间而不是直接信系统时间

农场游戏里常见两种时间:真实时间和游戏内时间。真实时间适合长线成长,比如 30 分钟成熟、每日订单、离线收益;游戏内时间适合氛围,比如早晨、黄昏、夜晚、天气。不要把两者混在一个变量里。比如作物是否成熟,应该看真实时间差;天空颜色和 NPC 行程,可以看游戏内日夜循环。否则玩家改一个“时间倍率”,可能顺便影响作物经济。

一个实用方案是用 TimeService 提供三个值:serverNowMs 表示可信当前时间,来自服务端或本地带校验的时间基准;sessionMs 表示本次打开游戏经过多久;dayPhase 表示视觉昼夜阶段。离线结算使用 serverNowMs - lastSaveMs,在线动画使用 Phaser 的 delta,昼夜表现使用游戏内循环。即使纯单机,也建议把这几层拆开,因为它们的规则不同。

flowchart TD
  A["启动游戏"] --> B["读取存档 lastSaveMs / plots / inventory"]
  B --> C["TimeService 计算离线跨度"]
  C --> D["FarmModel 推进作物状态"]
  D --> E["收益与仓库容量结算"]
  E --> F["FarmScene 渲染田地、天气、昼夜"]
  F --> G["玩家操作:播种、浇水、收获"]
  G --> H["Command 写入模型并保存"]
  H --> F

作物状态机要小而明确

作物不要只用一个 growthPercent 表示。真实项目里至少会有空地、已播种、成长中、缺水、成熟、枯萎、可收获后等待、被虫害影响等状态。如果全部靠百分比推断,UI 会很难解释,策划也很难配置。更好的做法是使用状态机:每块地有 cropIdplantedAtMswateredAtMsstatemodifiers。成长阶段由配置表决定,比如小麦 10 分钟发芽、30 分钟成熟,缺水时成长速度降低。

状态机要允许离线推进。玩家离线两小时回来,不应该每帧补算 7200 秒动画,而是直接根据时间差计算当前阶段。动画可以从旧状态过渡到新状态,但模型应该一步到位。比如作物在离线期间成熟,进入 ready;如果成熟后超过 24 小时未收获,并且这个作物会枯萎,再进入 withered。这些规则都应由配置表驱动,而不是写在作物 Sprite 里。

Phaser 场景只订阅模型变化

FarmScene 可以维护田地图层、作物 Sprite 池、天气效果和 UI,但它不应该决定作物是否成熟。模型更新后发出事件,比如 plotChanged,Scene 根据 plot id 找到对应 Sprite,切换贴图和动画。这样做能避免一个常见问题:玩家快速切换地图或打开仓库时,Scene 销毁重建导致作物状态丢失。状态在模型里,Scene 随时可以重画。

type CropState = "empty" | "seeded" | "growing" | "dry" | "ready" | "withered";

interface PlotState {
  id: string;
  cropId?: string;
  plantedAtMs?: number;
  wateredAtMs?: number;
  state: CropState;
}

interface CropConfig {
  id: string;
  growMs: number;
  dryAfterMs: number;
  witherAfterReadyMs?: number;
}

export function evaluatePlot(plot: PlotState, crop: CropConfig, nowMs: number): PlotState {
  if (!plot.cropId || !plot.plantedAtMs) return { ...plot, state: "empty" };
  const age = nowMs - plot.plantedAtMs;
  const waterAge = plot.wateredAtMs ? nowMs - plot.wateredAtMs : Number.POSITIVE_INFINITY;

  if (age >= crop.growMs) {
    const readyAge = age - crop.growMs;
    if (crop.witherAfterReadyMs && readyAge > crop.witherAfterReadyMs) {
      return { ...plot, state: "withered" };
    }
    return { ...plot, state: "ready" };
  }

  if (waterAge > crop.dryAfterMs) return { ...plot, state: "dry" };
  return { ...plot, state: age <= 1000 ? "seeded" : "growing" };
}

这段函数是纯函数,意味着它可以在 Phaser 外部测试。你可以喂入一个 plot、一个 crop 和一个 nowMs,验证 10 分钟后是什么状态。农场游戏最容易被低估的工程点就是这种可测试性。看似温和的系统,一旦有几十种作物、肥料、天气、好友帮助、活动加成,纯函数和配置表会救你很多次。

离线收益要保守计算

离线收益不是简单地把时间差乘以产量。首先要限制最大离线时长,比如最多结算 12 小时或 24 小时,避免玩家长期不登录后经济爆炸,也避免本地时间异常造成巨大收益。其次要考虑仓库容量。作物成熟但仓库满了,可以保持在田地里等待收获,而不是直接丢失;自动生产建筑则可能因为仓库满而停止。第三要记录结算明细,让玩家知道“离线 8 小时,小麦成熟 12 块,鸡蛋生产 6 个,仓库满停止 2 小时”。

如果有服务端,离线时间应以服务端时间为准,本地只做展示。如果是纯单机,可以保存最近一次可信时间和单调计时器信息。玩家把系统时间往前调,要避免负收益;往后调太多,可以触发异常提示或限制最大结算。不要试图在纯客户端完全防作弊,但要让作弊不会破坏普通玩家体验和排行榜公平。涉及排行、交易、付费加速的收益,最好由服务端确认。

昼夜循环是表现层也是提示层

昼夜循环不只是天空变色。它可以提示作物阶段、NPC 行程和可操作节奏。早晨适合浇水,下午适合交易,夜晚可以收集特殊资源。Phaser 里可以用一个全屏半透明色层、灯光贴图、粒子和音效切换来表现时间。关键是不要让视觉变化影响可读性。夜晚变暗后,田地状态、可收获标记和按钮仍然要清楚。

如果游戏内一天等于真实 20 分钟,建议把昼夜相位和真实作物成长脱钩。小麦成熟需要 30 分钟,不意味着必须跨过一个半游戏日;它只是经济规则。视觉日夜可以循环得更快,为场景提供节奏。玩家不会因为天空过了两次黄昏就认为番茄应该成熟,只要 UI 上的倒计时清楚即可。

操作命令要防止重复提交

播种、浇水、收获都应该是命令。玩家点击田地后,先由模型检查能否操作,再修改状态、扣除物品、发出事件。不要让 UI 先扣种子,再等动画结束后写状态。动画可以失败、Scene 可以被销毁、玩家可以连续点击。命令要具备幂等保护:同一块地在一次收获处理中不能重复收获,仓库满时不能先清空田地,网络请求未返回时要锁定按钮或进入 pending 状态。

批量操作尤其需要边界。玩家拖拽浇水经过 20 块地,系统不应该发送 20 个互相独立、可乱序失败的请求。客户端可以先收集目标列表,按顺序应用本地预测,再统一保存。如果中途失败,要能回滚或重新拉取模型。纯单机也要考虑保存失败,比如浏览器存储配额不足或隐私模式禁用 IndexedDB。

可视反馈要解释时间

农场游戏需要让玩家理解等待。每块地可以显示小型进度环、成熟闪光、缺水图标、剩余时间文本。不要让所有提示常驻屏幕,否则农场会像后台管理系统。可以在玩家靠近、长按或进入管理模式时显示详细信息,平时只保留关键状态。成熟和缺水要有不同形状,不要只靠颜色,因为色弱玩家可能分不清。

收获动画要和收益入账时机一致。如果金币飞向仓库,但仓库其实已满,玩家会觉得被骗。建议先由模型确认收获成功,再播放飞行动画;动画结束只是表现,不再决定收益。若需要网络确认,可以显示短暂 pending 状态,失败时保留作物并提示原因。

上线前检查清单

确认作物状态可以用 nowMs 纯函数计算;确认离线结算有最大时长;确认仓库满、活动结束、作物枯萎都有明确规则;确认播种、浇水、收获都是命令而不是 UI 直接改 Sprite;确认 Scene 重建不会丢失农场状态;确认昼夜视觉不会降低可读性;确认时间异常不会产生负收益或无限收益;确认本地存档包含版本号,作物配置改动后能迁移旧数据;确认调试面板能把当前可信时间、离线跨度、每块地状态和下次成熟时间显示出来。

农场模拟的吸引力来自稳定的期待:我种下的东西会按规则成长,我离开后世界仍然温和地运转,我回来时能理解发生了什么。Phaser 可以把这个世界画得亲切,但真正让它可信的是时间模型和状态规则。先把规则写清楚,再让 Sprite 长出来。

多地图和装饰系统不要抢规则主导权

农场项目做到中期,往往会加入温室、牧场、矿洞、好友农场和装饰摆放。这里容易出现一个新问题:不同地图 Scene 各自维护一套时间逻辑。温室作物快一点,牧场动物慢一点,矿洞节点按次数刷新,最后每个 Scene 都有自己的“离线结算”。更稳妥的方式是让 FarmModel 支持多个区域,每个区域有自己的配置倍率和实体列表,但推进入口仍然统一。Scene 只是打开某个区域的视图。

装饰物也要和生产规则分开。一个水井装饰可能给周围田地减浇水消耗,这属于规则修饰,应该进入模型;一盏路灯只在夜晚发光,这是表现层。不要因为它们都在地图上,就都塞进同一个 Sprite 列表。否则玩家移动装饰时,可能意外影响作物结算,或者离线结算找不到已经被 Scene 卸载的装饰对象。把“可影响规则的建筑”和“纯视觉装饰”分清,后期做主题皮肤和活动摆件会轻松很多。

最后,农场游戏很适合做批量操作,但批量操作要尊重玩家确认。批量铲除、批量收获、批量出售都可能造成损失,尤其是移动端误触。Phaser 里可以用长按进入管理模式,再拖选格子,确认后一次提交命令。这样既保持操作效率,也能避免玩家因为一次滑动误删半片田。温和的游戏更要小心这些细节,因为玩家投入的是长期经营的耐心。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页