9. 音效与特效
2026/4/14大约 6 分钟
长沙麻将——音效与特效
麻将音效的特点
麻将的音效比消消乐更有"仪式感"——每次摸牌、出牌、碰牌、杠牌、胡牌都有特定的声音。就像打真实的麻将一样,"啪啪"的洗牌声和"碰"的碰牌声是体验中不可分割的一部分。
音效清单
| 操作 | 音效描述 | 作用 |
|---|---|---|
| 洗牌 | "哗啦啦"的洗牌声 | 开局时的仪式感 |
| 摸牌 | 轻轻的"嗒"声 | 确认摸牌 |
| 出牌 | 清脆的"啪"声 | 确认出牌,提醒其他玩家 |
| 吃牌 | "吃"的语音 + 轻响 | 宣告操作 |
| 碰牌 | "碰"的语音 + 重响 | 宣告操作 |
| 杠牌 | "杠"的语音 + 重重一响 | 强烈的宣告 |
| 胡牌 | "胡了!"语音 + 欢快音乐 | 最激动人心的时刻 |
| 自摸 | "自摸!"语音 + 更激昂音乐 | 比点炮更激动 |
| 流局 | 轻柔的提示音 | 平淡的结束 |
音频管理器
C
using Godot;
/// <summary>
/// 麻将音频管理器
/// </summary>
public partial class MahjongAudioManager : Node
{
public static MahjongAudioManager Instance { get; private set; }
// ===== 音效资源 =====
[Export] public AudioStream ShuffleSound { get; set; }
[Export] public AudioStream DrawSound { get; set; }
[Export] public AudioStream DiscardSound { get; set; }
[Export] public AudioStream ChiSound { get; set; }
[Export] public AudioStream PengSound { get; set; }
[Export] public AudioStream GangSound { get; set; }
[Export] public AudioStream HuSound { get; set; }
[Export] public AudioStream ZiMoSound { get; set; }
[Export] public AudioStream LiujuSound { get; set; }
[Export] public AudioStream BGMusic { get; set; }
// ===== 播放器 =====
private AudioStreamPlayer _sfxPlayer;
private AudioStreamPlayer _musicPlayer;
// ===== 音量 =====
[Export] public float SFXVolume { get; set; } = 0.8f;
[Export] public float MusicVolume { get; set; } = 0.4f;
public override void _EnterTree()
{
if (Instance == null) Instance = this;
else QueueFree();
}
public override void _Ready()
{
_sfxPlayer = new AudioStreamPlayer();
_sfxPlayer.Bus = "SFX";
AddChild(_sfxPlayer);
_musicPlayer = new AudioStreamPlayer();
_musicPlayer.Bus = "Music";
AddChild(_musicPlayer);
}
// ===== 播放方法 =====
public void PlayShuffle() => PlaySFX(ShuffleSound);
public void PlayDraw() => PlaySFX(DrawSound);
public void PlayDiscard() => PlaySFX(DiscardSound);
public void PlayChi() => PlaySFX(ChiSound);
public void PlayPeng() => PlaySFX(PengSound);
public void PlayGang() => PlaySFX(GangSound);
public void PlayHu() => PlaySFX(HuSound);
public void PlayZiMo() => PlaySFX(ZiMoSound);
public void PlayLiuju() => PlaySFX(LiujuSound);
public void PlayMusic()
{
if (_musicPlayer != null && BGMusic != null)
{
_musicPlayer.Stream = BGMusic;
_musicPlayer.VolumeDb = Mathf.LinearToDb(MusicVolume);
_musicPlayer.Play();
}
}
public void StopMusic() => _musicPlayer?.Stop();
private void PlaySFX(AudioStream sound)
{
if (sound == null) return;
_sfxPlayer.Stream = sound;
_sfxPlayer.VolumeDb = Mathf.LinearToDb(SFXVolume);
_sfxPlayer.Play();
}
}GDScript
extends Node
static var instance: MahjongAudioManager
## 音效资源
@export var shuffle_sound: AudioStream
@export var draw_sound: AudioStream
@export var discard_sound: AudioStream
@export var chi_sound: AudioStream
@export var peng_sound: AudioStream
@export var gang_sound: AudioStream
@export var hu_sound: AudioStream
@export var zi_mo_sound: AudioStream
@export var liuju_sound: AudioStream
@export var bg_music: AudioStream
## 播放器
var _sfx_player: AudioStreamPlayer
var _music_player: AudioStreamPlayer
## 音量
@export var sfx_volume: float = 0.8
@export var music_volume: float = 0.4
func _enter_tree() -> void:
if instance == null:
instance = self
else:
queue_free()
func _ready() -> void:
_sfx_player = AudioStreamPlayer.new()
_sfx_player.bus = "SFX"
add_child(_sfx_player)
_music_player = AudioStreamPlayer.new()
_music_player.bus = "Music"
add_child(_music_player)
## 播放方法
func play_shuffle() -> void: _play_sfx(shuffle_sound)
func play_draw() -> void: _play_sfx(draw_sound)
func play_discard() -> void: _play_sfx(discard_sound)
func play_chi() -> void: _play_sfx(chi_sound)
func play_peng() -> void: _play_sfx(peng_sound)
func play_gang() -> void: _play_sfx(gang_sound)
func play_hu() -> void: _play_sfx(hu_sound)
func play_zi_mo() -> void: _play_sfx(zi_mo_sound)
func play_liuju() -> void: _play_sfx(liuju_sound)
func play_music() -> void:
if _music_player != null and bg_music != null:
_music_player.stream = bg_music
_music_player.volume_db = linear_to_db(music_volume)
_music_player.play()
func stop_music() -> void:
if _music_player != null:
_music_player.stop()
func _play_sfx(sound: AudioStream) -> void:
if sound == null:
return
_sfx_player.stream = sound
_sfx_player.volume_db = linear_to_db(sfx_volume)
_sfx_player.play()牌面动画
出牌动画
玩家打出一张牌时,牌从手牌位置飞到出牌区域。
C
/// <summary>
/// 播放出牌动画
/// 牌从手牌位置飞到出牌区域
/// </summary>
public async void AnimateDiscard(TileNode tileNode, Vector2 targetPosition)
{
// 获取牌的当前全局位置
Vector2 startPos = tileNode.GlobalPosition;
// 播放音效
MahjongAudioManager.Instance.PlayDiscard();
// 飞行动画
Tween tween = CreateTween();
tween.TweenProperty(tileNode, "global_position", targetPosition, 0.3f)
.SetTrans(Tween.TransitionType.Quad)
.SetEase(Tween.EaseType.Out);
// 稍微放大再缩小(弹性效果)
tileNode.Scale = Vector2.One * 1.1f;
tween.Parallel().TweenProperty(tileNode, "scale", Vector2.One, 0.3f);
await ToSignal(tween, Tween.SignalName.Finished);
}GDScript
## 播放出牌动画
## 牌从手牌位置飞到出牌区域
func animate_discard(tile_node, target_position: Vector2) -> void:
# 播放音效
MahjongAudioManager.instance.play_discard()
# 飞行动画
var tween := create_tween()
tween.tween_property(tile_node, "global_position", target_position, 0.3) \
.set_trans(Tween.TransitionType.QUAD) \
.set_ease(Tween.EaseType.OUT)
# 稍微放大再缩小(弹性效果)
tile_node.scale = Vector2.ONE * 1.1
tween.parallel().tween_property(tile_node, "scale", Vector2.ONE, 0.3)
await tween.finished碰牌动画
碰牌时,三张相同的牌从手中飞到副露区汇合。
C
/// <summary>
/// 播放碰牌动画
/// 三张相同的牌飞到副露区
/// </summary>
public async void AnimatePeng(int player, Tile tile, Vector2 meldPosition)
{
MahjongAudioManager.Instance.PlayPeng();
// 创建三张牌的动画节点
for (int i = 0; i < 3; i++)
{
var tempTile = HandDisplays[0].TileScene.Instantiate<TileNode>();
tempTile.Initialize(tile, true);
tempTile.GlobalPosition = new Vector2(
meldPosition.X - 30 + i * 30,
meldPosition.Y - 100 - i * 20 // 从上方不同位置飞入
);
AddChild(tempTile);
Tween tween = CreateTween();
tween.TweenProperty(tempTile, "global_position",
new Vector2(meldPosition.X + i * 50, meldPosition.Y), 0.3f)
.SetDelay(i * 0.1f) // 依次飞入
.SetTrans(Tween.TransitionType.Back)
.SetEase(Tween.EaseType.Out);
}
await ToSignal(GetTree().CreateTimer(0.6), SceneTree.TimerSignalName.Timeout);
}GDScript
## 播放碰牌动画
## 三张相同的牌飞到副露区
func animate_peng(player: int, tile: Tile, meld_position: Vector2) -> void:
MahjongAudioManager.instance.play_peng()
# 创建三张牌的动画节点
for i in range(3):
var temp_tile = hand_displays[0].tile_scene.instantiate()
temp_tile.initialize(tile, true)
temp_tile.global_position = Vector2(
meld_position.x - 30 + i * 30,
meld_position.y - 100 - i * 20
)
add_child(temp_tile)
var tween := create_tween()
tween.tween_property(temp_tile, "global_position",
Vector2(meld_position.x + i * 50, meld_position.y), 0.3) \
.set_delay(i * 0.1) \
.set_trans(Tween.TransitionType.BACK) \
.set_ease(Tween.EaseType.OUT)
await get_tree().create_timer(0.6).timeout胡牌特效
胡牌时需要最华丽的特效——粒子、闪光、屏幕震动。
C
using Godot;
/// <summary>
/// 胡牌特效控制器
/// </summary>
public partial class WinEffect : Control
{
[Export] public GPUParticles2D FireworkParticles { get; set; }
[Export] public ColorRect FlashOverlay { get; set; }
[Export] public Label WinTextLabel { get; set; }
/// <summary>
/// 播放胡牌特效
/// </summary>
public async void PlayWinEffect(string winText, bool isSelfDraw)
{
Visible = true;
// 显示胡牌文字
WinTextLabel.Text = winText;
WinTextLabel.Modulate = Colors.White;
WinTextLabel.Scale = Vector2.Zero;
// 播放音效
if (isSelfDraw)
{
MahjongAudioManager.Instance.PlayZiMo();
}
else
{
MahjongAudioManager.Instance.PlayHu();
}
// 文字缩放弹入
Tween textTween = CreateTween();
textTween.TweenProperty(WinTextLabel, "scale", Vector2.One * 1.5f, 0.4f)
.SetTrans(Tween.TransitionType.Back)
.SetEase(Tween.EaseType.Out);
textTween.TweenProperty(WinTextLabel, "scale", Vector2.One, 0.2f);
// 闪光效果
FlashOverlay.Color = new Color(1, 1, 0.8f, 0.5f);
var flashTween = CreateTween();
flashTween.TweenProperty(FlashOverlay, "color:a", 0f, 0.5f);
// 烟花粒子
if (FireworkParticles != null)
{
FireworkParticles.Emitting = true;
FireworkParticles.OneShot = false;
// 持续3秒
await ToSignal(GetTree().CreateTimer(3.0), SceneTree.TimerSignalName.Timeout);
FireworkParticles.Emitting = false;
}
// 渐隐
var fadeTween = CreateTween();
fadeTween.TweenProperty(WinTextLabel, "modulate:a", 0f, 1.0f);
await ToSignal(fadeTween, Tween.SignalName.Finished);
Visible = false;
}
}GDScript
extends Control
@export var firework_particles: GPUParticles2D
@export var flash_overlay: ColorRect
@export var win_text_label: Label
## 播放胡牌特效
func play_win_effect(win_text: String, is_self_draw: bool) -> void:
visible = true
# 显示胡牌文字
win_text_label.text = win_text
win_text_label.modulate = Color.WHITE
win_text_label.scale = Vector2.ZERO
# 播放音效
if is_self_draw:
MahjongAudioManager.instance.play_zi_mo()
else:
MahjongAudioManager.instance.play_hu()
# 文字缩放弹入
var text_tween := create_tween()
text_tween.tween_property(win_text_label, "scale", Vector2.ONE * 1.5, 0.4) \
.set_trans(Tween.TransitionType.BACK) \
.set_ease(Tween.EaseType.OUT)
text_tween.tween_property(win_text_label, "scale", Vector2.ONE, 0.2)
# 闪光效果
flash_overlay.color = Color(1, 1, 0.8, 0.5)
var flash_tween := create_tween()
flash_tween.tween_property(flash_overlay, "color:a", 0.0, 0.5)
# 烟花粒子
if firework_particles != null:
firework_particles.emitting = true
firework_particles.one_shot = false
# 持续3秒
await get_tree().create_timer(3.0).timeout
firework_particles.emitting = false
# 渐隐
var fade_tween := create_tween()
fade_tween.tween_property(win_text_label, "modulate:a", 0.0, 1.0)
await fade_tween.finished
visible = false屏幕震动
碰牌和胡牌时加入屏幕震动效果,增强打击感。
C
/// <summary>
/// 麻将屏幕震动
/// </summary>
public partial class MahjongScreenShake : Camera2D
{
private float _shakeStrength = 0;
private float _shakeTimer = 0;
public override void _Process(double delta)
{
if (_shakeTimer > 0)
{
_shakeTimer -= (float)delta;
float ox = (GD.Randf() * 2 - 1) * _shakeStrength;
float oy = (GD.Randf() * 2 - 1) * _shakeStrength;
Offset = new Vector2(ox, oy);
_shakeStrength *= 0.92f;
}
else
{
Offset = Vector2.Zero;
}
}
/// <summary>碰牌震动</summary>
public void PengShake() => Shake(4f, 0.15f);
/// <summary>杠牌震动</summary>
public void GangShake() => Shake(6f, 0.25f);
/// <summary>胡牌震动</summary>
public void WinShake() => Shake(8f, 0.4f);
private void Shake(float strength, float duration)
{
_shakeStrength = strength;
_shakeTimer = duration;
}
}GDScript
extends Camera2D
var _shake_strength: float = 0.0
var _shake_timer: float = 0.0
func _process(delta: float) -> void:
if _shake_timer > 0:
_shake_timer -= delta
var ox: float = (randf() * 2 - 1) * _shake_strength
var oy: float = (randf() * 2 - 1) * _shake_strength
offset = Vector2(ox, oy)
_shake_strength *= 0.92
else:
offset = Vector2.ZERO
## 碰牌震动
func peng_shake() -> void:
_do_shake(4.0, 0.15)
## 杠牌震动
func gang_shake() -> void:
_do_shake(6.0, 0.25)
## 胡牌震动
func win_shake() -> void:
_do_shake(8.0, 0.4)
func _do_shake(strength: float, duration: float) -> void:
_shake_strength = strength
_shake_timer = duration本章小结
| 完成项 | 说明 |
|---|---|
| 音效管理 | 单例管理所有麻将音效 |
| 洗牌/摸牌/出牌音效 | 基础操作音效 |
| 吃碰杠胡音效 | 语音 + 音效 |
| 出牌动画 | 牌飞到出牌区 |
| 碰牌动画 | 三张牌汇合 |
| 胡牌特效 | 烟花粒子 + 文字 + 闪光 |
| 屏幕震动 | 碰/杠/胡不同程度震动 |
最后一章,我们将完善计分规则、结算界面,并进行发布前的最终检查。
