天天看點

開發一個Canvas小遊戲 實作一個遊戲“引擎”

前言

這個遊戲其實在三四年前就寫了,中間還重構過好幾次,之前都是用簡單的面向對象和函數式程式設計來寫,遊戲中的元素關系到還是分的挺開,但是遊戲的渲染,運算等邏輯分的不夠清晰,整個邏輯基本都是自頂向下的流水一樣,今年又抽空重構了一版,把一些事件處理、渲染包括動畫封裝成一個“引擎”,這樣再寫一個别的遊戲也隻用寫遊戲本身的邏輯。(以下實作全靠瞎捉摸,或許再遊戲開發領域有很多更進階的玩法,但是就這樣吧 )。

先上個遊戲線上位址吧​​https://snowball.jaceyi.com/​​​ ,右上角可以設定遊戲操作方式,預設是拖拽模式,手指按下并移動小球會往手指移動的方向移動;還有個反向模式是手指按下小球就會朝目前移動方向的反方向轉動。服務用的是 Google 的 Firebase 在國外,通路或許會有點慢。

渲染邏輯

開發一個遊戲,渲染肯定是重中之重,就先來談一談渲染邏輯的實作。首先呢這是一個 2D 遊戲,那麼渲染自然也隻用考慮 2D 就好了,當然最主要的原因肯定是簡單。下面邏輯的描述就都寫在代碼的注釋裡了

渲染器 Renderer

// EntityRenderMap 是維護了一個個的實體的渲染方法,實體是什麼呢?舉個例子就是這個遊戲中的一顆樹、一個小球、或者是 RPG 遊戲中的一個人物。
interface RendererProps {
  entityRenderMap?: EntityRenderMap;
  style?: Partial<CSSStyleDeclaration>;
}

export class Renderer {
  dom!: HTMLCanvasElement;
  ctx!: CanvasRenderingContext2D;
  width: number = 0;
  height: number = 0;
  actualWidth: number = 0; // Canvas 實際寬度,下文有描述
  actualHeight: number = 0;
  entityRenderMap: EntityRenderMap = entityRenderMap;

  constructor(props?: RendererProps) {
    // 建立一個渲染器就是建立一個 Canvas
    const dom = document.createElement('canvas');
    Object.assign(this, {
      dom,
      ctx: dom.getContext('2d')
    });

    if (props) {
      const { entityRenderMap, style } = props;
      if (entityRenderMap) {
        // 建立渲染器時指定每一個實體的渲染方法,再與預設内部提供的一些實體渲染方法做合并
        entityRenderMap.forEach((render, key) => {
          this.entityRenderMap.set(key, render);
        });
      }
      if (style) {
        this.setStyle(style);
      }
    }
  }

  setStyle(style: Partial<CSSStyleDeclaration>) {
    for (const key in style) {
      if (style.hasOwnProperty(key)) {
        this.dom.style[key] = style[key] as string;
      }
    }
  }

  visible = true;
  setVisible(visible: boolean) {
    // 指定該渲染器是否可見,一個遊戲可能存在多個渲染器,可以将遊戲界面和UI界面具體的遊戲畫面區分開來
    this.visible = visible;
    this.setStyle({ visibility: visible ? 'visible' : 'hidden' });
  }

  penetrate = false;
  setPenetrate(penetrate: boolean) {
    // 綁定渲染器穿透事件,應用場景:我這個遊戲在玩的時候分數屬于UI渲染器,但是處于遊戲渲染器的上面,綁定樣式使其可以事件穿透到遊戲的界面。
    this.penetrate = penetrate;
    this.setStyle({ pointerEvents: penetrate ? 'none' : 'auto' });
  }

  setSize(width: number, height: number) {
    const { dom } = this;
    dom.style.width = width + 'px';
    dom.style.height = height + 'px';

    /**
     * 設定這個 Canvas 的樣式大小沒得說的
     * 但是這裡有個 getActualPixel 方法,這個方法是封裝的,可以拿到目前螢幕的實際像素
     * 例如有的螢幕是 2K、4K 的,那麼要畫一個 100px*100px 的正方形在 2K 螢幕上就需要畫成 200px*200px。
     * */
    const actualWidth = getActualPixel(width);
    const actualHeight = getActualPixel(height);
    dom.width = actualWidth;
    dom.height = actualHeight;
    Object.assign(this, {
      width,
      height,
      actualWidth,
      actualHeight
    });
  }

