FlutterFlame游戏实践#16 | 生命游戏 - 编辑与交互


theme: cyanosis

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


Flutter\&Flame 游戏开发系列前言:

该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台原生级 渲染框架,兼具 全端 跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]\ 第二季:从休闲游戏实践,进阶 Flutter\&Flame 游戏开发。

两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。


上一章,我们完成了生命游戏最最重要逻辑规则,实现了如下简易版的生命游戏演绎界面。本章将继续优化项目

2.gif

本章目标:

  • 实现演绎的自动播放与暂停。
  • 实现演绎速率可配置。
  • 实现宫格生命的编辑功能。
  • 实现世界视口的移动与缩放。

本篇源码详见: 【tolygame/modules/lifegame/lib/02】


一、自动播放与暂停

效果如下所示:

  • 左侧菜单栏最上方的按钮,控制自动播放与暂停。
  • 右下角支持选择切换世界演化的速度倍率。
  • 右下角展示当前世界迭代的次数。

03.gif


1.需求数据分析

在上面的三个小需求中,有三个数据影响界面中的视图表现。分别是:

  • [1]:当前播放状态,影响左上角播放按钮的展示。
  • [2]:当前播放的速率状态,影响右下角速率选择器和游戏主界面的迭代速度。
  • [3]:世界迭代的次数,影响右下第几代的展示。

其中红色区域是需要随状态数据变化的视图内容;蓝色监听是事件触发的行为,引发数据的变化:

image.png

对于播放状态数据,这里将世界演化通过 EvolveStatus 表示,只有演化中停止演化 两种状态;演化的次数通过一个 int 值表示即可:

```dart /// 演化状态 enum EvolveStatus { evolving, stopped, }

/// 演化次数 int _generationCount = 1; ```


演化速率本应只是一个 double 数字。但这里想要控制支持的速率范围,并且统一计算速率对应的时间,所以将演化速率封装为一个 EvolveSpeed 类。其中:

  • 提供了 kSupports 列表作为支持的速率选择范围;
  • 私有化构造方法,不希望外界创建对象,来添加其他速率。
  • 世界演化的单位是 1000 ms/次,提供 time 方法统一换算速率对应的时间。

```dart class EvolveSpeed extends Equatable{ final double level;

static const kWorldTimeUnit = 1000;

const EvolveSpeed._(this.level);

static List kSupports = [ 0.5, 1.0,2.0,3.0, 5.0, 10.0, 20.0] .map((e) => EvolveSpeed._(e)) .toList();

static EvolveSpeed initial = const EvolveSpeed._(1);

double get time => kWorldTimeUnit/level;

@override List get props => [level]; } ```


2. 业务逻辑与通知更新

我们可以使用任何状态管理工具,来维护状态数据和触发界面更新。这里目前功能还比较简单,先通过 ChangeNotifier 来维护数据和通知更新。如下所示,定义 FrameEvolve 类来维护上述的三个状态数据,并在状态数据发生变化时,通知监听者:

```dart class FrameEvolve with ChangeNotifier {

/// 速度状态 EvolveSpeed _speed = EvolveSpeed.initial;

EvolveSpeed get speed => _speed;

set speed(EvolveSpeed value) { _speed = value; notifyListeners(); }

/// 演化次数状态 int _generationCount = 1;

int get generationCount => _generationCount;

set generationCount(int value) { _generationCount = value; notifyListeners(); }

/// 演化状态 EvolveStatus _status = EvolveStatus.stopped;

EvolveStatus get status => _status;

set status(EvolveStatus value) { _status = value; notifyListeners(); } ```


除了状态数据,FrameEvolve 还承担演化的职责,所以其中维护了上章中的 Frame 对象,也就是世界中细胞存活状态的数据。调用 evolve 方法进行一次演化,其中通过 上一次演化的时间戳差速率时间间隔 对比;确定是否需要演化:

