ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Cocos Creator游戏开发入门:从零到一构建跨平台2D游戏

Cocos Creator游戏开发入门:从零到一构建跨平台2D游戏

1. 项目概述:为什么选择Cocos Creator作为你的游戏开发起点?

如果你正打算踏入游戏开发的大门,或者想从其他引擎(比如Unity)转过来试试水,Cocos Creator绝对是一个绕不开的名字。我最早接触它还是Cocos2d-x的时代,那时候写游戏得靠C++和一堆脚本,编辑器体验也比较“硬核”。后来Creator横空出世,把编辑器、组件化、TypeScript支持打包在一起,整个开发流程的顺畅度提升了好几个档次。现在,它已经是一个覆盖2D、3D、AR/VR乃至车载、教育等领域的全栈式实时内容开发平台了。

这个教程项目,目的不是让你看完就能做出一个3A大作,而是帮你从零开始,系统地理解一个Cocos Creator项目的完整生命周期。从新建一个空项目,到最终打包发布到微信小游戏、Web或原生平台,我会把每个环节的核心逻辑、容易踩的坑,以及我这些年积累的一些“野路子”技巧都揉碎了讲给你听。你会发现,很多官方文档一笔带过的东西,恰恰是项目能否顺利推进的关键。比如,为什么你的资源加载总是卡顿?UI适配在不同手机上怎么才能不出错?如何管理好一个逐渐膨胀的代码库?这些实战问题,我们都会一一拆解。

2. 项目整体设计与核心思路拆解

2.1 引擎选型与项目定位:Cocos Creator的优势场景

在动手之前,想清楚你的项目类型至关重要。Cocos Creator的核心优势在于轻量、高效和跨平台,尤其是在2D、休闲游戏、微信小游戏和H5游戏领域,它的性能和包体控制是杀手锏。

  • 对于2D游戏:Cocos Creator的2D渲染管线非常成熟,UI系统(Widget组件)和动画编辑器(Animation、DragonBones、Spine支持)用起来得心应手。如果你要做卡牌、消除、棋牌、横版过关这类游戏,它是上佳之选。
  • 对于3D项目:Creator的3D能力近年来进步神速,内置了基于物理的渲染(PBR)管线,支持光照、阴影、后期效果。虽然和Unity、Unreal在超大型3A项目上有差距,但对于中小体量的3D手游、展示类应用、轻量级AR体验来说,完全够用,而且打包后的体积优势明显。
  • 对于跨平台发布:这是Cocos的看家本领。一套代码,可以同时发布到Web、iOS、Android、微信小游戏、抖音小游戏、Windows、Mac等多个平台。它的构建系统已经帮你处理了大部分平台差异,省去了大量适配工作。

所以,在开始我们的教程项目时,我会以一个2D轻量级休闲游戏(比如一个简单的平台跳跃或收集类游戏)作为主线案例。这样既能覆盖Cocos Creator最常用的2D功能,又能触及资源管理、物理、UI、音频等核心模块,最终打包成微信小游戏和Web版本,体验完整的跨平台流程。

2.2 项目目录结构与核心资源规划

一个清晰的项目结构是后续高效开发的基石。在Cocos Creator中,assets目录是你的主战场,但怎么组织里面的内容大有讲究。

你的项目/ ├── assets/ │ ├── scripts/ # 所有TypeScript脚本 │ │ ├── core/ # 核心管理类(GameManager, AudioManager等) │ │ ├── ui/ # UI相关控制脚本 │ │ ├── character/ # 角色相关脚本 │ │ └── utils/ # 工具函数库 │ ├── scenes/ # 场景文件(.fire) │ ├── prefabs/ # 预制体文件 │ ├── textures/ # 图片资源(建议按功能分文件夹,如 ui/, bg/, role/) │ ├── sounds/ # 音频资源(bgm/, sfx/) │ ├── animations/ # 动画剪辑文件 │ └── fonts/ # 字体文件 ├── settings/ # 项目设置(构建、物理等) ├── extensions/ # 编辑器扩展插件 └── package.json # 项目npm依赖配置

注意:资源命名务必规范!我习惯使用小写字母、数字和下划线,例如player_idle_anim,避免使用中文和空格。纹理资源导入前,最好用工具(如TexturePacker)打好图集(Atlas),这能显著减少Draw Call,提升运行时性能。音频文件优先使用.mp3(兼容性好)或.ogg(体积小),避免使用未压缩的.wav

