[SpriteKit] 制作瓦片地图小游戏
概述
详细
说实话这个2D游戏实战的入门看的我脑浆子都沸腾了, 好多新的概念涌入, 没做过游戏开发的我表示真的难以接受, 吸收效率与之前相比也下降好多, 不过越往后学, 就能够加深对之前知识的掌握, 这可能也是看书的好处吧, 今天我也把对瓦片地图的一些学习经验记录下来供大家探讨.
说实话, 我很推荐Ray家的资源, 由浅入深手把手的教学, 内容前后呼应, 看几本书就能涵盖国内4个月培训班的课程体系. 遵循本系列一贯的风格, 我们还是从基础的API开始看起, 对API掌握熟练的话, 多敲两个Demo就能够基本的上手任何项目了.
一、瓦片地图技术要点
1、SKTileMapNode
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @available(iOS 10.0, *) open class SKTileMapNode : SKNode, NSCopying, NSCoding public init(tileSet: SKTileSet, columns: Int, rows: Int, tileSize: CGSize) open var numberOfColumns: Int open var numberOfRows: Int open var tileSize: CGSize open var mapSize: CGSize { get } open var tileSet: SKTileSet open var colorBlendFactor: CGFloat open func tileDefinition(atColumn column: Int, row: Int) -> SKTileDefinition? open func tileGroup(atColumn column: Int, row: Int) -> SKTileGroup? open func setTileGroup(_ tileGroup: SKTileGroup?, forColumn column: Int, row: Int) open func tileColumnIndex(fromPosition position: CGPoint) -> Int open func tileRowIndex(fromPosition position: CGPoint) -> Int open func centerOfTile(atColumn column: Int, row: Int) -> CGPoint |
-
init(tileSet: SKTileSet, columns: Int, rows: Int, tileSize: CGSize) 瓦片地图节点的初始化方法
-
numberOfColumns 瓦片地图的列数
-
numberOfRows 瓦片地图的行数
-
tileSize 瓦片地图中每个瓦片的尺寸
-
mapSize 瓦片地图的尺寸
-
tileSet 瓦片地图的瓦片集
-
colorBlendFactor 瓦片的渲染着色
-
tileDefinition(atColumn column: Int, row: Int) -> SKTileDefinition? 根据列数和行数返回瓦片定义
-
tileGroup(atColumn column: Int, row: Int) -> SKTileGroup? 根据列数和行数返回瓦片组
-
setTileGroup(_ tileGroup: SKTileGroup?, forColumn column: Int, row: Int) 根据列数和行数设置瓦片组
-
tileColumnIndex(fromPosition position: CGPoint) -> Int 根据瓦片位置返回瓦片在瓦片地图中列数下标
-
tileRowIndex(fromPosition position: CGPoint) -> Int 根据瓦片位置返回瓦片在瓦片地图中行数下标
2、SKTileSet
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @available(iOS 10.0, *) open class SKTileSet : NSObject, NSCopying, NSCoding public init(tileGroups: [SKTileGroup]) open var tileGroups: [SKTileGroup] open var name: String? open var defaultTileGroup: SKTileGroup? open var defaultTileSize: CGSize open var type: SKTileSetType @available(iOS 10.0, *) public enum SKTileSetType : UInt { case grid case isometric case hexagonalFlat case hexagonalPointy } |
-
init(tileGroups: [SKTileGroup]) 根据瓦片组初始化瓦片集
-
tileGroups 瓦片组
-
name 瓦片集的标识
-
defaultTileGroup 瓦片集默认瓦片组
-
defaultTileSize 瓦片集默认瓦片尺寸
-
type 瓦片集类型 - 网格, 等值, 六边形
3、SKTileGroup
1 2 3 4 5 6 | @available(iOS 10.0, *) open class SKTileGroup : NSObject, NSCopying, NSCoding open class func empty() -> Self public init(tileDefinition: SKTileDefinition) public init(rules: [SKTileGroupRule]) open var name: String? |
-
empty() 返回一个空的瓦片组
-
init(tileDefinition: SKTileDefinition) 根据瓦片定义初始化瓦片组
-
init(rules: [SKTileGroupRule]) 根据瓦片组规则初始化瓦片组
-
name 瓦片组的标识
4、SKTileGroupRule
1 2 3 4 5 6 | @available(iOS 10.0, *) open class SKTileGroupRule : NSObject, NSCopying, NSCoding public init(adjacency: SKTileAdjacencyMask, tileDefinitions: [SKTileDefinition]) open var adjacency: SKTileAdjacencyMask open var tileDefinitions: [SKTileDefinition] open var name: String? |
-
init(adjacency: SKTileAdjacencyMask, tileDefinitions: [SKTileDefinition]) 根据瓦片链接和瓦片定义初始化瓦片组规则
-
adjacency 瓦片链接
-
tileDefinitions 瓦片规则
-
name 瓦片组规则标识
5、SKTileDefinition
1 2 3 4 5 6 7 8 9 10 11 | @available(iOS 10.0, *) open class SKTileDefinition : NSObject, NSCopying, NSCoding public init(texture: SKTexture) public init(textures: [SKTexture], normalTextures: [SKTexture], size: CGSize, timePerFrame: CGFloat) open var userData: NSMutableDictionary? open var name: String? open var size: CGSize open var timePerFrame: CGFloat open var rotation: SKTileDefinitionRotation open var flipVertically: Bool open var flipHorizontally: Bool |
-
init(texture: SKTexture) 根据纹理初始化瓦片定义
-
init(textures: [SKTexture], normalTextures: [SKTexture], size: CGSize, timePerFrame: CGFloat) 根据纹理集合, 尺寸, 和帧率初始化瓦片定义
-
userData 瓦片定义的用户数据
-
name 瓦片定义的标识
-
timePerFrame 瓦片定义的帧率
-
rotation 瓦片定义的旋转规则
-
flipVertically 是否垂直翻转
-
flipHorizontally 是否水平翻转
二、程序实现
API, 了解一些基本的就够了, 如果要深究可以打开头文件逐个尝试, 我们现在就来实现一个小游戏, 这个游戏中包含了3个场景, 控制人物在规定时间内消灭所有的害虫, 我们着手进行游戏的开发吧!
1、step1 设置游戏场景的属性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | class GameScene: SKScene { var background: SKTileMapNode! //背景瓦片地图节点 var obstaclesTileMap: SKTileMapNode? //障碍物瓦片地图节点 var bugsprayTileMap: SKTileMapNode? //杀虫喷剂瓦片地图节点 var bugsNode = SKNode() //害虫的节点 var player = Player() //玩家的节点 var hud = HUD() //文字说明 var firebugCount: Int = 0 //高级害虫的节点数 var timeLimit: Int = 10 //时间限制 var elapsedTime: Int = 0 //经过时间 var startTime: Int? //开始时间 var currentLevel: Int = 1 //当前关卡等级 var gameState: GameState = .initial { //游戏状态默认为初始状态 didSet { hud.updateGameState(from: oldValue, to: gameState) //更新游戏状态 } } ... } |
2、step2 加载游戏场景的初始化设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) background = childNode(withName: "background" ) as! SKTileMapNode //通过节点名读取背景瓦片地图节点 obstaclesTileMap = childNode(withName: "obstacles" ) as? SKTileMapNode //通过节点名读取障碍物瓦片地图节点 if let timeLimit = userData?.object(forKey: "timeLimit" ) as? Int { self.timeLimit = timeLimit //通过节点的用户数据设置每个场景的时间限制 } // 1 let savedGameState = aDecoder.decodeInteger(forKey: "Scene.gameState" ) //解档保存游戏状态 if let gameState = GameState(rawValue: savedGameState), gameState == .pause { //当解档保存游戏状态为暂停时 self.gameState = gameState //赋值游戏状态 firebugCount = aDecoder.decodeInteger( forKey: "Scene.firebugCount" ) //解档高级害虫数 elapsedTime = aDecoder.decodeInteger( forKey: "Scene.elapsedTime" ) //解档经过时间 currentLevel = aDecoder.decodeInteger( forKey: "Scene.currentLevel" ) //解档当前关卡等级 // 2 player = childNode(withName: "Player" ) as! Player //根据节点名读取玩家节点 hud = camera!.childNode(withName: "HUD" ) as! HUD //根据节点名读取文字说明 bugsNode = childNode(withName: "Bugs" )! //根据节点名读取害虫节点 bugsprayTileMap = childNode(withName: "Bugspray" ) as? SKTileMapNode //通过节点名读取杀虫喷雾瓦片地图节点 } addObservers() //添加观察者 } deinit { NotificationCenter. default .removeObserver(self) //移除观察者 } |
3、step3 当场景移动到屏幕时的设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | override func didMove(to view: SKView) { if gameState == .initial { //当游戏状态为初始状态时 addChild(player) //添加玩家到场景 setupWorldPhysics() //添加物理世界 createBugs() //添加害虫 setupObstaclePhysics() //添加障碍物 if firebugCount > 0 { //如果有高级害虫 createBugspray(quantity: firebugCount + 10) //添加杀虫喷雾 } setupHUD() //添加文字说明 gameState = .start //设置游戏状态为开始状态 } setupCamera() //添加摄像头 } |
4、step4 物理世界的设置
1 2 3 4 5 6 7 8 | func setupWorldPhysics() { background.physicsBody = SKPhysicsBody(edgeLoopFrom: background.frame) //设置边缘物理体 background.physicsBody?.categoryBitMask = PhysicsCategory.Edge //设置物理体标识为边缘 physicsWorld.contactDelegate = self //物理世界代理 } |
5、step5 创建害虫的设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | func createBugs() { guard let bugsMap = childNode(withName: "bugs" ) as? SKTileMapNode else { return } //校验害虫瓦片地图节点 // 1 for row in 0..<bugsMap.numberOfRows { //逐行遍历害虫瓦片地图 for column in 0..<bugsMap.numberOfColumns { //逐列遍历害虫瓦片地图 // 2 guard let tile = tile(in: bugsMap, at: (column, row)) else { continue } //校验瓦片地图中的每个瓦片 // 3 let bug: Bug if tile.userData?.object(forKey: "firebug" ) != nil { //从用户数据中判断是否为高级害虫 bug = Firebug() //将害虫设置为高级害虫 firebugCount += 1 //高级害虫书自增 } else { bug = Bug() //将害虫设置为普通害虫 } bug.position = bugsMap.centerOfTile(atColumn: column, row: row) //从害虫瓦片地图中读取位置并赋值 bugsNode.addChild(bug) //添加节点 bug.moveBug() //移动害虫 } } // 4 bugsNode.name = "Bugs" //设置害虫节点标识 addChild(bugsNode) //添加父节点到场景 // 5 bugsMap.removeFromParent() //删除害虫瓦片地图地图节点 } |
6、step6 添加障碍物的设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | func setupObstaclePhysics() { guard let obstaclesTileMap = obstaclesTileMap else { return } //校验障碍物瓦片地图节点 // 1 for row in 0..<obstaclesTileMap.numberOfRows { for column in 0..<obstaclesTileMap.numberOfColumns { // 2 guard let tile = tile(in: obstaclesTileMap, at: (column, row)) else { continue } guard tile.userData?.object(forKey: "obstacle" ) != nil else { continue } // 3 let node = SKNode() //创建节点 node.physicsBody = SKPhysicsBody(rectangleOf: tile.size) 根据瓦片尺寸创建物理体 node.physicsBody?.isDynamic = false //不进入物理世界 node.physicsBody?.friction = 0 //摩擦系数为0 node.physicsBody?.categoryBitMask = PhysicsCategory.Breakable //设置物理体标识 node.position = obstaclesTileMap.centerOfTile( atColumn: column, row: row) obstaclesTileMap.addChild(node) } } } |
7、step7 添加杀虫喷雾的设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | func createBugspray(quantity: Int) { // 1 let tile = SKTileDefinition(texture: SKTexture(pixelImageNamed: "bugspray" )) //创建瓦片定义 // 2 let tilerule = SKTileGroupRule(adjacency: SKTileAdjacencyMask.adjacencyAll, tileDefinitions: [tile]) //创建瓦片组规则 // 3 let tilegroup = SKTileGroup(rules: [tilerule]) //创建瓦片组 // 4 let tileSet = SKTileSet(tileGroups: [tilegroup]) //创建瓦片集 // 5 let columns = background.numberOfColumns //读取背景瓦片地图节点的列数 let rows = background.numberOfRows //读取背景瓦片地图节点的行数 bugsprayTileMap = SKTileMapNode(tileSet: tileSet, columns: columns, rows: rows, tileSize: tile.size) //创建新的瓦片地图节点 // 6 for _ in 1...quantity { let column = Int.random(min: 0, max: columns-1) //随机列数 let row = Int.random(min: 0, max: rows-1) //随机行数 bugsprayTileMap?.setTileGroup(tilegroup, forColumn: column, row: row) //在新额的瓦片地图节点上随机生成瓦片组 } // 7 bugsprayTileMap?.name = "Bugspray" //设置瓦片地图节点的标识 addChild(bugsprayTileMap!) //将瓦片地图添加到场景 } |
8、step8 添加摄像头设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | func setupCamera() { guard let camera = camera, let view = view else { return } let zeroDistance = SKRange(constantValue: 0) let playerConstraint = SKConstraint.distance(zeroDistance, to: player) //对玩家进行约束 // 1 let xInset = min(view.bounds.width/2 * camera.xScale, background.frame.width/2) let yInset = min(view.bounds.height/2 * camera.yScale, background.frame.height/2) // 2 let constraintRect = background.frame.insetBy(dx: xInset, dy: yInset) // 3 let xRange = SKRange(lowerLimit: constraintRect.minX, upperLimit: constraintRect.maxX) let yRange = SKRange(lowerLimit: constraintRect.minY, upperLimit: constraintRect.maxY) let edgeConstraint = SKConstraint.positionX(xRange, y: yRange) edgeConstraint.referenceNode = background // 4 camera.constraints = [playerConstraint, edgeConstraint] } |
9、step9 获取瓦片的一些帮助方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | func tile(in tileMap: SKTileMapNode, at coordinates: TileCoordinates) -> SKTileDefinition? { return tileMap.tileDefinition(atColumn: coordinates.column, row: coordinates.row) } func tileCoordinates(in tileMap: SKTileMapNode, at position: CGPoint) -> TileCoordinates { let column = tileMap.tileColumnIndex(fromPosition: position) let row = tileMap.tileRowIndex(fromPosition: position) return (column, row) } func tileGroupForName(tileSet: SKTileSet, name: String) -> SKTileGroup? { let tileGroup = tileSet.tileGroups .filter { $0.name == name }.first return tileGroup } |
10、step10 点击场景的设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = touches.first else { return } switch gameState { // 1 case .start: //开始状态 gameState = .play //切换成游戏状态 isPaused = false //开始 startTime = nil elapsedTime = 0 // 2 case .play: //游戏状态 player.move(target: touch.location(in: self)) //移动玩家 case .win: //获胜状态 transitionToScene(level: currentLevel + 1) //切换场景 case .lose: //落败状态 transitionToScene(level: 1) //切换场景 case .reload: //唤醒状态 // 1 if let touchedNode = atPoint(touch.location(in: self)) as? SKLabelNode { // 2 if touchedNode.name == HUDMessages.yes { //如果点击的节点是YES isPaused = false startTime = nil gameState = .play // 3 } else if touchedNode.name == HUDMessages.no { //如果点击的节点是NO transitionToScene(level: 1) } } default : break } } |
11、step11 切换场景的设置
1 2 3 4 5 6 7 8 9 10 11 | func transitionToScene(level: Int) { // 1 guard let newScene = SKScene(fileNamed: "Level\(level)" ) as? GameScene else { fatalError( "Level: \(level) not found" ) } // 2 newScene.currentLevel = level view!.presentScene(newScene, transition: SKTransition.flipVertical(withDuration: 0.5)) } |
12、step12 刷帧
1 2 3 4 5 6 7 8 9 10 11 12 13 | override func update(_ currentTime: TimeInterval) { if gameState != .play { isPaused = true //如果不是游戏状态就暂停刷帧 return } if !player.hasBugspray { updateBugspray() //如果玩家没有杀虫喷雾, 就进行更新 } advanceBreakableTile(locatedAt: player.position) //更新障碍物的物理体状态 updateHUD(currentTime: currentTime) //更新文字说明 checkEndGame() //检查是否达到胜负条件 } |
13、step13 更新杀虫喷雾
1 2 3 4 5 6 7 8 9 10 | func updateBugspray() { guard let bugsprayTileMap = bugsprayTileMap else { return } let (column, row) = tileCoordinates(in: bugsprayTileMap, at: player.position) if tile(in: bugsprayTileMap, at: (column, row)) != nil { bugsprayTileMap.setTileGroup(nil, forColumn: column, row: row) player.hasBugspray = true } } |
14、step14 更新障碍物的物理体状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | func advanceBreakableTile(locatedAt nodePosition: CGPoint) { guard let obstaclesTileMap = obstaclesTileMap else { return } // 1 let (column, row) = tileCoordinates(in: obstaclesTileMap, at: nodePosition) // 2 let obstacle = tile(in: obstaclesTileMap, at: (column, row)) //3 guard let nextTileGroupName = obstacle?.userData?.object(forKey: "breakable" ) as? String else { return } // 4 if let nextTileGroup = tileGroupForName(tileSet: obstaclesTileMap.tileSet, name: nextTileGroupName) { obstaclesTileMap.setTileGroup(nextTileGroup, forColumn: column, row: row) //设置新的瓦片组到瓦片地图中 } } |
15、step15 更新文字说明
1 2 3 4 5 6 7 8 9 10 11 12 | func updateHUD(currentTime: TimeInterval) { // 1 if let startTime = startTime { // 2 elapsedTime = Int(currentTime) - startTime } else { // 3 startTime = Int(currentTime) - elapsedTime } // 4 hud.updateTimer( time : timeLimit - elapsedTime) //对文字说明进行更新 } |
16、step16 检查是否达到胜负条件
1 2 3 4 5 6 7 8 9 | func checkEndGame() { if bugsNode.children.count == 0 { //是否消灭全部害虫 player.physicsBody?.linearDamping = 1 gameState = .win } else if timeLimit - elapsedTime <= 0 { //是否时间用完 player.physicsBody?.linearDamping = 1 gameState = .lose } } |
17、step17 物理世界代理的设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | extension GameScene : SKPhysicsContactDelegate { func remove (bug: Bug) { //消灭害虫 bug.removeFromParent() background.addChild(bug) bug.die() hud.updateBugCount(with: bugsNode.children.count) } func didBegin(_ contact: SKPhysicsContact) { let other = contact.bodyA.categoryBitMask == PhysicsCategory.Player ? contact.bodyB : contact.bodyA switch other.categoryBitMask { case PhysicsCategory.Bug: if let bug = other.node as? Bug { remove (bug: bug) //当玩家接触到普通害虫, 消灭普通害虫 } case PhysicsCategory.Firebug: if player.hasBugspray { if let firebug = other.node as? Firebug { remove (bug: firebug) player.hasBugspray = false //当玩家手持杀虫喷雾接触高级害虫才能消灭高级害虫 } } case PhysicsCategory.Breakable: if let obstacleNode = other.node { // 1 advanceBreakableTile(locatedAt: obstacleNode.position) //更新障碍物 // 2 obstacleNode.removeFromParent() //删除原障碍物 } default : break } if let physicsBody = player.physicsBody { if physicsBody.velocity.length() > 0 { player.checkDirection() //进行玩家方向的设置 } } } } |
18、step18 观察者的设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | extension GameScene { func applicationDidBecomeActive() { print( "* applicationDidBecomeActive" ) if gameState == .pause { gameState = .reload //重新进入, 进行游戏重载 } } func applicationWillResignActive() { print( "* applicationWillResignActive" ) isPaused = true if gameState != .lose { gameState = .pause //暂停游戏进程 } } func applicationDidEnterBackground() { print( "* applicationDidEnterBackground" ) if gameState != .lose { saveGame() //进入后台保存游戏进度 } } func addObservers() { NotificationCenter. default .addObserver(self, selector: #selector(applicationDidBecomeActive), name: .UIApplicationDidBecomeActive, object: nil) NotificationCenter. default .addObserver(self, selector: #selector(applicationWillResignActive), name: .UIApplicationWillResignActive, object: nil) NotificationCenter. default .addObserver(self, selector: #selector(applicationDidEnterBackground), name: .UIApplicationDidEnterBackground, object: nil) } } |
19、step19 游戏的存储设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | extension GameScene { func saveGame() { // 1 let fileManager = FileManager. default guard let directory = fileManager.urls( for : .libraryDirectory, in: .userDomainMask).first else { return } // 2 let saveURL = directory.appendingPathComponent( "SavedGames" ) // 3 do { try fileManager.createDirectory(atPath: saveURL.path, withIntermediateDirectories: true , attributes: nil) } catch let error as NSError { fatalError( "Failed to create directory: \(error.debugDescription)" ) } // 4 let fileURL = saveURL.appendingPathComponent( "saved-game" ) print( "* Saving: \(fileURL.path)" ) // 5 NSKeyedArchiver.archiveRootObject(self, toFile: fileURL.path) //文件处理器新建路径并归档 } override func encode(with aCoder: NSCoder) { /对关键属性的归档 aCoder.encode(firebugCount, forKey: "Scene.firebugCount" ) aCoder.encode(elapsedTime, forKey: "Scene.elapsedTime" ) aCoder.encode(gameState.rawValue, forKey: "Scene.gameState" ) aCoder.encode(currentLevel, forKey: "Scene.currentLevel" ) super.encode(with: aCoder) } class func loadGame() -> SKScene? { //重新加载存储游戏进程 print( "* loading game" ) var scene: SKScene? // 1 let fileManager = FileManager. default guard let directory = fileManager.urls( for : .libraryDirectory, in: .userDomainMask).first else { return nil } // 2 let url = directory.appendingPathComponent( "SavedGames/saved-game" ) // 3 if FileManager. default .fileExists(atPath: url.path) { scene = NSKeyedUnarchiver.unarchiveObject( //根据路径进行解档游戏进程 withFile: url.path) as? GameScene _ = try ? fileManager.removeItem(at: url) } return scene } } |
三、运行效果与文件截图
1、运行效果

2、文件截图
PestControl文件里的截图:
PestControl.xcodeproj文件里的截图:
四、其他补充
Notice: 忽略了一些节点的设置, 但不影响瓦片地图的理解.
【推荐】还在用 ECharts 开发大屏?试试这款永久免费的开源 BI 工具!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 软件产品开发中常见的10个问题及处理方法
· .NET 原生驾驭 AI 新基建实战系列:向量数据库的应用与畅想
· 从问题排查到源码分析:ActiveMQ消费端频繁日志刷屏的秘密
· 一次Java后端服务间歇性响应慢的问题排查记录
· dotnet 源代码生成器分析器入门
· ThreeJs-16智慧城市项目(重磅以及未来发展ai)
· 软件产品开发中常见的10个问题及处理方法
· Vite CVE-2025-30208 安全漏洞
· 互联网不景气了那就玩玩嵌入式吧,用纯.NET开发并制作一个智能桌面机器人(四):结合BotSharp
· MQ 如何保证数据一致性?