帧同步入门项目LockstepDemo的初步学习

文章目录

  • 前言
    • 连帧同步都出SDK了
  • LockstepDemo
    • socket.io.js
    • jquery.min.js
    • 服务器app.js
    • 客户端main.js
  • 总结

前言

上一篇文章介绍了 LockstepDemo 这个项目可以作为帧同步入门读物,解决了跨域限制的问题以后,这个开源项目就可以运行起来啦,虽然我没有使用js写过实际的项目,但看的多了自然也能看懂大部分的js代码了,作为一个帧同步领域的小白,我开始了阅读这个项目代码的旅程,看过之后确实解开了我之前的迷惑,所以简单记录一下学习心得。

  • 基础的帧同步模式,每个客户端必须回报给服务器收到帧数,服务器再次发送确认包才执行帧数据,否则所有人等待,也就是一卡全卡。
  • 基于现在手游的流行程度和弱网环境,手游一般都采取乐观锁模式。即收到服务器推帧后,客户端立即执行,不等待其他人。这样卡顿的人自己卡,不影响其他人的游戏体验。同时卡顿的人在收到数据后,自行加速补帧来追赶上正确的游戏速度。

连帧同步都出SDK了

插播一条消息,今天在搜索帧同步资料的时候,无意间发现了游戏巨头——腾讯居然发布了帧同步SDK-LockStep,简直“丧心病狂”,真的是盘子大了什么都做啊,不过也挺好,我可以从中学到不少问题的处理方案,我摘抄官方文档部分内容,感兴趣的可以去看看

帧同步(LockStep)服务为手游开发提供一套快速、可靠的帧同步游戏开发框架。基于GCloud云服务
进行快速部署,同时支持TCP、UDP、RUDP三种通道。帧同步开发框架提供一致性数学库与一致性检测工具,并且针对弱网设计具有高可靠、低延时的特性。

帧同步与更为传统的状态同步均为游戏常见同步方案,主要区别在于:状态同步主要逻辑计算放在服务器端,将计算结果下发给客户端;而帧同步服务器仅仅起到收集客户端输入并广播的作用。基于帧同步相对状态同步流量消耗更低、开发效率更高、打击感更好等优点,《王者荣耀》选择了帧同步方案。

《拳皇命运》项目从完全不熟悉帧同步技术的情况下,接入SDK仅用了两个月时间,将原有状态同步游戏,改造成为帧同步游戏;项目仅需编写游戏逻辑,无需关心同步、网络品质、录像回放等核心功能。

专业的事情交给专业的人来做,如果之前没有积累,将这套SDK直接拿过来用也是不错的,我看到支持C++和C#两种语言,其他的语言就得自己去沟通了

LockstepDemo

言归正传,开始阅读LockstepDemo这个项目的源码,服务端app.js使用node运行,前端main.js+main.css+index.html,在浏览器中运行,其实主要的逻辑代码就在main.js中,后端就只有app.js一个文件,总共166行,前端还引用了jquery.min.js和socket.io.js两个库文件,但包含主要逻辑代码的main.js文件仅有328行。

在开始阅读自定义逻辑代码之前先来看看引用的这两个库:

socket.io.js

负责网络的建立、管理,消息的发送等等,看了逻辑中的调用真的是挺方便的

  • var io = require("socket.io")(server): 创建基于 Node.js 的 WebSocket 服务器,并将其绑定到了一个 HTTP 服务器实例 server 上
  • io.on('connection', function (socket) {...]): 使用Socket.IO 的服务器实例的 on 方法来监听客户端与服务器建立连接的事件
  • socket.on('join', function(account) {...}): 使用与客户端建立的连接的对象 socket 的 on 方法来监听客户端发送的 ‘join’ 事件
  • socket.emit("open", {...}): 使用了 Socket.IO 库的 emit 方法来向当前客户端发送一个名为 “open” 的自定义事件,并附带自定义对象作为数据
  • socket.broadcast.emit('system', ...): 用于向除当前连接的客户端之外的所有客户端发送消息
  • io.sockets.emit('start', {...}): 用于向所有连接的客户端发送消息

jquery.min.js

