С примером рендеринга игровой сцены

Photo by Rodion Kutsaev on Unsplash
Photo by Rodion Kutsaev on Unsplash

Возможность TypeScript определять поведение, используя несколько интерфейсов - очень мощная штука. Эта возможность предоставляет абстракцию, взаимодействие только через интерфейсы без использования классов.

Способность реализовывать несколько интерфесов решает некоторые сложности наследования, которые возникали с использованием обычных классов.

Интерфейсы также определяют полиморфизм, дают возможность различным классам определять поведение, не связанное с реализацией интерфеса. Классы, которые реализуют один и тот же интерфейс, могут заменять друг друга.


Able интерфейсы

Условности .NET Framework'а об able интерфейсах всегда ассоциируются с вещами по типу IEquatable или IComparable, которые обозначают действие интерфейса прилагательным с суффиксом "мый".

Например, у вас есть приложение для рисования, состоящее из некоторых сущностей, которые реализуют собственные операции рендера.

Эта функция render() будет существовать в IRenderable интерфейсе:

export interface IRenderable {  
	render(): void;
}

Несколько классов, например Circle и Rectangle , могут реализовать этот интерфейс:

export class Circle implements IRenderable {  
  render(): void {    
    console.log("Circle rendering code here...")  
	}
}

export class Rectangle implements IRenderable {  
  render(): void {    
    console.log("Rectangle rendering code here...")  
	}
}

Удобство состоит в том, чтобы не использовать в качестве типа конкретные классы, а использовать интерфейс. Например, вы хотите создать какую-нибудь фигуру:

const shape: IRenderable = new Circle();
shape.render();

Или изменить тип фигуры на другой класс, который реализует тот же интерфейс:

let shape: IRenderable;

shape = new Circle();
shape.render();

shape = new Rectangle();
shape.render();

Практический пример - добавление фигур в коллекцию, которая потом будет обработана для вывода на канвас.

const shapes: IRenderable[] = [  
  new Circle(),   
  new Rectangle()
];

for (let shape of shapes) {  
  shape.render();
}

Полный пример с использованием библиотеки PIXI:

export interface IRenderable {  
  render(graphics: PIXI.Graphics): void;
}

export class Circle implements IRenderable {  
  public radius: number = 100;  

	render(graphics: PIXI.Graphics): void {    
    graphics.drawCircle(0, 0, this.radius);  
	}
}

export class Rectangle implements IRenderable {  
  public width: number = 100;  
	public height: number = 100;  

	render(graphics: PIXI.Graphics): void {    
    graphics.drawRect(0, 0, this.width, this.height);  
	}
}

// Определение фигур и контекста для графики
const shapes: IRenderable[] = [new Circle(), new Rectangle()];
const graphics = new PIXI.Graphics();

for (let shape of shapes) {  
  shape.render(graphics);
}

Создание интерфейсов

Допустим, вы хотите создать игровую сцену, используя движок для рендера - давайте посмотрим на встроенный в PIXI движок и на собственный.

Наш интерфейс для игровой сцены будет содержать движок обощенного типа (generic type):

export interface IGameScene<T> {  
	engine: T;
}

В интерфейсе движка будет функция start() , которая будет запускать процесс рендера:

export interface IEngine {  
	start(): void;
}

Первый движок будет использовать обычный Ticker движок, который есть в PIXI. Назовем этот движок TickerEngine , он будет реализовывать IEngine интерфейс:

export class TickerEngine implements IEngine {  
  start(): void {    
    console.log("Starting ticker engine...");    
		const renderer = new PIXI.Renderer();    
		const scene = new PIXI.Container();    
		const ticker = new PIXI.Ticker();    

		ticker.add(() => {      
      console.log("ticker frame handler");      
			renderer.render(scene);    
    }, PIXI.UPDATE_PRIORITY.LOW);    

		ticker.start();  
	}
}

Второй движок будет использовать пользовательский обработчик. Назовем этот движок LoopEngine , который также будет реализовывать IEngine интерфейс:

export class LoopEngine implements IEngine {  
  renderer = new PIXI.Renderer();  
  scene = new PIXI.Container();  
  
  start(): void {    
    console.log("Starting loop engine...");    
		requestAnimationFrame(this.frameHandler);  
	}  

