本篇博客是在 AI 工具 Claude 的辅助下完成。

常见的设计模式有以下几类:

  1. 创建型模式:这些设计模式提供了一种在创建对象的同时隐藏创建逻辑的方式,比如 工厂方法模式抽象工厂模式单例模式原型模式 等。
  2. 结构型模式:这些设计模式关注类和对象的组合,使他们之间的相互关系更加清晰,比如 适配器模式装饰器模式代理模式外观模式桥接模式组合模式 等。
  3. 行为型模式:这些设计模式专注点关注对象之间的通信,比如 观察者模式迭代器模式模板方法模式策略模式命令模式职责链模式访问者模式中介者模式 等。

总体来说,常用设计模式有20多个,在游戏开发中比较常用的设计模式有以下几种:

  1. 单例模式
  2. 工厂方法模式
  3. 适配器模式
  4. 装饰器模式
  5. 观察者模式
  6. 策略模式
  7. 模板方法模式
  8. 代理模式
  9. 状态模式
  10. 命令模式

灵活使用设计模式可以使游戏开发更加模块化、扩展性更好并易于维护。

1. 单例模式

单例模式(Singleton Pattern),确保一个类只有一个实例,并提供一个全局访问点。

其主要目的是控制资源的数量,实现共享资源的控制。

1.1 基本结构

单例模式通常包含以下角色:

  • 单例类:负责创建和管理单例对象,控制客户端对单例对象的访问。
  • 单例对象:单例类创建的唯一对象。

单例模式的实现方式主要有三种:

1.1.1 饿汉式

线程安全,调用效率高,但始终占用内存。

class Singleton {
    private static instance: Singleton = new Singleton();

    private constructor() { }

    public static getInstance(): Singleton {
        return Singleton.instance;
    }
}

let s1 = Singleton.getInstance();
let s2 = Singleton.getInstance();

console.log(s1 === s2); // true

1.1.2 懒汉式

线程不安全,调用效率高,起初不占用内存。

class Singleton {
    private static instance: Singleton;

    private constructor() { }

    public static getInstance(): Singleton {
        if (!Singleton.instance) {
            Singleton.instance = new Singleton();
        }
        return Singleton.instance;
    }
}

let s1 = Singleton.getInstance();
let s2 = Singleton.getInstance();

console.log(s1 === s2); // true

1.1.3 双重校验锁

线程安全,调用效率高,起初不占用内存。

// singleton.ts
let instance: Singleton;

export class Singleton {
    private constructor() { }

    public static getInstance(): Singleton {
        if (!instance) {
            instance = new Singleton();
        }
        return instance;
    }
}

这里利用了 TypeScript 的模块化特性来实现线程安全。因为在编译期,这个模块会被编译为一个单独的作用域,所以即使有多个线程同时调用 getInstance 方法,也不会产生竞争条件,从而创建多个实例。

// main.ts
import { Singleton } from './singleton';

let s1 = Singleton.getInstance();
let s2 = Singleton.getInstance();

console.log(s1 === s2); // true

1.2 优缺点

单例模式的主要优点是:

  • 控制资源的数量。
  • 节省系统资源。
  • 实现线程安全。

单例模式的主要缺点是:

  • 扩展性差,一个类只能有一个实例。
  • 测试难度大,无法 mock。
  • 与单一职责原则冲突,一个类既充当了工厂角色又充当了产品角色。

所以,单例模式在需要 严格控制资源数量实现共享数据集 的场景中比较适用。但由于扩展性差,在可能频繁变化的场景中要慎用。

1.3 适用场景

在游戏开发中,单例模式常用于 资源管理器对象池游戏逻辑 等只需要一个实例的系统。

1.3.1 资源管理器

class ResourceManager {
    private static instance: ResourceManager;
    private resources: Map<string, any> = new Map();

    private constructor() { }

    public static getInstance(): ResourceManager {
        if (!ResourceManager.instance) {
            ResourceManager.instance = new ResourceManager();
        }
        return ResourceManager.instance;
    }

    public loadResource(name: string, resource: any) {
        this.resources.set(name, resource);
    }

    public getResource(name: string) {
        return this.resources.get(name);
    }
}

// 使用
let manager = ResourceManager.getInstance();
manager.loadResource("玩家资源", playerResource);
let resource = manager.getResource("玩家资源");

1.3.2 对象池

class ObjectPool {
    private static instance: ObjectPool;
    private pool: Obj[] = [];

    private constructor() { }

    public static getInstance(): ObjectPool {
        if (!ObjectPool.instance) {
            ObjectPool.instance = new ObjectPool();
        }
        return ObjectPool.instance;
    }

    public allocObject() {
        if (this.pool.length > 0) {
            return this.pool.pop();
        }
        return new Obj();
    }

    public releaseObject(obj: any) {
        this.pool.push(obj);
    }
}

// 使用
let pool = ObjectPool.getInstance();
let obj1 = pool.allocObject();
pool.releaseObject(obj1);
let obj2 = pool.allocObject();

1.3.3 游戏逻辑管理器

class GameLogic {
    private static instance: GameLogic;

    private constructor() { }

    public static getInstance(): GameLogic {
        if (!GameLogic.instance) {
            GameLogic.instance = new GameLogic();
        }
        return GameLogic.instance;
    }

    public update() {
        // 单例更新游戏逻辑
    }
}

// 调用
let logic = GameLogic.getInstance();
logic.update(); 

所以,单例模式在游戏开发中可以保证一些系统/管理器 全局唯一,并为整个游戏提供接口。它的应用场景比较广泛,使得游戏的逻辑和资源得以良好地管理和共享。

2. 工厂方法模式

工厂方法模式(Factory Method Pattern),是一种创建型设计模式,它提供一个创建对象的接口,但由子类决定要实例化的类是哪一个。

工厂方法模式让类的实例化推迟到子类中进行。

2.1 基本结构

工厂方法模式通常包含以下主要角色:

  • Product:抽象产品类,定义了产品的接口。
  • ConcreteProduct:具体产品类,实现了抽象产品类。
  • Factory:抽象工厂类,定义了创建产品的接口,由子类实现。
  • ConcreteFactory:具体工厂类,重写了抽象工厂类的方法,创建具体产品类的实例。

其基本结构如下:

AbstractProduct
  + ProductA
  + ProductB
  
AbstractFactory
  + ConcreteFactory1
  + ConcreteFactory2
  
ConcreteFactory1.createProduct() => ProductA
ConcreteFactory2.createProduct() => ProductB

客户端通过 ConcreteFactory 来创建不同的 ConcreteProduct 对象,而无需知道具体产品的类名。

2.2 优缺点

工厂方法模式的主要优势有:

  1. 它可以将客户端与具体产品类解耦,客户端不再需要知道具体产品类的类名。
  2. 它可以将产品创建的过程交给子类来实现,以便支持新的产品类型。
  3. 它增加了系统的灵活性,可以根据条件创建不同的产品对象。

缺点有:

  1. 类的个数较多,结构复杂。
  2. 增加了系统的抽象性和理解难度。

2.3 适用场景

工厂方法模式适用场景:

  1. 系统中有多个产品族,而系统只消费其中某一族产品。
  2. 要提供给客户端的具体产品类在编译期不确定,而是在运行期确定。
  3. 客户端不需要知道创建产品过程的细节。