即使没有做过前端,jQuery这个库的大名也应该听过,它是一个流行的 JavaScript 库,简化了在网页开发中的 JavaScript 编程。它提供了一系列功能强大且易于使用的 API,使得诸如 DOM 操作、事件处理、动画效果、AJAX 请求等任务变得更加简单和高效,主要特点如下:

  • 简化 DOM 操作:提供了简洁而强大的 DOM 操作方法,使得选择元素、修改元素属性、添加/删除元素等操作变得更加便捷
  • 事件处理:提供了简单易用的事件处理方法,可以方便地为元素绑定事件、移除事件、触发事件等,大大简化了事件处理的代码编写
  • 动画效果:提供了丰富的动画效果和特效,可以通过简单的方法实现页面元素的平滑过渡、淡入淡出、滑动等效果,为用户提供更流畅的交互体验
  • AJAX 请求:提供了简洁的 AJAX 方法,可以方便地进行异步数据加载和交互,从而实现更灵活和动态的网页内容加载和更新
  • 跨浏览器兼容性:封装了复杂的跨浏览器兼容性处理,使得开发者可以更加轻松地编写跨浏览器兼容的代码

简单来说它就是一个封装了常用操作的库,稍后会在main.js发现它的使用方法,简单摘录如下:

  • 文档加载完成事件$(function () {...}) 用于在文档加载完成后执行的函数,这是 jQuery 的快捷方式,等同于 $(document).ready(function() {...}),表示在 DOM 树构建完成后执行指定的函数
  • 元素选择$('body')$('#start_btn')$('#reconnect_btn') 等通过 jQuery 选择器选择了 HTML 元素。这些选择器能够基于元素的标签名、ID、类名等来选择元素,返回 jQuery 对象,以便进行后续的操作
  • 事件处理$('body').keydown(function(e) {...}) 注册了键盘事件处理函数,当键盘按键按下时执行相应的操作
  • 动画效果$("#tips").animate({...}) 使用了 jQuery 的动画效果,在提示框显示时执行动画效果,让提示框从屏幕中间上方滑动到屏幕中间
  • 样式操作$("#tips").show()$("#tips").fadeOut() 等使用了 jQuery 提供的方法来控制元素的显示和隐藏

在阅读app.js和main.js之前还是得说明一下,今天只介绍核心逻辑,像断线重连、消息提示、显示网络延迟等功能,都是在核心逻辑上的补充和优化,可以先忽略不看,并且这次看代码发现,整个运动的表现和实现逻辑是符合牛顿第一定律的,真的挺有意思:

一切物体总保持匀速直线运动状态或静止状态,直到有外力迫使它改变这种状态为止

这只是这个项目的特点,并不是所有的游戏都是这样的,有些游戏的实现就是和这惯性定律相违背的,比如很多游戏必须一直拖动摇杆才会移动,否则就会停住静止,它们所表达出来的就是,“力是维持物体运动的关键”,好了,扯得有点远了,一起看看代码实现吧

服务器app.js

var server = require('http').Server()
var io = require("socket.io")(server, {
    allowEIO3: true,
    cors: {
      origin: ["http://127.0.0.1:8080", "null"],
      methods: ["GET", "POST"],
      credentials: true // 设置为true以允许使用凭据
    }
});

// 监听3000端口
server.listen(3000, function(){
   console.log("服务器启动成功,监听端口3000")
})

定义服务器实例,启动并监听3000端口,这里已经做了跨域允许访问的配置

var g_onlines = {} // 所有在线玩家
var g_commands = new Array() // 指令数组
var g_commands_histroy = new Array() // 历史指令,用于断线重连
var g_joinCount = 0 // 已准备的人数
var g_maxJoinCount = 2 // 最大人数
var g_stepTime = 0 // 当前step时间戳
var g_stepInterval = 100 // 每个step的间隔ms

// 游戏状态枚举
var STATUS = {
   WAIT:1,
   START:2
}
var g_gameStatus = STATUS.WAIT

一些游戏全局变量和游戏状态枚举的定义,注释写的很详细,记一遍看下面的逻辑时能想起来就行

// frame定时器
var stepUpdateCounter = 0
function update(dt) {
   var now = Date.now()
   if(g_gameStatus == STATUS.START) {
      stepUpdateCounter += dt
      if(stepUpdateCounter >= g_stepInterval) {
         g_stepTime++
         stepUpdate()
         stepUpdateCounter -= g_stepInterval
      }
   }
}

