news 2026/9/29 1:45:52

C语言飞机大战项目实战:从链表到碰撞检测的EasyX游戏开发全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言飞机大战项目实战:从链表到碰撞检测的EasyX游戏开发全解析

手写一个能跑、能玩、还能拿出去当课程设计的小游戏,大概是每个学C语言的人都动过的念头。我当年也是从“飞机大战”入的门,一个窗体、几块矩形、一串链表,最后做出来的东西虽然简陋,但从结构体到指针、从链表到文件读写全给串起来了。这篇文章就把整个项目的设计思路、关键代码和调试过程完整拆开讲,适合正在学C语言、想拿小项目练手,或者马上要交课程设计的朋友。

整个项目完全基于C语言和EasyX图形库实现,源码结构不复杂,核心逻辑就几百行,但覆盖的知识点相当齐全:结构体管理游戏对象、链表动态维护敌机、矩形碰撞检测、双缓冲绘图、键盘非阻塞输入,还有文件存档。你把它跑起来之后,再回头看C语言课本里的那些概念,感觉完全不一样。

1. 项目全貌:这个游戏到底做了什么

1.1 玩法设计与功能清单

先明确一下游戏是什么样:屏幕上方会不断生成敌机,向下移动;玩家控制己方飞机左右上下移动,自动发射子弹;子弹击中敌机则得分,敌机撞到玩家则扣命,生命值归零游戏结束。整个循环就是“生成敌机 - 移动 - 发射子弹 - 检测碰撞 - 更新画面”,看起来简单,但这其实是一个完整游戏的核心骨架。

具体功能我列一下,方便你做对照:

  • 玩家飞机:方向键控制上下左右移动,子弹自动连发。
  • 敌机系统:随机从屏幕顶部生成,以一定速度下落,可设置多种速度和大小。
  • 碰撞检测:子弹与敌机碰撞、敌机与玩家碰撞,均采用矩形碰撞。
  • 得分与生命:每击毁一个敌机加分,碰撞玩家扣命,命归零游戏结束。
  • 最高分记录:用文件读写保存历史最高分,下次启动仍然有效。
  • 游戏状态:包含开始界面、游戏运行中、结束画面三个状态。

1.2 技术选型:为什么用EasyX而不是其他方案

做C语言图形小游戏,摆在你面前的方案其实有好几种:控制台字符画、EasyX图形库、SDL跨平台库,甚至用Win32 API硬写。我最终选了EasyX,核心原因是它和C语言配合最省事。

对比一下你就能明白:

方案上手难度效果适用场景
控制台字符画最低粗糙,但能体现逻辑练习语法和算法
EasyX低2D图形,适合小游戏课程设计、初学者项目
SDL中高跨平台,性能好正式开源游戏项目
Win32 API较高系统级绘制底层编程练习

你如果只是想在控制台里打印几个字符,那学不到图形绘制和双缓冲这些概念;直接上SDL又要折腾环境配置,很容易劝退新手。EasyX本质上是封装了Windows GDI绘图接口,initgraph一行代码就能创建一个窗口,circle、rectangle、fillrectangle这些函数跟画图板一样直觉化,完全不用碰底层的设备上下文。我的建议是:用EasyX把游戏逻辑跑通一次,再回头去理解SDL或者Unity,你会有一种“原来如此”的通透感。

2. 核心数据结构与关键机制拆解

2.1 用结构体组织游戏对象

游戏里牵扯到三类核心对象:玩家飞机、子弹、敌机。你当然可以用一堆散落的变量去记录它们的位置和状态,但那样的代码写到后面就是一锅粥。我在项目里为每个对象定义了对应的结构体,把相关的属性打包在一起,代码清晰度会提升很多。

先看玩家和子弹的结构体定义:

// 玩家飞机 typedef struct { int x, y; // 中心坐标 int width, height; // 飞机的宽高 int lives; // 剩余生命 int score; // 当前得分 } Player; // 子弹 typedef struct { int x, y; // 子弹中心坐标 int speed; // 移动速度 int active; // 1表示存活,0表示已失效 } Bullet;

