

前言
前面我们用 animateTo/.animation 做了得分弹跳、猫咪下落------但都靠 Curve.EaseOut 等缓动曲线模拟,没有真实的物理回弹 。玩家合并猫咪时想要「弹簧」反馈:新猫放大出现时超过目标尺寸再回弹 ,像橡皮筋拽拉后振荡。HarmonyOS 的 curves.springMotion 弹性曲线模拟真实物理弹簧------速度、刚性、阻尼三参数,自动振荡回弹。
本篇以「猫猫大作战」得分弹跳改用弹簧、合并新猫弹性出现为场景,把 springMotion 三参数 、responsiveSpringMotion 响应式弹簧 、与 EaseOut 缓动差异 、cancel 终止弹簧四大要点讲透。
提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1--58 篇。本篇是阶段三第九篇。
一、场景拆解:得分弹跳与合并弹性反馈
回顾「猫猫大作战」得分弹跳(第 55 篇用 animateTo + EaseOut):
ts
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 的解法:
ts
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 基本用法
ts
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 参数组合对照
ts
// 软弹簧:低刚性,振荡多
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 用
ts
// 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 普通弹簧
ts
// 普通弹簧:目标变,从起始重新振荡
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 实时跟随场景
ts
// 拖拽时猫咪跟随手指,用响应式弹簧(顺滑不卡顿)
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 的作用
ts
// 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
ts
// 来源: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 太短振荡未完
ts
// ❌ 错误: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 弹簧参数超范围
ts
// ❌ 错误:velocity 或 stiffness 超范围,行为未定义
const badSpring = curves.springMotion(2.5, -0.3); // 超范围
// ✅ 正确:参数在 0--1 范围
const goodSpring = curves.springMotion(0.5, 0.8);
6.3 忘取消旧弹簧动画
ts
// ❌ 错误:连续得分变化没取消旧弹簧,多个 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 普通弹簧用于实时跟随
ts
// ❌ 错误:拖拽跟随用普通 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
ts
// ❌ 错误:普通函数 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