// 启动定时器
var lastUpdate = Date.now()
setInterval(function() {
   var now = Date.now()
   var dt = now - lastUpdate
   lastUpdate = now
   update(dt)
})

开始进入主要逻辑,lastUpdate是上次走过的时间,在游戏开始g_gameStatus == STATUS.START后不断将 dt 积累到变量 stepUpdateCounter,超过一帧的间隔后执行一帧逻辑 stepUpdate()setInterval 是内部函数,第二个参数表示调用的时间间隔,默认为0,可以可以认为是游戏中常用的 tick() 函数


// step定时器
function stepUpdate() {
   // 过滤同帧多次指令
   var message = {}
   for(var key in g_onlines) {
      message[key] = {step:g_stepTime, id:key}
   }
   for(var i = 0; i < g_commands.length; ++i) {
      var command = g_commands[i]
      command.step = g_stepTime
      message[command.id] = command
   }
   g_commands = new Array()

   // 发送指令
   var commands = new Array()
   for(var key in message) {
      commands.push(message[key])
   }
   g_commands_histroy.push(commands)
   for(var key in g_onlines) {
      g_onlines[key].socket.emit('message', new Array(commands))
   }
}

这是转发客户端操作的核心函数,首先是遍历所有玩家 g_onlines,执行 message[key] = {step:g_stepTime, id:key} 为每个玩家构建一个空指令,然后遍历当前收到的所有命令 g_commands,将命令的帧值设置为当前帧,并且过掉一帧中的多个指令,保证一帧只能朝一个方向运动,将收集到的所有指令 commands 通过 socket.emit() 函数发送给所有玩家 g_onlines

io.on('connection', function (socket) {
   socket.emit("open", {id:socket.id, stepInterval:g_stepInterval})

   // 获取用户账户
   function getAccount(socketId) {
      for(var key in g_onlines) {
         if(socketId == g_onlines[key].socket.id) {
            return key
         }
      }
   }

   //...
})

这是服务器上所有事件监听的基础,io 监听新玩家连接事件,建立连接后向客户端发送一个名为 “open” 的自定义事件,并附带了一个包含 id 和 stepInterval 属性的对象作为数据,id的值是 socket.idstepInterval表示每帧的时间间隔,getAccount() 函数的作用通过socket.id获取账号名

   socket.on('join', function(account) {
      // 顶号/断线重连
      if(g_onlines[account]) {
         g_onlines[account].socket.emit('system', "被顶号了")
         g_onlines[account].socket.disconnect()
         if(g_gameStatus == STATUS.START) {
            g_onlines[account] = {socket: socket, online: true}
            socket.emit('join', {result:true, message:"正在断线重连..."})
            console.log(account, "重连游戏")
            socket.broadcast.emit('system', account + "重新连接!")
            socket.emit('start', {player:Object.keys(g_onlines)})
            socket.emit('message', g_commands_histroy)
            return
         }
      }
      // 房间已满
      if(g_joinCount == g_maxJoinCount) {
         console.log("房间已满", account, "加入失败")
         socket.emit('system', "房间已满")
         socket.disconnect()
         return
      }
      // 加入游戏
      if(g_joinCount < g_maxJoinCount) {
         console.log(account, "加入游戏")
         socket.emit('join', {result:true, message:"匹配中..."})
         g_onlines[account] = {socket: socket, online: true}
         g_joinCount++
      }
      // 开始游戏
      if(g_joinCount == g_maxJoinCount) {
         g_commands = new Array()
         g_commands_histroy = new Array()
         g_gameStatus = STATUS.START
         io.sockets.emit('start', {player:Object.keys(g_onlines)})
      }
   })

这是监听socket收到 'join' 事件的处理函数,实现逻辑有些技巧,通过查询账号是否已经登录过服务器,来判定是否为重连,如果为重连则一次发送 'join''start''message'事件和数据,其中 'message' 事件中的数据是从游戏开始以来的所有指令

如果不是重连就要判断匹配人数,超过到 g_maxJoinCount 不允许进入,达到g_maxJoinCount游戏开始,通知客户开始游戏,否则通知客户端正在匹配中

   socket.on('timeSync', function(time) {
      socket.emit('timeSync', {client:time, server:Date.now()})
   })

   socket.on('message', function(json) {
      if(g_gameStatus == STATUS.START) {
         // TODO:过滤高延迟的包 (json.step)
         json.id = getAccount(socket.id)
         if(json.id) {
            g_commands.push(json)
         }
      }
   })

socket.on('timeSync', function(time) {...}) 收到后立即返回,用于客户端计算延迟,socket.on('message', function(json) {...}),收到客户端指令后将其放入全局缓存指令,等待每帧处理

   socket.on('disconnect', function () {
      var account = getAccount(socket.id)
      if(account) {
         g_onlines[account].online = false
         console.log(account, "离开游戏")
         var isGameOver = true
         for(var key in g_onlines) {
            if(g_onlines[key].online) {
               isGameOver = false
            }
         }
         if(isGameOver) {
            io.sockets.emit('system', "游戏结束")
            g_joinCount = 0
            g_stepTime = 0
            g_gameStatus = STATUS.WAIT
            g_onlines = {}
            console.log("游戏结束")
         } else {
            io.sockets.emit('system', account + "离开了游戏!")
         }
      }
   })

socket.on('disconnect', function () {...}) 断开连接是否要结束游戏的处理逻辑,如果在玩家断开后还有其他玩家在线,则游戏继续,等待玩家重连回来,否则有些结束

完整的游戏代码就这么多,还是比较清晰的,记住服务器上监听和发送的各种事件,比如 'join''start''message''disconnect' 等,一会再客户端代码分析的时候也会出现,对照着分析逻辑就串起来了

客户端main.js

客户端的代码行数相对多一些,我只把重要的部分列举出来:

// 游戏对象
var GameObject = function(id) {
   this.id = id
   this.x = 0
   this.y = 0
   this.direction = DIRECTION.STOP
   this.speed = 100
   this.move = function (dt) {
      dt = dt / 1000
      var x = this.x
      var y = this.y
      switch(this.direction) {
         case DIRECTION.UP:
         {
            y -= this.speed * dt
            break
         }
         case DIRECTION.DOWN:
         {
            y += this.speed * dt
            break
         }
         case DIRECTION.LEFT:
         {
            x -= this.speed * dt
            break
         }
         case DIRECTION.RIGHT:
         {
            x += this.speed * dt
            break
         }
      }
      if(x <= (WIDTH - BOX_SIZE) && x >= 0) {
         this.x = x
      }
      if(y <= (HEIGHT - BOX_SIZE) && y >= 0) {
         this.y = y
      }
   }
}

这段代码定义了一个函数,该函数可以用来创建游戏对象(Game Objects)的实例,在 JavaScript 中,函数也可以用来定义对象的构造函数。在这个例子中,函数 GameObject 就是一个构造函数,用于创建具有特定属性和方法的游戏对象。当你使用 new GameObject(id) 来调用这个函数时,它会返回一个新的对象实例,该实例拥有指定的 idxydirectionspeed 属性以及 move() 方法,尽管这段代码中的 GameObject 是一个函数,但它被设计用来创建具有特定属性和行为的对象。

$(function () {
   // 画布
   var context = document.getElementById("canvas").getContext("2d")
   // 每个step的间隔ms,服务器返回
   var stepInterval = 0
   // 当前step时间戳
   var stepTime = 0
   // 输入方向
   var inputDirection = null
   // 游戏状态
   var gameStatus = STATUS.WAIT
   // 接受指令
   var recvCommands = new Array()
   // 所有游戏对象
   var gameObjects = {}
   // 是否连接socket
   var isConnected = false
   // 当前执行中的指令
   var runningCommands = null
   // 当前用户
   var currentAccount = null
   // 是否正在加速运行延迟到达的包
   var isFastRunning = false

   // 初始化UI显示
   $("#content").hide()
   $("#login").show()
   $("#tips").hide()

   //...
})

