HarmonyOS应用开发实战:猫猫大作战-springMotion 三参数、responsiveSpringMotion 响应式弹簧、与 EaseO

前言

前面我们用 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 触发流程

得分弹跳

  1. 合并猫咪得分 +10,oldScorescore 不同。
  2. handleScoreChange 触发 animateTo,弹簧曲线 scoreSpring
  3. scoreScale 从 1 弹簧补间到 1.3:1 → 1.5 → 1.1 → 1.4 → 1.15 → ... → 1.3,振荡回弹稳定。
  4. HUD 得分数字随之振荡放大缩小,800ms 完成。

合并新猫弹性出现

  1. 引擎合并生成新猫,调 handleMerge(newCatId)
  2. newCatScale 从 0 弹簧补间到 1:0 → 1.15 → 0.9 → 1.08 → 0.95 → ... → 1。
  3. 棋盘上该猫从 0 弹性放大出现,超过 1 再回弹,橡皮筋反馈。

连击闪烁

  1. 连击 ≥ 3,handleComboFlash 触发。
  2. comboFlash 从 true 弹簧补间到 false:true → false → true → false → ... → false,强阻尼快速稳定。
  3. 连击栏文字颜色随振荡闪烁,强阻尼振荡少不眩晕。

六、踩坑提示

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; });

七、调试技巧

  1. console.info 在 animateTo 闭包后:追 scoreScale 目标值,验证弹簧补间。
  2. 振荡不完成排查:检查 duration 是否足够(600--1000ms);检查参数是否超范围。
  3. 多个弹簧并行排查:检查是否忘取消旧动画;检查连续触发场景。
  4. DevEco Animation Inspector:查看弹簧振荡过程,验证物理参数。

八、性能与最佳实践

  1. 「弹跳反馈」用 Spring 弹簧------得分弹跳、合并出现有真实物理振荡。
  2. springMotion 三参数------velocity 速度、stiffness 刚性、damping 阻尼(可选)。
  3. 「实时跟随」用 responsiveSpringMotion------拖拽跟随保留速度顺势改向不卡顿。
  4. duration 足够振荡完成------通常 600--1000ms,太短卡中间值。
  5. 连续触发先取消旧动画------避免多个弹簧并行振荡错乱。
  6. 「匀减速到目标」用 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------播放预定义动画。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

相关推荐
达子6664 小时前
第13章_HarmonyOs开发图解 视频
华为·音视频·harmonyos
fiona20264 小时前
HarmonyOS应用开发实战:猫猫大作战-如何精准设置缓存数量来平衡内存与滚动流畅度
harmonyos·鸿蒙
程序员黑豆5 小时前
鸿蒙应用开发之父子组件传参:@Prop 装饰器使用教程
前端·后端·harmonyos
FF2501_940228585 小时前
HarmonyOS应用《玄象》开发实战:宜忌标签云:ForEach + padding + borderRadius 的 chip 实现
harmonyos·鸿蒙
程序员黑豆6 小时前
鸿蒙应用开发之V2状态管理:@Local、@ObservedV2、@Trace 使用教程
前端·后端·harmonyos
LEO111106 小时前
HarmonyOS应用开发实战:猫猫大作战-onHover 触发时机、HoverType 类型判定、与 onTouch 的差异、TV/PC 场景应用四
harmonyos·鸿蒙
程序员黑豆6 小时前
鸿蒙应用开发之@State 装饰器详解:从基本类型到 @Observed/@ObjectLink/@Track 嵌套监听
前端·harmonyos
慧海灵舟6 小时前
鸿蒙南向开发教程 Day 11:GPIO 按键中断与 LED 状态机
华为·harmonyos