高德地图游戏解决方案——自定义围栏系统结合智能设备模拟躲猫猫游戏端

应平台要求,为保护作者隐私,以下地址均为随机模拟地址

源码下载地址

体验地址 遇bug刷新

前言

高德地图除了导航还能做什么?本文章灵感来源于最近看的某台综艺《城市捉迷藏》和《真人猫鼠游戏》,也是最近相当火的一个大型户外交友游戏,由上帝视角制定玩家可活动区域,玩家佩戴智能设备,进行藏匿和寻找的游戏,高德地图有一整套的智能围栏和智能设备的解决方案,由于是个人开发,并没有获得高德地图的智能硬件的体验功能,所以文中用自定义电子围栏和模拟智能硬件地理位置开发一个猫鼠游戏的demo。文中涉及到非常多的数学公式,不过大部分功能高德地图都有封装。比如两点距离判断、某个点位是否在某个范围内、以某个点位为中心在指定半径范围内随机一个点位等等等等 可以参考数学库API

玩法梗概:

  • 通过上帝视角绘制游戏区域,模拟玩家佩戴的智能设备经纬度,定时随机点位来模拟玩家位置,
  • 当玩家越界(出现在围栏以外的范围)则判定玩家淘汰;
  • 当猫玩家随机位置时,检测到自身1米范围内存在鼠玩家,则鼠玩家转换阵营;
  • 游戏结束时(游戏时间结束或者猫鼠阵营没有玩家);
  • 鼠阵营玩家为0则为猫阵营胜利,抓鼠最多的玩家均为mvp进入结算画面;
  • 当游戏时间结束后,鼠阵营剩余玩家均为MVP进行结算画面;
  • 结算画面为猫鼠各自的行动轨迹,和对方阵营位置(轨迹)

脑图

按照游戏调研结果,结合高德地图现有功能,画了一张脑图,只要表述游戏流程和开发流程。创建地图文中就不讲述了,可以参考作者之前的文章 # 高德地图+threejs打造智慧景区大屏# 写一个高德地图巡航功能的小DEMO,下面和作者一起动手开发吧

技术栈

  • vite
  • typescript
  • amap

收藏 == 学会

正文

创建围栏

先看效果图 gif图比较大,耐心等待

围栏

初始化地图上没有线框信息,点击开始绘制,通过鼠标左键单击创建点位,鼠标移动时,预览下一个点位位置,单击鼠标右键则停止绘制,并根据操作控制左上角按钮状态

绘制多边形

绘制电子围栏首先需要创建一个Polygon,关键参数需要一个path LngLat类型,除了这些还可以传入边框颜色,和透明度,填充颜色和透明度,在文章中都有详细介绍,

typescript 复制代码
var polygon = new AMap.Polygon({
  path: [],
  strokeColor: "#3ba4f1",
  strokeWeight: 6,
  strokeOpacity: 1,
  fillOpacity: 0.5,
  fillColor: '#fff',
  zIndex: 50,
  bubble: true,
})

初始化path点位是空的,我们需要通过点击鼠标左键进行点位的添加,所以要绑定地图的click事件,

typescript 复制代码
// 点击事件
map.on('click', (e: any) => {
    if (state.type === 2) {
        const { lng, lat } = e.lnglat;
        const lnglat: LnglatType = [lng, lat]
        state.drawPoints.push(lnglat);
        const path = polygon.getPath();
        path.push(lnglat)
        polygon.setPath(path);
    }
})

state.type用来操控页面按钮状态,可以忽略,在点击事件中获取到当前点击的位置经纬度,e.lnglat

polygon.getPath获取当前多边形的所有点位信息。

polygon.setPath 将组装好的定位集合设置到多边形中,

并将所有的点位信息存在state中,这样我们就能在地图上绘制多边形了,此时在点击之前,鼠标移动时并不能预览图形,为了方便绘制,在鼠标移动的时候再多加一个移动时候的点位信息作为预览信息,值得注意的是预览信息在点击右键取消绘制的时候需要去掉,所以我们需要将移动时的点位在保存的时候过滤掉。

mousemove事件

