React + PixiJS 实现果园成长页:从状态机到浇水动画

资源链接: https://download.csdn.net/download/weixin_43073383/92884923

项目最终效果是一条完整的果园成长链路:用户第一次进入页面选择奖励,果树从空树进入成长状态;点击浇水按钮播放水壶、水流、水滴和果树反馈;每 5 次浇水升 1 级;升到最高等级后弹出收获弹窗;收获后回到空树,进入下一轮。

技术栈是 React 19、TypeScript、PixiJS 8 和 Vite 8。React 负责业务状态和弹窗,PixiJS 负责 Canvas 舞台和动效,Vite 负责构建、资源和分包。

1. 先把果园规则写成状态机

如果一上来就写 Pixi 动画,很容易把"果树几级""能不能浇水""满级后该不该收获"这些规则散落在点击事件和 ticker 里。这个项目先做的是业务模型。

果园只有三个业务状态:

ts 复制代码
export type OrchardLifecycleStatus = 'empty' | 'growing' | 'harvestable'

export type OrchardLifecycleState = {
  currentLevel: number
  selectedRewardId: null | string
  shouldAutoOpenRewardModal: boolean
  status: OrchardLifecycleStatus
  wateringCount: number
}

初始状态是一棵空树:

ts 复制代码
export function createInitialOrchardLifecycle(): OrchardLifecycleState {
  return {
    currentLevel: 0,
    selectedRewardId: null,
    shouldAutoOpenRewardModal: true,
    status: 'empty',
    wateringCount: 0,
  }
}

用户选择奖励后,果树进入成长状态,并从第 1 级开始:

ts 复制代码
export function selectOrchardReward(
  state: OrchardLifecycleState,
  rewardId: string,
): OrchardLifecycleState {
  return {
    ...state,
    currentLevel: 1,
    selectedRewardId: rewardId,
    shouldAutoOpenRewardModal: false,
    status: 'growing',
    wateringCount: 0,
  }
}

浇水升级的规则也放在纯函数里。这个项目设定是每 5 次浇水升 1 级:

ts 复制代码
export const orchardLifecycleWateringGoal = 5

export function waterOrchardLifecycle(
  state: OrchardLifecycleState,
  maxLevel: number,
): OrchardLifecycleState {
  if (state.status !== 'growing' || state.selectedRewardId === null) {
    return state
  }

  if (state.currentLevel >= maxLevel) {
    return {
      ...state,
      currentLevel: maxLevel,
      status: 'harvestable',
      wateringCount: 0,
    }
  }

  const nextWateringCount = state.wateringCount + 1

  if (nextWateringCount < orchardLifecycleWateringGoal) {
    return {
      ...state,
      wateringCount: nextWateringCount,
    }
  }

  const nextLevel = Math.min(maxLevel, state.currentLevel + 1)

  return {
    ...state,
    currentLevel: nextLevel,
    status: nextLevel >= maxLevel ? 'harvestable' : 'growing',
    wateringCount: 0,
  }
}

这里的重点不是代码复杂,而是边界清楚:生命周期规则不依赖 React,也不依赖 Pixi。它可以直接用 Node Test 测:

ts 复制代码
test('watering upgrades after the watering goal and becomes harvestable at max level', () => {
  let lifecycle = selectOrchardReward(createInitialOrchardLifecycle(), 'rice')

  for (let index = 0; index < 5; index += 1) {
    lifecycle = waterOrchardLifecycle(lifecycle, 6)
  }

  assert.equal(lifecycle.currentLevel, 2)
  assert.equal(lifecycle.status, 'growing')
  assert.equal(lifecycle.wateringCount, 0)
})

这样后面写动画时,不需要在动效代码里判断"浇几次升级"。动画只负责表现,规则由模型决定。

2. React 页面只做编排,不直接操作 Pixi 内部对象

果园页面 OrchardPage 是总控。它需要连接三类东西:

  • 生命周期状态。
  • 弹窗和按钮。
  • Pixi 舞台能力。

项目没有把 Pixi 的 SpriteContainer 暴露到 React 层,而是定义了一个很小的舞台 API:

ts 复制代码
type OrchardApi = {
  playWatering: () => Promise<void>
  setLevel: (level: number, options?: { animate?: boolean }) => void
}