3. 核心模块实现与实操要点

3.1 场景搭建与节点树管理

在Cocos Creator里,一切皆“节点”(Node)。场景就是一棵节点树。创建主场景Main.fire,我们首先搭建一个基础的游戏世界。

  1. 创建渲染层级:在场景根节点下,创建几个空节点,分别命名为BG(背景)、Game(游戏主层)、UI(界面层)。然后,在属性检查器中调整它们的Layer属性。例如,将UI节点的Layer设为UI_2D,并确保你的UI相机只渲染UI_2D层。这样做是为了实现正确的渲染顺序,避免UI被背景遮挡。
  2. 使用Canvas适配UI:在UI节点下添加一个Canvas组件。这是所有UI元素的根容器。关键步骤是设置它的Design Resolution(设计分辨率,比如 1920x1080)和Fit Height/Fit Width适配模式。对于竖屏游戏,我通常选择Fit Height,这样在不同高度的屏幕上,UI都能按高度比例缩放,宽度方向可能会裁剪或留黑边,但核心内容区域保持稳定。
  3. 预制体(Prefab)化常用元素:玩家角色、敌人、子弹、奖励道具这些会重复出现的物体,一定要做成预制体。在assets/prefabs下创建它们。这样做的好处是:一次制作,多次实例化;修改预制体资源,所有实例同步更新。这是模块化开发的基础。

3.2 编写核心游戏逻辑:以玩家控制器为例

我们来创建一个简单的玩家角色控制器脚本PlayerController.ts,挂载到玩家预制体上。

// assets/scripts/character/PlayerController.ts import { _decorator, Component, Input, input, KeyCode, Vec3, RigidBody2D, PhysicsSystem2D } from 'cc'; const { ccclass, property } = _decorator; @ccclass('PlayerController') export class PlayerController extends Component { // 通过属性装饰器暴露给编辑器调整 @property public moveSpeed: number = 200; @property public jumpForce: number = 400; private _rigidBody: RigidBody2D | null = null; private _isOnGround: boolean = false; private _currentInput: number = 0; // -1左, 0停, 1右 start() { // 获取刚体组件 this._rigidBody = this.getComponent(RigidBody2D); if (!this._rigidBody) { console.error('PlayerController requires a RigidBody2D component!'); return; } // 注册键盘输入事件 input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); input.on(Input.EventType.KEY_UP, this.onKeyUp, this); } onKeyDown(event: any) { switch(event.keyCode) { case KeyCode.KEY_A: case KeyCode.ARROW_LEFT: this._currentInput = -1; break; case KeyCode.KEY_D: case KeyCode.ARROW_RIGHT: this._currentInput = 1; break; case KeyCode.SPACE: case KeyCode.KEY_W: this.jump(); break; } } onKeyUp(event: any) { if (event.keyCode === KeyCode.KEY_A || event.keyCode === KeyCode.ARROW_LEFT || event.keyCode === KeyCode.KEY_D || event.keyCode === KeyCode.ARROW_RIGHT) { this._currentInput = 0; } } jump() { if (this._isOnGround && this._rigidBody) { let v = this._rigidBody.linearVelocity; v.y = this.jumpForce; this._rigidBody.linearVelocity = v; this._isOnGround = false; // 这里可以触发跳跃动画或音效 } } update(deltaTime: number) { // 每帧根据输入设置水平速度 if (this._rigidBody) { let v = this._rigidBody.linearVelocity; v.x = this._currentInput * this.moveSpeed; this._rigidBody.linearVelocity = v; } } // 碰撞检测,用于判断是否在地面 onBeginContact(contact: any, selfCollider: any, otherCollider: any) { // 简单判断:如果碰撞点法线向上,则认为在地面 if (contact.worldManifold.normal.y > 0.5) { this._isOnGround = true; } } onDestroy() { // 记得销毁时移除事件监听,防止内存泄漏! input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this); input.off(Input.EventType.KEY_UP, this.onKeyUp, this); } }

实操心得:物理移动使用RigidBody2D.linearVelocity直接设置速度,比用tween或直接改position更符合物理直觉,效果也更真实。记得在项目设置里开启物理引擎(PhysicsSystem2D),并设置好重力等参数。

