从零构建模块化游戏框架:ECS架构实践与游戏宇宙设计
在实际游戏开发项目中我们常常会遇到一个核心挑战如何构建一个逻辑清晰、易于扩展且能支撑复杂交互的“游戏宇宙”。这个宇宙不仅仅是地图和角色的简单堆砌它更是一套由实体、组件、系统、事件和状态交织而成的动态规则体系。很多开发者尤其是从简单Demo转向中型项目的开发者会发现自己陷入代码混乱的泥潭——新功能难以添加Bug难以定位不同模块如物理、渲染、AI高度耦合。本文将带你从零开始运用数据驱动和组合优于继承的设计思想构建一个模块化的游戏实体框架。我们将以“太空探索”为背景逐步实现一个由飞船、行星、空间站等实体构成的迷你宇宙并重点讲解如何通过组件Component和系统System来管理实体的行为与数据最终实现一个可运行、可调试、易于扩展的“不一样的游戏宇宙”原型。本文适合有一定编程基础熟悉如C#、Java或Python等面向对象语言的游戏开发初学者或希望重构项目结构的开发者。你将学习到ECSEntity-Component-System架构的简化实践、游戏循环的职责分离以及如何用代码清晰地表达游戏世界的规则。1. 理解核心概念为什么是“组件”与“系统”在构建复杂游戏宇宙时传统的深层类继承结构例如GameObject-MovableObject-Spaceship-PlayerSpaceship会迅速变得僵化。假设你想给一个行星也加上“可被攻击”的特性难道要让行星继承自Spaceship吗这显然不合理。1.1 实体-组件-系统ECS模式简述ECS是一种架构模式它通过组合而非继承来构建游戏对象。实体Entity一个唯一的标识符通常是一个ID。它本身没有任何数据或行为仅仅是一个“容器”或“标签”。组件Component纯粹的数据容器。它描述实体的某一方面属性例如PositionComponent位置、HealthComponent生命值、SpriteComponent渲染精灵。系统System包含游戏逻辑的处理器。系统遍历所有拥有特定组件组合的实体并对它们的数据进行操作。例如MovementSystem会处理所有拥有PositionComponent和VelocityComponent的实体在每一帧更新它们的位置。这种设计的优势在于极高的灵活性和可维护性。你可以通过为实体添加或移除组件来动态改变其行为而系统之间通过数据耦合职责清晰便于优化如利用数据局部性进行批处理。1.2 我们的简化设计目标完全标准的ECS实现可能涉及复杂的内存管理。对于学习和大多数项目而言一个“面向数据的简化ECS”或“组件系统”就足够了。我们的目标是每个实体是一个包含唯一ID和组件字典的对象。组件是简单的数据类结构体。系统是独立的类在游戏主循环中被调用它们查询并处理符合条件的实体。2. 环境准备与项目结构我们将使用C#和.NET 6 控制台应用程序来实现这个原型这样可以专注于逻辑而无需复杂的图形库。你也可以用其他语言如Java、Python遵循相同的思想进行实现。2.1 开发环境与工具IDE/编辑器Visual Studio 2022、Visual Studio Code 或 JetBrains Rider。.NET SDK确保安装了 .NET 6.0 或更高版本。在命令行输入dotnet --version验证。项目类型控制台应用。2.2 创建项目与规划结构打开终端执行以下命令创建项目并进入目录dotnet new console -n DifferentGameUniverse cd DifferentGameUniverse用IDE打开项目我们将创建以下核心文件夹和类这代表了我们的游戏宇宙框架DifferentGameUniverse/ ├── Program.cs // 游戏主循环入口 ├── Core/ │ ├── Entity.cs // 实体定义 │ ├── IComponent.cs // 组件接口可选 │ └── GameWorld.cs // 游戏世界管理所有实体和系统 ├── Components/ // 各种组件定义 │ ├── PositionComponent.cs │ ├── VelocityComponent.cs │ ├── HealthComponent.cs │ └── SpriteComponent.cs └── Systems/ // 各种系统定义 ├── MovementSystem.cs ├── RenderSystem.cs └── DamageSystem.cs3. 实现核心框架实体、组件与世界3.1 定义实体Entity实体是组件的载体。我们用一个类来封装ID和组件字典。// File: Core/Entity.cs using System.Collections.Generic; namespace DifferentGameUniverse.Core { public class Entity { public int Id { get; } private DictionaryType, object _components; public Entity(int id) { Id id; _components new DictionaryType, object(); } // 添加组件 public T AddComponentT(T component) where T : class { _components[typeof(T)] component; return component; } // 获取组件 public T GetComponentT() where T : class { if (_components.TryGetValue(typeof(T), out var component)) { return (T)component; } return null; } // 检查是否拥有某组件 public bool HasComponentT() where T : class { return _components.ContainsKey(typeof(T)); } // 移除组件 public void RemoveComponentT() where T : class { _components.Remove(typeof(T)); } } }3.2 定义组件Component组件是纯粹的数据类。这里我们定义几个基础组件。// File: Components/PositionComponent.cs namespace DifferentGameUniverse.Components { public class PositionComponent { public float X { get; set; } public float Y { get; set; } public PositionComponent(float x 0, float y 0) { X x; Y y; } } } // File: Components/VelocityComponent.cs namespace DifferentGameUniverse.Components { public class VelocityComponent { public float Vx { get; set; } public float Vy { get; set; } public VelocityComponent(float vx 0, float vy 0) { Vx vx; Vy vy; } } } // File: Components/HealthComponent.cs namespace DifferentGameUniverse.Components { public class HealthComponent { public int CurrentHealth { get; set; } public int MaxHealth { get; set; } public HealthComponent(int maxHealth) { MaxHealth maxHealth; CurrentHealth maxHealth; } public bool IsAlive CurrentHealth 0; } }3.3 构建游戏世界GameWorld游戏世界负责管理所有实体和系统并提供查询功能。// File: Core/GameWorld.cs using System.Collections.Generic; using System.Linq; using DifferentGameUniverse.Components; namespace DifferentGameUniverse.Core { public class GameWorld { private ListEntity _entities; private ListISystem _systems; private int _nextEntityId 1; public GameWorld() { _entities new ListEntity(); _systems new ListISystem(); } // 创建实体 public Entity CreateEntity() { var entity new Entity(_nextEntityId); _entities.Add(entity); return entity; } // 销毁实体 public void DestroyEntity(Entity entity) { _entities.Remove(entity); } // 注册系统 public void RegisterSystem(ISystem system) { system.World this; // 为系统注入世界引用 _systems.Add(system); } // 更新所有系统在游戏主循环中调用 public void Update(float deltaTime) { foreach (var system in _systems) { system.Update(deltaTime); } } // 核心查询获取所有拥有指定组件的实体 public IEnumerableEntity GetEntitiesWithComponents(params Type[] componentTypes) { return _entities.Where(entity componentTypes.All(type entity.HasComponent(type)) ); } } // 系统接口 public interface ISystem { GameWorld World { get; set; } void Update(float deltaTime); } }4. 实现游戏逻辑系统System系统是游戏宇宙的“规则执行者”。每个系统只关心特定的组件组合。4.1 移动系统MovementSystem此系统负责根据速度更新位置。// File: Systems/MovementSystem.cs using DifferentGameUniverse.Components; using DifferentGameUniverse.Core; using System.Linq; namespace DifferentGameUniverse.Systems { public class MovementSystem : ISystem { public GameWorld World { get; set; } public void Update(float deltaTime) { // 查询所有同时拥有 Position 和 Velocity 组件的实体 var entities World.GetEntitiesWithComponents(typeof(PositionComponent), typeof(VelocityComponent)); foreach (var entity in entities) { var pos entity.GetComponentPositionComponent(); var vel entity.GetComponentVelocityComponent(); // 简单的欧拉积分 pos.X vel.Vx * deltaTime; pos.Y vel.Vy * deltaTime; // 可以在这里添加边界检查等逻辑 // 例如让实体在屏幕边界反弹 if (pos.X 0 || pos.X 100) vel.Vx * -1; if (pos.Y 0 || pos.Y 30) vel.Vy * -1; } } } }4.2 简易控制台渲染系统RenderSystem由于是控制台应用我们用字符来“渲染”实体。// File: Systems/RenderSystem.cs using DifferentGameUniverse.Components; using DifferentGameUniverse.Core; using System; using System.Linq; namespace DifferentGameUniverse.Systems { public class RenderSystem : ISystem { public GameWorld World { get; set; } public void Update(float deltaTime) { Console.Clear(); // 清屏 Console.WriteLine( 游戏宇宙视图 ); // 查询所有有位置和“精灵”这里用字符代表的实体 var entities World.GetEntitiesWithComponents(typeof(PositionComponent)); // 为了简单我们假设一个固定的“画布”大小 const int width 100; const int height 30; char[,] buffer new char[height, width]; // 初始化缓冲区为空格 for (int y 0; y height; y) for (int x 0; x width; x) buffer[y, x] ; // 将实体绘制到缓冲区 foreach (var entity in entities) { var pos entity.GetComponentPositionComponent(); int x (int)pos.X; int y (int)pos.Y; // 确保坐标在缓冲区内 if (x 0 x width y 0 y height) { char symbol ?; // 默认符号 // 可以根据实体拥有的其他组件决定符号 if (entity.HasComponentHealthComponent()) symbol S; // 飞船 else symbol *; // 行星或其他 buffer[y, x] symbol; } } // 输出缓冲区 for (int y 0; y height; y) { for (int x 0; x width; x) { Console.Write(buffer[y, x]); } Console.WriteLine(); } // 显示一些状态信息 var ships World.GetEntitiesWithComponents(typeof(HealthComponent)); Console.WriteLine($\n宇宙中活跃的飞船数量: {ships.Count()}); } } }5. 组装宇宙并运行游戏循环现在我们在Program.cs中将这些部分组合起来创建一个简单的游戏循环。// File: Program.cs using DifferentGameUniverse.Components; using DifferentGameUniverse.Core; using DifferentGameUniverse.Systems; using System; using System.Threading; namespace DifferentGameUniverse { class Program { static void Main(string[] args) { Console.WriteLine(正在初始化游戏宇宙...); var world new GameWorld(); // 1. 创建系统并注册 var movementSystem new MovementSystem(); var renderSystem new RenderSystem(); world.RegisterSystem(movementSystem); world.RegisterSystem(renderSystem); // 2. 创建一些实体飞船、行星 // 玩家飞船 var playerShip world.CreateEntity(); playerShip.AddComponent(new PositionComponent(50, 15)); playerShip.AddComponent(new VelocityComponent(5, 2)); playerShip.AddComponent(new HealthComponent(100)); // 可以添加更多组件如 PlayerControlledComponent // 敌舰 var enemyShip world.CreateEntity(); enemyShip.AddComponent(new PositionComponent(10, 5)); enemyShip.AddComponent(new VelocityComponent(-3, 1)); enemyShip.AddComponent(new HealthComponent(50)); // 行星静止无速度无生命值 var planet world.CreateEntity(); planet.AddComponent(new PositionComponent(80, 20)); // 行星只有位置组件 Console.WriteLine(宇宙初始化完成。按任意键开始模拟按 Q 退出。); Console.ReadKey(); // 3. 游戏主循环 bool running true; DateTime previousTime DateTime.Now; while (running) { DateTime currentTime DateTime.Now; float deltaTime (float)(currentTime - previousTime).TotalSeconds; previousTime currentTime; // 处理输入简单示例 if (Console.KeyAvailable) { var key Console.ReadKey(true).Key; if (key ConsoleKey.Q) { running false; } // 可以在这里根据按键修改玩家实体的 VelocityComponent } // 更新世界驱动所有系统 world.Update(deltaTime); // 控制帧率避免刷新过快 Thread.Sleep(50); // 约20 FPS } Console.WriteLine(\n游戏宇宙模拟结束。); } } }运行这个程序在项目根目录执行dotnet run你将看到一个控制台窗口里面有两个代表飞船的S字符和一个代表行星的*字符在移动和反弹下方显示飞船数量。这就是我们构建的“不一样的游戏宇宙”的雏形——一个由数据驱动、通过组件组合定义行为、由系统执行规则的动态世界。6. 常见问题与排查路径在实现和扩展上述框架时你可能会遇到以下典型问题。问题现象可能原因检查与解决方式实体没有移动1. 实体缺少VelocityComponent。2.MovementSystem未被注册到GameWorld。3.deltaTime计算异常为0或极大。1. 在实体创建后使用entity.GetComponentVelocityComponent()检查是否为null。2. 确认world.RegisterSystem(new MovementSystem())被调用。3. 在Update方法开头打印deltaTime确保其值在0.016(60FPS) 左右。控制台闪烁或渲染错乱RenderSystem中Console.Clear()被频繁调用且绘制逻辑有误。1. 可以考虑双缓冲或更精细的局部重绘逻辑。2. 检查缓冲区坐标计算确保(int)pos.X和(int)pos.Y没有越界。查询实体返回空集合GetEntitiesWithComponents查询条件太严格或实体组件未正确添加。1. 确认实体拥有查询所需的所有组件类型。2. 在GameWorld的查询方法中加入调试日志打印遍历到的实体ID及其组件列表。添加新组件后对应系统不生效新系统未注册或系统的Update方法未被调用。1. 确保在游戏循环开始前调用world.RegisterSystem()。2. 确保world.Update(deltaTime)在循环中被调用。性能随着实体增多而下降系统每帧遍历所有实体复杂度是 O(N*M)N实体数M系统数。1. 为系统缓存符合条件的实体列表仅在实体组件变化时更新缓存。2. 考虑使用真正的ECS架构将同类型组件数据连续存储提高CPU缓存命中率。7. 最佳实践与扩展方向7.1 框架使用最佳实践组件保持纯净组件应是只有数据字段和简单属性的类/结构体不应包含业务逻辑。逻辑属于系统。系统职责单一一个系统只做一件事。例如CollisionSystem只检测碰撞并发出事件DamageSystem监听碰撞事件并处理伤害计算。使用事件解耦系统之间通过事件通信而不是直接调用。例如InputSystem发布MoveCommandEventMovementSystem监听并处理它。世界作为服务定位器GameWorld可以提供获取其他服务如资源管理器、随机数生成器的接口避免系统单例化。为组件和系统命名空间良好的命名空间组织如YourGame.Physics.Components有助于在大项目中管理代码。7.2 扩展你的游戏宇宙现在你有了一个可工作的核心可以尝试以下扩展来丰富你的宇宙输入系统创建InputSystem读取键盘/鼠标输入为玩家实体添加或修改VelocityComponent或AttackComponent。碰撞系统为实体添加ColliderComponent如圆形、矩形实现CollisionSystem。当两个碰撞体相交时发布一个CollisionEvent。伤害与生命系统创建DamageSystem监听CollisionEvent。根据实体的HealthComponent和攻击方的DamageComponent计算伤害生命值降至零时发布EntityDestroyedEvent由另一个系统处理实体移除和特效。AI系统为敌舰添加AIAgentComponent。AISystem根据玩家位置、自身状态HealthComponent等计算并设置敌舰的VelocityComponent或TargetComponent。状态机为实体添加StateMachineComponent使用状态模式管理实体的行为状态如巡逻、追击、逃跑不同状态由不同的系统或逻辑块处理。序列化与存档因为组件是纯数据可以轻松序列化如转为JSON。实现一个PersistenceSystem将整个GameWorld中所有实体的组件数据保存到文件实现游戏存档功能。通过不断添加新的组件和系统你可以像搭积木一样构建出极其复杂且行为丰富的游戏宇宙而代码结构依然保持清晰。这正是“组合优于继承”和“数据驱动”设计带来的强大力量。

