通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip

前言

蜘蛛纸牌最核心的操作就是拖牌。把一叠牌从一列拖到另一列,按照规则堆叠起来。

这个功能涉及到Flutter的拖拽系统:Draggable和DragTarget。这篇就来聊聊怎么实现牌的拖拽和堆叠。
请添加图片描述

拖拽的基本概念

Flutter的拖拽系统有两个核心组件:

  • Draggable: 可以被拖动的东西
  • DragTarget: 可以接收拖动物的目标

拖拽流程是:用户按住Draggable开始拖,拖到DragTarget上方松手,DragTarget决定是否接收。

蜘蛛纸牌里,牌是Draggable,列是DragTarget。

LongPressDraggable

我们用的是LongPressDraggable而不是普通的Draggable

return LongPressDraggable<Map<String, int>>(
  data: {'col': colIndex, 'index': cardIndex},
  delay: const Duration(milliseconds: 100),

为什么用长按拖拽

普通Draggable一碰就开始拖,太灵敏了。玩家可能只是想点击看看牌,结果不小心拖动了。

LongPressDraggable需要长按一小段时间才开始拖,避免误触。

delay参数

delay: const Duration(milliseconds: 100),

长按100毫秒后才开始拖拽。这个时间不长,不会让人觉得反应慢,但足够区分点击和拖拽。

💡 100毫秒是试出来的。一开始用默认的500毫秒,感觉太慢了,改成100刚好。

data参数

data: {'col': colIndex, 'index': cardIndex},

拖拽时携带的数据,告诉DragTarget这张牌来自哪一列的第几张。

用Map存储两个信息:列索引和牌索引。DragTarget收到后可以知道要移动哪些牌。

拖拽开始

onDragStarted: () => _onDragStart(colIndex, cardIndex),

拖拽开始时调用_onDragStart

void _onDragStart(int colIndex, int cardIndex) {
  if (!columns[colIndex][cardIndex].isFaceUp) return;
  if (!_isValidDragSequence(colIndex, cardIndex)) return;
  
  setState(() {
    dragSourceColumn = colIndex;
    dragStartIndex = cardIndex;
    draggingCards = columns[colIndex].sublist(cardIndex);
  });
}

前置检查

if (!columns[colIndex][cardIndex].isFaceUp) return;

背面的牌不能拖。只有翻开的牌才能移动。

if (!_isValidDragSequence(colIndex, cardIndex)) return;

检查从这张牌开始到列底的所有牌是否形成有效序列。蜘蛛纸牌的规则是:只能拖连续递减的牌。

记录拖拽状态

setState(() {
  dragSourceColumn = colIndex;
  dragStartIndex = cardIndex;
  draggingCards = columns[colIndex].sublist(cardIndex);
});

记录三个信息:

  • dragSourceColumn: 来源列
  • dragStartIndex: 从第几张开始拖
  • draggingCards: 正在拖的牌列表

sublist(cardIndex)获取从cardIndex到末尾的所有牌。蜘蛛纸牌拖一张牌时,它下面的牌也要跟着一起拖。

有效序列检查

bool _isValidDragSequence(int colIndex, int startIndex) {
  List<PlayingCard> col = columns[colIndex];
  for (int i = startIndex; i < col.length; i++) {
    if (!col[i].isFaceUp) return false;
  }
  for (int i = startIndex; i < col.length - 1; i++) {
    if (col[i].value != col[i + 1].value + 1) return false;
  }
  return true;
}

两个检查:

全部正面朝上

for (int i = startIndex; i < col.length; i++) {
  if (!col[i].isFaceUp) return false;
}

从起始位置到列底,所有牌都必须是正面朝上的。

连续递减

for (int i = startIndex; i < col.length - 1; i++) {
  if (col[i].value != col[i + 1].value + 1) return false;
}

相邻两张牌,上面的点数必须比下面的大1。比如8-7-6-5是有效的,8-7-5-4就不行(跳过了6)。

这是蜘蛛纸牌的核心规则:只有连续递减的序列才能一起移动。

拖拽时的视觉反馈

feedback: Material(
  color: Colors.transparent,
  child: _buildDragFeedback(colIndex, cardIndex, width, height),
),

feedback是拖拽时跟随手指移动的Widget。

_buildDragFeedback

Widget _buildDragFeedback(int colIndex, int cardIndex, double width, double height) {
  List<PlayingCard> cards = columns[colIndex].sublist(cardIndex);
  double offset = 25.0;
  
  return SizedBox(
    width: width,
    height: height + (cards.length - 1) * offset,
    child: Stack(
      children: List.generate(cards.length, (i) {
        return Positioned(
          top: i * offset,
          child: _buildCard(cards[i], width, height, true),
        );
      }),
    ),
  );
}

拖拽反馈要显示所有被拖的牌,不只是点击的那一张。

用Stack堆叠,每张牌偏移25像素,和原来的布局一样。

注意_buildCard的最后一个参数是true,表示抬起状态,阴影会更明显。

Material包裹

feedback: Material(
  color: Colors.transparent,

feedback必须用Material包裹,不然Text等Widget会报错。设置透明背景,不影响牌的显示。

原位置的显示

childWhenDragging: Opacity(opacity: 0.3, child: _buildCard(card, width, height, false)),

拖拽时,原位置显示一个半透明的牌,提示玩家牌是从这里拖出去的。

opacity: 0.3让牌变得很淡,和正在拖的牌形成对比。

DragTarget接收

每一列都是一个DragTarget:

return SizedBox(
  width: colWidth,
  child: DragTarget<Map<String, int>>(
    onWillAcceptWithDetails: (details) {
      if (draggingCards.isEmpty) return false;
      return _canDropOn(colIndex, draggingCards.first);
    },

onWillAcceptWithDetails

onWillAcceptWithDetails: (details) {
  if (draggingCards.isEmpty) return false;
  return _canDropOn(colIndex, draggingCards.first);
},

当拖拽物悬停在DragTarget上方时调用,返回true表示可以接收,false表示不能。

先检查是否有正在拖的牌,然后调用_canDropOn检查规则。

_canDropOn规则检查

bool _canDropOn(int targetCol, PlayingCard card) {
  if (columns[targetCol].isEmpty) return true;
  PlayingCard topCard = columns[targetCol].last;
  return topCard.isFaceUp && topCard.value == card.value + 1;
}

两种情况可以放:

  1. 目标列是空的:任何牌都可以放到空列
  2. 目标列顶部的牌比要放的牌大1:比如顶部是8,可以放7

这就是蜘蛛纸牌的堆叠规则:递减排列。

放下牌

onAcceptWithDetails: (details) => _onDragEnd(colIndex),

当用户松手且onWillAcceptWithDetails返回true时,调用_onDragEnd

void _onDragEnd(int? targetCol) {
  if (dragSourceColumn == null || dragStartIndex == null) return;
  
  if (targetCol != null && 
      targetCol != dragSourceColumn && 
      draggingCards.isNotEmpty &&
      _canDropOn(targetCol, draggingCards.first)) {

条件检查

if (targetCol != null && 
    targetCol != dragSourceColumn && 
    draggingCards.isNotEmpty &&
    _canDropOn(targetCol, draggingCards.first)) {

四个条件:

  • targetCol不为空(有目标列)
  • 目标列不是来源列(不能放回原处)
  • 有正在拖的牌
  • 符合放置规则

执行移动

setState(() {
  columns[dragSourceColumn!].removeRange(dragStartIndex!, columns[dragSourceColumn!].length);
  columns[targetCol].addAll(draggingCards);
  moves++;

从来源列移除牌,添加到目标列。移动步数加1。

removeRange移除从startIndex到末尾的所有牌,addAll把它们加到目标列。

翻开新的顶牌

  if (columns[dragSourceColumn!].isNotEmpty && !columns[dragSourceColumn!].last.isFaceUp) {
    columns[dragSourceColumn!].last.isFaceUp = true;
  }

移走牌后,来源列可能露出一张背面的牌。把它翻开。

这是蜘蛛纸牌的规则:列顶的牌总是正面朝上。

检查是否完成一套

  _checkColumnForCompleteSuit(targetCol);
});

放下牌后检查目标列是否形成了完整的一套(A到K)。

完整套牌检查

void _checkColumnForCompleteSuit(int col) {
  if (columns[col].length < 13) return;
  
  int startIndex = columns[col].length - 13;
  bool isComplete = true;
  for (int i = 0; i < 13; i++) {
    if (!columns[col][startIndex + i].isFaceUp || columns[col][startIndex + i].value != 13 - i) {
      isComplete = false;
      break;
    }
  }

长度检查

if (columns[col].length < 13) return;

一套牌有13张(A到K),列里不够13张肯定不完整。

序列检查

for (int i = 0; i < 13; i++) {
  if (!columns[col][startIndex + i].isFaceUp || columns[col][startIndex + i].value != 13 - i) {

从列底往上数13张,检查是否是K-Q-J-10-9-8-7-6-5-4-3-2-A。

13 - i:i=0时是13(K),i=12时是1(A)。

收走完整套牌

if (isComplete) {
  setState(() {
    columns[col].removeRange(startIndex, columns[col].length);
    completedSuits++;
    if (columns[col].isNotEmpty && !columns[col].last.isFaceUp) {
      columns[col].last.isFaceUp = true;
    }
    if (completedSuits == 8) _showWinDialog();
  });
}

完整的一套牌会被移除,completedSuits加1。

8套牌全部完成就赢了。蜘蛛纸牌用8副牌(只用黑桃),所以是8套。

高亮提示

builder: (context, candidateData, rejectedData) {
  bool isHighlighted = candidateData.isNotEmpty;

DragTarget的builder有三个参数:

  • candidateData: 可以接收的拖拽数据列表
  • rejectedData: 不能接收的拖拽数据列表

candidateData.isNotEmpty表示有牌正在往这里拖,而且可以放下。

border: Border.all(color: isHighlighted ? Colors.yellow : Colors.white24, width: isHighlighted ? 2 : 1),

高亮时边框变黄变粗,提示玩家这里可以放。

清理拖拽状态

setState(() {
  dragSourceColumn = null;
  dragStartIndex = null;
  draggingCards = [];
});

无论拖拽成功还是取消,最后都要清理状态。

拖拽取消

onDragEnd: (details) {
  if (dragSourceColumn != null) _onDragEnd(null);
},

如果用户把牌拖到无效位置松手,onDragEnd会被调用。

传入null作为targetCol,_onDragEnd里的条件检查会失败,牌不会移动,只是清理状态。

牌会"弹回"原位,因为我们没有真正修改数据。

小结

这篇讲了蜘蛛纸牌的拖拽堆叠,核心知识点:

  • LongPressDraggable:长按开始拖拽,避免误触
  • DragTarget:接收拖拽物的目标
  • data参数:拖拽时携带的数据
  • feedback:拖拽时的视觉反馈
  • childWhenDragging:原位置的占位显示
  • onWillAcceptWithDetails:判断是否可以接收
  • onAcceptWithDetails:接收后的处理
  • 序列验证:只有连续递减的牌才能一起拖
  • 堆叠规则:只能放在比自己大1的牌上面
  • 完整套牌检测:K到A连续13张自动收走

拖拽是蜘蛛纸牌最复杂的部分,理解了这些,游戏的核心玩法就实现了。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