  translateX: number = 0;
  translateY: number = 0;
  translate(x: number, y: number) {
    // 畫布偏移:在我這個遊戲中 小球在一直的往下走,但是要保證小球還能在螢幕的中間可見區域,那麼就給畫布做一個 Y 軸的負偏移。
    this.translateX += x;
    this.translateY += y;
    this.ctx.translate(getActualPixel(x), getActualPixel(y));
  }

  resetTranslate() {
    // 重置畫布偏移
    this.translateX = 0;
    this.translateY = 0;
    this.ctx.setTransform(1, 0, 0, 1, 0, 0);
  }

  /**
   * 渲染邏輯
   * scene 場景:場景内包含整個界面内的實體
   * camera 照相機:定義真正所能看到的區域。之前有學過一段時間的 3DMax 它裡面就有照相機的概念,實際給使用者所看到的場景就是照相機所看到的範圍。
   * 渲染器、照相機、場景 這三個是要配合在一起使用,渲染出照相機範圍内的場景(一個個的實體)。
   * */
  render(scene: Scene, camera: Camera) {
    const {
      ctx,
      entityRenderMap,
      actualWidth,
      actualHeight,
      translateX,
      translateY
    } = this;

    {
      // 每次繪制新的畫面之前要清除上一次繪制的畫面
      const renderX = getActualPixel(0 - translateX);
      const renderY = getActualPixel(0 - translateY);
      ctx.clearRect(
        renderX,
        renderY,
        renderX + actualWidth,
        renderY + actualHeight
      );
    }

    {
      // 繪制照相機區域 參考方法:https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/clip
      const { left, top, width, height } = camera;
      ctx.beginPath(); // 路徑開始
      ctx.rect(
        getActualPixel(left),
        getActualPixel(top),
        getActualPixel(width),
        getActualPixel(height)
      );
      ctx.clip(); // 畫一個正方形的區域用來限制之後所有的元素都隻會在正方形範圍内顯示
    }

    {
      // 繪制場景中的每一個 entity
      scene.entityMap.forEach(entity => {
        if (!entity.visible) return; // 實體不可見不繪制
        ctx.beginPath(); // 每一個實體繪制前開啟新的路徑
        if (entity.render) {
          // 實體有自帶的渲染方法
          entity.render(ctx);
        } else {
          const render = entityRenderMap.get(entity.type);
          // 擷取該實體類型配置的渲染方法
          if (render) {
            render.call(entity, ctx, entity);
          } else {
            console.warn(`The ${entity.id} Entity requires a render method!`);
          }
        }
      });
    }
  }
}      

在這裡我将渲染器 ​

​Renderer ​

​​的概念定義為一個 ​

​Renderer ​

​​就是一個 ​

​canvas​

​,一個遊戲可能有多個 Canvas 共同組成,一個渲染器對應了一個 照相機 ​

​Camera​

​ 和一個 場景 ​

​Scene ​

​​,當然遊戲開發中一個 ​

​Renderer ​

​​對應多個 ​

​Camera​

​ 也是比較常見的操作,隻不過我這裡想了想我的是2D遊戲,不存在一個畫面多個視角看的情況,是以就定義了一對一的概念;場景 ​

​Scene ​

​是一個虛拟的概念,就相當于是很多個 實體​

​ Entity​

​ 的合集,就例如由山、水、人、樹組成了一幅畫。

照相機 Camera

interface CameraConfig {
  left?: number;
  top?: number;
  width?: number;
  height?: number;
}

export class Camera {
  left: number = 0;
  top: number = 0;
  width: number = 0;
  height: number = 0;

  constructor(config: CameraConfig | Renderer) {
    if (config instanceof Renderer) {
      // 如果傳入的為 Renderer 執行個體,則相機自動追蹤 Render 區域
      this.traceRenderer(config);
      this.observerRenderer = config;
    } else {
      this.update(config);
    }
  }

  // 更新照相機的配置
  update(config: CameraConfig): Camera {
    Object.assign(this, config);
    return this;
  }

