游戏编程模式:(一)设计模式【typescript】

  • 抽象和解耦能使程序开发变得更快更简单,但不要浪费时间来做这件事,除非你确信存在问题的代码需要这种灵活性。
  • 在你的开发周期中要对性能进行思考与设计,但是要推迟那些降低灵活性的、底层的、详尽的优化,能晚则晚。
  • 尽快地探索你的游戏的设计空间,但是不要走的太快留下一堆烂摊子。
  • 如果你将要删除代码,那么不要浪费时间将它整理的很整洁。
  • 最重要的是,若要做一些有趣的玩意,那就乐在其中地做吧。
    【游戏编程模式】在线书籍

1. 命令模式:

  • a. 定义
    将一个请求封装成一个对象,从而允许你使用不同的请求、队列或日志将客户端参数化,同事支持请求操作的撤销与恢复。
    命令是具现化的方法调用,一个对象,可以存储在变量中传递给函数,意思是封装在对象中一个方法的调用
    命令模式是面向对象化的回调

    缺点:可能会浪费内存

  • b. 示例
    游戏中读取键盘,执行对应的行为,比如
    键盘X-->jump();//跳跃
    键盘Y-->fireGun();//开火
    键盘B-->lurch();//倾斜
    键盘A-->swapWeapon();//切换武器

  • 一个简单的实现

    function handleInput(){//处理输入
      if("X") jump();
      else if("Y") fireGun();
      else ...
    }
    

    将用户的输入和程序行为硬编码在一起时,这代码是可以正常工作的,但是许多游戏允许玩家配置按键功能。为了支持这点,我们将直接调用转化成可变换的东西。进入命令模式

  • 命令模式实现

    /** 行为命令接口 */
    export interface Command {
        execute(): void;
    }
    /** 跳跃子类实现 */
    export class JumpCommand implements Command {
        execute() {
            jump();//执行跳跃
        }
    }
    /** 开火子类实现 */
    export class FireCommand implements Command {
        execute() {
            fireGun();//执行开火
        }
    }
    /** 输入类  绑定键盘按钮 */
    export class InputHander {
        cmdX: Command;
        cmdY: Command;
        cmdB: Command;
        cmdA: Command;
        constructor() {
            let itself = this;
            itself.cmdX= new JumpCommand();
            itself.cmdY= new FireCommand();
            //其他绑定方法
        }
        /** 键盘输入处理,通过命令执行方法 */
        handleInput() {
            let itself = this;
            if ("X") itself.cmdX.execute();
            else if ("Y") itself.cmdY.execute();
            //...
        }
    }
    

    以前每个输入直接调用一个函数,现在则增加了一个间接调用。但是,这个命令类却还是有局限性的。这隐式的知道了玩家游戏实体进行木偶般操控,限制了使用范围,命令只能作用于玩家。
    让我们放宽限制,传入一个想控制的对象:

    /** 一个游戏对象 */
    export class GameActor {
        jump() { };
        fireGun() { };
    }
    /** 行为命令接口 */
    export interface Command {
        execute(actor: GameActor): void;//执行方法传入游戏对象
    }
    /** 跳跃子类实现 */
    export class JumpCommand implements Command {
        execute(actor: GameActor) {
            actor.jump();//执行跳跃
        }
    }
    /** 开火子类实现 */
    export class FireCommand implements Command {
        execute(actor: GameActor) {
            actor.fireGun();//执行开火
        }
    }
    //这里我们修改一下方法,执行命令改为返回对应的命令。
    /** 输入类  绑定键盘按钮 */
    export class InputHander {
        cmdX: Command;
        cmdY: Command;
        //绑定....
    
        /** 键盘输入处理  这里改为返回命令 */
        handleInput() {
            let itself = this;
            if ("X") return itself.cmdX;
            else if ("Y") return itself.cmdY;
            //...
        }
    }
    //现在它不能立即执行,因为它不知道该传入哪个角色对象。这里我们利用的是命令即具体化的函数调用。
    //我们接收命令、象征着玩家的角色执行命令:
    let actor = new GameActor();//假设这个一个游戏对象
    let command = new InputHander().handleInput();
    if (command) {
        command.execute(actor);
    }
    

    但是对于游戏中的非玩家角色呢?它们由游戏AI来驱动。我们可以照搬上面的命令模式来作为AI引擎和角色之间的接口;

  • 撤销和重做
    如果没有命令模式,撤销和重做是很困难的(书上说的,我也还没有其他想法emmm)。
    假设一个回合制游戏,想让玩家能撤销一些行动,因为是抽象输入,所以角色的每个行动都要封装起来,比如移动一个单位:

    /** 单位类 */
    export class Unit {
        x: number;
        y: number;
        /** 移动 */
        moveTo(x: number, y: number) { };
    }
    /** 移动单位类 */
    export class MoveUnitCommand implements Command {
        _unit: Unit;
        _x: number;
        _y: number;
        constructor(unit: Unit, x: number, y: number) {
            let itself = this;
            itself._unit = unit;
            itself._x = x;
            itself._y = y;
        }
        execute() {
            let itself = this;
            itself._unit.moveTo(itself._x, itself._y);
        }
    }
    //注意这里和前面的命令不太相同,前面我们想要从被操控的角色中抽象出命令,以便于和命令解耦。
    //这里,我们特别希望将命令绑定到被移动的单位上。
    //这个命令的实例不是一般性质的”移动某些物体”,在游戏回合次序中,它是一个特定的具体移动
    
    //前面一个命令代表一个可重用的对象,表示一件可完成的事,这个例子的命令更加具体,表示特定时间点完成的事,这意味着每次玩家选择一个动作,都会创建一个命令实例。
    function getSelecedUnit() {//假设是选择一个单位的获取方法
        return new Unit();
    }
    /** 输入处理变成了这样 */
    export class InputHander {
        /** 键盘输入  这里改为返回命令 */
        handleInput() {//
            let unit = getSelecedUnit();
            if ("Up") {//向上移动unit 1个单位
                let destY = unit.y - 1;
                return new MoveUnitCommand(unit, unit.x, destY);
            } else if ("down") {
                let destY = unit.y + 1;
                return new MoveUnitCommand(unit, unit.x, destY);
            }//...
            return null;
        }
    }
    

    为了使命令变得可撤销,我们定义一个操作,每个命令类都实现它:

    export interface Command {
        execute(): void;
        undo(): void;//undo方法会反转由对应的execute方法改变游戏状态。
    }
    export class MoveUnitCommand implements Command {
        _unit: Unit;
        _x: number;
        _y: number;
        _xBefore: number;
        _yBefore: number;
        constructor(unit: Unit, x: number, y: number) {
            let itself = this;
            itself._unit = unit;
            itself._x = x;
            itself._y = y;
            itself._xBefore = itself._yBefore = 0;
        }
        execute() {
            let itself = this;
            //在移动前记住位置
            itself._xBefore = itself._unit.x;
            itself._yBefore = itself._unit.y;
            itself._unit.moveTo(itself._x, itself._y);
        }
        undo() {
            let itself = this;
            itself._unit.moveTo(itself._xBefore, itself._yBefore);
        }
    }
    

    对于多次撤销,我们可以用一个命令列表+当前index。当玩家执行一个命令,将命令添加到列表,index++。
    最后,在某些方面,命令模式对于没有闭包的语言来说是模拟闭包的一种方式

