前言

代码演示环境

  • 软件环境:Windows 10
  • 开发工具:Visual Studio Code
  • JDK版本:OpenJDK 15
声效和音乐

声效基础知识

当我们玩游戏时,我们可能会听到声效,但是不会真正注意它们。因为希望听到他们,所以声效在游戏中是非常重要的。另外,在游戏中的音乐会动态被修改来配合游戏的剧情的发展。那么什么是声效(声音)呢?声效是通过媒体振动产生的效果。该媒体是空气和计算机中的扬声器产生的振动—从而发出了声音—传送到我们耳朵里;然后我们的耳膜会捕获这些信号,接着传送给我们的大脑,从而人类听到了声音。

共振(vibration)是通过空气的压缩振动(fluctuations)产生的,快速的振动产生高频声效,让我们听到高音。每个振动的压缩数量是使用振幅(amplitude)来表示的。高振幅会让我们听到声音大;简而言之,声波(sound waves)就是在持久时间不停修改振幅而已。如下图所示:

Java游戏编程不完全详解-4_游戏开发

数码声效、CD和计算机的音效格式都是一系列的声波,每秒中的音波振幅叫做音频采样。当然高采样的音波可以更加精确的表现声音,这些采样是使用16位来表示65535种可能的振幅。许多声音允许多个声道,比如CD有两个声道—一个给左扬声器,一个给右扬声器。

Java声效API

Java可以播放8位和16位的采样,它的范围从8000hz到48000hz,当然它也可以播放单声道和立体声声效。那么使用什么声音,这需要根据游戏的剧情,比如16位单声道,44100Hz声音。Java支持三种声频格式文件:AIFF, AU和WAV文件。我们装载音频文件时使用AudioSystem类,该类有几个静态方法,一般我们使用getAudioInputStream()方法来打开一个音频文件,可以从本地系统,或者从互联网打开,然后返回AudioInputStream对象。然后使用该对象读取音频采样,或者查询音频样式。

File file = new File(“sound.wav”);
AudioInputStream stream = AudioSystem.getAudioInputStream(file);
AudioFormat format = stream.getFormat();

其中AudioFormat类提供了获取声效采样的功能,比如采样率和声道数量。另外它还提供了frame尺寸--一些字节数量。比如16位立体声,它的frame大小是4,或者2个字节表示采样值,这样我们可以很方便的计算出立体声可以占多少内存。比如16位三分之二长度的立体音频格式采样所占内存值:44100x 3x 4字节 = 517KB,如果是单声道,那么采样容量是立体声的一半。

当我们知识声频采样的大小与格式之后,接下来就是从这些声频文件中读取内容了。接口Line是用来发送和接收系统的音频的API。我们可以使用Line发送声音采样到OS的声音系统去播放,或者接收OS的声音系统的声音,比如microphone声音等。Line有几个子接口,最主要的子接口是SourceDataLine,该接口可以让我们向OS中的声音系统写入声音数据。Line的实例是通过AudioSystem的getLine()方法获取,我们可以传送参数Line.Info对象来指定返回的Line类型。因为Line.Info有一个DataLine.Info子类,它知道Line类型除了SourceDataLine接口之外,还有另外一个Line叫做Clip()接口。该接口可以为我们做许多事情,比如把采样从AudioInputStream流装载到内存中去,并且自动向音频系统输送这些数据去播放。下面是使用Clipe来实现声音的播放代码:

//指定哪种line需要被创建
DataLine.Info info = new DataLine.Info(Clip.class,format);
//创建该line
Clip clip = (Clip)AudioSystem.getLine(info);
//从流对象装载采样
clip.open(stream);
//开始播放音频内容
clip.start();