React 页面通过 ref 保存这个 API:

ts 复制代码
const stageApiRef = useRef<OrchardApi | null>(null)

Pixi 舞台初始化完成后,把能力挂上去:

ts 复制代码
stageApiRef.current = {
  playWatering: () => wateringEffect.play(),
  setLevel: setOrchardLevel,
}

为什么这么做?因为 React 和 Pixi 的编程模型不一样。

React 适合表达"现在是什么状态";Pixi 适合表达"每一帧怎么画"。如果让 React 直接保存 Sprite 或每帧状态,组件会变得很难维护。这个项目把 Pixi 收敛成两个命令:播放浇水、切换等级。React 只调用命令,不碰舞台内部结构。

3. 用 PixiStage 包住 Canvas 生命周期

Pixi Application 不是普通 DOM 节点,它有自己的初始化、渲染、ticker、resize 和销毁流程。所以项目封装了一个 PixiStage 组件:

tsx 复制代码
type PixiStageProps = {
  onError?: (error: unknown) => void
  onLoaded?: () => void
  onResize?: (size: { height: number; width: number }) => void
  onReady: (app: Application) => void | (() => void) | Promise<void | (() => void)>
}

页面使用时只传入 onReady

tsx 复制代码
<PixiStage
  onError={handlePixiError}
  onLoaded={() => setIsStageLoaded(true)}
  onReady={handleReady}
/>

PixiStage 内部创建 Application

ts 复制代码
const app = new Application()

await app.init({
  width,
  height,
  backgroundAlpha: 0,
  backgroundColor: 0x000000,
  clearBeforeRender: true,
  preference: 'webgl',
  premultipliedAlpha: false,
  antialias: performanceLevel !== 'low',
  autoDensity: true,
  resolution: getOrchardStageResolution(
    window.devicePixelRatio || 1,
    performanceLevel,
  ),
  roundPixels: false,
})

这里有几个实际开发中很容易踩的坑。

第一,Pixi 初始化是异步的,所以组件内部维护了 destroyed 标记。如果 React 组件已经卸载,就不能再 append canvas:

ts 复制代码
if (destroyed) {
  app.destroy(true)
  return
}

第二,容器 resize 时要同步 Pixi 渲染尺寸和 CSS 尺寸:

ts 复制代码
function resizeStage(width: number, height: number) {
  if (!initialized || (width === lastWidth && height === lastHeight)) {
    return
  }

  lastWidth = width
  lastHeight = height
  app.renderer.resize(width, height)
  app.canvas.style.width = `${width}px`
  app.canvas.style.height = `${height}px`
  onResizeRef.current?.({ height, width })
  app.render()
}

第三,页面切到后台时停止 ticker:

ts 复制代码
const syncTickerWithVisibility = () => {
  if (document.hidden) {
    app.ticker.stop()
    return
  }

  app.ticker.start()
}

document.addEventListener('visibilitychange', syncTickerWithVisibility)

这一步对互动页很重要,尤其是移动端。用户切后台后继续跑 Canvas ticker,只会浪费性能和电量。

组件销毁时,移除监听、断开 ResizeObserver,再销毁 Pixi:

ts 复制代码
return () => {
  destroyed = true
  cleanup?.()

  if (initialized) {
    app.destroy(true)
  }
}

4. 在 Pixi 舞台里加载果树阶段图

果树有 0 到 6 共 7 个阶段:

ts 复制代码
export const orchardImages = [
  orchard0Webp,
  orchard1Webp,
  orchard2Webp,
  orchard3Webp,
  orchard4Webp,
  orchard5Webp,
  orchard6Webp,
]

export const maxOrchardLevel = orchardImages.length - 1

不同阶段图片高度不同,所以配置了每个阶段在舞台中的视觉高度比例:

ts 复制代码
export const orchardVisualHeightRatios = [
  0.52,
  0.34,
  0.43,
  0.52,
  0.54,
  0.56,
  0.58,
]

舞台初始化时不会简单一次性同步加载所有纹理,而是做了缓存和并发去重:

ts 复制代码
const orchardTextures = Array<Texture | undefined>(orchardImages.length)
const stages = Array<Orchard | undefined>(orchardImages.length)
const stageLoads = new Map<number, Promise<Orchard>>()