2. 享元模式:(看起来像类型对象模式,但背后的意图是不同的)

 在命令模式的结尾,作者说道,拥有不止一个这样的命令类的实例会浪费内存,享元模式就是用来解决这个问题的。

  • a. 定义
    一般说来当你有太多对象并考虑对其进行轻量化时便可派上用场。
    通过将对象数据切分成两种类型来解决问题:一种是能共享的数据(内在的或者上下文无关的),一种是对对象是唯一的数据。

  • b. 示例
    假设一个游戏世界,有数以千计的树木,每棵树又包含成千上万的多边形。每棵树都有着与之关联的数据:

    一个多边形:树干、树枝、树叶
    树皮和树叶的纹理
    树的位置和朝向
    调节参数:大小、颜色等。

    如果用代码表示,将得到这样的结构:

    export class Tree{
        _mesh: Mesh;//网格
        _bark: Texture;//树皮
        _leaves: Texture;//树叶
        _position: Vector;//位置
        _height: number;
        _thickness: number;//大小,厚度?
        _barkTint: Color;//树皮颜色
        _leafTint: Color;//树叶颜色
    }
    

    这里的数据量很大,尤其是网格和纹理。想要将包含整个森林的对象数据在1帧内传给GPU几乎不可能。
    但是,它们大部分是相识的,比如相同的网格和纹理。

    我们可以将对象分成两个独立类:通用的数据类TreeModel和Tree

    export class TreeModel {
        _mesh: Mesh;//网格
        _bark: Texture;//树皮
        _leaves: Texture;//树叶
    }
    export class Tree {
        _model: TreeModel;//通用数据
    
        _position: Vector;//位置
        _height: number;
        _thickness: number;//大小,厚度?
        _barkTint: Color;//树皮颜色
        _leafTint: Color;//树叶颜色
    }
    


    游戏世界中的每一棵树的示例都指向TreeModel的引用(c++示例用的是TreeModel* _model,这里注意一下怎么使用引用而不是新的实例)。

  • c. 再来一个栗子:游戏世界的地面
    地面的地形有草地、泥土、丘陵、湖泊、河流等地形。我们使用基于Tile瓦片的技术来构建地面,由许多个小瓦片组成巨大的网格。
    每一种地形都有一些影响着游戏玩法的属性

    移动开销:决定角色通过此地形的速度
    地形标志:比如是否是水域
    纹理:渲染地形

    通常做法:用一个枚举表示地形类型

    export enum Terrain{
        terrain_grass,//草地
        terrain_hill,//丘陵
        terrain_river,//河流
        //...
    }
    /** 游戏世界包含大量的瓦片对象 */
    export class World{
        tiles: Terrain[][];//格子,行列或者宽高
        //获得瓦片数据的实现
        /** 移动成本 */
        getMovementCost(x: number, y: number) {
            let itself = this;
            switch (itself.tiles[x][y]) {
                case Terrain.terrain_grass: return 1;
                case Terrain.terrain_hill: return 2;
                case Terrain.terrain_river: return 3;
                //...
            }
        }
        /** 是否湿地 */
        isWater(x: number, y: number) {
            let itself = this;
            switch (itself.tiles[x][y]) {
                case Terrain.terrain_grass: return false;
                case Terrain.terrain_hill: return false;
                case Terrain.terrain_river: return true;
                //...
            }
        }
    }
    

    如你所见,可以运行,但是比较简陋。把移动开销和湿地当作地形数据,但这里却被散落在代码中,单一的地形数据被一堆方法拆开了。
    将所有的数据封装在一起会更好。像下面这样实现,是非常值得肯定的。

    export class Terrain {
        _moveCost: number;
        _isWater: boolean;
        _texture: Texture;
        constructor(movementCost: number, isWater: boolean, texture: Texture) {
            let itself = this;
            itself._moveCost = movementCost;
            itself._isWater = isWater;
            itself._texture = texture;
        }
        getMoveCost() {
            return this._moveCost;
        }
        isWater() {
            return this._isWater;
        }
        getTexture() {
            return this._texture;
        }
    }
    

    但是我们并不希望为每个瓦片构建地形实例付出成本。这个瓦片类中并没有标识其位置的特殊代码。在享元术语中,地形的所有状态都是”内在的“或者“上下文无关的“。
    因此我们没有理由构建多个同种地形类型。我们不使用枚举或者地形对象网格,而是使用指向地形对象的网格指针。

    //每一个使用相同地形的瓦片将会指向相同的地形实例
    export class World {
        tiles: Terrain[][];
        _grassTerrain: Terrain;
        _hillTerrain: Terrain;
        _riverTerrain: Terrain;
        constructor() {
            let itself = this;
            itself._grassTerrain = new Terrain(1, false, Grass_Texture);
            itself._hillTerrain = new Terrain(3, false, Hill_Texture);
            itself._riverTerrain = new Terrain(2, true, River_Texture);
        }
        /** 生成地形 */
        generateTerrain() {
            let itself = this;
            //填充草地
            for (let x = 0; x < WIDTH; x++) {
                for (let y = 0; y < HEIGHT; y++) {
                    //随机一些丘陵
                    if (Math.random() < 0.1) {
                        itself.tiles[x][y] = itself._hillTerrain;
                    } else {
                        itself.tiles[x][y] = itself._grassTerrain;
                    }
                }
            }
            //放置一些河流
            let x = Math.floor(Math.random() * WIDTH);
            for (let y = 0; y < HEIGHT; y++) {
                itself.tiles[x][y] = itself._riverTerrain;
            }
        }
        //暴露地形对象
        getTile(x: number, y: number) {
            return this.tiles[x][y];
        }
    }
    //如果想得到砖块的一些属性:这里只是模拟
    let cost = new World().getTile(2, 3).getMoveCost();
    

    性能表现需要将指针和枚举的性能做比较。