  observerRenderer: Renderer | undefined;
  // 追蹤 Render 渲染的位置與大小,用于自動繪制出全屏的畫面
  traceRenderer(renderer: Renderer): Camera {
    const { translateY, translateX, actualWidth, actualHeight } = renderer;
    Object.assign(this, {
      top: -translateY,
      left: -translateX,
      width: actualWidth,
      height: actualHeight,
      renderer
    });

    // 使用 Object.defineProperty 封住哪個的方法,用來追蹤相機位置與大小
    observerSet(renderer, 'translateY', value => {
      this.top = -value;
    });
    observerSet(renderer, 'translateX', value => {
      this.left = -value;
    });
    observerSet(renderer, 'actualWidth', value => {
      this.width = value;
    });
    observerSet(renderer, 'actualHeight', value => {
      this.height = value;
    });

    return this;
  }

  // 取消對 Render 的追蹤
  clearTraceRenderer() {
    const { observerRenderer } = this;
    if (!observerRenderer) return;
    const keys: (keyof Renderer)[] = [
      'translateY',
      'translateX',
      'width',
      'height'
    ];
    keys.forEach(key => clearObserverSet(observerRenderer, key));
  }
}      

場景 Scene & 實體 Entity

上文有提到 場景 ​

​Scene​

​ 是一個虛拟的概念,就相當于是很多個 實體 ​

​Entity​

​ 的合集,是以我們先來看看 Entity 具體是什麼樣子

export type EntityType = Keys<CanvasRenderingContext2D> | string;

export interface EntityRender<T extends Entity = any> {
  (ctx: CanvasRenderingContext2D, entity: T): void;
}

interface EntityConfig {
  [key: string]: any;
}

// Entity 可以被其他類繼承使用再生成執行個體,也可以直接調用 Entity.create 方法進行建立執行個體
export class Entity<T extends EntityConfig = {}> {
  id: string;
  config: T = {} as T;

  constructor(public type: EntityType, config?: Partial<T>) {
    this.id = type + '-' + utils.getRandomId(); // 随機生成一個ID
    config && this.mergeConfig(config);
  }

  // 更新實體的 config
  mergeConfig(config: Partial<T>) {
    Object.assign(this.config, config);
    return this;
  }

  // 設定該實體是否可見,渲染的時候會忽略不可見的實體
  visible: boolean = true;
  setVisible(visible: boolean) {
    this.visible = visible;
  }

  // 定義實體渲染的方法
  render?(ctx: CanvasRenderingContext2D): void;
}      

實體的使用方式又兩種,考慮到部分實體隻具備展示效果(屬性)不具備動作(方法),是以可以使用 ​

​new Entity(config) ​

​傳入實體渲染所需要的資訊,後續也隻需要更新這些配置便可。

// 建立一個分數實體
const scoreEntity = new Entity('score', {
  count: 0,
  left: 10,
  top: 20
});

// 更新分數時
scoreEntity.mergeConfig({
  count: 2
})      

實體的第二種使用方法是繼承​

​Entity​

​類,使其上面包含基礎的實體屬性方法還可以擴充一些額外的屬性、事件等。

// 建立一個雪球實體
class SnowBall extends Entity {
  config = {}; // 一些 config
  constructor(config) {
    super('snowball');
    this.mergeConfig(config);
  }

  move() {} // 移動雪球實體

  render() {} // 定義雪球實體如何渲染
}

const snowBall = new SnowBall({}); // 建構雪球執行個體      

接下來看看 場景 ​

​Scene​

​​ 吧,場景其實就稍微對​

​Map​

​封裝一下。

type EntityMap = Map<string, Entity>;

export class Scene {
  entityMap: EntityMap = new Map(); // 場景内實體的合集

  // 給場景内添加實體
  add<T extends Entity>(entity: T): T {
    this.entityMap.set(entity.id, entity);
    return entity;
  }

  // 清空場景
  clear() {
    this.entityMap = new Map();
  }

  remove(id: string) { // 從場景内删除實體
    this.entityMap.delete(id);
  }
}      

動畫

一個遊戲動畫也是必不可少的,在前端 Canvas 裡面其實不存在動畫這個概念,它就是繪制一張圖檔,我們隻需将每次繪制的圖檔裡面的元素位置做一些調整,那麼快速的繪制多張就會形成一個動畫的效果。這種場景在JS中我們一般會想到 ​

