Double-Dartris






4.88/5 (5投票s)
在Web上实现类似于俄罗斯方块的游戏,使用Dart
引言
在本文中,我将介绍一种使用 Dart 实现 HTML5 俄罗斯方块式游戏的方法。鉴于我之前已经介绍过使用 Dart 进行 Web 游戏开发的基础知识,本文的重点将放在实现俄罗斯方块式游戏的逻辑和操作上。
如果想看看它运行起来是什么样子,有一个早期编译到 JavaScript 的版本。
使用代码
zip 存档包含一个 Dart 编辑器项目,解压缩并导入即可。
要求
我为我的俄罗斯方块实现定义了一系列要求:
- 某种形式的效果;我希望在消除一行时能有一些额外的效果,而不仅仅是方块消失。
- 墙壁踢击;一些俄罗斯方块式游戏的特点是,如果玩家试图旋转一个过于靠近游戏区域边缘的方块,它会被“踢”到游戏区域中心(前提是那里有空间)。
- 声音;游戏在不同事件发生时应播放声音。
- 可配置按键;我认为游戏开发中最难的部分之一是完成所有非游戏玩法特有的内容,例如介绍和菜单。因此,对于这个游戏,我至少需要添加一种配置按键的基本方式。
- 特色;游戏不应该是纯粹标准的俄罗斯方块版本,应该有一些特色。
关于“特色”部分,我最终做了“双重”的机制,玩家同时控制两个游戏,一个上下颠倒,另一个正向。
基本原理
在俄罗斯方块中,玩家旋转和移动由四个方块组成的方块。方块的外观或构造规则是:每个方块必须与其邻居相邻,并且至少一面完整的边与至少一个邻居共享。
方块的排列有七种不同的组合。如果只有三个方块,则有三种组合;如果五个方块,则有 * 种组合。
由于规则简单,可以生成所有可能的方块。如果方块由五个或更多方块组成,我会采取这种方法;但只有七种可能的方块时,则不需要。手动定义这七种方块更快、更整洁(我认为)。
在 Double-Dartris 中,有三个类是基础的:
- Block;代表一个单独的方块,或者是一个下落方块的一部分。
- Piece;代表一个下落的方块,它是由一组- Blocks 组成的。
- PlayingField;代表方块下落的区域,以及方块堆积的区域。
 
    代码
块
Block 类代表一个单独的方块,无论是作为下落的 Piece 的一部分,还是作为已经堆积在 PlayingField 中的 Block。我需要 Blocks 和 Pieces 的原因在于,一个 Piece 的特性一旦固定就会发生巨大变化,而且消除一行可能会消除 Piece 的一部分。
由于 Block 是一个非常基本事物的抽象,它基本上只包含两样东西:
- 职位
- Color
除了存储上述数据之外,它还知道如何渲染自身。
请注意,它没有尺寸的概念,而尺寸对于渲染是必需的。这是因为方块具有隐式的 1x1 尺寸,并且 render 方法知道如何将其转换和缩放到正确的屏幕位置。它通过将一个“游戏区域”投影到一个“屏幕”来实现这一点。
void render(final CanvasRenderingContext2D context, final Rect screen, final Rect gameArea) {
  final int w = screen.width ~/ gameArea.width;   
  final int h = screen.height ~/ gameArea.height;
  final Rect block = new Rect(w * (gameArea.left + position.x), h * (gameArea.top + position.y ), w, h);
  fillBlock(context, block, _color);
}
    
    在上面,screen 是像素单位的 Rect,而 gameArea 是以场地单位表示的,即如果游戏区域宽十个方块,高二十个方块,则 gameArea 为 10x20。
这样做可以将游戏逻辑与渲染逻辑解耦。Block 可以渲染在小区域或全屏,并且仍然看起来正确,因为宽度 w 和高度 h 的计算方式是,如果 gameArea 被拉伸到 screen 上时得到的距离。
运算符 ~/ 是 Dart 的便利运算符,它给出除法的整数结果。它是标准除法加上显式整数转换的更快、更简洁的版本。
// Do this
final int w = screen.width ~/ gameArea.width;
// Do not do this
final int w = (screen.width / gameArea.width).toInt();
    
    Block 的完整列表如下:
