普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月8日掘金 前端

告别重复造轮子!业务组件多场景复用实战指南

作者 蓝瑟
2025年12月8日 18:22
复用一个组件,就像在组装复杂拼图——清晰边界、拆分功能、组合而不耦合,才是关键。这篇文章就带你用“配置驱动设计”、“复合组件模式”、“策略模式”、“渲染函数”的思维拆开再组建出易复用的组件。

构建异步任务队列:高效批量化获取淘宝关键词搜索结果的实践

2025年12月8日 17:30
在电商数据分析、竞品调研或商品监控等场景中,批量获取淘宝关键词搜索结果是高频需求。但传统同步请求方式存在效率低、易阻塞、容错性差等问题 —— 当关键词数量达到数百甚至数千个时,同步请求会因网络延迟

uniapp 下拉刷新终极方案

2025年12月8日 17:05

z-paging

【z-paging下拉刷新、上拉加载】高性能,全平台兼容。支持虚拟列表,分页全自动处理

非常好用, 必须点赞。

uniapp 官方的下拉刷新好像有好多问题,具体是什么忘记了,反正神坑无比。

z-paging 挺好用的, 不过好像会和局部的 scroll-view 组件冲突,现象就是下拉刷新会一跳一跳的, 不知道怎么解决, 不过问题不是很大, 将就用吧。

下拉刷新使用


<ZPaging
    ref="pagingRef"
    :fixed="false"
    refresher-only
    @onRefresh="onRefresh"
    refresher-default-text="下拉刷新"
    refresher-pulling-text="释放刷新"
    refresher-refreshing-text="刷新中..."
    refresher-complete-text="刷新完成"
    :refresher-title-style="{ fontSize: '14px' }"
>
    <view>
            <Total @onRefresh="onRefreshed"></Total>
            <City @onRefresh="onRefreshed"></City>
            <Brand @onRefresh="onRefreshed"></Brand>
    </view>
</ZPaging>

// import ZPaging from "@/uni_modules/z-paging/components/z-paging/z-paging.vue";

有一个问题在线求助, 就是在底部会出现大面积留白

image.png

ai 聊天组件

对话内容和输入框都可以自定义。非常方便。具体的官方有示例


<z-paging
      ref="pagingRef"
      :fixed="false"
      :refresher-enabled="false"
      :refresher-default-style="'none'"
      v-model="chatHistories"
      use-chat-record-mode
      :safe-area-inset-bottom="false"
      bottom-bg-color="#FBFAFA"
      :calcKeyboardHeight="calcKeyboardHeight"
    >
      <!-- for循环渲染聊天记录列表 -->
      <view
        v-for="(chatItem, index) in chatHistories"
        :key="chatItem.id"
        style="position: relative"
      >
        <!-- style="transform: scaleY(-1)"必须写,否则会导致列表倒置 -->
        <!-- 注意不要直接在chat-item组件标签上设置style,因为在微信小程序中是无效的,请包一层view -->
        <view style="transform: scaleY(-1)">
          <!-- 对话消息组件 -->
          <ChatItem :message="chatItem" @onSelectOption="handleSelectOption" />
        </view>
      </view>
      <!-- 底部聊天输入框 -->
      <template #bottom>
        <view class="chat-input-container">
          <Question
            :status="networkStatus"
            :question="userMsg"
            @onSend="(params: any) => doSend(params.question)"
            @onCancel="handleCancel"
          />
        </view>
      </template>
    </z-paging>

浅拷贝与深拷贝: 克隆一只哈基米

作者 若倾
2025年12月8日 16:49

拷贝: 克隆一只哈基米

顾名思义,在js拷贝操作的目的就是按照原来的模板,克隆出一模一样的新对象出来。

它最简单的理解就是克隆出一只一模一样的猫,我们创建了一个新的个体,只是它们各方面都一样而已。

并且拷贝这个意义,只针对引用类型的数据。

拷贝.png

浅拷贝

什么是浅拷贝

顾名思义:浅层的拷贝

旧版本的js提供的拷贝方法都比较敷衍,只能进行浅层的拷贝,在拷贝对象内部引用类型数据时,仅仅是将引用数据类型的地址复制来了一份。而不会对这个引用数据类型整体拷贝一份。

1765179721043_7DBD414F-E13C-4c4c-9BAF-8AC5B963F568.png

浅拷贝带来的问题

因为拷贝出的新对象,内部引用数据类型依赖相同,当对新对象内部引用类型数据进行修改时,会导致原对象的引用数据也受影响 例子:

