动手做一个有趣的钢琴游戏!

前言

从很久之前,就幻想着自己能够弹一手流利的钢琴,但一直没有实现,后面电脑键盘用多了,就希望可以用键盘弹出像钢琴一般的曲谱。年前突然一时兴起,想做一个钢琴游戏自己玩一下,业余时间修修补补,也算是做成了这个游戏。这篇文章主要是对游戏开发的思路进行讲解</pre>

一、先整理需求:

1、页面监听键盘事件,发现键盘按下后就会弹出一个音符

2、演奏的乐器为钢琴

3、可以手动选择演奏的曲目

4、无论是按下哪个键盘键,都会自动弹出对应曲目的下一个音符

需求列表内容十分简单,除了需求3是拓展性功能外,需求1,2,4就可以大致定下来整个功能的框架了。

实现的思路也十分清晰:

1、页面加载时,先拿到88键钢琴的各个按键的音乐文件和演奏曲目的曲谱,把曲谱的每一个音节切分出来,这样我们就可以得到整首歌曲从开始到结束的所有音符,只要依次拿到每一个音符去找到对应的钢琴按键音乐文件,就可以播放出每个音符对应的音乐了。

2、步骤1虽然可以实现音乐文件的定位,但我们并不是一股脑地把整首歌曲的音符都直接播放出来,而是要结合键盘的动作来进行。所以前端要监听keydown事件,并绑定一个"演奏功能",当出现键盘按入事件后,就拿出一个音符去播放对应的音乐文件。

3、想要实现手动演奏曲目,这个功能其实比较简单,录入歌曲的时候以单首歌曲为单位录入到曲库中。页面取值的时候只需要根据需要从“曲库”中拿到新的曲谱替换掉当前曲谱即可。

二、可优化点分析

  • 音频文件处理

