使用 MVP 架構和 Web Components(Omi) 開發貪吃蛇

簡介

事實上, 我使用了 react、preact、vue 和 omi 框架 分別開發貪吃蛇遊戲,我發現 omi 的開發體驗最棒,而且其最後的源代碼很是地簡潔,讓我一步一步帶着大家開發這款簡單的遊戲。css

閱讀本文大概花費 10 分鐘,你能夠從中學會:html

  • 前端領域驅動實戰
  • CSS rpx 單位的應用和原理
  • Omi 開發貪吃蛇遊戲的經驗
  • 理解 MVC、MVP 和 MVVM 架構
  • 使用 DOM 開發遊戲 (not canvas)
  • Omi store 體系
  • 遊戲主循環和局部降幀技巧
  • 遊戲定時器循環優化

領域模型設計

  • 提取主要實體,好比(蛇、遊戲)
  • 從實體名詞中總結出具體業務屬性方法,
      • 包含運動方向、body屬性
      • 包含移動和轉向方法
    • 遊戲
      • 包含結束暫停狀態、地圖、分數、幀率、遊戲主角、食物
      • 包含開始遊戲、暫停遊戲、結束遊戲、生產食物、重置遊戲等方法
  • 創建實體屬性方法之間的聯繫
    • 遊戲主角惟一,即蛇
    • 蛇吃食物,遊戲分數增長
    • 食物消失,遊戲負責再次生產食物
    • 蛇撞牆或撞自身,遊戲狀態結束
  • 核心循環設計
    • 判斷是否有食物,沒有就生產一個(低幀率)
    • 蛇與自身碰撞檢測
    • 蛇與障礙物碰撞檢測
    • 蛇與食物碰撞檢測
    • 蛇移動

Snake Class

class Snake {
  constructor() {
    this.body = [3, 1, 2, 1, 1, 1]
    this.dir = 'right'
  }

  move(eating) {
    const b = this.body
    if (!eating) {
      b.pop()
      b.pop()
    }

    switch (this.dir) {
      case 'up':
        b.unshift(b[0], b[1] - 1)
        break
      case 'right':
        b.unshift(b[0] + 1, b[1])
        break
      case 'down':
        b.unshift(b[0], b[1] + 1)
        break
      case 'left':
        b.unshift(b[0] - 1, b[1])
        break
    }
  }

  turnUp() {
    if (this.dir !== 'down')
      this.dir = 'up'
  }
  turnRight() {
    if (this.dir !== 'left')
      this.dir = 'right'
  }
  turnDown() {
    if (this.dir !== 'up')
      this.dir = 'down'
  }
  turnLeft() {
    if (this.dir !== 'right')
      this.dir = 'left'
  }
}
複製代碼

蛇的轉向有個邏輯,就是不能反方向後退,好比正在向上移動,不能直接直接向下轉向,因此在 turnUp,turnRight,turnDown,turnLeft 中都有對應的條件判斷。前端

Game Class

import Snake from './snake'

class Game {
  constructor() {
    this.map = []
    this.size = 16
    this.loop = null
    this.interval = 500
    this.paused = false
    this._preDate = Date.now()
    this.init()
  }

  init() {

    this.snake = new Snake

    for (let i = 0; i < this.size; i++) {
      const row = []
      for (let j = 0; j < this.size; j++) {
        row.push(0)
      }
      this.map.push(row)
    }
  }

  tick() {

    this.makeFood()
    const eating = this.eat()
    this.snake.move(eating)
    this.mark()

  }

  mark() {
    const map = this.map
    for (let i = 0; i < this.size; i++) {
      for (let j = 0; j < this.size; j++) {
        map[i][j] = 0
      }
    }

    for (let k = 0, len = this.snake.body.length; k < len; k += 2) {
      this.snake.body[k + 1] %= this.size
      this.snake.body[k] %= this.size

      if (this.snake.body[k + 1] < 0) this.snake.body[k + 1] += this.size
      if (this.snake.body[k] < 0) this.snake.body[k] += this.size
      map[this.snake.body[k + 1]][this.snake.body[k]] = 1
    }
    if (this.food) {
      map[this.food[1]][this.food[0]] = 1
    }
  }

  start() {
    this.loop = setInterval(() => {
      if (Date.now() - this._preDate > this.interval) {
        this._preDate = Date.now()
        if (!this.paused) {
          this.tick()
        }
      }
    }, 16)
  }

  stop() {
    clearInterval(this.loop)
  }

  pause() {
    this.paused = true
  }

  play() {
    this.paused = false
  }

  reset() {
    this.paused = false
    this.interval = 500
    this.snake.body = [3, 1, 2, 1, 1, 1]
    this.food = null
    this.snake.dir = 'right'
  }

  toggleSpeed() {
    this.interval === 500 ? (this.interval = 150) : (this.interval = 500)
  }