在游戏开发中,工厂方法模式常用于创建不同类型的游戏对象和资源。

不同的工厂创建不同类型的对象。例如:

2.3.1 创建不同类型的敌人

interface Enemy {
    attack(): void;
    move(): void;
}

class Goblin implements Enemy {
    attack() {
        console.log('弓箭攻击!');
    }
    move() {
        console.log('移动到玩家身边!');
    }
}

class Orc implements Enemy {
    attack() {
        console.log('利斧攻击!');
    }
    move() {
        console.log('向玩家冲锋!');
    }
}

abstract class EnemyFactory {
    public abstract createEnemy(): Enemy;
}

class GoblinFactory extends EnemyFactory {
    public createEnemy(): Enemy {
        return new Goblin();
    }
}

class OrcFactory extends EnemyFactory {
    public createEnemy(): Enemy {
        return new Orc();
    }
}

let goblinFactory = new GoblinFactory();
let goblin = goblinFactory.createEnemy();
goblin.attack();   // 弓箭攻击!

let orcFactory = new OrcFactory();
let orc = orcFactory.createEnemy();
orc.move();      // 向玩家冲锋!

2.3.2 创建不同类型的道具

interface Item {
    use(): void;
}

class HealthPotion implements Item {
    use() {
        console.log('用药回血!');
    }
}

class ManaPotion implements Item {
    use() {
        console.log('用药回魔!');
    }
}

abstract class ItemFactory {
    public abstract createItem(): Item;
}

class HealthPotionFactory extends ItemFactory {
    public createItem(): Item {
        return new HealthPotion();
    }
}

class ManaPotionFactory extends ItemFactory {
    public createItem(): Item {
        return new ManaPotion();
    }
}

let healthPotionFactory = new HealthPotionFactory();
let healthPotion = healthPotionFactory.createItem();
healthPotion.use();    // 用药回血!

let manaPotionFactory = new ManaPotionFactory();
let manaPotion = manaPotionFactory.createItem();
manaPotion.use();     // 用药回魔! 

工厂方法模式用于创建不同类型的游戏对象,通过不同的工厂类实例化不同的具体产品类,进而获取不同类型的对象实例。

它使得对象的创建和使用分离,降低了系统的耦合度,增强了可扩展性。

2.4 补充讲解

这里以游戏开发中创建不同类型的敌人为例,分别使用 工厂方法模式非工厂方法模式 两种方法实现,通过对比二者使用上的区别,来更好的感受工厂方法模式的优势。

不使用工厂方法模式实现:

interface Enemy {
    name: string;
    attack(): void;
}

class Skeleton implements Enemy {
    name = 'Skeleton';
    attack() {
        console.log('Skeleton attacks!');
    }
}

class Zombie implements Enemy {
    name = 'Zombie';
    attack() {
        console.log('Zombie attacks!');
    }
}

function createEnemy(type: string): Enemy {
    if (type === 'skeleton') {
        return new Skeleton();
    } else if (type === 'zombie') {
        return new Zombie();
    }
}

let enemy1 = createEnemy('skeleton');
let enemy2 = createEnemy('zombie');

使用工厂方法模式实现:

interface Enemy {
    name: string;
    attack(): void;
}

interface EnemyFactory {
    createEnemy(type: string): Enemy;
}

class Skeleton implements Enemy {
    name = 'Skeleton';
    attack() {
        console.log('Skeleton attacks!');
    }
}

class Zombie implements Enemy {
    name = 'Zombie';
    attack() {
        console.log('Zombie attacks!');
    }
}

class EnemyFactoryImpl implements EnemyFactory {
    createEnemy(type: string): Enemy {
        if (type === 'skeleton') {
            return new Skeleton();
        } else if (type === 'zombie') {
            return new Zombie();
        }
    }
}

let factory: EnemyFactory = new EnemyFactoryImpl();
let enemy1 = factory.createEnemy('skeleton');
let enemy2 = factory.createEnemy('zombie');

两种方式都可以实现所需功能,而且似乎不使用工厂方法模式的代码更加简洁。

那么工厂方法模式的优势如何体现呢?

这里我们以添加一个新的 Boss 敌人类型为例,演示在使用和不使用工厂方法模式的情况下,如何修改代码:

不使用工厂方法模式,当添加一个新的 Boss 敌人时,我们需要:

  1. 定义 Boss 类:
class Boss implements Enemy {
    name = 'Boss';
    attack() {
        console.log('Boss attacks!');
    }
}
  1. 修改 createEnemy 方法来创建 Boss 实例:
function createEnemy(type: string): Enemy {
    if (type === 'skeleton') {
      return new Skeleton();
    } else if (type === 'zombie') {
      return new Zombie(); 
    } else if (type === 'boss') {
      return new Boss();
    }
  }
  1. 客户端调用 createEnemy('boss') 来创建 Boss 实例:
let enemy1 = createEnemy('skeleton');
let enemy2 = createEnemy('zombie');
let boss = createEnemy('boss');

所以在这个模式下,每添加新的敌人类型都需要修改 createEnemy 方法,违反开闭原则。

而使用工厂方法模式,当添加一个新的 Boss 敌人时,我们需要:

  1. 定义 Boss 类:
class Boss implements Enemy {
    name = 'Boss';
    attack() {
        console.log('Boss attacks!');
    }
}
  1. 创建 BossEnemyFactory 继承自 EnemyFactory 来产生 Boss 实例:
class BossEnemyFactory implements EnemyFactory {
    createEnemy(type: string): Enemy {
        if (type === 'boss') {
            return new Boss();
        }
    }
} 
  1. 客户端通过 BossEnemyFactory 来创建 Boss 实例,原有代码不变:
let factory: EnemyFactory = new EnemyFactoryImpl();  
let enemy1 = factory.createEnemy('skeleton');  
let enemy2 = factory.createEnemy('zombie');

let bossFactory = new BossEnemyFactory(); 
let boss = bossFactory.createEnemy('boss'); 

在这个模式下,每添加新的敌人类型,只需要创建一个新的对象工厂来生产新的敌人对象即可,无需改动原有代码。

通过上面例子的对比,我们可以感受到,工厂方法模式的优势主要体现在:

  1. 扩展性:使用工厂方法模式的系统更易扩展,可以在不修改原有代码的基础上添加新的产品(例如添加新的敌人类型)。而不使用工厂方法模式的系统则需要修改 createEnemy 方法来支持新的产品类型,违反了开闭原则。
  2. 解耦性:使用工厂方法模式的客户端与具体产品类解耦,如果有新的产品加入,客户端的代码不需要任何修改。而不使用工厂方法模式的客户端则依赖于具体产品类,当有新的产品加入时可能需要修改客户端代码。
  3. 复用性:可以重用抽象工厂类,并通过子类来创建不同系列的产品对象。非工厂方法模式不具备这种灵活性。

所以,使用工厂方法模式通过封装变化点,产生可重用的设计和体系结构,可以带来更高的 扩展性解耦性复用性,这也是设计模式的目标。

3. 适配器模式

适配器模式(Adapter Pattern)是一种结构型模式,它能使接口不兼容的类可以一起工作。它通常通过包装一个接口来实现这一功能,而不是直接修改已有的接口。