```dart late Frame frame; XY size; int _timeRecord = 0;

FrameEvolve(this.size) { reset(); }

void reset() { frame = Frame(size); generationCount = 1; status = EvolveStatus.stopped; _timeRecord = 0; }

void evolve([ValueChanged? onEvolved]) { int cur = DateTime.now().millisecondsSinceEpoch; bool timeSkip = cur - _timeRecord < _speed.time; bool evolving = status == EvolveStatus.evolving; if (timeSkip && evolving) return; frame.evolve(); onEvolved?.call(frame); _generationCount++; notifyListeners(); _timeRecord = DateTime.now().millisecondsSinceEpoch; } ```


我们知道 Flame 游戏引擎在非暂停状态时,GameLoop 会让构件的 update 会持续触发。所以在主游戏类持有 FrameEvolve,并在 update 回调中不断触发 evolve 即可,由于 FrameEvolve#evolve 中已经进行过更新时间间隔的限制,所以并不会每个游戏帧都触发演化:

image.png


3. 视图层处理

到这里,游戏的状态数据变化已经就绪,接下来对接界面表现和事件的触发。再回到这张图上,左侧和底部的组件是 Flutter 层的视图,我们可以监听 LifeGame 中的 FrameEvolve 状态变化,来通知界面更新:

image.png

考虑到 FrameEvolve 是个比较大的可监听对象,我们可以通过 ValueNotifier 来将其拆成局部组件感兴趣的小状态。比如左上角的游戏控制按钮,只对 EvolveStatus 这个枚举状态感兴趣,速率改变、演化次数改变都与它的视图表现无关。
所以这里拆分出 PlayCtrlButton 组件,传入 ValueNotifier<EvolveStatus> 只感知 EvolveStatus 的状态变化。并通过 onAction 回调点击事件。这样,可以使用 ValueListenableBuilder 监听 status 的变化,在构建逻辑中就可以基于 EvolveStatus 数据,决定视图的表现。

```dart class PlayCtrlButton extends StatelessWidget { final ValueNotifier status; final ValueChanged onAction;

const PlayCtrlButton({super.key, required this.status, required this.onAction});

@override Widget build(BuildContext context) { ActionStyle style = const ActionStyle( backgroundColor: Colors.black, padding: EdgeInsets.all(2), borderRadius: BorderRadius.all(Radius.circular(4)), );

return ValueListenableBuilder(valueListenable: status,builder: (BuildContext context, EvolveStatus value, Widget? child) {Color? color;IconData icon;switch(value){case EvolveStatus.evolving:color = Colors.red;icon = TolyIcon.icon_pause;break;case EvolveStatus.stopped:icon = TolyIcon.icon_play;color = Colors.green;}return TolyAction(style: style,child: Icon(icon, size: 18,color: color,),onTap: () => onAction(ToolAction.play),);},
);

} } ```


最后一个问题就是 PlayCtrlButton 构造入参中的 ValueNotifier<EvolveStatus> 从哪里来?
FrameEvolve 相当于一个大广播,播报所有状态变化的事件;而 _statusNtf 相当于一个消息的 二道贩子。它的任务是:听着广播里的某一个数据的变化,当新值和旧值不同时,就把它通知给特定的人,也就是 PlayCtrlButton:

```dart class _LifeGameViewState extends State { final LifeGame game = LifeGame(); late ValueNotifier _statusNtf;

@override void initState() { statusNtf = ValueNotifier(game.frameEvolve.status); game.frameEvolve.addListener(onEvolveChange); super.initState(); }

void onEvolveChange() { EvolveStatus newStatus = game.frameEvolve.status; if (statusNtf.value != newStatus) { _statusNtf.value = newStatus; } }

@override void dispose() { super.dispose(); statusNtf.dispose(); game.frameEvolve.removeListener(onEvolveChange); } ```


最后,当监听到 ToolAction.play 的事件时,游戏主类触发 play 方法,停止或恢复游戏。比如 start 时,paused 为 false,这样 update 就会持续触发,从而带动世界的演化;

image.png

```dart void play() { if (frameEvolve.status == EvolveStatus.evolving) { stop(); } else { start(); } }

void start() { if (frameEvolve.status == EvolveStatus.evolving) {} paused = false; frameEvolve.status = EvolveStatus.evolving; }

void stop() { paused = true; frameEvolve.status = EvolveStatus.stopped; } ```

