动手做一个有趣的钢琴游戏!
前言
从很久之前,就幻想着自己能够弹一手流利的钢琴,但一直没有实现,后面电脑键盘用多了,就希望可以用键盘弹出像钢琴一般的曲谱。年前突然一时兴起,想做一个钢琴游戏自己玩一下,业余时间修修补补,也算是做成了这个游戏。这篇文章主要是对游戏开发的思路进行讲解</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-Cli3
、node14.8.0
、axios1.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.14
、JDK1.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
进行体验~