const ensureStage = (stageIndex: number) => {
  const cachedStage = stages[stageIndex]

  if (cachedStage) {
    return Promise.resolve(cachedStage)
  }

  const pendingStageLoad = stageLoads.get(stageIndex)

  if (pendingStageLoad) {
    return pendingStageLoad
  }

  const stageLoad = loadOrchardTexture(stageIndex).then((texture) => {
    const stage = createOrchard(texture)

    stages[stageIndex] = stage
    stageLoads.delete(stageIndex)

    return stage
  })

  stageLoads.set(stageIndex, stageLoad)

  return stageLoad
}

这段解决的是实际问题:如果多处同时需要同一个阶段图,不能重复发起加载。stageLoads 保存正在进行的 Promise,后续请求直接复用。

首屏加载多少阶段图,则由性能等级决定:

ts 复制代码
await Promise.all(
  orchardImages
    .slice(0, eagerOrchardCount)
    .map((_, stageIndex) => ensureStage(stageIndex)),
)

void Promise.all(
  orchardImages
    .slice(eagerOrchardCount)
    .map((_, offset) => ensureStage(offset + eagerOrchardCount)),
)

这就是"先保证首屏能用,再后台补齐"的策略。

5. 果树等级切换:低等级缩放,高等级渐变

果树的主舞台结构是两个容器:

  • orchardGroup:当前正在显示的果树。
  • morphGroup:用于高等级切换时的新果树过渡。

切换等级时,根据等级决定动画方式:

ts 复制代码
const shouldGrow = Boolean(options.animate && stageIndex >= 1 && stageIndex <= 3)
const shouldMorph = Boolean(options.animate && stageIndex >= 4)

1 到 3 级使用缩放生长:

ts 复制代码
if (shouldGrow) {
  growFromScale = targetScale * 0.88
  growToScale = targetScale
  growTime = growDuration
  orchardGroup.scale.set(growFromScale)
  return
}

4 级以后使用新旧阶段交叉渐变:

ts 复制代码
if (shouldMorph) {
  morphToStageIndex = stageIndex
  showMorphStage(stageIndex)
  morphGroup.scale.set(targetScale * 0.985)
  morphGroup.alpha = 0
  morphGroup.visible = true
  morphTime = morphDuration
  growTime = 0
  return
}

ticker 里再推进这两类动画:

ts 复制代码
if (growTime > 0) {
  growTime = Math.max(0, growTime - ticker.deltaMS)

  const progress = 1 - growTime / growDuration
  const easedProgress = easeOutBack(Math.min(1, Math.max(0, progress)))
  const scale = growFromScale + (growToScale - growFromScale) * easedProgress

  orchardGroup.scale.set(scale)
}

if (morphTime > 0) {
  morphTime = Math.max(0, morphTime - ticker.deltaMS)

  const progress = 1 - morphTime / morphDuration
  const easedProgress = 1 - Math.pow(1 - progress, 3)
  const targetScale = getStageScale(morphToStageIndex)

  orchardGroup.alpha = 1 - easedProgress
  morphGroup.alpha = easedProgress
  morphGroup.scale.set(targetScale * (0.985 + easedProgress * 0.015))
}

为什么要分两种?因为早期树苗阶段尺寸变化明显,用弹性缩放更像"长出来";后期树形差异大,直接缩放会跳,用两张图渐变更平滑。

6. 把浇水动画拆成"时间线函数"

浇水动效不是直接在 ticker 里乱写 x += 1。项目先定义了一个纯函数:输入当前经过的时间、舞台宽高,输出这一帧应该长什么样。

ts 复制代码
export type OrchardWateringFrame = {
  canAlpha: number
  canRotation: number
  canScale: number
  canVisible: boolean
  canX: number
  canY: number
  dropAlpha: number
  dropProgress: number
  flowVisible: boolean
  isComplete: boolean
  orchardFeedbackProgress: number
}

核心函数如下:

ts 复制代码
export function getOrchardWateringFrame({
  elapsedMs,
  height,
  width,
}: OrchardWateringFrameInput): OrchardWateringFrame {
  const elapsed = clamp(elapsedMs, 0, orchardWateringDuration)
  const moveProgress = easeOutCubic(clamp(elapsed / 520, 0, 1))
  const pourProgress = easeInOutSine(clamp((elapsed - 430) / 880, 0, 1))
  const exitProgress = easeInOutSine(clamp((elapsed - 1320) / 300, 0, 1))
  const orchardFeedbackProgress = clamp((elapsed - 1160) / 520, 0, 1)

  const startX = width * 0.88
  const startY = height * 0.74
  const pourX = width * 0.66
  const pourY = height * 0.34
  const exitX = width * 0.82
  const exitY = height * 0.58

  const activeX = lerp(startX, pourX, moveProgress)
  const hoverBob = Math.sin(pourProgress * Math.PI * 3) * 4
  const activeY = lerp(startY, pourY, moveProgress) + hoverBob

  const canX = elapsed < 1320 ? activeX : lerp(pourX, exitX, exitProgress)
  const canY = elapsed < 1320 ? activeY : lerp(pourY, exitY, exitProgress)
  const canAlpha = elapsed < 1320 ? 1 : 1 - exitProgress

  const tipProgress = easeInOutSine(clamp((elapsed - 360) / 260, 0, 1))
  const recoverProgress = easeInOutSine(clamp((elapsed - 1180) / 240, 0, 1))
  const pourSwing = Math.sin(pourProgress * Math.PI * 5) * 0.055

  return {
    canAlpha,
    canRotation: -0.1 - tipProgress * 0.58 + recoverProgress * 0.42 + pourSwing,
    canScale: 0.048 + moveProgress * 0.014,
    canVisible: elapsedMs < orchardWateringDuration && canAlpha > 0.02,
    canX,
    canY,
    dropAlpha: elapsed >= 430 && elapsed <= 1320 ? Math.sin(pourProgress * Math.PI) : 0,
    dropProgress: clamp((elapsed - 410) / 980, 0, 1),
    flowVisible: elapsed >= 430 && elapsed <= 1320,
    isComplete: elapsedMs >= orchardWateringDuration,
    orchardFeedbackProgress,
  }
}

这个设计很适合写测试:

ts 复制代码
test('moves watering can from lower right toward the tree', () => {
  const start = getOrchardWateringFrame({ elapsedMs: 0, height: 500, width: 320 })
  const move = getOrchardWateringFrame({ elapsedMs: 450, height: 500, width: 320 })

  assert.equal(start.canVisible, true)
  assert.ok(start.canX > move.canX)
  assert.ok(start.canY > move.canY)
  assert.equal(move.flowVisible, true)
})

这样测试不需要启动浏览器,也不需要创建 Pixi canvas。只要时间线函数正确,动效的大结构就不会跑偏。

7. 再把时间线应用到 Pixi 对象

有了 getOrchardWateringFrame,Pixi 层就只负责渲染。

浇水 effect 对外暴露三个方法:

ts 复制代码
export type OrchardWateringEffect = {
  destroy: () => void
  play: () => Promise<void>
  update: (ticker: Ticker) => void
}

创建 effect 时,先把水壶、水雾、水花、水滴加到独立 layer:

ts 复制代码
const layer = new Container()
const can = new Sprite(createWateringCanTexture(wateringCanTexture))
const splash = new Graphics()
const mist = new Graphics()
const drops = Array.from(
  { length: getWaterDropCount(performanceLevel) },
  (_, index) => createWaterDrop(index),
)

layer.addChild(mist)
layer.addChild(splash)
drops.forEach(({ node }) => layer.addChild(node))
layer.addChild(can)
app.stage.addChild(layer)

播放时只记录开始时间,并返回一个 Promise:

ts 复制代码
play: () => {
  if (playing) {
    return Promise.resolve()
  }

  completed = false
  playing = true
  startedAt = performance.now()

  return new Promise<void>((resolve) => {
    resolvePlay = resolve
  })
}

每帧更新时,先取时间线帧,再写到 Pixi 对象:

ts 复制代码
const frame = getOrchardWateringFrame({
  elapsedMs: performance.now() - startedAt,
  height: app.screen.height,
  width: app.screen.width,
})

can.visible = frame.canVisible
can.alpha = frame.canAlpha
can.x = frame.canX
can.y = frame.canY
can.rotation = frame.canRotation
can.scale.set(frame.canScale)

水滴则根据 dropProgress 做局部偏移、透明度和弧线:

ts 复制代码
drops.forEach((drop, index) => {
  const localProgress = Math.min(1, Math.max(0, frame.dropProgress * drop.speed - drop.delay))
  const active = frame.flowVisible && localProgress > 0 && localProgress < 1
  const arc = Math.sin(localProgress * Math.PI)
  const wobble = Math.sin((localProgress * 5 + index) * Math.PI) * 2.5

  drop.node.alpha = active ? frame.dropAlpha * Math.sin(localProgress * Math.PI) * 0.95 : 0
  drop.node.x = spoutX + drop.drift + wobble - localProgress * app.screen.width * 0.12
  drop.node.y = spoutY + localProgress * app.screen.height * 0.41 - arc * app.screen.height * 0.055
  drop.node.rotation = -0.35 + localProgress * 0.7
  drop.node.scale.set(drop.scale * (0.86 + arc * 0.2))
})

最后,如果时间线完成,就清理状态并 resolve:

ts 复制代码
if (frame.isComplete) {
  finish()
}

finish() 里会隐藏水壶、水雾、水花、水滴,重置果树反馈,并调用 resolvePlay?.()。这就是为什么页面层可以 await stageApi.playWatering()

8. 浇水点击其实是一段事务

页面里的浇水逻辑不是"点一下就升级",而是一个完整事务。

核心代码是:

ts 复制代码
const requestUpgradeFromActionBar = useCallback(async () => {
  if (assetError) {
    return
  }

  const currentLevel = normalizeOrchardLevel(currentLevelRef.current)
  const currentLifecycle = lifecycleRef.current

  if (currentLevel === 0 || currentLifecycle.status === 'empty') {
    return
  }

  if (currentLevel >= maxOrchardLevel || currentLifecycle.status === 'harvestable') {
    setShowHarvestModal(true)
    return
  }

  if (isWateringRef.current || isUpgradingRef.current) {
    return
  }

  const stageApi = stageApiRef.current

  if (!stageApi) {
    return
  }

  isWateringRef.current = true
  setIsWatering(true)

  try {
    await stageApi.playWatering()
  } finally {
    isWateringRef.current = false
    setIsWatering(false)
  }

  const nextLifecycle = waterOrchardLifecycle(lifecycleRef.current, maxOrchardLevel)

  if (nextLifecycle.currentLevel === currentLevel) {
    applyLifecycle(nextLifecycle)
    return
  }

  const upgrade = { fromLevel: currentLevel, toLevel: nextLifecycle.currentLevel }

  isUpgradingRef.current = true
  pendingUpgradeRef.current = upgrade
  setPendingUpgrade(upgrade)
}, [applyLifecycle, assetError])

这段代码的执行顺序非常明确:

  1. 资源失败则不执行。
  2. 空树不能浇水。
  3. 满级直接打开收获弹窗。
  4. 正在浇水或升级时拒绝重复点击。
  5. 等 Pixi 浇水动画完成。
  6. 推进生命周期。
  7. 如果没有升级,只更新浇水次数。
  8. 如果升级,记录 pendingUpgrade,交给升级遮罩处理。

这里同时用了 ref 和 state:

  • isWateringRefisUpgradingRef 用来做同步锁。
  • setIsWateringsetPendingUpgrade 用来驱动 UI。

只用 state 做锁,在连续点击和闭包场景下更容易出问题。动画交互里,用 ref 存"当前是否可执行"会更稳。

9. 升级动效用独立 Canvas

升级动效没有塞进主舞台,而是单独做了 OrchardUpgradeEffect

页面中这样使用:

tsx 复制代码
<OrchardUpgradeEffect
  fromLevel={pendingUpgrade?.fromLevel ?? currentLevel}
  onComplete={completeUpgrade}
  toLevel={pendingUpgrade?.toLevel ?? Math.min(maxOrchardLevel, currentLevel + 1)}
  visible={Boolean(pendingUpgrade)}
/>

升级完成时,再同步主舞台和生命周期:

ts 复制代码
const completeUpgrade = useCallback(() => {
  const upgrade = pendingUpgradeRef.current

  if (!upgrade) {
    return
  }

  const nextLifecycle: OrchardLifecycleState = {
    ...lifecycleRef.current,
    currentLevel: upgrade.toLevel,
    shouldAutoOpenRewardModal: false,
    status: upgrade.toLevel >= maxOrchardLevel ? 'harvestable' : 'growing',
    wateringCount: 0,
  }

  pendingUpgradeRef.current = null
  stageApiRef.current?.setLevel(upgrade.toLevel, { animate: true })
  applyLifecycle(nextLifecycle)
  setPendingUpgrade(null)
  isUpgradingRef.current = false

  if (nextLifecycle.status === 'harvestable') {
    setShowHarvestModal(true)
  }
}, [applyLifecycle])

这里的关键是:升级开始时不立刻把主舞台切到新等级,而是先让遮罩展示"从旧等级到新等级"。遮罩结束后,再把主舞台和业务状态同步到新等级。

独立 Canvas 的好处是升级动效可以有自己的纹理、阴影、纸屑、文案和关闭按钮,不污染主舞台 ticker。

10. 资源预热和失败兜底

互动页最怕用户点击时才发现资源没准备好。项目维护了关键资源清单:

ts 复制代码
export const orchardBackgroundAsset = new URL(
  '../features/orchard/assets/orchard-background.webp',
  import.meta.url,
).href

export const orchardAssetUrls = Array.from(
  { length: 7 },
  (_, index) => new URL(`../features/orchard/assets/orchard-${index}.webp`, import.meta.url).href,
)

export const orchardWaterSequenceAssets = Array.from(
  { length: 12 },
  (_, index) => `/orchard/watering/water-sequence-${String(index).padStart(2, '0')}.png`,
)

按性能等级决定预热多少阶段图:

ts 复制代码
export function getCriticalImageAssetsForPerformance(level: PerformanceLevel) {
  return [
    orchardBackgroundAsset,
    ...orchardAssetUrls.slice(0, getOrchardEagerLoadCount(level)),
    wateringButtonAssetUrl,
    ...orchardWaterSequenceAssets,
  ]
}

预热逻辑使用 Image.decode()

ts 复制代码
function defaultDecodeImage(url: string) {
  if (typeof Image === 'undefined') {
    return Promise.resolve()
  }

  return new Promise<void>((resolve, reject) => {
    const image = new Image()

    image.onload = async () => {
      try {
        if (typeof image.decode === 'function') {
          await image.decode()
        }

        resolve()
      } catch (error) {
        reject(error)
      }
    }

    image.onerror = () => reject(new Error(`Failed to load image: ${url}`))
    image.src = url
  })
}

页面里预热失败会记录 APM,Pixi 关键资源加载失败则进入可见错误态:

tsx 复制代码
{assetError ? (
  <div className="orchard-resource-error" role="alert">
    <strong>资源加载失败</strong>
    <span>当前网络或设备环境暂时无法加载果园资源,请稍后重试。</span>
  </div>
) : (
  <PixiStage
    onError={handlePixiError}
    onLoaded={() => setIsStageLoaded(true)}
    onReady={handleReady}
  />
)}

这比空白 Canvas 更友好,也更容易定位线上问题。

11. 性能降级从工具层开始做

项目没有在每个组件里随手写"低端机少渲染一点",而是集中做性能分级:

ts 复制代码
export type PerformanceLevel = 'high' | 'low' | 'medium'

export function getDevicePerformanceLevel({
  deviceMemory,
  hardwareConcurrency,
  prefersReducedMotion,
}: DevicePerformanceInput): PerformanceLevel {
  if (prefersReducedMotion || (deviceMemory !== undefined && deviceMemory <= 2)) {
    return 'low'
  }

  if (hardwareConcurrency !== undefined && hardwareConcurrency <= 4) {
    return 'low'
  }

  if (
    deviceMemory !== undefined &&
    hardwareConcurrency !== undefined &&
    deviceMemory >= 6 &&
    hardwareConcurrency >= 8
  ) {
    return 'high'
  }

  return 'medium'
}

然后其他模块只消费策略:

ts 复制代码
export function getWaterDropCount(level: PerformanceLevel) {
  if (level === 'low') {
    return 8
  }

  if (level === 'medium') {
    return 14
  }

  return 22
}

export function getOrchardEagerLoadCount(level: PerformanceLevel) {
  if (level === 'low') {
    return 3
  }

  if (level === 'medium') {
    return 5
  }

  return 7
}

