3. 牌面渲染与2.5D效果
2026/4/13大约 13 分钟
牌面渲染与2.5D效果
上一章我们搭好了牌桌,但上面的牌还只是白色方块。本章我们要给这些方块"画上脸"——让它们变成真正的麻将牌,并且加上各种动画效果。
牌面3D模型详解
牌的组成
一张麻将牌虽然看起来简单,但仔细观察,它其实由几个部分组成:
┌──────────────────┐
│ 牌面(正面) │ ← 有花色图案
│ 万/条/筒/风/箭 │
├──────────────────┤
│ 牌背(背面) │ ← 统一的图案或颜色
│ 通常是绿色/蓝色 │
├──────────────────┤
│ 牌边(侧面) │ ← 牌的厚度
└──────────────────┘CSGBox3D vs MeshInstance3D
Godot 中创建3D模型主要有两种方式:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| CSGBox3D | 零代码创建、参数可调 | 不支持多面不同贴图 | 原型开发 |
| MeshInstance3D | 支持多材质、性能更好 | 需要导入模型文件 | 正式开发 |
建议
开发初期用 CSGBox3D 快速验证效果,后面再换成 MeshInstance3D。就像画画先用铅笔打草稿,确认构图后再上色。
用 MeshInstance3D 实现多面贴图
CSGBox3D 的一个限制是:它的所有面只能用同一种材质。但我们的牌需要正面和背面用不同的贴图。解决方案是用 MeshInstance3D 配合 ArrayMesh:
C
using Godot;
/// <summary>
/// 牌面3D模型——支持正面/背面不同贴图
/// </summary>
public partial class TileMesh : MeshInstance3D
{
/// <summary>
/// 牌的宽度
/// </summary>
private const float TileWidth = 0.7f;
/// <summary>
/// 牌的高度(厚度方向)
/// </summary>
private const float TileHeight = 0.05f;
/// <summary>
/// 牌的长度
/// </summary>
private const float TileDepth = 1.0f;
/// <summary>
/// 正面材质
/// </summary>
private StandardMaterial3D _faceMaterial;
/// <summary>
/// 背面材质
/// </summary>
private StandardMaterial3D _backMaterial;
/// <summary>
/// 侧面材质
/// </summary>
private StandardMaterial3D _sideMaterial;
public override void _Ready()
{
CreateMaterials();
CreateMesh();
}
/// <summary>
/// 创建材质
/// </summary>
private void CreateMaterials()
{
// 正面材质——白色底,后续会贴上图案
_faceMaterial = new StandardMaterial3D
{
AlbedoColor = new Color(1, 1, 1), // 白色底
Roughness = 0.3f, // 较光滑
Metallic = 0.05f // 轻微金属感
};
// 背面材质——深蓝色
_backMaterial = new StandardMaterial3D
{
AlbedoColor = new Color(0.1f, 0.2f, 0.4f), // 深蓝
Roughness = 0.5f,
Metallic = 0.1f
};
// 侧面材质——淡黄色(模拟真实牌的边缘)
_sideMaterial = new StandardMaterial3D
{
AlbedoColor = new Color(0.95f, 0.93f, 0.85f), // 象牙白
Roughness = 0.4f,
Metallic = 0.05f
};
}
/// <summary>
/// 创建带有不同面材质的网格
/// </summary>
private void CreateMesh()
{
// 使用 BoxMesh 作为基础
var boxMesh = new BoxMesh
{
Size = new Vector3(TileWidth, TileHeight, TileDepth)
};
// 为每个面设置不同的材质
var surfaceArray = boxMesh.SurfaceGetArrays(0);
var materialArray = new Godot.Collections.Array();
// BoxMesh 有6个面:右、左、上、下、前、后
// 面0: 右面(侧面)
// 面1: 左面(侧面)
// 面2: 上面(正面朝上时可见)
// 面3: 下面(贴桌面)
// 面4: 前面(正面)
// 面5: 后面(背面)
materialArray.Add(_sideMaterial); // 右
materialArray.Add(_sideMaterial); // 左
materialArray.Add(_sideMaterial); // 上
materialArray.Add(_sideMaterial); // 下
materialArray.Add(_faceMaterial); // 前(正面)
materialArray.Add(_backMaterial); // 后(背面)
Mesh = boxMesh;
Mesh.SurfaceSetMaterial(0, _faceMaterial);
}
/// <summary>
/// 设置正面贴图
/// </summary>
public void SetFaceTexture(Texture2D texture)
{
_faceMaterial.AlbedoTexture = texture;
}
/// <summary>
/// 翻转到背面
/// </summary>
public void FlipToBack()
{
var tween = CreateTween();
tween.TweenProperty(this, "rotation:y", Mathf.Pi, 0.3f);
}
/// <summary>
/// 翻转到正面
/// </summary>
public void FlipToFace()
{
var tween = CreateTween();
tween.TweenProperty(this, "rotation:y", 0, 0.3f);
}
}GDScript
extends MeshInstance3D
## 牌的宽度
const TILE_WIDTH := 0.7
## 牌的高度(厚度方向)
const TILE_HEIGHT := 0.05
## 牌的长度
const TILE_DEPTH := 1.0
## 正面材质
var _face_material: StandardMaterial3D
## 背面材质
var _back_material: StandardMaterial3D
## 侧面材质
var _side_material: StandardMaterial3D
func _ready() -> void:
create_materials()
create_mesh()
## 创建材质
func create_materials() -> void:
# 正面材质——白色底
_face_material = StandardMaterial3D.new()
_face_material.albedo_color = Color(1, 1, 1)
_face_material.roughness = 0.3
_face_material.metallic = 0.05
# 背面材质——深蓝色
_back_material = StandardMaterial3D.new()
_back_material.albedo_color = Color(0.1, 0.2, 0.4)
_back_material.roughness = 0.5
_back_material.metallic = 0.1
# 侧面材质——象牙白
_side_material = StandardMaterial3D.new()
_side_material.albedo_color = Color(0.95, 0.93, 0.85)
_side_material.roughness = 0.4
_side_material.metallic = 0.05
## 创建网格
func create_mesh() -> void:
var box_mesh := BoxMesh.new()
box_mesh.size = Vector3(TILE_WIDTH, TILE_HEIGHT, TILE_DEPTH)
mesh = box_mesh
# 设置材质
mesh.surface_set_material(0, _face_material)
## 设置正面贴图
func set_face_texture(texture: Texture2D) -> void:
_face_material.albedo_texture = texture
## 翻转到背面
func flip_to_back() -> void:
var tween = create_tween()
tween.tween_property(self, "rotation:y", PI, 0.3)
## 翻转到正面
func flip_to_face() -> void:
var tween = create_tween()
tween.tween_property(self, "rotation:y", 0, 0.3)牌面贴图生成
为什么用代码生成贴图?
真实的麻将牌有136种不同的牌面,如果每种都手动画一张图片,工作量巨大。更好的方法是用代码程序化生成——就像用"印章"一样,一个印章模板加上不同的数字/图案,就能印出所有的牌。
生活化比喻
程序化生成贴图就像自动售货机:你投入不同的"配方"(花色+数字),它就自动"生产"出对应的牌面图片。你不需要为每种牌都准备一张手绘图片。
CanvasTexture 方案
Godot 提供了 Image 类,可以用代码在内存中"画"出图片,然后包装成 ImageTexture 给材质使用:
C
using Godot;
/// <summary>
/// 牌面贴图生成器——用代码画出所有牌面
/// </summary>
public partial class TileTextureGenerator : Node
{
/// <summary>
/// 牌面图片尺寸
/// </summary>
private const int TextureSize = 128;
/// <summary>
/// 生成麻将牌面贴图
/// </summary>
/// <param name="tileType">牌的类型</param>
/// <param name="value">牌的数值</param>
/// <returns>生成的贴图</returns>
public ImageTexture GenerateTileTexture(int tileType, int value)
{
// 创建空白图片
var image = Image.CreateEmpty(TextureSize, TextureSize, false, Image.Format.Rgba8);
// 填充白色背景
image.Fill(Colors.White);
// 根据牌的类型画不同的图案
switch (tileType)
{
case 1: // 万子
DrawWanPattern(image, value);
break;
case 2: // 条子
DrawTiaoPattern(image, value);
break;
case 3: // 筒子
DrawTongPattern(image, value);
break;
case 4: // 风牌
DrawFengPattern(image, value);
break;
case 5: // 箭牌
DrawJianPattern(image, value);
break;
}
// 画边框
DrawBorder(image);
// 转换为 Godot 可用的贴图
var texture = ImageTexture.CreateFromImage(image);
return texture;
}
/// <summary>
/// 画万子图案——数字 + "万"字
/// </summary>
private void DrawWanPattern(Image image, int value)
{
// 在这里我们简化处理:画出数字和"万"字
// 实际项目中可以用更精细的绘制逻辑
// 万子用红色
var color = new Color(0.9f, 0.1f, 0.1f); // 红色
// 画数字(简化:用小方块代表)
DrawDigitPattern(image, value, color);
// 画"万"字的位置标记
DrawCharacterMarker(image, "万", color);
}
/// <summary>
/// 画条子图案——竹节形状
/// </summary>
private void DrawTiaoPattern(Image image, int value)
{
var color = new Color(0.1f, 0.5f, 0.1f); // 绿色
DrawDigitPattern(image, value, color);
DrawCharacterMarker(image, "条", color);
}
/// <summary>
/// 画筒子图案——圆形
/// </summary>
private void DrawTongPattern(Image image, int value)
{
var color = new Color(0.1f, 0.1f, 0.8f); // 蓝色
DrawDigitPattern(image, value, color);
DrawCharacterMarker(image, "筒", color);
}
/// <summary>
/// 画风牌图案
/// </summary>
private void DrawFengPattern(Image image, int value)
{
var colors = new[] { "东", "南", "西", "北" };
var color = new Color(0.2f, 0.2f, 0.2f); // 黑色
DrawCharacterMarker(image, colors[value - 1], color);
}
/// <summary>
/// 画箭牌图案
/// </summary>
private void DrawJianPattern(Image image, int value)
{
var color = value == 1
? new Color(0.9f, 0.1f, 0.1f) // 中:红色
: new Color(0.1f, 0.5f, 0.1f); // 发/白:绿色
var chars = new[] { "中", "发", "白" };
DrawCharacterMarker(image, chars[value - 1], color);
}
/// <summary>
/// 画数字图案(简化版)
/// </summary>
private void DrawDigitPattern(Image image, int value, Color color)
{
// 简化:用小圆点代表数字
int centerX = TextureSize / 2;
int centerY = TextureSize / 3;
for (int i = 0; i < value; i++)
{
int x = centerX + (i - value / 2) * 12;
DrawCircle(image, x, centerY, 4, color);
}
}
/// <summary>
/// 画字符标记
/// </summary>
private void DrawCharacterMarker(Image image, string text, Color color)
{
// 简化:在底部画一个小矩形代表文字
int rectWidth = TextureSize / 2;
int rectHeight = TextureSize / 4;
int x = (TextureSize - rectWidth) / 2;
int y = TextureSize * 2 / 3;
DrawRect(image, x, y, rectWidth, rectHeight, color);
}
/// <summary>
/// 画圆
/// </summary>
private void DrawCircle(Image image, int cx, int cy, int radius, Color color)
{
for (int y = -radius; y <= radius; y++)
{
for (int x = -radius; x <= radius; x++)
{
if (x * x + y * y <= radius * radius)
{
image.SetPixel(cx + x, cy + y, color);
}
}
}
}
/// <summary>
/// 画矩形
/// </summary>
private void DrawRect(Image image, int x, int y, int w, int h, Color color)
{
for (int iy = y; iy < y + h; iy++)
{
for (int ix = x; ix < x + w; ix++)
{
image.SetPixel(ix, iy, color);
}
}
}
/// <summary>
/// 画边框
/// </summary>
private void DrawBorder(Image image)
{
var borderColor = new Color(0.7f, 0.7f, 0.7f);
int border = 4;
// 上边框
DrawRect(image, 0, 0, TextureSize, border, borderColor);
// 下边框
DrawRect(image, 0, TextureSize - border, TextureSize, border, borderColor);
// 左边框
DrawRect(image, 0, 0, border, TextureSize, borderColor);
// 右边框
DrawRect(image, TextureSize - border, 0, border, TextureSize, borderColor);
}
}GDScript
extends Node
## 牌面图片尺寸
const TEXTURE_SIZE := 128
## 生成麻将牌面贴图
func generate_tile_texture(tile_type: int, value: int) -> ImageTexture:
# 创建空白图片
var image := Image.create_empty(TEXTURE_SIZE, TEXTURE_SIZE, false, Image.FORMAT_RGBA8)
# 填充白色背景
image.fill(Color.WHITE)
# 根据牌的类型画不同的图案
match tile_type:
1: # 万子
draw_wan_pattern(image, value)
2: # 条子
draw_tiao_pattern(image, value)
3: # 筒子
draw_tong_pattern(image, value)
4: # 风牌
draw_feng_pattern(image, value)
5: # 箭牌
draw_jian_pattern(image, value)
# 画边框
draw_border(image)
# 转换为 Godot 可用的贴图
return ImageTexture.create_from_image(image)
## 画万子图案
func draw_wan_pattern(image: Image, value: int) -> void:
var color := Color(0.9, 0.1, 0.1) # 红色
draw_digit_pattern(image, value, color)
draw_character_marker(image, "万", color)
## 画条子图案
func draw_tiao_pattern(image: Image, value: int) -> void:
var color := Color(0.1, 0.5, 0.1) # 绿色
draw_digit_pattern(image, value, color)
draw_character_marker(image, "条", color)
## 画筒子图案
func draw_tong_pattern(image: Image, value: int) -> void:
var color := Color(0.1, 0.1, 0.8) # 蓝色
draw_digit_pattern(image, value, color)
draw_character_marker(image, "筒", color)
## 画风牌图案
func draw_feng_pattern(image: Image, value: int) -> void:
var names = ["东", "南", "西", "北"]
var color := Color(0.2, 0.2, 0.2)
draw_character_marker(image, names[value - 1], color)
## 画箭牌图案
func draw_jian_pattern(image: Image, value: int) -> void:
var color := Color(0.9, 0.1, 0.1) if value == 1 else Color(0.1, 0.5, 0.1)
var names = ["中", "发", "白"]
draw_character_marker(image, names[value - 1], color)
## 画数字图案
func draw_digit_pattern(image: Image, value: int, color: Color) -> void:
var center_x := TEXTURE_SIZE / 2
var center_y := TEXTURE_SIZE / 3
for i in range(value):
var x := center_x + (i - value / 2) * 12
draw_circle(image, x, center_y, 4, color)
## 画圆
func draw_circle(image: Image, cx: int, cy: int, radius: int, color: Color) -> void:
for y in range(-radius, radius + 1):
for x in range(-radius, radius + 1):
if x * x + y * y <= radius * radius:
image.set_pixel(cx + x, cy + y, color)
## 画矩形
func draw_rect(image: Image, x: int, y: int, w: int, h: int, color: Color) -> void:
for iy in range(y, y + h):
for ix in range(x, x + w):
image.set_pixel(ix, iy, color)
## 画边框
func draw_border(image: Image) -> void:
var border_color := Color(0.7, 0.7, 0.7)
var border := 4
draw_rect(image, 0, 0, TEXTURE_SIZE, border, border_color)
draw_rect(image, 0, TEXTURE_SIZE - border, TEXTURE_SIZE, border, border_color)
draw_rect(image, 0, 0, border, TEXTURE_SIZE, border_color)
draw_rect(image, TEXTURE_SIZE - border, 0, border, TEXTURE_SIZE, border_color)牌的排列方式
手牌扇形排列
真实打麻将时,手牌会呈扇形排列。我们可以用数学公式计算每张牌的位置和旋转角度:
玩家
╱ ╱ ╱ ╱
╱ ╱ ╱ ╱
╱ ╱ ╱ ╱
────────────
牌 桌每张牌的位置用极坐标计算:
| 参数 | 说明 |
|---|---|
| 中心点 | 扇形的圆心位置 |
| 半径 | 扇形的半径 |
| 角度范围 | 扇形展开的角度 |
| 每张牌的角度 | 总角度 / 牌数 |
扇形排列代码
C
using Godot;
/// <summary>
/// 手牌排列器——将手牌排列成扇形
/// </summary>
public partial class HandLayout : Node3D
{
/// <summary>
/// 扇形半径
/// </summary>
[Export] public float ArcRadius = 6.0f;
/// <summary>
/// 扇形角度范围(度)
/// </summary>
[Export] public float ArcAngle = 40.0f;
/// <summary>
/// 排列手牌
/// </summary>
/// <param name="tiles">要排列的牌节点列表</param>
public void ArrangeTiles(Godot.Collections.Array<Node3D> tiles)
{
int count = tiles.Count;
if (count == 0) return;
float startAngle = -ArcAngle / 2;
float angleStep = count > 1 ? ArcAngle / (count - 1) : 0;
for (int i = 0; i < count; i++)
{
float angle = Mathf.DegToRad(startAngle + i * angleStep);
// 计算位置
float x = Mathf.Sin(angle) * ArcRadius;
float y = 0.125f; // 牌桌表面上方
float z = Mathf.Cos(angle) * ArcRadius - ArcRadius + 3.5f;
var tile = tiles[i];
tile.Position = new Vector3(x, y, z);
// 计算旋转——牌面朝向圆心
float rotation = -Mathf.RadToDeg(angle);
tile.Rotation = new Vector3(0, rotation, 0);
// 设置层级——中间的牌在上面
int distance = Mathf.Abs(i - count / 2);
tile.ZIndex = count - distance;
}
}
}GDScript
extends Node3D
## 扇形半径
@export var arc_radius: float = 6.0
## 扇形角度范围(度)
@export var arc_angle: float = 40.0
## 排列手牌
func arrange_tiles(tiles: Array[Node3D]) -> void:
var count := tiles.size()
if count == 0:
return
var start_angle := -arc_angle / 2.0
var angle_step := arc_angle / (count - 1) if count > 1 else 0.0
for i in range(count):
var angle := deg_to_rad(start_angle + i * angle_step)
# 计算位置
var x := sin(angle) * arc_radius
var y := 0.125
var z := cos(angle) * arc_radius - arc_radius + 3.5
var tile = tiles[i]
tile.position = Vector3(x, y, z)
# 计算旋转
var rotation := -rad_to_deg(angle)
tile.rotation = Vector3(0, rotation, 0)
# 设置层级
var distance := abs(i - count / 2)
tile.z_index = count - distance牌的动画效果
动画类型一览
| 动画 | 触发时机 | 效果描述 |
|---|---|---|
| 摸牌 | 从牌堆摸一张牌 | 牌从牌墙滑入手牌位置 |
| 出牌 | 打出一张手牌 | 牌从手牌飞向弃牌堆 |
| 碰牌 | 其他玩家碰牌 | 三张牌从手牌飞到碰牌区 |
| 杠牌 | 杠操作 | 四张牌从手牌飞到杠牌区 |
| 翻牌 | 游戏结束/对手出牌 | 牌从背面翻转到正面 |
| 选中 | 玩家点击手牌 | 牌向上弹起一点 |
用 Tween 实现动画
Godot 的 Tween 系统非常适合做牌面动画。Tween 就像"动画配方"——你告诉它"从A状态到B状态,用多长时间",它会自动帮你完成中间的过渡:
C
using Godot;
/// <summary>
/// 牌面动画控制器
/// </summary>
public partial class TileAnimator : Node
{
/// <summary>
/// 动画持续时间(秒)
/// </summary>
private const float DefaultDuration = 0.3f;
/// <summary>
/// 摸牌动画——牌从牌堆滑入手牌位置
/// </summary>
/// <param name="tile">要动画的牌</param>
/// <param name="fromPos">起始位置</param>
/// <param name="toPos">目标位置</param>
/// <param name="onComplete">动画完成回调</param>
public void AnimateDrawTile(Node3D tile, Vector3 fromPos, Vector3 toPos,
System.Action onComplete = null)
{
// 设置起始位置
tile.Position = fromPos;
tile.Rotation = new Vector3(0, 0, 0);
// 创建动画
var tween = CreateTween();
// 牌先升高一点(像被拿起来一样)
tween.TweenProperty(tile, "position:y", fromPos.Y + 0.5f, DefaultDuration * 0.3f)
.SetTrans(Tween.TransitionType.Sine)
.SetEase(Tween.EaseType.Out);
// 然后滑向目标位置
tween.TweenProperty(tile, "position", toPos, DefaultDuration * 0.7f)
.SetTrans(Tween.TransitionType.Quad)
.SetEase(Tween.EaseType.InOut);
// 动画完成回调
if (onComplete != null)
{
tween.TweenCallback(Callable.From(onComplete));
}
}
/// <summary>
/// 出牌动画——牌从手牌飞向弃牌堆
/// </summary>
public void AnimateDiscardTile(Node3D tile, Vector3 fromPos, Vector3 toPos,
System.Action onComplete = null)
{
tile.Position = fromPos;
var tween = CreateTween();
// 牌先向前(向桌面中心方向)飞出
tween.TweenProperty(tile, "position", new Vector3(toPos.X, 0.5f, toPos.Z),
DefaultDuration * 0.5f)
.SetTrans(Tween.TransitionType.Cubic)
.SetEase(Tween.EaseType.Out);
// 然后落到弃牌堆位置
tween.TweenProperty(tile, "position", toPos, DefaultDuration * 0.5f)
.SetTrans(Tween.TransitionType.Bounce)
.SetEase(Tween.EaseType.Out);
if (onComplete != null)
{
tween.TweenCallback(Callable.From(onComplete));
}
}
/// <summary>
/// 选中动画——牌向上弹起
/// </summary>
public void AnimateSelect(Node3D tile, bool selected)
{
float targetY = selected ? 0.3f : 0.125f;
var tween = CreateTween();
tween.TweenProperty(tile, "position:y", targetY, 0.15f)
.SetTrans(Tween.TransitionType.Spring)
.SetEase(Tween.EaseType.Out);
}
/// <summary>
/// 翻牌动画——从背面翻到正面
/// </summary>
public void AnimateFlip(Node3D tile, System.Action onComplete = null)
{
var tween = CreateTween();
tween.TweenProperty(tile, "rotation:y", Mathf.Pi, 0.15f);
tween.TweenProperty(tile, "rotation:y", 0, 0.15f);
if (onComplete != null)
{
tween.TweenCallback(Callable.From(onComplete));
}
}
/// <summary>
/// 碰牌动画——多张牌飞到碰牌区
/// </summary>
public void AnimatePong(Godot.Collections.Array<Node3D> tiles, Vector3 targetPos,
System.Action onComplete = null)
{
if (tiles.Count == 0) return;
var tween = CreateTween();
foreach (var tile in tiles)
{
// 每张牌稍有延迟,产生"叠放"效果
float delay = tiles.IndexOf(tile) * 0.05f;
tween.TweenCallback(Callable.From(() =>
{
var t = CreateTween();
t.TweenProperty(tile, "position", targetPos, DefaultDuration)
.SetTrans(Tween.TransitionType.Back)
.SetEase(Tween.EaseType.Out);
})).SetDelay(delay);
}
if (onComplete != null)
{
tween.TweenCallback(Callable.From(onComplete)).SetDelay(tiles.Count * 0.05f + DefaultDuration);
}
}
}GDScript
extends Node
## 动画持续时间(秒)
const DEFAULT_DURATION := 0.3
## 摸牌动画
func animate_draw_tile(tile: Node3D, from_pos: Vector3, to_pos: Vector3,
on_complete: Callable = Callable()) -> void:
tile.position = from_pos
tile.rotation = Vector3.ZERO
var tween = create_tween()
# 牌先升高一点
tween.tween_property(tile, "position:y", from_pos.y + 0.5, DEFAULT_DURATION * 0.3)\
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
# 滑向目标位置
tween.tween_property(tile, "position", to_pos, DEFAULT_DURATION * 0.7)\
.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
if on_complete.is_valid():
tween.tween_callback(on_complete)
## 出牌动画
func animate_discard_tile(tile: Node3D, from_pos: Vector3, to_pos: Vector3,
on_complete: Callable = Callable()) -> void:
tile.position = from_pos
var tween = create_tween()
# 向前飞出
tween.tween_property(tile, "position", Vector3(to_pos.x, 0.5, to_pos.z),
DEFAULT_DURATION * 0.5)\
.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
# 落到弃牌堆
tween.tween_property(tile, "position", to_pos, DEFAULT_DURATION * 0.5)\
.set_trans(Tween.TRANS_BOUNCE).set_ease(Tween.EASE_OUT)
if on_complete.is_valid():
tween.tween_callback(on_complete)
## 选中动画
func animate_select(tile: Node3D, selected: bool) -> void:
var target_y := 0.3 if selected else 0.125
var tween = create_tween()
tween.tween_property(tile, "position:y", target_y, 0.15)\
.set_trans(Tween.TRANS_SPRING).set_ease(Tween.EASE_OUT)
## 翻牌动画
func animate_flip(tile: Node3D, on_complete: Callable = Callable()) -> void:
var tween = create_tween()
tween.tween_property(tile, "rotation:y", PI, 0.15)
tween.tween_property(tile, "rotation:y", 0, 0.15)
if on_complete.is_valid():
tween.tween_callback(on_complete)Tween 缓动函数速查
动画的"手感"很大程度取决于缓动函数的选择:
| 缓动函数 | 效果 | 适用场景 |
|---|---|---|
| Linear | 匀速运动 | 匀速滑动 |
| Sine | 平滑的加减速 | 一般移动 |
| Quad | 快速加速/减速 | 弹出效果 |
| Cubic | 更强的加速/减速 | 飞出效果 |
| Back | 超出目标后回弹 | 碰牌叠放 |
| Bounce | 弹跳效果 | 落到桌面 |
| Spring | 弹簧效果 | 选中弹起 |
| Elastic | 橡皮筋效果 | 夸张动画 |
动画设计原则
- 摸牌动画要"轻柔"——用 Sine 或 Quad 缓动
- 出牌动画要"果断"——用 Cubic 缓动
- 落牌动画要"有弹性"——用 Bounce 缓动
- 选中动画要"有反馈感"——用 Spring 缓动
- 所有动画时间控制在 0.15~0.5 秒之间,太快看不清,太慢拖沓
小结
本章我们实现了:
- 牌面3D模型——用 MeshInstance3D 实现正面/背面不同贴图
- 牌面贴图生成——用代码程序化生成所有136种牌面
- 手牌扇形排列——用极坐标计算实现自然的扇形布局
- 牌面动画——用 Tween 实现摸牌、出牌、碰牌、翻牌等动画效果
提示
牌面渲染是视觉层面最核心的部分。做好了这一步,你的麻将游戏就已经"有模有样"了。接下来我们将实现游戏的核心——麻将规则引擎。