  makeFood() {
    if (!this.food) {
      this.food = [this._rd(0, this.size - 1), this._rd(0, this.size - 1)]
      for (let k = 0, len = this.snake.body.length; k < len; k += 2) {
        if (this.snake.body[k + 1] === this.food[1]
          && this.snake.body[k] === this.food[0]) {
          this.food = null
          this.makeFood()
          break
        }

      }
    }
  }

  eat() {
    for (let k = 0, len = this.snake.body.length; k < len; k += 2) {
      if (this.snake.body[k + 1] === this.food[1]
        && this.snake.body[k] === this.food[0]) {
        this.food = null
        return true
      }
    }
  }

  _rd(from, to) {
    return from + Math.floor(Math.random() * (to + 1))
  }
}
複製代碼

能夠看到上圖使用了 16*16 的二維數組來存儲蛇、食物、地圖信息。蛇和食物佔據的格子爲 1,其他爲 0。vue

[
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
]
複製代碼

因此上面表明了一條長度爲 5 的蛇和 1 個食物,你能在上圖中找到嗎?react

Game 面板渲染

import { define, rpx } from 'omi'

define('my-game', ['map'], _ => (
  <div class="game"> {_.store.data.map.map(row => { return <p> {row.map(col => { if (col) { return <b class='s'></b> } return <b></b> })} </p> })} </div>
), rpx(require('./_index.css')))
複製代碼

帶有 classs 的格式是黑色的,好比食物、蛇的身體,其他的會灰色底色。['map'] 的做用後面細講,['map'] 表明依賴 store.data.map,map 更新會自動更新視圖。git

Ctrl 和 Game 面板渲染

import { define, rpx } from 'omi'
import '../game'

define('my-index', ['paused'], ({store}) => (
  <div class="container"> <h1>OMI SNAKE</h1> <my-game></my-game> <div class="ctrl"> <div class="btn cm-btn cm-btn-dir up" onClick={store.turnUp}><i></i><em></em><span>Up</span></div> <div class="btn cm-btn cm-btn-dir down" onClick={store.turnDown}><i></i><em></em><span>Down</span></div> <div class="btn cm-btn cm-btn-dir left" onClick={store.turnLeft}><i></i><em></em><span >Left</span></div> <div class="btn cm-btn cm-btn-dir right" onClick={store.turnRight}><i></i><em></em><span >Right</span></div> <div class="btn cm-btn space" onClick={store.toggleSpeed}><i></i><span >加速/減速</span></div> <div class="btn reset small" onClick={store.reset}><i ></i><span >Reset</span></div> <div class="btn pp small" onClick={store.pauseOrPlay}><i></i><span >{store.data.paused ? 'Play' : 'Pause'}</span></div> </div> </div> ), rpx(require('./_index.css'))) 複製代碼

定義 Store

import Game from '../models/game'

const game = new Game
const { snake, map } = game

game.start()

class Store {
  data = {
    map,
    paused: false
  }

  turnUp() {
    snake.turnUp()
  }
  
  turnRight() {
    snake.turnRight()
  }

  turnDown() {
    snake.turnDown()
  }

  turnLeft() {
    snake.turnLeft()
  }

  pauseOrPlay = () => {
    if (game.paused) {
      game.play()
      this.data.paused = false
    } else {
      game.pause()
      this.data.paused = true
    }
  }
  
  reset() {
    game.reset()
  }

  toggleSpeed() {
    game.toggleSpeed()
  }
}

export default new Store
複製代碼

會發現, store 很薄,只負責中轉 View 的 action,到 Model,以及更改this.data.paused數據會自動更新 View,其中 this.data.map 的更改是在 model 中進行(game.js 的 tick 方法)。github

由於定義組件時聲明瞭依賴:web