这里有个细节:坐标我统一用“中心点”而不是“左上角”来记录。为什么?因为绘制矩形时我们需要左上角坐标,但坐标运算时用中心点更符合直觉,比如玩家左右移动时直接改player.x就行,碰撞检测也只需要计算两个中心点的距离。最后绘制前做个减法转换即可,一举两得。

子弹用了固定数组Bullet bullets[MAX_BULLETS],配合active标记位来管理存活状态。为什么这里不用链表?因为子弹数量有限且频繁创建销毁,用数组加标记的方式,逻辑简单,也不容易出内存泄漏的问题。

2.2 敌机管理:链表是这道题的重点

敌机和子弹不一样,它生成的频率和数量是动态变化的,如果用固定数组,你需要预先分配很大的空间,还得频繁判断哪些位是空的。我这里用了链表来管理敌机,每次生成新敌机就插入一个节点,敌机被击毁或飞出屏幕就删除节点。这个过程把C语言里最核心的指针操作给练透了。

定义和插入节点的代码:

// 敌机节点 typedef struct EnemyNode { int x, y; // 位置 int width, height; // 尺寸 int speed; // 下落速度 int alive; // 0表示待删除 struct EnemyNode* next; // 指向下一节点 } EnemyNode; // 在链表头部插入新敌机 void addEnemy(EnemyNode** head, int x, int y, int speed) { EnemyNode* newNode = (EnemyNode*)malloc(sizeof(EnemyNode)); newNode->x = x; newNode->y = y; newNode->width = 40; newNode->height = 30; newNode->speed = speed; newNode->alive = 1; newNode->next = *head; *head = newNode; }

为什么用头插法?因为头插法的时间复杂度是O(1),不需要遍历链表找尾节点,代码也最简洁。你如果把新节点插到尾部,每次生成敌机都得从头遍历一遍,敌机数量多了之后开销会被放大。

游戏每帧更新时,我们遍历链表,把所有敌机的y坐标加上速度,模拟下落。同时判断每个节点的alive标记和是否飞出窗口底部,如果满足删除条件,记录要删除的节点,然后通过前一个节点的next指针跳过它,最后free释放内存。这里要特别提醒:在链表中遍历时删除节点,容易丢后续节点,一定要用一个临时指针先保存next,再删当前节点。

我用删除链表中所有死亡敌机的代码做示范:

void removeDeadEnemies(EnemyNode** head) { EnemyNode* prev = NULL; EnemyNode* curr = *head; while (curr != NULL) { EnemyNode* next = curr->next; // 先保存下一个节点 if (curr->alive == 0 || curr->y > WINDOW_HEIGHT) { if (prev == NULL) { *head = next; // 删除的是头节点 } else { prev->next = next; // 断开当前节点 } free(curr); } else { prev = curr; } curr = next; } }

2.3 碰撞检测:为什么“碰上了”又好像没碰上

碰撞检测是整个游戏最核心的算法。常见的碰撞检测有圆形碰撞和矩形碰撞两种。圆形碰撞需要计算两点间的距离,矩形碰撞只需要比较坐标差,运算量更小,也更适合飞机大战这种大体上由矩形对象组成的游戏。

我的实现方式是:计算两个矩形中心点的横向距离和纵向距离,如果横向距离小于两个矩形宽度和的一半,纵向距离小于两个矩形高度和的一半,则说明二者相交。

对应代码如下:

// 矩形碰撞检测:a和b分别为两个对象的中心坐标、宽高 int isCollide(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) { if (abs(ax - bx) < (aw + bw) / 2 && abs(ay - by) < (ah + bh) / 2) { return 1; } return 0; }

这个判断逻辑其实很像两个人擦肩而过:横向距离小于两人身宽之和的一半,纵向距离小于两人身长之和的一半,那必然是撞上了。如果你把这里的坐标系画在纸上,就更容易理解。

实操中有一个坑:直接用整个飞机的宽高做碰撞矩形,视觉上会觉得“还没碰到就判定撞了”。这是因为飞机的图片或矩形边缘往往有一些透明区域或装饰性的尖角。解决方法是留安全边距,在实际判定时把碰撞框调小一点。比如敌机宽度是40,判定时只取30:

int collideW = enemy->width - 10; // 预留5像素误差 int collideH = enemy->height - 10; if (isCollide(player.x, player.y, player.width - 10, player.height - 10, enemy->x, enemy->y, collideW, collideH)) { // 扣命、销毁敌机 }

这种“预留位”的思想在游戏开发里叫碰撞框微调,做2D格斗游戏时尤其常见,很多看起来手感极好的游戏,秘密全在碰撞框的宽窄调整上。

3. 从零搭建:游戏主循环与关键模块实现

3.1 游戏主循环:所有游戏的心脏

游戏本质就是不断重复三件事:处理输入、更新逻辑、绘制画面。这个循环只要不退出,游戏就一直在跑。我先把主流程框架贴出来:

#include <graphics.h> #include <conio.h> #include <stdlib.h> #include <time.h> #define WINDOW_WIDTH 640 #define WINDOW_HEIGHT 480 int main() { initgraph(WINDOW_WIDTH, WINDOW_HEIGHT); srand((unsigned)time(NULL)); // 初始化玩家、子弹、敌机链表等数据 // ... BeginBatchDraw(); // 开启双缓冲 while (!gameOver) { // 1. 处理输入 handleInput(); // 2. 更新游戏逻辑:移动、碰撞、生成敌机 updateGame(); // 3. 绘制画面 drawGame(); Sleep(10); // 控制帧率 FlushBatchDraw(); // 将绘制内容一次性显示到屏幕 } EndBatchDraw(); closegraph(); return 0; }

Sleep(10)是控制游戏速度的关键。如果不加暂停,程序会以CPU允许的最快速度疯狂刷新,游戏速度快到根本没法玩。加了Sleep之后,每帧大约10毫秒,算下来游戏大概是100帧每秒。不过要注意,Sleep的精度在Windows下并不高,如果需要精确帧率,更好的做法是用GetTickCount或高精度计时器计算每帧的真实耗时,然后在下一帧做补偿,感兴趣的同学可以自己扩展。

这里要重点解释一下双缓冲。你如果尝试过不用双缓冲直接画,会看到窗口疯狂闪烁。原因是:每次while循环里,清理屏幕、画图形、显示结果是一系列操作,如果每一步都直接输出到屏幕,用户就会看到图形被逐笔绘制的过程,闪烁感就来了。双缓冲的做法是先把所有图形画到内存中的缓冲区里,全部画完再一次性拷贝到屏幕上。这就好比你在厨房里把菜全部做好摆盘,最后才端出去,食客只看到完整的菜,不会看到你切菜的过程。

3.2 玩家控制与子弹发射的实现细节

游戏输入这块需要用非阻塞方式检测键盘,不能让程序卡在getchar()上等用户输入。我用了_kbhit()和_getch()两个函数来检测键盘状态。

_kbhit()的作用是检查键盘缓冲区中是否有输入,如果有则返回非零值。配合_getch()读取按键值,就能实现实时响应。这里有个历史原因要说清楚:这两个函数在标准C语言中是kbhit()和getch(),但在Visual Studio的C运行库中,它们被加了前导下划线,变成了_kbhit()和_getch(),因为下划线开头的是编译器私有函数。你在别的编译器里编译时,可能要去掉下划线才行。

方向键在_getch()里不是普通字符,而是两个字节:0xE0或0x00之后跟一个扫描码。代码里需要先读掉第一个字节再读第二个字节,否则方向键会被识别成两个独立的按键。

我示范一下处理逻辑:

void handleInput() { while (_kbhit()) { int key = _getch(); if (key == 0xE0 || key == 0x00) { // 方向键是扩展键 key = _getch(); switch (key) { case 72: player.y -= 5; break; // 上 case 80: player.y += 5; break; // 下 case 75: player.x -= 5; break; // 左 case 77: player.x += 5; break; // 右 default: break; } } else if (key == 27) { // Esc键退出 gameOver = 1; } } }

子弹发射我是放在更新逻辑里的,每帧检查一个冷却计数器,如果计数器归零且子弹数量未满,就生成一颗新子弹,子弹的x坐标等于玩家飞机的中心点,y坐标等于玩家的顶部。为什么要用冷却时间而不是每按一次空格发射一颗?因为自动连发是这类小游戏的标准手感,玩家只需要专注移动躲避,射击交给系统自动完成。冷却时间设置为150毫秒左右,手感比较适中,太快屏幕全是子弹反而没有压力。

3.3 敌机生成与计分:随机数和文件存档

敌机生成讲究一个“节奏感”。如果敌机生成太快,玩家根本没有喘息机会;如果生成太慢,游戏又显得无聊。常见的做法是每隔一定帧数生成一个敌机,同时随着游戏得分的提升,逐步缩短生成间隔、提高敌机下落速度,这就是最简单的难度曲线。

敌机生成的核心代码如下:

static int spawnTimer = 0; spawnTimer++; if (spawnTimer >= currentSpawnInterval) { spawnTimer = 0; int x = rand() % (WINDOW_WIDTH - 80) + 40; // 避免贴边 int speed = rand() % 3 + 2; // 速度2~4 addEnemy(&enemyList, x, -30, speed); // 从顶部外进入 }

随机数要放在srand((unsigned)time(NULL))初始化之后才有意义,否则每次运行游戏的敌机位置都是一样的。这一点很多新手会踩坑,觉得“随机”不随机,其实就是忘了播种。

计分和最高分保存用了文件读写,正好把课本上的fopen、fwrite、fread用上了。游戏开始时读取最高分:

int highScore = 0; FILE* fp = fopen("score.dat", "rb"); if (fp) { fread(&highScore, sizeof(int), 1, fp); fclose(fp); }

游戏结束时若当前分数高于最高分,就写回文件:

if (player.score > highScore) { FILE* fp = fopen("score.dat", "wb"); if (fp) { fwrite(&player.score, sizeof(int), 1, fp); fclose(fp); } }

注意fread和fwrite都以二进制方式操作,比使用格式化文本更高效,也不用考虑数字To字符串的转换问题。文件读写这种操作,你在课本上练十遍,都不如按下“重新开始”按钮、看着最高分真的被记住的那一刻来得有成就感。

3.4 渲染绘制:怎么把“飞机”画得像样

渲染部分我用了两种方案。第一版全部用基础图形绘制:玩家飞机画成一个绿色的等腰三角形,敌机用红色矩形,子弹用黄色小竖条。优点是零素材依赖,代码拷贝过去就能跑;缺点是视觉效果非常朴素,只适合练手。

第二种方案是加载外部图片,比如用loadimage函数从PNG或JPG文件中加载飞机素材,然后用putimage绘制到窗口。如果你的素材有透明背景,EasyX还提供了putimage配合透明颜色处理的技巧。但使用外部图片有两个问题:一是图片版权要留意,二是loadimage对图片格式和路径有要求,路径写错程序会直接崩溃。

这里建议初学者先用图形绘制把游戏逻辑跑通,之后再换素材,两个方案其实互补。我画玩家飞机时用的是一组直线围成的三角形:

// 绘制玩家飞机示例 setfillcolor(GREEN); POINT pts[] = { { player.x, player.y - 20 }, { player.x - 15, player.y + 15 }, { player.x + 15, player.y + 15 } }; solidpolygon(pts, 3);

游戏画面绘制顺序也讲究:先画背景,再画敌机,再画子弹,最后画玩家,最后绘制计分信息。因为后画的图形会盖住先画的图形,所以玩家的飞机要最后画,保证它始终显示在最上层。这和Photoshop的图层逻辑一模一样。

4. 调试与运行:常见问题排查实录

4.1 编译环境问题:下划线函数和图形库链接

我在本地调试时用的是Visual Studio,第一次编译直接报了一堆错误,全是函数未定义。排查下来有几个经典问题,这里全部列出来:

现象原因解决办法
kbhit未定义VS要求用下划线开头的私有函数改成_kbhit()
getch未定义同上改成_getch()
scanf不安全编译报错VS强制要求安全函数改用scanf_s(),或开头加#define _CRT_SECURE_NO_WARNINGS
找不到graphics.h没有安装EasyX到EasyX官网下载安装包,按对应VS版本安装
链接时WinMain冲突控制台程序和窗口程序入口不同在项目属性里把子系统设为控制台

graphics.h链接失败是最容易卡住的。EasyX的安装包需要根据VS版本手动选择,装完之后它会把头文件和库文件放到VS的对应目录下。如果你用的是VS2022,就选VS2022对应的安装选项,装完之后重启VS才能生效。

4.2 中文乱码问题

游戏界面上要显示“得分”“游戏结束”这些中文字,我第一次运行时满屏都是乱码。原因在于源文件的编码格式和EasyX默认的字符集不一致。VS默认使用GBK编码保存文件,如果你的源文件是UTF-8格式,中文字符串在运行时就会乱。

解决办法有两种:最简单的是用宽字符函数和宽字符串,代码如下:

settextstyle(30, 0, _T("宋体")); outtextxy(100, 100, _T("得分: 100"));

_T()宏会自动适配宽窄字符,配合settextstyle使用。还有一种是在代码开头加上system("chcp 936"),强制控制台使用GBK编码,但这治标不治本,换了环境又会变。

4.3 画面闪烁与游戏卡顿

前面提到过,不用双缓冲会闪烁。如果你技术上已经用了BeginBatchDraw,但画面还是偶尔闪,原因大概率是FlushBatchDraw调用得太频繁,或者绘制时使用了大量的透明图像处理。再检查一下Sleep是否在大循环里被某些耗时操作绕过。绘制大量图片时,建议用屏幕裁剪或只重绘变化区域,避免整屏刷新,画面会顺滑一些。

至于卡顿,最大的坑是malloc和free使用不匹配。比如敌机链表中每个节点分配的内存释放次数错了,重复释放会产生未定义行为,轻则崩溃,重则内存泄漏。建议在删除节点时打印一条日志,看到每个节点都被正确释放,心里才踏实。

4.4 链表操作:野指针与死循环

链表删除节点是最容易出问题的环节。我在调试中遇到过两次死循环:第一次是在遍历链表时直接free了当前节点但curr又指向已释放的内存,第二次是没有处理头节点删除的情况,导致head变成一个野指针,整个链表断掉。

排查这类问题,我的建议是:在游戏循环外单独写一个测试函数,生成固定数量的节点,执行插入和删除操作,每次操作后遍历打印链表的长度和节点地址。把这个小测试跑通了,再放回游戏里,你会省下大量调试时间。

问题可能原因排查与修复
程序闪退数组越界检查敌人的出生坐标是否超出窗口,确保rand()%在合理区间
敌机消失删除节点时丢链用临时变量保存next指针再释放内存
分数不增加碰撞检测判定太严打印碰撞帧的坐标,手动判断是否触发
最高分未更新文件没读写权限检查当前程序目录是否可写,以管理员身份运行

5. 如何把项目做得更完整:优化与扩展方向

飞机大战做到能跑能玩只是第一步。很多同学做完基础版本后,想知道项目怎么继续“值钱”起来。我这里给出几个切实可行的方向,你可以按需选做。

5.1 使用真实素材替换自绘图形

基础版本用基础图形绘制,适合逻辑验证。但如果你想把它做成一个“看起来像样”的课程设计,换素材是最直接的办法。你可以在网上找一些免费可商用的PNG素材,或者自己用画图软件抠图,然后用loadimage加载。加载图片后,用putimage带透明色参数绘制,飞机的锯齿感和方块感立刻消失。

关键是注意图片文件夹路径。我的做法是在项目文件夹下建一个res目录存放所有素材,用相对路径访问。如果用绝对路径,代码换到别人的电脑上就会因为路径不一致而崩溃,这一点在交作业时尤其致命。

5.2 增加道具与Boss系统

道具系统是“飞机大战”扩展的经典方向。你可以定义道具类型,比如加速弹、护盾、炸弹,让被击毁的敌机有一定概率掉出道具。道具从屏幕上方落下,玩家碰到后触发效果,这就需要在结构体中增加一个type字段,并在碰撞检测后通过switch分发不同逻辑。

Boss的设计会更复杂一些,需要给Boss增加血量条和多个阶段。我建议的做法是定义一个独立的Boss结构体,拥有比普通敌机更大的碰撞框,同时周期性发射子弹。Boss每掉落一定血量,会切换攻击模式,这种“阶段转换”机制是很多射击游戏的核心爽点。C语言实现阶段转换只需要用一个phase整数变量加上switch判断即可,难度不大但项目档次会提升许多。

5.3 用动态数组或对象池替代链表

我前面推荐用链表练指针,但从工程角度看,频繁malloc和free会产生内存碎片,也可能造成性能抖动。另一种常见方案是动态数组,用realloc扩容;更高级的做法是对象池,预分配一个足够大的数组,用空闲链表维护可复用节点。

你做到这一步,相当于从“能跑”走向“跑得稳”。可以自己给两种方案各写一版,监控运行时的帧率和内存占用,用数据做对比。这个过程对理解数据结构和性能优化非常有帮助,也是在面试中能跟面试官聊起来的东西。

5.4 把游戏体验打磨到“舍不得关掉”

还有一个方向不要忽略——游戏手感。很多小游戏逻辑没问题,但玩家玩两局就关了,多半是手感不行。手感这个词听起来虚,拆开看就是三件事:灵敏度、反馈、节奏。灵敏度是移动速度是否和人的反应匹配;反馈是击中敌机时有没有加分飘字、爆炸特效、屏幕震动;节奏是敌人密度和玩家火力之间的平衡。

你可以给代码增加一个简单的粒子系统来模拟爆炸效果:敌机被击毁时,生成几个小固体圆,向外扩散并逐渐消失,整个过程只需要一个粒子结构体数组和几十行代码。加分飘字也可以做,在得分位置向上移动一行文字,同时淡出。这些细节加起来,会让整个游戏有质的飞跃。

最后再分享一点经验

做完这个项目后,我的一个直观感受是:C语言不是靠背知识点学会的,是靠一个又一个真实问题逼出来的。你在写飞机大战时会遇到内存错误、逻辑边界、随机数分布、碰撞判定各种问题,每修一个问题,你对C语言的理解就深一层。不要怕代码写得丑,第一版能跑,再谈优化。

另外,调试时善用打印输出,窗口上画不出来的信息,用printf打到控制台或者写到日志文件里,定位问题的速度会快很多。这个小习惯比任何调试器都实惠。如果你的最终目标是做游戏开发,C语言这门课打下的内存和算法底子,会是你未来用Unity或虚幻引擎时最大的底气。

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

RL-06-赵:随机逼近与随机梯度下降04:随机梯度下降(SGD/Stochastic gradient descent)【SGD是RM算法的一个特殊情况】【平均值估计算法是SGD的一个特殊情况】

三、Stochastic gradient descent【随机梯度下降】 stochastic gradient descent(SGD)算法在机器学习和强化学习的许多领域中广泛应用; SGD算法是RM算法的一个特殊情况。 mean estimation algorithm 是 RM算法的一个特殊情况,也是SGD算法的一个特殊情况。 假设我们的目标是求…

作者头像 李华
网站建设 2026/9/29 1:42:50

高精度除法算法详解:模拟竖式实现大数整除

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

作者头像 李华
网站建设 2026/9/29 1:42:36

仓储托盘到底贵在哪:把一次看似便宜的选择拆开算账

1. 看起来省下几万块&#xff0c;最后为什么在流水线上赔得肉疼&#xff1f;很多人在给仓库或者车间采购托盘时&#xff0c;直觉逻辑非常朴素&#xff1a;这不就是一块垫在货物下面的塑料板吗&#xff1f;既然都是长方形、都能被叉车挑起来&#xff0c;那报价表里谁便宜就选谁&…

作者头像 李华
网站建设 2026/9/29 1:41:49

Allegro差分对等长设计:从Constraint Manager规则到Delay Tune绕线实操

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

作者头像 李华
网站建设 2026/9/29 1:40:46

用DeepSeek搭建酒店投诉知识库:RAG检索增强生成落地实践

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

作者头像 李华