let arr=[1,2,3,4,{age:18}]
let arr2=arr.slice(0) 
console.log(arr==arr2)

arr[4].age=10;
console.log(arr2[4].age) // 新对象受原对象影响,所以是一个浅拷贝

实战:手搓一个浅拷贝函数

function shallowCopy(obj) {
    let o = {};
    //for in 循环  遍历 obj的key值
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            //规避对象隐式原型上的属性
            o[key] = obj[key]
        }
    }
    return o;
}

深拷贝

什么是深拷贝

顾名思义:深层的拷贝

浅拷贝只是会被拷贝对象的浅层进行拷贝,对于其浅层引用数据类型,则不进行拷贝,仅仅是将引用类型数据的地址复制了一份。所以深拷贝就是顺着引用数据类型层层拷贝,直到所有引用类型数据都被拷贝完成。

未命名文件.png 由此就可以拷贝出一个不会于原来对象共用同一引用类型数据的对象

常见的深拷贝方法

  1. 利用structuredClone(obj)方法进行深拷贝

缺点:

  • 无法拷贝symbol类型
  • 无法拷贝函数
const obj = {
    name: '哈基米',
    age: 18,
    like: {
        n: {n:'哈气'},
        m: '哈呀裤'
    },
    x:null,
}
let newObj;
//利用structuredClone(obj)方法进行深拷贝
newObj=structuredClone(obj)
obj.like.m="篮球"
console.log(obj,newObj)
  1. 利用JSON格式转换深拷贝

将对象转为JSON字符串,再由JSON字符串转换为一个新的对象。

缺点:

  • 无法处理函数,正则表达式,map等等复杂类型的数据
  • 可拷贝数据类型太少
  • 原型链丢失:拷贝出的新对象,无法通过原型链调用父对象方法与属性
const str= JSON.stringify(obj) //将对象变为一个字符串

//Json.parse() 将字符串转回对象
newObj=JSON.parse(str) //也实现了深拷贝
obj.like.m="篮球"
console.log(obj,newObj)

实战:手搓一个深拷贝

限制:函数正则表达式等

function deepCopy(obj) {
    let O = {};
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            if (obj[key] instanceof Object && !obj[key] instanceof Function) {
                O[key] = deepCopy(obj[key]);
            }
            else {
                O[key] = obj[key]
            }
        }
    }
    return O;
}

react的事件优先级

作者 jump680
2025年12月8日 16:47

在 React 18(以及 React 17 的重构版本)中,优先级的概念被具象化为 "Lanes"(车道)模型

React 内部使用一套复杂的**位运算(Bitmask)**来管理这些优先级。为了方便理解,我们可以将它们映射为 Scheduler(调度器) 的 5 个核心等级。

理解这些优先级,是你掌握 React 并发机制(Concurrent Features)和性能调优的钥匙。

以下是按照优先级从高到低的详细分类:


1. Immediate / Synchronous Priority (立即/同步优先级)

对应的 Lane: SyncLane 权重: 最高级(No.1) 不可中断

  • 定义: 必须立即完成的任务。如果不立即更新,用户会觉得“坏了”或“没反应”。这种更新会绕过 React 的时间切片机制,强制同步执行(类似于 React 15 的行为)。
  • 触发场景:
    • 离散的用户交互: click (点击), input (输入), keydown (按键), touchstart
    • 强制同步 API: flushSync(() => { ... })
  • 行为:
    • 一旦触发,React 会锁死主线程,直到 DOM 更新完成。
    • 如果在 click 事件中写了死循环,浏览器会直接卡死。
  • 过期时间: -1ms(立刻过期,必须马上做)。

2. User Blocking Priority (用户阻塞优先级)

对应的 Lane: InputContinuousLane 权重: 次高级(No.2) 短时间可中断,但必须尽快

  • 定义: 同样是用户交互,但属于连续触发的类型。虽然需要高频响应,但允许极短的延迟(为了防止连续触发导致 JS 线程完全堵塞)。
  • 触发场景:
    • scroll (滚动)
    • mousemove (鼠标移动)
    • drag (拖拽)
    • wheel (滚轮)
  • 行为:
    • React 会尝试尽快处理,但如果短时间内触发太多,React 会进行一定的合并(Batching)。
    • 它比点击事件低一级,是因为:用户滚动时,如果画面有一丢丢(比如几毫秒)的跟手延迟,通常是可以接受的,但点击按钮没反应是不能接受的。
  • 过期时间: 250ms(如果排队超过 250ms 还没执行,就会强制升级为同步任务立即执行)。

3. Normal Priority (普通/默认优先级)