3.3 UI系统与数据绑定

游戏UI,比如分数显示、血条、开始按钮,我们用Creator自带的UI组件和脚本来实现。

  1. 制作UI预制体:在UI节点下创建ScorePanel节点,添加Label组件显示分数,再创建一个Button作为开始按钮。
  2. 编写UI控制脚本:创建UIManager.ts,它是一个单例管理器,负责所有UI的显示、隐藏和更新。
// assets/scripts/ui/UIManager.ts import { _decorator, Component, Label, Node, director } from 'cc'; const { ccclass, property } = _decorator; @ccclass('UIManager') export class UIManager extends Component { @property(Label) scoreLabel: Label | null = null; @property(Node) startMenu: Node | null = null; @property(Node) gameOverPanel: Node | null = null; private _score: number = 0; private static _instance: UIManager | null = null; // 简单的单例模式获取方式 public static get instance(): UIManager { return UIManager._instance!; } start() { if (UIManager._instance === null) { UIManager._instance = this; } this.updateScore(0); this.showStartMenu(); } showStartMenu() { if (this.startMenu) this.startMenu.active = true; if (this.gameOverPanel) this.gameOverPanel.active = false; } hideStartMenu() { if (this.startMenu) this.startMenu.active = false; } showGameOver() { if (this.gameOverPanel) this.gameOverPanel.active = true; } updateScore(add: number) { this._score += add; if (this.scoreLabel) { this.scoreLabel.string = `Score: ${this._score}`; } // 这里可以触发得分特效或音效 } onStartButtonClick() { this.hideStartMenu(); // 通知游戏管理器开始游戏 director.emit('game-start'); } onRestartButtonClick() { director.loadScene(director.getScene()!.name); // 重载当前场景 } }

注意事项:UI节点的active属性控制显隐,比动态创建和销毁节点性能开销小得多。对于频繁更新的数据(如分数),直接更新Labelstring属性即可。复杂的UI数据绑定可以考虑使用观察者模式或像MVVM这样的框架,但对于中小项目,直接管理通常更简单高效。

4. 资源管理、动画与音频系统

4.1 动态加载与释放资源

游戏资源不能一股脑全加载进内存。Cocos Creator提供了resources.load和 Asset Bundle 两种主要方式。

  • resources目录:放在assets/resources下的资源,可以通过resources.load动态加载。适合一些非必需或关卡资源。
    resources.load('prefabs/enemy_01', Prefab, (err, prefab) => { if (err) { console.error(err); return; } let enemyNode = instantiate(prefab); this.node.parent.addChild(enemyNode); });
  • Asset Bundle:这是更推荐的方式。你可以将不同关卡、场景的资源打包成独立的Bundle,按需加载和释放,对包体控制和内存管理非常友好。
    // 加载Bundle let bundle = await assetManager.loadBundle('level1'); // 从Bundle中加载资源 let sceneAsset = await bundle.load('level1_scene', SceneAsset); // 释放Bundle bundle.releaseAll();

踩坑记录:动态加载的资源,用完后一定要记得释放!特别是场景切换时,旧场景的资源如果没有被引用,要调用assetManager.releaseAsset(asset)assetManager.releaseUnusedAssets()来释放,否则会导致内存泄漏,在移动端上可能很快引发崩溃。

4.2 动画系统与状态控制