class Block {
  final Position position;
  final ColorPair _color;
  
  Block(this.position, this._color);
  String toString() => "B@$position";
  
  get hashCode => position.hashCode;
  operator ==(final Block other) {
    return position == other.position;
  }
  
  void render(final CanvasRenderingContext2D context, final Rect screen, final Rect gameArea) {
    final int w = screen.width ~/ gameArea.width;   
    final int h = screen.height ~/ gameArea.height;
    final Rect block = new Rect(w * (gameArea.left + position.x), h * (gameArea.top + position.y ), w, h);
    fillBlock(context, block, _color);
  }
}    
    
    认为两个 Blocks 相等,即使它们的颜色不同但位置相同,这似乎有些奇怪。但 PlayingField 中的位置使一个方块独一无二(当我在后面介绍 PlayingField 时会很明显)。
Piece
Piece 类代表当前在 PlayingField 中下落的四个 Blocks 的组合。它将渲染委托给 Block 上的 render 方法。
方块的形状由其 final List<Block> _positions; 成员定义,该成员存储构成 Piece 的“默认”配置的方块。使用 PlayingField 中位置和旋转的属性,Piece 在每个渲染帧上将“默认” Blocks 转换为当前表示。这显然效率不高,但对于像俄罗斯方块这样简单的游戏来说,应该影响不大。
List<Block%gt; _getTransformed() {
  final List<Block%gt; transformed = new List<Block%gt;();
  for(int i = 0; i < _positions.length; ++i) {
    Block block = _positions[i];
    for(int r = 0; r < _rotation; ++r) {
      block = new Block(new Position(-block.position.y, block.position.x), block._color);
    }
    transformed.add(new Block(_position + block.position, block._color));
  }
  
  return transformed;
}
void render(final CanvasRenderingContext2D context, final Rect screen, final Rect gameArea) {
  final List<Block%gt; transformed = _getTransformed();
  for(int i = 0; i < transformed.length; ++i) {
    final Block block = transformed[i];
    block.render(context, screen, gameArea);
  }
}
    
    Piece 类的另一项职责是接受控制器发出的移动和旋转请求。不是在移动之前检查移动是否有效(我所谓的有效是指不移出 PlayingField 的边界或移入另一个 Block),而是接受所有移动,并在完成后检查 PlayingField 当前状态的有效性,如果发现无效,则回滚到前一个状态。
使用这种尝试-回滚方法,移动(水平移动、软下落和旋转)的各个方法变得相当简单,基本上只是将一个非常小的负载委托给一个名为 _tryAction 的方法。
  /// This method runs the transaction delegate, then verifies that the piece is still in a valid position
  /// if the field, if it is not valid the rollback delegate is run to undo the effect.
  bool _tryAction(void transaction(), void rollback(), final Set<Block%gt; field, final Rect gameArea) {
    transaction();
    final Iterable<Block%gt; transformed = _getTransformed();
    final int lastLine = falling ? gameArea.bottom : gameArea.top - 1;
    if (transformed.any((b) =%gt; b.position.y == lastLine || b.position.x < gameArea.left || b.position.x %gt;= gameArea.right || field.any((fp) =%gt; fp == b))) {
      rollback();
      return true;
    }
    else {
      return false;
    }
  }
    
    有两种委托用于操作和回滚,首先执行 transaction(这会改变位置和/或旋转),在使用转换后的版本(使用新参数转换)检查状态后,要么保持该状态,要么在发现无效时回滚。要回滚,则调用 rollback 委托。
由于尝试-回滚的方法,移动 Piece 的代码变成了一行,其中有两个相等且相反的委托。
  bool move(final Position delta, final Set<Block> field, final Rect gameArea) {
    return _tryAction(() => _position += delta, () => _position -= delta, field, gameArea);
  }
    
    将 _position 调整 _delta 量,如果结果无效,则通过调整负 _delta 来回滚。如果不是因为“墙壁踢击”功能(当旋转被墙壁阻挡时,它会将方块向内移动),则旋转方块会同样简单。要实现“墙壁踢击”,会尝试执行 try-rollback 操作,次数等于 Piece 中的 Block 数量,这确保了所有必需的踢击位置都已尝试,因为再也无需踢出比这更多的次数来找到一个空位。
每次迭代,都会应用旋转,并将踢击距离从 0 增加到 Block 数量。如果旋转有效,则保留;否则,回滚并尝试使用更高的踢击距离值再次进行。
  /// Helper method for the rotate method
  void _wallKickRotate(final Position kickDirection, final bool rollback) {
    _rotation += rollback ? -1 : 1;
    if (rollback)
      _position -= kickDirection;
    else
      _position += kickDirection;
  }
  
  bool rotate(final Set<Block> field, final Rect gameArea) {
    final int originalX = _position.x;
    for(int i = 0; i < _positions.length; ++i) {
      final Position kickDirection = new Position(_position.x < gameArea.horizontalCenter ? i : -i, 0);
      if (!_tryAction(() => _wallKickRotate(kickDirection, false), () => _wallKickRotate(kickDirection, true), field, gameArea)) {
        if (_position.x != originalX)
          _audioManager.play(_position.y % 2 == 0 ? "wallKickA" : "wallKickB");
        return true;
      }
    }
    return false;
    
    // This is rotating without wall kicking    
    //return _tryAction(() => ++_rotation, () => --_rotation, field, gameArea);
  }
    
    同样,这完全没有优化性能,显然有更有效的方法可以做到这一点。
PlayingField
PlayingField 类代表方块下落和方块占据的区域。它接受移动、旋转和下落当前 Piece 的请求,并使用工厂在当前方块在场地中固定为 Blocks 后生成下一个 Piece。
场地还负责检查是否有任何行被 Blocks 完全占据并应该被折叠。该类的主要目的是游戏逻辑,因此它不计算行折叠时新的分数,它只返回被折叠的 Blocks。之所以返回方块而不是仅仅返回折叠了多少个,是因为特殊效果需要折叠方块的位置,而我不希望 PlayingField 知道特殊效果,这不是游戏逻辑。
此外,PlayingField 负责检测游戏结束状态,它还知道如何渲染自己,但该操作委托给 Piece 和 Block 上的 render 方法。
存储方块
我决定尝试一种略有不同的方法,而不是使用二维数组来表示场地;PlayingField 中的 Blocks 保存在一个集合中。
    final Set<Block> _field = new HashSet<Block>();
      
    这就是为什么 Block 上的 equals 重载只考虑位置而不考虑颜色。
我这样做的目的是尝试使用 Iterable 上的类 LINQ 功能,而不是访问类似网格的结构。这种方法没有什么特别巧妙之处,我只是想看看用这种方式实现它会是什么样子。
折叠一行
要折叠一行,场地将从顶部(或底部,对于翻转的一侧)遍历所有行,如果一行中的 Block 数量等于 _gameArea 的宽度(即可能的水平 Block 数量),则该行将被清除,其上方的所有内容将向下移动一步。
因为我不关心性能,所以被清除行上方的 Blocks 实际上并没有移动,而是被删除并重新添加,这是 Block 类是不可变的一种效果。
  Iterable<Block> checkCollapse() {
    final int rowDirection = _isFalling ? 1 : -1;
    final Position gridDelta = new Position(0, rowDirection);
    final int firstRow = _isFalling ? _gameArea.top : _gameArea.bottom;
    final int lastRow =  _isFalling ? _gameArea.bottom : _gameArea.top - 1;
    
    final Set<Block> collapsed = new HashSet<Block>();
    for(int r = firstRow; r != lastRow; r += rowDirection) {
      final Iterable<Block> row = _field.where((block) => block.position.y == r).toList();
      if (row.length == _gameArea.width) {
        collapsed.addAll(row);
        _field.removeAll(row);
        final Iterable<Block> blocksToDrop = _field.where((block) => _isFalling ? block.position.y < r : block.position.y > r).toList();
        _field.removeAll(blocksToDrop);
        _field.addAll(blocksToDrop.map((block) => new Block(block.position + gridDelta, block._color)));
      }
    }
    return collapsed;
  }
    
    检查游戏结束状态的过程很简单,就是检查是否有任何 Blocks 固定在游戏区域的第一行。