Clip接口非常好用,它非常类似于JDK 1.0版本中AudioClip对象,但是它有一些缺点,比如Java声效有限制Line的数量,这种限制是在相同的时间打开Line时出现,一般最多有32个Line对象同时存在。也就是说,我们只能打开有限个line对象使用。另外,如果我们想同时播放多个Clip对象,那么Clip只能在同一时间播放一个声音,比如我们想同时播放两到三个爆炸声,但是一个声音只能应用一个爆炸声。因为这种缺陷,所以我们会创建一种解决方案来克服这种问题。

播放声音

下面我们创建一个简单的声音播放器,主要使用AudioInputStream类把音频文件读到字节数组中,然后使用Line对象来自动播放。因为ByteArrayInputStream类封装了字节数组,所以,我们可以同时播放多个相同音频的复本。getSamples(AudioInputStream)方法从AudioInputStream流中读采样数据,然后保存到字节数组中,最后使用play()方法从InputStream流对象中读取数据到缓存,然后写到SourceDataLine对象中让它播放。

由于Java声效API中有bug,所以让Java进程不会自己退出,通常情况下,JVM只运行精灵线程,但是当我们使用Java声效时,非精灵线程在台后进行中运行,所以我们必须呼叫System.exit(0)结束Java声效进程。

SimpleSoundPlayer类

package com.funfree.arklis.sounds;
import java.io.*;
import javax.sound.sampled.*;

/**
	功能:书写一个的类,用来封装声音从文件系统打开,然后进行播放
	*/

public class SimpleSoundPlayer  {
    private AudioFormat format;
    private byte[] samples;//保存声音采样

    /**
        Opens a sound from a file.
    */
    public SimpleSoundPlayer(String filename) {
        try {
            //打开一个音频流
            AudioInputStream stream =
                AudioSystem.getAudioInputStream(
                new File(filename));

            format = stream.getFormat();

            //取得采样
            samples = getSamples(stream);
        }
        catch (UnsupportedAudioFileException ex) {
            ex.printStackTrace();
        }
        catch (IOException ex) {
            ex.printStackTrace();
        }
    }


    /**
        Gets the samples of this sound as a byte array.
    */
    public byte[] getSamples() {
        return samples;
    }


    /**
        从AudioInputStream获取采样,然后保存为字节数组。--这里的数组会被封装ByteArrayInputStream类中,
		以便Line可以同时播放多个音频文件。
    */
    private byte[] getSamples(AudioInputStream audioStream) {
        //获取读取字节数
        int length = (int)(audioStream.getFrameLength() *
            format.getFrameSize());

        //读取整个流
        byte[] samples = new byte[length];
        DataInputStream is = new DataInputStream(audioStream);
        try {
            is.readFully(samples);
        }
        catch (IOException ex) {
            ex.printStackTrace();
        }

        // 返回样本
        return samples;
    }


    /**
        播放流
    */
    public void play(InputStream source) {
		//每100毫秒的音频采样
        int bufferSize = format.getFrameSize() *
            Math.round(format.getSampleRate() / 10);
        byte[] buffer = new byte[bufferSize];

        //创建line对象来执行声音的播放
        SourceDataLine line;
        try {
            DataLine.Info info =
                new DataLine.Info(SourceDataLine.class, format);
            line = (SourceDataLine)AudioSystem.getLine(info);
            line.open(format, bufferSize);
        }catch (LineUnavailableException ex) {
            ex.printStackTrace();
            return;
        }

        // 开始自动播放
        line.start();

        // 拷贝数据到line对象中
        try {
            int numBytesRead = 0;
            while (numBytesRead != -1) {
                numBytesRead =
                    source.read(buffer, 0, buffer.length);
                if (numBytesRead != -1) {
                   line.write(buffer, 0, numBytesRead);
                }
            }
        }catch (IOException ex) {
            ex.printStackTrace();
        }

        // 等待所有的数据播放完毕,然后关闭line对象。
        line.drain();
        line.close();

    }

}

如果需要循环播出,那么修改一下上面类就可以实现该功能。

LoopingByteInputStream类

package com.funfree.arklis.engine;
import static java.lang.System.*;
import java.io.*;