这是利用jQuery库的写法,等同于 $(document).ready(function() {...}),表示在 DOM 树构建完成后执行指定的函数,也就是每次加载完页面都会执行这个函数,这个函数里包含了客户端绝大部分逻辑,函数的开始定义了一些变量,用于记录游戏数据,具体含义参考注释即可,之后便初始化了界面显示

   // 连接socket
   socket = io.connect('http://127.0.0.1:3000')

   // socket连接成功
   socket.on('open', function(json) {
      isConnected = true
      stepInterval = json.stepInterval
      id = json.id
      console.log("Socket连接成功:", id)
      // 断线重连自动登录
      if(localStorage.account) {
         setTimeout(function () {
            $("#account").val(localStorage.account)
            localStorage.account = ""
            $('#start_btn').click()
         }, 0)
      }
   })

使用 socket = io.connect('http://127.0.0.1:3000') 语句连接服务器,再接收到 'open' 事件之后,使用服务器同步数据给客户端变量赋值,函数末尾是根据本地存储情况决定是否需要重连,这部分逻辑可以先不看

   // 收到游戏开始事件
   socket.on('start',function(json) {
      // 初始化GameObject
      for(var i = 0; i < json.player.length; ++i) {
         var id = json.player[i]
         gameObjects[id] = new GameObject(id)
      }
      gameStatus = STATUS.START
      stepTime = 0
      showTips("游戏开始")
   })

   // 收到指令
   socket.on('message',function(json){
      // 储存收到的指令
      for(var i = 0; i < json.length; ++i) {
         var command = json[i]
         recvCommands.push(command)
         stepTime = command[command.length - 1].step
         console.log("**** recv " + stepTime + " ****")
      }
   })

收到游戏开始事件'start'之后,根据服务器下发的数据创建游戏对象 gameObjects[id] = new GameObject(id), 收到'message'缓存服务器发送的指令到 recvCommands 中,后续在tick函数中处理

   // 发送指令
   function sendCommand() {
      if(isFastRunning) {
         console.log("正在加速执行延迟包,无法发送指令!")
         return
      }
      var direction = inputDirection
      socket.emit("message", {
         direction: direction,
         step:stepTime,
      })
   }

   // 键盘事件
   $('body').keydown(function(e) {
      if(gameStatus != STATUS.START) return
      switch(e.keyCode) {
         case 38:
         {
            inputDirection = DIRECTION.UP
            break
         }
         case 40:
         {
            inputDirection = DIRECTION.DOWN
            break
         }
         case 37:
         {
            inputDirection = DIRECTION.LEFT
            break
         }
         case 39:
         {
            inputDirection = DIRECTION.RIGHT
            break
         }
         case 13:
         {
            inputDirection = DIRECTION.STOP
            break
         }
      }
      sendCommand()
   })

监听键盘按键,键盘上下左右键移动方块,回车键停止方块,每次将按键指令发送给服务器,重点看下这段实现,这就涉及到我们前边提到的牛顿第一定律,也就是每次按键时向服务器发送一次,可以改变物体的运动方向或者停止,之后便不再向服务器发消息了

   // frame定时器
   var stepUpdateCounter = 0
   function update(dt) {
      if(gameStatus == STATUS.START) {
         // TODO: 逻辑/UI分离
         stepUpdateCounter += dt
         if(stepUpdateCounter >= stepInterval) {
            stepUpdate()
            stepUpdateCounter -= stepInterval
         }

         // 积攒的包过多时要加速运行
         var scale = Math.ceil(recvCommands.length / 3)
         if(scale > 10) scale = 10
         isFastRunning = (scale > 1)
         // 执行指令
         if(recvCommands.length > 0) {
            var ms = dt * scale
            if(runningCommands == null) {
               runningCommands = recvCommands[0]
               runningCommands.ms = stepInterval
            }
            if(runningCommands.ms < ms) {
               ms = runningCommands.ms
            }
            for (var i = 0; i < runningCommands.length; i++) {
               var command = runningCommands[i]
               if(runningCommands.ms == stepInterval) console.log(command)
               var obj = gameObjects[command.id]
               if(command.direction) {
                  obj.direction = command.direction
               }
               obj.move(ms)
            }
            runningCommands.ms = runningCommands.ms - ms
            if(runningCommands.ms == 0) {
               recvCommands.shift()
               runningCommands = null
            }
         }

         // 绘制
         context.clearRect(0, 0, WIDTH, HEIGHT)
         for(var key in gameObjects) {
            var obj = gameObjects[key]
            context.fillStyle = "#000000"
            context.fillRect(obj.x, obj.y, BOX_SIZE, BOX_SIZE)
            context.font = "15px Courier New";
            context.fillStyle = "#FFFFFF";
            context.fillText(key, obj.x, obj.y + BOX_SIZE, BOX_SIZE);
         }
      }
   }

   // 启动定时器
   var lastUpdate = Date.now()
   setInterval(function() {
      var now = Date.now()
      var dt = now - lastUpdate
      lastUpdate = now
      update(dt)
      if(isConnected == true) {
         socket.emit("timeSync", now)
      }
   })

