推荐 最新
万码4P7VTGUT

Flutter 美颜相机

#### Flutter 美颜相机 ##### 当前功能: - 开关美容 - 拍照 - 制作视频 - 切换镜头 - 设置保存路径 - 添加筛选器 ##### 用法 - 需要提前申请并开通相机和存储所需的权限。 - 允许用户自定义过滤器(非程序员也可以自定义过滤器,并且过滤器可以在线更新) 过滤器编辑可以在文档“过滤器编辑规则”中找到 ##### 本项目基于开源 Git Hub 项目 - android-gpuimage-plus - ios-gpuimage-plus ##### 已编辑好滤镜 滤镜 ##### 准备下一个版本要实现的功能 - 保存滤镜图片纹理到本地 - 实现动态滤镜 - 人脸识别 - 增加人脸识别贴纸 ``` 开启美颜: cameraFlutterPluginDemo?.enableBeauty(true); 设置美颜等级(0~1): cameraFlutterPluginDemo?.setBeautyLevel(1); 拍摄照片: cameraFlutterPluginDemo?.takePicture(); 开始拍视频: cameraFlutterPluginDemo?.takeVideo(); 结束拍视频: cameraFlutterPluginDemo?.stopVideo(); 切换镜头: cameraFlutterPluginDemo?.switchCamera(); 设置图片纹理加载路径(默认存放在Caches目录): cameraFlutterPluginDemo?.setLoadImageResource(); 设置文件保存路径: cameraFlutterPluginDemo?.setOuPutFilePath(); 添加滤镜: cameraFlutterPluginDemo?. addFilter("@adjust saturation 0 @adjust level 0 0.83921 0.8772"); ``` ![https://wmprod.oss-cn-shanghai.aliyuncs.com/community/FnrxuQrMEU3rovhZe7q6Ft3rPfn1.png](https://wmprod.oss-cn-shanghai.aliyuncs.com/community/FnrxuQrMEU3rovhZe7q6Ft3rPfn1.png) ![https://wmprod.oss-cn-shanghai.aliyuncs.com/community/llkM-a4Oi3aUAw7qeuJXt6TdQet-.png](https://wmprod.oss-cn-shanghai.aliyuncs.com/community/llkM-a4Oi3aUAw7qeuJXt6TdQet-.png) 如果您觉得对您有用的话请给个star或者打赏一下,您的激励会使我更加有动力!!! ![https://wmprod.oss-cn-shanghai.aliyuncs.com/community/FhaY5oOKYpUtndr2AAPppJxxuPcf.png](https://wmprod.oss-cn-shanghai.aliyuncs.com/community/FhaY5oOKYpUtndr2AAPppJxxuPcf.png)

25
0
4
浏览量257
777七月

在安装Flutter SDK的时候:`开发工具`和`文本编辑器或IDE`是只需要安装一个还是都必须都安装?