对应的 Lane: DefaultLane 权重: 中级(No.3) 可中断

  • 定义: React 中的默认优先级。如果你没有特殊操作,绝大多数状态更新都落在这个桶里。
  • 触发场景:
    • useEffect 里发起的更新。
    • 网络请求(fetch, axios)回调里的 setState
    • setTimeout / setInterval 里的更新。
    • 自定义事件监听器。
  • 行为:
    • 并发模式的主战场: 这里的任务会利用 Time Slicing(时间切片)
    • React 会给每个任务分配 5ms 的时间片。如果 5ms 没做完,就暂停,把主线程还给浏览器去画帧,下一帧再继续。
  • 过期时间: 5000ms(5秒)。这意味着即使很卡,5秒后 React 也会强制把这个任务执行完,保证界面最终会更新。

4. Low Priority (低优先级)

对应的 Lane: TransitionLane (React 18 新增) 权重: 低级(No.4) 随时可被中断、可被废弃

  • 定义: 明确告诉 React “我不着急”的任务。这是 React 18 并发特性的核心。
  • 触发场景:
    • startTransition 包裹的更新。
    • useDeferredValue 产生的更新。
    • Suspense 的重试机制。
  • 行为:
    • “备胎”: 只要有更高优先级的任务(比如用户点了一下鼠标),正在运行的 Low Priority 任务就会立刻被暂停,甚至直接扔掉。
    • 无 Loading: 在 Transition 期间,React 会保留旧的 UI,在后台悄悄计算新 UI,算好了再一次性切换。
  • 典型用例: 搜索框下方的长列表过滤、Tab 切换时的复杂页面渲染。

5. Idle Priority (空闲优先级)

对应的 Lane: IdleLane 权重: 最低级(No.5)

  • 定义: 只有当浏览器完全没事干(主线程空闲)时才会执行的任务。
  • 触发场景:
    • 通常用于 Offscreen(离屏) 内容的预渲染。
    • 比如:隐藏的 Tab 页、视口之外的组件预热。
  • 行为:
    • 优先级无限低,可能永远不会被执行(如果一直忙的话)。
    • React 内部其实还没大规模暴露这个给开发者直接用,主要在框架内部使用(如 BeforeContentOffscreenComponent)。

总结图谱

为了方便记忆,你可以想象成医院的急诊分诊台

优先级名称 对应交互 医院比喻 行为特点
Immediate click, input, flushSync 心脏骤停 (抢救) 医生放下所有事,立刻处理,不能等。
User Blocking scroll, drag 骨折/外伤 (急诊) 很痛,要尽快看,但稍微排几分钟队死不了人。
Normal fetch, setTimeout 发烧/感冒 (普通门诊) 正常排队,医生看一会儿可能会去接个急诊电话。
Low startTransition 常规体检 随时可以被插队,今天人多就明天再来。
Idle Offscreen 医院保洁 没病人的时候打扫卫生。

为什么你要知道这些?

  1. 性能调试: 当你发现输入框打字卡顿时,你要反应过来:是不是混入了大量的计算逻辑?是不是应该把列表过滤的计算降级为 Low Priority(使用 useDeferredValue)?
  2. 避免滥用: 以前为了优化,很多开发者喜欢用 setTimeout(..., 0) 来把任务往后排。现在你知道了,setTimeout 属于 Normal 优先级,而 startTransition 属于更低的 Low 优先级,后者在 React 18 中通常是更好的选择,因为它支持中断。
  3. 理解 batchedUpdates 只有同一优先级的更新才会被合并。高优先级会打断低优先级的合并。

Flutter 勇闯2D像素游戏之路(二):绘制加载游戏地图

作者 _大学牲
2025年12月8日 16:41

Flutter 勇闯2D像素游戏之路(一):一个 Hero 的诞生
Flutter 勇闯2D像素游戏之路(二):绘制加载游戏地图

前言

在上篇文章中,我们基本完成了一个像素风游戏角色的搭建与移动控制。
本篇文章,我们就来看看游戏地图是怎么绘制的。
而绘制 2d地图 我们就不得不借助于 Tiled

什么是 Tiled ?

Tiled 是一个免费的 2D 地图编辑器(Map Editor),主要用于制作:

  • RPG 地图
  • 闯关地图
  • 迷宫
  • 关卡碰撞层
  • 动态对象(宝箱、门、怪物刷新点)
  • 动画瓦片(如会动的水面、火焰)

在 Flame 中,Tiled 是最常见、最成熟的地图制作方式。

Tiled 的使用教程

1. Tiled 的安装