​setInterval​

​​, ​

​setTimeout​

​​ 等;實際再寫遊戲、動畫的時候都是用到 ​

​requestAnimationFrame ​

​這個API的,這裡淺淺的講一下他們的差別。

setInterval 與 setTimeout

這兩個的概念其實是差不多的,都是浏覽器JS引擎提供的方法,無非就是用 ​

​setTimeout​

​​ 要做一個遞歸邏輯。JS引擎是單線程的,在使用這些異步方法的時候會将其添加至一個隊列當中,等待主任務執行完成後再來執行這些異步任務就有可能造成一個延遲執行,達到的效果比預期的要慢,不過這個不是主要的問題,主要的問題是渲染不同步,例如目前顯示器重新整理率是每隔100毫秒重新整理一下,​

​setInterval​

​ 設定的是50毫秒繪制一下,這兩個不同步就會導緻有的時候JS繪制了最新的效果,但是顯示器還沒重新整理。然後再顯示器下次重新整理時候,已經累加了幾次的JS繪制就會出現跳幀,卡頓現象。

requestAnimationFrame

​requestAnimationFrame​

​ 會把每一幀中的所有DOM操作集中起來,在一次重繪或回流中就完成,而且重繪或回流的時間是跟着顯示器的重新整理率來的,這樣無論在高刷還是低刷的螢幕上都能有很好的體驗。

interface Callback {
  (timestamp: number): boolean | unknown;
}

interface AnimationEvent {
  (animation: Animation): void;
}

type AnimationEvents = Array<[AnimationEvent, number]>;

export class Animation {
  constructor(public callback: Callback) {}

  timer: number = 0;
  status: 'animation' | 'stationary' = 'stationary';
  startTime: number = 0;
  prevTime: number = 0;
  start(timeout?: number) {
    this.status = 'animation';
    this.startTime = 0;
    const animation = (timestamp: number) => {
      let { startTime } = this;
      if (startTime === 0) {
        startTime = timestamp;
        this.startTime = startTime;
      }
      if (typeof timeout === 'number' && timestamp - startTime > timeout) {
        return this.stop(); // 如果傳入了逾時時間 則動畫就會再執行一段時間後停止
      }

      {
        const { evnets, prevTime } = this;
        const millisecond = timestamp - startTime;
        const prevMillisecond = prevTime - startTime;

        // evnets 維護了一個事件隊列 可以設定每隔多長時間執行一次事件
        for (const [event, stepMillisecond] of evnets) {
          const step = Math.floor(millisecond / stepMillisecond);
          const prevStep = Math.floor(prevMillisecond / stepMillisecond);
          if (step !== prevStep) {
            event(this);
          }
        }
      }

      const keep = this.callback(timestamp); // 如果回調函數傳回了 false 則表示要停止動畫
      if (keep === false) {
        return this.stop();
      }
      this.prevTime = timestamp;
      this.timer = window.requestAnimationFrame(animation);
    };
    this.timer = window.requestAnimationFrame(animation);
  }

  stop() {
    this.status = 'stationary';
    window.cancelAnimationFrame(this.timer);
  }

  evnets: AnimationEvents = [];
  /**
   * @description 增加事件,讓動畫執行時每隔多少毫秒執行一次事件
   * @param event 事件
   * @param millisecond 毫秒
   */
  bind(event: AnimationEvent, millisecond: number) {
    this.evnets.push([event, millisecond]);
  }

  // 移除事件
  remove(event: AnimationEvent) {
    const index = this.evnets.findIndex(e => e[0] === event);
    if (index >= 0) {
      this.evnets.splice(index, 1);
    }
  }
}      

事件

這裡還封裝了一個事件,主要是針對移動端和PC端的融合,現階段支援了三個事件,分别是滑鼠按下、滑鼠擡起、和點選,對應到手機就是手指的操作,後續還可以将 ​

​mousemove​

​​ 和 ​

​touchmove​

​ 也做一個合并。(2022.2.8 更新 move 事件也加上了 )

type TMEventType = 'touchStart' | 'touchMove' | 'touchEnd' | 'tap';