在安装Flutter SDK的时候: 有 * "开发工具" (https://link.segmentfault.com/?enc=PepSK%2FPtpMbOkbxRQWwpPQ%3D%3D.lhsFEmccw%2FSZU3DKrVNYpAsRBrQGkMs08fvoReyUe3OFvJijibDWtYrztljjWFWPHNicEedVl0LGvVmXEIX29oEUBawJzO0E48RIhB0H18Y%3D) * "文本编辑器或集成开发环境 (IDE)" (https://link.segmentfault.com/?enc=kJqxEB4GPOUhvvwUyevGpA%3D%3D.7HVPGfcDag88Up0FsC4aLsuiUZMji%2BkELEYQIVwsoVc5hSndePn9eG6uz1znwu0dAtewxUwv9q19w7xL4pxfj5dG8a3gpcHK4MSu6O2bTieNsuVUnUMeOTsIMIH5en4a9DNaJpS8Zo0JOLBLE476Ow%3D%3D) 请问是只需要安装其中一个,还是2个都必须安装?

12
1
0
浏览量366
chole

Flutter从0到1实现高性能、多功能的富文本编辑器(基础实战篇)

前言在上一章中,我们分析了一个富文本编辑器需要有哪些模块组成。在本文中,让我们从零开始,去实现自定义的富文本编辑器。注:本文篇幅较长,从失败的方案开始分析再到成功实现自定义富文本编辑器,真正的从0到1。建议收藏!— 完整代码太多, 文章只分析核心代码,需要源码请到 代码仓库错误示范遭一蹶者得一便,经一事者长一智。——宋·无名氏《五代汉史平话·汉史》在刚开始实现富文本时,为了更快速的实现富文本的功能,我利用了TextField这个组件,但写着写着发现TextField有着很大的局限性。不过错误示范也给我带来了一些启发,那么现在就让我和大家一起去探索富文本编辑器的世界吧。最后效果图:定义文本格式作为基础的富文本编辑器实现,我们需要专注于简单且重要的部分,所以目前只需定义标题、文本对齐、文本粗体、文本斜体、下划线、文本删除线、文本缩进符等富文本基础功能。定义文本颜色:class RichTextColor {  //定义默认颜色  static const defaultTextColor = Color(0xFF000000); ​  static const c_FF0000 = Color(0xFFFF0000); ...      ///用户自定义颜色解析  ///=== 如需方法分析,请参考https://juejin.cn/post/7154151529572728868#heading-11 ===  Color stringToColor(String s) {    if (s.startsWith('rgba')) {      s = s.substring(5);      s = s.substring(0, s.length - 1);      final arr = s.split(',').map((e) => e.trim()).toList();      return Color.fromRGBO(int.parse(arr[0]), int.parse(arr[1]),          int.parse(arr[2]), double.parse(arr[3]));   }   ...    return const Color.fromRGBO(0, 0, 0, 0); } } 定义功能枚举类enum RichTextInputType {  header1,  header2, ... } 定义富文本样式TextStyle richTextStyle(List<RichTextInputType> list, {Color? textColor}) {  //默认样式  double fontSize = 18.0;  FontWeight fontWeight = FontWeight.normal;  Color richTextColor = RichTextColor.defaultTextColor;  TextDecoration decoration = TextDecoration.none;  FontStyle fontStyle = FontStyle.normal; ​  //分析用户选中样式  for (RichTextInputType i in list) {    switch (i) {      case RichTextInputType.header1:        fontSize = 28.0;        fontWeight = FontWeight.w700;        break;     ...   } }  return TextStyle(    fontSize: fontSize,    fontWeight: fontWeight,    fontStyle: fontStyle,    color: richTextColor,    decoration: decoration, ); } 定义不同样式文本间距EdgeInsets richTextPadding(List<RichTextInputType> list) {  //默认间距  EdgeInsets edgeInsets = const EdgeInsets.symmetric(    horizontal: 16.0,    vertical: 4.0, );  for (RichTextInputType i in list) {    switch (i) {      case RichTextInputType.header1:        edgeInsets = const EdgeInsets.only(          top: 24.0,          right: 16.0,          bottom: 8.0,          left: 16.0,       );        break;     ...   } }  return edgeInsets; } 当为list type时,加上前置占位符/// 效果-> ·Hello Taxze String prefix(List<RichTextInputType> list) {  for (RichTextInputType i in list) {    switch (i) {      case RichTextInputType.list:        return '\u2022';      default:        return '';   } }  return ''; } 封装RichTextField为了让TextField更好的使用自定义的样式,需要对它进行一些简单的封装。=== 完整代码,请前往仓库中的rich_text_field.dart === @override Widget build(BuildContext context) {  return TextField(    controller: controller,    focusNode: focusNode,    //用于自动获取焦点    autofocus: true,    //multiline为多行文本,常配合maxLines使用    keyboardType: TextInputType.multiline,    //将maxLines设置为null,从而取消对行数的限制    maxLines: null,    //光标颜色    cursorColor: RichTextColor.defaultTextColor,    textAlign: textAlign,    decoration: InputDecoration(      border: InputBorder.none,      //当为list type时,加入占位符      prefixText: prefix(inputType),      prefixStyle: richTextStyle(inputType),      //减少垂直高度减少,设为密集模式      isDense: true,      contentPadding: richTextPadding(inputType),   ),    style: richTextStyle(inputType, textColor: textColor), ); } 自定义Toolbar工具栏这里使用PreferredSize组件,在自定义AppBar的同时,不对其子控件施加任何约束,不影响子控件的布局。效果图:  @override  Widget build(BuildContext context) {    return PreferredSize(        //直接设置AppBar的高度        preferredSize: const Size.fromHeight(56.0),        child: Material(            //绘制适当的阴影            elevation: 4.0,            color: widget.color,            //SingleChildScrollView包裹Row,使其能横向滚动            child: SingleChildScrollView(              scrollDirection: Axis.horizontal,              child: Row(                children: [                  //功能按钮                  Card(                    //是否选中了该功能                    color: widget.inputType.contains(RichTextInputType.header1)                        ? widget.colorSelected                       : null,                    child: IconButton(                      icon: const Icon(Icons.font_download_sharp),                      color:                          widget.inputType.contains(RichTextInputType.header1)                              ? Colors.white                             : Colors.black,                      onPressed: () {                        //选中或取消该功能                        widget.onInputTypeChange(RichTextInputType.header1);                        setState(() {});                     },                   ),                 ),                 ...               ],             ),           ))); } 全局控制管理分析需要实现的功能后,我们需要将每一块样式分为一个输入块 (block) 。因此,我们需要存储三个列表,用来管理:List<FocusNode> _nodes = [] 存放每个输入块的焦点List<TextEditingController> _controllers = [] 存放每个输入块的控制器List<List<RichTextInputType>> _types = [] 存放每个输入块的样式再进一步分析后,我们还需要这些模块:返回当前焦点所在输入块的索引插入新的输入块修改输入块的样式class RichTextEditorProvider extends ChangeNotifier {  //默认样式  List<RichTextInputType> inputType = [RichTextInputType.normal]; ...    //存放每个输入框的焦点  final List<FocusNode> _nodes = [];  int get focus => _nodes.indexWhere((node) => node.hasFocus);  //返回当前焦点索引  FocusNode nodeAt(int index) => _nodes.elementAt(index);   ...  //改变输入块样式  void setType(RichTextInputType type) {  //判断改变的type是不是三种标题中的一种    if (type == RichTextInputType.header1 ||        type == RichTextInputType.header2 ||        type == RichTextInputType.header3) {      //三种标题只能同时存在一个,isAdd用来判断是删除标题样式,还是修改标题样式      bool isAdd = true;      //暂存需要删除的样式      RichTextInputType? begin;      for (RichTextInputType i in inputType) {        if ((i == RichTextInputType.header1 ||            i == RichTextInputType.header2 ||            i == RichTextInputType.header3)) {          begin = i;          if (i == type) {         //如果用户点击改变的样式,已经存在了,证明需要删除这个样式。            isAdd = false;         }       }     }      //删除或修改样式      if (isAdd) {        inputType.remove(begin);        inputType.add(type);     } else {        inputType.remove(type);     }   }   ...    else {      //如果不是以上type,则直接添加      inputType.add(type);   }    //修改输入块属性    _types.removeAt(focus);    _types.insert(focus, inputType);    notifyListeners(); }  //在用户将焦点更改为另一个输入文本块时,更新键盘工具栏和insert()  void setFocus(List<RichTextInputType> type) {    inputType = type;    notifyListeners(); } ​  //插入  void insert({    int? index,    String? text,    required List<RichTextInputType> type, }) {      // \u200b是Unicode中的零宽度字符,可以理解为不可见字符,给文本前加上它,目的是为了检测删除事件。    final TextEditingController controller = TextEditingController(      text: '\u200B${text ?? ''}',   );    controller.addListener(() {        //如果用户随后按下退格键并删除起始字符,即\u200B        //就会检测到删除事件,删除焦点文本输入块,同时将焦点移动到上面的文本输入块。      if (!controller.text.startsWith('\u200B')) {        final int index = _controllers.indexOf(controller);        if (index > 0) {          //通过该语句可以轻松地将两个单独的块合并为一个          controllerAt(index - 1).text += controller.text;          //文本选择          controllerAt(index - 1).selection = TextSelection.fromPosition(            TextPosition(              offset: controllerAt(index - 1).text.length - controller.text.length,           ),         );          //获取光标          nodeAt(index - 1).requestFocus();          //删除文本输入块          _controllers.removeAt(index);          _nodes.removeAt(index);          _types.removeAt(index);          notifyListeners();       }     }      //处理删除事件。因为我们在封装TextField时,使用了keyboardType: TextInputType.multiline的键盘类型      //当用户按下回车键后,我们需要检测是否包含Unicode 的\n字符,如果包含了,我们需要创建新的文本编辑块。      if (controller.text.contains('\n')) {        final int index = _controllers.indexOf(controller);        List<String> split = controller.text.split('\n');        controller.text = split.first;        insert(            index: index + 1,            text: split.last,            type: typeAt(index).contains(RichTextInputType.list)                ? [RichTextInputType.list]               : [RichTextInputType.normal]);        controllerAt(index + 1).selection = TextSelection.fromPosition(          const TextPosition(offset: 1),       );        nodeAt(index + 1).requestFocus();        notifyListeners();     }   });    //创建新的文本输入块    _controllers.insert(index!, controller);    _types.insert(index, type);    _nodes.insert(index, FocusNode()); } } 布局常用Stack,将工具栏Appbar固定在页面底部。前面我们定义了ChangeNotifier,现在需要使用ChangeNotifierProvider。@override  Widget build(BuildContext context) {    return ChangeNotifierProvider<RichTextEditorProvider>(      create: (_) => RichTextEditorProvider(),      builder: (BuildContext context, Widget? child) {        return Stack(children: [          Positioned(            top: 16,            left: 0,            right: 0,            bottom: 56,            child: Consumer<RichTextEditorProvider>(              builder: (_, RichTextEditorProvider value, __) {                return ListView.builder(                  itemCount: value.length,                  itemBuilder: (_, int index) {                    //分配焦点给它本身及其子Widget                    //同时内部管理着一个FocusNode,监听焦点的变化,来保持焦点层次结构与Widget层次结构同步。                    return Focus(                      onFocusChange: (bool hasFocus) {                        if (hasFocus) {                          value.setFocus(value.typeAt(index));                       }                     },                      //文本输入块                      child: RichTextField(                        inputType: value.typeAt(index),                        controller: value.controllerAt(index),                        focusNode: value.nodeAt(index),                     ),                   );                 },               );             },           ),         ),          //固定在页面底部          Positioned(            bottom: 0,            left: 0,            right: 0,            child: Selector<RichTextEditorProvider, List<RichTextInputType>>(              selector: (_, RichTextEditorProvider value) => value.inputType,              builder:                 (BuildContext context, List<RichTextInputType> value, _) {                //工具栏                return RichTextToolbar(                  inputType: value,                  onInputTypeChange: Provider.of<RichTextEditorProvider>(                    context,                    listen: false,                 ).setType,               );             },           ),         )       ]);     },   ); } 分析总结通过上面的步骤,我们就能实现效果图中的功能了。但是,这样实现后,会出现几个对于富文本来说致命的问题:由于TextField对富文本支持不完善,在对文本添加颜色、文本段落中添加图片时,有较大的困难。无法选中ListView中未渲染的TextField...在遇到这些问题后,我想到了RichText。它除了可以支持TextSpan,还可以支持WidgetSpan,这样在对文本添加颜色,或者在文本中插入图片这样放入Widget的功能时就比较灵活了。对于文本选择问题,通过渲染多个TextField不是个好方案。正确案例为了解决分析出的问题,第一点就是,我们不能再渲染多个TextField,虽然也能通过同时控制多个controller来解决部分问题,但是实现成本较高,实现后也会有很多缺陷。所以实现方案要从渲染多个输入块转为一个输入块,渲染多个TextSpan。方案有了,那么让我们开始实现吧!实现buildTextSpan方法来将文本转化为TextSpan在之前的基础文本知识篇中,我们知道RichText的text属性接收一个InlineSpan类型的对象(TextSpan和WidgetSpan是InlineSpan的子类),而InlineSpan又有一个叫做children的List属性,接收InlineSpan类型的数组。class TextSpan extends InlineSpan{} class WidgetSpan extends PlaceholderSpan{} abstract class PlaceholderSpan extends InlineSpan {} 构建TextSpan///构建TextSpan @override TextSpan buildTextSpan({  required BuildContext context,  TextStyle? style,  required bool withComposing, }) {  assert(!value.composing.isValid ||      !withComposing ||      value.isComposingRangeValid); ​  //保留TextRanges到InlineSpan的映射以替换它。  final Map<TextRange, InlineSpan> rangeSpanMapping =      <TextRange, InlineSpan>{}; ​  // 迭代TextEditingInlineSpanReplacement,将它们映射到生成的InlineSpan。  if (replacements != null) {    for (final TextEditingInlineSpanReplacement replacement        in replacements!) {      _addToMappingWithOverlaps(        replacement.generator,        TextRange(start: replacement.range.start, end: replacement.range.end),        rangeSpanMapping,        value.text,     );   } } ... ​  // 根据索引进行排序  final List<TextRange> sortedRanges = rangeSpanMapping.keys.toList();  sortedRanges.sort((a, b) => a.start.compareTo(b.start)); ​  // 为未替换的文本范围创建TextSpan并插入替换的span  final List<InlineSpan> spans = <InlineSpan>[];  int previousEndIndex = 0;  for (final TextRange range in sortedRanges) {    if (range.start > previousEndIndex) {      spans.add(TextSpan(          text: value.text.substring(previousEndIndex, range.start)));   }    spans.add(rangeSpanMapping[range]!);    previousEndIndex = range.end; }  // 后面添加的文字使用默认的TextSpan  if (previousEndIndex < value.text.length) {    spans.add(TextSpan(        text: value.text.substring(previousEndIndex, value.text.length))); }  return TextSpan(    style: style,    children: spans, ); } 文本输入块的基础实现为了更好的实现文本输入块,TextField是不能够满足我们的。现在让我们开始实现自己的文本输入块。分析TextEditingController我们可以知道,TextField的最后执行相关逻辑的Widget是_Editable,那么我们就要先从它入手。return CompositedTransformTarget(  link: _toolbarLayerLink,  child: Semantics(    onCopy: _semanticsOnCopy(controls),    onCut: _semanticsOnCut(controls),    onPaste: _semanticsOnPaste(controls),    child: _ScribbleFocusable(      focusNode: widget.focusNode,      editableKey: _editableKey,      enabled: widget.scribbleEnabled,      updateSelectionRects: () {        _openInputConnection();        _updateSelectionRects(force: true);     },      child: _Editable(        key: _editableKey,       ...     ),   ), ), ); 因为InlineSpan有一个叫做children的List属性,用于接收InlineSpan类型的数组。我们需要通过遍历InlineSpan,在WidgetSpan中创建子部件。class _Editable extends MultiChildRenderObjectWidget {   ... static List<Widget> _extractChildren(InlineSpan span) {  final List<Widget> result = <Widget>[];  //通过visitChildren来实现对子节点的遍历  span.visitChildren((span) {    if (span is WidgetSpan) {      result.add(span.child);   }    return true; });  return result; } ... } 定义了_Editable后,我们需要构建基本的文本输入块。Flutter 3.0以后,加入了DeltaTextInputClient,用于细分新旧状态之间的变化量。class BasicTextInput extends State<BasicTextInputState>    with TextSelectionDelegate    implements DeltaTextInputClient {} 让我们从用户行为来分析实现BasicTextInput,当用户编辑文字时,需要先点击屏幕,需要我们先获取到焦点后,用户才能进一步输入文字。///获取焦点,键盘输入 bool get _hasFocus => widget.focusNode.hasFocus; ​ ///在获得焦点时打开输入连接。焦点丢失时关闭输入连接。 void _openOrCloseInputConnectionIfNeeded() {  if (_hasFocus && widget.focusNode.consumeKeyboardToken()) {    _openInputConnection(); } else if (!_hasFocus) {    _closeInputConnectionIfNeeded();    widget.controller.clearComposing(); } } ​ void requestKeyboard() {  if (_hasFocus) {    _openInputConnection(); } else {    widget.focusNode.requestFocus(); } } 当用户编辑文本后,我们需要更新编辑文本的值。///更新编辑的值,输入一个值就要经过该方法 @override void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {  TextEditingValue value = _value; ​ ...  if (selectionChanged) {    manager.updateToggleButtonsStateOnSelectionChanged(value.selection,        widget.controller as ReplacementTextEditingController); } } ​ @override  void userUpdateTextEditingValue(      TextEditingValue value, SelectionChangedCause cause) {    if (value == _value) return; ​    final bool selectionChanged = _value.selection != value.selection; ​    if (cause == SelectionChangedCause.drag ||        cause == SelectionChangedCause.longPress ||        cause == SelectionChangedCause.tap) {      // 这里的变化来自于手势,它调用RenderEditable来改变用户选择的文本区域。      // 创建一个TextEditingDeltaNonTextUpdate后,我们可以获取Delta的历史RenderEditable      final bool textChanged = _value.text != value.text;      if (selectionChanged && !textChanged) {        final TextEditingDeltaNonTextUpdate selectionUpdate =            TextEditingDeltaNonTextUpdate(          oldText: value.text,          selection: value.selection,          composing: value.composing,       );        if (widget.controller is ReplacementTextEditingController) {         (widget.controller as ReplacementTextEditingController)             .syncReplacementRanges(selectionUpdate);       }        manager.updateTextEditingDeltaHistory([selectionUpdate]);     }   } } 有了基础了编辑文字,那么如何复制粘贴文字呢?//粘贴文字 @override Future<void> pasteText(SelectionChangedCause cause) async {   ...  // 粘贴文字后,光标的位置应该被定位于粘贴的内容后面  final int lastSelectionIndex = math.max(      pasteRange.baseOffset, pasteRange.baseOffset + data.text!.length); ​  _userUpdateTextEditingValueWithDelta(    TextEditingDeltaReplacement(      oldText: textEditingValue.text,      replacementText: data.text!,      replacedRange: pasteRange,      selection: TextSelection.collapsed(offset: lastSelectionIndex),      composing: TextRange.empty,   ),    cause, );   //如果用户操作来源于文本工具栏,那么则隐藏工具栏  if (cause == SelectionChangedCause.toolbar) hideToolbar(); } 隐藏文本工具栏//隐藏工具栏 @override void hideToolbar([bool hideHandles = true]) {  if (hideHandles) {    _selectionOverlay?.hide(); } else if (_selectionOverlay?.toolbarIsVisible ?? false) {    // 只隐藏工具栏    _selectionOverlay?.hideToolbar(); } } 不过,当文本发生变化时,需要对文本编辑进行更新时,更新的值必须在文本选择的范围内。void _updateOrDisposeOfSelectionOverlayIfNeeded() { if (_selectionOverlay != null) {   if (_hasFocus) {     _selectionOverlay!.update(_value);   } else {     _selectionOverlay!.dispose();     _selectionOverlay = null;   } } } 构建_Editable,Shortcuts是通过按键或按键组合激活的键绑定。具体参考:docs.flutter.dev/development…@override Widget build(BuildContext context) {  return Shortcuts(    shortcuts: kIsWeb ? _defaultWebShortcuts : <ShortcutActivator, Intent>{},    child: Actions(      actions: _actions,      child: Focus(        focusNode: widget.focusNode,        child: Scrollable(          viewportBuilder: (context, position) {            return CompositedTransformTarget(              link: _toolbarLayerLink,              child: _Editable(                key: _textKey,               ...             ),           );         },       ),     ),   ), ); } 分析到这里,我们就把自定义的富文本文本输入块实现了。当然,目前还要许多需要扩展和优化的地方,大家有兴趣可以持续关注代码仓库~尾述在这篇文章中,我们从0到1实现了基本的富文本编辑器,通过失败的简单案例,在分析吸取经验后实现扩展好的富文本编辑器。在下一篇文章中,会实现更多对富文本编辑器的扩展。希望这篇文章能对你有所帮助,有问题欢迎在评论区留言讨论~

0
0
0
浏览量2012
chole

拿来吧你!Flutter 贪吃蛇 — 陀螺仪至尊VIP版

前言:放假期间,小T打算回顾一下经典,想用Flutter做一下小游戏,做什么好呢,从打飞机到坦克大战,最后还是想做一款贪吃蛇,依稀还记得,小时候第一次玩游戏是在父母的小灵通上玩贪吃蛇哈哈,但是光光一个贪吃蛇太单调了,我们就加一个陀螺仪吧~话不多说,先上效果图,有图有真相!!(陀螺仪好难操控)!开发步骤:非常简单,就是玩起来有点费手~github仓库还没有搭建,大家可以先看一下文末通知!1.定义蛇和豆子2.让蛇动起来3.使用陀螺仪来控制蛇4.让蛇吃掉豆子5.吃掉豆子随机生成一个豆子1.定义蛇和豆子///蛇的每一块的大小 const double size = 10; Offset ball = Offset.zero;//豆子 List<Offset> snakeList = [Offset(50, 0), Offset(60, 0)];//蛇的长度显示豆子和蛇:使用Stack是因为豆子在被蛇吃的时候会重叠使用Positioned来进行定位body: Stack(      children: snakeList         .map((snake) => Positioned.fromRect(              rect: Rect.fromCenter(                  center: adjust(snake), width: size, height: size),              child: Container(margin:const EdgeInsets.all(1),color: Colors.black)))//加个外边距使每一块更清晰         .toList()           ..add(Positioned.fromRect(                rect: Rect.fromCenter(                    center: adjust(ball), width: size, height: size),                child: Container(color: Colors.orange))),   )Offset adjust(Offset offset) {    return Offset(offset.dx + size / 2, offset.dy + size / 2); //使每一块更好的展示出来 }2.让蛇动起来在这里我们要先给蛇来一个状态定义:///控制蛇的状态 enum Direction { Up, Down, Left, Right }因为蛇要一直动,所以给一个周期函数:1.定义200毫秒动一次2.处理更新蛇的长度3.求余的好处在于优化吃豆子,因为随机生成豆子时,可能是个不会被整除的。Direction direction = Direction.Left; //给蛇设置一个状态,默认向左移动 ///周期函数  void didChangeDependencies() {    ///两百毫秒的周期函数    var period = Duration(milliseconds: 200);    ///对蛇的长度进行优化    double maxWidth = MediaQuery.of(context).size.width;    double widthPad = maxWidth % size;    maxWidth -= widthPad;    double maxHeight = MediaQuery.of(context).size.height;    double heigthPad = maxHeight % size; //这里除于是为了更好的游戏体验    maxHeight -= heigthPad;    ///周期调用    ///用于贪吃蛇的自己移动,以及碰撞检测    Timer.periodic(period, (timer) {      final snakeHead = snakeList[0];      List<Offset> newSnakeList = List.generate(snakeList.length, (index) {        if (index > 0) {          return snakeList[index - 1];       } else {          ///移动处理          switch (direction) {            case Direction.Up:              return Offset(snakeHead.dx,                 (snakeHead.dy - size + maxHeight) % maxHeight); //求余是保证不会超标            case Direction.Down:              return Offset(                  snakeHead.dx, (snakeHead.dy + size + maxHeight) % maxHeight);            case Direction.Left:              return Offset(                 (snakeHead.dx - size + maxWidth) % maxWidth, snakeHead.dy);            case Direction.Right:              return Offset(                 (snakeHead.dx + size + maxWidth) % maxWidth, snakeHead.dy);         }       }     });      setState(() {        snakeList = newSnakeList; //更新蛇的状态     });   });    super.didChangeDependencies(); }3.使用陀螺仪来控制蛇Flutter使用陀螺仪需要借助一个库:sensors 或者sensors_plus,两者没有太大的区别这个demo使用:sensors: any官方给的例子:import 'package:sensors/sensors.dart'; ​ accelerometerEvents.listen((AccelerometerEvent event) { print(event); }); // [AccelerometerEvent (x: 0.0, y: 9.8, z: 0.0)] 加速度 ​ userAccelerometerEvents.listen((UserAccelerometerEvent event) { print(event); }); // [UserAccelerometerEvent (x: 0.0, y: 0.0, z: 0.0)] ​ gyroscopeEvents.listen((GyroscopeEvent event) { print(event); }); // [GyroscopeEvent (x: 0.0, y: 0.0, z: 0.0)] 陀螺仪我们在initState对陀螺仪进行监听:这里有x,y,z的三个参数,也可以自己优化调试,5.0是当手机倾斜>=45°@override void initState() { super.initState(); accelerometerEvents.listen((AccelerometerEvent event) {   setState(() {     _accelerometerValues = <double>[event.x, event.y, event.z];     if(_accelerometerValues[0] >= 5.0){       direction = Direction.Left;     }else if(_accelerometerValues[1] >= 5.0){       direction = Direction.Down;     }else if(_accelerometerValues[0] <= -5.0){       direction = Direction.Right;     }else if(_accelerometerValues[1] <= -5.0){       direction = Direction.Up;     }   }); }); }4.让蛇吃掉豆子还是在刚刚的周期函数里添加:当蛇头碰到豆子时,给蛇加一格if(newSnakeList[0] == ball){  newSnakeList..add(snakeList[snakeList.length - 1]);  setState(() {    ball = randoowPositon(maxWidth, maxHeight); //随机生成一个豆子,randoowPositon方法在后面 }); }5.吃掉豆子随机生成一个豆子对豆子的生成也需要优化一下(之前生成有点问题,现在优化一下)Offset randoowPositon(double widthRange, double heightRange) { ///随机生成豆子 var rng = Random(); int intWidthRange = widthRange.toInt(); int intHeightRange = heightRange.toInt(); int finalWdith = rng.nextInt(intWidthRange); int finalHeight = rng.nextInt(intHeightRange); double widthPad = finalWdith % size; double heightPad = finalHeight % size; double actualWidth = finalWdith - widthPad; double actualHeight = finalHeight - heightPad; return Offset(rng.nextInt(widthRange.toInt()).toDouble(), rng.nextInt(heightRange.toInt()).toDouble()); }ok到这里就完成了,需要源码请看通知通知:juejin.cn/pin/7034450…这游戏好玩是好玩,就是费手,哈哈,来个大神优化一下吧

0
0
0
浏览量2018
chole

Flutter 历时5天,我终于做出它!!!(炫酷的引导页、登录界面)

前言五天前,我发布了很多登录界面的UI,并发起投票:juejin.cn/post/701092…从那天开始我就找了ui小姐姐,使用一杯送到手中的奶茶,换取了小姐姐的切图。然后我在两天的工作之余,我开始使用Flutter实现,可是,这张设计图在很多android手机的表现并不好,作为一个良心up😭,那我必须不能把它给大家做了一半,发现效果不好,我都封装好了,就想着大家下载改一改就可以商用然后我又选择了一张,这次秉承着是男人就带点绿的原则我实现了它!!!效果图:有点累,但还是封装了数据,所以给我点个赞吧😘 代码数据基本封装完成,界面适配也做好了,github仓库还没有搭(需要的看文末通知),自己改改就可以放到项目里,请认真看文章,不然有可能运行不起来😜分析:1.数据封装2.引导页·左右滑动3.引导页·底部动画处理(跟随动画)4.引导页·判断滑动到最后跳转至登陆界面,并从内存中移除5.首页·输入框处理6.首页·忘记密码,注册按钮,登录按钮处理注:屏幕适配使用 flutter_screenutil: ^5.0.0 (可以自己写,这样可以只需要写自己需要的,代码会比较清晰)1.数据封装为了后续更好的维护,我们需要将data进行封装,如果有接口,也需要对接口表进行封装:class TextData { static String welcome = "Holding spave\nfor collaborative\nconversation"; //欢迎词 static String login = "Login"; // 登录 static String name = "username"; //姓名框提示 static String password = "password"; static String register = "Not a member yet? Sing up!\n Forgot password"; //登录按钮 static String welcomeImage = "images/welcome.png"; //欢迎界面背景 static String loginBackImage = "images/loginBack.png"; //登录界面背景 }2.左右滑动从效果图中可以看出是可以左右移动的Widget,这样的场景下,PageView是非常适合的,为了适配,我们需要使用Stack来包裹PageView:return Scaffold( body: Stack( childern:[ .... ] ));这里为了大家更好的修改,我选择了PageView.builder(),大家只需要编辑所需要的引导Widget就可以。来看代码:1.我们需要使用一个PageController来控制PageViewPageController _pageController;2.定义一个int变量用于记录当前是第几个引导页int _currentPage = 0;3.自己使用时,可以使用数组来存放Widget,代码中给了替换的注释😉4.在onPageChanged中处理当页面滑动改变时的数据PageView.builder( itemBuilder: (context, index) { return Stack( children: [ Container( width: 1.sw, height: 1.sh, child: Image.asset( TextData.welcomeImage, fit: BoxFit.fill, ), ), Positioned( left: 60.w, bottom: 200, child: Text(TextData.welcome, style: TextStyle(fontSize: 50.sp, color: Colors.white)), ///博主自己随便写写,大家自己修改哈 ), ], ); }, onPageChanged: (int index) { setState(() { _currentPage = index; ///保存当前页面的下标 }); }, itemCount: 5, //换成自己的Widget scrollDirection: Axis.horizontal, reverse: false, controller: _pageController, ),3.引导页·底部动画处理(跟随动画)我建议可以在菱形中加入当前界面的下标,可惜ui小姐姐没有帮我画😭这个是对如何画带弧的菱形做处理:我们定义一个double来存放M系数:double radius = 1.sw / 20; //这个值是为了适配我们还需要对运动时的动画进行分析处理,这样的动画很类似三阶贝塞尔曲线:p0、p1、p2、p3四个点在平面或在三维空间定义了三次贝塞尔曲线。曲线起始于p0走向p1,并从p2的方向来到p3.一般不会经过p1或者p2;这两点只是在那里提供了方向资讯。p0和p1之间的间距,决定了曲线在转而趋进p3之前,走向p2方向的“长度有多长”。根据这样的公式,我计算出了动画路径:void _canvasBesselPath(Path path) { Point p1 = Point(x: radius*2,y: radius); Point p2 = Point(x: radius,y: radius*2); Point p3 = Point(x: 0,y: radius); Point p4 = Point(x: radius,y: 0); if (isToRight) { if (percent <= 0.2) { p1.x = radius*2 + radius*percent/0.2; } else if (percent <= 0.4) { p4.x = p2.x = radius + radius*(percent-0.2)/0.2; p1.x = p2.x + radius*2; } else if (percent <= 0.6) { p3.x = radius*(percent - 0.4)/0.2; p4.x = p2.x = p3.x + radius*2; p1.x = radius*4; } else if (percent <= 0.8) { p3.x = radius + radius*(percent - 0.6)/0.2; p4.x = p2.x = radius*3; p1.x = radius*4; } else if (percent <= 0.9) { p3.x = 2*radius+radius*(percent - 0.8)/0.3; p4.x = p2.x = radius*3; p1.x = radius*4; } else if (percent <= 1.0) { p3.x = 2*radius+radius*(1 - percent)/0.3; p4.x = p2.x = radius*3; p1.x = radius*4; } } else { if (percent <= 0.2) { p3.x = - radius*percent/0.2; } else if (percent <= 0.4) { p3.x = -radius - radius*(percent-0.2)/0.2; p4.x = p2.x = p3.x + 2*radius; } else if (percent <= 0.6) { p3.x = - 2*radius; p4.x = p2.x = - radius*(percent - 0.4)/0.2; p1.x = p2.x + radius*2; } else if (percent <= 0.8) { p3.x = -2*radius; p4.x = p2.x = -radius; p1.x = p2.x + radius*2 - radius*(percent - 0.6)/0.2; } else if (percent <= 0.9) { p3.x = -2*radius; p4.x = p2.x = -radius; p1.x = p2.x + radius - radius*(percent - 0.8)/0.4; } else if (percent <= 1.0) { p3.x = -2*radius; p4.x = p2.x = -radius; p1.x = p2.x + radius - radius*(1 - percent)/0.4; } } final p1Radius = p2.y - p1.y; final p24LeftRadius = p2.x - p3.x; final p24RightRadius = p1.x - p2.x; final p3Radius = p2.y - p3.y; path.moveTo(p1.x, p1.y); path.cubicTo( p1.x, p1.y + p1Radius*M, p2.x + p24RightRadius*M, p2.y, p2.x, p2.y ); path.cubicTo( p2.x - p24LeftRadius*M, p2.y, p3.x, p3.y + p3Radius*M, p3.x, p3.y ); path.cubicTo( p3.x, p3.y - p3Radius*M, p4.x - p24LeftRadius*M, p4.y, p4.x, p4.y ); path.cubicTo( p4.x + p24RightRadius*M, p4.y, p1.x , p1.y - p1Radius*M, p1.x, p1.y ); }我们还需要计算每次的落点:(已经自适应了,XDM放心食用)定义一个int变量与当前页面下标做比较:int preInteger = 0;然后对PageView的controller进行监听:@override void initState() { super.initState(); _pageController = PageController(viewportFraction: 1); _pageController.addListener(() { curPosition = _pageController.page; if (curPosition.toInt() == curPosition) { preInteger = curPosition.toInt(); } else if (curPosition > preInteger) { isToRight = true; } else { isToRight = false; } setState(() {}); }); }使用Transform.translate对路径进行定位(使用):计算offSetX的值用于定位,横坐标:double percent; if (isToRight) { percent = curPosition - curPosition.toInt(); } else { percent = 1 - curPosition + curPosition.toInt(); } double offsetPercent; if (isToRight) { if (percent <= 0.8) { offsetPercent = curPosition.toInt() + percent / 0.8; } else { offsetPercent = curPosition.ceil().toDouble(); } } else { if (percent <= 0.8) { offsetPercent = curPosition.ceil() - percent / 0.8; } else { offsetPercent = curPosition.toInt().toDouble(); } } double deviceWidth = 1.sw; double offSetX = deviceWidth * 0.2 + (deviceWidth - radius * 2 - deviceWidth * 0.2) * offsetPercent / 5 - 20;最重要的是:double offSetX = deviceWidth * 0.2 + (deviceWidth - radius * 2 - deviceWidth * 0.2) * offsetPercent / 5 - 20;这句话才是定位算出横坐标的关键!下面是使用代码:Transform.translate( offset: Offset(offSetX, 0), ///offSetx用于定位 child: Stack( children: [ CustomPaint( painter: BesselView( ///这个是上面计算的动画路径 radius: radius, percent: percent, isToRight: isToRight, color: Colors.white), ), // Text(currentPage.toString(),style: TextStyle(fontSize: 50.sp),),本来想自己写下标的,但是样式很难看就注释了 ], ), )完整的实现,大家可以看看源代码4.引导页·判断滑动到最后跳转至登陆界面,并从内存中移除在PageView中的onPageChanged进行判断,当滑动时的下标超出定义的Widget数组时,我们跳转:pushReplacement跳转方式:换当前页为目标页(也就是说,堆栈中只有首页和当前页 两个页面,当前页返回自然是首页)。使用以下语句完成替换跳转。onPageChanged: (int index) { print("当前的页面是 $index"); if (index + 1 == 5) { print("跳转到首页"); ///清除引导页 Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => LoginPage())); } setState(() { _currentPage = index; }); },5.首页·输入框处理这个就很常规了,不过我针对这个效果给大家封装了一下:import 'package:flutter/material.dart'; inputTextItem( {FocusNode focusNode, TextEditingController controller, TextInputType textInputType, String hintText, double hintFontSize, double cursorHeight = 2.0, ValueChanged onPress, bool obscureText = false, Key key}) { return TextField( controller: controller, focusNode: focusNode, keyboardType: textInputType, obscureText: obscureText, cursorHeight: cursorHeight, decoration: InputDecoration( isCollapsed: true, contentPadding: EdgeInsets.symmetric(horizontal: 0, vertical: 8), //内容内边距,影响高度 border: InputBorder.none, filled: false, fillColor: Color.fromARGB(255, 225, 225, 225), hintText: hintText, hintStyle: TextStyle(fontSize: hintFontSize, color: Colors.grey, textBaseline: TextBaseline.alphabetic), ), onSubmitted: onPress, ); }6.首页·忘记密码,注册按钮,登录按钮处理这里主要是想告诉大家一些不常用的Text的属性,以及简单处理了一下字符串,告诉一下小白:style: TextStyle( color: Colors.white, fontSize: 32.sp, decoration: TextDecoration.underline, ),decoration: TextDecoration.underline,文字下划线decoration: TextDecoration.lineThrough,删除线虚线和上划线:decoration: TextDecoration.overline,decorationStyle: TextDecorationStyle.dashed,

0
0
0
浏览量2014
chole

Flutter 小技巧之 ButtonStyle 和 MaterialStateProperty

今天分享一个简单轻松的内容: ButtonStyle 和 MaterialStateProperty 。大家是否还记得去年 Flutter 2.0 发布的时候,除了空安全之外 ,还更新了一系列关于控件的  breaking change,其中就有  FlatButton  被标志为弃用,需要替换成 TextButton 的情况。如今已经 Flutter 3.0 ,不大知道大家对  TextButton  是否已经足够了解,或者说对 MaterialStateProperty 是否已经足够了解?为什么  TextButton  会和 MaterialStateProperty  扯到一起?首先,说到 MaterialStateProperty 就不得不提 Material Design ,MaterialStateProperty 的设计理念,就是基于 Material Design 去针对全平台的交互进行兼容。相信大家当初在从 Flutter 1 切换到 Flutter 2 的时候,应该都有过这样一个疑问:为什么 FlatButton 和 RaisedButton 会被弃用替换成 TextButton 和 RaisedButton ?因为以前只需要使用 textColor 、backgroundColor 等参数就可以快速设置颜色,但是现在使用  ButtonStyle  ,从代码量上看相对会麻烦不少。当然,在后续里官方也提供了类似 styleFrom 等静态方法来简化代码,但是本质上切换到 ButtonStyle 的意义是什么 ?MaterialStateProperty 又是什么?首先我们看看   MaterialStateProperty  ,在  MaterialStateProperty  体系里有一个 MaterialState 枚举,它主要包含了:disabled:当控件或元素不能交互性时hovered:鼠标交互悬停时focused: 在键盘交互中突出显示selected:例如 check box 的选定状态pressed:通过鼠标、键盘或者触摸等方法发起的轻击或点击dragged:用户长按并移动控件时error:错误状态下,比如 TextField 的 Error所以现在理解了吧? 随着 Web 和 Desktop 平台的发布,原本的   FlatButton  无法很好满足新的 UI 交互需要,例如键鼠交互下的 hovered ,所以 TextButton  开始使用 MaterialStateProperty 来组成 ButtonStyle 支持不同平台下 UI 的状态展示。在此之前,如果需要多平台适配你可能会这么写,你需要处理很多不同的状态条件,从而产生无数 if 或者 case : getStateColor(Set<MaterialState> states) { if (states.contains(MaterialState.hovered)) { ///在 hovered 时还 focused 了 if (states.contains(MaterialState.focused)) { return Colors.red; } else { return Colors.blue; } } else if (states.contains(MaterialState.focused)) { return Colors.yellow; } return Colors.green; }但是现在, 你只需要继承  MaterialStateProperty  然后 @override  resolve 方法就可以了,例如   TextButton  里的 hovered 效果,在 TextButton 内默认就是通过  _TextButtonDefaultOverlay 实现,对  primary.withOpacity 来实现 hovered  效果。@immutable class _TextButtonDefaultOverlay extends MaterialStateProperty<Color?> { _TextButtonDefaultOverlay(this.primary); final Color primary; @override Color? resolve(Set<MaterialState> states) { if (states.contains(MaterialState.hovered)) return primary.withOpacity(0.04); if (states.contains(MaterialState.focused) || states.contains(MaterialState.pressed)) return primary.withOpacity(0.12); return null; } @override String toString() { return '{hovered: ${primary.withOpacity(0.04)}, focused,pressed: ${primary.withOpacity(0.12)}, otherwise: null}'; } }其实在 TextButton  的内部,默认同样是通过 styleFrom  来配置所需的  MaterialState  效果,其中有:_TextButtonDefaultForeground : 用于处理  disabled ,通过 onSurface?.withOpacity(0.38) 变化颜色;_TextButtonDefaultOverlay:  用于处理 hovered 、  focused  和 pressed ,通过 primary.withOpacity 变化颜色;_TextButtonDefaultMouseCursor : 用于处理鼠标 MouseCursor 的 disabled;剩下的参则是通过我们熟悉的   ButtonStyleButton.allOrNull 进行添加,也就是不需要特殊处理的参数。那   ButtonStyleButton.allOrNull  的作用是什么?其实   ButtonStyleButton.allOrNull  就是 MaterialStateProperty.all 方法的可 null 版本,对应内部实现最终还是实现了  resolve 接口的  MaterialStateProperty ,所以如果需要支持 null,你也可以做直接使用   MaterialStateProperty.all 。static MaterialStateProperty<T>? allOrNull<T>(T? value) => value == null ? null : MaterialStateProperty.all<T>(value);当然,如果不想创建新的 class 但是又想定制逻辑,如下代码所示,那你也可以使用 resolveWith 静态方法:TextButton( style: ButtonStyle( backgroundColor: MaterialStateProperty.resolveWith((states) { if (states.contains(MaterialState.hovered)) { return Colors.green; } return Colors.transparent; })), onPressed: () {}, child: new Text( "TEST", style: TextStyle(fontSize: 100), ), ),当然,谷歌在对 Flutter 控件进行 MaterialState 的 UI 响应时,也是遵循了 Material Design 的设计规范,比如 Hover 时  primary.withOpacity(0.04); ,所以不管在 TextButton 还是  RaisedButton 内部都遵循类似的规范。另外,有时候你肯定不希望每个地方单独去配置 Style ,那这时候你就需要配合 Theme 来实现。事实上  TextButton 、 ElevatedButton 和  OutlinedButton 都是  ButtonStyleButton 的子类,他们都会遵循以下的原则: final ButtonStyle? widgetStyle = widget.style; final ButtonStyle? themeStyle = widget.themeStyleOf(context); final ButtonStyle defaultStyle = widget.defaultStyleOf(context); assert(defaultStyle != null); T? effectiveValue<T>(T? Function(ButtonStyle? style) getProperty) { final T? widgetValue = getProperty(widgetStyle); final T? themeValue = getProperty(themeStyle); final T? defaultValue = getProperty(defaultStyle); return widgetValue ?? themeValue ?? defaultValue; }也就是 return widgetValue ?? themeValue ?? defaultValue;  ,其中:widgetValue  就是控件单独配置的样式themeValue 就是 Theme 里配置的全局样式defaultValue 就是默认内置的样式,也即是  styleFrom  静态方法,当然 styleFrom 里也会用一些 ThemeData 的对象,例如 colorScheme.primary 、 textTheme.button  、theme.shadowColor 等所以,例如当你需要全局去除按键的水波纹时,如下代码所示,你可以修改 ThemeData 的 TextButtonTheme  来实现,因为 TextButton 内的 themeStyleOf 使用的就是 TextButtonTheme  。theme: ThemeData( primarySwatch: Colors.blue, textButtonTheme: TextButtonThemeData( // 去掉 TextButton 的水波纹效果 style: ButtonStyle(splashFactory: NoSplash.splashFactory), ), ),最后做个总结:如果只是简单配置背景颜色,可以直接用  styleFrom如果单独配置,可以使用   ButtonStyleButton.allOrNull如果需要灵活处理,可以使用   ButtonStyleButton.resolveWith   或者实现  MaterialStateProperty   的 resolve 接口

0
0
0
浏览量2017
chole

Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套

这次的 Flutter 小技巧是 ListView 和 PageView 的花式嵌套,不同 Scrollable 的嵌套冲突问题相信大家不会陌生,今天就通过  ListView 和 PageView 的三种嵌套模式带大家收获一些不一样的小技巧。正常嵌套最常见的嵌套应该就是横向  PageView  加纵向  ListView  的组合,一般情况下这个组合不会有什么问题,除非你硬是要斜着滑。最近刚好遇到好几个人同时在问:“斜滑 ListView 容易切换到 PageView  滑动” 的问题,如下 GIF 所示,当用户在滑动    ListView   时,滑动角度带上倾斜之后,可能就会导致滑动的是   PageView   而不是 ListView 。我们简单看一下,不管是  PageView    还是   ListView  它们的滑动效果都来自于  Scrollable ,而    Scrollable  内部针对不同方向的响应,是通过 RawGestureDetector 完成:VerticalDragGestureRecognizer 处理垂直方向的手势HorizontalDragGestureRecognizer  处理水平方向的手势所以简单看它们响应的判断逻辑,可以看到一个很有趣的方法 computeHitSlop : 根据 pointer 的类型确定当然命中需要的最小像素,触摸默认是 kTouchSlop (18.0)。看到这你有没有灵光一闪:如果我们把 PageView 的 touchSlop 修改了,是不是就可以调整它响应的灵敏度? 恰好在  computeHitSlop  方法里,它可以通过 DeviceGestureSettings 来配置,而  DeviceGestureSettings  来自于 MediaQuery ,所以如下代码所示:body: MediaQuery( ///调高 touchSlop 到 50 ,这样 pageview 滑动可能有点点影响, ///但是大概率处理了斜着滑动触发的问题 data: MediaQuery.of(context).copyWith( gestureSettings: DeviceGestureSettings( touchSlop: 50, )), child: PageView( scrollDirection: Axis.horizontal, pageSnapping: true, children: [ HandlerListView(), HandlerListView(), ], ), ),小技巧一:通过嵌套一个 MediaQuery ,然后调整 gestureSettings 的 touchSlop 从而修改 PageView 的灵明度 ,另外不要忘记,还需要把  ListView 的   touchSlop  切换会默认 的 kTouchSlop :class HandlerListView extends StatefulWidget { @override _MyListViewState createState() => _MyListViewState(); } class _MyListViewState extends State<HandlerListView> { @override Widget build(BuildContext context) { return MediaQuery( ///这里 touchSlop 需要调回默认 data: MediaQuery.of(context).copyWith( gestureSettings: DeviceGestureSettings( touchSlop: kTouchSlop, )), child: ListView.separated( itemCount: 15, itemBuilder: (context, index) { return ListTile( title: Text('Item $index'), ); }, separatorBuilder: (context, index) { return const Divider( thickness: 3, ); }, ), ); } }最后我们看一下效果,如下 GIF 所示,现在就算你斜着滑动,也很触发  PageView  的水平滑动,只有横向移动时才会触发   PageView  的手势,当然, 如果要说这个粗暴的写法有什么问题的话,大概就是降低了 PageView 响应的灵敏度。同方向 PageView 嵌套 ListView介绍完常规使用,接着来点不一样的,在垂直切换的 PageView 里嵌套垂直滚动的  ListView , 你第一感觉是不是觉得不靠谱,为什么会有这样的场景?对于产品来说,他们不会考虑你如何实现的问题,他们只会拍着脑袋说淘宝可以,为什么你不行,所以如果是你,你会怎么做?而关于这个需求,社区目前讨论的结果是:把 PageView 和 ListView 的滑动禁用,然后通过 RawGestureDetector 自己管理。如果对实现逻辑分析没兴趣,可以直接看本小节末尾的 源码链接 。看到自己管理先不要慌,虽然要自己实现  PageView 和  ListView  的手势分发,但是其实并不需要重写   PageView 和  ListView  ,我们可以复用它们的 Darg 响应逻辑,如下代码所示:通过 NeverScrollableScrollPhysics 禁止了 PageView 和 ListView 的滚动效果通过顶部 RawGestureDetector 的 VerticalDragGestureRecognizer 自己管理手势事件配置 PageController 和 ScrollController  用于获取状态body: RawGestureDetector( gestures: <Type, GestureRecognizerFactory>{ VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers< VerticalDragGestureRecognizer>( () => VerticalDragGestureRecognizer(), (VerticalDragGestureRecognizer instance) { instance ..onStart = _handleDragStart ..onUpdate = _handleDragUpdate ..onEnd = _handleDragEnd ..onCancel = _handleDragCancel; }) }, behavior: HitTestBehavior.opaque, child: PageView( controller: _pageController, scrollDirection: Axis.vertical, ///屏蔽默认的滑动响应 physics: const NeverScrollableScrollPhysics(), children: [ ListView.builder( controller: _listScrollController, ///屏蔽默认的滑动响应 physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { return ListTile(title: Text('List Item $index')); }, itemCount: 30, ), Container( color: Colors.green, child: Center( child: Text( 'Page View', style: TextStyle(fontSize: 50), ), ), ) ], ), ),接着我们看 _handleDragStart 实现,如下代码所示,在产生手势 details 时,我们主要判断:通过  ScrollController   判断 ListView 是否可见判断触摸位置是否在 ListIView 范围内根据状态判断通过哪个 Controller 去生产  Drag 对象,用于响应后续的滑动事件 void _handleDragStart(DragStartDetails details) { ///先判断 Listview 是否可见或者可以调用 ///一般不可见时 hasClients false ,因为 PageView 也没有 keepAlive if (_listScrollController?.hasClients == true && _listScrollController?.position.context.storageContext != null) { ///获取 ListView 的 renderBox final RenderBox? renderBox = _listScrollController ?.position.context.storageContext .findRenderObject() as RenderBox; ///判断触摸的位置是否在 ListView 内 ///不在范围内一般是因为 ListView 已经滑动上去了,坐标位置和触摸位置不一致 if (renderBox?.paintBounds .shift(renderBox.localToGlobal(Offset.zero)) .contains(details.globalPosition) == true) { _activeScrollController = _listScrollController; _drag = _activeScrollController?.position.drag(details, _disposeDrag); return; } } ///这时候就可以认为是 PageView 需要滑动 _activeScrollController = _pageController; _drag = _pageController?.position.drag(details, _disposeDrag); }前面我们主要在触摸开始时,判断需要响应的对象时 ListView 还是 PageView ,然后通过 _activeScrollController 保存当然响应对象,并且通过  Controller 生成用于响应手势信息的  Drag 对象。简单说:滑动事件发生时,默认会建立一个 Drag 用于处理后续的滑动事件,Drag 会对原始事件进行加工之后再给到 ScrollPosition 去触发后续滑动效果。接着在 _handleDragUpdate 方法里,主要是判断响应是不是需要切换到 PageView :如果不需要就继续用前面得到的  _drag?.update(details)响应 ListView 滚动如果需要就通过 _pageController 切换新的  _drag 对象用于响应void _handleDragUpdate(DragUpdateDetails details) { if (_activeScrollController == _listScrollController && ///手指向上移动,也就是快要显示出底部 PageView details.primaryDelta! < 0 && ///到了底部,切换到 PageView _activeScrollController?.position.pixels == _activeScrollController?.position.maxScrollExtent) { ///切换相应的控制器 _activeScrollController = _pageController; _drag?.cancel(); ///参考 Scrollable 里 ///因为是切换控制器,也就是要更新 Drag ///拖拽流程要切换到 PageView 里,所以需要 DragStartDetails ///所以需要把 DragUpdateDetails 变成 DragStartDetails ///提取出 PageView 里的 Drag 相应 details _drag = _pageController?.position.drag( DragStartDetails( globalPosition: details.globalPosition, localPosition: details.localPosition), _disposeDrag); } _drag?.update(details); }这里有个小知识点:如上代码所示,我们可以简单通过 details.primaryDelta 判断滑动方向和移动的是否是主轴最后如下 GIF 所示,可以看到 PageView 嵌套 ListView 同方向滑动可以正常运行了,但是目前还有个两个小问题,从图示可以看到:在切换之后  ListView 的位置没有保存下来产品要求去除 ListView 的边缘溢出效果所以我们需要对 ListView 做一个 KeepAlive ,然后用简单的方法去除 Android 边缘滑动的 Material 效果:通过 with AutomaticKeepAliveClientMixin 让 ListView 在切换之后也保持滑动位置通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Scrollable 的边缘  Material 效果child: PageView( controller: _pageController, scrollDirection: Axis.vertical, ///去掉 Android 上默认的边缘拖拽效果 scrollBehavior: ScrollConfiguration.of(context).copyWith(overscroll: false), ///对 PageView 里的 ListView 做 KeepAlive 记住位置 class KeepAliveListView extends StatefulWidget { final ScrollController? listScrollController; final int itemCount; KeepAliveListView({ required this.listScrollController, required this.itemCount, }); @override KeepAliveListViewState createState() => KeepAliveListViewState(); } class KeepAliveListViewState extends State<KeepAliveListView> with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { super.build(context); return ListView.builder( controller: widget.listScrollController, ///屏蔽默认的滑动响应 physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { return ListTile(title: Text('List Item $index')); }, itemCount: widget.itemCount, ); } @override bool get wantKeepAlive => true; }所以这里我们有解锁了另外一个小技巧:通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Android 滑动到边缘的 Material 2效果,为什么说 Material2, 因为 Material3 上变了,具体可见: Flutter 3 下的 ThemeExtensions 和 Material3 。

0
0
0
浏览量1938
chole

马上2024年了,现在去开发一款App需要投入多少资金?

前言本文主要探讨跨平台应用的开发成本,原生与小程序不在探讨范围之内,为什么呢?请接着往下看~选择大于努力原生开发的现状先来看下目前原生开发存在的问题以及国内的现状。开发人员的人力成本相对高于跨平台开发人员,对于纯原生的项目,企业通常需要招两个端的开发人员。这也是导致很多企业不愿意选择原生开发的重要原因之一(Android、iOS)。原生应用开发成本高,开发周期慢,如果不招人(提高用人成本)较难跟上市场节奏。原生应用推广成本也高(与小程序相对比)。对于我们开发人员来说,需要掌握一种语言Java或者kotlin,ios开发需要oc或者swift,难度相对于跨平台学习成本较高。企业对于技术上的选择,目前需要的就是能节省成本、同时开发效率高的,跨平台已经是大势所趋。国内特有的小程序小程序的优势很大,自从小程序出来后,蚕食了很大一部分手机应用的市场份额。小程序相较于原生应用具有显著的优势,其中最大的优势在于成本的降低。相比于开发原生应用,小程序的开发成本更低,同时也更加省时省力。此外,小程序还能够充分利用微信等大型平台的庞大用户流量入口,从而降低企业在推广方面的成本。这种降低成本的好处不仅体现在企业推广方面,也使得用户在使用小程序时所需投入的成本降低(不用去下载app,也不用再走一遍注册流程)。正因为如此,许多中小企业不愿再开发原生应用,或者说,“没能力”开发原生应用。更倾向于选择小程序。小程序的低成本开发和推广,使得中小企业能够以更少的投入获得更大的回报。此外,小程序还可以借助微信等平台的用户基础,更容易吸引和留住用户。如何计算一款App开发的成本本文选择跨平台技术作为开发成本的参考。那在跨平台中,从Statista(一家全球领先的统计数据平台和市场研究公司)收集的数据来看,很明显 Flutter 继续脱颖而出,成为跨平台框架中的首选。截止2023年6月,Flutter占跨平台份额的46%,在跨平台中占比第一,React Native占32%,居第二。长话短说,开发 Flutter 应用程序的相关费用基本在 10,000 到 450,000 人民币之间,甚至更高。在这本文中,我们将分解各种成本因素,去计算 Flutter 应用程序开发成本。那如何去计算一款应用的开发成本呢,开发一款应用一共分一下几个阶段,每个阶段都会影响总成本。第一阶段:需求分析与规划第二阶段:原型设计(UI/UX)第三阶段:正式编码(此时应用已经基本成型)第四阶段:测试第五阶段:部署与维护Flutter技术在国内多用于外包项目,所以通常三四五(有些项目会包含二)的几个阶段都由开发者全权负责完成,应用的总体成本通常是通过将总工时乘以开发者的小时费来估算的。影响一款应用开发成本的因素不同的应用开发的成本可能会因多种因素而有很大差异,每个因素都会直接影响项目的预算和时间表。最终价格可能受到一系列因素的影响,例如应用程序的复杂程度、要纳入的功能总数、开发人员的每小时费以及许多其他方面。主要的因素也是对应到开发阶段中,主要是以下这些:在需求分析时,应用程序的范围和复杂性在UI设计时,UI的动画、复杂的布局、对设计风格的要求在开发时,选择的开发方式(1.外包给自由职业者。2.外包给专业软件公司。3.自己招人干)。选择外包开发者(开发商)的地理位置,假设你在美国,找一个中国开发者,成本就会降低许多在测试时,跨平台的设备成本,功能测试的范围部署维护时,服务器的成本,bug的修复,添加新的功能那让我们再来详细聊聊每个阶段具体要花多少费用。需求分析设计阶段项目的需求和范围是开发成本的主要决定因素,例如,开发一个基本的笔记应用程序比开发一个功能齐全的电商平台便宜得多。因此,在App开发的初始阶段定义项目需求和应用程序复杂性对于估算总体成本至关重要。App在刚开始需要舍弃掉一些不重要的功能。UI设计阶段如果有一个高质量的 UI/UX 设计,那对于App的成功是很有帮助的。但它也会影响成本,一款简单、简约的设计比具有独特图形、复杂动画动画的定制成本更低。如果需要高度定制的设计或想要实现特定的品牌元素,这将极大增加的应用程序开发成本。根据应用程序的复杂程度,设计一款完整的App平均需要 40 到 90 多个小时。设计一款App的UI,价格平均在5000-25000左右,让我们对应到每项工作中去。前期的需求交流和沟通。此阶段涉及创建草图和线框图。所需的时间和成本取决于设计的复杂程度。创建草图和线框图可能需要 200 至 1000 的预算分配UI/UX 设计视觉效果的创建。 此阶段为整个App的内容设计,例如登录界面、注册界面等。同样,实际所需时间取决于App的复杂性。此阶段的预算范围从 5,000 到 15,000或更多logo设计。在这个阶段阶段,设计师根据之前设计的App内容和、我们的品牌配色和其他设计元素。这项共工作需要相当大的预算,大约需要 5,000 到 10,000 的预算甚至更多。当然,为了节省成本也可以放弃这一阶段,由我们自己设计代码开发阶段选择不同的开发人员或开发团队也会影响成本。如果选择经验丰富的专业人员团队会花费更多的前期成本,但可以带来更高的效率和更高质量的产品。如果,雇用经验不足的开发人员刚开始可能会省钱,但可能会导致开发时间更长或日后出现潜在问题。目前主流的方式为以下三种:自由职业者(外包给程序员做私活)这种方式可以很好的降低成本,身边也有很多朋友会接私活,确实是一个很不错的选择。但是,这种方式可能会遇到许多不确定性,例如没法按时交付。此外,如果这个项目后期需要进行维护、更新,那这个方案可能就不是最可靠选择了,因为他们可能会转移到其他项目(或者跑路),从而使持续协作变得具有挑战性。如果选择这个方案,建议是朋友推荐,或者是网上具有一定知名度的开发者。在国内,跨平台应用开发者(Flutter开发)的时薪通常在每小时150到350人民币不等。如果选择这个方式,开发成本在10000到50000之间。外包公司这种方法是节省开发资金而又不影响产品质量的绝佳方法,通常开发成本在50000到150000之间。如果项目需要后期的维护,迭代,那么可以优先选择这样的方式。(现在的外包公司也比较卷)自己组团队如果是想要真的以一种创业的方式,那么开发成本的范围是0到无上限。如果自身就是一个技术人员,那么只需要一台笔记本就可以完成对应用的开发,所花的只是时间成本。如果要招人组团队,那成本就不可估计了。测试阶段这部分在大多数App开发过程中,已经由开发者自己测试解决的。稍微正规些的应用可以将测试的工作外包给测试公司。成本在0~20000人民币之间。维护与迭代开发一款App不是短跑,而更像是一场马拉松。即使在App第一版上线后,这个旅程仍在继续。定期更新、bug修复和UI修改只是维护App的冰山一角。最好预留总成本的 15-20% 的额外费用,用来进行维护。其他因素——每个项目都是独特的,具体要求将决定最终成本。因此,在规划App开发预算时,必须彻底了解这些因素并加以考虑。第三方API集成如果项目中需要集成即时通讯等功能模块,那么第三方API集成的这部分的花销也是不可忽略。软著申请、应用商店发布软著申请是免费的,自行准备材料申请即可,但是通常会有2~3个月的时间,才能申请成功。如果想快速申请,可以找专门的三方申请机构,价格在500-2000左右。如果App需要上架Google Play和App Store,那么,开通Google Play 开发者账户一次性收取 25 美元费用,Apple Store 个人开发者账号每年收取 99 美元费用。此外,还会从应用内购买或订阅中扣除部分费用。申请软著和App上架的材料准备工作,通常需要10-20小时的工作。按每小时50元,此部分工作需要500-1000元的费用。后端开发和服务器的费用如果App只会进行一些本地操作,那么这部分的费用基本为0。如果需要后端提供服务,则需要在拿出一大笔钱进行后端的开发和服务器的购买费用。如何降低开发成本外包项目这种模式允许利用全球人才库,通常以比雇用本地人才更具竞争力的价格获得服务。这点如果你在美国等发达国家可以考虑。如果在大陆,可以看看三哥他们。此外,这种方式还减少了对办公空间和设备的需求,并减少了与员工福利和津贴相关的管理费用。明确项目要求还是那句话,最后的成本一定与开始的需求有着很大关联。所以一定要精简需求,明确App到底要做什么。专注于敏捷方法如果你是个人开发者或者要带领团队开发,那一定要注重敏捷开发,确定任务优先级、经常重新评估和调整项目目标。结论 — 关于开发一款App的成本关于开发一款App的成本,为了让大家能更直观的感受,让我们具体数字来说明这一点。(采用Flutter跨平台)对于简单功能的App(例如提供膳食计划App、日记App、记账App等),估计开发成本约为 10,000 — 50,000人民币之间,根据项目的复杂度来决定。对于中等复杂度的App(例如具有即时通讯、语音通话等功能)预计成本约为 50,000 — 150,000人民币之间。对于开发高复杂度的应用,例如抖音(简化版,真抖音现在哪个团队能从0开始做一个...),起价基本在150,000,上不封顶。那这就是当前开发一款App的成本,以及对应的工作。

0
0
0
浏览量1943
chole

国庆假期,我用Flutter写了个我自己都玩不赢的五子棋AI

前言在上一篇文章中,讲解了如何实现双人在本地对战的五子棋,但是只有一个人的时候就不太好玩,同时博主也没有把五子棋相关的文章写过瘾。那么这篇文章,我们来实现一个功能更加丰富的五子棋吧!在设计五子棋的算法方面,我们将引入一些经典的算法,如最大最小搜索(Max-Min)算法和Alpha-Beta剪枝算法。这些算法将帮助我们创建一个智能的对手,使游戏更具挑战性和趣味性。除了算法的介绍,本文还将深入探讨五子棋的基本玩法和规则。我们将详细解释如何落子、如何判断胜负以及如何对各种局面进行评分估值。通过学习这些基础知识,您将能够更好地理解和享受五子棋游戏。效果图:棋盘绘制本次采用的棋盘绘制与上篇文章的方式不同,上篇文章中采用的是GridView这样的基础组件,使用简单,无需手动编写绘制逻辑。利用GridView的布局特性,可以很方便地进行排列和调整。但是它也有缺点,就是不够灵活,当我们想实现更多的棋盘细节时,实现起来就不是很方便了,所以在本篇文章中,我们采用CustomPaint绘制的方式。那在绘制棋盘之前,我们需要先定义游戏所需要的一些参数和实体类:玩家类//玩家 class Player { static final Player black = Player(Colors.black); static final Player white = Player(Colors.white); late Color color; Player(this.color); @override String toString() { return 'Player{${this == black ? "black" : "white"}}'; } }单颗棋子类class Chessman { //坐标 late Offset position; //该棋子的所属人 late Player owner; //棋子id int numberId = chessmanList.length; //棋子的分数,默认为0 int score = 0; Chessman(this.position, this.owner); Chessman.white(this.position) { owner = Player.white; } Chessman.black(this.position) { owner = Player.black; } @override String toString() { return 'Chessman{position: (${position.dx},${position.dy}), owner: ${owner == Player.black ? "black" : "white"}, score: $score, numberId: $numberId}'; } }全局通用参数//初始化一个玩家,掌握黑棋 Player firstPlayer = Player.black; //存放所有的棋子 List<Chessman> chessmanList = []; //存放胜利的棋子 List<Chessman> winResult = [];那么所需的参数及实体类编写完成后,就可以开始棋盘的绘制啦!游戏页面整体布局结构@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("五子棋AI版"), ), body: Padding( padding: EdgeInsets.only(top: 50, left: 20, right: 20), child: Column( children: [ //棋盘 GestureDetector( child: CustomPaint( painter: ChessmanPaint(), size: Size(400, 400), ), onTapDown: (details) { onTapDown(details); setState(() {}); }, ), //底部操作项目 Padding() ], ), ), ); }棋盘绘制主体定义所需绘制参数//默认棋盘的行列数 const int LINE_COUNT = 14; //根据屏幕大小与行列数,计算得出每个格子的宽高,初始化先为0 double cellWidth = 0, cellHeight = 0;绘制黄褐色背景在绘制背景这里:canvas.drawRect(Offset.zero & size, painter),用了个dart的语法糖,有些朋友可能会有些疑惑,drawRect方法第一个参数不是Rect类型的吗,这里传了个Offset.zero & size是什么鬼?这里单独解释下:Offset.zero表示矩形范围的左上角坐标为原点(0,0),size表示矩形的大小。这个表达式使用&符号将两个对象合并成了一个Rect对象作为canvas.drawRect()方法的第一个参数。实际上,&符号在这里是Dart语言中的语法糖,等效于使用Rect.fromLTWH(0, 0, size.width, size.height)来创建一个矩形。因此,这里的语法Offset.zero & size可以通过Rect.fromLTWH(0, 0, size.width, size.height)来替代。class ChessmanPaint extends CustomPainter { late Canvas canvas; late Paint painter; //用于控制打印在棋子上的id static const bool printLog = true; @override void paint(Canvas canvas, Size size) { this.canvas = canvas; //计算单个格子的宽高 cellWidth = size.width / LINE_COUNT; cellHeight = size.height / LINE_COUNT; painter = Paint() ..isAntiAlias = true ..style = PaintingStyle.fill ..color = Color(0x77cdb175); //绘制背景 canvas.drawRect(Offset.zero & size, painter); } ... }绘制棋盘上的线条(格子)@override void paint(Canvas canvas, Size size) { ... painter ..style = PaintingStyle.stroke ..color = Colors.black87 ..strokeWidth = 1.0; for (int i = 0; i <= LINE_COUNT; ++i) { double y = cellHeight * i; canvas.drawLine(Offset(0, y), Offset(size.width, y), painter); } for (int i = 0; i <= LINE_COUNT; ++i) { double x = cellWidth * i; canvas.drawLine(Offset(x, 0), Offset(x, size.height), painter); } }绘制五子棋盘上的五个交叉点这5个点称为“星”。中间的星也称天元,表示棋盘的正中心,其他4个星,也叫小星。星在棋盘上起标示位置的作用,利于在行棋、复盘、记录等时,更清晰、迅速地找到所需位置。//绘制棋盘上的5个黑点 void _drawMarkPoints() { // 通过多次调用_drawMarkPoint方法来绘制标记点 _drawMarkPoint(const Offset(7.0, 7.0)); _drawMarkPoint(const Offset(3.0, 3.0)); _drawMarkPoint(const Offset(3.0, 11.0)); _drawMarkPoint(const Offset(11.0, 3.0)); _drawMarkPoint(const Offset(11.0, 11.0)); } void _drawMarkPoint(Offset offset) { painter ..style = PaintingStyle.fill ..color = Colors.black; // 计算标记点在画布上的具体位置 Offset center = Offset(offset.dx * cellWidth, offset.dy * cellHeight); // 在计算得到的位置绘制一个半径为3的圆形标记点 canvas.drawCircle(center, 3, painter); }绘制棋子这里使用min(cellWidth / 2, cellHeight / 2) - 2计算出较小的一边长度减去2作为圆的半径,可以使得所有棋子的大小一致,并且不会越出格子范围。//遍历chessmanList绘制,每下一颗子,触发setState if (chessmanList.isNotEmpty) { for (Chessman c in chessmanList) { _drawChessman(c); } } void _drawChessman(Chessman chessman) { painter ..style = PaintingStyle.fill //根据owner取得每课棋子对应的颜色 ..color = chessman.owner.color; Offset center = Offset( chessman.position.dx * cellWidth, chessman.position.dy * cellHeight); canvas.drawCircle(center, min(cellWidth / 2, cellHeight / 2) - 2, painter); //如果当前棋子的编号是最后一枚棋子,则使用painter绘制一个描边的蓝色圆圈,表示这是最后下的一枚棋子。 if (chessman.numberId == chessmanList.length - 1) { painter ..color = Colors.blue ..style = PaintingStyle.stroke ..strokeWidth = 3.0; canvas.drawCircle( center, min(cellWidth / 2, cellHeight / 2) - 2, painter); } }绘制棋子编号(非主要功能,可以跳过这步)//在棋子上绘制它的id if (printLog) { _drawText((i.toString()), Offset(-19, y - _calcTrueTextSize(i.toString(), 15.0).dy / 2)); } void _drawText(String text, Offset offset, {Color? color, double? textSize}) { // 创建ParagraphBuilder对象,用于构建文本段落 ui.ParagraphBuilder builder = ui.ParagraphBuilder(ui.ParagraphStyle( textAlign: TextAlign.center, ellipsis: '...', maxLines: 1, )); // 使用pushStyle方法设置文本风格,包括颜色和字体大小 builder.pushStyle( ui.TextStyle(color: color ?? Colors.red, fontSize: textSize ?? 15.0)); // 添加文本到builder对象中 builder.addText(text); // 构建一个Paragraph对象 ui.Paragraph paragraph = builder.build(); // 对paragraph进行layout,指定宽度为无限大 paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); // 在Canvas上绘制paragraph对象,位置为offset canvas.drawParagraph(paragraph, offset); } //根据给定的文本字符串和字体大小,计算出该文本所占据的实际宽度和高度,以便在UI布局中更好地控制文本的位置和尺寸。 Offset _calcTrueTextSize(String text, double textSize) { // 创建ParagraphBuilder对象,并设置字体大小 var paragraph = ui.ParagraphBuilder(ui.ParagraphStyle(fontSize: textSize)) ..addText(text); // 构建Paragraph对象,并进行layout,指定宽度为无限大 var p = paragraph.build() ..layout(const ui.ParagraphConstraints(width: double.infinity)); // 返回Paragraph对象的最小内在宽度和高度作为偏移量 return Offset(p.minIntrinsicWidth, p.height); }用户交互(下棋)取得用户点击的位置通过GestureDetector的onTapDown取得用户点击的位置GestureDetector( child: CustomPaint( painter: ChessmanPaint(), size: Size(400, 400), ), onTapDown: (details) { onTapDown(details); setState(() {}); }, ),点击事件//棋盘点击事件 void onTapDown(TapDownDetails details) { //游戏胜利后,再点击棋盘就无效 if (winResult.isNotEmpty) { return; } double clickX = details.localPosition.dx; //计算点击点所在列的索引值 floorX。通过将 clickX 除以格子的宽度 cellWidth 并向下取整,可以得到点击点所处的列索引值 int floorX = clickX ~/ cellWidth; //计算了当前列横坐标网格线中点的横坐标值 offsetFloorX。通过将 floorX 乘以格子的宽度 cellWidth,再加上格子宽度的一半 cellWidth / 2,可以得到当前列横坐标网格线中点的横坐标值。 double offsetFloorX = floorX * cellWidth + cellWidth / 2; //判断点击点在哪一列,并将结果赋值给变量 x。如果 offsetFloorX 大于点击点的 x 坐标 clickX,则说明点击点在 floorX 列;否则,说明点击点在 floorX + 1 列。如果点击点在 floorX + 1 列,则通过 ++floorX 来获取 floorX + 1 的值。 int x = offsetFloorX > clickX ? floorX : ++floorX; //y轴同理 double clickY = details.localPosition.dy; int floorY = clickY ~/ cellHeight; double offsetFloorY = floorY * cellHeight + cellHeight / 2; int y = offsetFloorY > clickY ? floorY : ++floorY; //触发落子 fallChessman(Offset(x.toDouble(), y.toDouble())); }落子函数void fallChessman(Offset position) { if (winResult.isNotEmpty) { return; } //创建棋子 Chessman newChessman; //棋子的颜色 if (chessmanList.isEmpty || chessmanList.length % 2 == 0) { newChessman = firstPlayer == Player.black ? Chessman.black(position) : Chessman.white(position); } else { newChessman = firstPlayer == Player.black ? Chessman.white(position) : Chessman.black(position); } //判断是否能落子 bool canFall = canFallChessman(newChessman); if (canFall) { //可以落子 //打印下落子棋子的信息 printFallChessmanInfo(newChessman); //此处还需完成: //1.棋子估值、ai相关逻辑 //2.对游戏胜利的校验,对游戏和棋的校验 }else{ print("此处无法落子!"); } } void printFallChessmanInfo(Chessman newChessman) { print( "[落子成功], 棋子序号:${newChessman.numberId} ,颜色:${newChessman.owner == Player.WHITE ? "白色" : "黑色"} , 位置 :(${newChessman.position.dx.toInt()} , ${newChessman.position.dy.toInt()})"); }该坐标能否落子的判断bool canFallChessman(Chessman chessman) { //定义一个不可能生成到棋盘上的棋子 Chessman defaultChessman = Chessman(Offset(-1, 0), Player.black); if (chessmanList.isNotEmpty) { Chessman cm = chessmanList.firstWhere((Chessman c) { //如果找到位置相同的棋子,那么cm就等于这棋子的信息 return c.position.dx == chessman.position.dx && c.position.dy == chessman.position.dy; }, orElse: () { //没找到就把该棋子添加到列表中,然后返回一个不可能在棋盘上的棋子用作校验 chessmanList.add(chessman); return defaultChessman; }); // 如果找到了相同位置的棋子,这里就会返回false;否则返回true return cm == defaultChessman; } else { //如果为空直接添加 chessmanList.add(chessman); return true; } }棋盘校验规则相较于棋子估值和ai的实现,对棋子胜利、和棋的校验会比较简单,从简到难,让我们先完成对游戏规则的定义:胜利判断bool checkResult(Chessman newChessman) { int currentX = newChessman.position.dx.toInt(); int currentY = newChessman.position.dy.toInt(); int count = 0; ///横 /// o o o o o /// o o o o o /// x x x x x /// o o o o o /// o o o o o winResult.clear(); // 循环遍历当前行的前后四个位置(如果存在),检查是否有特定的棋子连成五子相连 //判断 currentX - 4 > 0 时,它的意思是判断左侧第 4 个位置是否在棋盘内。 //如果 currentX - 4 大于 0,则表示左侧第 4 个位置在棋盘内; //否则,即 currentX - 4 <= 0,表示左侧第 4 个位置已经超出了棋盘边界。 for (int i = (currentX - 4 > 0 ? currentX - 4 : 0); i <= (currentX + 4 < LINE_COUNT ? currentX + 4 : LINE_COUNT); i++) { // 计算当前位置的坐标 Offset position = Offset(i.toDouble(), currentY.toDouble()); // 检查当前位置是否存在胜利的棋子 if (existSpecificChessman(position, newChessman.owner)) { // 将该棋子添加到胜利结果列表中,并增加计数器 winResult.add(Chessman(position, newChessman.owner)); count++; } else { // 如果不存在特定的棋子,清空胜利结果列表,并将计数器重置为0 winResult.clear(); count = 0; } // 解析:如果计数器达到5,表示有五子相连,输出胜利者信息并返回true if (count >= 5) { print("胜利者产生: ${newChessman.owner == Player.white ? "白色" : "黑色"}"); //游戏胜利的提示弹窗 winDialog("胜利者产生: ${newChessman.owner == Player.white ? "白色" : "黑色"}"); return true; } } //竖、正斜、反斜的逻辑代码请查看源码,和横的校验差不多 ... winResult.clear(); return false; } // 检查给定位置是否存在特定的棋子,并且这个棋子的所有者是否与指定玩家相同 bool existSpecificChessman(Offset position, Player player) { //定义一个不可能生成到棋盘上的棋子 Chessman defaultChessman = Chessman(Offset(-1, 0), Player.black); // 检查棋子列表是否非空 if (chessmanList.isNotEmpty) { // 在棋子列表中查找匹配给定位置的棋子 var cm = chessmanList.firstWhere((Chessman c) { return c.position.dx == position.dx && c.position.dy == position.dy; }, orElse: () { return defaultChessman; }); // 如果找到匹配的棋子,检查其所有者是否是指定的玩家 return cm != defaultChessman && cm.owner == player; } // 如果棋子列表为空或不存在棋子匹配给定位置,则返回false return false; }existSpecificChessman函数看起来和前面判断该坐标能否落子的canFallChessman函数差不多,这两个函数的主要区别在于作用和调用时机不同:existSpecificChessman校验的是当前位置是否存在特定棋子且所有者是否相符,而canFallChessman校验的是当前位置是否可以落子。和棋判断判断是否和棋其实非常简单,只要没有胜利,同时棋盘满了,就代表和棋了。//判断棋盘是否满了 bool isHaveAvailablePosition() { return chessmanList.length <= 255; }到这里为止呢已经完成了五子棋的基本玩法,你可以邀请你的朋友和你一起对战了棋子估值对每颗棋子进行打分,是完成一切算法的基础条件,如果没有分数,那么算法也就无法生效。估值算法也是本文的核心,个人觉得估价函数比MinMax算法和Alpha-Beta剪枝算法这两个算法的难度大多了,本文的算法部分主要参考了这几篇文章:五子棋估值算法基于博弈树的五子棋 AI 算法及其 C++ 实现前提条件:本文的规则只涉及 无禁手的五子棋大部分的棋类游戏,先手都有一个优势。以五子棋为例,先达成五子连珠者胜,由于黑方先走了一步,五子棋几乎是先手必胜的局面。所以假设五子棋的胜负条件会变成:如果黑方达成五子连珠之后,白棋也可在一步之内达成五子连珠,判定平手。这样的话就公平了,但是也失去了对弈的一些乐趣和意义,因为白棋只要一直跟着黑棋下,最后一定会为平局。所以为了平衡先手优势,大部分棋类都有一个补偿规则。如五子棋的禁手以及三手交换五手两打。在此不作过多解释,有兴趣可以自行百度,本文的规则及算法对先手无任何限制。相较于象棋、围棋,五子棋的局面并不复杂,估值还算比较简单,我们简单的用一个整数表示当前局势,分数越大,则自己优势越大,分数越小,则对方优势越大,分数为0是表示双方局势相当。可以先把几种情况定义出来:其中的解释中,x代表白棋,o代表黑棋,我们从黑棋的角度去评分static const int WIN = 10000; //低级死二 xoox static const int DEEP_DEATH2 = 2; //死二 xoo static const int LOWER_DEATH2 = 4; //低级死三 xooox static const int DEEP_DEATH3 = 3; //死三 xooo static const int LOWER_DEATH3 = 6; //低级死四 xoooox static const int DEEP_DEATH4 = 4; //死四 xoooo static const int LOWER_DEATH4 = 32; //活二 oo static const int ALIVE2 = 10; //跳活二 o o static const int JUMP_ALIVE2 = 2; //活三 ooo static const int ALIVE3 = 100; //跳活三 oo o static const int JUMP_ALIVE3 = 10; //活四 oooo static const int ALIVE4 = 5000; //跳活四 (1跳3或者3跳1或2跳2) o ooo || ooo o || oo oo static const int JUMP_ALIVE4 = 90;在实现估值算法前,我们还需要实现一个泛型类BufferMap,实现一个缓冲区的功能,BufferMap的用处在于记录和管理最近的几个棋盘状态。借助它可以用于实现游戏的一些功能,例如:悔棋功能:如果玩家想要悔棋,可以通过BufferMap中的历史记录回退到之前的棋盘状态,从而实现悔棋操作。撤销操作:当玩家进行某些操作后,发现操作结果不符合预期,可以利用BufferMap中的历史记录撤销该操作,恢复到之前的棋盘状态。历史记录展示:通过BufferMap中保存的棋盘状态,可以展示游戏的历史记录,供玩家回顾以及分析棋局发展。AI训练:对于AI算法的训练过程中,可以使用BufferMap来保存训练数据中的棋盘状态,以便进行样本回放、经验重放等技术。class BufferMap<V> { //设置缓冲区为3 num maxCount = 3; final Map<num, V> buffer = {}; BufferMap(); BufferMap.maxCount(this.maxCount); // 添加元素(key存的是每个棋子的分数,value是每个棋子的offset) void put(num key, V value) { buffer.update(key, (V val) { return value; }, //当缓冲区中不存在指定键时,会执行该回调函数来添加新的键值对。 ifAbsent: () { return value; }); _checkSize(); } // 批量添加元素 void putAll(BufferMap<V> map) { for (var entry in map.buffer.entries) { buffer[entry.key] = entry.value; } } // 检查并缩减缓冲区大小 void _checkSize() { //将缓冲区的所有键转换成列表,并赋值给变量 list,按照从大到小排列 var list = buffer.keys.toList() ..sort((num a, num b) { return b.compareTo(a); }); while (buffer.length > maxCount) { buffer.remove(list.last); } } // 将缓冲区转为Map Map<num, V> toMap() { return Map<num, V>.from(buffer); } // 获取所有元素的值 Iterable<V> values() { return buffer.values; } // 获取缓存元素个数 int size() { return buffer.length; } // 转为字符串表示 @override String toString() { StringBuffer sb = StringBuffer(); sb.write("{"); var keys = buffer.keys.toList() ..sort((num a, num b) { return b.compareTo(a); }); for (var i in keys) { sb.write("[$i , ${buffer[i]}] ,"); } return "${sb.toString().substring(0, sb.toString().length - 2)}}"; } // 获取第一个元素的值 V? get first => buffer[buffer.keys.toList() ..sort((num a, num b) { return b.compareTo(a); }) ..first]; // 获取键的最小值 num minKey() { if (buffer.isEmpty) { return double.negativeInfinity; } var list = buffer.keys.toList() ..sort((num a, num b) { return b.compareTo(a); }); return list.isNotEmpty ? list.last : double.negativeInfinity; } // 获取键值最小的元素 MapEntry<num, V>? min() { if (buffer.isEmpty) { return null; } var list = buffer.keys.toList() ..sort((num a, num b) { return b.compareTo(a); }); return list.isNotEmpty ? MapEntry(list.last, buffer[list.last]!) : null; } // 获取所有键的列表 List<num> get keySet { if (buffer.isEmpty) return []; var sortedKeys = buffer.keys.toList() ..sort((num a, num b) { return (b - a).toInt(); }); return sortedKeys; } // 通过键访问元素的值 V? operator [](Object? key) { return buffer[key]; } // 获取键的最大值 // 最优位置得分 num maxKey() { if (buffer.isEmpty) { return double.negativeInfinity; } var list = buffer.keys.toList() ..sort((num a, num b) { return b.compareTo(a); }); return list.isNotEmpty ? list.first : 0; } // 获取键值最大的元素 // MapEntry 提供了 key 和 value 两个只读属性来获取键和值,分别返回对应键值对的键和值。在 Map 中使用迭代器遍历时,每个元素都是 MapEntry 类型的实例。 MapEntry<num, V>? max() { if (buffer.isEmpty) { return null; } var list = buffer.keys.toList() ..sort((num a, num b) { return b.compareTo(a); }); return list.isNotEmpty ? MapEntry(list.first, buffer[list.first]!) : null; } }判断是那种棋局情况需要对活二、跳活二、活三...这些不同的棋局状态定义校验规则,规则太多,文章中只看活二的校验规则,其余请查看源码。bool isAlive2(List<Offset> list) { assert(list.length == 2); //把两颗棋子传入 Offset offset1 = nextChessman(list[1], list[0]); Offset offset2 = nextChessman(list[0], list[1]); return isEffectivePosition(offset1) && isEffectivePosition(offset2) && isBlankPosition(offset1) && isBlankPosition(offset2); } //输入的first和second返回下一个棋子的位置偏移量。 Offset nextChessman(Offset first, Offset second) { //检查first和second的dy值是否相等。 //如果相等,表示棋子在水平方向上移动。那么下一个棋子的位置偏移量将在水平方向上向右或向左移动一格,取决于first的dx是否大于second的dx。 //如果first.dx > second.dx,则向左移动一格,即second.dx - 1;否则,向右移动一格,即second.dx + 1。纵坐标保持不变,即为first.dy if (first.dy == second.dy) { return Offset( first.dx > second.dx ? second.dx - 1 : second.dx + 1, first.dy); } //如果first.dx和second.dx相等,表示棋子在垂直方向上移动。那么下一个棋子的位置偏移量将在垂直方向上向上或向下移动一格,取决于first的dy是否大于second的dy。如果first.dy > second.dy,则向上移动一格,即second.dy - 1;否则,向下移动一格,即second.dy + 1。横坐标保持不变,即为first.dx。 //如果以上两种情况都不满足,那么表示棋子在斜对角线方向上移动。根据first.dx和second.dx的大小关系,以及first.dy和second.dy的大小关系,决定下一个棋子的位置偏移量。 else if (first.dx == second.dx) { return Offset( first.dx, first.dy > second.dy ? second.dy - 1 : second.dy + 1); } else if (first.dx > second.dx) { if (first.dy > second.dy) { return Offset(second.dx - 1, second.dy - 1); } else { return Offset(second.dx - 1, second.dy + 1); } } else { if (first.dy > second.dy) { return Offset(second.dx + 1, second.dy - 1); } else { return Offset(second.dx + 1, second.dy + 1); } } } //判断该位置是否有效。 bool isEffectivePosition(Offset offset) { return offset.dx >= 0 && offset.dx <= LINE_COUNT && offset.dy >= 0 && offset.dy <= LINE_COUNT; } //isBlankPosition是用于判断某个位置上是否没有棋子,写法逻辑和用户交互能否落子差不多 bool isBlankPosition(Offset position) { if (chessmanList.isNotEmpty) { Chessman defaultChessman = Chessman(Offset(-1, 0), Player.black); var cm = chessmanList.firstWhere((Chessman c) { return c.position.dx == position.dx && c.position.dy == position.dy; }, orElse: () { return defaultChessman; }); return cm != defaultChessman; } return true; }对每一种情况进行估分这里只展示了两颗棋子的情况。//将给定的数限制在最大值为2的范围内 int limitMax(int num) { return num >= 2 ? 2 : num; } //对每种棋局加分 int scoring(Offset first, List<Offset> myChessman, Player player, {required String printMsg, bool isCanPrintMsg = false}) { if (myChessman.length >= 5) { return WIN; } int score = 0; switch (myChessman.length) { case 1: break; case 2: if (isAlive2(myChessman)) { score += ALIVE2; score += limitMax(getJumpAlive3Count(myChessman, player)) * JUMP_ALIVE3; score += limitMax(getJumpAlive4Count(myChessman, player)) * JUMP_ALIVE4; if (isCanPrintMsg) { print("$printMsg 活2成立, 得分+$ALIVE2"); } } else if (isLowerDeath2(myChessman)) { score += LOWER_DEATH2; if (isCanPrintMsg) { print("$printMsg 低级死2成立 ,得分+$LOWER_DEATH2"); } } else { score += DEEP_DEATH2; if (isCanPrintMsg) { print("$printMsg 死2成立 ,得分+$DEEP_DEATH2"); } } break; case 3: ... case 4: ... case 5: default: score += WIN; } return score; }对单颗棋子估分在棋盘中某一块范围内只有一颗棋子时,就都不能满足上方的几种棋局,那我们还需要对单颗棋子进行一个打分。///位置得分(越靠近中心得分越高) int positionScore(Offset offset) { //这个值是通过对(offset.dx - 7.5)^2 + (offset.dy - 7.5)^2进行运算得到的。 //其中,^表示乘方操作,即取平方,可以把棋盘上每颗棋子的位置想成一个圆锥,越靠近中心位置越高 //参考点被设定为(7.5, 7.5),棋盘的中心 double z = -(pow(offset.dx - 7.5, 2) + pow(offset.dy - 7.5, 2)) + 112.5; z /= 10; return z.toInt(); } ///孤子价值 int scoringAloneChessman(Offset offset) { int score = 0; List<Offset> list = [ Offset(offset.dx - 1, offset.dy), Offset(offset.dx + 1, offset.dy), Offset(offset.dx, offset.dy + 1), Offset(offset.dx, offset.dy - 1), Offset(offset.dx - 1, offset.dy - 1), Offset(offset.dx - 1, offset.dy + 1), Offset(offset.dx + 1, offset.dy - 1), Offset(offset.dx + 1, offset.dy + 1), ]; for (offset in list) { if (offset.dx > 0 && offset.dy > 0 && isBlankPosition(offset)) { score++; } } return score + positionScore(offset); }计算某一颗棋子对于玩家的评分只分析横向上的棋子,其他方向的代码请查看源码。///计算某个棋子对于 ownerPlayer 的分值 int chessmanGrade(Offset chessmanPosition, {required Player ownerPlayer, bool isCanPrintMsg = false}) { int score = 0; List<Offset> myChenssman = []; Offset offset; Offset first = chessmanPosition; Player player = ownerPlayer; player ??= computerPlayer; ///横向 //横向(左) offset = Offset(first.dx - 1, first.dy); myChenssman ..clear() ..add(first); while (existSpecificChessman(offset, player)) { myChenssman.add(offset); offset = Offset(offset.dx - 1, offset.dy); } //横向(右) offset = Offset(first.dx + 1, first.dy); while (existSpecificChessman(offset, player)) { myChenssman.add(offset); offset = Offset(offset.dx + 1, offset.dy); } myChenssman.sort((a, b) { return (a.dx - b.dx).toInt(); }); score += scoring(first, myChenssman, player, printMsg: "横向", isCanPrintMsg: isCanPrintMsg); ... int ss = score + scoringAloneChessman(first); if (isCanPrintMsg) { print("该子分值为: $ss ,其中单子得分:${scoringAloneChessman(first)}, 组合得分:$score"); } int jumpAlive4Count = getJumpAlive4Count([first], player); int jumpAlive3Count = getJumpAlive3Count([first], player); int jumpAlive2Count = getJumpAlive2Count([first], player); score += limitMax(jumpAlive4Count) * JUMP_ALIVE4 + limitMax(jumpAlive3Count) * JUMP_ALIVE3 + limitMax(jumpAlive2Count) * JUMP_ALIVE2; return score + scoringAloneChessman(first); }计算我方下一步较好的位置BufferMap<Offset> ourBetterPosition({maxCount = 5}) { Offset offset = Offset.zero; BufferMap<Offset> ourMap = BufferMap.maxCount(maxCount); for (int i = 0; i <= LINE_COUNT; i++) { for (int j = 0; j <= LINE_COUNT; j++) { offset = Offset(i.toDouble(), j.toDouble()); if (isBlankPosition(offset)) { int score = chessmanGrade(offset, ownerPlayer: Player.black); if (ourMap.minKey() < score) { ourMap.put(score, Offset(offset.dx, offset.dy)); } } } } return ourMap; }计算敌方下一步较好的位置BufferMap<Offset> enemyBetterPosition({maxCount = 5}) { Offset offset = Offset.zero; BufferMap<Offset> enemyMap = BufferMap.maxCount(5); print("查找敌方最优落子位置"); int count = 0; for (int i = 0; i <= LINE_COUNT; i++) { for (int j = 0; j <= LINE_COUNT; j++) { offset = Offset(i.toDouble(), j.toDouble()); if (isBlankPosition(offset)) { DateTime start = DateTime.now(); int score = chessmanGrade(offset, ownerPlayer: computerPlayer == Player.black ? Player.white : Player.black); DateTime end = DateTime.now(); count++; int time = end.millisecondsSinceEpoch - start.millisecondsSinceEpoch; if (time > 5) { print("查找敌方最优落子位置耗时:$time"); } if (enemyMap.minKey() < score) { enemyMap.put(score, Offset(offset.dx, offset.dy)); } } } } print("查找敌方最优落子位置次数:$count"); return enemyMap; }基础版本AIFuture<Offset> nextByAI({bool isPrintMsg = false}) async { //如果评分出现ALIVE4的级别,直接下 Offset pos = needDefenses(); if (pos != const Offset(-1, 0)) { return pos; } // 取我方,敌方 各5个最优点位置, // 防中带攻: 如果判断应该防守,则在敌方5个最优位置中找出我方优势最大的点落子 // 攻中带防: 如果判断应该进攻,则在己方5个最优位置中找出敌方优势最大的点落子 BufferMap<Offset> ourPositions = ourBetterPosition(); BufferMap<Offset> enemyPositions = enemyBetterPosition(); Offset position = bestPosition(ourPositions, enemyPositions); return position; } Offset needDefenses() { BufferMap<Offset> enemy = enemyBetterPosition(); late Offset defensesPosition; for (num key in enemy.keySet) { print("key:${key}"); if (key >= ALIVE4) { defensesPosition = enemy[key]!; break; } else { defensesPosition = const Offset(-1, 0); } } return defensesPosition; } //基础AI,没有涉及算法 //遍历当前棋盘上的空位置,然后逐个计算该空位的得分(位置分+组合分),然后取分数最高的点落子 Offset bestPosition( BufferMap<Offset> ourPositions, BufferMap<Offset> enemyPositions) { late Offset position; double maxScore = 0; ///当对手的最优位置得分 / 我方最优位置得分 > 1.5 防守,反之进攻 if (enemyPositions.maxKey() / ourPositions.maxKey() > 1.5) { for (num key in enemyPositions.keySet) { int attackScore = chessmanGrade(enemyPositions[key]!, ownerPlayer: computerPlayer); double score = key * 1.0 + attackScore * 0.8; if (score >= maxScore) { maxScore = score; position = enemyPositions[key]!; } } } else { for (num key in ourPositions.keySet) { int defenseScore = chessmanGrade(ourPositions[key]!, ownerPlayer: computerPlayer); double score = key * 1.0 + defenseScore * 0.8; if (score >= maxScore) { maxScore = score; position = ourPositions[key]!; } } } return position; }这个时候,一个基础的五子棋AI就实现啦,它也能和五子棋入门的选手碰一碰了!(玩了3把,稍微没注意就输了一把给它...)基于Max-Min算法本文算法内容,参考多篇与Max-Min算法相关文章:井字游戏/一字棋——Max-Min智能算法AI MinMax算法计算机博弈 基本算法 极大极小算法在基础版本的AI中,我们已经取得了下一步较好的maxCount个位置,有每个位置有着对应的分数,那么我们就可以把这些位置都落子一次,这个时候我们需要给每一种结果一个分数,就是下图中的Utility(下图是井字棋游戏,整体逻辑差不多)。这个分数是站在Max的角度评估的,比如上图中我赢了就是+1,输了是-1,平局时0。所以,我希望最大化这个分数,而我的对手希望最小化这个分数。(MaxMin算法在有限深度的范围内进行搜索,假定博弈双方都是最精明的,也就是每次都会选择可能获胜的最大值。那么对于我方来说,对方每次都会选取使我方获胜的最小值MIN;我方会选择使我方获胜的最大值MAX。)大部分游戏是不太可能把所有结果都列出来的,因为计算量会过于庞大,所以我们可能只能往前推7,8步(根据算力),所以这个时候分数就不只-1,0,1这么简单了。那么我们如何如何确定最后的落子地点呢?就是模拟棋盘,往后模拟几步,生成这颗博弈树,再向上反推,找到双方最优的落子地点。具体的算法细节可以看下上面参考的几篇文章,在看这个算法之前需要了解基础的广度优先搜索(BFS),深度优先搜索(DFS)。回到我们的编码部分在开始具体的算法编写前,我们还需要一些前置的参数:enum ChildType { /// 标记当前节点为对手节点,会选择使我方得分最小的走势 MIN, /// 标记当前节点为我方节点,会选择使我方得分最大的走势 MAX } class ChessNode{ /// 当前节点的棋子 Chessman current; /// 当前节点的父节点 ChessNode parentNode; /// 当前节点的所有子节点 List<ChessNode> childrenNode = []; /// 当前节点的值 num value = double.nan; /// 当前节点的类型(我方/敌方) ChildType type; /// 当前节点值的上限 num maxValue; /// 当前节点值的下限 num minValue; /// 当前节点的层深度 int depth = 0; /// 用于根节点记录选择的根下子节点 Chessman checked; } 使用算法相较于前面的基础版本AI就是多了模拟棋盘的步骤:生成临时棋局/// 生成临时棋局 List<Chessman> createTempChessmanList(ChessNode node) { //growable是一个可选参数,用于指定是否允许在列表中添加或删除元素。 //当growable为false时,列表的长度是固定的,并且不能添加或删除元素;当growable为true时,列表的长度是可变的,可以随时添加或删除元素。 List<Chessman> temp = List.from(chessmanList, growable: true); temp.add(node.current!); ChessNode? current = node.parentNode; while (current != null && current.current != null) { temp.add(current.current!); current = current.parentNode; } return temp; }生成博弈树子节点/// 生成博弈树子节点 void createChildren(ChessNode parent) { if (parent == null) { return null; } // 判断是否达到最大深度,如果是则计算棋局估值并返回 if (parent.depth > maxDepth) { List<Chessman> list = createTempChessmanList(parent); var start = DateTime.now(); parent.value = statusScore(our, list); var value = DateTime.now(); return; } // 确定当前玩家和子节点类型 Player currentPlayer = parent.current!.owner == Player.black ? Player.white : Player.black; ChildType type = parent.type == ChildType.MAX ? ChildType.MIN : ChildType.MAX; // 创建临时棋子列表 var list = createTempChessmanList(parent); // 查找最优落子位置 var start = DateTime.now(); BufferChessmanList enemyPosList = enemyBestPosition(list, maxCount: 5); var value = DateTime.now(); // 将最优落子位置放入列表中 OffsetList offsetList = OffsetList()..addAll(enemyPosList.toList()); List<Offset> result = offsetList.toList(); // 遍历最优落子位置,生成子节点 for (Offset position in result) { Chessman chessman = Chessman(position, currentPlayer); ChessNode node = ChessNode() ..parentNode = parent ..current = chessman ..type = type ..depth = parent.depth + 1 ..maxValue = parent.maxValue ..minValue = parent.minValue; parent.childrenNode.add(node); // 递归调用 createChildren 方法生成子节点的子节点,直到达到最大深度或无法再生成子节点为止。 createChildren(node); } }生成五子棋博弈树//生成五子棋博弈树 ChessNode createGameTree() { //创建根节点 root,设置其属性值:深度为0,估值为NaN,节点类型为 ChildType.MAX,最小值为负无穷,最大值为正无穷。 ChessNode root = ChessNode() ..depth = 0 ..value = double.nan ..type = ChildType.MAX ..minValue = double.negativeInfinity ..maxValue = double.infinity; //确定当前玩家 currentPlayer //如果棋子列表 chessmanList 为空,则当前玩家为黑色 //否则,根据棋子列表中最后一个棋子的颜色设置当前玩家为另一个颜色。 Player currentPlayer; if (chessmanList.isEmpty) { currentPlayer = Player.black; } else { currentPlayer = chessmanList.last.owner == Player.black ? Player.white : Player.black; } //查找敌方最优落子位置,并将结果存储在 enemyPosList 变量中。 //然后,将 enemyPosList 转换为 OffsetList 对象 //再将其转换为普通列表类型 List<Offset> 对象。这些位置将用于创建第一层子节点。 BufferChessmanList enemyPosList = enemyBestPosition(chessmanList, maxCount: 5); OffsetList list = OffsetList()..addAll(enemyPosList.toList()); List<Offset> result = list.toList(); int index = 0; //通过遍历 result 列表,为每个位置 position 创建一个新的棋子 chessman 和一个新的子节点 node //然后将子节点 node 添加到根节点的子节点列表 root.childrenNode 中 for (Offset position in result) { Chessman chessman = Chessman(position, currentPlayer); ChessNode node = ChessNode() ..parentNode = root ..depth = root.depth + 1 ..maxValue = root.maxValue ..minValue = root.minValue ..type = ChildType.MIN ..current = chessman; root.childrenNode.add(node); var start = DateTime.now(); createChildren(node); var create = DateTime.now(); print( '创建第一层第$index个节点耗时:${create.millisecondsSinceEpoch - start.millisecondsSinceEpoch}'); index++; } return root; }Max-Min算法实现num maxMinSearch(ChessNode root) { if (root.childrenNode.isEmpty) { return root.value; // 返回叶子节点的估值 } List<ChessNode> children = root.childrenNode; if (root.type == ChildType.MIN) { // 如果是对手执行操作 for (ChessNode node in children) { if (maxMinSearch(node) < root.maxValue) { // 判断子节点的估值是否小于当前节点的最大值 root.maxValue = node.value; // 更新当前节点的最大值 root.value = node.value; // 更新当前节点的估值 root.checked = node.current!; // 更新当前节点的选择步骤 } else { continue; // 否则继续遍历下一个子节点 } } } else { // 如果是自己执行操作 for (ChessNode node in children) { if (maxMinSearch(node) > root.minValue) { // 判断子节点的估值是否大于当前节点的最小值 root.minValue = node.value; // 更新当前节点的最小值 root.value = node.value; // 更新当前节点的估值 root.checked = node.current!; // 更新当前节点的选择步骤 } else { continue; // 否则继续遍历下一个子节点 } } } return root.value; // 返回当前节点的估值 }基于alpha-beta剪枝算法如果在比赛中,假设使用极小极大的算法,计算机能往前评估7步,加上剪枝算法,计算机就能往前评估14步!num alphaBetaSearch(ChessNode current) { count++; // 搜索次数累加 if (current.childrenNode.isEmpty) { // 如果当前节点没有子节点,即为叶子节点 return current.value; // 返回该节点的值 } if (current.parentNode != null && !current.parentNode!.childrenNode.contains(current)) { ChessNode parent = current.parentNode!; // 如果父节点存在且父节点的子节点不包含当前节点,说明该枝已经被剪掉,返回父节点的最大/最小值 return parent.type == ChildType.MAX ? parent.minValue : parent.maxValue; } List<ChessNode> children = current.childrenNode; // 获取当前节点的子节点 if (current.type == ChildType.MIN) { // 当前节点为MIN节点 num parentMin = current.parentNode?.minValue ?? double.negativeInfinity; // 获取父节点的最小值,若不存在父节点则设置为负无穷大 int index = 0; // 索引计数器 for (ChessNode node in children) { index++; // 索引递增 num newCurrentMax = min(current.maxValue, alphaBetaSearch(node)); // 计算当前子节点的最大值 if (newCurrentMax <= parentMin) { // 如果当前子节点的最大值小于等于父节点的最小值,则说明该枝可以被完全剪掉 current.childrenNode = current.childrenNode.sublist(0, index); // 将当前节点的子节点列表截断至当前索引位置 return parentMin; // 返回父节点的最小值 } if (newCurrentMax < current.maxValue) { // 如果当前子节点的最大值小于当前节点的最大值,则更新当前节点的最大值、值和经过路径的位置信息 current.maxValue = newCurrentMax; current.value = node.value; current.checked = node.current!; } } if (current.maxValue > parentMin) { // 如果当前节点的最大值大于父节点的最小值,则更新父节点的最小值、值和经过路径的位置信息 current.parentNode?.minValue = current.maxValue; current.parentNode?.value = current.value; current.parentNode?.checked = current.current!; } return current.maxValue; // 返回当前节点的最大值作为该节点在搜索树中的价值 } else { // 当前节点为MAX节点 num parentMax = current.parentNode?.maxValue ?? double.infinity; // 获取父节点的最大值,若不存在父节点则设置为正无穷大 int index = 0; // 索引计数器 for (ChessNode node in children) { index++; // 索引递增 num newCurrentMin = max(current.minValue, alphaBetaSearch(node)); // 计算当前子节点的最小值 if (parentMax < newCurrentMin) { // 如果父节点的最大值小于当前子节点的最小值,则说明该枝可以被完全剪掉 current.childrenNode = current.childrenNode.sublist(0, index); // 将当前节点的子节点列表截断至当前索引位置 return parentMax; // 返回父节点的最大值 } if (newCurrentMin > current.minValue) { // 如果当前子节点的最小值大于当前节点的最小值,则更新当前节点的最小值、值和经过路径的位置信息 current.minValue = newCurrentMin; current.value = node.value; current.checked = node.current!; } } if (current.minValue < parentMax) { // 如果当前节点的最小值小于父节点的最大值,则更新父节点的最大值、值和经过路径的位置信息 current.parentNode?.maxValue = current.minValue; current.parentNode?.value = current.value; current.parentNode?.checked = current.current!; } return current.minValue; // 返回当前节点的最小值作为该节点在搜索树中的价值 } }Max-Min和剪枝算法曾在IBM开发的国际象棋超级电脑,深蓝(Deep Blue)中被应用,并且两次打败当时的世界国际象棋冠军。文章到这里,五子棋的AI版本就完成了!

0
0
0
浏览量1994
chole

Flutter小技巧之Flutter 3 下的 ThemeExtensions 和 Materi13

本篇分享一个简单轻松的内容: ThemeExtensions  和 Material3 ,它们都是 Flutter 3.0 中的重要组成部分,相信后面的小知识你可能还没了解过~。ThemeExtensions相信大家都用过 Flutter 里的 Theme ,在 Flutter 里可以通过修改全局的   ThemeData  就来实现一些样式上的调整,比如 :全局去除 InkWell 和 TextButton 的点击效果。theme: ThemeData( primarySwatch: Colors.blue, // 去掉 InkWell 的点击水波纹效果 splashFactory: NoSplash.splashFactory, // 去除 InkWell 点击的 highlight highlightColor: Colors.transparent, textButtonTheme: TextButtonThemeData( // 去掉 TextButton 的水波纹效果 style: ButtonStyle(splashFactory: NoSplash.splashFactory), ), ),当然,开发者也可以通过  Theme.of(context) 去读取 ThemeData 的一些全局样式,从而让自己的控件配置更加灵活,但是如果 ThemeData 里没有符合你需求的参数,或者你希望这个参数只被特定控件是用,那该怎么办 ?Flutter 3 给我们提供了一个解决方案: ThemeExtensions 。开发者可以通过继承 ThemeExtension  并 override 对应的  copyWith 和 lerp  方法来自定义需要拓展的  ThemeData 参数,比如这样:@immutable class StatusColors extends ThemeExtension<StatusColors> { static const light = StatusColors(open: Colors.green, closed: Colors.red); static const dark = StatusColors(open: Colors.white, closed: Colors.brown); const StatusColors({required this.open, required this.closed}); final Color? open; final Color? closed; @override StatusColors copyWith({ Color? success, Color? info, }) { return StatusColors( open: success ?? this.open, closed: info ?? this.closed, ); } @override StatusColors lerp(ThemeExtension<StatusColors>? other, double t) { if (other is! StatusColors) { return this; } return StatusColors( open: Color.lerp(open, other.open, t), closed: Color.lerp(closed, other.closed, t), ); } @override String toString() => 'StatusColors(' 'open: $open, closed: $closed' ')'; }之后就可以将上面的  StatusColors 配置到 Theme 的  extensions 上,然后通过 Theme.of(context).extension<StatusColors>()  读取配置的参数。theme: ThemeData( primarySwatch: Colors.blue, extensions: <ThemeExtension<dynamic>>[ StatusColors.light, ], ), ····· @override Widget build(BuildContext context) { /// get status color from ThemeExtensions final statusColors = Theme.of(context).extension<StatusColors>(); return Scaffold( extendBody: true, body: Container( alignment: Alignment.center, child: new ElevatedButton( style: TextButton.styleFrom( backgroundColor: statusColors?.open, ), onPressed: () {}, child: new Text("Button")), ), ); }是不是很简单?通过 ThemeExtensions  ,第三方 package 在编写控件时,也可以提供对应的 ThemeExtensions 对象,实现更灵活的样式配置支持。Material3Material3 又叫 MaterialYou , 是谷歌在 Android 12 时提出的全新 UI 设计规范,现在 Flutter 3.0 里你可以通过  useMaterial3: true  打开配置支持。theme: ThemeData( primarySwatch: Colors.blue, ///打开 useMaterial3 样式 useMaterial3: true, ),当然,在你开启 Material3 之前,你需要对它有一定了解,因为它对 UI 风格的影响还是很大的,知己知彼才能不被背后捅刀。如下图所示,是在 primarySwatch: Colors.blue 的情况下,AppBar 、Card、TextButton、 ElevatedButton 的样式区别:可以看到圆角和默认的颜色都发生了变化,并且除了 UI 更加圆润之外,交互效果也发生了一些改变,比如:点击效果和 Dialog 的默认样式都发生了变化;Android 上列表滚动的默认 OverscrollIndicator 效果也发生了改变;目前在 Flutter 3 中受到 useMaterial3 影响的主要有以下这些 Widget ,可以看到主要影响的还是具有交互效果的 Widget 居多:[AlertDialog][AppBar][Card][Dialog][ElevatedButton][FloatingActionButton][Material][NavigationBar][NavigationRail][OutlinedButton][StretchingOverscrollIndicator][GlowingOverscrollIndicator][TextButton]那 Material3 和之前的 Material2 有什么区别呢?以 AppBar 举例,可以看到在 M2 和 M3 中背景颜色的获取方式就有所不同,在 M3 下没有了 Brightness.dark 的判断,那是说明 M3 不支持暗黑模式吗?回答这个问题之前,我们先看 _TokeDefaultsM3 有什么特别之处,从源码注释里可以看到   _TokeDefaultsM3  是通过脚本自动生成,并且目前版本号是 v0_92 ,所以 M3 和 M2 最大的不同之一就是它的样式代码现在是自动生成。gen_defaults 下就可以看到,基本上涉及 M3 的默认样式,都是通过 data 下的数据利用模版自动生成,比如 Appbar 的 backgroundColor 指向的就是 surface 。而之所以 M3 的默认样式不再需要 Brightness.dark 的判断,是因为在 M3 使用的 ColorScheme 里已经做了判断。事实上现在 Flutter 3.0 里 colorScheme 才是主题颜色的核心,而 primaryColorBrightness 和 primarySwatch 等参数在未来将会被弃用,所以如果目前你还在使用 primarySwatch  ,在 ThemeData 内部会通过  ColorScheme.fromSwatch 方法转换为  ColorScheme 。ColorScheme.fromSwatch( primarySwatch: primarySwatch, primaryColorDark: primaryColorDark, accentColor: accentColor, cardColor: cardColor, backgroundColor: backgroundColor, errorColor: errorColor, brightness: effectiveBrightness, );另外你也可以通过  ColorScheme.fromSeed 或者 colorSchemeSeed 来直接配置  ThemeData 里的 ColorScheme ,那 ColorScheme 又是什么 ?theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Color(0xFF4285F4)), ///打开 useMaterial3 样式 useMaterial3: true, ),这里其实就涉及到一个很有趣的知识点:Material3 下的 HCT 颜色包: material-color-utilities 。在 Material3 下颜色其实不是完全按照 RGB 去计算,而是会经过  material-color-utilities  的转化,通过内部的 CorePalette 对象,RGB 会转化为 HCT 相关的值去计算显示。对于 HCT 其实是 Hue、Chroma、Tone 三个单词的缩写,可以解释为色相、色度和色调,通过谷歌开源的  material-color-utilities   插件就可以方便实现 HCT 颜色空间的接入,目前该 repo 已支持 Dart、Java 和 Typecript 等语言,另外 C/C++ 和 Object-C 也在即将支持。得益于 HCT ,例如我们前面的 ColorScheme.fromSeed(seedColor: Color(0xFF4285F4)),就可以通过一个 seedColor 直接生成一系列主题颜色,这就是 Material3 里可以拥有更丰富的主题色彩的原因。最后最后我们回顾一下,今天的小技巧有:通过 ThemeExtensions 拓展想要的自定义 ThemeData通过 useMaterial3 启用 Material3 ,并通过 ColorScheme 配置更丰富的 HCT 颜色好了,现在你可以去问你的设计师:你知道什么是 HCT 么?

0
0
0
浏览量2002