preface

It has been a long time since I wrote an article, and I feel that there is no need to write a large number of hooks on the Internet. I wanted to get ahooks library, but now I find that it is getting more and more perfect, and there is no need to get them. I didn’t realize there was an article left to finish until December 31).

The game actually wrote in three or four years ago, the intermediate refactoring it several times, before is done with a simple object-oriented and functional programming to write, game elements in relation to or points very open, but the rendering of the game, operations such as logic points is not clear, the entire logical basic it is top-down like water, and take time to reconstruct a version this year, Encapsulate some of the event handling, rendering, and animation into an “engine” so that writing another game can only be about the logic of the game itself. (The implementation below is a matter of guesswork, and there may be more advanced gameplay in game development, but there you go 🥺).

Snowball.jacey.enter a new online address for the game. In the upper right corner, you can set the operation mode of the game. The default is single finger mode, which means the ball will rotate in the opposite direction of the current movement. The service uses Google’s Firebase and may be a bit slow to access abroad.

Rendering logic

Development of a game, rendering is certainly the most important, first to talk about the implementation of rendering logic. First of all, this is a 2D game, so render only 2D, of course, the main reason is simplicity. The following logic is described in the comments of the code

The Renderer Renderer

// EntityRenderMap is a rendering method that maintains one entity after another. What are entities? An example would be a tree in this game, a small ball, or a character in an RPG.
interfaceRendererProps { entityRenderMap? : EntityRenderMap; style? : Partial<CSSStyleDeclaration>; }export class Renderer { dom! : HTMLCanvasElement; ctx! : CanvasRenderingContext2D; width:number = 0;
  height: number = 0;
  actualWidth: number = 0; // The actual Canvas width is described below
  actualHeight: number = 0;
  entityRenderMap: EntityRenderMap = entityRenderMap;

  constructor(props? : RendererProps) {
    // Creating a renderer is creating a Canvas
    const dom = document.createElement('canvas');
    Object.assign(this, {
      dom,
      ctx: dom.getContext('2d')});if (props) {
      const { entityRenderMap, style } = props;
      if (entityRenderMap) {
        // Specify the rendering method for each entity when creating the renderer, and then merge it with some of the entity rendering methods provided internally by default
        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) {
    // Specify whether the renderer is visible. A game can have multiple renderers to distinguish the game interface from the UI interface
    this.visible = visible;
    this.setStyle({ visibility: visible ? 'visible' : 'hidden' });
  }

  penetrate = false;
  setPenetrate(penetrate: boolean) {
    // Bind renderer penetration events, application scenario: my game is played with scores that belong to the UI renderer, but are above the game renderer, and bind styles that allow events to penetrate into the game interface.
    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';

    /** * There is no argument for setting the Canvas's style size * but there is a getActualPixel method, which is wrapped to get the actual pixels of the current screen * for example, some screens are 2K, 4K, So to draw a 100px by 100px square on a 2K screen you need to draw it 200px by 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) {
    // Canvas offset: In my game the ball keeps moving down, but to make sure the ball is still visible in the middle of the screen, give the canvas a negative y-offset.
    this.translateX += x;
    this.translateY += y;
    this.ctx.translate(getActualPixel(x), getActualPixel(y));
  }

  resetTranslate() {
    // Reset the canvas offset
    this.translateX = 0;
    this.translateY = 0;
    this.ctx.setTransform(1.0.0.1.0.0);
  }

  /** * Render logic * scene: the scene contains the entities of the entire interface * camera: Defines the area that is really visible. 3DMax, which I have learned for some time, has the concept of a camera in it, and the actual scene shown to the user is the range seen by the camera. * Renderer, camera and scene are used together to render the scene (entities) within the scope of the camera. * * /
  render(scene: Scene, camera: Camera) {
    const {
      ctx,
      entityRenderMap,
      actualWidth,
      actualHeight,
      translateX,
      translateY
    } = this;

    {
      // Each time a new screen is drawn, the previous screen is cleared
      const renderX = getActualPixel(0 - translateX);
      const renderY = getActualPixel(0 - translateY);
      ctx.clearRect(
        renderX,
        renderY,
        renderX + actualWidth,
        renderY + actualHeight
      );
    }

    {
      / / draw the camera area reference methods: https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/clip
      const { left, top, width, height } = camera;
      ctx.beginPath(); // The path starts
      ctx.rect(
        getActualPixel(left),
        getActualPixel(top),
        getActualPixel(width),
        getActualPixel(height)
      );
      ctx.clip(); // Draw a square area to restrict all elements to be displayed within the square
    }

    {
      // Draw each entity in the scene
      scene.entityMap.forEach(entity= > {
        if(! entity.visible)return; // Entities are not visible and are not drawn
        ctx.beginPath(); // Open a new path before each entity is drawn
        if (entity.render) {
          // Entities have their own rendering methods
          entity.render(ctx);
        } else {
          const render = entityRenderMap.get(entity.type);
          // Get the rendering method for the configuration of the entity type
          if (render) {
            render.call(entity, ctx, entity);
          } else {
            console.warn(`The ${entity.id}Entity requires a render method! `); }}}); }}}Copy the code

Here, I define the concept of Renderer as a canvas. A Renderer may be composed of multiple canvas, and a Renderer corresponds to a Camera and a Scene. Of course, in game development, it is common for one Renderer to correspond to multiple cameras. However, I have thought about my 2D game, and there is no case that one screen can be viewed from multiple angles, so I defined the concept of one-to-one. Scene Scene is a virtual concept, which is equivalent to a collection of many entities. For example, a picture composed of mountains, water, people and trees.

Camera Camera

interfaceCameraConfig { 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) {
      // If the Renderer instance is passed in, the camera automatically tracks the Render field
      this.traceRenderer(config);
      this.observerRenderer = config;
    } else {
      this.update(config); }}// Update the camera configuration
  update(config: CameraConfig): Camera {
    Object.assign(this, config);
    return this;
  }

  observerRenderer: Renderer | undefined;
  // Track the position and size of the Render to automatically Render the full screen
  traceRenderer(renderer: Renderer): Camera {
    const { translateY, translateX, actualWidth, actualHeight } = renderer;
    Object.assign(this, {
      top: -translateY,
      left: -translateX,
      width: actualWidth,
      height: actualHeight,
      renderer
    });

    // Enclose which method with object.defineProperty to track camera position and size
    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;
  }

  // Untrace Render
  clearTraceRenderer() {
    const { observerRenderer } = this;
    if(! observerRenderer)return;
    const keys: (keyof Renderer)[] = [
      'translateY'.'translateX'.'width'.'height'
    ];
    keys.forEach(key= >clearObserverSet(observerRenderer, key)); }}Copy the code

Scene & Entity

As mentioned above, Scene is a virtual concept, which is equivalent to a collection of many entities. So let’s first look at what Entity looks like in detail

export type EntityType = Keys<CanvasRenderingContext2D> | string;

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

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

// An Entity can be used by other classes to regenerate an instance, or an instance can be created by calling entity.create directly
export class Entity<T extends EntityConfig = {} > {id: string;
  config: T = {} as T;

  constructor(public type: EntityType, config? : Partial<T>) {
    this.id = type + The '-' + utils.getRandomId(); // Randomly generate an ID
    config && this.mergeConfig(config);
  }

  // Update the entity config
  mergeConfig(config: Partial<T>) {
    Object.assign(this.config, config);
    return this;
  }

  // Set whether the entity is visible. Invisible entities are ignored during rendering
  visible: boolean = true;
  setVisible(visible: boolean) {
    this.visible = visible;
  }

  // Define the entity rendering methodrender? (ctx: CanvasRenderingContext2D):void;
}
Copy the code

There are two ways to use an Entity. Considering that some entities only have display effects (attributes) but no actions (methods), you can use new Entity(config) to pass in the information needed for the rendering of the Entity, and then you only need to update these configurations.

// Create a fraction entity
const scoreEntity = new Entity('score', {
  count: 0.left: 10.top: 20
});

// Update the score
scoreEntity.mergeConfig({
  count: 2
})
Copy the code

The second way to use entities is to inherit the Entity class to include the base Entity attribute methods and extend additional attributes, events, and so on.

// Create a snowball entity
class SnowBall extends Entity {
  config = {}; / / some config
  constructor(config) {
    super('snowball');
    this.mergeConfig(config);
  }

  move() {} // Move the snowball entity

  render() {} // Define how to render snowball entities
}

const snowBall = new SnowBall({}); // Build a snowball instance
Copy the code

Let’s take a look at the Scene, which encapsulates the Map a little bit.

type EntityMap = Map<string, Entity>;

export class Scene {
  entityMap: EntityMap = new Map(a);// A collection of entities in the scene

  // Add entities to the scene
  add<T extends Entity>(entity: T): T {
    this.entityMap.set(entity.id, entity);
    return entity;
  }

  // Clear the scene
  clear() {
    this.entityMap = new Map(a); }remove(id: string) { // Delete entities from the scene
    this.entityMap.delete(id); }}Copy the code

animation

A game animation is also essential. In the front Canvas, there is no such concept as animation, which is to draw a picture. All we need to do is to adjust the position of elements in each picture. In JS we usually think of setInterval, setTimeout, etc.; The requestAnimationFrame API is used when writing games and animations. Here is a brief description of the difference.

SetInterval and setTimeout

In fact, the two concepts are similar, both methods are provided by the browser JS engine, nothing more than using setTimeout to do a recursive logic. JS engine is single-threaded, when using the asynchronous method will add it to a queue, wait for after the completion of the main tasks to perform these asynchronous task is likely to cause a delay, achieve the effect of slower than expected, but this is not the main problem, the main problem is rendering out of sync, For example, the current display refresh rate is set to refresh every 100 milliseconds and setInterval is set to draw every 50 milliseconds. This mismatch may cause JS to draw the latest effects, but the display has not been refreshed. Then display the next time to refresh, has accumulated several times JS drawing will appear frame hopping, stalling phenomenon.

requestAnimationFrame

RequestAnimationFrame brings together all DOM operations in each frame in a single redraw or reflow that is dependent on the monitor’s refresh rate, making it a good experience on both high and low brush screens.

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(); // If a timeout is passed, the animation will be executed for another time before stopping
      }

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

        // Evnets maintain an event queue and can set how often events are executed
        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); // If the callback returns false, the animation is stopped
      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 Add an event to the number of milliseconds in which the animation is executed *@param The event event *@param Millisecond milliseconds * /
  bind(event: AnimationEvent, millisecond: number) {
    this.evnets.push([event, millisecond]);
  }

  // Remove the event
  remove(event: AnimationEvent) {
    const index = this.evnets.findIndex(e= > e[0] === event);
    if (index >= 0) {
      this.evnets.splice(index, 1); }}}Copy the code

The event

It also encapsulates an event, which is mainly for the fusion of mobile and PC terminals. At the present stage, three events are supported, namely, mouse press, mouse lift and click, which are finger operations corresponding to mobile phones. In the future, mousemove and Touchmove can also be combined.

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

type TTMEvent = TouchEvent | MouseEvent;

interface ITouchEventOption<T> {
  type: TMEventType;
  pointX: number;
  pointY: number;
  originEvent: T;
}
interface ITouchEvent<T> {
  (e: ITouchEventOption<T>): void;
}
type TMJoinEvent = ITouchEvent<TouchEvent> | ITouchEvent<MouseEvent>;

interface IEventListener {
  touchStart: TMJoinEvent[];
  touchEnd: TMJoinEvent[];
  tap: TMJoinEvent[];
}

/** * Touch Mouse Event * combines PC and mobile events to implement tap events similar to click. * /
export class TMEvent {
  constructor(public dom: HTMLCanvasElement) {
    dom.addEventListener('touchstart'.this.dispatchTouchEvent('touchStart'));
    dom.addEventListener('touchend'.this.dispatchTouchEvent('touchEnd'));
    dom.addEventListener('mousedown'.this.dispatchMouseEvent('touchStart'));
    dom.addEventListener('mouseup'.this.dispatchMouseEvent('touchEnd'));
  }

  dispatchMouseEvent(type: TMEventType) {
    return (e: MouseEvent) = > {
      const rect = this.dom.getBoundingClientRect(); // Gets the size of the element and its position relative to the viewport

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

      this.bindTapEvent<MouseEvent>(type, eventOption);
    };
  }

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

      const firstTouch = e.changedTouches[0]; // There may be multiple finger presses on the mobile terminal, so the first one is used here
      if(! firstTouch)return;
      const rect = this.dom.getBoundingClientRect();

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

      this.bindTapEvent<TouchEvent>(type, eventOption);
    };
  }

  tapStartTime: number = 0;
  bindTapEvent<T extends TTMEvent>(
    type: TMEventType,
    eventOption: ITouchEventOption<T>
  ) {
    const currentTime = new Date().getTime();
    if (this.tapStartTime && currentTime - this.tapStartTime < 500) {
      // Click events within 500 milliseconds
      this.dispatchTapEvent<T>('tap', eventOption);
      this.tapStartTime = 0;
    }

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

  dispatchTapEvent<T extends TTMEvent>(
    type: TMEventType,
    eventOption: ITouchEventOption<T>
  ) {
    const listeners = this._listeners[type] as ITouchEvent<T>[];
    listeners.forEach(event= > {
      event(eventOption);
    });
  }

  _listeners: IEventListener = {
    touchStart: [].touchEnd: [].tap: []};// Add events
  add(eventName: 'touchStart'.event: ITouchEvent<TouchEvent>): void;
  add(eventName: 'touchEnd'.event: ITouchEvent<TouchEvent>): void;
  add(eventName: 'tap'.event: ITouchEvent<TouchEvent>): void;
  add(eventName: TMEventType, event: TMJoinEvent) {
    this._listeners[eventName].push(event);
  }

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

conclusion

This article mainly focuses on the implementation of the “engine”, without any practical application. There will be another article on how to use the “engine” to develop a small Canvas game. The design of the “engine” is basically based on their own ideas to achieve, maybe there are some deficiencies, or the design is not reasonable, also welcome to comment on some suggestions ~. There is not much content on Canvas in this article. More usage of Canvas will be described in detail in the next article.

Github has the complete code of the entire game and engine. If you want to know more about Canvas, you can have a look.

The next article will be written as soon as possible, and we will see if it can be done before the Lunar New Year. If it can’t be done, judging from the current epidemic situation in Xi ‘an, it is likely that I will not be able to go back for the Spring Festival this year. If I haven’t finished writing the article, I may stay in Shanghai to write the article during the Spring Festival.

2022 is only an hour away. Happy New Year to you all in advance!

This year is not zero output