FieldController
连接游戏状态(StateGame)和游戏逻辑(PlayingField)的“粘合剂”是 FieldController。
FieldController 负责读取用户输入并将其传递给 PlayingField,以及创建和维护与游戏逻辑不直接相关的图形效果(在 Double-Dartris 中,这些效果是在一次下落中消除多个行时会播放的动画消息)。
拥有一个控制器可以很好地解耦游戏抽象与用户界面,并允许添加 AI 玩家等功能。由于 PlayingField 类关心游戏中可以做什么(左移、右移、下落、旋转、清除行或输掉游戏),它不应该知道是什么触发了这些动作,那是控制器的任务。即使是 Piece 的下落也是控制器的任务,场地只知道如何下落以及 Piece 是否已固定为 Blocks。
由于游戏在浏览器提供“动画帧”时“滴答”作响,控制器需要跟踪已过去的时间,并且仅在有足够的时间(由当前级别决定)过去后才请求下落。
FieldController 中有各种辅助方法,但相关方法是 control,如下所示:
  Iterable<Block> control(final double elapsed, final Rect screen) {
    if (isGameOver) 
      return new List<Block>();  
    _accumulatedElapsed += elapsed;
    _checkRotating();
    _checkMoving();
    
    // This way it's getting very difficult, very fast
    _dropInterval = (1.0 / level);
    double dropTime = _dropInterval * (isDropping ? 0.25 : 1.0);
    dropTime = min(dropTime, isDropping ? 0.05 : dropTime);
    if (_accumulatedElapsed > dropTime) {
      _accumulatedElapsed = 0.0;
      score += level;
      if (_field.dropCurrent()) {
        // Piece has settled, check collapsed and then generate a new Piece
        final Iterable<Block> collapsed = _field.checkCollapse();
        score += collapsed.length * collapsed.length * level;
        _field.next(); 
        final numberOfRowsCleared = collapsed.length ~/ _field._gameArea.width;
        switch(numberOfRowsCleared) {
          case 0: _audioManager.play("drop"); break; // 0 means no rows cleared by the piece came to rest
          case 2: _audioManager.play("double"); break;  
          case 3: _audioManager.play("triple"); break;  
          case 4: _audioManager.play("quadruple"); break;  
        }
        
        final TextEffect effect = _buildTextEffect(numberOfRowsCleared, screen); 
        if (effect != null)
          _textEffects.add(effect);
        return collapsed;
      }
    }
    return new List<Block>();
  }
    
    该方法本质上做了以下工作:
      Process Horizontal Move
      Process Rotate
      If it is time for Piece to Drop Then
        Drop Piece
        If dropped piece settled then
          Collapse rows // If any
          Play some sounds
        End If
        
        If applicable Then Create Text Effect
        
        Generate Next Piece
      End if
    
    控制器在构造时(除其他外)会传入控制 Piece 的按键,这使得游戏使用的按键易于配置,这正是我设定的要求之一。
FieldController(实际上是一对)由主状态 StateGame 使用。
StateGame
游戏使用了状态机,就像我在上一篇关于 Dart 游戏开发的文章中描述的那样。虽然 StateGame 是最有趣的状态,但整个状态机如下所示:
 
    主状态封装了两个 FieldController(一个下落游戏和一个上升游戏),并控制着行清除或游戏结束时发生的动画。
它也是游戏与用于显示大部分游戏文本的 HTML 元素进行交互的地方。我希望将这部分内容从 PlayingField 和 FieldController 中分离出来,因为当代码耦合到 DOM 树时,单元测试会变得更难。当然,我将设置和清除文本的调用封装起来,以便于扩展以支持模拟,但我认为对于像俄罗斯方块这样简单的游戏来说,这有点过度。
文本效果
 
    当一次下落清除两行或更多行时,一条文本消息,例如“Double”、“Triple”或“Quadruple”,会快速淡入淡出并同时进行动画。这些效果由 PlayingField 拥有,并且有两种类构成了文本效果的实现。