define('my-index', ['paused'], ...
複製代碼
define('my-game', ['map'], ...
複製代碼

幀率控制

怎麼控制主幀率和局部幀率。通常狀況下,咱們認爲 60 FPS 是流暢的,因此咱們定時器間隔是有 16ms,核心循環裏的計算量越小,就越接近 60 FPS:json

this.loop = setInterval(() => {
  //
}, 16)
複製代碼

可是有些計算沒有必要 16 秒計算一次,這樣會下降幀率,因此能夠記錄上一次執行的時間用來控制幀率:canvas

this.loop = setInterval(() => {
  //執行在這裏是大約 60 FPS
  if (Date.now() - this._preDate > this.interval) {
    //執行在這裏是大約 1000/this.interval FPS
    this._preDate = Date.now()
    //暫停判斷
    if (!this.paused) {
      //核心循環邏輯
      this.tick()
    }
  }
}, 16)
複製代碼

你可使用基於 requestAnimationFrameraf-interval 來替代 setInterval,用於提升性能:

this.loop = setRafInterval(() => {
  //執行在這裏是大約 60 FPS
  if (Date.now() - this._preDate > this.interval) {
    //執行在這裏是大約 1000/this.interval FPS
    this._preDate = Date.now()
    //暫停判斷
    if (!this.paused) {
      //核心循環邏輯
      this.tick()
    }
  }
}, 16)
複製代碼

貪吃蛇目錄說明

├─ build            //web 編譯出的文件,用於生產環境
├─ config
├─ public
├─ scripts
├─ src
│  ├─ assets
│  ├─ components    //存放全部頁面的組件
│  ├─ models        //存放全部模型
│  ├─ stores        //存放頁面的 store
│  └─ index.js      //入口文件,會 build 成 index.html
複製代碼

那麼是 MVC、MVP 仍是 MVVM?

從貪吃蛇源碼能夠看出:視圖(components)和模型(models)是分離的,沒有相互依賴關係,可是在 MVC 中,視圖依賴模型,耦合度過高,致使視圖的可移植性大大下降,因此必定不是 MVC 架構。

在 MVP 模式中,視圖不直接依賴模型,由 Presenter 負責完成 Model 和 View 的交互。MVVM 和 MVP 的模式比較接近。ViewModel 擔任這 Presenter 的角色,而且提供 UI 視圖所須要的數據源,而不是直接讓 View 使用 Model 的數據源,這樣大大提升了 View 和 Model 的可移植性,好比一樣的 Model 切換使用 Flash、HTML、WPF 渲染,好比一樣 View 使用不一樣的 Model,只要 Model 和 ViewModel 映射好,View 能夠改動很小甚至不用改變。

從貪吃蛇源碼能夠看出,View(components) 裏直接使用了 Presenter(stores) 的 data 屬性進行渲染,data 屬性來自於 Model(models) 的屬性,並無出現 Model 到 ViewModel 的映射。因此必定不是 MVVM 架構。

因此上面的貪吃蛇屬於 MVP !只不過是進化版的 MVP,由於 M 裏的 map 的變動會自定更是 View,從 M->P->V的迴路是自動化的,代碼裏看不到任何邏輯。僅僅須要聲明依賴:

define('my-game', ['map'] ...
複製代碼

這樣也規避了 MVVM 最大的問題: M 到 VM 映射的開銷。

進化版 MVP 優點

一、複用性

Model 和 View 之間解耦,Model 或 View 中的一方發生變化,Presenter 接口不變,另外一方就不必對上述變化作出改變,那麼 Model 層的業務邏輯具備很好的靈活性和可重用性。

二、靈活性

Presenter 的 data 變動自動映射到視圖,使得 Presenter 很薄很薄,View 屬於被動視圖。並且基於 Presenter 的 data 可使用任何平臺、任何框架、任何技術進行渲染。

三、測試性

假如 View 和 Model 之間的緊耦合,在 Model 和 View 同時開發完成以前對其中一方進行測試是不可能的。出於一樣的緣由,對 View 或 Model 進行單元測試很困難。如今,MVP模式解決了全部的問題。MVP 模式中,View 和 Model 之間沒有直接依賴,開發者可以藉助模擬對象注入測試二者中的任一方。

CSS rpx unit

rpx(responsive pixel)最初來源於小程序的 wxss,可是知道其原理後也能夠用於 web。 rpx 能夠根據屏幕寬度進行自適應。規定屏幕寬爲750rpx。如在 iPhone6 上,屏幕寬度爲375px,共有750個物理像素,則750rpx = 375px = 750物理像素,1rpx = 0.5px = 1物理像素。

設備 rpx 轉 px (屏幕寬度/750) px 轉 rpx (750/屏幕寬度)
iPhone5 1rpx = 0.42px 1px = 2.34rpx
iPhone6 1rpx = 0.5px 1px = 2rpx
iPhone6 Plus 1rpx = 0.552px 1px = 1.81rpx

rpx 單元很是有利於前端開發的總體工做流程,由於設計人員的設計草圖是按照750的寬度設計的,因此前端頁面能夠直接使用草圖導出標尺進行 rpx 佈局。

rpx 原理

由於設備寬度只能在運行時知道,因此須要在運行時動態計算 rpx 到 px 的映射。

export function rpx(css) {
  return css.replace(/([1-9]\d*|0)(\.\d*)*rpx/g, (a, b) => {
    return (window.innerWidth * Number(b)) / 750 + 'px'
  })
}
複製代碼

兼容性

Omi HTML 輸出結構

從上圖能夠看到使用的 web components shadow dom 進行渲染,最新的兩個版本的現代瀏覽器都支持。Edge 和 Internet Explorer 11 須要引入 web components polyfills。

若是你要兼容 IE8+, 只要改 package.json 裏的一行代碼即可以:

"alias": {
    "omi": "omio"
  }
複製代碼

Omio - Omi for old browsers with same api of omi(IE8+)

Omio HTML 輸出結構

Links

相關文章
相關標籤/搜索