另外两个状态的视图层也是类似,这里就不赘述了。可以详见源码。其中速率的选择器,使用了 TolyUI 中的 TolyDropMenu 组件


二、可编辑宫格生命

下面来处理宫格中生命的编辑功能,如下所示,在侧栏菜单中有画笔橡皮擦 两个按钮.画笔模式下,按下或拖拽时,可以让空间中诞生细胞;橡皮擦模式相反,将存在的细胞杀死。绘制完满意的排布方式之后,就可以播放,或者逐代演化:

04.gif


1. 激活模式控制

可编辑宫格生命,就是说在宫格的点击拖拽事件中,根据落点坐标来 添加移除 格点对应的细胞。其中添加和移除,通过侧栏按钮的选中状态进行控制。如果每个按钮的激活状态,都通过一个数据来控制,会让逻辑变得非常复杂。
另外,考虑到有些按钮是互斥的,比如 画笔激活时,需要取消激活 移动橡皮擦。这里在 FrameEvolve 中维护一个 Map<ToolAction, bool> 的映射对象,来记录侧栏按钮的激活关系。通过 get 方法访问按钮是否激活:

```dart --->[FrameEvolve]---- final Map _selectedActionMap = {};

List get actions => _selectedActionMap.keys.toList();

bool get seeWorld => _selectedActionMap[ToolAction.see] ?? false; bool get paintMode => _selectedActionMap[ToolAction.paint] ?? false; bool get deleteMode => _selectedActionMap[ToolAction.eraser] ?? false; ```

然后,通过 handleAction 方法处理事件。_toggleAndRemove 处理激活时,取消指定激活项。

  • ToolAction.see 用于开启和关闭上帝视角。
  • ToolAction.paint 启用绘制模式。
  • ToolAction.eraser 启用删除模式。
  • ToolAction.move 启用移动模式。

其中 paint、eraser、move 是互斥的,一者激活时,其他两个取消激活:

```dart void handleAction(ToolAction action) { switch (action) { case ToolAction.see: _toggleAndRemove(action); break; case ToolAction.paint: _toggleAndRemove(action, [ToolAction.eraser, ToolAction.move]); break; case ToolAction.eraser: _toggleAndRemove(action, [ToolAction.paint, ToolAction.move]); break; case ToolAction.move: _toggleAndRemove(action, [ToolAction.eraser, ToolAction.paint]); break; default: } notifyListeners(); }

/// [action] 激活时,需要取消激活 [removeList] void (ToolAction action, [List ? removeList]) { bool select = selectedActionMap[action] ?? false; if (select) { _selectedActionMap.remove(action); } else { _selectedActionMap[action] = true; removeList?.forEach(selectedActionMap.remove); } } ```


2. 视图层和事件处理

视图层同理,监听 FrameEvolve 中状态数据的变化,通过 ValueNotifier<List<ToolAction>> 贩卖激活项列表信息,在 ActionToolbar 的构造函数传入。这样在构建按钮时,可以监听激活信息数据,设置 TolyAction#selected 的表现:

image.png

```dart class ActionToolbar extends StatelessWidget { final ValueChanged onAction; final ValueNotifier status; final ValueNotifier > actions;

...

/// 构建条目时 if(e==ToolAction.see || e==ToolAction.paint || e==ToolAction.eraser || e==ToolAction.move){ return ValueListenableBuilder( valueListenable: actions, builder: (context,value,__) { return TolyAction( selected: value.contains(e), style: style, child: Icon(e.icon, size: 18), onTap: () => onAction(e), ); }, ); } ```

按钮的点击事件触发 _onAction ,在 paintmoveeraser 时,通过 game 触发 FrameEvolve#handleAction 方法即可。

image.png


3.点击和拖拽事件处理

点击和拖拽事件发生在 Flame 中的网格中,它的处理方式和之前介绍的 《扫雷》 是类似的。主要是在交互时,更新网格的地图数据,也就是 Frame 中 spaces 映射数据,再重新渲染。

image.png

为了让业务逻辑尽可能和视图分离,这里使用 GridActionLogic 作为 mixin 处理交互逻辑。这样 SpaceManager 只要混入 GridActionLogic 即可拥有点击和拖拽的交互逻辑:

image.png