适配器模式主要用来吸收两个已经存在的接口之间的差异,解决因接口不匹配所造成的类的兼容问题。它通过对现有接口的适配,使得原本因接口不同而无法协同工作的类可以协同工作,最大限度地重用已有的类,而不必修改原有代码。

3.1 基本结构

适配器模式包含以下主要要素:

  • Target:目标接口,需要适配的接口。
  • Adaptee:需要适配的现有接口。
  • Adapter:适配器类,通过包装 Adaptee 对象,把源接口转换成目标接口。

其类图结构如下:

Target
  + ConcreteTarget
  
Adaptee
  + ConcreteAdaptee
  
Adapter
  - adaptee: Adaptee 
  - target: Target

工作原理是:客户端调用 Adapter 的方法,Adapter 再调用 Adaptee 转换成目标接口要求的方法,并把结果返回给客户端。

3.2 优缺点

适配器模式的主要优点有:

  1. 可以实现两个已经存在但接口不兼容的类的兼容工作。
  2. 增加了类的复用性。
  3. 实现开闭原则。

缺点有:

  1. 过多使用适配器会让系统变得复杂。
  2. 由于要包装现有类,所以会产生额外的开销。

3.3 适用场景

适配器模式的适用场景:

  1. 系统需要使用现有的类,但其接口不符合系统的需求。
  2. 想要创建一个可以复用的类,可以适配与不同的接口。
  3. 组合产品线中兼容性差异问题。

比如我们有一个武器接口和两个具体的武器类:

interface Weapon {
    attack(): void;
}

class Sword implements Weapon {
    attack() { console.log('剑攻击!'); }
}

class Axe implements Weapon {
    attack() { console.log('斧攻击!'); }
}

此时,有一个游戏角色可以装备武器并进行攻击:

class Warrior {
    weapon?: Weapon;

    public equipWeapon(weapon: Weapon) {
        this.weapon = weapon;
    }

    public attack() {
        this.weapon?.attack();
    }
}

然后,就可以使用了:

let sword = new Sword();
let axe = new Axe();

let warrior = new Warrior();
warrior.equipWeapon(sword);
warrior.attack();     // 剑攻击!

warrior.equipWeapon(axe);
warrior.attack();     // 斧攻击!  

此时,加入一个新的武器类型 Bow(弓),其方法名为 shoot 而不是 attack

class Bow {
    shoot() { console.log('射箭!') }
}

此时 Bow 类并不匹配 Weapon 接口,无法直接使用,Warrior 也无法直接装备 Bow

如果不适用适配器模式,我们可能需要对 Warrior 类进行修改,增加新的接口,如:

class Warrior {
    weapon?: Weapon;
    bow?: Bow;

    public equipWeapon(weapon: Weapon) {
        this.weapon = weapon;
    }

    public attack() {
        this.weapon?.attack();
    }

    public equipBow(bow: Bow) {
        this.bow = bow;
    }

    public shoot() {
        this.bow?.shoot();
    }
}

这样便破坏了开闭原则,不利于代码维护。

这时候我们可以使用适配器模式,定义一个 BowAdapter 来适配 BowWeapon 接口:

class BowAdapter implements Weapon {
    bow?: Bow;

    constructor(bow: Bow) {
        this.bow = bow;
    }

    attack() {
        this.bow?.shoot();
    }
}

现在就可以这样使用了:

let bow = new Bow();
let bowAdapter = new BowAdapter(bow);

let warrior = new Warrior();
warrior.equipWeapon(bowAdapter);
warrior.attack();     // 射箭!

可以看到,BowAdapter 适配器将 Bow 适配到 Weapon 接口,使 Warrior 可以像使用其他常规武器一样使用 Bow ,而不需要修改 Warrior 的代码。

这就是适配器模式的典型应用场景,它通过构建一个适配器类作为两个已有接口之间的桥梁,使原本不匹配的类能够协同工作。最大限度地重用已有代码,无需修改原有系统。

4. 装饰器模式

装饰器模式(Decorator Pattern),是一种结构型模式。它是通过包装对象来实现扩展其功能,可以动态地给对象添加一些额外的职责,而不改变其原有的结构。

4.1 基本结构

装饰器模式包含以下主要元素:

  • Component:抽象构件,定义对象的接口。
  • ConcreteComponent:具体构件,实现抽象构件。
  • Decorator:抽象装饰类,包含一个构件对象,并定义一个与构件接口相同的接口。
  • ConcreteDecorator:具体装饰类,继承抽象装饰类,并在调用构件方法的前后添加额外的方法。

其类图结构如下:

Component
  + ConcreteComponent
  
Decorator 
  + ConcreteDecorator
     - component: Component  

客户端通过装饰器对构件对象进行包装,,从而增强构件对象的行为,但客户端看来却仍然与原有的构件对象有相同的接口。

4.2 优缺点

装饰器模式的主要优点有:

  1. 继承的替代方案。通过包装可以扩展对象的行为,而无需继承。
  2. 可以动态添加对象功能。通过嵌套不同装饰器可以添加多个扩展。
  3. 符合开闭原则。扩展系统功能不修改现有代码。

缺点有:

  1. 会产生许多装饰类,系统变得复杂。
  2. 动态装饰时,难以判断一个对象具有哪些扩展。

4.3 适用场景

装饰器模式的适用场景:

  1. 需要扩展对象的功能,但继承不合适。
  2. 动态地为对象增加职责,这些职责可以改变。
  3. 当不能采用继承的方式进行扩展时。

装饰器模式通过 动态包装对象 实现其功能扩展,这种灵活的方式可以避免继承导致的类爆炸问题,并满足开闭原则的要求。

在游戏开发中,装饰器模式常用于给游戏对象添加附加功能,而不改变其原有结构。

例如:给游戏角色添加 buff

// 定义角色接口 
interface ICharacter {
    attack(): void;
    move(): void;
    description(): string;
}
// 具体构件: 实现角色
class Character implements ICharacter {
    attack() { console.log('攻击!'); }
    move() { console.log('移动!'); }
    description() { return '普通角色'; }
}

// 抽象装饰: 定义buff接口
abstract class CharacterDecorator implements ICharacter {
    protected character: ICharacter;

    constructor(character: ICharacter) {
        this.character = character;
    }

    attack() {
        this.character.attack();
    }
    move() {
        this.character.move();
    }
    description() {
        return this.character.description();
    }
}

// 具体装饰: 速度提高buff
class SpeedBuff extends CharacterDecorator {
    attack() {
        console.log('速度提高,攻击!');  // 添加额外功能
        super.attack();
    }
    move() {
        console.log('速度提高,移动!');  // 添加额外功能
        super.move();
    }
    description() {
        return `${this.character.description()},速度提高`;
    }
}

let character = new Character();
character = new SpeedBuff(character);

character.attack();   // 速度提高,攻击! 攻击! 
character.move();    // 速度提高,移动! 移动!
console.log(character.description()); // 普通角色,速度提高

可以看到,SpeedBuff 装饰器给 Character 添加了 速度buff,在不改变原 Character 类的基础上增加了额外功能,并通过组合的方式弥补了 Character 类的不足。

所以,装饰器模式就是动态地给一个对象添加额外的职责,而不用生成新的子类。通过使用对象组合,可以动态地扩展对象的功能。

