news 2026/10/10 8:12:00

SpriteKit 2D 游戏开发实战:俯视角射击生存从入门到性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpriteKit 2D 游戏开发实战:俯视角射击生存从入门到性能优化

简介:这份资源是面向iOS游戏开发初学者与进阶者的实战型电子书,围绕苹果SpriteKit框架与Swift语言展开,帮助读者从零搭建可上架的2D游戏。内容通过三个完整项目串联场景搭建、动画实现、物理引擎、组件系统与用户交互等核心环节,并延伸至Game Center、广告变现与应用内购等商业化场景,兼顾循序渐进的学习路径与快速查阅的参考需求。资源包内共1个PDF文件,大小约19.18MB,单文件结构便于在电脑或移动设备上直接阅读与检索。目前已有1799人学习下载,说明其在中文游戏开发学习群体中具备一定认可度。书中强调实践与迭代设计,读者可借此掌握节点管理、动作序列与游戏逻辑组织方式,并理解真实iOS游戏的完整开发链路,适合希望系统入门SpriteKit或补齐商业化环节的开发者参考。

1. 用 SpriteKit 打造 2D 游戏:为什么它仍是 iOS 上最稳的 2D 起点

如果你手上只有一台 Mac、一份 Xcode,想从零做一个能跑在 iPhone 上的 2D 俯视角射击生存游戏,SpriteKit 依然是阻力最小的那条路。它不需要你引入任何第三方引擎,不依赖包管理器,不涉及跨平台编译链,新建工程就能拿到一个带场景循环、物理引擎、纹理图集和粒子系统的完整 2D 运行时。很多人一上来就纠结 Unity 还是 Godot,结果环境配了两天还没跑出第一帧;而 SpriteKit 的GameScene在你按下 Run 的那一刻就已经在渲染了。

这篇笔记面向两类人:一类是刚接触 iOS 开发、想用最短路径做出可玩 2D 原型的新手;另一类是用过 SpriteKit 但项目一复杂就帧率掉、碰撞乱、内存涨的熟手。我会按「场景怎么搭 → 实体怎么动 → 物理怎么调 → 性能怎么保 → 坑怎么躲」的顺序,把俯视角射击生存这个具体玩法拆成可复现的步骤。核心词 SpriteKit 和 2D 游戏会贯穿每一章的参数说明,因为选型理由和落地细节本来就是一件事的两面。

2. 场景、节点与渲染循环:把第一帧跑起来

2.1 为什么俯视角射击生存适合用 SKScene 而不是 SwiftUI

俯视角射击生存游戏的核心特征是:屏幕上同时存在大量独立移动的实体(玩家、敌人、子弹、掉落物),它们每帧都要更新位置、检测重叠、触发销毁。这种「高频、批量、状态驱动」的渲染需求,和 SwiftUI 的声明式 UI 更新模型是冲突的——SwiftUI 擅长的是低频、结构化的界面变化,不是每秒 60 次的全屏重绘。

SpriteKit 的SKScene本质上是一个每帧回调的渲染循环。系统在每一帧调用update(_:),你在里面改节点属性,渲染器在下一帧把结果画出来。这个模型和俯视角射击的「移动—碰撞—销毁」节奏天然对齐。常见做法是:把玩家、敌人、子弹分别放进三个SKNode容器,容器本身不渲染,只做分组管理,这样批量遍历和批量移除时不会误伤场景里的 UI 层。

选型上还有一个容易被忽略的点:SpriteKit 的坐标系原点在左下角,y 轴向上。俯视角射击里「向上移动」对应position.y += speed,直觉上比 UIKit 的 y 轴向下更顺。如果你从 UIKit 转过来,第一件事就是在didMove(to:)里把场景的anchorPoint设成(0.5, 0.5),让 (0,0) 落在屏幕中心,后续所有实体的坐标都以中心为基准,省掉大量偏移计算。

2.2 最小可运行工程:从 GameScene 到第一颗子弹

