從零開始制作微信小游戲-彈一弾,純原生Canvas與物理引擎Matter.js應用

前言

H5游戲一直以來,以跨平臺,低體驗著稱,其很大原因在于早期技術方案的不成熟和受限于H5游戲編碼水平。但現今,Canvas和WebGL的渲染性能已經很好了,合理編碼的情況下,體驗與原生應用游戲并無區別

由微信小程序衍生且獨立而出的【微信小游戲】便是瞄準了Web游戲渲染,代表著這是未來游戲制作一個很大方向上的趨勢。微信小游戲運行環境移除了BOM和DOM,這是一個很有意思的方案,因為這意味著游戲開發者必須用純canvas繪制游戲內容,這對于游戲性能的提升是巨大的

同時,為了保留對游戲引擎的支持和減少現行大量H5游戲的遷移工作,微信小游戲官方提供了weapp-adapter適配器,通過微信小游戲官方的適配器或自行開發編寫的適配器,可以兼容很多的BOM或DOM的API

因為微信小游戲平臺才剛剛推出,目前網絡上大量存在的,包括github上開源的微信小游戲其實都是微信小程序的網頁版本,和傳統頁游沒區別,受限于BOM和DOM,性能和體驗上都并不好。本文的主旨在于從零開始,以純Canvas的開發方式,制作一個微信小游戲上非常流行和好玩的游戲——【彈一弾】

演示

elastic-demo.gif

H5模式演示版本:https://cheneyweb.github.io/wxgame-elastic/dist/index.html

H5模式二維碼,手機掃碼體驗(微信掃碼,瀏覽器掃碼等都可以)
elastic-qrcode.png

微信小游戲模式演示版本:需要打開微信開發者工具導入工程目錄

思路

【彈一弾】游戲的核心在于對物理彈動的真實模擬和大量物體元素的碰撞交互,是一個非常有挑戰的游戲制作

任何的游戲開發開發離不開游戲引擎,因為純原生的編碼制作游戲效率是非常低下的,而且難以維護,所以工欲善其,必先利其器,在開發【彈一弾】的同時,我們還需要先制作一個精簡高效的canvas游戲引擎(稱之為游戲引擎是不合適的,因為我們不可能在短時間內完成一個游戲引擎的開發,這里只是為了類比了游戲引擎的少部分功能)

任何的游戲其本質一定是包含著一個或多個循環,這才會有了我們所見的動畫效果,下面先列舉【彈一弾】的開發思路

  1. 統一的資源定義(包括圖片,音效,音樂)等資源
  2. 統一的資源加載(初始資源在內存中的載入)
  3. 統一的狀態管理(全局變量數據的維護,這里說個題外話,我本人非常不喜歡狀態管理之類的的全局變量方案,但是在游戲開發中,這是必須且不得不引入的,因為游戲編程對于狀態變更的需求非常大,合理的使用全局變量能大大提高編碼效率)
  4. 統一的資源渲染,繪制呈現
  5. 全局物理引擎,負責模擬彈性碰撞實現,實現游戲核心邏輯
  6. 面向對象的開發思路,以物體元素作為游戲內容單位,制定每個物體元素的行為和邏輯

以上的1-4點就是我們需要制作的簡單高效的精簡版“游戲引擎”,有了1-4的基礎鋪墊后,通過5的引入和6的自定義展開,我們就可以完成【彈一弾】的制作

這里需要補充說明的是第5點,物理引擎,為了開發【彈一弾】我尋找對比了多款JS物理引擎。目前的現狀是大部分JS物理引擎都已經處于停止開發維護的狀態,多款知名的JS物理引擎在github上已經多年沒更新。或許是因為物理引擎的門檻較高和H5游戲早年的發展不順利導致。但對游戲來說,物理引擎是非常核心且重要的一環,很多PC和Mobile上的游戲大作,之所以體驗良好,就是因為有強大的物理引擎作為背后支撐,但是這些大作的物理引擎很多都是商業版本,價格高昂且不開源

不過所幸的是,有一款JS物理引擎很突出,性能和功能很強大,且目前有著持續性的維護,它就是Matter.js。這款物理引擎幾乎是我制作彈一弾的唯一選擇,我個人測試下來問題并不多,有部分問題可以通過了對源碼的一些修改解決。需要特別說明的是Matter物理引擎也是知名游戲引擎Laya和Egret的開發常選