再举一个例子。比如我们有一个游戏角色类:

// 定义角色接口 
interface ICharacter {
    name: string;
    hp: number;
    power: number;
    attack(): void;
}

// 具体构件: 实现角色
class Character implements ICharacter {
    name: string;
    hp: number;
    power: number;

    constructor(name: string, hp: number, power: number) {
        this.name = name;
        this.hp = hp;
        this.power = power;
    }
    attack() {
        console.log(`${this.name}攻击,造成${this.power}点伤害!`);
    }
}

let character = new Character('骑士', 100, 10);
character.attack(); // 骑士攻击,造成10点伤害!

现在我们想给这个角色添加 防御buff攻击buff ,可以这样使用装饰器模式:

// 抽象装饰类 实现Character接口
abstract class CharacterDecorator implements ICharacter {
    protected character: Character;

    constructor(character: Character) {
        this.character = character;
    }

    //实现接口方法 getter 和 setter
    get name() { return this.character.name }
    get hp() { return this.character.hp }
    get power() { return this.character.power }
    set name(value: string) { this.character.name = value; }
    set hp(value: number) { this.character.hp = value; }
    set power(value: number) { this.character.power = value; }

    attack() {
        this.character.attack();
    }
}

// 具体装饰类 防御 buff
class DefenseDecorator extends CharacterDecorator {
    attack() {
        console.log('防御buff生效,受到的伤害减半!');
        this.character.attack();
    }
}

// 具体装饰类 攻击 buff
class AttackDecorator extends CharacterDecorator {
    attack() {
        console.log('攻击buff生效,伤害加倍!');
        this.character.power *= 2;
        this.character.attack();
        this.character.power /= 2;
    }
}

// 调用
let character = new Character('骑士', 100, 10);
character = new DefenseDecorator(character);
character = new AttackDecorator(character);

character.attack();
// 攻击buff生效,伤害加倍!
// 防御buff生效,受到的伤害减半!
// 骑士攻击,造成20点伤害!

可以看到,DefenseDecoratorCharacter 添加了 防御buffAttackDecoratorCharacter 添加了 攻击buff 。这两个装饰器动态地扩展了Character的功能,而不需要修改Character本身的代码。

Character可以被多个装饰器包装,实现更加丰富的功能,而不改变其原有的结构和原有的功能,这就是装饰器模式的强大之处。

5. 观察者模式

观察者模式(Observer Pattern),定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。这个主题对象在状态发生变化时,会通知所有的观察者对象,使它们能够自动更新自己。

5.1 基本结构

观察者模式的典型结构包含:

  • Subject:抽象主题类,它维护了一个观察者集合,提供了增加、移除观察者的接口。
  • ConcreteSubject:具体主题类,它实现抽象主题类,在状态发生变化时通知所有的观察者。
  • Observer:抽象观察者类,它定义了一个更新接口,使得在主题状态发生变化时更新自己。
  • ConcreteObserver:具体观察者类,它实现抽象观察者类,并在主题状态发生变化时更新自己的状态。

5.2 优缺点

观察者模式的优点:

  • 观察者和主题之间是抽象耦合的。
  • 主题对象可以很容易地增加和删除观察者对象。
  • 观察者模式建立了一套触发机制。

观察者模式的缺点:

  • 如果一个主题有很多直接和间接的观察者,将所有的观察者通知到会花费很长时间。
  • 如果在观察者和主题之间有循环依赖,观察者会被重复调用。
  • 主题和观察者之间的依赖关系并不很清晰。

观察者模式在软件系统中广泛使用,比如事件处理系统、新闻出版系统等等。它提供了一种在软件系统之间低耦合的结构。

5.3 适用场景

观察者模式在游戏开发中常用于游戏对象之间的 消息传递事件处理,其中一个对象的状态变化会通知其他对象,使它们作出相应的反应。

例如:玩家升级时,通知跟随者也升级。

// 主题接口
interface ISubject {
    // 增加观察者
    attach(observer: IObserver): void;
    // 删除观察者
    detach(observer: IObserver): void;
    // 通知观察者
    notify(): void;
}

// 观察者接口
interface IObserver {
    // 更新接口
    update(): void;
}

// 主题类:玩家类
class Player implements ISubject {
    // 观察者列表
    observers: IObserver[] = [];
    // 玩家等级
    level = 1;

    // 添加观察者
    attach(observer: IObserver): void {
        this.observers.push(observer);
    }

    // 删除观察者
    detach(observer: IObserver): void {
        this.observers = this.observers.filter(o => o != observer);
    }

    // 通知观察者
    notify(): void {
        // 通知所有观察者更新
        this.observers.forEach(o => o.update());
    }

    // 升级
    levelUp() {
        this.level++;
        this.notify();
    }
}

// 观察者类:跟随者
class Follower implements IObserver {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    // 更新状态
    update(): void {
        console.log(`${this.name}升至第${player.level}级!`);
    }
}

// 客户端
let player = new Player();
let follower1 = new Follower('跟随者1');
let follower2 = new Follower('跟随者2');

// 玩家添加两个跟随者
player.attach(follower1);
player.attach(follower2);

// 玩家升级时,跟随者更新状态
player.levelUp();
// 跟随者1升至第2级!  
// 跟随者2升至第2级!

// 删除跟随者2
player.detach(follower2);

// 玩家升级时,跟随者1更新状态
player.levelUp();
  // 跟随者1升至第3级!

可以看到,观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。 Player 作为 主题(Subject)Follower 作为 观察者(Observer) ,多个 Follower 可以同时监听一个 Player

主题对象在状态上发生变化时,会通知所有观察者对象,使它们能够自动更新自己。当 Player 升级时,会通知跟随者也作出升级的反应。

6. 策略模式

策略模式(Strategy Pattern),是一种行为型设计模式,它定义了一系列的算法,并分别封装起来,让它们之间可以互相替换,策略模式可以让算法的变化独立于使用算法的客户。

6.1 基本结构

策略模式的典型结构包含:

  • Strategy:抽象策略类,定义了一个公共接口,各种不同的算法以不同的方式实现这个接口。
  • ConcreteStrategy:具体策略类,实现了抽象策略类,封装了具体的算法或行为。
  • Context:环境类,维护一个策略对象,最终给客户端调用。

6.2 优缺点

策略模式的优点:

  • 可以很好地管理和组织多个相关的算法。每种算法类之间相互独立,可以相互替换。
  • 扩展方便。新增策略时,只需增加一个新的策略类,不影响现有的环境类和策略类。
  • 提供继承的代替方案。若使用继承来扩展算法,将产生许多衍生类,不易维护。
  • 避免使用条件语句。

策略模式的缺点:

  • 客户端复杂度增大。因为客户端必须知道所有的策略类,并自行决定使用哪一个策略类。
  • 可能产生许多策略类,增加程序的大小。

6.3 适用场景

策略模式的适用场景:

  • 一个系统有许多类,它们的行为随着外部条件改变而改变。
  • 一个系统需要动态地在几种算法中选择一种。

在游戏开发中,策略模式常用于实现不同的 算法行为 方式。

例如:定义不同的移动算法,玩家可以在运行时选择使用