这套策略影响:

  • Pixi resolution。
  • 是否开启抗锯齿。
  • 首屏预加载果树阶段数。
  • 水滴粒子数量。
  • 是否启用装饰动效。
  • 是否启用升级纸屑。

好处是后续新增动效时不用重新判断设备,只要接入 PerformanceLevel 即可。

12. Vite 分包处理 Pixi 体积

Pixi 和动画库通常会让 bundle 变重。项目在 Vite 里做了手动分包:

ts 复制代码
manualChunks(id) {
  if (id.includes('node_modules/react-router-dom')) {
    return 'router'
  }

  if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
    return 'react'
  }

  if (id.includes('node_modules/pixi.js/lib/assets')) {
    return 'pixi-assets'
  }

  if (id.includes('node_modules/pixi.js/lib/rendering')) {
    return 'pixi-rendering'
  }

  if (id.includes('node_modules/pixi.js/lib/scene')) {
    return 'pixi-scene'
  }

  if (id.includes('node_modules/gsap')) {
    return 'gsap'
  }

  return undefined
}

这样构建产物里 React、路由、Pixi 子模块和 GSAP 会拆开,后续分析包体时更清晰。

同时项目启用了 legacy 构建:

ts 复制代码
legacy({
  modernPolyfills: true,
  renderLegacyChunks: true,
  targets: ['Android >= 5', 'iOS >= 11', 'Chrome >= 37', 'Safari >= 11'],
})

这说明目标不是只在现代桌面浏览器跑一个 Demo,而是要考虑更旧的移动端环境。

13. 最后用测试守住关键点

这个项目没有把测试重点放在 Canvas 像素上,而是测三类东西。

第一类是业务规则,比如生命周期和浇水升级:

ts 复制代码
assert.equal(lifecycle.currentLevel, 2)
assert.equal(lifecycle.status, 'growing')
assert.equal(lifecycle.wateringCount, 0)

第二类是动效时间线,比如浇水过程中水壶位置、水流状态和完成标记:

ts 复制代码
const frame = getOrchardWateringFrame({
  elapsedMs: orchardWateringDuration + 1,
  height: 500,
  width: 320,
})

assert.equal(frame.isComplete, true)
assert.equal(frame.canVisible, false)

第三类是工程约束,比如构建分包:

ts 复制代码
test('splits heavy pixi vendors into dedicated chunks', () => {
  const source = readFileSync(viteConfigPath, 'utf8')

  assert.match(source, /pixi-rendering/)
  assert.match(source, /pixi-assets/)
})

这样能保证"规则不变、时间线不漂、构建不退化"。动画好不好看仍然要靠浏览器验收,但正确性不能全靠肉眼。

总结

这个果园成长页的实现思路可以概括成六步:

  1. 先把果园生命周期写成纯函数状态机。
  2. React 页面只做业务编排和弹窗控制。
  3. Pixi 舞台通过 PixiStage 管理初始化、resize、ticker 和销毁。
  4. 舞台内部只暴露 playWatering()setLevel() 给 React。
  5. 动画先写成时间线函数,再应用到 Pixi 对象。
  6. 资源、性能和构建从一开始就做工程化处理。
相关推荐
暗冰ཏོ2 小时前
VUE面试题大全
前端·javascript·vue.js·面试
次元工程师!2 小时前
LangFlow开发(三)—Bundles组件架构设计(3W+字详细讲解)
java·前端·python·低代码·langflow
Bug-制造者3 小时前
现代Web应用全栈开发:从架构设计到部署落地实战
前端
青春喂了后端3 小时前
IntelliGit 前端状态层重构:把一个全局 Store 拆成清晰的状态边界
前端·重构·状态模式
IT_陈寒4 小时前
Redis内存用爆了,原来我们都忽略了这个配置
前端·人工智能·后端
qq_2518364574 小时前
基于java Web汽车销售管理系统设计与实现
java·前端·汽车
花椒技术4 小时前
低代码平台接入 Agent 后,我们踩到的组件、上下文和追问坑
前端·人工智能·agent
豹哥学前端5 小时前
事件循环(Event Loop)深度解析:让你彻底搞懂 JS 的执行顺序
前端·javascript·面试