这段代码是客户端的核心逻辑,需要多看几遍,看懂了这一段,帧同步的思想也就基本算掌握了,调用 setInterval 的逻辑比较好理解,之前在服务器代码中也存在,就是启动tick函数,不断积累 dt,用于做客户端物体移动的表现

重点在 setInterval 我们化繁为简,不用看函数末尾的绘制部分,这一段就是根据GameObject的坐标绘制图形,因为这个项目没有实现UI表现和逻辑分离,所以函数开始更新变量 stepUpdateCounter的逻辑也没有用,简化完成后函数逻辑就剩下这些:

   function update(dt) {
      // 积攒的包过多时要加速运行
      var scale = Math.ceil(recvCommands.length / 3)
      if(scale > 10) scale = 10
      isFastRunning = (scale > 1)
      // 执行指令
      if(recvCommands.length > 0) {
         var ms = dt * scale
         if(runningCommands == null) {
            runningCommands = recvCommands[0]
            runningCommands.ms = stepInterval
         }
         if(runningCommands.ms < ms) {
            ms = runningCommands.ms
         }
         for (var i = 0; i < runningCommands.length; i++) {
            var command = runningCommands[i]
            if(runningCommands.ms == stepInterval) console.log(command)
            var obj = gameObjects[command.id]
            if(command.direction) {
               obj.direction = command.direction
            }
            obj.move(ms)
         }
         runningCommands.ms = runningCommands.ms - ms
         if(runningCommands.ms == 0) {
            recvCommands.shift()
            runningCommands = null
         }
      }
   }

注意这个参数 dt 很微妙,虽然传入的值是tick实际的时间间隔,但分析完代码你会发现这个dt传入任意值,因为真正调用obj.move函数进行移动出入的参数是 ms,当 ms 等于 dt 时,是正常播放,当 ms > dt 时,是加速播放,当 ms < dt 时是减速播放,这里的代码只存在加速和正常两种情况

看到没有,你可以通过 scale 变量人为的改变时间的快慢,是不是很神奇,所以在帧同步中绝对顺序是靠帧数来决定的,而物理时间只是一个数字,想快就快,想慢就慢

思考下为什么会有这个判断 if(runningCommands.ms < ms) { ms = runningCommands.ms }, 它的含义是无论你怎么加速,每个指令执行时间不能超过一帧的时间间隔,不然就和正常播放的逻辑数据不一致了

   if(command.direction) {
      obj.direction = command.direction
   }
   obj.move(ms)

这几句比较有意思,翻译过来就是如果命令我改变方向,那么我就改变方向后移动,否则我按照原来的方向移动或者保持静止,再想想是不是惯性定律?

   runningCommands.ms = runningCommands.ms - ms
   if(runningCommands.ms == 0) {
      recvCommands.shift()
      runningCommands = null
   }

最后这几句处理的是一个tick跑不完一帧时间间隔的情况,逐个tick改变物体坐标,其实就是一个指令运行一帧的分段表现

好了,写到这里基本上也讲完了,有什么疑问欢迎交流哈,我要睡觉去了

总结

  • 严格的帧同步,服务器必须等待所有客户端上报帧数才会下发当前帧命令,会造成一卡全卡
  • 乐观锁模式,服务器不等,会定时推帧,卡顿的人在收到数据后,自行加速补帧来追赶上正确的游戏速度
  • 一切物体总保持匀速直线运动状态或静止状态,直到有外力迫使它改变这种状态为止
  • 在帧同步中绝对顺序是靠帧数来决定的,而物理时间只是一个数字,想快就快,想慢就慢
  • 对于UDP丢包问题,上行采用重发3次,下行采用根据网络情况在2次到9次范围内调整