3. 观察者模式

比如MVC架构,其根本,是因为观察者模式。

  • a. 栗子:
    假设成就系统,有一个成就:“从桥上掉落”,这和物理引擎相关,我们并不希望游戏中的处理撞击代码时有个unlockFallOffBridge()的调用不是吗?
    我们喜欢的是让关于游戏一部分的所有代码集成到一块,但是不同层面的代码怎么解耦成就系统和其他部分呢?这就需要观察者模式了。
    这让代码宣称有趣的事情发生了,而不必关心到底是谁接受了通知
    export class Physics{
        updateEntity(entity: Entity) {
            entity.accelerate(GRAVITY);//重力加速
            entity.update();
            if (wasOnSurface && !entity.isOnSurface()) {
                notify(entity, EVENT_START_FALL);//通知entity:掉落事件。这代表:“额,我不知道有谁感兴趣,但是这个东西刚刚掉下去了,做你想做的事吧”
            }
        }
    }
    
    成就系统注册他自己为观察者,这样无论何时发送通知,成就系统都能收到。实现代码:
    export class Entity {//构建一个实体
        isHero() {
            return true;
        }
    }
    export enum Event_Observer {//定义几个事件
        /** 掉落事件 */
        Entiy_Fall,
    }
    export enum Achievement {//定义几个成就
        /** 掉落在桥上 */
        ACHIEVEMENT_FELL_OFF_BRIDGE,
    }
    
    //观察者:
    //这是一个需要知道别的对象做了什么事的类
    export interface Observer {
        onNotify(entity: Entity, event: Event_Observer): void;//监听通知
    }
    //实现这个类成为观察者。比如成就系统
    export class Achievements implements Observer {
        heroIsOnBridge: boolean = true;
        unlock(achievement: Achievement) {
            // 如果还没有解锁,那就解锁成就……
            console.log("掉落在了桥上", achievement);
        }
        onNotify(entity: Entity, event: Event_Observer) {
            switch (event) {
                case Event_Observer.Entiy_Fall: {
                    if (entity.isHero() && this.heroIsOnBridge) {
                        this.unlock(Achievement.ACHIEVEMENT_FELL_OFF_BRIDGE)//解锁成就
                    }
                } break;
                //处理其他事件,更新heroIsOnBridge_变量……
            }
        }
    }
    //被观察者:
    //拥有通知的方法函数。它有一个列表,保存等通知的观察者,
    export class Subject {
        private observers_: Observer[] = [];
        private numObservers_: number = 0;
    
        //被观察者暴露 公开的 API来修改这个列表
        addOberver(observer: Observer) {
            let itself = this;
            itself.observers_.push(observer);//添加到数组中
            itself.numObservers_++;
        }
        removeObserver(observer: Observer) {
            let itself = this;
            let index = itself.observers_.indexOf(observer);
            if (index >= 0) {
                itself.observers_.splice(index, 1);//从数组中移除
                itself.numObservers_--;
            }
        }
        //发送通知
        notify(entity: Entity, event: Event_Observer) {
            let itself = this;
            for (let i = 0; i < itself.numObservers_; i++) {
                itself.observers_[i].onNotify(entity, event);
            }
        }
    }
    //物理系统与成就系统
    export class Physics extends Subject {
        updateEntity(entity: Entity) {
            let itself = this;
            itself.notify(entity, Event_Observer.Entiy_Fall);
        }
    }
    //验证:
    let entity = new Entity();
    let observer = new Achievements();
    let physics = new Physics();
    physics.addOberver(observer);
    physics.updateEntity(entity);
    
    这里将notify()实现为Subject内的保护方法,这样只有派生类的物理引擎类可以调用并发送通知,外部的代码不行。
    值得注意的是,被观察者直接调用了观察者,这意味着直到观察者的通知方法返回后,被观察者才会进行下一步。具体请看原文解释。
    这里每次加入都做了内存的动态分配,下面是一种无需动态分配的方式:链式观察者
    如果我们愿意在Observer中放一些状态,我们可以将观察者的列表,分布到观察者自己中,来解决动态分配问题。
    //这里仅仅贴了修改的代码
    export interface Observer {
        next_: Observer;
    }
    export class Achievements implements Observer {
        next_: Observer = null;
    }
    export class Subject {
        /** 一个指向观察者的指针或者地址 */
        private head_: Observer;
        addOberver(observer: Observer) {
            let itself = this;
            observer.next_ = itself.head_;
            itself.head_ = observer;
        }
        removeObserver(observer: Observer) {
            if (!observer) {
                return;
            }
            let itself = this;
            if (itself.head_ == observer) {
                itself.head_ = observer.next_;
                observer.next_ = null;
                return;
            }
            let curr = itself.head_;
            while (curr != null) {
                if (curr.next_ == observer) {
                    curr.next_ = observer.next_;
                    observer.next_ = null;
                    return;
                }
                curr = curr.next_;
            }
        }
        //发送通知
        notify(entity: Entity, event: Event_Observer) {
            let itself = this;
            let observer = itself.head_;
            while (observer != null) {
                observer.onNotify(entity, event);
                observer = observer.next_;
            }
        }
    }
    
    缺点是:一个观察者,一次只能观察一个被观察者。解决这个问题可以使用链表节点池
    简答来说是:被观察者的head指向的不是观察者,而是一个个节点node,每个节点node指向对应观察者。