88键钢琴一共涉及88个音频文件,如果前后端交互的话,每个音乐文件都单独下载的话太麻烦了,不如把所有音频文件转成base64字符串放在一个接口里面,这样后面就只需要调用1次接口就可以了,减少了``http`请求,也减少了丢包的可能性。

  • 音符和音乐文件的对应关系怎么处理

这其实是一个规则的定义问题,像我的话为了后续方便录入简谱,把标准的C-B定为1-7,也就是1-7分别表示“do re mi fa sol la si”,每高一个八度则在数字前面加上一个“+”号,比如高两个八度的“do”表示为“++1”;同理,每降一个八度则在数据前面加上一个“-”号,比如将一个八度的“do”表示为“-1”。

简谱的书写规则定义好了,接下来就是音符和音频文件的匹配了。这里涉及到一点点钢琴上的小知识,88键钢琴一共有52个白键、36个黑键,其中白键为全音,黑键为半音。黑白键的布局看似没有规律,其实也是有章可循的。除了最前面的三个按键和最后面的一个按键外,可以把中间部分划分为7个组,每个组都是7个白键和5个黑键,且每个组的黑白键布局都是一样的,得到的总键位数为3+(7+5)*7+1=88

已知标准“c”的键位为28,那么后续每个音符只需要先根据最前面的符号判断所属的组别,再根据组别中的序号信息就可以确定每个音符所对应的钢琴键位。


三、具体实现

项目其实比较简单,只做前端应用也可以完成,但出于习惯还是做成了前后端分离的项目(未来如果要拓展更多乐器的话,更多功能的话,前后端分离就更有必要了)

(一)前端实现
环境准备

Vue-Cli3node14.8.0axios1.3.3

1、使用vue-cli3搭建脚手架
vue create projectName
2、初始化完成后,下载axios作为网络交互工具
npm install axios
3、配置axios请求参数,初始化页面
<template>
  <div class="home">

    <div class="error-message-bar">
      <div class="error-message-content" v-if="errorMsg">
          {{errorMsg}}
      </div>
    </div>

    <!-- 演奏模式选择框 -->
    <div class="play-mode-bar">
      <div class="play-mode-title">演奏模式:</div>
      <div v-for="item in playModeOption"
           :class="['play-mode-btn', {'activeMode':item.key==currentPlayMode}]"
           @click="changePlayMode(item.key)">
        {{item.value}}
      </div>
      <div>
        <img :src="LastSongIcon" class="song-switch-icon" @click="goLastSong">
        <img :src="NextSongIcon" class="song-switch-icon" @click="goNextSong">
      </div>
    </div>

    <!-- 曲谱信息框 -->
    <div class="song-info-bar" v-if="songLib[currentSongIdx]">
      <div style="padding-bottom: 10px;">当前乐谱:《{{songLib[currentSongIdx].name}}》</div>
      <div>歌手:{{songLib[currentSongIdx].singer}}</div>
    </div>

    <Piano></Piano>
  </div>
</template>
4、前端的核心代码逻辑

(1)键位匹配逻辑

  data(){
    return {
      songLib:[], // 存放后台曲库中所有乐谱
      song:[], // 存放单首歌曲的所有音阶
      currentSongIdx:0, // 当前演奏的乐谱序号
      musicLib:[], // 存放88键钢琴的所有音阶
      baseIdx: 3+12*2, // 首个C音符前面的所有键位数
      errorMsg:'', // 调用接口的报错信息
      currentPlayMode: 0, // 当前的演奏模式
      playModeOption:[], //可选择的演奏模式
      NextSongIcon,
      LastSongIcon,
      showMainScene:true, // 是否显示页面的操作菜单
    }
  },
  created(){
    // 初始化演奏模式
    this.initPlayMode();
    // 初始化88键钢琴的所有音阶
    this.initPianoAudios();
    // 初始化曲库
    this.initSongLib();
  },
  methods:{
  /**
   *  初始化乐谱库和首个乐谱序号
   */
    initSongLib(){
      let that = this;
      queryAllSongs().then(result => {
        if(result.responseCode == '200' && result.data.length > 0){
          that.songLib = result.data;
          let keyIdxForPiano = that.transferNoteToPianoKeyIdxArray(result.data[0].note);
          that.song = keyIdxForPiano;
          that.currentSongIdx = 0;
        }else{
          that.updateErrorMsg(result.errorMsg);
          console.error('曲库初始化失败',result.errorMsg);
        }
      }).catch(error => {
        console.log('error',error);
      })
    },
        
   /*
    *  将音阶字符串转换为88键钢琴对应的键位
    *  noteStr 音阶字符串,例如"1 1 5 5"
    */
    transferNoteToPianoKeyIdxArray(noteStr){
      let result = [];
      if(!noteStr){
        return result
      }
      // 得到音阶字符串中的所有音符
      let temNoteElementArr = noteStr.split(' ');
      temNoteElementArr.map(item =>{
        let keyPosition = this.transferSpectrumToKeyPosition(item);
        result.push(keyPosition);
      })
      return result;
    },
    /**
     * 将简谱数值转为钢琴的键位
     * [1,7] 标识正常的C-B
     * [-1,-7] 表示降一个八度的C-B
     * [+1,+7] 表示升一个八度的C-B
     * 以此类推
     *
     * @param whiteNoteIndex 白键的键位
     * @returns {*}
     */
    transferSpectrumToKeyPosition(whiteNoteIndex){
      const upRegex = /\+/g;
      const downRegex = /\-/g;
      let upCycleNum = 0;
      if(whiteNoteIndex.match(upRegex)){
        upCycleNum = whiteNoteIndex.match(upRegex).length;
      }
      let downCycleNum =  0;
      if(whiteNoteIndex.match(downRegex)){
        downCycleNum = whiteNoteIndex.match(downRegex).length;
      }
      if(upCycleNum>0){
        let idxInGroup = parseInt(whiteNoteIndex.replaceAll(upRegex,''));
        let blackKeywordNum = this.getBlackKeywordNum(idxInGroup);
        return this.baseIdx + upCycleNum*12 + idxInGroup + blackKeywordNum;
      }else if(downCycleNum>0){
        let idxInGroup = parseInt(whiteNoteIndex.replaceAll(downRegex,''));
        let blackKeywordNum = this.getBlackKeywordNum(idxInGroup);
        return this.baseIdx - downCycleNum*12 + idxInGroup + blackKeywordNum;
      }else{
        let intIdx = parseInt(whiteNoteIndex);
        let blackKeywordNum = this.getBlackKeywordNum(intIdx);
        return this.baseIdx + intIdx + blackKeywordNum;
      }
    },
   getBlackKeywordNum(num){
      let result = 0;
      switch (num) {
        case 1:
          result = 0;
          break
        case 2:
          result = 1;
          break
        case 3:
          result = 2;
          break
        case 4:
          result = 2;
          break
        case 5:
          result = 3;
          break
        case 6:
          result = 4;
          break
        case 7:
          result = 5;
          break
      }
      return  result;
    },
      ...
  }
  

(2)页面初始化时绑定键盘监听事件

  mounted(){
    document.body.addEventListener('keydown', e => {
      this.play(e.keyCode);
    });
  },

(3)我们希望演奏的时候可以自由挑选曲谱,这里就再补充一下曲谱的跳转逻辑

   /*
    * 切换到下一首音乐
    */
    goNextSong(){
      const nextSongIdx = this.currentSongIdx + 1;
      if(nextSongIdx >= this.songLib.length){
        this.updateErrorMsg('没有下一首了!');
        return;
      }
      this.switchSong(nextSongIdx);
    },
   /*
    * 切换到上一首音乐
    */
    goLastSong(){
      const lastSongIdx = this.currentSongIdx - 1;
      if(lastSongIdx < 0){
        this.updateErrorMsg('没有上一首了!');
        return;
      }
      this.switchSong(lastSongIdx);
    },
    /*
     * 切换到最后一首音乐
     */
    goTheLastSong(){
      if(this.songLib && this.songLib.length>0){
        this.switchSong(this.songLib.length-1)
        return;
      }
      this.updateErrorMsg('曲库空空如也,请补充曲谱');
    },
    /*
     * 切换到最后一首音乐
     */
    goFirstSong(){
      if(this.songLib && this.songLib.length>0){
        this.switchSong(0);
        return;
      }
      this.updateErrorMsg('曲库空空如也,请补充曲谱');
    },
    /**
     *  根据坐标索引切换乐谱
     */
    switchSong(songIdx){
      if(songIdx < 0){
        this.updateErrorMsg('不存在的歌曲索引');
        return
      }
      let songNote = this.songLib[songIdx].note;
      console.log('songNote',songNote)
      let keyIdxForPiano = this.transferNoteToPianoKeyIdxArray(songNote);
      console.log('keyIdxForPiano',keyIdxForPiano)
      this.song = keyIdxForPiano;
      this.currentSongIdx = songIdx;
    },
(二)后端实现
环境准备:Springboot2.5.14JDK1.8

(由于功能较为简单,所以不打算建数据表,直接把数据放在yml文件中进行读取)

依赖准备
    <dependencies>
        <!-- 引入springboot web模块 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- 简化dto的代码开发 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!-- common3工具类 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
    </dependencies>
定义Controller、Service
@RestController
@RequestMapping("instrument")
public class InstrumentController {
    
    @Resource
    private InstrumentService instrumentService;

    /**
     * 获取(88键)钢琴的音频文件
     * @return
     */
    @RequestMapping("getPianoAudios")
    public QiQvResult getPianoAudios(){
        Piano piano = instrumentService.getPianoAudios();
        return QiQvResult.OK(piano);
    }
}

@RestController
@RequestMapping("song")
public class SongController {
    
    @Resource
    private SongService songService;

    /**
     * 获取所有可用歌曲
     * @return
     */
    @PostMapping("getAllSongs")
    public QiQvResult getAllSongs(){
        List<Song> songList = songService.getAllSongs();
        return QiQvResult.OK(songList);
    }

    /**
     * 根据歌名获取歌曲
     * @return
     */
    @PostMapping("findSongByName")
    public QiQvResult findSongByName(Song song){
        if(StringUtils.isBlank(song.getName())){
            throw new ApplicationException("歌名不能为空");
        }
        Song songList = songService.findSongByName(song.getName());
        return QiQvResult.OK(songList);
    }
}
@Service
public class SongServiceImpl implements SongService {
    
    @Autowired
    private SongData songData;

    /**
     * 根据歌名获取歌曲信息
     * @param songName  歌名
     * @return
     */
    @Override
    public Song findSongByName(String songName) {
        if(StringUtils.isBlank(songName)){
            return null;
        }
        List<Song> songList = songData.getSongList();
        Song hitSong = songList.stream().map(o -> {
            String note = o.getNote();
            if(StringUtils.isNotBlank(note)){
                List<String> singleNotes = Arrays.asList(note.split(" "));
                o.setSingleNoteList(singleNotes);
            }
            return o;
        }).filter(o -> songName.equals(o.getName())).findFirst().orElse(null);
        return hitSong;
    }

    @Override
    public List<Song> getAllSongs() {
        return songData.getSongList();
    }
}

@Service
public class InstrumentServiceImpl implements InstrumentService {
    
    @Autowired
    private PianoData instrumentData;
    
    @Override
    public Piano getPianoAudios() {
        Piano piano = new Piano();
        piano.setAudios(instrumentData.getPiano());
        return piano;
    }
}
引入数据初始化类
@Component
// 由于 DefaultPropertySourceFactory 不能解析yml文件,所以这里需要指定
@PropertySource(value = "classpath:data/instrument.yml",factory = YamlAndPropertySourceFactory.class)
@ConfigurationProperties(prefix = "instrument")
public class PianoData {
    
    List<String> piano;

    public List<String> getPiano() {
        return piano;
    }

    public void setPiano(List<String> piano) {
        this.piano = piano;
    }
}

@Data
@Component
// 由于 DefaultPropertySourceFactory 不能解析yml文件,所以这里需要指定
@PropertySource(value = "classpath:data/song.yml",factory = YamlAndPropertySourceFactory.class)
@ConfigurationProperties(prefix = "qiqv")
public class SongData {
    
    public List<Song> songList;
}

至此,第一阶段的前后端项目的代码基本已经完成~ 我们可以在前端页面根据键盘弹出简谱的曲子了!

第二阶段需求

前端的页面空空如也,只有菜单选项框。这显然不是很美观,既然核心逻辑已经完成,不如考虑一下怎么样能够让页面的元素更加酷一点!

GitHub上逛了一圈,发现了一个十分有趣的电音游戏页面,可以实现灵活性较高的音乐演奏,更难得的是页面上有十分丰富的动画效果。

GitHub地址:https://github.com/HFIProgramming/mikutap

在线访问地址:https://aidn.jp/mikutap/

看了一下源代码,项目的外部依赖大致分为以下三个模块:

  • 前端动画渲染:pixi.js

  • 动画元素创建:gsap.js

  • 基本依赖:jquery.min.js

涉及动画的内容之前未曾了解过,但是也不妨碍我将这个项目利用起来。兴趣使然下,我把整个项目适配到了原来开发的一阶段项目上,最终实现了弹钢琴和页面动画相结合的效果。

至此,钢琴游戏的第二阶段开发也到此结束。

项目的代码已经上传至码云,有兴趣的可以瞄瞄:https://gitee.com/moutory/keyborad-music

如果只是单纯想体验一下游戏,可以通过PC端访问:http://117.50.173.229 进行体验~

posted @ 2023-03-08 20:36  moutory  阅读(54)  评论(0编辑  收藏  举报  来源