相关新闻

Qt QLabel实现工业状态指示灯:样式表封装与实战应用

Qt QLabel实现工业状态指示灯:样式表封装与实战应用

1. 项目缘起:一个被忽视的界面细节在开发工业上位机、设备监控或者任何需要人机交互的桌面应用时,我们常常会遇到一个看似简单却至关重要的需求:状态指示。想象一下,你正在开发一个烘箱的控制程序,用户需要一眼就能知道…

2026/10/20 13:56:00 阅读更多 →
金融风控平台中TinyMCE5粘贴Excel内容异常解决方案

金融风控平台中TinyMCE5粘贴Excel内容异常解决方案

1. 问题现象与背景分析在金融风控平台的日常运营中,我们经常需要将Excel表格数据快速插入到文档系统中。TinyMCE5作为一款轻量级富文本编辑器,因其良好的兼容性和易用性被广泛应用于各类业务系统。但近期多个项目组反馈:当从Excel复制包含图表…

2026/10/19 8:15:19 阅读更多 →
从“卡片归位”赛题拆解工程化思维:构建可靠可调试的自动化系统

从“卡片归位”赛题拆解工程化思维:构建可靠可调试的自动化系统

最近在整理往年电赛题目时,发现一个很有意思的现象:很多同学拿到“卡片归位”这类任务时,第一反应是去研究最前沿的视觉算法,琢磨怎么把识别准确率刷到99.9%。但真正在赛场上,让队伍功亏一篑的,往往不是算法…