点击和拖拽都会触发 pressed 方法,通过事件中的 localPosition 可以得到相对于网格左上角的落点坐标。然后通过 trans 方法,将落点坐标转换为网格坐标即可。最后根据当前的模式和细胞存活状态,诞生或杀死对应宫格的细胞:

```dart mixin GridActionLogic on DragCallbacks, TapCallbacks, HasGameRef {

double get cellSize;

@override void onTapDown(TapDownEvent event) { pressed(event.localPosition); super.onTapDown(event); }

@override void onDragUpdate(DragUpdateEvent event) { pressed(event.localStartPosition); super.onDragUpdate(event); }

void pressed(Vector2 vector2) { XY position = trans(vector2); bool alive = game.frameEvolve.frame.spaces[position]==true; if(game.frameEvolve.paintMode){ if(!alive){ game.birth(position); } } if(game.frameEvolve.deleteMode){ if(alive){ game.died(position); } } }

XY trans(Vector2 vector2) { int x = vector2.x ~/ cellSize; int y = vector2.y ~/ cellSize; return (x, y); } } ```


三、游戏视口的缩放与偏移

如下所示,在移动模式下,可以通过鼠标滚轮进行缩放拖拽平移。Flame 中并没有鼠标的滚轮事件,而交互界面时 Flame 的世界,那该怎么办呢?

05.gif


1. Flame 世界本质上也是一个 Widget

GameWidget 展示 Flame 的游戏世界,在它的上层可以套一个 Flutter Widget,这样鼠标事件就可以在 Flutter 这边处理。这里封装了一个 TransformWrapper 的组件处理变换:

image.png

所以 Listener 组件,可以在 onPointerSignal 中监听鼠标的滚轮事件; onPointerMove 中监听触点的拖拽事件。在其中处理具体的变换逻辑即可。

```dart class TransformWrapper extends StatelessWidget { final Widget child; final LifeGame game;

const TransformWrapper({super.key, required this.child, required this.game});

@override Widget build(BuildContext context) { return ClipRect( child: Listener( onPointerSignal: _onPointerSignal, onPointerMove: _onPointerMove, child: child, ), ); }

void _onPointerSignal(PointerSignalEvent event) { if (!game.frameEvolve.moveMode) return; // TODO 缩放 }

void _onPointerMove(PointerMoveEvent event) { if (!game.frameEvolve.moveMode) return; // TODO 移动 } } ```


2. Flame 相机变换的应用

在本季第六篇 《打砖块 - 世界与相机》 中介绍过相机的变换,这里就是对相机变换的具体应用。由于后期需要非常多的网格,缩放和移动来观察生命游戏中世界的情况是非常必要的。

_onPointerSignal 方法用于处理鼠标滚轮的事件,其中会回调 PointerSignalEvent 事件,通过竖直方向的偏移量可以校验鼠标滚轮滚动的方向。根据方向改编相机的 zoom 值完成缩放:

dart void _onPointerSignal(PointerSignalEvent event) { if (!game.frameEvolve.moveMode) return; if (event is PointerScrollEvent) { bool larger = event.scrollDelta.dy < 0; double curZoom = game.camera.viewfinder.zoom; double newZoom = 0; if (larger) { newZoom = curZoom + 0.1; } else { newZoom = curZoom - 0.1; } if (newZoom < 0.01 || newZoom > 20) return; Viewfinder viewfinder = game.camera.viewfinder; viewfinder.zoom = newZoom; game.paused = false; } }


_onPointerSignal 方法用于处理鼠标拖拽事件,根据偏移量和当前缩放值,使用 moveBy 对相机进行偏移。注意一点,目前的游戏世界是出于暂停状态的,想要相机变化生效,需要 game.paused = false 来启动一帧:

dart void _onPointerMove(PointerMoveEvent event) { if (!game.frameEvolve.moveMode) return; double curZoom = game.camera.viewfinder.zoom; Offset delta = event.delta / curZoom; game.camera.moveBy(Vector2(-delta.dx, -delta.dy)); game.paused = false; }


到这里,我们的生命游戏已经万事俱备了。目前只是在 9*9 的网格中体验生命游戏。下一章将带来大量网格下,真正的生命游戏体验,敬请期待~