点击进入 Tiled Map Editor 安装界面,大家选择合适自己的版本安装即可。

安装完成后,按上述步骤即可设置中文。

有些同学,语言中 系统默认 可能就是中文,那么上述步骤就无需设置了。

2. 新建地图参数

点击新建地图之后,就出现了地图弹窗,可以将其分为三个部分:

  • 最上面的 地图 部分:一般保持默认参数即可。
  • 右边的 块大小 部分:块的宽高一般和你地图素材大小相关。
  • 左边的 地图大小 部分:就是以 为基本单位,指定地图的大小。

3. 图块集

Tilemap Dungeon original size.png

图块集(Tilemap)在 Tiled 中是用于构建和管理地图的基本元素之一,主要由一组瓦片(Tiles)构成。每个瓦片代表地图中的一个小区域或单元,通常是一个固定大小的图像,可以用来表示不同的地图元素,如地面、墙壁、水体、装饰物等。图块集将这些瓦片组织在一起,通常按纹理图像的方式排列,方便在地图编辑器中选择和使用。通过图块集,我们能够快速构建地图,选择不同的瓦片来创建多样化的地图元素和场景。

(1). 图块集类型

基于整张图块集图片

  • 定义:这种图块集类型使用一张大图作为瓦片集的来源,图块集的每个瓦片都来自这张图片的不同部分。

  • 特点

    • 纹理图像:所有的瓦片都来自于一张单独的图片,通常是一个大图,包含多个小的瓦片。
    • 简单和高效:这种方式比较常见,适用于瓦片数量较少的地图。它可以通过设定瓦片的大小、间隙和边距等参数来从大图中提取不同的瓦片。
    • 性能优化:加载时只需要加载一张图像,减少了加载多个图像的开销。
  • 应用场景:适用于较简单的地图设计,其中瓦片种类不多,且纹理图像较为统一。

多图片集合

  • 定义:这种图块集类型使用多个图片文件来作为瓦片集,每个图片文件代表不同的瓦片组。

  • 特点

    • 多个图像文件:每个图块集中的瓦片可以来自不同的图片文件,这样可以根据需要拆分图块集,避免一张图片过大。
    • 灵活性:适用于需要更大、更多种类瓦片的地图,能够根据不同的需求拆分多个图像文件进行管理。
    • 复杂性:由于使用多个图像文件,可能会增加地图管理的复杂性,但也能使瓦片集的管理更加灵活。
  • 应用场景:适用于复杂的地图设计,其中瓦片种类和图块集较多,且每个图像文件大小较大。


(2). 是否嵌入地图

嵌入地图

  • 定义:图块集可以选择是否嵌入到地图文件中,这意味着图块集的图像数据会直接包含在地图文件(如 .tmx)内。

  • 特点

    • 便于分发:嵌入地图的图块集会使地图文件包含所有必要的数据,使得地图文件在移动或共享时更加独立,减少了依赖外部资源的需要。
    • 增加地图文件大小:嵌入图块集会增加 .tmx 文件的大小,因为图像数据会被编码并存储在地图文件中。
    • 适用场景:适合小型项目或当你希望把所有地图数据捆绑在一起时。
  • 应用场景:适用于需要将所有地图和图块集数据打包在一个文件中的场景,便于管理和传输。

不嵌入地图

  • 定义:如果不选择将图块集嵌入地图,图块集将作为外部资源存在,即地图文件将引用外部的图块集文件(通常是 .tsx 文件)。

  • 特点

    • 减少地图文件大小:图像数据存储在外部图块集文件中,地图文件只需要保存图块集的引用,地图文件更小。
    • 资源共享:外部图块集可以被多个地图共享,方便资源复用和管理。
    • 便于修改:修改外部图块集文件时,所有引用该图块集的地图都会自动更新。
  • 应用场景:适用于大型项目或需要多个地图共享相同图块集的场景,便于维护和更新。


(3). 图像参数

透明度

  • 定义:透明度设置控制图块集的整体透明程度。
  • 功能:通过调整透明度,你可以使图块集在地图上的显示效果更为柔和或透明,常用于创建具有层次感的地图效果。
  • 应用:适用于背景图层或需要部分透明效果的地图元素。

块宽度

  • 定义:块宽度是每个瓦片的水平像素尺寸。
  • 功能:设置图块的宽度决定了每个瓦片的水平尺寸。不同的瓦片大小会影响地图的整体布局和渲染效果。
  • 应用:你可以根据实际需要调整瓦片宽度,以适应游戏或地图的设计需求。瓦片的宽度通常与图像的实际尺寸一致,或者是设定的纹理切割方式。

块高度

  • 定义:块高度是每个瓦片的垂直像素尺寸。
  • 功能:类似于块宽度,块高度设置了每个瓦片的垂直尺寸。调整瓦片的高度对于地图的布局和显示效果同样重要。
  • 应用:可以根据需求设定瓦片的高度,通常瓦片的高度和宽度是相同的,但也可以是矩形瓦片。

边距

  • 定义:边距是瓦片图像与图像边缘之间的空隙,通常用于设置瓦片之间的间隔。
  • 功能:边距设置了每个瓦片与纹理图像边缘之间的距离。它有助于确保图像中的瓦片之间不会出现视觉重叠,尤其是在瓦片集图像的边缘。
  • 应用:当图块集的图像有多个瓦片时,设置边距可以避免瓦片之间的像素混叠,提升地图的渲染效果。

间距

  • 定义:间距是图块集中的每个瓦片之间的空隙,即瓦片之间的空白区域。
  • 功能:间距控制瓦片之间的间隔距离,通常用于分隔不同的瓦片,以确保它们不会相互重叠或对齐不当。
  • 应用:适用于确保瓦片集中的瓦片之间有明确的分隔,尤其是在每个瓦片的纹理图像之间有额外的空白时。

4. 图层

截屏2025-12-08 09.00.33.png

Tiled 中,图层(Layers)是地图的基本组成部分,用于存储不同的地图元素。每个图层可以包含不同类型的信息,例如瓦片(Tile)、对象(Object)、图形(Graphics)等。

以下是 Tiled 中常见的三种图层类型的解析:

(1). Tile Layer(图块层)
  • 定义:最常用的图层类型,包含瓦片的网格布局。每个瓦片代表地图上的一小块区域,通常用于构建地形、背景等。

  • 特点

    • 可以使用 Tileset 来定义瓦片集,Tileset 是一组图像,每个瓦片对应一个图像部分。
    • 图层中的每个格子都对应一个瓦片,瓦片是基于 Tileset 的索引来标识的。
    • 可以设置图层的透明度、可见性等属性。
  • 常见应用:地面、墙壁、道路等。

(2). Object Layer(对象层)
  • 定义:包含一个或多个对象,每个对象可以是任意形状的区域(矩形、圆形、多边形等),每个对象通常可以带有附加的属性(例如,ID、名称、标签等)。

  • 特点

    • 每个对象的坐标、大小、旋转角度和其他属性可以自由设置。
    • 对象可以不受格子限制,适合存放动态元素,比如敌人、道具、触发区等。
    • 可以添加额外的属性,如图片、链接等。
  • 常见应用:敌人、NPC、触发器、道具等。

(3). Image Layer(图像图层)
  • 定义:此图层使用一个单一的图像(背景或装饰)作为图层内容。

  • 特点

    • 图像直接覆盖在地图上,通常不参与瓦片的拼接,而是作为一种背景或装饰。
    • 可以控制图像的位置和大小。
  • 常见应用:背景图像、装饰性图像等。

图层的其他设置:
  • 层顺序:在 Tiled 中,图层的渲染顺序是从下到上的。即,下面的图层会先绘制,上面的图层会覆盖下面的图层。
  • 图层属性:每个图层可以设置不同的属性,如可见性、透明度、锁定状态等。

MyHero

一. 本章目标

3.gif

二. 绘制地图

本项目绘制地图所采用参数,是符合于项目内素材的。
如用不同素材,酌情调整参数。

1. 新建地图

2. 导入瓦片集

选择项目中该素材地址:myhero/lib/assets/image/xxx.png 即可。

Tilemap Dungeon original size.png

3. 绘制地面

首先新建 ground图层

  • 绘制普通地板:

    • 在右下角的素材面板中,选择 普通地板 类型的素材。
    • 然后点击上方的 喷漆工具。(这个工具允许你快速地在地图上绘制普通地板)
    • 在地图区域中点击需要放置地板的地方,这样你就完成了基础地板的绘制。
  • 添加特别的想法:

    • 先在右下角框选需要使用的素材。
    • 点击上方的 印章工具。(印章工具允许你将选择的素材或特殊效果快速应用到地图中)
    • 然后,在地图中点击需要放置特殊设计的地方,即可添加。

4. 绘制水池

首先新建 water图层

重复上一步骤操作,选择素材完成绘制。

5. 绘制墙体

首先新建 wall图层

重复上一步骤操作,选择素材完成绘制。

6. 绘制杂物

首先新建 other图层

重复上一步骤操作,选择素材完成绘制。

7. 添加碰撞区