typescript 复制代码
// 鼠标移动事件
map.on('mousemove', (e: any) => {
if (state.type === 2) {
    const { lng, lat } = e.lnglat;
    const lnglat: LnglatType = [lng, lat]
    state.drawingPoints.push(lnglat);
    const path = state.drawPoints.concat([lnglat]);
    polygon.setPath(path);
}
})

点击的时候将所有点位信息存在state中,在鼠标移动的时候就要用到了,我们并不是在移动的时候一直往多边形添加点位,而是在已有多边形的基础上添加一个最新的鼠标位置的点位,所以要用到点击时候存的点位信息state.drawPoints,再将鼠标位置的点位和已有点位结合,得到一个新的多边形。

rightclick事件

typescript 复制代码
// 监听右键点击事件
map.on('rightclick', () => {
if (state.type === 2) {
    const path = polygon.getPath();
    if (path.length < 3) {
        alert('请至少添加三个点')
        return
    } else {
        state.type = 3; 
        const path = state.drawPoints
        polygon.setPath(path);
        polyEditor.open();

    }

}
})

两点一线,三点成面,所以围栏我们这里规定至少3个点位,如果少于3个点位则保持绘制状态,超过3个点位则改变绘制状态为编辑,

编辑多边形

监听鼠标右键的方法用到了一个apiPolygonEditor,这是多边形编辑工具,实例接受一个map地图实例和一个polygon多边形实例,其他参数参考文档,可以修改手柄的颜色配置。

typescript 复制代码
const polyEditor = new AMap.PolygonEditor(map, polygon);

右键单击的时候,将编辑器实例状态改为活跃,点击保存绘制时需要将编辑器改为关闭

typescript 复制代码
// 开启编辑器
polyEditor.open();
// 关闭编辑器
polyEditor.close();

除此之外,编辑器还提供一个方法getTarget获取绑定编辑器的多边形实例,每次点击保存的时候需要通过这个方法获取到编辑后的点位信息,以便后续使用。

typescript 复制代码
 polyEditor.getTarget().getPath()

挖洞

为了适配不同区域地图的绘制,还需要一个挖洞的功能,比如在一个校园里,假设在围栏中有某个区域不可躲藏(例如泳池,私人场所等)则需要挖洞将这片区域隔离开,挖洞主要是为了之后的越界判断,如果不挖洞而做两个围栏,后续判断越界会比较费劲

挖洞效果图

点击挖洞按钮,其他操作和绘制围栏是相同的,只不过数据结构不同而已,围栏的数据格式是 Lnglat[],而挖洞的数据结构是Array<Lnglat[]>,是一个多元的数组,外围多边形为数组第一位,挖洞数据在数组的末位,你可以添加多个挖洞数据

将前面制作围栏时候的代码修改一下,主要修改click事件和mousemove事件,

typescript 复制代码
// 点击事件
map.on('click', (e: any) => {
    if (state.type === 2) {
        let path = polygon.getPath();
        if (!path) {
            path = [[]]
        }
        path[path.length - 1].push(e.lnglat)
        polygon.setPath(path)
        state.drawPoints = [...path];
    }

})

// 鼠标移动事件
map.on('mousemove', (e: any) => {
    if (state.type === 2) {
        let path = polygon.getPath();
        if (!path) {
            path = [[]]
        }
        if (path[path.length - 1].length !== 0) {
            path[path.length - 1][path[path.length - 1].length - 1] = e.lnglat;
            polygon.setPath(path)
            state.drawingPoints = path;

        }
    }
})

每次操作都判断一下围栏数据是否有数据,如果没数据则添加一个空数组,点击挖洞的时候同理,需要在围栏数据的数组末位添加一个空数组,用来保存挖洞信息

typescript 复制代码
if (burrowBtn) {
    burrowBtn.addEventListener('click', () => {
        state.type = 2;

        let path = polygon.getPath();

        path.push([[]]);
        polygon.setPath(path);

        state.drawPoints.push([]);

    })
}

同时需要修改的还有单击鼠标右键时候的点位数量判断,由于数据结构改变了,我们将判断围栏数据中的每一个数组的长度,保证每一个围栏和挖洞的数据都至少有3个点位

typescript 复制代码
// 监听右键点击事件
map.on('rightclick', () => {
    if (state.type === 2) {
       ...
        const minLenght = path.some((p: any) => p.length < 3)
        if (minLenght) {
            alert('请至少添加三个点')
            return
        } else {
          ...
        }
    }
})

数据持久化

该项目所有数据均储存在indexedDb 中,只以围栏为例,indexeddb配置在文件夹中src\utils\indexedDB\index.ts路径下,围栏的数据操作在文件夹中src\request\fence.ts路径下,

typescript 复制代码
// 保存
if (saveBtn) {
    saveBtn.addEventListener('click', () => {
       ...
        const arr=  path.map((item: any)=>{
           return  item.map((ite: any)=>{
                return [ite.lng,ite.lat]
            })
        })
        if(fenceId) {
          
        } else {
            add({
                paths:arr
    
            })

        }

    })
}
typescript 复制代码
const add = async (foorInfo: createFenceInfoParams) => {
    const res = await createFence(foorInfo)
    if (res.success) {
        localStorage.setItem('fenceId', res.data.fenceId)
        fenceId = res.data.fenceId;

    }
}

以新增为例,点击保存时候判断是否已有围栏id如果不存在围栏id则新增一条,如果存在 则更新围栏

typescript 复制代码
if (savePath) {
    state.type = 4
    polygon.setPath(savePath);
}

当获取到已有围栏,则将按钮状态设置为4,这里只设置多边形的定点数据即可,整个围栏的操作数据流向是单一的,只有设置围栏定点,才能从定点获取信息给state中使用,并不可以直接用state中的定点信息直接赋值给多边形。

已创建的id存在localStorage

typescript 复制代码
let savePath = []

let fenceId = localStorage.getItem('fenceId') || '';
if (fenceId) {
    const res = await getFenceInfoByFenceId({ fenceId });
    if (res.success) {
        console.log(res.data);
        savePath = res.data.paths
    }
}

简单交代一下数据储存方案,避免之后的文章用到数据的时候大家不知道数据从哪里来的。

创建定时器和公共方法

定时器

在创建角色和玩家之前,先定义几个定时器和公共方法,定时器一共有3个,游戏结束倒计时,由于是demo 只定义5分钟时长,猫和鼠的移动定时器,定时5s刷新一下位置

typescript 复制代码
import { IntervalTime } from "../utils/IntervalTime";
import { catChangePosition, gameEnd, mouseChangePosition } from "./main";

const intervalTime = new IntervalTime();
// 游戏结束时间
const gameEndTime = 1000 * 60 * 5; // 5分钟

// 鼠鼠移动时间
const mouseChangePositionTime = 1000 * 5; // 5秒

// 猫猫移动时间
const catChangePositionTime = 1000 * 5; // 5秒

let interval: any = null
const animation = () => {
    intervalTime.update();
    interval = requestAnimationFrame(animation);
};

// 游戏时间倒计时
intervalTime.interval(() => {
    // 倒计时结束删除倒计时功能
    removeInterval()
}, gameEndTime, 1);

// 游戏结束 删除定时器
export const removeInterval = () => {
    intervalTime.clearIntervals()
    interval&&cancelAnimationFrame(interval)
     // 触发游戏结束的回调函数
     gameEnd()
}
// 鼠鼠移动
intervalTime.interval(() => {
    mouseChangePosition()
}, mouseChangePositionTime);

// 猫猫移动
intervalTime.interval(() => {
    catChangePosition()
}, catChangePositionTime);

公共方法

创建一个随机点位并判断是否在多边形内,其中包含两个方法,一个是检测玩家是否在范围内,另一个是创建一个范围内的随机点位,其中用到了高德地图提供的API关系判断,由于该API只能够判断某一个对变形和点位之间的关系,所以挖洞是判断不了的,我们需要进行再次加工,根据挖洞的数据组成,path数组第一位是外框,后面的都是挖洞的范畴,所以,遍历一下多边形信息,如果是第一位,则判断在范围内,如果是其他位,判断不在范围内,这样通过多次判断即可获取一个完全在范围内而且还规避了挖洞范围的随机点位

typescript 复制代码
import { IntervalTime } from "../utils/IntervalTime";
import { catChangePosition, gameEnd, gameEndTime, gameEndTimeDown, mouseChangePosition } from "./main";