/**
	功能:封装ByteArrayInputStream类,用来循环播放音频文件。当停止循环播放时
		  呼叫close()方法
	*/
public class LoopingByteInputStream extends ByteArrayInputStream{
	private boolean closed;
	
	public LoopingByteInputStream(byte[] buffer){
		super(buffer);
		closed = false;
	}
	
	/**
		读取长度为length的数组。如果读完数组内容,那么把下标设置为开始处,
		如果关闭状态,那么返回-1.
		*/
	public int read(byte[] buffer, int offset, int length){
		if(closed){
			return -1;
		}
		int totalBytesRead = 0;
		while(totalBytesRead < length){
			int numBytesRead = super.read(buffer,offset + totalBytesRead,
				length - totalBytesRead);
			if(numBytesRead > 0){
				totalBytesRead += numBytesRead;
			}else{
				reset();
			}
		}
		return totalBytesRead;
	}
	
	/**
		关闭流
		*/
	public void close()throws IOException{
		super.close();
		closed = true;
	}
}

声效过滤器是简单的音频处理器,用来现有的声音样本,这种过滤器一般用来实时处理声音。所以谓声效过滤器就是常说的是数字信号处理器(digital signal processor)—用于后期声效的处理,比如吉他添加回响效果。

创建一个实时的声效过滤框架

因为声效过滤器可以让游戏更加动态效果,所以我们可平衡游戏情节和声效的效果。比如,我们可以添加打击的回响效果,或者播放一段摇滚声音等。下面我们创建一个回响过滤器和3D环绕声效过滤器。因为有多种声效过程器,所以我们可创建一个过滤器框架。在这里把这个框架定义了三种非常重要的方法:

  • 过滤样本
  • 获取剩下的尺寸
  • 复位

SoundFilter对象可以包含状态数据,所在不同的SoundFilter对象可以用来播放不同的声音。为简化,允许SoundFilter播放16位、带符号和little-endian格式的样本。所以little endian是一种专业术语,它表示数据的字节顺序。Little-endian表示以最低有效位来存贮16位的样本数据;big-endian表示是以最高有效位来存贮16位的样本数据。带符号是正负值的意思,比如-32768到32767数值。WAV声音文件默认使用是little-endian格式存贮的。

Java声效就像字节数据,所以我们必须转换这些字节数据为16位带符号的格式才能工作。SoundFilter类提供这种功能,两个静态的方法setSample()和getSample()方法来实现。下面就是我们需要一种简单的方式来使用SoundFilter类来播放我们的声音文件。我们可以直接在样本数组中,但是灵活性不好,所以我们可以封装SimpleSoundPlayer类,加上LoopingByteArrayInputStream工具类,可以实现实时过滤源音乐文件,而不修改源文件。

我们说过,过滤器可以当作回音播放,实现这种效果是当原音频文件播放之后,在过滤器接着还播放声效,从而产生回音效果。不过在FilteredSoundStream类中,如果SoundFilter类还剩下数据字节,那么在read方法必须小清除这些字节数据,让它静音,最后这些动作完成之后,返回-1表示音频流读取结束。

FilteredSoundStream类

package com.funfree.arklis.sounds;

import java.io.FilterInputStream;
import java.io.InputStream;
import java.io.IOException;
import com.funfree.arklis.engine.*;

/**
    功能:该类是FilterInputStream类,它用于SoundFilter来实现底层的流。参见SoundFilter
    备注:FilterInputStream包含其它的输入流,它将这些流作为基本的数据源,它可以直接传输
    	或者提供额外的功能。
*/
public class FilteredSoundStream extends FilterInputStream {

    private static final int REMAINING_SIZE_UNKNOWN = -1;

    private SoundFilter soundFilter;
    private int remainingSize;
	
