Создание гибких TypeScript интерфейсов

от автора

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

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 интерфейсом структуру и этим можно пользоваться.

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


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

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


ссылка на оригинал статьи https://habr.com/ru/post/649933/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *