在这里插入图片描述
在这里插入图片描述

实例:简易钢琴(Piano)|技术:琴键几何布局、黑白键命中判定、按下高亮、AVPlayer 发音

一、为什么选简易钢琴

1.1 从"视觉"到"视听结合"

前 9 个 Canvas 应用都是纯视觉(图表/粒子/游戏/导图)。简易钢琴引入声音维度:

  1. 琴键布局——钢琴键盘是"白键 + 黑键"的复杂几何(黑键不连续、跨白键交界);
  2. 精确命中——黑白键交错,触摸要判定"按到了白键还是黑键";
  3. 按下反馈——视觉(高亮)+ 听觉(发音)双重反馈。

钢琴是"几何布局 + 精确命中 + 多通道反馈"的综合练习——琴键排列是有规律的几何,命中判定比矩形网格(贪吃蛇)复杂。

1.2 一个八度的键盘

真实钢琴 88 键。本应用实现一个八度(C3~B3)

白键 C3 D3 E3 F3 G3 A3 B3
黑键 C#3 D#3 F#3 G#3 A#3

黑键位置:C-D 间、D-E 间、F-G 间、G-A 间、A-B 间(E-F 间、B-C 间没有黑键——钢琴的天然半音规律)。

二、琴键几何布局 ★核心算法

2.1 数据结构

/** 白键(C 大调音阶) */
const WHITE_KEYS: string[] = ['C3', 'D3', 'E3', 'F3', 'G3', 'A3', 'B3'];
/** 黑键位置:白键之间的间隔序号 */
const BLACK_POS: number[] = [1, 3, 5, 6, 7];
/** 黑键音符名 */
const BLACK_NAMES: string[] = ['C#3', 'D#3', 'F#3', 'G#3', 'A#3'];

黑键位置编码BLACK_POS = [1,3,5,6,7] 表示黑键位于第 1、3、5、6、7 个"白键间隔"。间隔编号规则:间隔 i 在白键 i-1 与白键 i 之间(间隔 1 = C-D 间,间隔 3 = D-E 间,间隔 5 = F-G 间,间隔 6 = G-A 间,间隔 7 = A-B 间)。用间隔序号而非像素,让黑键位置与白键布局解耦——调整白键宽度,黑键自动跟随。

2.2 坐标计算

private whiteX(idx: number): number {
  return this.startX + idx * this.whiteW;   // 白键 idx 左边缘
}

private blackX(pos: number): number {
  return this.startX + pos * this.whiteW - this.blackW / 2;  // 黑键中心在交界处
}

白键startX + idx × whiteW——等宽线性排布(idx 0~6)。

黑键startX + pos × whiteW - blackW/2——黑键中心对准白键间隔的中点(交界处),左右各露半宽。黑键跨越两个白键的交界,覆盖在交界线上。

三、触摸命中判定 ★核心交互

private hitKey(x: number, y: number): void {
  // ① 先查黑键(黑键在上半区)
  for (let i = 0; i < BLACK_POS.length; i++) {
    const bx = this.blackX(BLACK_POS[i]);
    if (x >= bx && x <= bx + this.blackW && y >= 0 && y <= this.blackH) {
      this.pressedKey = BLACK_NAMES[i];
      this.noteText = `${BLACK_NAMES[i]}`;
      this.playNote(BLACK_NAMES[i]);
      this.draw();
      return;
    }
  }
  // ② 再查白键(下半区全高)
  for (let i = 0; i < WHITE_KEYS.length; i++) {
    const wx = this.whiteX(i);
    if (x >= wx && x <= wx + this.whiteW && y >= 0 && y <= this.whiteH) {
      this.pressedKey = WHITE_KEYS[i];
      this.noteText = `${WHITE_KEYS[i]}`;
      this.playNote(WHITE_KEYS[i]);
      this.draw();
      return;
    }
  }
}

黑白键的命中顺序(先黑后白)

  • 黑键优先:黑键只有白键高度的约 60%(blackH=126 vs whiteH=200),且覆盖在白键上方。若先查白键,触摸黑键区域会误命中其下的白键(黑键矩形在白键矩形内)。先查黑键保证黑键区域优先命中黑键。
  • 判定条件x ∈ [bx, bx+blackW] && y ∈ [0, blackH]——黑键只在上半区有效(y < blackH);白键全高(y < whiteH)。Y 分区:上半区可能是黑键,下半区一定是白键。

滑动连弹TouchType.Move 也调用 hitKey——手指滑过多个琴键时逐个触发("滑音"效果)。

四、按下高亮