    /**
        使用指定的流和指定的SoundFilter对象
    */
    public FilteredSoundStream(InputStream in,
        SoundFilter soundFilter){
        super(in);
        this.soundFilter = soundFilter;
        remainingSize = REMAINING_SIZE_UNKNOWN;
    }
	
	
    /**
        重写read方法,用来过滤字节流
    */
    public int read(byte[] samples, int offset, int length)throws IOException{
        // 读取和过滤流中声音样本
        int bytesRead = super.read(samples, offset, length);
        if (bytesRead > 0) {
            soundFilter.filter(samples, offset, bytesRead);
            return bytesRead;
        }
		
        // 如果在声音流没有剩余的字节,那么检查过滤器是否有剩余字节(作为回音)
        if (remainingSize == REMAINING_SIZE_UNKNOWN) {
            remainingSize = soundFilter.getRemainingSize();
            // 取整后乘以4(标准帧的大小)
            remainingSize = remainingSize / 4 * 4;
        }
        if (remainingSize > 0) {
            length = Math.min(length, remainingSize);
			
            // 清除缓存
            for (int i=offset; i<offset+length; i++) {
                samples[i] = 0;
            }
			
            // 过滤剩下的字节
            soundFilter.filter(samples, offset, length);
            remainingSize-=length;
			
            // 返回长度
            return length;
        }
        else {
            // 结束流
            return -1;
        }
    }

}

创建一个实时的回音过滤器

回音表示在源音频文件播放结束之后,还有延迟的效果,图形表示如下:

Java游戏编程不完全详解-4_游戏开发_02

  • Delay--延迟
  • Original Sound--源声
  • First Echo--第一次回声
  • Second Echo--第二次回声

因为SoundFilter类是的格式是不知的,所以它不管我们过滤的音频文件是44100Hz的采样率还是8000hz的采样率,所以我们不可简单的告诉回音过滤器确定的延迟时间。于是,我们只能告诉回音过滤器有多省样本可以被延迟。比如让44100hz有一比二的延迟效果,于是告诉回音过滤器有44100样本被延迟。注意:因为延迟计时是从音频的开始处理计算。所以decay本身使用0或者表示延迟的样本。如果使用0表示不延迟,或者1表示回音的时间与原音频一样长。

给回音添加decay的代码如下:

short newSample = (short)(oldSample + decay * delayBuffer[delayBuffersPos]);

参见下面EchoFilter类

EchoFilter类

package com.funfree.arklis.sounds;
import com.funfree.arklis.engine.*;
/**
    功能:该类是SoundFilter类,它实现了模拟回音的效果,参见类FilteredSoundStream
*/
public class EchoFilter extends SoundFilter {
	
    private short[] delayBuffer;
    private int delayBufferPos;
    private float decay;
	
    /**
        使用指定的延迟样本数,以及指定的延迟比率。<p>延迟样本数是指初始听到的延迟样本数是多少。如果一
        秒的回音,那么使用单声道、44100声效以及44100延迟样本。<p>延迟值是从源样本中怎样实现回音。一个
        延迟率.5值表示回音是源音的一半。
    */
    public EchoFilter(int numDelaySamples, float decay) {
        delayBuffer = new short[numDelaySamples];
        this.decay = decay;
    }


    /**
        获取剩余的尺寸(表示单位是字节),该样本过滤可以源声效播放完成之后开始。但是必须确保
        延迟率必须小1%。
    */
    public int getRemainingSize() {
        float finalDecay = 0.01f;
        // 计算出Math.pow(decay,x) <= finalDecay(最终延迟)
        int numRemainingBuffers = (int)Math.ceil(
            Math.log(finalDecay) / Math.log(decay));
        int bufferSize = delayBuffer.length * 2;
		
        return bufferSize * numRemainingBuffers;
    }
	
	
    /**
        清除内部延迟缓存
    */
    public void reset() {
        for (int i=0; i<delayBuffer.length; i++) {
            delayBuffer[i] = 0;
        }
        delayBufferPos = 0;
    }