interface TMJoinEventOption<T extends TMEventType> {
  type: T;
  pointX: number;
  pointY: number;
  originEvent: any;
}

export interface TMJoinEvent<T extends TMEventType = any> {
  (e: TMJoinEventOption<T>): void;
}

interface IEventListener {
  touchStart: TMJoinEvent<'touchStart'>[];
  touchMove: TMJoinEvent<'touchMove'>[];
  touchEnd: TMJoinEvent<'touchEnd'>[];
  tap: TMJoinEvent<'tap'>[];
}

/**
 * Touch Mouse Event
 * 合并了 PC 及移動端的事件,實作了類似于 click 的 tap 事件。
 */
export class TMEvent {
  constructor(public dom: HTMLCanvasElement) {
    dom.addEventListener('touchstart', this.dispatchTouchEvent('touchStart'));
    dom.addEventListener('touchmove', this.dispatchTouchEvent('touchMove'));
    dom.addEventListener('touchend', this.dispatchTouchEvent('touchEnd'));
    dom.addEventListener('mousedown', this.dispatchMouseEvent('touchStart'));
    dom.addEventListener('mousemove', this.dispatchMouseEvent('touchMove'));
    dom.addEventListener('mouseup', this.dispatchMouseEvent('touchEnd'));
  }

  dispatchMouseEvent(type: TMEventType) {
    return (e: MouseEvent) => {
      const rect = this.dom.getBoundingClientRect();

      const listeners = this._listeners[type] as TMJoinEvent<TMEventType>[];
      const eventOption: TMJoinEventOption<TMEventType> = {
        type,
        pointX: e.clientX - rect.left,
        pointY: e.clientY - rect.top,
        originEvent: e
      };
      listeners.forEach(event => {
        event(eventOption);
      });

      this.bindTapEvent(type, eventOption);
    };
  }

  dispatchTouchEvent(type: TMEventType) {
    return (e: TouchEvent) => {
      e.preventDefault();

      const firstTouch = e.changedTouches[0];
      if (!firstTouch) return;
      const rect = this.dom.getBoundingClientRect();

      const listeners = this._listeners[type] as TMJoinEvent<TMEventType>[];
      const eventOption: TMJoinEventOption<TMEventType> = {
        type,
        pointX: firstTouch.pageX - rect.left,
        pointY: firstTouch.pageY - rect.top,
        originEvent: e
      };
      listeners.forEach(event => {
        event(eventOption);
      });

      this.bindTapEvent(type, eventOption);
    };
  }

  tapStartTime: number = 0;
  bindTapEvent(type: TMEventType, eventOption: TMJoinEventOption<TMEventType>) {
    const currentTime = new Date().getTime();
    if (this.tapStartTime && currentTime - this.tapStartTime < 500) {
      // 500 毫秒内 表示點選事件
      this.dispatchTapEvent('tap', {
        ...eventOption,
        type: 'tap'
      });
      this.tapStartTime = 0;
    }

    if (type === 'touchStart') {
      this.tapStartTime = currentTime;
    }
  }

  dispatchTapEvent(type: 'tap', eventOption: TMJoinEventOption<'tap'>) {
    const listeners = this._listeners[type] as TMJoinEvent<'tap'>[];
    listeners.forEach(event => {
      event(eventOption);
    });
  }

  _listeners: IEventListener = {
    touchStart: [],
    touchMove: [],
    touchEnd: [],
    tap: []
  };

  add(eventName: TMEventType, event: TMJoinEvent<TMEventType>) {
    this._listeners[eventName].push(event);
  }

  remove(eventName: TMEventType, event: TMJoinEvent<TMEventType>) {
    const index = this._listeners[eventName].findIndex(item => item === event);
    delete this._listeners[eventName][index];
  }
}      

結語

這個“引擎”呢其實就是一些簡單的封裝,渲染器 Renderer 是将 Canvas 對象進行封裝,并提供了一些更便捷的方法,具體怎麼渲染元素這些也沒做處理;照相機 Camera 其實就是一個虛拟的概念,描述了一個正方形的大小寬高,然後讓渲染的時候隻渲染這個正方形内的内容;實體 Entity 是将遊戲裡面存着的元素用面向對象的方式來規範了一遍。場景 Scene 就是一些 實體 Entity 的集合。

繼續閱讀