==>> 反爬链接,请勿点击,原地爆炸,概不负责!<<==

纵有千古,横有八荒,沧海一粟,还妄图超脱三界吗?

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

自动化立体库安全使用管理制度

导语 大家好&#xff0c;我是智能仓储物流技术研习社的社长&#xff0c;老K。专注分享智能仓储物流技术、智能制造等内容。 新书《智能物流系统构成与技术实践》 完整版文件和更多学习资料&#xff0c;请球友到知识星球 【智能仓储物流技术研习社】自行下载 关于自动化立体库安…

Visual Studio导入libtorch(Cuda版)

Visual Studio导入libtorch&#xff08;Cuda版&#xff09; 一、安装 官网&#xff1a;https://pytorch.org/get-started/locally/ 相应地选择并下载 二、环境变量配置 解压zip&#xff0c;得到libtorch文件夹&#xff0c;将libtorch\lib和libtorch\bin对应路径添加到系统环…

如何把视频中的画面保存为图片?免费的工具不用白不用

在数字化时代&#xff0c;截取视频中的珍贵瞬间成为了人们创作、分享和保存回忆的重要方式。 那么&#xff0c;如何迅速捕捉视频中的精彩画面&#xff0c;留存美好瞬间呢&#xff1f;有人说直接截图就可以&#xff0c;如果直接截图就可以&#xff0c;小编就不用写这篇文章了&a…

Redis 安装及配置教程(Windows)【安装】

文章目录 一、简介一、 下载1. GitHub 下载2. 其它渠道 二、 安装1. ZIP2. MSI 软件 / 环境安装及配置目录 一、简介 Redis 官网地址&#xff1a;https://redis.io/   Redis 源码地址&#xff1a;https://github.com/redis/redis   Redis 官网安装地址&#xff08;无Windo…

组合预测 | Matlab实现LSTM-XGBoost长短期记忆网络组合极限梯度提升树多输入单输出回归预测

组合预测 | Matlab实现LSTM-XGBoost长短期记忆网络组合极限梯度提升树多输入单输出回归预测 目录 组合预测 | Matlab实现LSTM-XGBoost长短期记忆网络组合极限梯度提升树多输入单输出回归预测效果一览基本描述模型描述代码实现参考资料效果一览 基本描述 组合预测 | Matlab实现L…

AIGC-stable-diffusion(文本生成图片)+PaddleHub/HuggingFace

功能 stable-diffusion(文本生成图片)PaddleHub&#xff0c;HuggingFace两种调用方式 PaddleHub 环境 pip install paddlepaddle-gpu pip install paddlehub 代码 from PIL import Image import paddlehub as hub module hub.Module(namestable_diffusion)## 保存在demo…

Golang基础4-type、go测试

type相关 别名&#xff1a;本质上是更好的理解代码&#xff0c;比如byte(uint8)、rune(int32) 定义新类型&#xff0c;那么就相当于时struct了 package mainimport ("fmt""strconv" )// XInt 别名,在编译的时候会直接替换int type XInt int// YInt 自定…

C/C++程序设计实验报告4 | 函数实验

本文整理自博主本科大一《C/C程序设计》专业课的课内实验报告&#xff0c;适合C语言初学者们学习、练习。 编译器&#xff1a;gcc 10.3.0 ---- 注&#xff1a; 1.虽然课程名为C程序设计&#xff0c;但实际上当时校内该课的内容大部分其实都是C语言&#xff0c;C的元素最多可能只…

Spring Boot 的文件配置

SpringBoot的配置文件,有三种格式 1.properties 2.yaml 3.yml(yaml的简写) 这里主要介绍1和3格式的。 在项目中,同时存在properties和yml配置文件, properties的优先级更高 同时存在时,两个文件都生效 如果两个文件中,都包含同一个配置,以properties为主。 properties的配置…

纸箱码垛机:从传统到智能,科技如何助力产业升级

随着科技的飞速发展&#xff0c;传统工业领域正经历着一场重要的变革。作为物流行业重要一环的纸箱码垛机&#xff0c;其从传统到智能的转型升级&#xff0c;不仅提高了生产效率&#xff0c;还大幅降低了人工成本&#xff0c;为产业升级提供了强大助力。星派将探讨纸箱码垛机的…

