2. 项目搭建与牌面渲染基础
项目搭建与牌面渲染基础
创建 Godot 4 项目
万事开头难,但搭建 Godot 项目其实很简单。就像搭积木之前先铺好底板——底板铺好了,后面的积木才能一块一块往上摞。
新建项目步骤
- 打开 Godot 4 编辑器
- 点击 新建项目(New Project)
- 项目名称填写
Mahjong2.5D - 选择一个你方便找到的文件夹
- 渲染器选择 Forward+(默认选项,性能最好)
- 点击 创建并编辑(Create & Edit)
渲染器选择
Godot 4 提供三种渲染器:Forward+(默认,适合大多数场景)、Mobile(移动端优化)、Compatibility(兼容模式)。我们做的是桌面端棋牌游戏,直接用默认的 Forward+ 就好。
项目设置
创建项目后,我们需要做一些基础设置。点击菜单栏的 项目 → 项目设置(Project → Project Settings):
| 设置项 | 值 | 说明 |
|---|---|---|
| 窗口 → 宽度 | 1280 | 游戏窗口宽度 |
| 窗口 → 高度 | 720 | 游戏窗口高度 |
| 窗口 → 模式 | 窗口化 | 开发阶段方便调试 |
| 渲染 → 抗锯齿 | MSAA 2x | 让牌面边缘更平滑 |
| 输入 → 鼠标 → 默认光标 | 隐藏 | 棋牌游戏不需要系统光标 |
| 通用 → 运行 → 主场景 | res://scenes/Game.tscn | 游戏入口场景 |
不要忘记保存
每次修改项目设置后,记得点击右下角的 保存 按钮。设置不会自动保存,关闭对话框后修改会丢失。
俯视角正交摄像机
为什么用正交摄像机?
摄像机有两种模式:透视(Perspective)和正交(Orthographic)。
| 模式 | 效果 | 类比 |
|---|---|---|
| 透视 | 近大远小,像人眼看世界 | 用眼睛看现实世界 |
| 正交 | 没有近大远小,像看地图 | 用尺子量图纸 |
生活化比喻
想象你站在一座高楼顶上往下看地面——近处的楼房和远处的楼房看起来大小差不多,这就是"正交"视角。如果用"透视"视角,近处的楼房会显得特别大,远处的楼房会变得很小,这在棋牌游戏中会让牌面变形,不方便看。
做棋牌游戏我们一定要用正交摄像机,因为:
- 牌面大小不会因为位置不同而变化
- 玩家看到的牌面始终是"正面朝上"的样子
- 方便布局——每张牌占多少像素是固定的
创建摄像机
在场景中添加摄像机的方法:
- 在场景树(Scene dock)中点击根节点
- 点击顶部的 "+" 按钮
- 搜索并选择 Camera3D
- 在右侧的 Inspector 面板中修改属性:
| 属性 | 值 | 说明 |
|---|---|---|
| Projection | Orthogonal | 改为正交投影 |
| Size | 15 | 视口大小(单位:米) |
| Position | (0, 20, 0) | 摄像机在牌桌正上方 |
| Rotation | (-90, 0, 0) | 朝下看 |
| Far | 50 | 最远能看到50米 |
| Near | 0.05 | 最近能看到0.05米 |
相关信息
Size 的值决定了视口能覆盖多大范围。值越大,能看到的范围越广,但牌面会显得更小。15是一个比较合适的起始值,后续可以根据实际效果微调。
场景结构设计
整体场景树
一个清晰合理的场景结构,就像整理好的书架——每样东西都有自己的位置,找起来很方便。
Game (Node3D) ← 游戏根节点
├── Camera3D ← 正交摄像机
├── DirectionalLight3D ← 主光源(模拟天花板灯光)
├── WorldEnvironment ← 环境光设置
├── Table (CSGBox3D) ← 牌桌(绿色桌面)
├── TileContainer (Node3D) ← 所有牌的容器
│ ├── HandTiles (Node3D) ← 玩家手牌区域
│ ├── DiscardPile (Node3D) ← 弃牌堆
│ ├── MeldArea (Node3D) ← 吃碰杠区域
│ └── WallTiles (Node3D) ← 牌墙(未摸的牌)
├── Opponents (Node3D) ← 对手区域
│ ├── OpponentLeft (Node3D) ← 左边对手
│ ├── OpponentRight (Node3D) ← 右边对手
│ └── OpponentTop (Node3D) ← 对面对手
└── UILayer (CanvasLayer) ← UI层(2D界面)
├── ScorePanel (Panel) ← 计分板
├── RemainCount (Label) ← 剩余牌数
├── ActionButtons (HBoxContainer) ← 操作按钮
└── TurnIndicator (Label) ← 回合指示为什么这样组织?
| 组织原则 | 说明 |
|---|---|
| 功能分组 | 把3D场景和2D UI分开,互不干扰 |
| 层级清晰 | 牌、桌子、UI各放各的文件夹(节点) |
| 方便查找 | 想改手牌就找 HandTiles,想改UI就找 UILayer |
| 便于扩展 | 后续加新功能只需要在对应节点下添加子节点 |
场景组织原则
好的场景结构就像好的文件夹管理——按功能分组、命名清晰、层级不超过4层。如果某个节点下面超过7个子节点,说明需要进一步分组。
牌桌搭建
用 CSG 制作牌桌
Godot 的 CSG(Constructive Solid Geometry,构造实体几何)工具可以让我们不导入任何模型文件,直接用代码创建3D形状。这就像用乐高积木搭桌子——简单直观。
- 添加 CSGBox3D 节点作为桌面
- 在 Inspector 中设置属性:
| 属性 | 值 | 说明 |
|---|---|---|
| Size | (8, 0.2, 6) | 桌面大小:8米×6米,厚0.2米 |
| Position | (0, 0, 0) | 放在场景中心 |
- 再添加一个 CSGBox3D 作为桌面边框:
| 属性 | 值 | 说明 |
|---|---|---|
| Size | (8.4, 0.4, 6.4) | 比桌面大一圈 |
| Position | (0, -0.1, 0) | 稍微低于桌面 |
相关信息
CSGBox3D 的 Size 是指"长×高×宽"。Size 为 (8, 0.2, 6) 表示长8米、高0.2米、宽6米——一个薄薄的大平板,正好模拟桌面。
桌面材质
给桌面加上材质,让它看起来像真实的麻将桌:
- 在 Inspector 中找到 Material 属性
- 点击
<空>→ 新建 StandardMaterial3D - 设置材质属性:
| 属性 | 值 | 说明 |
|---|---|---|
| Albedo Color | #2d7d46(深绿色) | 经典的麻将桌绿色 |
| Metallic | 0.1 | 轻微的金属光泽 |
| Roughness | 0.7 | 有一定粗糙度,像绒布 |
边框材质可以用木质色:
| 属性 | 值 | 说明 |
|---|---|---|
| Albedo Color | #8B4513(棕色) | 木质边框 |
| Roughness | 0.5 | 木质表面 |
光照设置
主光源
添加一个 DirectionalLight3D 节点,模拟天花板上的灯照射桌面:
| 属性 | 值 | 说明 |
|---|---|---|
| Position | (2, 10, 2) | 灯光位置(偏移一点更自然) |
| Rotation | (-45, 30, 0) | 灯光照射角度 |
| Light Color | #FFF8E7(暖白色) | 温暖的灯光 |
| Shadow Enabled | 勾选 | 开启阴影 |
| Shadow Opacity | 0.5 | 阴影半透明 |
环境光
添加 WorldEnvironment 节点来设置全局环境光:
- 添加 WorldEnvironment 节点
- 在 Inspector 中新建 Environment 资源
- 设置属性:
| 属性 | 值 | 说明 |
|---|---|---|
| Background Mode | Clear Color | 纯色背景 |
| Background Color | #1a1a2e(深蓝色) | 深色背景 |
| Ambient Light Source | Color | 环境光来源 |
| Ambient Light Color | #404060(浅蓝灰) | 柔和的环境光 |
| Ambient Light Energy | 0.3 | 环境光强度(不要太亮) |
光照小技巧
棋牌游戏的光照不要太亮也不要太暗。太亮会让牌面反光看不清,太暗会让眼睛疲劳。主光源用暖白色模拟室内灯光,环境光用冷色增加层次感。
牌面3D模型方案
用 CSGBox3D 制作立体牌面
一张麻将牌就是一个薄薄的长方体。用 CSGBox3D 来做最简单:
| 属性 | 值 | 说明 |
|---|---|---|
| Size | (0.7, 0.05, 1.0) | 牌面:宽0.7、厚0.05、高1.0 |
| Position | (0, 0.125, 0) | 放在桌面上方 |
相关信息
0.05 米 = 5 厘米,大约就是真实麻将牌的厚度。0.7 × 1.0 米的比例接近真实麻将牌的长宽比。
将牌面做成可复用场景
与其每次都手动创建牌面,不如做成一个"模板"——就像饼干模具一样,需要多少块牌就用模具压多少块。
- 创建一个新场景,根节点选择 Node3D,命名为
Tile3D - 添加子节点 CSGBox3D,命名为
Body - 设置 Body 的 Size 为 (0.7, 0.05, 1.0)
- 保存为
res://scenes/tiles/Tile3D.tscn
这样,每次需要一张新牌的时候,只需要用代码实例化这个场景:
using Godot;
public partial class TileSpawner : Node3D
{
/// <summary>
/// 牌面场景的预加载路径
/// </summary>
private PackedScene _tileScene;
public override void _Ready()
{
// 加载牌面场景模板
_tileScene = GD.Load<PackedScene>("res://scenes/tiles/Tile3D.tscn");
}
/// <summary>
/// 在指定位置生成一张牌
/// </summary>
/// <param name="position">牌的位置</param>
/// <param name="faceUp">是否正面朝上</param>
public Node3D SpawnTile(Vector3 position, bool faceUp = true)
{
// 实例化牌面场景
var tile = _tileScene.Instantiate<Node3D>();
// 设置位置
tile.Position = position;
// 如果背面朝上,翻转180度
if (!faceUp)
{
tile.Rotation = new Vector3(0, Mathf.Pi, 0);
}
// 添加到场景中
AddChild(tile);
return tile;
}
/// <summary>
/// 批量生成一排手牌
/// </summary>
public void SpawnHandTiles(int count)
{
float startX = -(count - 1) * 0.4f; // 起始X坐标
float spacing = 0.8f; // 牌间距
for (int i = 0; i < count; i++)
{
var pos = new Vector3(startX + i * spacing, 0.125f, 3f);
SpawnTile(pos, faceUp: true);
}
}
}extends Node3D
## 牌面场景的预加载
var _tile_scene: PackedScene
func _ready() -> void:
# 加载牌面场景模板
_tile_scene = load("res://scenes/tiles/Tile3D.tscn")
## 在指定位置生成一张牌
func spawn_tile(pos: Vector3, face_up: bool = true) -> Node3D:
# 实例化牌面场景
var tile = _tile_scene.instantiate()
# 设置位置
tile.position = pos
# 如果背面朝上,翻转180度
if not face_up:
tile.rotation = Vector3(0, PI, 0)
# 添加到场景中
add_child(tile)
return tile
## 批量生成一排手牌
func spawn_hand_tiles(count: int) -> void:
var start_x := -(count - 1) * 0.4 # 起始X坐标
var spacing := 0.8 # 牌间距
for i in range(count):
var pos := Vector3(start_x + i * spacing, 0.125, 3.0)
spawn_tile(pos, true)场景管理器
游戏初始化脚本
我们需要一个总管来协调整个游戏的启动和初始化:
using Godot;
/// <summary>
/// 游戏管理器——整个游戏的总控制器
/// </summary>
public partial class GameManager : Node3D
{
/// <summary>
/// 牌的容器节点
/// </summary>
[Export] public Node3D TileContainer { get; set; }
/// <summary>
/// 牌桌节点
/// </summary>
[Export] public Node3D Table { get; set; }
/// <summary>
/// UI层节点
/// </summary>
[Export] public CanvasLayer UILayer { get; set; }
/// <summary>
/// 摄像机节点
/// </summary>
[Export] public Camera3D MainCamera { get; set; }
/// <summary>
/// 牌面生成器
/// </summary>
private TileSpawner _tileSpawner;
/// <summary>
/// 游戏是否已初始化
/// </summary>
private bool _isInitialized;
public override void _Ready()
{
InitializeGame();
}
/// <summary>
/// 初始化游戏
/// </summary>
private void InitializeGame()
{
if (_isInitialized) return;
GD.Print("游戏初始化开始...");
// 验证节点引用
if (TileContainer == null)
{
GD.PrintErr("牌容器节点未设置!");
return;
}
// 创建牌面生成器
_tileSpawner = new TileSpawner();
TileContainer.AddChild(_tileSpawner);
// 生成测试用的手牌
_tileSpawner.SpawnHandTiles(13);
// 设置摄像机
SetupCamera();
_isInitialized = true;
GD.Print("游戏初始化完成!");
}
/// <summary>
/// 设置摄像机
/// </summary>
private void SetupCamera()
{
if (MainCamera == null) return;
// 确保使用正交投影
MainCamera.Projection = Camera3D.ProjectionType.Orthogonal;
MainCamera.Size = 15;
MainCamera.Position = new Vector3(0, 20, 0);
MainCamera.Rotation = new Vector3(-90, 0, 0);
}
/// <summary>
/// 重新开始游戏
/// </summary>
public void RestartGame()
{
// 清除所有牌
if (_tileSpawner != null)
{
_tileSpawner.QueueFree();
}
// 重新初始化
_isInitialized = false;
InitializeGame();
}
}extends Node3D
## 牌的容器节点
@export var tile_container: Node3D
## 牌桌节点
@export var table: Node3D
## UI层节点
@export var ui_layer: CanvasLayer
## 摄像机节点
@export var main_camera: Camera3D
## 牌面生成器
var _tile_spawner: TileSpawner
## 游戏是否已初始化
var _is_initialized: bool = false
func _ready() -> void:
initialize_game()
## 初始化游戏
func initialize_game() -> void:
if _is_initialized:
return
print("游戏初始化开始...")
# 验证节点引用
if tile_container == null:
push_error("牌容器节点未设置!")
return
# 创建牌面生成器
_tile_spawner = TileSpawner.new()
tile_container.add_child(_tile_spawner)
# 生成测试用的手牌
_tile_spawner.spawn_hand_tiles(13)
# 设置摄像机
setup_camera()
_is_initialized = true
print("游戏初始化完成!")
## 设置摄像机
func setup_camera() -> void:
if main_camera == null:
return
# 确保使用正交投影
main_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
main_camera.size = 15
main_camera.position = Vector3(0, 20, 0)
main_camera.rotation = Vector3(-90, 0, 0)
## 重新开始游戏
func restart_game() -> void:
# 清除所有牌
if _tile_spawner != null:
_tile_spawner.queue_free()
# 重新初始化
_is_initialized = false
initialize_game()项目目录结构
搭建完基础场景后,项目的文件目录应该长这样:
Mahjong2.5D/
├── project.godot ← Godot 项目配置文件
├── scenes/ ← 场景文件
│ ├── Game.tscn ← 主游戏场景
│ └── tiles/
│ └── Tile3D.tscn ← 牌面模板场景
├── scripts/ ← 脚本文件
│ ├── GameManager.cs ← 游戏管理器
│ └── TileSpawner.cs ← 牌面生成器
├── resources/ ← 资源文件
│ ├── textures/ ← 贴图(后续章节创建)
│ ├── materials/ ← 材质(后续章节创建)
│ └── audio/ ← 音效(后续章节创建)
└── addons/ ← 插件目录命名规范
- 场景文件用 PascalCase(大驼峰):
Game.tscn、Tile3D.tscn - 脚本文件用 PascalCase:
GameManager.cs、TileSpawner.gd - 资源文件夹用 snake_case(下划线):
textures/、materials/ - 私有字段用下划线前缀:
_tileSpawner、_isInitialized
运行测试
按下 F5(或点击编辑器右上角的播放按钮)运行项目,你应该能看到:
- 一个绿色的牌桌
- 桌面上方整齐排列着 13 张白色的牌(还没有贴图)
- 俯视角的正交视图
常见问题
| 问题 | 解决方案 |
|---|---|
| 什么都看不到 | 检查摄像机 Position 是否在 (0, 20, 0),Rotation 是否在 (-90, 0, 0) |
| 牌面是黑色的 | 检查是否有 DirectionalLight3D 或 WorldEnvironment |
| 牌面太小/太大 | 调整 Camera3D 的 Size 属性 |
| 牌面看不到立体感 | 调整 DirectionalLight3D 的角度,确保有阴影 |
小结
本章我们完成了:
- 创建了 Godot 4 项目并配置了基础设置
- 设置了正交摄像机,实现俯视角2.5D视角
- 搭建了清晰的场景树结构
- 用 CSGBox3D 制作了牌桌和牌面模型
- 创建了场景管理器,可以批量生成牌面
- 项目可以成功运行并显示
提示
下一章我们将为这些白色的牌面加上漂亮的贴图,让它们变成真正的麻将牌!