Cocos Creator的动画系统功能强大。对于角色动画,我推荐以下流程:

  1. 制作动画剪辑:在动画编辑器中,选中你的角色节点,创建动画剪辑(如idlerunjump)。通过关键帧记录Sprite的spriteFrame、节点的positionscale等属性变化。
  2. 创建动画状态机:在角色节点上添加Animation组件。然后将制作好的多个动画剪辑拖入,并设置默认播放的剪辑。
  3. 代码控制动画切换:在PlayerController脚本中,根据角色状态(移动、跳跃、静止)来播放对应的动画。
    @property(Animation) playerAnim: Animation | null = null; update() { // ... 移动逻辑 if (this._rigidBody) { let vx = Math.abs(this._rigidBody.linearVelocity.x); if (this._isOnGround) { if (vx > 0.1) { this.playAnim('run'); } else { this.playAnim('idle'); } } else { this.playAnim('jump'); } } } playAnim(name: string) { if (this.playerAnim && this.playerAnim.defaultClip?.name !== name) { this.playerAnim.play(name); } }

技巧:对于复杂的角色(如骨骼动画),可以集成Spine或DragonBones。Creator对它们有很好的原生支持,导入后使用sp.SkeletondragonBones.ArmatureDisplay组件即可。

4.3 音频播放与管理

音效和背景音乐是游戏的灵魂。创建一个AudioManager单例来统一管理是个好习惯。

// assets/scripts/core/AudioManager.ts import { _decorator, AudioSource, AudioClip, resources } from 'cc'; const { ccclass } = _decorator; @ccclass('AudioManager') export class AudioManager { private _audioSource: AudioSource | null = null; private static _instance: AudioManager; private _soundOn: boolean = true; private _musicOn: boolean = true; public static get instance(): AudioManager { if (!this._instance) { this._instance = new AudioManager(); } return this._instance; } // 初始化,需要传入一个挂载了AudioSource的节点 init(audioSourceNode: AudioSource) { this._audioSource = audioSourceNode; } // 播放背景音乐(循环) playMusic(clip: AudioClip, volume: number = 1.0) { if (!this._musicOn || !this._audioSource) return; this._audioSource.stop(); this._audioSource.clip = clip; this._audioSource.loop = true; this._audioSource.volume = volume; this._audioSource.play(); } // 播放音效(一次) playSound(clip: AudioClip, volume: number = 1.0) { if (!this._soundOn || !this._audioSource) return; this._audioSource.playOneShot(clip, volume); } // 动态加载并播放音效 playSoundByName(name: string) { resources.load(`sounds/sfx/${name}`, AudioClip, (err, clip) => { if (err) { console.error(err); return; } this.playSound(clip); }); } toggleMusic(on: boolean) { this._musicOn = on; if (!on) this._audioSource?.stop(); } toggleSound(on: boolean) { this._soundOn = on; } }

注意:移动端(特别是iOS)有自动播放策略限制,背景音乐最好在用户第一次交互(如点击开始按钮)后播放。音效文件要短小精悍,格式正确,避免因解码导致卡顿。

5. 物理与碰撞系统实战

5.1 物理引擎配置与刚体类型

Cocos Creator内置了基于Box2D的2D物理引擎。在项目设置 -> 物理中,可以开启物理引擎并配置重力等全局参数。

为物体添加物理属性,主要是添加碰撞体(Collider)和刚体(RigidBody)。

  • 碰撞体类型BoxCollider2D(矩形)、CircleCollider2D(圆形)、PolygonCollider2D(多边形)。根据物体形状选择,尽量用简单的形状逼近,性能更好。
  • 刚体类型
    • Static:静态刚体,不受力,不移动(如地面、墙壁)。
    • Dynamic:动态刚体,完全受物理引擎模拟(如玩家、箱子)。
    • Kinematic:运动学刚体,不受力,但可以通过代码设置速度或位置来移动(常用于平台、移动的障碍物)。

5.2 碰撞与触发检测

物理交互有两种方式:碰撞触发

  • 碰撞:物体会发生真实的物理反弹和阻挡。需要双方都有碰撞体和刚体。
  • 触发:物体可以互相穿过,但会收到碰撞事件。只需至少一方勾选了isTrigger属性。

在脚本中监听事件:

// 在继承了Component的脚本中 onLoad() { // 获取碰撞体组件 let collider = this.getComponent(Collider2D); if (collider) { collider.on('onTriggerEnter', this.onTriggerEnter, this); collider.on('onCollisionEnter', this.onCollisionEnter, this); // 还有 onTriggerStay, onTriggerExit, onCollisionStay, onCollisionExit } } onTriggerEnter(self: Collider2D, other: Collider2D) { // 触发逻辑,例如吃到金币 if (other.node.group === 'coin') { other.node.destroy(); // 销毁金币 UIManager.instance.updateScore(100); // 更新分数 AudioManager.instance.playSoundByName('get_coin'); } } onCollisionEnter(self: Collider2D, other: Collider2D) { // 碰撞逻辑,例如碰到敌人 if (other.node.group === 'enemy') { // 玩家受伤或死亡逻辑 this.takeDamage(); } }

关键点:合理设置节点的Group(分组)。在项目设置 -> 物理 -> 分组管理中定义分组(如player,enemy,ground,coin),然后在属性检查器中为每个碰撞体设置所属分组和可以碰撞的分组。这能精确控制哪些物体之间会发生交互,避免不必要的性能开销和逻辑错误。

6. 构建、打包与多平台发布

6.1 构建配置详解

点击编辑器上方的构建按钮,会打开构建发布面板。这里有几个关键配置:

  • 发布平台:选择目标平台,如Web Mobile、微信小游戏、Android、iOS等。
  • 主包压缩类型合并所有JSON可以减少请求数量,小游戏分包则适用于微信小游戏等有包体限制的平台。
  • MD5 Cache:勾选后会给资源文件名加上哈希值,有利于浏览器缓存和热更新。
  • 调试模式:开发阶段建议开启,会包含Source Map,方便调试。
  • Source Maps:发布线上版本时建议关闭,以保护代码和减小包体。

6.2 微信小游戏发布专项适配

发布到微信小游戏是Cocos Creator的强项,但有几个特殊点需要注意:

  1. 项目配置:在构建面板选择“微信小游戏”平台后,需要填写appid(从微信公众平台获取)。构建完成后,会生成一个wechatgame目录。
  2. 使用微信开发者工具:用微信开发者工具打开这个wechatgame目录。首次打开可能需要配置project.config.json中的appid
  3. 小游戏API调用:游戏内如需调用微信登录、分享、支付、广告等能力,需要使用wx.开头的API。Cocos Creator通过wx全局对象暴露了这些接口,但要注意平台差异,做好条件编译或运行时判断。
    // 示例:调用微信登录 if (typeof wx !== 'undefined') { wx.login({ success(res) { if (res.code) { console.log('登录凭证:', res.code); } } }); }
  4. 包体超限与分包加载:微信小游戏有4M(主包)或8M(分包)的包体限制。务必使用资源分包功能。在构建面板的“分包”页签,将一些大的场景、图集、音频资源设置为远程包或分包。游戏运行时动态加载。
    // 加载分包 assetManager.loadBundle('subpackage1', (err, bundle) => { if (err) { /* 处理错误 */ return; } bundle.loadScene('level1', (err, scene) => { /* ... */ }); });

6.3 Web平台发布与性能优化

发布到Web(HTML5)是最简单的。构建后得到一个build目录,里面包含index.html和所有资源。

  • 服务器部署:将整个build目录上传到你的Web服务器(如Nginx, Apache)即可。
  • CDN加速:对于资源文件(js, 图片,音频),建议上传到CDN,并在构建时配置“资源服务器地址”。
  • 性能优化点
    • Draw Call:使用自动图集(Auto Atlas)功能,将碎图合并,这是降低Draw Call最有效的手段。
    • 减少Canvas操作:避免每帧频繁修改Canvas的宽高或样式。
    • WebGL警告:在浏览器控制台注意WebGL相关的警告,如纹理尺寸非2的幂次方(NPOT),尽量使用符合规范的纹理。
    • 内存泄漏:使用Chrome开发者工具的Memory面板,定期做快照对比,查找未被释放的节点和资源。

7. 调试、性能分析与常见问题排查

7.1 调试技巧

  • 浏览器开发者工具:Web平台和小游戏平台(通过真机调试)都可以用Chrome DevTools。重点关注Console(日志)、Sources(源码调试,需开启Source Map)、Network(资源加载)、Performance(性能分析)面板。
  • Cocos Creator调试器:在编辑器里运行游戏,可以打开调试器(Debugger)查看场景节点树、属性、动画状态等,非常直观。
  • VSCode调试:配置好launch.json,可以实现在VSCode中打断点调试TypeScript代码,体验和调试普通前端项目一样。

7.2 性能分析工具

  • 编辑器中的性能分析器:运行游戏后,打开控制台 -> Profiler。这里可以看到CPU占用、渲染批次(Draw Call)、帧率(FPS)、内存、物理引擎等详细数据。Draw Call是2D游戏最重要的性能指标之一,优化目标就是尽可能降低它。
  • Chrome Performance面板:录制一段时间内的运行时性能,可以精确到每个函数的执行时间,是定位脚本性能瓶颈的利器。

7.3 常见问题与解决方案速查表

问题现象可能原因排查步骤与解决方案
游戏在真机上黑屏/白屏1. 资源加载失败
2. WebGL上下文创建失败
3. 代码执行错误(如未捕获的异常)
1. 查看浏览器或开发者工具Console报错。
2. 检查资源路径、名称是否正确,网络请求是否成功。
3. 在真机上开启远程调试,查看日志。
4. 检查设备是否支持WebGL(老旧手机可能不支持)。
画面卡顿,帧率低1. Draw Call过高
2. 脚本逻辑每帧计算量过大
3. 内存泄漏导致GC频繁
1. 打开Profiler查看Draw Call数,使用图集合并精灵。
2. 使用Performance面板分析脚本耗时,优化算法(避免在update里进行复杂计算或查找)。
3. 检查是否有未销毁的节点、未取消的事件监听、未释放的资源。
物理表现不正常(穿墙、下坠)1. 碰撞体形状设置不准确
2. 刚体类型设置错误
3. 物理世界步长不合适
1. 在编辑器中使用碰撞体组件的编辑模式,仔细调整形状,使其与视觉轮廓匹配。
2. 确认动态物体是Dynamic,静态地面是Static
3. 在项目设置中调整物理步长(fixedTimeStep),默认1/60通常可以,过快可能导致不稳定。
微信小游戏包体超限主包超过4MB限制1. 使用构建面板的分包功能,将大资源(场景、图集、音频)移到分包。
2. 检查resources目录,只保留必须动态加载的资源,其他移到分包或远程。
3. 压缩图片、音频资源。
音频在iOS上无法播放iOS的自动播放策略和音频格式限制1. 确保所有音频播放(尤其是背景音乐)都在用户交互(如触摸)事件回调中触发。
2. 音频文件格式优先使用.mp3.ogg在部分iOS版本上可能不支持。
出现SystemError (JsEngineScriptError)通常是JavaScript/TypeScript代码运行时错误1. 仔细查看错误堆栈信息,定位到具体文件和行号。
2. 常见原因:访问了未定义的变量、函数参数类型错误、异步回调中的状态问题。
3. 使用TypeScript并开启严格模式,能在编译阶段发现很多潜在错误。
UI在不同分辨率下错位Canvas适配模式设置不当或UI节点未正确使用Widget组件1. 检查Canvas上的Fit Height/Fit Width设置是否符合你的游戏设计(竖屏Fit Height,横屏Fit Width)。
2. 为需要对齐或拉伸的UI子节点添加Widget组件,并设置好对齐目标和边距。

8. 项目优化与进阶方向

当你的游戏能跑起来之后,就该考虑让它跑得更快、更稳了。

  • 资源优化
    • 纹理:使用合适的压缩格式(Web平台用.jpg/.png,小游戏可用.pkm/.astc),启用纹理压缩。坚决使用图集。
    • 音频:控制采样率和比特率,背景音乐可稍大,音效应尽可能小。使用音频剪辑的loadMode,将常用音效设为预加载,不常用的设为动态加载
  • 渲染优化
    • 静态合批:对于场景中静止不动的元素(如背景块),可以勾选cc.Sprite组件的isStatic属性,引擎可能会将它们合并批次。
    • 减少透明重叠:半透明物体渲染开销大,且顺序依赖严重,尽量减少。
  • 代码优化
    • 避免在update中做查找:如this.node.parent.getChildByName('xxx'),这类操作应放在startonEnable中缓存结果。
    • 对象池:对于频繁创建和销毁的物体(子弹、敌人、特效),一定要用对象池(cc.NodePool)。这是游戏开发中最重要的性能优化手段之一。
    • 事件监听管理onoff必须成对出现,防止内存泄漏。使用this.node.on监听的事件,在节点销毁时会自动清理,相对安全一些。

最后,关于进阶,你可以探索Cocos Creator更强大的功能:Shader效果来自定义材质实现炫酷视觉;Native扩展来调用平台原生能力;编辑器扩展来打造专属的开发工具链;以及深入3D渲染管线,制作更复杂的3D场景。这个由浅入深的过程,也正是游戏开发乐趣和挑战所在。

返回列表