實踐

整個開發流程會分七步走,需要注意的是,因為文章篇幅所限,不可能展示所有代碼,但所有核心流程都會有介紹說明,在文末我會附上項目的github地址,提供大家參考

1、開發環境準備

相比傳統游戲開發,H5游戲的開發環境十分簡單輕巧,而且我們不采用商業游戲引擎,而是純原生開發,所有我們只需要一個關鍵工具:

微信開發者工具


微信開發者工具.png

2、開發精簡版的游戲引擎

一個超級無敵精簡版的游戲引擎需要什么功能,那就是把游戲畫面渲染繪制出來。 所以理論上我們只需要一個“畫筆類”就夠了,這支畫筆能夠繪制出我們想要的內容。當然,除了畫筆之外,我們也還需要一些其他的關鍵組件
我們命名一個文件夾——"base",然后在這個文件夾內放置我們所有需要的游戲基礎類

    ├── base  精簡版游戲引擎
    │   ├── Body.js  物理物體元素基類
    │   ├── DataStore.js  全局狀態管理類
    │   ├── Resource.js  統一資源定義類
    │   ├── ResourceLoader.js  統一資源加載類
    │   ├── Sprite.js  普通物體渲染畫筆類
    │   └── matter.js  物理引擎

Resource.js
這是統一資源管理類,非常簡單,因為整個游戲只需要兩張圖片和兩個音效

export const Resources = [
    ['background', 'res/background.png'],
    ['startButton', 'res/startbutton.png'],
    ['bgm', 'res/xuemaojiao.mp3'],
    ['launch', 'res/launch.mp3']
]

ResourceLoader.js
這是統一資源加載類,同樣簡單,我們只需要在資源加載后回調即可,因為微信小游戲的圖片和音效資源的加載需要其官方API,這里和H5原生標準稍有不同

//資源文件加載器,確保在圖片資源加載完成后才渲染
import { Resources } from './Resource.js'

export class ResourceLoader {
  constructor() {
    this.imageCount = 0
    this.audioCount = 0
    //導入資源
    this.map = new Map(Resources)
    for (let [key, src] of this.map) {
      let res = null
      if (src.split('.')[1] == 'png' || src.split('.')[1] == 'jpg') {
        this.imageCount++
        // H5創建image的API
        res = new Image()
        // 微信創建image的API
        // res = wx.createImage()
        res.src = src
      } else {
        this.audioCount++
        // H5創建audio的API
        res = new Audio()
        // 微信創建audio的API
        // res = wx.createInnerAudioContext()
        res.src = src
      }
      this.map.set(key, res)
    }
  }

  // 加載完成回調
  onload(cb) {
    let loadCount = 0
    for (let res of this.map.values()) {
      // 使this指向當前的ResourceLoader
      res.onload = () => {
        loadCount++
        if (loadCount >= this.imageCount) {
          cb(this.map)
        }
      }
    }
  }
}

Sprite.js
這是普通物體渲染畫筆類,目前我們只需要封裝底層的canvas的圖片繪制即可

import { DataStore } from './DataStore.js'
export class Sprite {
  constructor(ctx, img, x = 0, y = 0, w = 0, h = 0, srcX = 0, srcY = 0, srcW = 0, srcH = 0, ) {
    this.ctx = ctx
    this.img = img
    this.srcX = srcX
    this.srcY = srcY
    this.srcW = srcW
    this.srcH = srcH
    this.x = x
    this.y = y
    this.w = w
    this.h = h
  }

  /**
   * 繪制圖片
   * img 傳入Image對象
   * srcX 要剪裁的起始X坐標
   * srcY 要剪裁的起始Y坐標
   * srcW 剪裁的寬度
   * srcH 剪裁的高度
   * x 放置的x坐標
   * y 放置的y坐標
   * w 要使用的寬度
   * h 要使用的高度
   */
  draw(img = this.img,
    x = this.x, y = this.y, w = this.w, h = this.h,
    srcX = this.srcX, srcY = this.srcY, srcW = this.srcW, srcH = this.srcH) {
    this.ctx.drawImage(img, srcX, srcY, srcW, srcH, x, y, w, h)
  }

  static getImage(key) {
    return DataStore.getInstance().res.get(key)
  }
}

Body.js
這是物理物體元素基類,目前只需要實現引入物理引擎實例即可