	private frameHandler = () => {    
    console.log("loop frame handler");    
    this.renderer.render(this.scene);    
    requestAnimationFrame(this.frameHandler);  
  };
}

Класс игровой сцены будет реализовывать IGameScene интерфейс типа IEngine. Это позволит нам указывать конкретный движок, который мы будем использовать и автоматически запускать в конструкторе:

export class Scene implements IGameScene<IEngine> {  
  engine: IEngine;  
  
  constructor(engine: IEngine) {    
    this.engine = engine;    
    this.engine.start();  
	}
}

Теперь создадим экзепляр игровой сцены, используя TickerEngine :

Сделаем тоже самое, но теперь с LoopEngine :

Этот подход позволяет нам создать похожее поведение для разных сущностей.

Дальнейшая инкапсуляция

Допустим, вы оцениваете два физических движка для игры и хотите менять их. В обработчике анимаций в вашем приложении вы хотите использовать функцию step(), чтобы вызывать вычисления движка по одному. Начнем с определения интерфейса:

export interface IPhysicsEngine {  
	step(): void;
}

Скажем, вы нашли библиотеку физического движка Box 2D, который взаимодействует с вашим графическим фреймворком. Создадим вокруг него обертку, вызывающую следующее вычисление:

export class Box2DPhysicsEngine implements IPhysicsEngine {  
  step(): void {    
    // Какая-то реализация движка Box 2D  
  }
}

Возможно, вы хотите сравнить Box 2D с Planck. Создадим обертку вокруг этого движка:

export class PlanckPhysicsEngine implements IPhysicsEngine {  
  step(): void {    
    // Какая-то реализация движка Planck 
  }
}

В классе игры создадим экземпляр физического движка. В обработчике будем ссылаться на экземпляр и вызывать метод step() :

export class GameScene {  
  private physicsEngine: IPhysicsEngine;  

	constructor(physicsEngine: IPhysicsEngine) {    
    this.physicsEngine = physicsEngine;  
  }  

	private frameHandler = () => {    
    this.physicsEngine.step();    
    requestAnimationFrame(this.frameHandler);  
  };
}

Попробуем создать игру с помощью движка Box 2D:

const game = new GameScene(new Box2DPhysicsEngine());

А теперь с помощью Planck:

const game = new GameScene(new PlanckPhysicsEngine());

Конечно, смена основной функциональности может быть и сложной, и невозможной¹, это зависит от архитектуры и компонентов. Может быть интересно исследовать различные паттерны, используя обобщенные типы, интерфейсы и инкапсуляцию.

Упрощение

Описание связей с помощью типов упрощает ваш код, требует лишь один интерфейс, который будет определять что-то общее.

Рассмотрим игру в космическом стиле с многоугольниками, летающими в открытом пространстве, у которых есть свои координаты и вращение. Также, вероятно, количество сторон многоугольника уменьшается после попадания в него.

Будем использовать тот же интерфейс IRenderable :

export interface IRenderable {  
  render(graphics: PIXI.Graphics): void;
}

Наши фигуры будут иметь две координаты, угол вращения, а также количество сторон и радиус. Определим для этого три разных интерфейса:

export interface IPosition {  
  x: number;  
  y: number;
}

export interface IRotation {  
  angle: number;
}

export interface IPolygon {  
  sides: number;  
  radius: number;
}

Класс фигуры будет реализовывать IPosition и IRotation интерфейсы без знания того, как потом эту фигуру обработают:

export class Shape implements IPosition, IRotation {  
  x: number = 0;  
  y: number = 0;  
  angle: number = 0;
}

Конкретный класс многоугольника унаследует класс фигуры, реализует интерфейсы, который реализует класс фигуры, а помимо этого еще и интерфейсы IPolygon и IRenderable :