// 定义移动策略接口类
interface IMovementStrategy {
    move(): void;
}
// 步行移动策略类
class WalkingStrategy implements IMovementStrategy {
    move() {
        console.log('步行移动');
    }
}
// 跑步移动策略类
class RunningStrategy implements IMovementStrategy {
    move() {
        console.log('跑步移动');
    }
}
// 跳跃移动策略类
class JumpingStrategy implements IMovementStrategy {
    move() {
        console.log('跳跃移动');
    }
}

// 玩家类
class Player {
    movementStrategy?: IMovementStrategy;
    // 选择移动策略
    setMovementStrategy(strategy: IMovementStrategy) {
        this.movementStrategy = strategy;
    }
    // 移动
    move() {
        this.movementStrategy?.move();
    }
}

// 客户端调用
let player = new Player();
player.setMovementStrategy(new WalkingStrategy());
player.move();      // 步行移动

player.setMovementStrategy(new RunningStrategy());
player.move();      // 跑步移动  

player.setMovementStrategy(new JumpingStrategy());
player.move();      // 跳跃移动

可以看到,Player 通过 setMovementStrategy() 方法在运行时选择不同的移动策略( WalkingStrategyRunningStrategyJumpingStrategy ),并在 move() 方法中调用当前策略的算法来执行移动。

再举一个例子来深入解释策略模式。

比如在游戏中,我们有不同的敌人AI策略(普通敌人、精英敌人、BOSS敌人),玩家在不同区域会遇到不同的敌人。

我们可以这样定义:

// 敌人AI策略接口类
interface IEnemyStrategy {
    move(): void;
    attack(): void;
}
// 普通敌人AI策略类
class NormalEnemyStrategy implements IEnemyStrategy {
    move() {
        console.log('普通敌人移动');
    }
    attack() {
        console.log('普通敌人攻击');
    }
}
// 精英敌人AI策略类
class EliteEnemyStrategy implements IEnemyStrategy {
    move() {
        console.log('精英敌人移动');
    }
    attack() {
        console.log('精英敌人攻击');
    }
}
// BOSS敌人AI策略类
class BossEnemyStrategy implements IEnemyStrategy {
    move() {
        console.log('BOSS敌人移动');
    }
    attack() {
        console.log('BOSS敌人攻击');
    }
}

然后我们有一个敌人类,在不同的区域会选择不同的 AI 策略:

// 敌人类
class Enemy {
    strategy?: IEnemyStrategy;
    // 选择AI策略
    setStrategy(strategy: IEnemyStrategy) {
        this.strategy = strategy;
    }
    // 移动
    move() {
        this.strategy?.move();
    }
    // 攻击
    attack() {
        this.strategy?.attack();
    }
}

// 客户端调用
let enemy1 = new Enemy();
enemy1.setStrategy(new NormalEnemyStrategy());
enemy1.move();     // 普通敌人移动 
enemy1.attack();   // 普通敌人攻击

let enemy2 = new Enemy();
enemy2.setStrategy(new EliteEnemyStrategy());
enemy2.move();     // 精英敌人移动
enemy2.attack();   // 精英敌人攻击  

let enemy3 = new Enemy();
enemy3.setStrategy(new BossEnemyStrategy());
enemy3.move();     // BOSS敌人移动  
enemy3.attack();   // BOSS敌人攻击 

在这个例子中,策略模式的优势是非常明显的:

  1. 策略类和敌人类是松耦合的,将算法的变化封装在策略类里面,策略可以独立于敌人进行扩展和改变。耦合性低,扩展性强。
  2. 可以根据不同的情况动态地选择不同的策略,有很强的灵活性。

得益于这些优势,策略模式是实现 游戏对象智能扩展性 的重要手段之一。

6.4 补充讲解

为了更好的理解策略模式的优势,我们举个例子。

假设我们有一个计算器,它可以执行加法运算、减法运算、乘法运算、除法运算等等。

不用策略模式的话,我们可以这样实现:

// 计算器类
class Calculator {
    // 计算方法
    calc(operator: string, a: number, b: number): number {
        if (operator === "+") {
            return a + b;
        } else if (operator === "-") {
            return a - b;
        } else if (operator === "*") {
            return a * b;
        } else if (operator === "/") {
            return a / b;
        }
        // ...
        return 0;
    }
}
// 客户端调用
let calculator = new Calculator()
console.log(calculator.calc("+", 2, 3));   // 5
console.log(calculator.calc("-", 7, 2));   // 5

这种实现方式使用了条件语句选择不同的算法,如果以后算法增加,需要不断修改这个方法,这显然是不符合开闭原则的。

而使用策略模式的话,我们可以这样实现:

首先定义一个抽象策略接口类 IStrategy ,并实现加法、减法等具体策略类。

// 抽象策略类
interface IStrategy {
    calc(a: number, b: number): number;
}

// 具体策略类 加法
class AddStrategy implements IStrategy {
    calc(a: number, b: number): number {
        return a + b;
    }
}

// 具体策略类 减法
class SubStrategy implements IStrategy {
    calc(a: number, b: number): number {
        return a - b;
    }
}

然后实现计算器类 Calculator

// 环境类 计算器类
class Calculator {
    private strategy: IStrategy;
    // 设置算法策略
    public setStrategy(strategy: IStrategy) {
        this.strategy = strategy;
    }
    // 计算
    public calc(a: number, b: number): number {
        return this.strategy.calc(a, b);
    }
}

客户端调用时,可以选择不同的算法策略来进行运算。

// 客户端调用
let calculator = new Calculator();
// 加法
calculator.setStrategy(new AddStrategy());
console.log(calculator.calc(1, 2));  // 3
// 减法
calculator.setStrategy(new SubStrategy());
console.log(calculator.calc(1, 2)); // -1

当算法变化时,我们只需要增加新的策略类,而环境类和已有策略类不需要任何修改,这保证了策略模式具有良好的可扩展性和灵活性。

例如,在上述代码基础上,增加乘法运算策略时。

// 新增策略类 乘法
class MultiStrategy implements IStrategy {
    calc(a: number, b: number): number {
        return a * b;
    }
}

// 客户端调用
let calculator = new Calculator();
// 乘法
calculator.setStrategy(new MultiStrategy());
console.log(calculator.calc(2, 3));  // 6

无需修改已有的代码,具有良好的可扩展性和灵活性。

7. 模板方法模式

模板方法模式(Template Method Pattern),是一种行为型设计模式。在抽象类中定义模板方法,该模板方法中包含了一些步骤,而每个步骤又调用了抽象类中的抽象方法。子类可以重写抽象方法,但是必须按照模板方法中的步骤顺序执行。

举个简单的例子:

把大象装冰箱需要几步,第一,打开冰箱门,第二步,把大象放进去,第三步,关上冰箱门。

这便是典型的模板方法模式。不论把大象放冰箱,还是把鸡蛋放冰箱,都需要这三个步骤,这个便是模板方法;放不同的东西进冰箱,第二步实现的具体细节不同,这些具体步骤由子类重写实现。

7.1 基本结构

模板方法模式的基本结构包含以下几个要素:

  1. 抽象类(Abstract Class):定义抽象方法和模板方法。

    • 模板方法:定义算法骨架,包含一些步骤,这些步骤会调用抽象方法。
    • 抽象方法:抽象步骤,由子类实现。
    • 钩子方法:在模板方法中已经实现,但允许子类加以重写的方法。
  2. 具体子类(Concrete Class):继承抽象类,实现抽象方法。
  3. 具体实例(Objects):子类的具体对象,执行模板方法。