2026/10/17 3:03:48 阅读更多 →

最新新闻

Context-Mode 上下文模式:多租户与异步场景下的工程实践指南

Context-Mode 上下文模式:多租户与异步场景下的工程实践指南

1. 从“上下文模式”这个命名说起:它到底在解决什么问题第一次看到“context-mode”这个词,很多人会下意识觉得它是个抽象概念,不像某个具体框架或库那样一眼能看懂。我最初也是这个反应——直到自己在几个项目里反复被同一类问题折磨之后&am…

2026/10/20 15:28:17 阅读更多 →
深入拆解Java包装类与泛型:集合中的类型陷阱与实战排查

深入拆解Java包装类与泛型:集合中的类型陷阱与实战排查

搞Java这么多年&#xff0c;我见过太多人在集合上栽跟头&#xff1a;往ArrayList<Integer>里存了个值&#xff0c;取出来用比较&#xff0c;结果在127和128两个数字上表现完全不一样&#xff1b;想写个List<int>省点内存&#xff0c;编辑器直接报错&#xff1b;自定…

2026/10/20 15:28:17 阅读更多 →
三相并网变流器与SVG无功补偿Simulink仿真建模全解析

三相并网变流器与SVG无功补偿Simulink仿真建模全解析

前段时间做了一套光伏并网方向的仿真模型&#xff0c;客户点名要三相并网变流器带上无功静态补偿的功能&#xff0c;还要带一份能讲清楚原理和结果分析的报告。我一开始也觉得这东西不就是把SVG并联在并网点上嘛&#xff0c;真动手建模才发现里面值得抠的细节比想象中多得多。这…