export class Polygon extends Shape implements IPolygon, 
  IRenderable {  
    sides: number;  
    radius: number;  
    
    constructor(sides: number, radius: number) {    
      super();    
      this.sides = sides;    
      this.radius = radius;  
    }  
    
    render(graphics: PIXI.Graphics): void {    
      let step = (Math.PI * 2) / this.sides;    
			let start = (this.angle / 180) * Math.PI;    
			let n, dx, dy;    

			graphics.moveTo(      
        this.x + Math.cos(start) * this.radius,      
        this.y - Math.sin(start) * this.radius    
      );    

			for (n = 1; n <= this.sides; ++n) {      
        dx = this.x + Math.cos(start + step * n) * this.radius;      
				dy = this.y - Math.sin(start + step * n) * this.radius;      
				graphics.lineTo(dx, dy);    
			}  
		}
}

Все многоугольники могут быть созданы как:

const triangle = new Polygon(3, 100);
const square = new Polygon(4, 100);
const pentagon = new Polygon(5, 100);
const hexagon = new Polygon(6, 100);

Или определены как отдельные классы:

export class Triangle extends Polygon {  
  constructor(radius: number) {    
    super(3, radius);  
  }
}

export class Square extends Polygon {  
  constructor(radius: number) {    
    super(4, radius);  
  }
}

export class Pentagon extends Polygon {  
  constructor(radius: number) {    
    super(5, radius);  
  }
}

Сейчас, когда мы определили все эти интерфейсы, мы может забыть обо всех сложностях и сфокусироваться на других задачах.

Для нашего рендерного обработчика важно только то, чтобы все фигуры реализовывали интерфейс IRenderable :

const shapes: IRenderable[] = [triangle, square, pentagon, hexagon];
const graphics = new PIXI.Graphics();

for (let shape of shapes) {  
  shape.render(graphics);
}

Возможно, в тестах на попадание в многоугольники, нам нужно будет считать площадь этого многоугольника. Для этого мы может использовать интерфейс IPolygon , чтобы получить доступ к необходимым полям raduis и sides :

/** Подсчет площади многоульника */
export const area = (polygon: IPolygon): number => {  
  const r = polygon.radius;  
  const n = polygon.sides;  
  
  return (n * Math.pow(r, 2)) / (4 * Math.tan(Math.PI / n));
};

const polygons: IPolygon[] = [  
  new Triangle(100),  
  new Square(100),  
  new Pentagon(100)
];

for (let polygon of polygons) {  
	console.log(`Area: ${area(polygon)}`);
}

Возможно, каждый раз, когда в многоугольник попадают, количество сторон будет уменьшаться до того момента, пока фигура не будет уничтожена:

const hit = (shape: IPolygon) => {  
  shape.sides -= 1;  
  if (shape.sides < 3) {    
    console.log("Enemy destroyed!");  
  }
};

Возможно, при клике вам нужно будет получить доступ к координатам фигуры, для этого можно использовать IPosition интерфейс:

export const onMouseDown = (position: IPosition) => {  
  console.log(position.x, position.y);
};

Координаты мыши имеют схожую с IPosition интерфейсом структуру и этим можно пользоваться.

Интерфейсы помогают разделить логику и сделать код более понятным.


¹ нет ничего невозможного, но есть то, что не стоит своих вложений.

Все проблемы в сфере компьютерных наук могут быть решены другим уровнем абстракции. - Дэвид Уиллер

Комментарии (5)


  1. whalemare
    06.02.2022 14:13
    +9

    Вся статья - объяснение полиморфизма через сложные конструкции, хотя заголовок очень интригующий.
    Ожидал увидеть здесь более интересные техники применения типов


    1. ReDev1L
      06.02.2022 22:04

      Да, хотел написать что эту статью осилит только тот, кто уже это знает)


  1. AjnaGame
    06.02.2022 17:30

    Интерфейсы помогают разделить логику и сделать код более понятным.

    Точно также и наоборот

    Напомнило https://habr.com/ru/post/153225/


  1. atomic1989
    07.02.2022 08:05

    Мне кажется лучше использовать базовый класс(возможно и абстрактный). Ими можно управлять в рантайме. В ts интерфейсы пустышки, в рантайме недоступны, например в отличии c#. Интерфейсы ts годны как некий контрак между сервером и клиентом, где кроме структуры данных не передать(скажем методы)


    1. m0tral
      08.02.2022 09:48

      Мужчина дорвался до базовых вещей ООП в виде интерфейсов и теперь нарадоваться не может)) следующая статья видимо будет наследование от абстрактного класса) ожидал большего от статьи