前言
前面我们用animateTo/.animation做了得分弹跳、猫咪下落——但都靠Curve.EaseOut等缓动曲线模拟,没有真实的物理回弹。玩家合并猫咪时想要「弹簧」反馈:新猫放大出现时超过目标尺寸再回弹,像橡皮筋拽拉后振荡。HarmonyOS 的curves.springMotion弹性曲线模拟真实物理弹簧——速度、刚性、阻尼三参数,自动振荡回弹。
本篇以「猫猫大作战」得分弹跳改用弹簧、合并新猫弹性出现为场景,把springMotion 三参数、responsiveSpringMotion 响应式弹簧、与 EaseOut 缓动差异、cancel 终止弹簧四大要点讲透。
提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1–58 篇。本篇是阶段三第九篇。
一、场景拆解:得分弹跳与合并弹性反馈
回顾「猫猫大作战」得分弹跳(第 55 篇用animateTo+ EaseOut):
handleScoreChange() { animateTo({ duration: 300, curve: Curve.EaseOut }, () => { this.scoreScale = 1.5; // 放大到 1.5,EaseOut 先快后慢 }); setTimeout(() => { animateTo({ duration: 200, curve: Curve.EaseIn }, () => { this.scoreScale = 1; // 回 1 }); }, 300); }痛点:EaseOut 是「先快后慢匀减速」,无回弹——放大到 1.5 就停,回 1 也停,缺橡皮筋反馈。玩家想:
- 放大超过 1.5(如 1.6)再回弹到 1.5,再震荡几次到 1。
- 合并新猫从 0 放大到 1,超过 1 到 1.1 再回 0.95 再回 1,弹簧感。
Spring 的解法:
import { curves } from '@kit.ArkUI'; // 弹性曲线:速度 0.5,刚性 0.8 const springCurve = curves.springMotion(0.5, 0.8); handleScoreChange() { animateTo({ duration: 800, curve: springCurve }, () => { this.scoreScale = 1.5; // 弹簧补间到 1.5,会振荡回弹 }); } // 效果:1 → 1.6 → 1.4 → 1.55 → 1.45 → ... → 1.5,振荡回弹稳定关键经验:Spring 弹簧曲线让动画有真实物理振荡——超过目标再回弹,比 EaseOut 的匀减速更有橡皮筋反馈。
二、springMotion 三参数
2.1 基本用法
import { curves } from '@kit.ArkUI'; const springCurve: ICurve = curves.springMotion( velocity: number, // 速度(响应/振荡速率) stiffness: number, // 刚性(弹簧硬度) damping?: number // 阻尼(振荡衰减,可选) );2.2 三参数拆解
| 参数 | 含义 | 范围 | 效果 |
|---|---|---|---|
velocity | 速度 | 0–1 | 响应快慢,越大越快 |
stiffness | 刚性 | 0–1 | 弹簧硬度,越大振荡越少 |
damping | 阻尼 | 0–1 | 振荡衰减,越大越快停(可选,默认 0) |
2.3 参数组合对照
// 软弹簧:低刚性,振荡多 const softSpring = curves.springMotion(0.3, 0.2); // 硬弹簧:高刚性,振荡少 const hardSpring = curves.springMotion(0.8, 0.9); // 强阻尼:振荡快速衰减 const dampedSpring = curves.springMotion(0.5, 0.5, 0.8);| 参数组合 | 效果 | 适合 |
|---|---|---|
(0.5, 0.3)软 | 振荡多回弹多 | 弹跳反馈 |
(0.8, 0.9)硬 | 振荡少快速稳定 | 精确移动 |
(0.5, 0.5, 0.8)强阻尼 | 振荡快速衰减 | 避免眩晕 |
关键经验:「弹跳反馈」用软弹簧低刚性,「精确移动」用硬弹簧高刚性——得分弹跳软,猫咪定位硬。
2.4 在 animation/animateTo 用
// animation 隐式用弹簧 Column() .position({ x: this.catX, y: 100 }) .animation({ duration: 800, curve: springCurve }) // animateTo 显式用弹簧 animateTo({ duration: 800, curve: springCurve }, () => { this.scoreScale = 1.5; });三、responsiveSpringMotion 响应式弹簧
3.1 响应式 vs 普通弹簧
// 普通弹簧:目标变,从起始重新振荡 const springCurve = curves.springMotion(0.5, 0.8); // catX 从 0 弹簧到 300,中途改 500,从当前位置重新弹簧到 500 // 响应式弹簧:目标变,保留当前速度顺势改向 const responsiveCurve = curves.responsiveSpringMotion( velocity: number, stiffness: number, damping?: number ); // catX 从 0 弹簧到 300,中途改 500,保留当前速度顺势转向 500关键差异:
| 类型 | 目标变时 | 适合 |
|---|---|---|
springMotion | 从当前位置重新振荡 | 弹跳反馈 |
responsiveSpringMotion | 保留速度顺势改向 | 实时跟随 |
3.2 实时跟随场景
// 拖拽时猫咪跟随手指,用响应式弹簧(顺滑不卡顿) Column() .position({ x: this.catX, y: this.catY }) .animation({ duration: 800, curve: responsiveCurve }) // onTouch Move 改 catX/catY,响应式弹簧顺滑跟随 .onTouch((event) => { if (event.type === TouchType.Move) { this.catX = event.touches[0].x; this.catY = event.touches[0].y; } })关键经验:「实时跟随」用 responsiveSpringMotion——拖拽、滑动跟随手指,保留速度顺势改向不卡顿。
四、与 EaseOut 缓动差异
4.1 视觉对比
EaseOut(先快后慢匀减速): 1 → 1.5(线性减速到目标,停) Spring 弹簧: 1 → 1.6 → 1.4 → 1.55 → 1.45 → ... → 1.5(振荡回弹到目标)4.2 duration 的作用
// EaseOut:duration 决定动画总时长,到达目标就停 animateTo({ duration: 300, curve: Curve.EaseOut }, () => { this.scoreScale = 1.5; }); // 300ms 从 1 到 1.5,停 // Spring:duration 决定振荡时长,振荡在 duration 内完成 animateTo({ duration: 800,0800, curve: springCurve }, () => { this.scoreScale = 1.5; }); // 800ms 内振荡 1 → 1.6 → 1.4 → ... → 1.5,停关键经验:Spring 的 duration 是「振荡总时长」——不是匀减速时长,振荡在 duration 内完成。
4.3 取舍
要回弹振荡反馈? ├ 是 → Spring └ 匀减速到目标即可? └ EaseOut / EaseInOut| 场景 | 推荐 | 原因 |
|---|---|---|
| 得分弹跳 | Spring | 弹跳反馈真实 |
| 合并新猫放大 | Spring | 出现感弹性 |
| 猫咪下落 | EaseOut | 重力下落匀加速 |
| 页面滑入 | EaseInOut | 两端慢中间快自然 |
| 拖拽跟随 | responsiveSpring | 顺滑不卡顿 |
五、实战:得分弹跳与合并弹性
5.1 改造得分弹跳用 Spring
// 来源:entry/src/main/ets/pages/Index.ets(Spring 改造后) import { curves, Curve, AnimationParams, animateTo, cancelAnimation } from '@kit.ArkUI'; @Entry @Component struct Index { @State gameState: GameState = GameState.IDLE; @State score: number = 0; @State cats: Cat[] = []; @State combo: ComboInfo = { count: 0, multiplier: 1, lastMergeTime: 0 }; @State nextCatLevel: CatLevel = CatLevel.SMALL; @State highScore: number = 0; @State gameTime: number = 0; // animateTo 用:动画 state @State scoreScale: number = 1; @State newCatScale: number = 1; @State newCatId: string = ''; @State comboFlash: boolean = false; private gameEngine: GameEngine = new GameEngine(); private gameLoopTimer: number = -1; private spawnTimer: number = -1; private timeTimer: number = -1; private readonly cols: number[] = [0, 1, 2, 3, 4]; // 弹簧曲线(本篇重点) private scoreSpring: ICurve = curves.springMotion(0.4, 0.3); // 软弹簧:得分弹跳振荡多 private mergeSpring: ICurve = curves.springMotion(0.6, 0.4); // 中弹簧:合并新猫弹性出现 private comboSpring: ICurve = curves.springMotion(0.5, 0.5, 0.7); // 强阻尼弹簧:连击闪烁振荡少 private followSpring: ICurve = curves.responsiveSpringMotion(0.7, 0.6); // 响应式:拖拽跟随 private scoreAnimId: number = -1; private mergeAnimId: number = -1; private comboAnimId: number = -1; startGame() { this.clearTimers(); this.0800,0800,0800, 0800, this.gameEngine.reset(); this.gameState = GameState.PLAYING; this.score = 0; this.cats = []; this.gameTime = 0; this.combo = { count: 0, multiplier: 1, lastMergeTime: 0 }; this.nextCatLevel = this.gameEngine.getNextCatLevel(); this.scoreScale = 1; this.newCatScale = 1; this.newCatId = ''; this.comboFlash = false; this.gameLoopTimer = setInterval(() => { if (this.gameState !== GameState.PLAYING) return; const oldScore = this.score; this.cats = this.gameEngine.updateCats(); this.score = this.gameEngine.getScore(); this.combo = this.gameEngine.getCombo(); // 得分变化触发弹簧弹跳(本篇重点) if (this.score !== oldScore) { this.handleScoreChange(); } // 连击 ≥ 3 触发弹簧闪烁 if (this.combo.count >= 3 && !this.comboFlash) { this.handleComboFlash(); } if (this.gameEngine.isGameOver()) { this.endGame(); } }, 100); /* spawnTimer、timeTimer 筥略 */ } // 得分弹跳:弹簧振荡(本篇重点) handleScoreChange() { if (this.scoreAnimId !== -1) cancelAnimation(this.scoreAnimId); this.scoreAnimId = animateTo({ duration: 800, curve: this.scoreSpring }, () => { this.scoreScale = 1.3; // 弹簧补间到 1.3,会超过再振荡回弹 }); // 效果:1 → 1.5 → 1.1 → 1.4 → 1.15 → ... → 1.3,振荡回弹 } // 连击闪烁:强阻尼弹簧(振荡少快速稳定) handleComboFlash() { this.comboFlash = true; this.comboAnimId = animateTo({ duration: 600, curve: this.comboSpring }, () => { this.comboFlash = false; // 弹簧补间到 false,会振荡回弹 }); } // 合并新猫弹性出现(本篇重点) handleMerge(newCatId: string) { this.newCatId = newCatId; this.newCatScale = 0; this.mergeAnimId = animateTo({ duration: 800, curve: this.mergeSpring }, () => { this.newCatScale = 1; // 弹簧补间到 1,会超过 1 再振荡回弹 }); // 效果:0 → 1.15 → 0.9 → 1.08 → 0.95 → ... → 1,弹性出现 } endGame() { this.gameState = GameState.GAME_OVER; if (this.scoreAnimId !== -1) { cancelAnimation(this.scoreAnimId); this.scoreAnimId = -1; } if (this.mergeAnimId !== -1) { cancelAnimation(this.mergeAnimId); this.mergeAnimId = -1; } if (this.comboAnimId !== -1) { cancelAnimation(this.comboAnimId); this.comboAnimId = -1; } this.scoreScale = 1; this.newCatScale = 1; this.comboFlash = false; /* ... 其他结束逻辑 ... */ this.clearTimers(); } /* pauseGame / resumeGame / clearTimers / formatTime / aboutToDisappear 筑略 */ @Builder GameHUD() { Row() { Column() { Text('得分').fontSize(11).fontColor('#95A5A6') // 得分 Text:读 scoreScale,弹簧振荡 Text(this.score.toString()) .fontSize(22).fontWeight(FontWeight.Bold).fontColor('#2C3E50') .scale({ x: this.scoreScale, y: this.scoreScale }) // ← 读缩放,弹簧振荡 }.alignItems(HorizontalAlign.Start) Spacer() if (this.combo.count > 1) { Row() { Text(`🔥 x${this.combo.multiplier}`) .fontSize(18).fontWeight(FontWeight.Bold) .fontColor(this.comboFlash ? '#FF6B6B' : '#E74C3C') } .padding({ left: 12, right: 12, top: 4, bottom: 4 }) .backgroundColor(this.comboFlash ? 'rgba(255, 107, 107, 0.3)' : 'rgba(231, 76, 60, 0.1)') .borderRadius(16) .scale({ x: this.comboFlash ? 1.15 : 1, y: this.comboFlash ? 1.15 : 1 }) } Spacer() Column() { Text('时间').fontSize(11).fontColor('#95A5A6') Text(this.formatTime(this.gameTime)) .fontSize(18).fontWeight(FontWeight.Medium).fontColor('#2C3E50') }.alignItems(HorizontalAlign.End) } .width('100%').padding({ left: 20, right: 20, top: 12, bottom: 8 }) } @Builder GameView() { Column() { this.GameHUD() Column() { Row() { /* 预告区 */ } Stack() { /* 棋盘背景 */ ForEach(this.cats, (cat: Cat) => { Column() { Text(CatConfig[cat.level].emoji).fontSize(CatConfig[cat.level].size * 0.5) } .width(CatConfig[cat.level].size).height(CatConfig[cat.level].size) .borderRadius(CatConfig[cat.level].size / 2) .backgroundColor(CatConfig[cat.level].color) .justifyContent(FlexAlign.Center) .shadow({ radius: 4, color: 'rgba(0,0,0,0.2)', offsetY: 2 }) // 新合并的猫:弹簧放大出现 .scale({ x: cat.id === this.newCatId ? this.newCatScale : 1, y: cat.id === this.newCatId ? this.newCatScale : 1 }) .position({ x: cat.x * 60 + (60 - CatConfig[cat.level].size) / 2, y: cat.y * 60 + (60 - CatConfig[cat.level].size) / 2 }) // 猫咪下落用 Linear(重力匀速,非弹簧) .animation({ duration: 100, curve: Curve.Linear }) }, (cat: Cat) => cat.id) Row() { ForEach(this.cols, (col: number) => { Column() .width(GameConfig.CELL_SIZE) .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE) .backgroundColor('rgba(0,0,0,0)') .onClick(() => { this.handleColumnClick(col); }) }, (col: number) => `click_${col}`) } } .width(GameConfig.BOARD_WIDTH * GameConfig.CELL_SIZE) .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE) .borderRadius(12).clip(true).backgroundColor('#D6EEF5') }.alignItems(HorizontalAlign.Center) Spacer() Row() { /* 底部控制栏 */ } .width('100%').padding({ left: 24, right: 24, bottom: 24, top: 12 }) } .width('100%').height('100%') .linearGradient({ direction: GradientDirection.Bottom, colors: [['#E8F4F8', 0.0], ['#D6EEF5', 0.5], ['#C9E8F2', 1.0]] }) .alignItems(HorizontalAlign.Center) } /* MainMenuView / PauseOverlay / GameOverOverlay / StatItem / handleColumnClick 等略 */ }5.2 触发流程
得分弹跳:
- 合并猫咪得分 +10,
oldScore与score不同。 handleScoreChange触发animateTo,弹簧曲线scoreSpring。scoreScale从 1 弹簧补间到 1.3:1 → 1.5 → 1.1 → 1.4 → 1.15 → … → 1.3,振荡回弹稳定。- HUD 得分数字随之振荡放大缩小,800ms 完成。
合并新猫弹性出现:
- 引擎合并生成新猫,调
handleMerge(newCatId)。 newCatScale从 0 弹簧补间到 1:0 → 1.15 → 0.9 → 1.08 → 0.95 → … → 1。- 棋盘上该猫从 0 弹性放大出现,超过 1 再回弹,橡皮筋反馈。
连击闪烁:
- 连击 ≥ 3,
handleComboFlash触发。 comboFlash从 true 弹簧补间到 false:true → false → true → false → … → false,强阻尼快速稳定。- 连击栏文字颜色随振荡闪烁,强阻尼振荡少不眩晕。
六、踩坑提示
6.1 Spring duration 太短振荡未完
// ❌ 错误:duration 300ms 太短,振荡还没完就停,卡在中间值 animateTo({ duration: 300, curve: this.scoreSpring }, () => { this.scoreScale = 1.3; }); // 可能停在 1.2,振荡未完成 // ✅ 正确:duration 足够长,振荡完成 animateTo({ duration: 800, curve: this.scoreSpring }, () => { this.scoreScale = 1.3; });关键经验:Spring duration 要足够振荡完成——通常 600–1000ms,太短卡中间值。
6.2 弹簧参数超范围
// ❌ 错误:velocity 或 stiffness 超范围,行为未定义 const badSpring = curves.springMotion(2.5, -0.3); // 超范围 // ✅ 正确:参数在 0–1 范围 const goodSpring = curves.springMotion(0.5, 0.8);6.3 忘取消旧弹簧动画
// ❌ 错误:连续得分变化没取消旧弹簧,多个 animateTo 并行 handleScoreChange() { // 忠了 if (this.scoreAnimId !== -1) cancelAnimation(this.scoreAnimId); this.scoreAnimId = animateTo({ duration: 800, curve: this.scoreSpring }, () => { this.scoreScale = 1.3; }); } // 多个弹簧并行,scoreScale 振荡错乱 // ✅ 正确:先取消旧再启动新 handleScoreChange() { if (this.scoreAnimId !== -1) cancelAnimation(this.scoreAnimId); this.scoreAnimId = animateTo({ duration: 800, curve: this.scoreSpring }, () => { this.scoreScale = 1.3; }); }6.4 普通弹簧用于实时跟随
// ❌ 错误:拖拽跟随用普通 springMotion,目标变时重新振荡卡顿 Column() .position({ x: this.catX, y: this.catY }) .animation({ duration: 800, curve: curves.springMotion(0.5, 0.8) }) // 手指移动改 catX,每次都重新振荡,不顺滑 // ✅ 正确:实时跟随用 responsiveSpringMotion Column() .position({ x: this.catX, y: this.catY }) .animation({ duration: 800, curve: curves.responsiveSpringMotion(0.7, 0.6) }) // 保留速度顺势改向,顺滑跟随6.5 用普通函数丢 this
// ❌ 错误:普通函数 this 不指向组件 animateTo({ duration: 800, curve: this.scoreSpring }, function () { this.scoreScale = 1.3; }); // ✅ 正确:箭头函数保留 this(第 38 篇讲过) animateTo({ duration: 800, curve: this.scoreSpring }, () => { this.scoreScale = 1.3; });七、调试技巧
console.info在 animateTo 闭包后:追 scoreScale 目标值,验证弹簧补间。- 振荡不完成排查:检查 duration 是否足够(600–1000ms);检查参数是否超范围。
- 多个弹簧并行排查:检查是否忘取消旧动画;检查连续触发场景。
- DevEco Animation Inspector:查看弹簧振荡过程,验证物理参数。
八、性能与最佳实践
- 「弹跳反馈」用 Spring 弹簧——得分弹跳、合并出现有真实物理振荡。
- springMotion 三参数——velocity 速度、stiffness 刚性、damping 阻尼(可选)。
- 「实时跟随」用 responsiveSpringMotion——拖拽跟随保留速度顺势改向不卡顿。
- duration 足够振荡完成——通常 600–1000ms,太短卡中间值。
- 连续触发先取消旧动画——避免多个弹簧并行振荡错乱。
- 「匀减速到目标」用 EaseOut,「振荡回弹」用 Spring——按场景选。
九、阶段三进度(51–59)
本篇是阶段三「交互与动画」第 9 篇:
| 篇 | 主题 | 核心要点 |
|---|---|---|
| 51 | onTouch | 手势三阶段 |
| 52 | onHover | 悬停反馈 |
| 53 | onKeyEvent | 键盘/遥控按键 |
| 54 | bindContextMenu | 上下文菜单 |
| 55 | animateTo | 显式动画触发 |
| 56 | animation | 隐式补间 |
| 57 | Hero | 共享元素跨页面 |
| 58 | transition | if 进/出转场 |
| 59(本篇) | Spring | 弹簧物理振荡 |
接下来第 60 篇会覆盖:Hero Style Player 播放预定义动画。
总结
本篇我们从 Spring 弹性物理切入,掌握了springMotion 三参数(velocity/stiffness/damping)、responsiveSpringMotion 响应式(实时跟随)、与 EaseOut 缓动差异(振荡回弹 vs 匀减速)、cancel 终止 + duration 足够四大要点,并给出了得分弹跳、合并弹性出现、连击闪烁的完整 Spring 改造代码。核心要点:弹跳反馈用 Spring;三参数调振荡;实时跟随用 responsive;duration 足够振荡完成;连续触发先取消旧。
下一篇我们将拆解 Hero Style Player——播放预定义动画。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- 「猫猫大作战」项目源码:本仓库
entry/src/main/ets/pages/Index.ets - ArkUI curves 弹性曲线官方指南
- springMotion API 官方文档
- HarmonyOS 物理动画最佳实践
- 开源鸿蒙跨平台社区
- HarmonyOS 开发者官方文档首页
- 系列索引:本仓库
articles/INDEX.md