2026/10/20 15:28:17 阅读更多 →
Unity射击游戏工程快速上手:版本、管线与输入系统避坑指南

Unity射击游戏工程快速上手:版本、管线与输入系统避坑指南

简介&#xff1a;这是一份面向Unity3D初学者与游戏开发入门者的射击小游戏完整工程包&#xff0c;基于Unity5.0构建&#xff0c;适合想通过实战理解引擎核心机制的开发者练手。压缩包共28个文件&#xff0c;约33.73MB&#xff0c;包含dll动态库、config配置、assets资源、exe可…

2026/10/20 15:28:17 阅读更多 →
Django仓库管理系统实战:从库存流水设计到并发扣减与部署

Django仓库管理系统实战:从库存流水设计到并发扣减与部署

做仓库管理这套系统&#xff0c;说起来不算什么特别新鲜的事&#xff0c;但从需求梳理到真正能跑起来、能交到使用者手里&#xff0c;中间隔着的东西远比想象中多。我最近正好把一个基于 Python Django 的仓库管理系统从源码到数据库再到部署文档完整过了一遍&#xff0c;这套…

2026/10/20 15:28:17 阅读更多 →
计组与计网底层逻辑全解析:从CPU到TCP的软硬件协同

计组与计网底层逻辑全解析:从CPU到TCP的软硬件协同

我知道很多同学一看到“计组”和“计网”这两个词&#xff0c;脑子里浮现的就是“背不完的概念”和“算不完的题”。作为一个在计算机领域摸爬滚打多年的老兵&#xff0c;我可以负责任地说&#xff1a;CS基础里的计算机组成原理和计算机网络&#xff0c;恰恰是最能拉开技术上限…

2026/10/20 15:27:16 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

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

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

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

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

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

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式: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/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/20 9:50:45 阅读更多 →