首先新建 Collisions对象图层

  • 点击上方矩形框工具。
  • 在地图中框选出墙体。
  • 再给每个碰撞区添加属性:type:collision

三. 加载地图

1.gif

1. 配置

地图文件位置.png

flutter:
  assets:
    - assets/images/
    - assets/tiles/

首先在 项目文件 和 配置文件 pubspec.yaml 中正确设置。

2. 代码

// 地图缩放比例
static const double mapScale = 2.0;
// 地图瓦片大小
static const double tileSize = 8.0;
  
// 加载地图
final realTileSize = mapScale * tileSize;
final tiled = await TiledComponent.load('地牢.tmx', Vector2.all(realTileSize));
world.add(tiled);

这段代码的目的是加载 Tiled 地图并将其添加到 Flame 游戏的世界中。

  1. 参数

    • tileSize: 表示每个瓦片的原始宽度和高度。
    • mapScale: 表示每个瓦片在项目中的缩放比例。
    • realTileSize:表示真实应用在项目中的瓦片大小。
  2. 加载

    • Vector2.all(tileSize) 是一个向量,用于设置地图中每个瓦片的大小。
    • TiledComponent.load() 方法是 Flame 引擎中的 Tiled 地图加载器,它会将 Tiled 地图文件(.tmx 文件)解析为 Flame 可使用的 TiledComponent 对象。
    • await 用于等待地图加载完成。
  3. 添加

    • Flame 的 World 是一个容器,用来管理和更新游戏中的所有组件(如精灵、物体、背景等)。
    • world.add() 将加载的 Tiled 地图组件 tiled 添加到游戏的世界(World)中。

四. 相机跟随

2.gif

1. 必要性

相机跟随 的作用是让玩家角色始终保持在屏幕可视范围内,使地图可以比屏幕更大,同时带来更强的空间感与沉浸感。随着角色移动,画面随之平滑移动,让世界显得更真实、更连贯。

如果没有相机跟随,角色一旦走出屏幕(就像上一步仅仅 加载地图 一样),游戏就无法正常体验,因此它是大多数地图类游戏的 核心机制 之一。

2. 代码

  @override
  Future<void> onLoad() async {
    // 加载游戏资源
    super.onLoad();

    ......
    
    // ---- Camera ----
    camera.setBounds(Rectangle.fromLTRB(0, 0, tiled.size.x, tiled.size.y));
    camera.follow(hero);
  }

这短短两行代码就实现了 相机跟随

  1. camera.setBounds()

    • 通过 setBounds 方法来限制摄像机的移动范围。

    • Rectangle.fromLTRB(0, 0, tiled.size.x, tiled.size.y) 创建了一个矩形区域,该矩形的左上角是 (0, 0),右下角是 tiled.size.xtiled.size.y,也就是地图的宽高。

    • 如此摄像机只能在该矩形区域内移动,确保视角不会超出地图的范围。

  2. camera.follow(hero);

    • 这行代码将摄像机与 hero 绑定,使得摄像机会一直跟随英雄(HeroComponent)的位置。
    • 每当英雄移动时,摄像机会自动调整视角,保持英雄始终在屏幕上的某个位置。

五. 碰撞区检测

3.gif

在游戏开发中,碰撞检测是一个常见而又重要的环节,它确保了游戏对象在交互过程中能够正确地反应,本文主要涉及到 墙体碰撞与英雄角色 的交互。

1. 创建墙体组件 (WallComponent)

首先,我们创建了一个 WallComponent,它代表地图中的墙体,并为其添加了一个 RectangleHitbox 来检测碰撞:

class WallComponent extends PositionComponent with CollisionCallbacks {
  WallComponent({
    required Vector2 position,
    required Vector2 size,
  }) : super(position: position, size: size);

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    add(RectangleHitbox()); // 为墙体添加碰撞检测
  }
}
  • WallComponent 类继承自 PositionComponent,它需要指定位置(position)和大小(size)。
  • 通过 add(RectangleHitbox()) 为墙体添加一个矩形碰撞体,确保我们可以检测到与其它对象的碰撞。

2. 地图碰撞区生成

在加载地图时,我们读取地图的 ObjectLayer(就是我们在Tiled 绘制的 Collisions 对象图层),并将其对象转换为墙体组件,添加到一个 walls 列表中,以便后续使用。

    final List<WallComponent> walls = [];

    final objGroup = tiled.tileMap.getLayer<ObjectGroup>('Collisions');
    if (objGroup != null) {
      for (final obj in objGroup.objects) {
        final x = mapScale * obj.x;
        final y = mapScale * obj.y;
        final w = mapScale * obj.width;
        final h = mapScale * obj.height;

        // 将墙体添加为 WallComponent 组件
        final wall = WallComponent(
          position: Vector2(x, y),
          size: Vector2(w, h),
        );
        wall.debugMode = true;
        walls.add(wall);
        await world.add(wall); // 将墙体添加到世界中
      }
    }