    /**
        过滤声效样添加一个回音效果。让样本播放出现延迟效果,该结果会被存贮在延迟缓存中,所以
        可以听到多个回音效果。
    */
    public void filter(byte[] samples, int offset, int length) {
		
        for (int i=offset; i<offset+length; i+=2) {
            // 更新可更新样本
            short oldSample = getSample(samples, i);
            short newSample = (short)(oldSample + decay *
                delayBuffer[delayBufferPos]);
            setSample(samples, i, newSample);
			
            // 更新延迟缓存
            delayBuffer[delayBufferPos] = newSample;
            delayBufferPos++;
            if (delayBufferPos == delayBuffer.length) {
                delayBufferPos = 0;
            }
        }
    }
}

当然还要参见SimpleSoundPlayer类的源代码。其中.6f表示60%的延迟效果。下面我们来怎样封装3D声效。

模拟3D环绕效果

3D声效又叫做方位听(directional hearing),它可以给玩家创建一个3维立体的体验游戏声音效果。作为3D声效实现时通有的功能如下:

  • 距离渐远时声音会随之变小,反之会逐渐增大
  • 单声道扬声器会在左喇叭播放,如果声源在右喇叭播放,那么我们的右耳朵会听到,3D声效可以实现四喇叭的声音播放效果
  • 可以创建室内的回响效果
  • 可以实现多普勒(Doppler)声效

3D声效不会应用于3D的游戏中,也可以运用于2D游戏中。下面我们实现3D过滤器,理论就是依据Pythagorean Theorem:

Java游戏编程不完全详解-4_游戏开发_03

该类是根据Sprite对象移动位置来确定声效,通过控制过滤之后的回音效果来实现3D声效。

Filter3d类

package com.funfree.arklis.sounds;
import com.funfree.arklis.graphic.Sprite;
import com.funfree.arklis.engine.*;

/**
    功能:书写一个3D过滤器,该类继承SoundFilter类,用来创建3D声效。
    	实现效果会根据距离来改变声音的大小。参见FilteredSoundStream类
*/
public class Filter3d extends SoundFilter {
	
    // 指定被修改的样本数
    private static final int NUM_SHIFTING_SAMPLES = 500;
	
    private Sprite source;//数据源
    private Sprite listener;//监听者
    private int maxDistance;//最大距离
    private float lastVolume;//最大音量
	
    /**
        创建一个Filter3D对象,创建时需要指定数据源对象和监听者对象(妖怪),在该过滤器运行时
        小怪的位置可以改变。
    */
    public Filter3d(Sprite source, Sprite listener,
        int maxDistance)
    {
        this.source = source;
        this.listener = listener;
        this.maxDistance = maxDistance;
        this.lastVolume = 0.0f;
    }
	
	
    /**
        根据距离来过滤声音
    */
    public void filter(byte[] samples, int offset, int length) {
		
        if (source == null || listener == null) {
            // 如果没有过滤的,那么什么都不做
            return;
        }
		
        // 否则计算监听者的到声音源的距离
        float dx = (source.getX() - listener.getX());
        float dy = (source.getY() - listener.getY());
        float distance = (float)Math.sqrt(dx * dx + dy * dy);
		
        // 设置音量从0(表示没有声音)
        float newVolume = (maxDistance - distance) / maxDistance;
        if (newVolume <= 0) {
            newVolume = 0;
        }
		
        // 设置样本的大小
        int shift = 0;
        for (int i = offset; i < offset + length; i += 2) {
			
            float volume = newVolume;
			
            // 把最后的音量转换成新的音量
            if (shift < NUM_SHIFTING_SAMPLES) {
                volume = lastVolume + (newVolume - lastVolume) *
                    shift / NUM_SHIFTING_SAMPLES;
                shift++;
            }
			
            // 修改样本的音量
            short oldSample = getSample(samples, i);
            short newSample = (short)(oldSample * volume);
            setSample(samples, i, newSample);
        }
		
        lastVolume = newVolume;
    }

}