image.png

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/874905.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Jenkins卡在等待界面解决方法

一、问题 部署jenkins服务器出现Please wait while Jenkins is getting ready to work。 二、原因分析 jenkins里面文件指向国外的官网&#xff0c;因为防火墙的原因连不上。 三、解决方法 将配置文件里面的url换成国内镜像&#xff1a; &#xff08;1&#xff09;修改配…

LLM模型与实践之基于 MindSpore 实现 BERT 对话情绪识别

安装环境 # 该案例在 mindnlp 0.3.1 版本完成适配&#xff0c;如果发现案例跑不通&#xff0c;可以指定mindnlp版本&#xff0c;执行!pip install mindnlp0.3.1 !pip install mindnlp 模型简介 BERT是一种由Google于2018年发布的新型语言模型&#xff0c;它是基于Transforme…

css黑色二级下拉导航菜单

黑色二级下拉导航菜单https://www.bootstrapmb.com/item/14816 body { font-family: Arial, sans-serif; margin: 0; padding: 0; }nav { background-color: #000; /* 导航背景色为黑色 */ }.menu { list-style-type: none; margin: 0; padding: 0; overflow: hidden; }.menu l…

JavaScript(12)——内置对象

JavaScript内部提供的对象&#xff0c;包含各种属性和方法给开发者调用。 Math Math对象是JavaScript提供的一个“数学”对象 包含的方法有&#xff1a; random:生成0-1之间的随机数 ceil&#xff1a;向上取整 floor&#xff1a;向下取整 max&#xff1a;找最大数 min&#…

展馆导览系统架构解析,从需求分析到上线运维

在物质生活日益丰富的当下&#xff0c;人们对精神世界的追求愈发强烈&#xff0c;博物馆、展馆、纪念馆等场所成为人们丰富知识、滋养心灵的热门选择。与此同时&#xff0c;人们对展馆的导航体验也提出了更高要求&#xff0c;展馆导览系统作为一种基于室内外地图相结合的位置引…

Unity显示泰语且兼容泰语音标

前言&#xff1a;使用Unity开发的游戏需要支持泰语本地化&#xff0c;以及解决显示泰语时Unity的bug 目录 1、Text组件显示泰语2、TextMeshPro组件显示泰语 现在很多游戏都需要显示泰语&#xff0c;下面将介绍Unity如何显示泰语&#xff0c;&#xff08;仅介绍Unity字体方面的设…

npm 安装报错(已解决)+ 运行 “wue-cli-service”不是内部或外部命令,也不是可运行的程序(已解决)

首先先说一下我这个项目是3年前的一个项目了&#xff0c;中间也是经过了多个人的修改惨咋了布置多少个人的思想&#xff0c;这这道我手里直接npm都安装不上&#xff0c;在网上也查询了多种方法&#xff0c;终于是找到问题所在了 问题1&#xff1a; 先是npm i 报错在下面图片&…

Microsoft 365 Office BusinessPro LTSC 2024 for Mac( 微软Office办公套件)

Microsoft 365 Office BusinessPro LTSC 2024是一款专为商业用户设计的办公软件套件&#xff0c;它集成了Word、Excel、PowerPoint等核心应用&#xff0c;并特别包含了Microsoft Teams这一强大的协作工具。Teams将聊天、会议、文件共享、任务管理等功能整合到一个平台上&#x…

AI+HPC 部署优化面试范围分享

背景 最近几年生成式AI技术和自动驾驶技术发展发展很快&#xff0c;这些行业对于算法的运行效率有很高的要求&#xff0c;尤其一个模型在训练完成后运行到设备上&#xff0c;需要大量的工作&#xff0c;包括模型的剪枝、蒸馏、压缩、量化、算子优化、系统优化等。 对于传统的…

Go基础编程 - 12 -流程控制

流程控制 1. 条件语句1.1. if...else 语句1.2. switch 语句1.3. select 语句1.3.1. select 语句的通信表达式1.3.2. select 的基特性1.3.3. select 的实现原理1.3.4. 经典用法1.3.4.1 超时控制1.3.4.2 多任务并发控制1.3.4.3 监听多通道消息1.3.4.4 default 实现非堵塞读写 2. …