// 物體基類
export class Body {
  constructor(physics) {
    this.physics = physics
  }
}

3、編碼游戲主邏輯

App.js
這是游戲的入口,也是整個游戲應用類,只需要canvas實例,以及拓展物理引擎實例作為入參,即可實例化該游戲應用

import { ResourceLoader } from './src/base/ResourceLoader.js'
import { DataStore } from './src/base/DataStore.js'
import { Director } from './src/Director.js'

/**
 * 游戲入口
 */
export class App {
  constructor(canvas, options) {
    this.canvas = canvas                                             // 畫布
    this.physics = { ...options, ctx: this.canvas.getContext('2d') } // 物理引擎
    this.director = new Director(this.physics)                       // 導演
    this.dataStore = DataStore.getInstance()
    // 資源加載
    new ResourceLoader().onload(res => {
      // 持久化資源
      this.dataStore.res = res
      // 加載精靈
      this.director.spriteLoad(res)
      // 運行游戲
      this.run()
    })
  }

  /**
   * 運行游戲
   */
  run() {
    // 注冊事件
    this.registerEvent()
    // 物理渲染
    this.director.physicsDirect()
    // 精靈渲染
    this.director.spriteDirect()
    // 音樂播放
    this.dataStore.res.get('bgm').autoplay = true
  }

  /**
   * 重新加載游戲
   */
  reload() {
    // 物理渲染
    this.director.physicsDirect(true)
    // 精靈渲染
    this.director.spriteDirect(true)
  }

  /**
   * 注冊事件
   */
  registerEvent() {
    // 移動設備觸摸事件,使用=>使this指向Main類
    this.canvas.addEventListener('touchstart', e => {
      // 屏蔽事件冒泡
      e.preventDefault()
      // 如果游戲是結束狀態,則重新開始
      if (this.dataStore.isGameOver) {
        // 重新初始化
        this.dataStore.isGameOver = false
        this.reload()
      }
    })
    // PC設備點擊事件
    this.canvas.addEventListener('mousedown', e => {
      // 屏蔽事件冒泡
      e.preventDefault()
      // 如果游戲是結束狀態,則重新開始
      if (this.dataStore.isGameOver) {
        // 重新初始化
        this.dataStore.isGameOver = false
        this.reload()
      }
    })
  }
}

Director.js
這是游戲導演類,負責游戲主邏輯調度調配,以及游戲畫面渲染工作

// 精靈對象
import { BackGround } from './sprite/BackGround.js'
import { StartButton } from './sprite/StartButton.js'
import { Score } from './sprite/Score.js'
// 物理引擎繪制對象
import { Block } from './body/Block.js'
import { Border } from './body/Border.js'
import { Bridge } from './body/Bridge.js'
import { Aim } from './body/Aim.js'
// 數據管理
import { DataStore } from './base/DataStore.js'

/**
 * 導演類,控制游戲的邏輯
 */
export class Director {
  constructor(physics) {
    this.physics = physics
    this.dataStore = DataStore.getInstance()
  }
  // 加載精靈對象
  spriteLoad() {
    this.sprite = new Map()
    this.sprite['score'] = new Score(this.physics)
    this.sprite['startButton'] = new StartButton(this.physics)
    this.sprite['background'] = new BackGround(this.physics)
  }
  // 逐幀繪制
  spriteDirect(isReload) {
    if(isReload){
      this.dataStore.scoreCount = 0
    }
    // 繪制前先判斷是否碰撞
    // this.check()
    // 游戲未結束
    if (!this.dataStore.isGameOver) {
      // 繪制游戲內容
      this.sprite['score'].draw()
      // this.sprite['background'].draw()
      // 自適應瀏覽器的幀率,提高性能
      this.animationHandle = requestAnimationFrame(() => this.spriteDirect())
    }
    //  游戲結束
    else {
      // 停止物理引擎
      this.physics.Matter.Engine.clear(this.physics.engine)
      this.physics.Matter.World.clear(this.physics.engine.world)
      this.physics.Matter.Render.stop(this.physics.render)
      // 停止繪制
      cancelAnimationFrame(this.animationHandle)
      // 結束界面
      this.sprite['score'].draw()
      this.sprite['startButton'].draw()
    }
  }
  // 物理繪制
  physicsDirect(isReload) {
    this.physics.Matter.Render.run(this.physics.render)
    if (!isReload) {
      new Aim(this.physics).draw().event()
      // new Bridge(this.physics).draw()
    }
    new Block(this.physics).draw().event().upMove()
    new Border(this.physics).draw()
  }
}