注意:在GameCore类的lazilyExit()方法。不过该方法使用使用了多线程,所以处理它时需要小心,下面我们会书写一个SoundManager来处理这些系统级问题。该类实现了SimpleSoundPlayer类的相似的功能。SoundManager类有一个内部类SoundPlayer,它用来完成拷贝声音数据到Line对象中。SoundPlayer实现Runnable接口,所以它可以被作为一个任务线程,在线程池中使用。另外SoundPlayer与SimpleSoundPlayer不同之处是,如果SoundManager处理暂停状态,那么它会停止拷贝数据,SoundPlayer会呼叫wait()方法暂停线程,直到等待SoundManager通过所有线程,它处理非暂停状态为止。

Thread-Local Variable—是本地线程变量,我们希望SoundManager可以保障每个线程有自己的Line对象和字节缓存,那么我们可重复使用它们,而不需要每次播放时创建新的对象。为从线程池中获取自己的line和字节缓存,我们可使用thread-local变量来实现。因为本地变量是表示本地代码块,所以thread-local变量对于每个线程不同的值在该示例中,SoundManager类有localLine和localBuffer两个本地线程就是,每个线程可以有自己的line和字节缓存对象,而其它的线程不可以访问本地线程的变量。本地线程变量使用类ThreadLocal来创建。为让本地变量工作,我们需要修改一个ThreadPool类,我们需要有一种方式来创建本地线程变量,创建的时机是一个线程启动,然后在线程死掉时清理这些本地线程变量。为达到这样的效果,需要在ThreadPool中有如下代码:

public void run(){
  	threadStarted(); //标识该线程已经启动
	while(!isInterrupted()){
		Runnable task = null; //获取一个运行的任务
		try{
			task = getTask();
		}catch(InterruptedException e){ }
		//如果getTask()返回null或者停止
		if(task == null){
		       break; //跳出循环体
		}
		//否则运行该任务,并且不让异常抛出
		try{
		       task.run();
		}catch(Throwable t){
		       uncuaghtException(this,t);
		}
	}
	threadStopped();//标识该线程结束
}

本地线程变量

在ThreadPool类中threadStarted()和threadStopped()方法不做为,但是在SoundManager类是有用的。前者会用来创建一个新的Line对象和一个新的缓存对象,然后被添加到本地线程对象中;在后者方法中Line对象会被关闭与清理掉。就SoundManager类来说,除了提供暂停播放功能之外,该类还提供非常简便的方法来播放声音功能。

播放音乐

虽然背景音乐不是每个游戏都播放,但是它中游戏中是非常重要的。因为音乐可调整心情,同时音乐也可以表示游戏的剧情的发展方向,比如一个玩家与一个Boss打斗时的音乐会比较激烈。当我们确定使用什么样的音乐之后,那么游戏中怎样获取音乐呢?它主要有三各方式:

  • 从CD的音轨获取
  • 播放压缩的MP3或者Ogg音乐文件
  • 播放MIDI音乐文件

第一种方式是可以实现好的音质,并且容易实现它的缺陷是CD非常占空间,30MB的空间只能播放三分钟的音乐,如果想播放四首三分钟的音乐至少会占120MB的空间。第二种方式是播放压缩文件MP3和Ogg格式文件,它的缺陷是解压缩文件时会非常占CPU的处理时间。解决方案是使用专门的Java解压器,www.javazoom.net网站可以下载这些解压器。第三种方式MIDI方式除了有样本之外,还有指令,所以它是混成的,文件非常小,缺陷是音质会失真。为解决这个问题,我们需要使用JDK的soundback来解决它。

总结

Javar把硬件设计都深度封装了,所以当我们使用Java来开发游戏时,应该说是一件非常轻松愉快的事情。

希望相关的专业术语的讲解,可以给大家带来帮助。如果不足之处,请大家指正和补充。

图片来源:游戏盒子