Python Web应用框架库之web2py使用详解

概要 Python Web2py是一个开源的Web应用框架,旨在简化Web开发过程并提供强大的功能。本文将深入探讨Web2py库的安装、特性、基本功能、高级功能、实际应用场景等方面。 安装 首先,需要安装Web2py库。 可以通过pip工具进行安装: pip install web2py安装完成后,就可以开始…

Node.JS安装及配置教程(Windows)【安装】

文章目录 一、 Node.JS 下载1. 官网下载&#xff08;1&#xff09;国内地址&#xff08;2&#xff09;国外地址 2. 其它渠道 二、 Node.JS 安装三、 Node.JS验证四、 Node.JS 配置&#xff08;可选&#xff09;1. 配置全局模块安装路径方法一方法二2. 配置国内镜像 五、 yarn 安…

《从零开始的Java世界》10File类与IO流

《从零开始的Java世界》系列主要讲解Javase部分&#xff0c;从最简单的程序设计到面向对象编程&#xff0c;再到异常处理、常用API的使用&#xff0c;最后到注解、反射&#xff0c;涵盖Java基础所需的所有知识点。学习者应该从学会如何使用&#xff0c;到知道其实现原理全方位式…

WPForms Pro插件下载:简化您的在线表单构建,提升用户互动

在当今的数字化世界中&#xff0c;表单是网站与用户互动的关键。无论是收集信息、处理订单还是进行调查&#xff0c;一个好的表单可以极大地提升用户体验和转化率。WPForms Pro插件&#xff0c;作为一款专业的WordPress表单构建工具&#xff0c;旨在帮助您轻松创建美观、功能强…

指针(5)

前言 本节是有关指针内容的最后一节&#xff0c;本节的内容以讲解指针习题为主&#xff0c;那么就让我们一起来开启本节的学习吧&#xff01; sizeof和strlen的对比 1.sizeof 我们在学习操作符的时候&#xff0c;学习了sizeof。sizeof存在的意义是用来计算变量所占用的内存空…

函数的查询

Oracle从入门到总裁:​​​​​​https://blog.csdn.net/weixin_67859959/article/details/135209645 在实际使用中经常会需要查询数据库中已有的函数或者某一个函数的内容&#xff0c;下面就介绍一下如何查询函数。 和存储过程类似&#xff0c;这也需要使用到数据字典user_s…

3月黄油奶酪行业数据分析:安佳和妙可蓝多领军市场

近些年来&#xff0c;随着新消费主义盛行&#xff0c;老少皆宜的黄油和奶酪逐渐成为都市年轻人的烘培“新宠”。 今年3月份&#xff0c;黄油奶酪表现的中规中矩&#xff0c;处在稳定发展阶段。根据鲸参谋数据显示&#xff0c;3月份&#xff0c;在线上综合电商平台&#xff08;…

【Java】全套云HIS源码包含EMR、LIS(多医院、卫生机构使用)

云HIS系统简介 SaaS模式Java版云HIS系统源码&#xff0c;在公立二甲医院应用三年&#xff0c;经过多年持续优化和打磨&#xff0c;系统运行稳定、功能齐全&#xff0c;界面布局合理、操作简便。 1、融合B/S版电子病历系统&#xff0c;支持电子病历四级&#xff0c;HIS与电子病…

RK3588S和ARM阵列服务器在虚拟化云平台的应用

RK3588是瑞芯微2021年底推出的首款高端8nm旗舰芯片&#xff0c;而RK3588S 则是针对消费端市场在RK3588基础上缩减了部分外围接口&#xff0c;CPU、GPU和NPU等主要参数得到了保留&#xff0c;主要应用范围为高端ARM平板、ARM笔电产品&#xff0c;会议平板类、ARM服务器、智能机器…

linux权限维持(二)

3.SSH 后门 3.1 SSH 软连接后门 软连接后门的原理是利用了 PAM 配置文件的作用&#xff0c;将 sshd 文件软连接名称设置为 su &#xff0c;这样应用在启动过 程中他会去PAM 配置文件夹中寻找是否存在对应名称的配置信息 (su) &#xff0c;然而 su 在 pam_rootok 只检测 uid…