const intervalTime = new IntervalTime();


// 鼠鼠移动时间
const mouseChangePositionTime = 1000 * 5; // 5秒

// 猫猫移动时间
const catChangePositionTime = 1000 * 5; // 5秒

let interval: any = null
export const animation = () => {
    intervalTime.update();
    interval = requestAnimationFrame(animation);
};

// // 游戏时间倒计时
// intervalTime.interval(() => {
//     // 倒计时结束删除倒计时功能
//     removeInterval()
// }, gameEndTime, 1);

// 游戏结束 删除定时器
export const removeInterval = () => {
    intervalTime.clearIntervals()
    interval&&cancelAnimationFrame(interval)
    
}
// 鼠鼠移动
intervalTime.interval(() => {
    mouseChangePosition()
}, mouseChangePositionTime);

// 猫猫移动
intervalTime.interval(() => {
    catChangePosition()
}, catChangePositionTime);

// 每秒执行一次
intervalTime.interval(()=>{
    console.log('每秒执行一次');
    gameEndTimeDown()
}, 1000)

循环调用一下这个方法,大概就能看出随机的效果

随机点位效果图

创建角色和玩家

角色分三种,猫、鼠、已淘汰,角色可以转换,但有条件,鼠可转猫,鼠和猫可转已淘汰,猫不可转鼠,已淘汰不可转任何角色。

有了上面的方法,我们可以创建出5个角色为鼠的玩家,并在地图中心添加5个猫角色

typescript 复制代码
  // 创建5个鼠角色的玩家
for (let i = 0; i < 5; i++) {
    const position = generateRandomPointInRange(state.drawPoints);
    addMarker('M', position)
}

const center = map.getCenter();
// 在地图中心创建5个猫角色的玩家
for (let i = 0; i < 5; i++) {
    addMarker('C', [center.lng, center.lat])
}

并且在点击开始游戏时 创建一个时间戳用于倒计时

typescript 复制代码
const now = +new Date() + gameEndTime;
window.localStorage.setItem('timeDown', `${now}`);

// 每秒倒计时 计算具体的时间
const timeStr = localStorage.getItem('timeDown')
if (timeStr) {
    const time = Number(timeStr)
    const now = +new Date()

    const diffInSeconds = dayjs(time).diff(dayjs(now), 'second');
    const diffInMinutes = dayjs(time).diff(dayjs(now), 'minute');

    const second = diffInSeconds % 60;

    if (gameTime) {
        gameTime.innerText = `当前时间还剩:${diffInMinutes < 10 ? `0${diffInMinutes}` : second}:${second < 10 ? `0${second}` : second}`;
    }
    // 倒计时结束游戏结束
    if (time - now < 1000) {
        removeInterval()
        gameEnd()
    }

}

创建角色效果图

角色移动

为了尽量还原和模拟真实智能硬件的定位推送效果,不打算直接用创建角色那样的随机点位去修改角色位置,而是从角色当前位置向四周半径为1米的位置随机移动,点位多了后续MVP结算画面才会好看,轨迹才会连贯

写完代码调试了一下,发现1米的距离在地图上几乎属于没动的效果,为了演示 改为10米了,从图中可以看出每隔几秒猫角色位置发生改变,还做了数据持久化,刷新页面也会记录玩家位置。接下来要做的除了鼠的位置移动,还有范围判断,看看角色是否越界,还需要做双方阵营之间的距离,判断猫玩家是否抓住了鼠玩家

typescript 复制代码
 // 猫鼠阵营移动
    const changePosition = (type: RoleType) => {
        const markers = map.getAllOverlays('marker');
        markers.forEach(async (marker: any) => {
            const info = marker.roleInfo
            if (info && info.roleId && info.type === type) {
                const res = await getRoleInfoByRoleId({ roleId: info.roleId })
                if (res.data) {
                    const params = changeRulePos(marker, res.data)
                    // 更新角色位置信息
                    saveRoleInfo(info.roleId, params);

                }
            }

        })
    }
    // 改变猫鼠角色位置
    const changeRulePos = (marker: any, data: any): RoleInfoParams => {
        const { lastPos, position = [] } = data;
        const pos = [...position]
        const newPos = getRandomPoint(lastPos?.point?.[0], lastPos.point[1]);

        const now = +new Date()

        const posItem = {
            point: newPos,
            time: now
        }
        const params: RoleInfoParams = {
            position: [...pos, posItem],
            lastPos: posItem,
        }

        const paths = polygon.getPath();
        let outline = !AMap.GeometryUtil.isPointInRing(newPos, paths[0].map((p: any) => [p.lng, p.lat]));

       let  isPointInRing = [...paths].splice(1, paths.length - 1).some((path: any) => {
            return !AMap.GeometryUtil.isPointInRing(newPos, path.map((p: any) => [p.lng, p.lat]));
        })

        if (outline||!isPointInRing) {
            console.log('淘汰一个');
            params.type = 'E'
            map.remove(marker)
        } else {
            marker.setPosition(newPos);
        }
        return params
    }

角色移动效果图

角色运动的时候,收集了历史轨迹和时间点,后续需要根据时间排序绘制MVP结算画面的运动轨迹

判断出界

使用高德地图apiisPointInRing 接受两个参数,第一个是被判断的点位,第二个是判断范围的经纬度数组集合,判断围栏和角色之间的关系,如果角色出界则将状态改为淘汰,并删除marker,由于前文提到,带挖洞的围栏,第一位是外框,其他是孔洞,所以需要做两次判断,判断出界依据是超过外框或者在孔洞内。

typescript 复制代码
...
  const paths = polygon.getPath();
    let outline = !AMap.GeometryUtil.isPointInRing(newPos, paths[0].map((p: any) => [p.lng, p.lat]));

   let  isPointInRing = [...paths].splice(1, paths.length - 1).some((path: any) => {
        return !AMap.GeometryUtil.isPointInRing(newPos, path.map((p: any) => [p.lng, p.lat]));
    })

    if (outline||!isPointInRing) {
        console.log('淘汰一个');
        params.type = 'E'
        map.remove(marker)
    } else {
        marker.setPosition(newPos);
    }
    ...

判断出界效果图

动图的前摇有点长,在后面几帧控制台打印出淘汰一个字样。

判断抓捕

判断抓捕逻辑,在角色运动的时候,判断和对方角色之间的距离,小于1米则鼠角色转为猫角色 主要用到测距 distance 的API,AMap.LngLat.distance(AMap.LngLat) 得到一个长度

typescript 复制代码
const obsolete = async (marker: any, type?: RoleType,) => {
    const res = await getAllData();
    const list = res.data
    const p1 = marker.getPosition()

    const otherList = (list || []).filter((item: any) => item.type === (type === 'M' ? 'C' : '' || type === 'C' ? 'M' : ''))

    if (otherList) {
        for (let i = 0; i < otherList.length; i++) {
            const item: RoleInfoParams = otherList[i]
            const p2 = new AMap.LngLat(item?.lastPos?.point[0], item?.lastPos?.point[1])

            if (p1 && p2) {
                const distance = Math.round(p1.distance(p2));

                if (distance <= obsoleteDistance) {
                    if (item.type === 'M') {
                        console.log('鼠被淘汰')
                        const obsMarker = map.getAllOverlays('marker').find((ite: any) => {
                            return ite.roleId === item.roleId
                        })
                        if (obsMarker) {
                            // 调用转换类型的接口
                            transTeam(item.roleId || '', 'C', marker)
                        }
                    } else if (item.type === 'C') {
                        transTeam(marker.roleInfo.roleId, 'C', marker)
                    }
                }
            }
        }

    }
}

 //转换阵营
const transTeam = async (id: string, targetType: RoleType, marker: any) => {
    if (id) {
        const res = await getRoleInfoByRoleId({ roleId: id })
        if (res.data) {
            const params = {
                ...res.data,
                type: targetType
            }
            await saveRoleInfo(id, params);
            marker.setIcon(getMarkerIcon(MarkerIconMap.get('C')!))
        }
    }

}

总体的逻辑就是在猫鼠运动的时候,互相进行距离判断,如果双方距离小于规定的距离obsoleteDistance 则对比对象中的鼠方改变阵营,变为猫阵营,不更改位置,并且在下次移动的时候可以抓捕老鼠,游戏结束后可以参与MVP结算画面

定向抓捕

为了演示效果的表现,取消了之前写的猫角色以自身位置为中心随机更改位置,而是改成了定向抓捕,每次更换位置,找到一个合适的目标,并采取抓捕行动

抓捕效果图

输赢判断

相对于前面的功能,这部分超级简单,有两种判定方式,第一种是游戏时间结束,第二种是双方任意阵营没有玩家的存在,第一种判断游戏结束方式,前面在讲定时器的时候说过,这里不赘述,简单的介绍一下第二种方法

写一个方法名,然后按tab即可生成想要的内容!!!

typescript 复制代码
// 判断人数
const roleCount = (timeEnd?: boolean) => {
    const markers = map.getAllOverlays('marker');
    const catList = markers.filter((item: any) => item.roleInfo?.type === 'C')
    const mouseList = markers.filter((item: any) => item.roleInfo?.type === 'M')
    // 如果猫阵营没人
    if (catList.length === 0) {
        gameEnd("M")
    } else if (mouseList.length === 0) {
        // 如果鼠阵营没人
        gameEnd("C")
    } else if (timeEnd) {
        // 双方都有人,但是时间结束,则鼠赢
        gameEnd("M")
    } else if (catList.length === 0 && catList.length === mouseList.length) {
        // 双方都没人了,平局
        gameEnd()
    }
}

MVP结算

鼠阵营结算画面相对比较简单,时间结束后 或者猫阵营没人的时候 剩下的所有鼠均为mvp,代码中不做处理,感兴趣的童鞋自行处理,猫阵营结算mvp根据抓过多少鼠阵营玩家,并且在轨迹回放的时候,回显抓过的每一个鼠阵营玩家的每一条轨迹,所以在判定鼠转换阵营的时候,需要将转换阵营前鼠阵营玩家的轨迹记录下来,下面修改一下 转换阵营的方法transTeam

新增一个heroId参数。

typescript 复制代码
  //转换阵营
const transTeam = async (id: string, targetType: RoleType, marker: any, heroId?: string) => {
   ...
    if (heroId) {
        const hero = await getRoleInfoByRoleId({ roleId: heroId })
        if (hero.data) {
            const defeat = { id, position: params.position }
            hero.data.defeatList = hero.data.defeatList?.length === 0 ?
                [defeat] :
                [defeat, ...hero.data.defeatList]
        }
    }
...

在转换阵营的时候将被捕角色的轨迹全部记录在抓捕者的defeatList字段内,在结算时,生成获胜者列表并在点击的时候进行轨迹回放

typescript 复制代码
// 绘制猫阵营获胜者dom
 const drawMvpDom = (result: any, type: RoleType) => {
        if (mvpUl) {
            result.forEach((item: any) => {
                mvpUl.innerHTML += `<li data-winDat='${JSON.stringify(item)}'><img src="${MarkerIconMap.get(type)}" /></li>`

            })
        }
    }
    
 // 点击获胜者dom并绘制轨迹播放动画
if (mvpUl) {
    mvpUl.addEventListener('click', (e: any) => {
        const markers = map.getAllOverlays('marker');
        map.remove(markers)
        const Polyline = map.getAllOverlays('polyline');
        map.remove(Polyline)
        if (e.target.nodeName === 'LI') {
            const winDat = e.target.getAttribute('data-winDat');
            if (winDat) {
                const winData = JSON.parse(winDat);
                addPath(winData.position, winData.type)
                winData.defeatList.forEach((item: any) => {
                    console.log('item', item);

                    addPath(item.position, 'M', 'blue')
                })
            }
        }

    })
}

猫阵营结算效果图

历史文章

three.js 专栏

threejs------从实战出发之智慧塔吊大屏

高德地图+threejs打造智慧景区大屏

three.js------完整3d大屏展示超详细讲解

threejs------可视化风力发电车物联交互效果 内附源码

three.js------商场楼宇室内导航系统 内附源码

three.js------可视化高级涡轮效果+警报效果 内附源码

相关推荐
Pedantic4 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘4 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆4 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师5 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆5 小时前
VSCode自动格式化三要素
前端
爱勇宝6 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen6 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518139 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode9 小时前
Redis 在生产项目的使用
前端·后端