private draw(): void {
  // ① 白键
  for (let i = 0; i < WHITE_KEYS.length; i++) {
    const x = this.whiteX(i);
    const pressed = this.pressedKey === WHITE_KEYS[i];
    this.ctx.fillStyle = pressed ? '#BFDBFE' : '#FFFFFF';   // 按下浅蓝
    this.ctx.fillRect(x, 0, this.whiteW, this.whiteH);
    this.ctx.strokeStyle = '#CBD5E1';
    this.ctx.strokeRect(x, 0, this.whiteW, this.whiteH);
    // 白键标签
    this.ctx.fillStyle = '#64748B';
    this.ctx.font = '12px sans-serif';
    this.ctx.textAlign = 'center';
    this.ctx.fillText(WHITE_KEYS[i], x + this.whiteW / 2, this.whiteH - 10);
    this.ctx.textAlign = 'start';
  }
  // ② 黑键(后画,覆盖在白键上)
  for (let i = 0; i < BLACK_POS.length; i++) {
    const x = this.blackX(BLACK_POS[i]);
    const pressed = this.pressedKey === BLACK_NAMES[i];
    this.ctx.fillStyle = pressed ? '#1E3A8A' : '#0F172A';   // 按下深蓝
    this.ctx.fillRect(x, 0, this.blackW, this.blackH);
    this.ctx.strokeStyle = '#334155';
    this.ctx.strokeRect(x, 0, this.blackW, this.blackH);
  }
}

绘制顺序:先白键后黑键——黑键"压"在白键交界上,视觉正确(黑键高于白键)。

按下高亮pressedKey === 音符名 时白键浅蓝(#BFDBFE)、黑键深蓝(#1E3A8A)——按下态颜色可视化。抬起后(Up 事件 pressedKey=‘’)重绘恢复。

五、发音(AVPlayer 接入说明)

private playNote(name: string): void {
  if (this.muted) {
    return;
  }
  // 尝试播放音阶资源(assets/entry/resources/rawfile/notes/C3.wav 等)
  hilog.info(0x0000, TAG, 'play note: %{public}s', name);
}

发音方案(注释说明接入方式):

  1. 准备音阶资源:把 C3~B3 的 12 个音符 wav 文件放入 resources/rawfile/notes/
  2. 创建播放器audio.createAudioPlayer()(@kit.AudioKit);
  3. 播放player.src = 'rawfile:///notes/C3.wav'; player.play()

教学版 playNote 只打日志(无资源不报错),视觉高亮是主演示,声音是可选增强。真机接入时按上述三步替换即可。

静音开关:🔊/🔇 切换 muted——无资源环境下的演示开关。

六、技术要点对照表

技术点 实现方式 生产价值
黑键位置编码 间隔序号数组 布局解耦
黑白键几何 白等宽 + 黑跨交界 真实键盘
命中顺序 先黑后白 防误命中
Y 分区 黑键半高判定 分区精确
按下高亮 pressedKey 比较 视觉反馈
滑动连弹 Move 也 hitKey 滑音效果
声音降级 无资源仅日志 环境自适应
静音开关 muted 切换 演示控制

七、文章小结

本篇完成了简易钢琴的数据与几何层:白键等宽排布 + 黑键跨交界的琴键几何、间隔序号编码的黑键位置、先黑后白 + Y 分区的命中判定、按下高亮绘制、AVPlayer 发音接入说明。这套"几何编码 + 分区命中"的键盘实现是乐器类应用的基础——黑键位置用间隔序号而非像素,是"布局参数化"的典范。

下一篇《页面 UI 与弹奏交互》将搭建:钢琴画布、音符提示、静音开关、音阶说明。


八、深度扩展

1. 多八度扩展

一个八度扩展到 88 键:WHITE_KEYS 生成 52 个白键名(C1~C7),BLACK_POS 模式每八度重复。八度 = 位置模式 + 音名循环

// 每八度 7 白键 5 黑键,位置模式固定 [1,3,5,6,7]
// 白键名:C D E F G A B + 八度号

2. 和弦

多指触摸(touches 数组)同时按下多个键 → 和弦。实现:pressedKeys 数组(非单值)+ 多播放器并发。多点触控是乐器的刚需。

3. 录音与回放

录制弹奏序列(音符 + 时间戳)→ 回放(定时依次触发)。弹奏轨迹是教学/分享功能。

4. 音高合成(无资源)

不依赖 wav 资源,用 WebAudio API(@kit.ArkWeb?HarmonyOS 需查)或原生音频合成正弦波。程序化合成免资源,但 HarmonyOS 封装有限,教学用资源方案。

5. FAQ

Q1:为什么黑键要先于白键命中?
A:黑键矩形完全位于白键矩形内(黑键盖在白键交界上)。若先查白键,黑键区域会被其下的白键"截胡"。先查覆盖层是命中检测的通用原则(36 的叶子优先同理)。

Q2:黑键为什么只在上半区有效(y < blackH)?
A:黑键比白键短(126 vs 200),下半区没有黑键。y < blackH 限定黑键的有效高度,防止"白键下半区却命中黑键"的误判。

Q3:没有音频资源能跑吗?
A:能——playNote 降级为日志,视觉高亮完整可用。声音是增强不是必需,教学环境友好。

Q4:滑动弹奏的 Move 判定会不会太灵敏?
A:手指滑过时每帧 hitKey,琴键逐个点亮——"滑音"正是想要的效果。若要去抖可加"同键不重复触发"判断(pressedKey 相同则跳过)。

Q5:为什么只做一个八度?
A:教学聚焦"琴键几何 + 命中 + 反馈"核心,88 键是重复模式的扩展(见深度扩展 1)。先做最小可玩,再横向扩展


九、下篇预告

下一篇《页面 UI 与弹奏交互》将完成:钢琴画布(96% 宽 × 210 高)、音符提示、静音开关、音阶说明——并演示 C 大调音阶的弹奏体验。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