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

Возможность 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 интерфейсом структуру и этим можно пользоваться.
Интерфейсы помогают разделить логику и сделать код более понятным.
¹ нет ничего невозможного, но есть то, что не стоит своих вложений.
Все проблемы в сфере компьютерных наук могут быть решены другим уровнем абстракции. - Дэвид Уиллер