3. 英雄角色与墙体的碰撞检测

接下来,我们就要在 HeroComponent 类中实现了英雄角色与墙体的碰撞检测。
我们之前通过 JoystickComponent 获取用户输入来控制角色的移动,现在我们要检查每一帧是否发生碰撞。

    final Set<WallComponent> _nearbyWalls = {}; // 存储与英雄接触的墙体
    late RectangleHitbox _hitbox; // 英雄角色的碰撞体

    @override
    void update(double dt) {
      super.update(dt);

      final joy = game.joystick;
      if (joy.direction == JoystickDirection.idle) {
        _setState(HeroState.idle);
        return;
      }

      _setState(HeroState.run);

      final movement = joy.relativeDelta * speed * dt;
      final originalPosition = position.clone();

      // 尝试移动角色
      position += movement;

      // 检测碰撞
      if (_wouldCollideWithWalls()) {
        position.setFrom(originalPosition); // 如果发生碰撞,回到原位置

        // 通过滑动轴检查X、Y轴方向
        position.x += movement.x;
        if (_wouldCollideWithWalls()) {
          position.x = originalPosition.x;
        }

        position.y += movement.y;
        if (_wouldCollideWithWalls()) {
          position.y = originalPosition.y;
        }
      }
    }

    bool _wouldCollideWithWalls() {
      final heroRect = _hitbox.toAbsoluteRect();
      
      // 检查英雄与墙体的碰撞
      for (final wall in game.walls) {
        final wallHitboxes = wall.children.query<RectangleHitbox>();
        if (wallHitboxes.isEmpty) continue;

        final wallRect = wallHitboxes.first.toAbsoluteRect();
        if (heroRect.overlaps(wallRect)) return true; // 如果有重叠,表示发生碰撞
      }
      return false;
    }
  • 碰撞判断:通过 _wouldCollideWithWalls() 方法判断是否发生碰撞。
  • 平滑移动:如果发生碰撞,则将角色位置恢复到原位置,并在 X、Y 轴方向上分别尝试滑动,直到没有发生碰撞为止。

4. 碰撞回调