FPGA读写操作SRAM_CY7C1051DV33

手上有一块sram需要验证下功能是否正常&#xff0c;我门通过fpga来进行读写测试。 1.首先看下芯片手册&#xff0c;我们重点关注时序部分 总结下&#xff0c;就是读写时间不能小于10nS,也就是最高频率100M&#xff0c;所以我们程序设计按100M时钟速率进行设计。注意&#x…

构建稳固与安全的网络环境:从微软蓝屏事件看软件更新流程与应急响应

“微软蓝屏”事件暴露了网络安全哪些问题&#xff1f; 近日&#xff0c;由微软视窗系统软件更新引发的全球性“微软蓝屏”事件&#xff0c;不仅让科技领域为之震动&#xff0c;更是一次对全球IT基础设施韧性与安全性的深刻检验。这次事件源于美国电脑安全技术公司“众击”的一…

2024-07-23 Unity插件 Odin Inspector11 —— 使用 Odin 自定义编辑窗口

文章目录 1 OdinEditorWindow1.1 运作方式1.2 使用特性绘制 OdinEditorWindow1.3 在 OdinEditorWindow 中渲染对象 2 OdinMenuEditorWindow2.1 添加菜单导航栏2.2 添加导航栏示例 ​ Odin Window 可以完整地访问 Odin 绘图系统&#xff0c;不再需要操心 Window 的绘制 方式&am…

BGP选路之Local Preference

原理概述 当一台BGP路由器中存在多条去往同一目标网络的BGP路由时&#xff0c;BGP协议会对这些BGP路由的属性进行比较&#xff0c;以确定去往该目标网络的最优BGP路由。BGP首先比较的是路由信息的首选值&#xff08;PrefVal)&#xff0c;如果 PrefVal相同&#xff0c;就会比较本…

全方位了解智慧校园行政办公的新闻管理功能

在智慧校园的日常运营中&#xff0c;行政办公系统中的新闻公告功能犹如一座沟通的桥梁&#xff0c;连接着校园内外的每一个角落&#xff0c;传递着最新的资讯与动态。它不仅是智慧校园信息发布的平台&#xff0c;更是校园文化与精神风貌的展现窗口&#xff0c;对于增强师生的凝…

JavaWeb(4)JavaScript入门2—— JS的对象和JSON

一、JS的对象 1.声明语法1 通过new Object()直接创建对象 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><ti…

C#与C++交互开发系列(三):深入探讨P/Invoke基础知识

欢迎来到C#与C交互开发系列的第三篇。在这篇博客中&#xff0c;我们将深入探讨P/Invoke&#xff08;Platform Invocation Services&#xff09;的基础知识。P/Invoke是C#调用非托管代码的一种机制&#xff0c;能够让C#直接调用C编写的动态链接库&#xff08;DLL&#xff09;中的…

外六角半螺纹螺丝主要应用领域

外六角半螺纹螺丝&#xff0c;作为一种常见的紧固件&#xff0c;因其独特的设计和多样的功能而在多个工业领域中占据着重要的地位。这种螺丝的一端具有完整的螺纹&#xff0c;而另一端则可能没有螺纹或螺纹较短&#xff0c;这样的设计使其在某些应用场景中具有独特的优势。 应用…

docker文件挂载和宿主主机文件的关系

一、背景 在排查docker日志时发现在读取docker的文件时找不到文件&#xff0c;在宿主主机上可以查到对应的文件。这里就要理解docker文件目录和宿主主机上的文件的关系。 二、Docker文件系统和宿主系统 Docker文件和宿主文件之间的关系主要体现在Docker容器的运行环境中。Docke…

【目录】8051汇编与C语言系列教程

8051汇编与C语言系列教程 作者将狼才鲸创建日期2024-07-23 CSDN文章地址&#xff1a;【目录】8051汇编与C语言系列教程本Gitee仓库原始地址&#xff1a;才鲸嵌入式/8051_c51_单片机从汇编到C_从Boot到应用实践教程 一、本教程目录 序号教程名称简述教程链接1点亮LCD灯通过IO…