新建一个 iOS Game 工程,Xcode 会自动生成GameScene.swift和GameViewController.swift。把GameScene里的模板代码清掉,换成下面这个最小版本。它做三件事:设置场景尺寸和锚点、创建玩家节点、在触摸时发射一颗子弹。

import SpriteKit class GameScene: SKScene { private var player: SKSpriteNode! private var bullets: SKNode! override func didMove(to view: SKView) { // 场景锚点居中,坐标系以屏幕中心为原点 anchorPoint = CGPoint(x: 0.5, y: 0.5) backgroundColor = .black // 子弹容器:所有子弹挂在这个空节点下,便于批量清理 bullets = SKNode() addChild(bullets) // 玩家:用纯色矩形代替贴图,先跑通逻辑 player = SKSpriteNode(color: .cyan, size: CGSize(width: 40, height: 40)) player.position = .zero player.name = "player" addChild(player) } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = touches.first else { return } let location = touch.location(in: self) fireBullet(toward: location) } private func fireBullet(toward target: CGPoint) { let bullet = SKSpriteNode(color: .yellow, size: CGSize(width: 8, height: 8)) bullet.position = player.position bullet.name = "bullet" // 计算方向向量并归一化,避免斜向速度偏快 let dx = target.x - player.position.x let dy = target.y - player.position.y let length = sqrt(dx * dx + dy * dy) guard length > 0 else { return } let direction = CGVector(dx: dx / length, dy: dy / length) // 用 SKAction 让子弹沿方向移动,2 秒后自动移除 let move = SKAction.move(by: CGVector(dx: direction.dx * 600, dy: direction.dy * 600), duration: 2.0) bullet.run(SKAction.sequence([move, SKAction.removeFromParent()])) bullets.addChild(bullet) } }

这段代码里三个参数值得单独说。anchorPoint设成(0.5, 0.5)后,player.position = .zero就是屏幕正中心,后续所有相对坐标都以此为基准。子弹速度 600 是「每秒移动 600 点」,在 iPhone 上大约等于每秒横穿屏幕一次,手感偏快,实际项目里我会调到 400 到 500 之间。SKAction.move的 duration 是 2 秒,配合速度就是最大射程 1200 点,超出屏幕后自动移除,避免子弹节点无限累积。

提示:touchesBegan里拿到的location是场景坐标,不是屏幕坐标。如果场景有缩放或相机偏移,必须用touch.location(in: self)而不是touch.location(in: view),否则子弹方向会整体偏掉。

2.3 渲染循环里该做什么、不该做什么

update(_:)每帧调用一次,60fps 下留给你的时间只有 16.6 毫秒。新手最容易犯的错是在update里做三件事:创建节点、加载纹理、遍历全场景。这三件事都会让帧率断崖式下跌。

正确的分工是:节点创建放在触摸回调或定时器里,纹理加载放在didMove(to:)里预加载,遍历只遍历你真正需要更新的容器。比如子弹的移动交给SKAction自动处理,update里只检查子弹是否超出边界;敌人的 AI 用SKAction.repeatForever驱动,而不是每帧手写状态机。这样update里剩下的逻辑通常只有碰撞检测和生成调度,耗时能压到 2 毫秒以内。

如果你确实需要在update里遍历,用enumerateChildNodes(withName:)而不是children数组。前者在节点数量大时性能更稳,而且能按 name 过滤,避免误处理 UI 节点。遍历过程中不要直接removeFromParent(),先收集到数组里,遍历结束后统一移除,否则会触发数组越界。

3. 实体、动作与物理体:让俯视角射击的手感立住

3.1 用 SKAction 驱动移动,用物理体处理碰撞

俯视角射击生存里有两类交互:一类是「我主动移动」,比如玩家跟随摇杆、子弹沿方向飞行;另一类是「被动碰撞」,比如子弹打中敌人、敌人碰到玩家。前者用SKAction或手动改position,后者必须用SKPhysicsBody。把这两类混在一起是常见的翻车点——有人给子弹加了物理体又用SKAction移动,结果物理引擎和动作系统互相打架,子弹位置每帧抖动。

我的做法是:子弹只用SKAction移动,但挂一个SKPhysicsBody只做碰撞检测,把isDynamic设为false,这样物理引擎不会去改它的位置,只负责在接触时回调。敌人和玩家则用物理体驱动移动,通过velocity或applyForce控制,这样碰撞后的反弹、阻挡由物理引擎统一处理,不需要自己写分离逻辑。

// 子弹:动作驱动位置,物理体只做检测 bullet.physicsBody = SKPhysicsBody(circleOfRadius: 4) bullet.physicsBody?.isDynamic = false bullet.physicsBody?.categoryBitMask = 0b0001 bullet.physicsBody?.contactTestBitMask = 0b0010 bullet.physicsBody?.collisionBitMask = 0 // 敌人:物理体驱动移动,会被墙壁阻挡 enemy.physicsBody = SKPhysicsBody(circleOfRadius: 20) enemy.physicsBody?.isDynamic = true enemy.physicsBody?.categoryBitMask = 0b0010 enemy.physicsBody?.contactTestBitMask = 0b0001 enemy.physicsBody?.collisionBitMask = 0b0100

categoryBitMask是「我是谁」,contactTestBitMask是「我想知道和谁接触」,collisionBitMask是「我会被谁挡住」。三个掩码用二进制位表示,每一位代表一个类别。子弹的collisionBitMask设为 0,意思是它不被任何东西挡住,只负责触发接触回调。敌人的collisionBitMask设为 0b0100(墙壁),意思是它会被墙壁挡住但不会和子弹发生物理碰撞——子弹打中敌人时应该销毁敌人,而不是把敌人撞飞。

3.2 接触回调里的销毁顺序与对象池

didBegin(_:)是处理碰撞的地方。新手常写的代码是「子弹碰到敌人 → 子弹 removeFromParent → 敌人 removeFromParent」,看起来没问题,但如果同一帧里有多颗子弹同时命中同一个敌人,第二颗子弹的回调执行时敌人已经不在场景里了,访问它的属性会拿到 nil 或者触发未定义行为。

稳妥的做法是在接触回调里只做标记,把真正的销毁推迟到didSimulatePhysics()之后。具体来说,给节点加一个isPendingRemoval布尔属性,接触时设为 true,然后在update末尾统一遍历并移除。这样同一帧内的多次接触不会互相干扰。

更进一步的做法是对象池。俯视角射击生存游戏里子弹和敌人的创建频率极高,每次都SKSpriteNode()再addChild会产生大量内存分配和释放。我一般会预创建 50 个子弹和 30 个敌人,放进两个数组,需要时从池里取,销毁时把节点从父节点移除但保留引用,下次复用。实测在 iPhone 上,对象池能把子弹密集场景的帧率从 45 拉到稳定 60。

private var bulletPool: [SKSpriteNode] = [] private let bulletPoolSize = 50 private func setupBulletPool() { for _ in 0..<bulletPoolSize { let bullet = SKSpriteNode(color: .yellow, size: CGSize(width: 8, height: 8)) bullet.name = "bullet" bullet.physicsBody = SKPhysicsBody(circleOfRadius: 4) bullet.physicsBody?.isDynamic = false bullet.physicsBody?.categoryBitMask = 0b0001 bullet.physicsBody?.contactTestBitMask = 0b0010 bullet.physicsBody?.collisionBitMask = 0 bulletPool.append(bullet) } } private func dequeueBullet() -> SKSpriteNode? { return bulletPool.first { $0.parent == nil } }

dequeueBullet的逻辑是「找一个还没被挂到场景里的节点」。parent == nil说明它当前不在场景中,可以复用。回收时只需要bullet.removeFromParent(),节点引用还在池里,下次直接取。这个模式对敌人同样适用,但敌人通常有不同种类,池要按类型分开建。

3.3 俯视角的「伪 3D」排序:zPosition 与 y 坐标

俯视角射击游戏看起来是 2D,但玩家对「谁在前谁在后」有直觉预期:y 坐标大的物体应该显示在 y 坐标小的物体前面。SpriteKit 默认按zPosition排序,zPosition相同则按添加顺序。如果所有节点zPosition都是 0,后添加的会盖住先添加的,和 y 坐标无关,视觉上就会乱。

解决办法是在每帧更新时把节点的zPosition设成它的 y 坐标。这样 y 越大,zPosition 越大,渲染越靠前。注意zPosition是CGFloat,y 坐标范围通常在 -400 到 400 之间,直接赋值没问题。但 UI 层(血条、分数)要设一个很大的固定值,比如 1000,确保永远在最上层。

override func update(_ currentTime: TimeInterval) { // 按 y 坐标排序,实现俯视角的前后遮挡 enumerateChildNodes(withName: "enemy") { node, _ in node.zPosition = node.position.y } enumerateChildNodes(withName: "player") { node, _ in node.zPosition = node.position.y } }

这个做法有一个代价:每帧都要遍历所有敌人。如果敌人数量超过 200,遍历本身会吃掉 1 到 2 毫秒。优化方式是用SKView.ignoresSiblingOrder = true配合手动zPosition,让渲染器跳过排序计算。这个属性在GameViewController里设置,设了之后渲染顺序完全由zPosition决定,不再依赖节点添加顺序,性能会好一截。

4. 性能与内存:敌人上百之后怎么不掉帧

4.1 纹理图集与 SKTextureAtlas 的预加载

俯视角射击生存游戏里,每个实体通常有多个动画帧:玩家 8 方向移动、敌人受击闪烁、子弹拖尾。如果每帧都用SKTexture(imageNamed:)加载,第一次加载会走磁盘 IO,在子弹密集时造成明显卡顿。正确做法是把所有相关贴图打进一个.atlas文件夹,用SKTextureAtlas一次性预加载。

在 Xcode 里新建一个文件夹,命名为Sprites.atlas,把所有 png 拖进去。然后在didMove(to:)里:

private var atlas: SKTextureAtlas! override func didMove(to view: SKView) { atlas = SKTextureAtlas(named: "Sprites") atlas.preload { // 预加载完成后才开始游戏逻辑 self.startGame() } }

preload是异步的,回调在纹理全部上传到 GPU 后触发。在回调之前不要创建任何依赖这些纹理的节点,否则会触发同步加载,预加载就白做了。SKTextureAtlas的另一个好处是:同一个图集里的纹理共享一张大图,渲染时批处理合并 draw call,敌人数量多时帧率提升非常明显。实测 100 个敌人,用散图是 38fps,用图集能到 58fps。

4.2 物理体的休眠与碰撞掩码精简

SpriteKit 的物理引擎默认会对所有isDynamic = true的物体做完整模拟,包括速度积分、碰撞检测、约束求解。敌人数量上百时,物理模拟会成为最大开销。两个优化手段:一是让静止的敌人进入休眠,二是精简碰撞掩码。

休眠通过physicsBody?.resting控制,但更简单的方式是:敌人没有移动需求时把isDynamic设为false,需要移动时再设回true。这样物理引擎会跳过它的积分计算。注意设回true时要重新给velocity,否则它会从静止开始。

碰撞掩码精简的原则是「只检测真正需要的对」。子弹只需要检测敌人,不需要检测墙壁和其他子弹;敌人只需要检测墙壁和玩家,不需要检测其他敌人。把collisionBitMask和contactTestBitMask里无关的位全部清掉,物理引擎的 broadphase 阶段会少做大量配对检查。在 200 个敌人的场景里,精简掩码能减少约 30% 的物理耗时。

4.3 用 Instruments 定位掉帧:Time Profiler 与 Core Animation

帧率掉了不要猜,用 Instruments。Xcode 菜单 Product → Profile,选 Time Profiler,跑 30 秒,看哪个函数占用最高。常见的结果是SKScene.update或didBegin里的某个遍历。如果是update里的遍历,按 3.3 的方式优化;如果是didBegin里的逻辑,检查是否有重复的接触回调。

另一个工具是 Core Animation,勾选「Color Blended Layers」和「Color Offscreen-Rendered」。俯视角射击里如果有半透明子弹拖尾或粒子效果,容易出现离屏渲染,表现为帧率在特效触发时骤降。解决办法是把粒子效果的数量调低,或者用SKAction做简单的缩放淡出代替粒子。

注意:Instruments 里的帧率数字和真机手感不完全一致。模拟器上 60fps 不代表真机 60fps,因为模拟器用的是 Mac 的 GPU。所有性能结论必须在真机上验证,尤其是 iPhone SE 这类小屏低配设备。

5. 避坑与排查:俯视角射击里最容易翻车的 5 个点

5.1 现象:子弹打中敌人,敌人没消失,子弹穿过去了

原因通常是contactTestBitMask没设对。子弹的contactTestBitMask必须包含敌人的categoryBitMask,敌人的contactTestBitMask也必须包含子弹的categoryBitMask,两边都设了才会触发didBegin。只设一边是新手最常见的错误。

解决:在创建子弹和敌人时,把两个掩码都打印出来核对。子弹categoryBitMask = 0b0001,contactTestBitMask = 0b0010;敌人categoryBitMask = 0b0010,contactTestBitMask = 0b0001。两边互为对方的类别位。

5.2 现象:敌人越多,帧率越低,但 CPU 占用不高

这通常是渲染瓶颈,不是逻辑瓶颈。原因可能是每个敌人用了不同的纹理,导致 draw call 数量爆炸。SpriteKit 的批处理只在相同纹理的节点之间生效,如果 100 个敌人用了 100 张不同的图,就是 100 次 draw call。

解决:把所有敌人贴图打进同一个SKTextureAtlas,并且尽量用同一张纹理的不同区域,而不是多张独立图片。如果敌人有多种外观,用SKTexture的textureRect从同一张大图里裁剪,而不是加载多个文件。

5.3 现象:触摸发射子弹时,偶尔会一次射出两颗

原因是touchesBegan在快速点击时可能被调用两次,或者你在touchesBegan和touchesMoved里都写了发射逻辑。俯视角射击通常用摇杆控制移动、点击控制射击,如果摇杆和射击共用了触摸事件,就会冲突。

解决:在touchesBegan里判断触摸位置,如果落在摇杆区域就交给摇杆处理,否则才发射子弹。用一个isFiring布尔值做节流,发射后 0.1 秒内忽略新的发射请求。这个节流时间根据射速调整,常见射速是每秒 5 到 10 发。

5.4 现象:游戏运行几分钟后内存持续上涨,最终被系统杀掉

原因是节点创建后没有正确移除。SKAction.removeFromParent()只把节点从场景树里摘掉,如果还有强引用指向它(比如放在数组里没清),节点不会释放。子弹和敌人的数组如果只增不减,内存就会一直涨。

解决:用对象池替代数组追加。池里的节点复用,不创建新对象。如果不用池,确保每次removeFromParent()后把数组里的引用也置 nil。用 Instruments 的 Allocations 工具跑 5 分钟,看SKSpriteNode的实例数是否稳定,如果持续上涨就是泄漏。

5.5 现象:真机上子弹方向偏移,模拟器上正常

原因是模拟器和真机的屏幕尺寸、缩放比例不同。如果你在代码里硬编码了坐标或速度,在不同设备上表现会不一致。比如子弹速度 600 在 iPhone 15 上刚好,在 iPad 上就显得很慢,因为 iPad 的场景尺寸更大。

解决:所有速度和距离都按场景尺寸的比例来算,不要用绝对值。比如子弹速度设为scene.size.width * 0.8,这样在任何设备上都是「每秒横穿屏幕 0.8 次」。场景的scaleMode设为.resizeFill,让场景尺寸始终等于屏幕尺寸,避免黑边和坐标偏移。

6. 进阶技巧:用 SKCameraNode 做屏幕震动与后坐力

俯视角射击的手感有一半来自反馈,而屏幕震动是最便宜的反馈手段。SpriteKit 从 iOS 9 开始提供SKCameraNode,把它设成场景的camera属性后,移动相机就能实现视口偏移。屏幕震动的本质是在短时间内让相机位置快速抖动,然后回到原位。

具体做法是给相机写一个扩展方法,接收震动强度和持续时间,用SKAction序列生成随机偏移。注意震动幅度要随距离衰减,否则远处敌人开枪也会震屏,玩家会晕。我一般把震动幅度限制在 4 到 8 点之间,持续时间 0.1 到 0.2 秒。

extension SKCameraNode { func shake(intensity: CGFloat, duration: TimeInterval) { let steps = 6 var actions: [SKAction] = [] for _ in 0..<steps { let dx = CGFloat.random(in: -intensity...intensity) let dy = CGFloat.random(in: -intensity...intensity) let move = SKAction.move(by: CGVector(dx: dx, dy: dy), duration: duration / Double(steps)) actions.append(move) } // 最后回到原点,避免相机漂移 let reset = SKAction.move(to: .zero, duration: 0.02) run(SKAction.sequence(actions + [reset]), withKey: "shake") } }

调用时用camera?.shake(intensity: 6, duration: 0.15)。withKey: "shake"是关键:如果连续开枪,新的震动会替换旧的,而不是叠加。不加 key 的话,多次震动会同时运行,相机位置会累积偏移,最后跑到屏幕外。

后坐力是另一个反馈点。玩家开枪时,让玩家节点沿子弹反方向位移几个点,然后用SKAction.move(to: player.position, duration: 0.05)弹回。这个位移不要用物理体,直接改position,因为物理体会和碰撞检测打架。位移量控制在 3 到 5 点,太大影响操作精度。

验证震动是否生效,不要靠肉眼。在shake方法里加一行print(camera!.position),跑起来看输出是否在预期范围内波动。如果相机位置一直不归零,检查reset动作是否被其他动作覆盖了。我踩过的坑是:在update里每帧设置camera.position = .zero,结果震动刚偏移就被重置,看起来完全没效果。相机位置只在震动时改,平时不要碰。

最后说一个习惯:每次调完手感参数,立刻在真机上用慢动作录屏回看。60fps 下肉眼很难分辨 0.05 秒的差异,但录屏逐帧看能发现震动是否过冲、后坐力是否回弹过快。这个习惯帮我省掉了大量「感觉不对但说不清哪里不对」的调试时间。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 8:11:25

Node.js 接口开发实战:Mongodb、Mongoose 与 TaoToken 统一 Key 配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/10 8:11:24

基于PCA9422与STM32的便携设备电源管理方案设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/10 8:10:51

ClickHouse为什么快?拆解向量化执行引擎的工作原理与实战

ClickHouse在国内火了很多年&#xff0c;但每次聊到它为什么快&#xff0c;最常见的答案就是三个字&#xff1a;列式存储。这个答案没错&#xff0c;但不够。你打开一条SQL执行计划&#xff0c;把一张四千多万行的订单表按天做聚合&#xff0c;从原来的数据库里跑出来要3秒&…

作者头像 李华
网站建设 2026/10/10 8:10:50

智能制造AI落地指南:数据基座、场景选型与回报测算

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/10 8:10:44

PCA9422搭配PIC32MZ的完整电源管理方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/10 8:10:44

仿金蝶ERP进销存系统:电商仓储落地方案与SQL Server数据架构解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华