每当英雄与墙体发生碰撞时,我们可以通过 onCollisionStartonCollisionEnd 方法来处理相关逻辑。

    @override
    void onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other) {
      super.onCollisionStart(intersectionPoints, other);
      if (other is WallComponent) {
        _nearbyWalls.add(other);
      }
    }

    @override
    void onCollisionEnd(PositionComponent other) {
      super.onCollisionEnd(other);
      if (other is WallComponent) {
        _nearbyWalls.remove(other);
      }
    }
  • 发生碰撞(onCollisionStart:将墙体添加到 nearbyWalls 集合中。
  • 碰撞结束(onCollisionEnd:将 nearbyWalls 集合中墙体移除。

💡 小知识组件.debugMode = true
让组件显示调试可视化内容,方便检查碰撞框和位置。
开启后,Flame 会在屏幕上绘制:

  • 组件的边界框
  • 碰撞区(Hitbox)
  • 组件中心点与大小轮廓

常用于地图碰撞、角色位置、物体范围等的调试。

六. 总结与展望

总结

本章主要介绍了 Flutter&Flame 开发 2D像素游戏 关于 地图 的基础实践。
通过上述步骤,我们完成了加载地图、在地图中跟随人物 移动相机与地图元素交互

截至目前为止,游戏主要包括了以下内容:

  • 角色与动画:使用精灵图 (SpriteSheet) 创建角色,支持 idle/run 等动画状态切换。
  • 玩家交互:通过摇杆控制角色移动,并根据方向翻转动画。
  • 地图:通过 Tiled 绘制并在 Flame 中加载的 2d像素地图
  • 碰撞区检测:角色与墙体产生碰撞,并实现平滑侧移。

展望

  • 完成攻击与技能系统,包括动画切换、攻击范围和远程弹道。

  • 实现怪物生成、自动攻击与玩家碰撞逻辑。

  • 支持局域网多玩家联机功能。

🚪 github 源码
💻 个人门户网站


之前尝试的Demo预览

Vue生命周期钩子详解与实战应用

2025年12月8日 16:12

image.png Vue的生命周期钩子是组件开发中的重要概念,它们允许我们在组件的不同阶段执行特定的逻辑。本文将详细介绍Vue 3中常用的生命周期钩子,并结合实际例子展示其应用场景。

什么是生命周期钩子?

生命周期钩子是在组件从创建到销毁过程中自动调用的特殊函数。它们为我们提供了在特定时间点执行代码的机会。

主要生命周期钩子

1. onMounted - 组件挂载完成

当组件的DOM被渲染完成后调用,通常用于:

  • 发起网络请求
  • 操作DOM元素
  • 初始化第三方库
<script setup>
import { ref, onMounted } from 'vue'

const userList = ref([])

onMounted(async () => {
  // 组件挂载后加载用户数据
  try {
    const response = await fetch('/api/users')
    userList.value = await response.json()
  } catch (error) {
    console.error('加载用户数据失败:', error)
  }
})
</script>

2. onBeforeMount - 组件挂载前

在DOM渲染之前调用,此时还不能访问DOM元素:

<script setup>
import { ref, onBeforeMount } from 'vue'

const isLoading = ref(true)

onBeforeMount(() => {
  // 在DOM渲染前做一些准备工作
  console.log('组件即将挂载...')
  isLoading.value = true
})
</script>

3. onBeforeUpdate - 数据更新前

在响应式数据改变后,DOM重新渲染前调用:

<script setup>
import { ref, onBeforeUpdate } from 'vue'

const count = ref(0)
const renderCount = ref(0)

onBeforeUpdate(() => {
  // 每次更新前增加渲染计数
  renderCount.value++
  console.log(`组件即将第${renderCount.value}次更新`)
})
</script>

4. onUpdated - 数据更新后

在DOM重新渲染完成后调用:

<script setup>
import { ref, onUpdated } from 'vue'

const message = ref('Hello')

onUpdated(() => {
  // DOM更新完成后可以访问最新的DOM
  console.log('DOM已更新完成')
})
</script>

5. onBeforeUnmount - 组件卸载前

在组件卸载之前调用,用于清理工作:

<script setup>
import { ref, onBeforeUnmount } from 'vue'

let timer = null

// 启动定时器
timer = setInterval(() => {
  console.log('定时任务执行中...')
}, 1000)

onBeforeUnmount(() => {
  // 组件卸载前清除定时器
  if (timer) {
    clearInterval(timer)
    timer = null
  }
})
</script>

6. onUnmounted - 组件卸载后

在组件卸载完成后调用:

<script setup>
import { onUnmounted } from 'vue'

onUnmounted(() => {
  // 组件完全卸载后的清理工作
  console.log('组件已被卸载')
})
</script>

实际应用案例

完整的用户管理组件示例

<template>
  <div class="user-management">
    <h2>用户管理系统</h2>
    <div v-if="loading">加载中...</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">
        {{ user.name }} - {{ user.email }}
      </li>
    </ul>
    <button @click="refreshData">刷新数据</button>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUpdate, onUnmounted } from 'vue'

const users = ref([])
const loading = ref(false)
const refreshCount = ref(0)
let pollingTimer = null

// 组件挂载时加载数据
onMounted(async () => {
  console.log('用户管理组件已挂载')
  await loadUsers()
  
  // 启动轮询定时器
  pollingTimer = setInterval(async () => {
    await loadUsers()
  }, 30000) // 每30秒刷新一次
})

// 更新前记录刷新次数
onBeforeUpdate(() => {
  refreshCount.value++
  console.log(`数据第${refreshCount.value}次更新`)
})

// 组件卸载前清理资源
onUnmounted(() => {
  console.log('用户管理组件正在卸载')
  if (pollingTimer) {
    clearInterval(pollingTimer)
    pollingTimer = null
  }
})

// 加载用户数据
const loadUsers = async () => {
  try {
    loading.value = true
    const response = await fetch('/api/users')
    users.value = await response.json()
  } catch (error) {
    console.error('加载用户失败:', error)
  } finally {
    loading.value = false
  }
}

// 手动刷新数据
const refreshData = () => {
  loadUsers()
}
</script>

最佳实践建议

  1. 合理选择钩子时机

    • 网络请求通常放在 onMounted
    • 资源清理放在 onBeforeUnmount
    • DOM操作避免在 onBeforeMountonBeforeUpdate
  2. 注意内存泄漏

    • 及时清理定时器、事件监听器等
    • 使用 onBeforeUnmount 进行资源回收
  3. 异步操作处理

    • 在组件卸载前取消未完成的异步请求
    • 避免在已卸载组件上设置状态
❌
❌