4、渲染基礎物體元素

BackGround.js
從此處開始,就已經使用搭建好的游戲框架,開始正式設計和繪制游戲內容,在這里以最簡單的背景類舉例,這個基礎物體非常簡單,且只做了一件事情,那就是繪制游戲背景。剩余的基礎物體還有計分器和游戲開始按鈕,限于篇幅不做展開,文末會有本項目的github開源項目地址

import { Sprite } from '../base/Sprite.js'
/**
 * 背景類
 */
export class BackGround extends Sprite {
  constructor(physics) {
    const image = Sprite.getImage('background')
    super(
      physics.ctx, image,
      (physics.canvas.width - image.width) / 2,
      (physics.canvas.height - image.height) / 2.5,
      image.width, image.height,
      0,
      0,
      image.width, image.height
    )
  }
}

5、引入物理引擎

為了讓matter.js這個物理引擎能夠適合游戲的開發需求,我們需要對其進行適當的修改,讓其增加能夠渲染文字等功能,所以我們選擇了matter.js的未壓縮版本
在matter.js的Render.bodies方法中,跟著c.globalAlpha = 1;之后,增加拓展代碼

c.globalAlpha = 1;
// 增加自定義渲染TEXT
if (part.render.text) {
    // 30px is default font size
    var fontsize = 30;
    // arial is default font family
    var fontfamily = part.render.text.family || "Arial";
    // white text color by default
    var color = part.render.text.color || "#FFFFFF";
    // text maxWidth
    var maxWidth = part.render.text.maxWidth

    if (part.render.text.size)
        fontsize = part.render.text.size;
    else if (part.circleRadius)
        fontsize = part.circleRadius / 2;

    var content = "";
    if (typeof part.render.text == "string")
        content = part.render.text;
    else if (part.render.text.content)
        content = part.render.text.content;

    c.textBaseline = "middle";
    c.textAlign = "center";
    c.fillStyle = color;
    c.font = fontsize + 'px ' + fontfamily;
    if (part.bounds) {
        maxWidth = part.bounds.max.x - part.bounds.min.x;
    }
    c.fillText(content, part.position.x, part.position.y, maxWidth);
}

game.js
對Matter物理引擎做一些調整之后,我們就可以在微信小游戲的入口文件中引入,并初始化【彈一弾】游戲實例

// require('./src/base/weapp-adapter.js')
const Matter = require('./src/base/matter.js')
import { App } from './App.js'

// 同時兼容H5模式和微信小游戲模式
const canvas = typeof wx == 'undefined' ? document.getElementById('app') : wx.createCanvas()
// H5網頁游戲模式
if (typeof wx == 'undefined') {
  canvas.width = 375
  canvas.height = 667
}
// 微信小游戲模式
else {
  window.Image = () => wx.createImage()
  window.Audio = () => wx.createInnerAudioContext()
}
// 初始化物理引擎
const engine = Matter.Engine.create({
  enableSleeping: true
})
const render = Matter.Render.create({
  canvas: canvas,
  engine: engine,
  options: {
    width: canvas.width,
    height: canvas.height,
    background: './res/background.png', // transparent
    wireframes: false,
    showAngleIndicator: false
  }
})
Matter.Engine.run(engine)
// Matter.Render.run(render)

// 初始化游戲
const physics = { Matter, engine, canvas, render }
new App(canvas, physics)

6、渲染物理物體元素

Border.js
當基礎物體渲染工作和物理引擎引入工作完成后,就可以開始利用物理引擎繪制我們需要的物理物體元素,在【彈一弾】游戲中,總共有三種物理物體,分別是墻體,彈球,方塊

在這以最簡單的墻體為例,其余比較復雜的彈球和方塊,代碼比較長,在此限于篇幅不展開,文末會有本項目開源的github地址,可以前往進一步了解

// 邊界
import { Body } from '../base/Body.js'

export class Border extends Body {
  constructor(physics) {
    super(physics)
  }