模板方法模式可以看作是一种 稳定骨架,可变细节 的设计思想。它通过抽象定义算法模板,将算法的不变部分的实现封装在父类中,而变化的部分的实现则由子类来完成,从而封装变化、避免重复、控制继承树。这就是模板方法模式的核心思想。

7.2 优缺点

模板方法模式的优点:

  • 提高代码复用性,子类只需要实现父类约定的方法。
  • 扩展方便,可以通过继承扩展算法步骤,不影响其他子类。
  • 调用简单,只需要调用父类的模板方法,具体子类的实现被隐藏

模板方法模式的缺点:

  • 类的数量增加。需要定义抽象类和具体子类,导致类的数量增加。
  • 初始学习成本较大。模板方法模式的结构和实现都较为复杂,初始学习难度较大。

7.3 适用场景

模板方法模式的适用场景:

  • 有多个子类共有的方法,且逻辑相同。
  • 重要的、复杂的算法,可以抽象出公共的模板方法,而将可变的部分延迟到子类中。
  • 控制子类的扩展。模板方法定义了算法的完整流程,子类只能在指定的位置执行指定的步骤。

在游戏开发中,模板方法模式有着广泛的应用,如:角色行为、生命周期、地图生成以及资源加载等方面。它实现了算法流程的统一,避免了代码重复,同时也为各种变化留出了扩展点。这使得游戏开发可以得到很高的复用性和灵活性。

例如:游戏对象行为控制。

abstract class Movement {
    // 模板方法,定义算法骨架 
    public move() {
        this.start();
        this.walk();
        this.run();
        this.otherAction();
        this.end();
    }

    protected start() {
        console.log('开始移动');
    }

    protected end() {
        console.log('移动结束');
    }

    protected otherAction() {
        // 钩子方法,方便扩展
    }

    // 定义抽象方法,由子类实现
    protected abstract walk(): void;
    protected abstract run(): void;
}

class PlayerMovement extends Movement {

    // 重写抽象方法,定义玩家移动行为  
    protected walk() {
        console.log('玩家步行');
    }

    protected run() {
        console.log('玩家跑步');
    }

    protected jump() {
        console.log('玩家跳跃');
    }

    protected otherAction() {
        this.jump();
    }
}

class MonsterMovement extends Movement {

    // 重写抽象方法,定义怪物移动行为
    protected walk() {
        console.log('怪物走路');
    }

    protected run() {
        console.log('怪物奔跑');
    }
}
let player = new PlayerMovement();
player.move();
// 开始移动
// 玩家步行  
// 玩家跑步 
// 玩家跳跃 
// 移动结束

let monster = new MonsterMovement();
monster.move();
// 开始移动
// 怪物走路
// 怪物奔跑
// 移动结束 

可以看到,Movement 定义了角色移动算法的框架,而将 步行跑步 等具体实现推迟到子类。

PlayerMovementMonsterMovement 继承 Movement 并重定义了某些方法,实现了不同游戏对象的移动方式。此外,PlayerMovement 通过重写钩子方法 otherAction() 为玩家类添加了 跳跃 的行为。

所以,模板方法模式在基类中定义对象的行为框架,而将部分算法的细节延迟到子类实现,这可以大大提高代码 复用性,同时允许子类灵活变化,非常适用于游戏对象的行为封装和扩展。

8. 代理模式

代理模式(Proxy Pattern),是一种结构型设计模式。给某一个对象提供一个代理对象,并由代理对象控制对实际对象的访问,客户端通过代理对象间接地访问目标对象。代理对象负责请求的预处理和过滤,可以起到保护目标对象的作用。

8.1 基本结构

代理模式的典型结构是:

  • 抽象主题(Subject)角色:定义实际主题和代理的公共接口。
  • 实际主题(Real Subject)角色:定义代理所代表的实际对象。
  • 代理(Proxy)角色:内部包含对实际主题的引用,并提供与实际主题相同的接口。
抽象主题(Subject)
│      
│  代理(Proxy)
│      
└─  真实主题(RealSubject)

代理模式的主要特点是:

  • 代理对象与目标对象具有相同的接口,所以代理对象可以替代目标对象。
  • 代理对象在访问目标对象之前和之后可以执行一些相关操作,如延迟加载、访问控制、缓存等。
  • 代理对象可以隐藏目标对象的细节信息。

8.2 优缺点

代理模式的优点:

  • 可以在访问真实对象之前进行权限控制或其他功能处理。
  • 可以在访问真实对象之后进行缓存处理,避免重复操作。
  • 可以对真实对象进行延迟加载,在需要时才创建。
  • 可以隐藏真实对象的复杂度,提供简单接口。

主要缺点有:

  • 额外开销:由于代理对象需要干预所有对目标对象的访问,所以会带来一定的性能开销。
  • 引入复杂度:代理对象本身也比较复杂,需要维护对目标对象的引用,预处理请求等逻辑。这增加了系统的复杂性。

8.3 适用场景

代理模式通过引入代理对象来控制真实对象的访问,它经常用于 延迟加载访问控制缓存 等场景,有效地提高系统性能,是软件设计中比较常用和重要的模式之一。

在游戏开发中,代理模式常用于控制游戏对象的访问,实现延迟加载和缓存等功能。

例如:使用代理实现游戏对象的延迟加载。

// 定义游戏角色接口
interface IGameCharacter {
    fight(): void;
}
// 实现游戏角色的实际对象
class Character implements IGameCharacter {
    constructor(public name: string) { }

    fight() {
        console.log(`${this.name} 正在战斗!`);
    }
}
// 实现游戏角色的代理对象
class ProxyCharacter implements IGameCharacter {
    // 定义对实际对象的引用,初始为空
    private character?: Character;

    constructor(public name: string) { }

    fight() {
        // 首次请求时创建对象,实现延迟加载
        if (!this.character) {
            this.character = new Character(this.name);
        }
        this.character.fight();
    }
}

//客户端调用,创建代理角色对象
let proxy = new ProxyCharacter('小明');
// 第一次调用战斗方法
proxy.fight();        // 小明 正在战斗!
// 第二次调用战斗方法
proxy.fight();        // 小明 正在战斗!

可以看到,ProxyCharacter 是一个代理类,代理 Character 真实对象。在第一次访问时,它才会创建真实的 Character 对象,而不是在一开始就创建所有的对象,这可以节省系统资源,提高性能。

9. 状态模式

状态模式(State Pattern),是一种行为型设计模式。它允许一个对象在其内部状态改变时改变其行为,对象看起来似乎改变了其类。

9.1 基本结构

状态模式的基本思想是:当对象的内部状态改变时,对象的行为也随之改变。这个状态的改变是动态的,根据环境的改变来切换状态。

状态模式通常包括以下主要角色:

  • 环境(Context):也称为上下文,它定义了客户端使用的接口,并且负责具体状态的切换。
  • 抽象状态(State):定义一个接口以封装与环境有关的行为。
  • 具体状态(Concrete State):实现抽象状态接口并实现与环境有关的具体行为。

状态模式的结构如下:

环境(Context)
│      
│  抽象状态(State)
│       │ 
│       ├─  具体状态 A(ConcreteStateA) 
│       └─  具体状态 B(ConcreteStateB)
│            ...

9.2 优缺点

状态模式的主要优点:

  • 简洁:与条件语句相比,可读性更高、结构更清晰。
  • 扩展性:易于增加新状态和转换。

主要缺点有:

  • 复杂度:状态类数量过多时会过于复杂。
  • 耦合:环境与状态类耦合度高。
  • 开闭原则:增加新状态时,环境类需要修改。

9.3 适用场景

使用状态模式时需要考虑以下几点:

  1. 状态变化是否复杂?如果状态转换比较简单,使用状态模式得不偿失。
  2. 是否需要频繁添加新的状态和转换?如果状态较稳定,状态模式优点不明显。
  3. 是否可以减少环境与状态的耦合?我们可以在环境中持有状态的抽象,而非具体状态。
  4. 是否可以同时满足扩展性和开闭原则?可以考虑使用状态模式与策略模式组合。
  5. 采取措施减轻状态模式带来的影响,如控制状态类数量,提高状态类的可重用性等。

在游戏开发中,状态模式经常用来表示游戏对象的 状态机,当对象的状态改变时,对象的行为也会跟着改变。这使得游戏对象可以有更加真实和复杂的表现。

例如,可以实现游戏对象的状态机:

// 定义角色状态枚举
enum State {
    Standing,    // 站立
    Running,    // 跑动
    Jumping        // 跳跃
}

class Character {
    state: State = State.Standing;
    // 设置状态
    setState(state: State) {
        this.state = state;
    }
    // 移动
    move() {
        switch (this.state) {
            case State.Standing:
                console.log('站立状态移动');
                break;
            case State.Running:
                console.log('跑动状态移动');
                break;
            case State.Jumping:
                console.log('跳跃状态移动');
                break;
        }
    }
}

// 客户端调用
let character = new Character();
character.setState(State.Running);
character.move();  // 跑动状态移动

character.setState(State.Jumping);
character.move(); // 跳跃状态移动

可以看到,Character 的行为 (move() 方法) 会随着状态 ( standingrunningjumping ) 的改变而改变。这使得对象看起来就像改变了类一样。

9.4 补充讲解

9.4.1 如何添加新状态

还用前面的例子,如何给角色添加新的状态 Attack 攻击

// 定义角色状态枚举
enum State {
    Standing,
    Running,
    Jumping,
    Attack        // 新增状态,攻击
}

class Character {
    ...
    // 移动
    move() {
        switch (this.state) {
            ...
            case State.Attack:
                console.log('攻击状态');
                break;
        }
    }
}

// 客户端调用
let character = new Character();
character.setState(State.Attack);
character.move(); // 攻击状态

9.4.2 状态模式和策略模式的区别

状态模式和策略模式都是行为模式。

状态模式:

  • 封装状态转换逻辑,实现行为随状态而变化。
  • 根据状态来决定执行哪个行为。
  • 状态变化影响执行的行为。

策略模式:

  • 封装算法家族,可以在运行时选择不同算法实现。
  • 固定的上下文根据策略来决定使用哪个算法。
  • 策略的改变影响所使用的算法。

两者区别的关键在于状态和策略本身的区别:

  1. 状态模式注重状态之间的切换以及行为的改变,而策略模式更关注在一个固定环境下选择不同的算法来实现行为。
  2. 状态模式中同时只能处于一种状态。而策略模式可以同时使用多个策略。
  3. 状态模式中环境类通常会有多个状态类的实例,并且环境类的行为会随着状态的改变而改变。而在策略模式中,环境类具有固定的行为,只是具体的算法实现会改变。
  4. 增加新状态时,环境类要做相应的调整,增加新策略时,不需要改变环境类。

从这几个方面可以看出,。两者都是用于实现系统的动态行为,但是侧重点不同。

状态模式——状态变化 → 行为变化
策略模式——算法选择 → 行为实现

9.4.3 状态模式和策略模式结合使用

可以结合使用状态模式和策略模式,最大限度地发挥每种模式的优势。

例如在前面的例子中,使用策略模式的思想,使其符合开闭原则。

可以这样设计:

定义状态抽象类。

abstract class State {
    // 进入该状态
    enter() { }
    // 退出该状态
    exit() { }
    // 执行状态行为
    execute() { }
}

定义三种具体状态类:

class StandState extends State {
    enter() {
        console.log('进入站立状态');
    }
    exit() {
        console.log('退出站立状态');
    }
    execute() {
        console.log('站立状态移动');
    }
}

class RunState extends State {
    enter() {
        console.log('进入跑动状态');
    }
    exit() {
        console.log('退出跑动状态');
    }
    execute() {
        console.log('跑动状态移动');
    }
}

class JumpState extends State {
    enter() {
        console.log('进入跳跃状态');
    }
    exit() {
        console.log('退出跳跃状态');
    }
    execute() {
        console.log('跳跃状态移动');
    }
}

环境(游戏角色)类:

class Character {
    state?: State;
    // 设置状态
    setState(state: State) {
        this.state?.exit();
        this.state = state;
        this.state.enter();
    }
    // 移动
    move() {
        this.state?.execute();
    }
}

客户端调用

// 客户端调用
let character = new Character();
character.setState(new StandState());    // 进入站立状态
character.move();  // 跑动状态移动

character.setState(new JumpState());    // 退出站立状态  进入跳跃状态
character.move(); // 跳跃状态移动

10. 命令模式

命令模式(Command Pattern),是一种行为设计模式,它将一个请求封装为一个对象,以便支持事件、交易、撤消等操作。

10.1 基本结构

命令模式中的主要角色包括:

  • 抽象命令(Command):声明执行操作的接口。
  • 具体命令(Concrete Command):定义一个接收者和行为之间的具体绑定。实现抽象命令接口。
  • 客户端(Client):创建具体命令对象并设置其接收者。
  • 调用者/请求者(Invoker):向命令对象进行请求,并不知道具体的命令行为。
  • 接收者(Receiver):知道如何实施与之相关的命令操作,任何类都可能作为命令的接收者。

命令模式的结构图如下:

抽象命令(Command)
   │
   ├─ 具体命令A(ConcreteCommandA)
   │  │       
   │  └─ 接收者A(ReceiverA)
   └─ 具体命令B(ConcreteCommandB) 
       │  
       └─ 接收者B(ReceiverB)
               
调用者(Invoker)  
               │       
               └─ 命令(Command)

10.2 优缺点

命令模式的主要优点:

  • 解耦调用者和接收者。调用者只需要知道具有命令接口的命令对象,而不必知道具体的命令实现与接收者,两者可以独立变化。
  • 容易设计命令队列。通过命令对象,可以将多个命令装入队列,并控制执行顺序,实现宏命令等功能。
  • 可以实现命令的延迟执行。通过调用者来决定何时执行命令。
  • 容易实现可撤销操作。只需要在命令队列中存储命令对象,即可根据需要撤销上一步或上 N 步操作。
  • 容易实现事件模型。命令对象可以作为事件触发的参数被传递,接收者根据事件执行相应命令。