文本
Text 类代表单个状态下的文本,并能够渲染该状态下的文本。我所谓的“状态”是指属性,例如:
- 字体大小
- 职位
- 旋转
- Alpha 混合
除了上述之外,文本字符串和颜色也可用在 Text 类中,但它们不允许动画。
由于 Text 的变换可以被设置(位置和旋转共同构成变换),并且变换是全局设置的,即对 CanvasRenderingContext2D 的所有后续绘制都有效,因此 Text 的 render 方法会保存当前的渲染变换,然后再应用文本的属性。然后,当它渲染完当前的自身状态后,它会恢复之前的变换。
render 方法获取 Text 的属性并将其应用于 CanvasRenderingContext2D,因此 _position 成为平移,_fontSize 和 _font 成为字体,等等。
  void render(final CanvasRenderingContext2D context, final Rect screen) {
    context.save();
    context.translate(_x, _y);
    context.rotate(_angle);
    context.globalAlpha = _alpha;
    context.fillStyle = _color.primary.toString();
    context.shadowColor = _color.secondary.toString();
    context.shadowOffsetX = 2;
    context.shadowOffsetY = 2;
    context.font = "${_fontSize}px ${_font}";
    context.textAlign = _align;
    context.textBaseline = "middle";
    context.fillText(_text, 0, 0);
    context.restore();
  }
    
    因此,虽然 Text 类保存并渲染单个“状态”,但另一个类会改变该状态,那就是 TextEffect 类。
TextEffect
TextEffect 类有两个非常简单的职责:
- 跟踪效果持续多长时间,即持续时间。
- 使用动画器改变其 Text的状态。
持续时间(以秒为单位)作为 double 传递给 TextEffect 构造函数,并且在每次更新时,已过去的时间都会被添加到累积的 _elapsed 字段中。当 _elapsed 大于 _duration 时,效果完成,可以从 FieldController 所拥有的集合中移除。
对于每一帧,都会计算已过去时间和持续时间之间的比例,这个比例被用作动画器的输入,动画器反过来产生 Text 属性的新值。
动画器是简单的函数指针:
typedef double DoubleAnimator(final double _elapsed);
    
class TextEffect {
  Text _text;
  
  double _elapsed = 0.0;
  double _duration;
  
  DoubleAnimator _sizeAnimator;
  DoubleAnimator _xAnimator;
  DoubleAnimator _yAnimator;
  DoubleAnimator _angleAnimator;
  DoubleAnimator _alphaAnimator;
  ...
}     
    
    这样,TextEffect 的 update 方法将更新,例如,Text 位置的 X 部分,如下所示:
void update(final double elapsed) {
  _elapsed += elapsed;
  final double fraction = _elapsed / _duration;
  _text._x = _xAnimator(fraction).toInt();
    
  ...
}  
    
    由于传递给动画器的值是 _elapsed 和 _duration 之间的比例,因此动画器不应该回答“N 秒后的状态是什么?”这个问题,而应回答“持续时间过去 P% 后的状态是什么?”
这样做可以轻松地操纵文本的属性。
例如;玩家一次清除四行时播放的文本效果如下:
final TextEffect effect = new TextEffect(new Text("QUADRUPLE!", "pressstart", "center", color), 1.0);
effect._sizeAnimator = (f) => 10.0 + f * 30;
effect._xAnimator = (f) => screen.horizontalCenter;
effect._yAnimator = (f) => screen.verticalCenter;
effect._angleAnimator = (f) => sin(f * 2 * PI);
effect._alphaAnimator = (f) => 1.0 - f;
    
    这设置了一个文本,它从 10.0 点增长到 30.0 点,并且在以线性方式淡出时会轻微摆动。
关注点
摘要
我认为我完成了设定的要求,但由于这是我第二次尝试编写 Dart 程序/游戏,代码比我想要的要混乱。我不认为它很糟糕,但随着我不断学习,我发现了更适合 Dart 的做事方式。不过,第二次项目重新证实了,由于 Dart 与我日常使用的语言(Java、C#)相似,我在制作 Web 内容方面比使用 JavaScript 更高效。但是,正如我在之前的 Dart 文章中所讨论的,我认为 Dart 缺乏资源和工具。
事后诸葛亮
由于 Double-Dartris 中的一个游戏是下落的,另一个是上升的,我的 Piece、PlayingField 和 FieldController 都关心什么是向上和向下,以及 Piece 以什么方向“下落”。我本应该不这样做,因为它会因为收益很小而使代码变得复杂。更明智的做法是只在渲染中处理一个游戏的翻转,并为两个游戏保持相同的游戏逻辑。这基本上可以将单元测试工作量减半。
历史
- 2014-04-24;第一个版本。