  draw() {
    const physics = this.physics
    let bottomHeight = 10
    let leftWidth = 10
    const borderBottom = physics.Matter.Bodies.rectangle(
      physics.canvas.width / 2, physics.canvas.height - bottomHeight / 2,
      physics.canvas.width - leftWidth * 2, bottomHeight, {
        isStatic: true,
        render: {
          visible: true
        }
      })
    const borderLeft = physics.Matter.Bodies.rectangle(
      leftWidth / 2, physics.canvas.height / 2,
      leftWidth, physics.canvas.height, {
        isStatic: true,
        render: {
          visible: true
        }
      })
    const borderRight = physics.Matter.Bodies.rectangle(
      physics.canvas.width - leftWidth / 2, physics.canvas.height / 2,
      leftWidth, physics.canvas.height, {
        isStatic: true,
        render: {
          visible: true
        }
      })
    physics.Matter.World.add(physics.engine.world, [borderBottom, borderLeft, borderRight])
  }

}

7、游戲完成,項目總覽

到此為止,整個【彈一弾】微信小游戲的制作就完成了,其實回首梳理整個流程,還算是流暢,也不復雜,但是很多時候萬事開頭難,在一開始我的確遇到了很多很多的問題,包括物理引擎的引入,游戲邏輯的合理安排,算是一些挑戰,所幸這些問題很多都解決了,也就有了此文

當然還有一些問題我至今還沒有完美解決,例如當球速過快引起的“穿墻”問題,這其實是Matter.js物理引擎的問題,在github上有關這一問題的討論,作者還建立了CCD算法分支嘗試解決,但是遺憾的是,截止文本完成時間,這一問題仍然沒有在Matter.js上得到解決,如果讀者們有解決思路的,也可以聯系我,不勝感激

另外,【彈一弾】整個游戲我目前為止完成了核心交互邏輯,但是比起微信上的彈一弾游戲,很多細節都還沒有做,例如美術風格的完善和彈球的回收,以及多樣的方塊和道具,這些以后如果有時間,我會進一步完善

我個人非常追求極簡和拓展,以下是【彈一弾】的工程目錄結構

├── App.js  彈一弾游戲入口
├── game.js  微信小游戲入口
├── game.json
├── project.config.json
├── res  資源集合
│   ├── background.png
│   ├── launch.mp3
│   ├── startbutton.png
│   └── xuemaojiao.mp3
└── src
    ├── Director.js  導演
    ├── base  精簡版游戲引擎
    │   ├── Body.js  物理物體元素基類
    │   ├── DataStore.js  全局狀態管理類
    │   ├── Resource.js  統一資源定義類
    │   ├── ResourceLoader.js  統一資源加載類
    │   ├── Sprite.js  普通物體渲染畫筆類
    │   └── matter.js  物理引擎
    ├── body  物理物體元素
    │   ├── Aim.js  準星瞄準
    │   ├── Block.js  障礙方塊
    │   ├── Border.js  邊界墻體
    └── sprite  普通物體元素
        ├── BackGround.js  游戲背景
        ├── Score.js  游戲分數
        └── StartButton.js  開始按鈕

后記

【彈一弾】的開發我選用了純canvas的方案,一方面適合微信小游戲平臺,一方面也能兼容H5網頁,同時性能良好,整個游戲的大小不超過1MB,可說是非常迷你,但是麻雀雖小五臟俱全
另外,因為沒有采用游戲引擎,而是搭建制作了一個精簡迷你的游戲開發框架,所以我也沒有采用微信官方的適配器weapp-adapter,一來可以節省53KB,二來可以提升代碼執行效率,讓快更快
當然,全文中我所描述制作的精簡版游戲引擎,其實比起目前主流的商業游戲引擎,只是冰山一角,目的只是為了讓更多初入門的玩家能對游戲引擎有個初步的概念。真正的商業游戲引擎例如Laya和Egret,功能十分強大,我后續也會出一篇文章,采用這類商業游戲引擎將【彈一弾】重做一遍
從現今微信小游戲的發展上我們可以展望,未來H5之類的純Web游戲很可能會占據游戲市場很大份額,使得游戲開發也徹底走向真正的跨平臺,熱更新,高性能

感謝你的閱讀,希望本文能夠給你帶來幫助:)

作者:CheneyXu
Github:wxgame-elastic
關于:XServer官網

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,663評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,125評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,506評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,614評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,402評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,934評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,021評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,168評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,690評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,596評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,288評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,027評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,404評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,662評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,398評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,743評論 2 370

推薦閱讀更多精彩內容