命令模式的主要缺点:

  • 可能会导致某些系统有过多的具体命令类。如果都设计成具体命令类,会增加系统的复杂度。
  • 一个系统通常只需要实现命令接口的一部分命令,但需要全部实现,这会增加系统的开发难度与测试难度。
  • 某些系统的命令比较简单,使用命令模式可能造成复杂度上升,带来的好处并不明显。
  • 由于需要引入命令对象,可能会影响系统的性能与资源占用。

10.3 适用场景

命令模式适用的主要场景有:

  1. 需要将请求调用者和接收者解耦,使得调用者不需要知道接收者的详细信息。
  2. 需要支持命令的撤销、重做、事务等功能。
  3. 需要参数化某些请求,将参数化的请求对象作为参数传递。
  4. 需要支持宏命令,将多个命令组合成一个命令对象。

在游戏开发中,命令模式主要应用在玩家 输入处理NPC 行为控制操作撤销/重做 等方面。

例如,我们可以设计一个 RPG 游戏战斗系统,通过命令模式实现不同的技能和行动。

首先定义技能和行动的基类以及角色类:

// 动作基类
abstract class Action {
    actor: Actor;
    target?: Actor;

    constructor(actor: Actor, target?: Actor) {
        this.actor = actor;
        this.target = target;
    }

    public abstract execute(): void;
}
// 技能基类
abstract class Skill extends Action {
    name: string;

    constructor(name: string, actor: Actor, target?: Actor) {
        super(actor, target);
        this.name = name;
    }
}
// 角色类
class Actor {
    name: string;
    health: number;
    constructor(name: string, health = 100) {
        this.name = name;
        this.health = health;
    }
}

然后定义一些具体的技能和行动:

// 攻击技能类
class AttackSkill extends Skill {
    execute() {
        console.log(`${this.actor.name} 使用 ${this.name} 攻击 ${this.target!.name}`);
        this.target!.health -= 10;  // 损失生命值
    }
}
// 治疗技能类
class HealSkill extends Skill {
    execute() {
        console.log(`${this.actor.name} 使用 ${this.name} 治疗了 ${this.target!.name}`);
        this.target!.health += 10;  // 恢复生命值
    }
}
// 逃跑类
class RunAway extends Action {
    execute() {
        console.log(`${this.actor.name} 逃跑了!`);
    }
}

actor 是施法者,target 是受术者,这些技能和行动通过 execute 方法来执行效果。

然后我们可以定义 命令队列,控制不同角色的行动:

// 命令队列
class CommandQueue {
    private commands: Action[] = [];

    addCommand(command: Action) {
        this.commands.push(command);
    }

    execute() {
        const command = this.commands.shift();
        if (command) {
            command.execute();
        }
    }
}

客户端调用时。

// 定义两个战斗的角色
const actor = new Actor('小明');
const target = new Actor('小红');
// 定义命令列表
let queue = new CommandQueue();

// 向列表中添加命令
queue.addCommand(new AttackSkill('火球术', actor, target));
queue.addCommand(new HealSkill('治疗术', actor, target));
queue.addCommand(new RunAway(target));

// 执行列表中的命令
console.log(target.health); // 100(小红血量)

queue.execute();        // 小明 使用 火球术 攻击 小红
console.log(target.health); // 90(小红血量)

queue.execute();        // 小明 使用 治疗术 治疗了 小王
console.log(target.health); // 100(小红血量)

queue.execute();            // 小红 逃跑了!

可以看到,我们将所有技能封装为命令对象,通过命令队列来控制执行,并且可以任意组合不同的技能和行动,这使得战斗系统拥有更大的灵活性。

10.4 补充讲解

下面补充讲解一些示例,以便更好的理解命令模式的特点。

10.4.1 使用命令模式和不使用的区别

如果没有命令模式,我们的代码可能如下:

class Actor {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    attack(target: Actor) {
        console.log(`${this.name} 使用 ${this.name} 攻击 ${target.name}`);
    }
    heal(target: Actor) {
        console.log(`${this.name} 使用 ${this.name} 治疗了 ${target.name}`);
    }
    runAway() {
        console.log(`${this.name} 逃跑了!`);
    }
}

let actor = new Actor("小明");
let target = new Actor('小红');

actor.attack(target);    // 小明 使用 小明 攻击 小红
actor.heal(target);        // 小明 使用 小明 治疗了 小红
actor.runAway();        // 小明 逃跑了!

可以看到,行为的调用者 ( actor ) 与具体实现 ( attackhealrunAway 方法 ) 是紧密耦合的。如果我们要改变调用顺序、实现撤销操作,或基于事件驱动执行行为,这种紧耦合的代码会很难维护和扩展。

而借助命令模式,我们可以这样调用:

// 实例化命令对象
let attackCmd = new AttackCommand(actor, target);
let healCmd = new HealCommand(actor, target);
let runAwayCmd = new RunAwayCommand(actor);

let queue = new CommandQueue();
// 将命令对象放入命令队列
queue.add(attackCmd);
queue.add(healCmd); 
queue.add(runAwayCmd);

// 执行命令
queue.execute(); // 执行攻击命令
queue.execute(); // 执行治疗命令  
queue.execute(); // 执行逃跑命令

调用者 ( CommandQueue ) 与接收者 ( actor ) 解耦,行为通过 命令对象 来抽象,调用者只需要知道和持有这些命令对象,而无需关心具体细节。

命令模式的主要优势不在于任何单一操作,而在于它可以让调用者与接收者解耦,灵活控制和组合不同的行为,从而实现更加强大的功能。

10.4.2 命令模式如何实现撤销操作

例如,我们可以这样实现一个简单的可撤销系统。

首先,定义命令接口类,和移动命令类。

interface ICommand {
    execute(): void;
    undo(): void;
}

class MoveCommand implements ICommand {
    constructor(private gameObject: GameObject) { }

    execute() {
        this.gameObject.move();
    }

    undo() {
        this.gameObject.undoMove();
    }
}

定义游戏对象类。

class GameObject {
    move() {
        console.log("移动:前进 10 米");
    }
    undoMove() {
        console.log("撤销:后退 10 米");
    }
}

定义命令队列类。

// 命令队列
class CommandQueue {
    private commands: ICommand[] = [];
    private history: ICommand[] = [];

    addCommand(command: ICommand) {
        this.commands.push(command);
    }

    execute() {
        const command = this.commands.shift();
        if (command) {
            command.execute();
            this.history.push(command);
        }
    }

    undo() {
        const command = this.history.pop();
        if (command) {
            command.undo();
        }
    }
}

客户端调用时。

let actor = new GameObject();
let queue = new CommandQueue();

queue.addCommand(new MoveCommand(actor));
// 移动
queue.execute();        // 移动:前进 10 米
// 撤销上一步移动
queue.undo();             // 撤销:后退 10 米

可以看到,我们将移动命令定义为一个实现了 ICommandMoveCommand 类。CommandQueue 负责执行这些命令并将其保存到历史记录中。通过调用 undo 方法,我们可以撤销上一步操作。

所以,命令模式通过将 请求/操作 封装为 对象 ,实现命令的 队列化记录撤销,这为游戏开发带来了极大便利,是实现游戏的 可撤销系统事件系统 以及 AI 系统 的重要手段。


以上便是游戏开发中常用的 10 种设计模式。

最后修改:2023 年 04 月 28 日 12 : 37 PM
如果觉得我的文章对你有用,请随意赞赏