4. 原型模式

一个对象可以生成与自身相似的其他对象。

  • a. 栗子
    我们为游戏里的3中怪物类型(幽灵、恶魔、术士)分别设计了3个类,
    并为每种怪物设计一个生成器类
    export interface Monster {//怪物基类
        //stuff
    }
    export class Ghost implements Monster { }//幽灵类
    export class Demon implements Monster { }//恶魔类
    export class Sorcerer implements Monster { }//术士类
    
    export interface Spawner {//生成器基类
        spawnMonst(): Monster;
    }
    export class GhostSpawner implements Spawner {//幽灵生成类
        spawnMonst() {
            return new Ghost();
        }
    }
    export class DemonSpawner implements Spawner {//恶魔生成类
        spawnMonst() {
            return new Demon();
        }
    }
    export class SorcererSpawner implements Spawner {//术士生成类
        spawnMonst() {
            return new Sorcerer();
        }
    }
    
    除非薪资以代码行数来计算,否者这显然不是一个好设计:太多类,太多样板,太多冗余,太多重复代码....
    原型模式:如果你有一个幽灵,则你可以通过这个幽灵制作出更多幽灵。设计基类Monster他有一个抽象方法clone()。
    export interface Monster {//怪物基类
        clone(): Monster;
    }
    export class Ghost implements Monster { //幽灵类
        private health_: number;//血量
        private speed_: number
        constructor(health: number, speed: number) {
            let itself = this;
            itself.health_ = health;
            itself.speed_ = speed;
        }
        clone() {
            let itself = this;
            return new Ghost(itself.health_, itself.speed_);
        }
    }
    
    export class Spawner {//生成器类
        prototype_: Monster;
        constructor(prototype: Monster) {
            this.prototype_ = prototype;
        }
        spawnMonst(): Monster {
            return this.prototype_.clone();
        };
    }
    //创建一个幽灵生成器,先创建一个幽灵原型实例
    let ghostPrototype = new Ghost(15, 3);
    let ghostSpawner = new Spawner(ghostPrototype);
    
    比较优雅的是,不仅克隆了原型类,还克隆了状态。但是,实际上这与之前的代码量差不多。如果一个魔王拿着叉子,那么克隆出来的也需要拿着叉子么?
    我们定义
posted @ 2022-04-07 18:07  何吓吓  阅读(246)  评论(0)    收藏  举报