開発Tips

育成キャラをタップしたら反応する — 表情・バウンス・触覚・効果音の重ね方

家族向けアプリ「ぽんっ!」(旧称:でいりんぐ)のこどもホームには、育てているキャラの画像が出ています。ここをタップすると、キャラがよろこんで反応するようにしました。表情が happy に変わり、ぴょこんとバウンスし、指先に軽い触覚が返り、なでる効果音が鳴ります。1 つのタップに複数のフィードバックを重ねるときの組み方と、そこで気をつけた排他・ゲート・フォールバックを残します。対象はキャラ表示ウィジェット1つです。

背景: 遷移とタップが重複していた

もともとキャラ画像のタップは「図鑑へ遷移」に割り当てていました。ところが同じカードの下部に「図鑑をみる」ボタンがあり、図鑑への導線が二重になっていました。

そこで図鑑遷移はボタン側に一本化し、空いた画像タップを「なでる」反応に充てました。こどもにとっては、キャラをさわると返事が返ってくるほうが自然で、愛着とアプリ滞在の楽しさにつながります。ここが今回の狙いです。

反応の組み立て: 3 つ(のちに 4 つ)のフィードバック

タップ 1 回で走らせるのは次のフィードバックです。責務ごとに別々の仕組みに分け、1 つの入口メソッドが束ねています。

  • 触覚: HapticFeedback.lightImpact()。軽い一発。
  • 表情: 反応中フラグを立てて happy 画像を短時間(900ms)再生し、タイマーで元に戻す。
  • バウンス: AnimationController + ScaleTransition で 1.0 → 1.14 → 1.0 の一往復。
  • 効果音: あとから追加(後述)。

入口はこんな形です(識別子は説明用の仮名)。

// 画像タップでキャラがよろこぶ反応(表情 happy+バウンス+触覚)。
// 別の占有演出(給餌など)の最中はタイミングが競合するため無視する(排他)。
void onTap() {
  if (isBusyWithExclusiveAnim) return;        // 一方向の排他ゲート
  HapticFeedback.lightImpact();
  unawaited(playSound());
  reactTimer?.cancel();
  setState(() => isReacting = true);
  reactTimer = Timer(const Duration(milliseconds: 900), () {
    if (mounted) setState(() => isReacting = false);
  });
  // reduced-motion 時は揺れ/バウンスを行わない(表情・触覚は維持)。
  if (!MediaQuery.disableAnimationsOf(context)) {
    bounce.forward(from: 0);
  }
}

表情の再生は、別の占有演出(給餌など)と同じ「一定時間だけ animated にする」仕組みを流用しつつ、フラグは分けました。占有演出(約 3 秒)とタップ反応(900ms)で、画像に渡す animated は 2 つのフラグの論理和です。片方が終わってももう片方が生きていれば再生は続く、という素直な合成になります。

バウンスは TweenSequence の一往復です。前半 easeOut で 1.14 まで伸び、後半 easeIn で 1.0 に戻します。

bounceScale = TweenSequence<double>([
  TweenSequenceItem(
    tween: Tween<double>(begin: 1, end: 1.14)
        .chain(CurveTween(curve: Curves.easeOut)),
    weight: 50,
  ),
  TweenSequenceItem(
    tween: Tween<double>(begin: 1.14, end: 1)
        .chain(CurveTween(curve: Curves.easeIn)),
    weight: 50,
  ),
]).animate(bounce);

ポイント 1: AnimationController の後始末

AnimationController を持つと、dispose の取りこぼしがそのままリークになります。ここでは SingleTickerProviderStateMixin を state に付けて vsync: this を供給し、dispose で確実に破棄します。タイマー、あとから足したオーディオプレイヤーも同じ場所でまとめて片付けます。

@override
void dispose() {
  feedTimer?.cancel();
  reactTimer?.cancel();
  bounce.dispose();
  audioPlayer.dispose();
  super.dispose();
}

ポイント 2: 連打と、別演出との排他

こどもは連打します。連打してもフラグが宙に浮かないよう、前のタイマーを破棄してから新しく張り直します。バウンスも毎回 forward(from: 0) で頭出しするので、タップのたびに気持ちよく跳ね直します。

排他ももう 1 つ入れています。占有演出(給餌など)の最中はタップ反応を無視する、という一方向のゲートです。占有演出とタップの表情が重なるとタイミングが競合するため、入口で早期リターンします。逆(反応中に占有演出が来る)は占有演出側が優先で上書きされて問題ないため、ゲートは 1 か所だけに置いています。

ポイント 3: reduced-motion への配慮

「ぽんっ!」は端末の視差効果を減らす設定(reduced-motion)を尊重しています。タップ反応でも、MediaQuery.disableAnimationsOf(context) が true のときはバウンスだけを止めて、表情変化と触覚は残します。動きに敏感な人にも、なでた手応え自体は返るようにしています。

効果音はあとから、ゲートとフォールバック付きで

最初のリリースでは専用アセットが無く、効果音は入れませんでした。素材が用意できた後追いで足しています。音まわりは 2 つのルールを守りました。

  • ON/OFF は既存の音設定を尊重する。設定で音を切っている人には鳴らしません。
  • サイレントフォールバック。プラグイン未対応環境やファイル未配置でも、例外で落とさず黙って何もしません。
Future<void> playSound() async {
  if (!ref.read(soundEnabledProvider)) return;   // 設定ゲート
  try {
    await audioPlayer.play(tapSoundAsset, volume: 0.7);
  } catch (_) {
    // ファイル未配置・プラットフォーム未対応などはサイレントフォールバック
  }
}

呼び出し側は unawaited(playSound()) で、音の再生を待たずに触覚・表情・バウンスを先に走らせています。音がわずかに遅れても、見た目と手応えは即応します。

テスト観点

このウィジェットは永続データを触らないので、ウィジェットテストで反応の成立を素直に確認できます。育成のフル機能(作成 → 成長 → 図鑑)はテストアカウントの状態を変えてしまうため自動 E2E を避ける、という方針は据え置きです。ウィジェットテストで押さえた仕様は次のとおりです。

  • タップで反応(happy)が成立し、図鑑へは遷移しない。
  • バウンスで scale が 1.0 を超え、収束後に等倍へ戻る。
  • reduced-motion ではバウンスせず(scale=1)表情変化のみ
  • 占有演出中のタップは無視される(排他)。
  • 効果音 ON でタップしても反応は成立し、例外で落ちない。テスト環境ではオーディオプラグインが未実装ですが、try/catch で握るぶんテストは通ります。

音の ON/OFF は、音設定を差し替える小さな override で切り替えて両経路を通しました。テスト環境で実際に音は鳴りませんが、「有効時に再生経路(play + catch)を通ってもクラッシュしない」ことを担保できます。

まとめ

  • 1 タップに複数のフィードバックを重ねるときは、触覚・表情・アニメ・音を別々の仕組みに分け、入口の 1 メソッドで束ねると見通しがよい。
  • AnimationController・タイマー・オーディオプレイヤーは同じ dispose でまとめて片付ける。連打対策はタイマーの張り直しと forward(from: 0)
  • reduced-motion はアニメだけ止めて手応えは残す。演出どうしの競合は一方向の排他で 1 か所に集約する。
  • 音は設定ゲート + サイレントフォールバックで、切っている人・非対応環境を静かに素通りさせる。