
资源链接: 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 的 Sprite、Container 暴露到 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])
这段代码的执行顺序非常明确:
- 资源失败则不执行。
- 空树不能浇水。
- 满级直接打开收获弹窗。
- 正在浇水或升级时拒绝重复点击。
- 等 Pixi 浇水动画完成。
- 推进生命周期。
- 如果没有升级,只更新浇水次数。
- 如果升级,记录
pendingUpgrade,交给升级遮罩处理。
这里同时用了 ref 和 state:
isWateringRef、isUpgradingRef用来做同步锁。setIsWatering、setPendingUpgrade用来驱动 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/)
})
这样能保证"规则不变、时间线不漂、构建不退化"。动画好不好看仍然要靠浏览器验收,但正确性不能全靠肉眼。
总结
这个果园成长页的实现思路可以概括成六步:
- 先把果园生命周期写成纯函数状态机。
- React 页面只做业务编排和弹窗控制。
- Pixi 舞台通过
PixiStage管理初始化、resize、ticker 和销毁。 - 舞台内部只暴露
playWatering()和setLevel()给 React。 - 动画先写成时间线函数,再应用到 Pixi 对象。
- 资源、性能和构建从一开始就做工程化处理。