尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Dev-C++控制台双人跑酷游戏开发:从Windows API到实时交互

Dev-C++控制台双人跑酷游戏开发:从Windows API到实时交互 1. 项目概述为什么选择Dev-C做控制台游戏很多刚接触C/C编程的朋友可能都想过自己动手写个小游戏。但一上来就面对Unity、Unreal这些庞然大物或者复杂的图形库很容易被劝退。其实用最经典的Dev-C集成开发环境配合Windows自带的控制台就能做出好玩又有挑战性的游戏。这次我们要做的就是一个“双人跑酷游戏”。你可能会问控制台黑底白字的能做游戏吗当然能而且别有洞天。我们不用任何外部图形库纯粹用字符比如、#、-、|来构建游戏世界。两个玩家分别控制两个角色在由字符组成的障碍赛道中奔跑、跳跃、躲避。这听起来简单但背后涉及了控制台光标定位、实时键盘事件捕获、游戏状态逻辑、碰撞检测等核心编程概念。对于想夯实C语言基础、理解程序实时交互本质的开发者来说这是一个绝佳的练手项目。选择Dev-C一方面是因为它轻量、经典在Windows环境下配置简单另一方面它自带的MinGW编译器能很好地支持我们需要的Windows API调用。我们将大量使用windows.h这个头文件里的函数来实现控制台“画布”的绘制和键盘的即时响应。网络上常说的“小熊猫Dev-C”或“Red Panda Dev-C”是原版的一个活跃分支界面更现代对中文支持更好同样完全适用本项目。这个项目的核心价值在于“完整思路”。它不是教你抄几行代码而是带你从零开始像搭积木一样构建一个可运行、可交互的游戏程序。你会清晰地看到如何将“画个静态字符界面”的想法一步步变成“两个角色能实时响应你的按键并动态更新”的活游戏。下面我们就来拆解这整个过程。2. 环境准备与核心API扫盲2.1 Dev-C的安装与项目创建首先你需要一个Dev-C。如果你还没有直接搜索“小熊猫Dev-C”或访问其官方仓库下载最新版它的界面和体验比古老的Dev-C 5.11要好很多。安装过程一路下一步即可没有特别需要注意的坑。安装完成后打开软件新建一个项目文件-新建-项目。在弹出的窗口中选择Console Application控制台应用程序语言选择C或C都可以因为我们将主要使用C风格的API和语法。给项目起个名字比如DoubleDashRunner然后选择一个干净的目录保存。项目创建好后你会看到一个包含了main函数的初始.cpp文件。我们后续的所有代码都将写在这里。首先请确保在文件的最开头包含我们最核心的头文件#include windows.h #include conio.h #include stdio.h #include stdlib.h #include time.hwindows.h提供了所有Windows API的声明是我们操纵控制台窗口和键盘输入的基础。conio.h经典的控制台输入输出头文件虽然我们会用更强大的API但其中_kbhit()等函数有时仍很方便。stdio.h和stdlib.h用于标准输入输出和动态内存管理、随机数生成等。time.h主要用于初始化随机数种子让每次游戏运行时障碍物的出现位置是随机的。2.2 必须掌握的Windows控制台API我们的游戏画面本质上是不断刷新的字符矩阵。Windows API提供了一组函数允许我们精确控制控制台屏幕缓冲区可以理解为控制台背后的“画布”的每一个字符单元。以下是几个最关键的函数理解了它们你就掌握了控制台图形学的钥匙获取与设置控制台句柄HANDLE hOutput GetStdHandle(STD_OUTPUT_HANDLE); HANDLE hInput GetStdHandle(STD_INPUT_HANDLE);GetStdHandle函数获取标准输出和标准输入的句柄。句柄Handle是Windows系统用来标识各种资源如窗口、文件、控制台缓冲区的一个唯一值。hOutput和hInput就是我们后续所有绘制和输入操作的“入场券”。设置控制台窗口标题和大小SetConsoleTitle(双人跑酷大冒险 - Dev-C制造); system(mode con cols80 lines25);SetConsoleTitle顾名思义设置窗口标题。system(“mode con …”)是调用系统命令来快速设置控制台窗口的缓冲区大小为80列、25行。这是一个经典的尺寸足够我们构建游戏画面。你也可以调整成其他尺寸比如cols120 lines30来获得更宽阔的视野。核心绘制函数SetConsoleCursorPosition和WriteConsole控制台光标就像你的画笔笔尖。SetConsoleCursorPosition函数将笔尖移动到指定的坐标位置。COORD pos {x, y}; // COORD是一个结构体包含X和Y两个成员 SetConsoleCursorPosition(hOutput, pos);坐标原点(0,0)在屏幕的左上角X轴向右增加Y轴向下增加。移动光标后你可以用printf输出字符但更推荐使用WriteConsole它性能更好且能避免一些格式问题。char ch ; DWORD bytesWritten; WriteConsole(hOutput, ch, 1, bytesWritten, NULL);不过对于单个字符的绘制更常见的做法是组合使用SetConsoleCursorPosition和printf(“%c”, ch)代码更简洁。键盘事件处理的基石SetConsoleMode与ReadConsoleInput这是实现双人实时操控的关键。默认情况下控制台输入是“行缓冲”模式你需要按回车键程序才能读到输入。对于游戏我们需要“即时”模式。DWORD mode; GetConsoleMode(hInput, mode); SetConsoleMode(hInput, mode ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT) | ENABLE_WINDOW_INPUT);这段代码关闭了行输入和回显开启了窗口输入模式允许我们读取输入事件队列。 然后在一个循环中我们可以检查并读取输入事件INPUT_RECORD inputRecord; DWORD numEventsRead; PeekConsoleInput(hInput, inputRecord, 1, numEventsRead); if (numEventsRead 0) { ReadConsoleInput(hInput, inputRecord, 1, numEventsRead); if (inputRecord.EventType KEY_EVENT inputRecord.Event.KeyEvent.bKeyDown) { // 处理按键按下事件 WORD keyCode inputRecord.Event.KeyEvent.wVirtualKeyCode; // 根据keyCode判断是哪个键被按下 } }INPUT_RECORD结构体包含了各种输入事件的详细信息我们只关心KEY_EVENT键盘事件。bKeyDown为TRUE表示键被按下。wVirtualKeyCode是虚拟键码比如VK_UP、VK_W、VK_A等这是我们判断玩家操作的依据。注意很多教程会用_getch()或kbhit()但这些函数在处理双人同时按键时会有阻塞或丢失输入的问题。ReadConsoleInput是更专业、更可靠的解决方案它能完美捕获所有按键事件是实现流畅双人操控的基础。3. 游戏核心架构设计与数据定义在动手写代码之前我们必须先规划好游戏里有哪些“东西”以及它们如何运作。这就像盖房子先画图纸。3.1 游戏世界的抽象定义结构体我们将游戏中的元素抽象为几个结构体这能让代码逻辑更清晰。1. 游戏角色 (Player)每个玩家控制一个角色。角色需要知道自己的位置、形状以及是否处于跳跃状态。typedef struct { int x; // 角色所在的列横向坐标 int y; // 角色所在的行纵向坐标 char shape; // 角色的字符表示比如或 int isJumping; // 是否正在跳跃0表示否0表示跳跃剩余帧数 int jumpForce; // 跳跃的初始力度决定能跳多高 int gravity; // 重力值每帧增加让角色下落 } Player;这里我引入了一个简单的跳跃模拟isJumping是跳跃计时器当它大于0时角色每帧会向上移动跳跃上升阶段同时gravity累积当计时器结束或碰到天花板gravity开始起作用让角色加速下落直到落回地面。2. 障碍物 (Obstacle)障碍物是玩家需要躲避的东西。它可能是一堵墙、一个坑或者一个移动的敌人。typedef struct { int x; // 障碍物起始列 int y; // 障碍物所在行通常在地面行 int width; // 障碍物的宽度占几列 char shape; // 障碍物的填充字符如#或^ int isActive; // 障碍物是否处于活动状态是否在屏幕上 } Obstacle;我们将采用“对象池”的思想。预先生成一个障碍物数组游戏过程中循环使用。当一个障碍物移出屏幕左端我们就将其重置到屏幕右端某个位置并随机其类型宽度从而实现无穷无尽的障碍赛道。3. 游戏全局状态 (GameState)用一个结构体来管理游戏的全局信息。typedef struct { Player player1; Player player2; Obstacle obstacles[MAX_OBSTACLES]; // 假设我们定义最多10个障碍物 int score; // 游戏分数比如存活时间或越过障碍数 int gameOver; // 游戏是否结束0进行中1结束 int groundY; // 地面的行坐标角色和障碍物都以此为基准 int screenWidth; // 屏幕宽度列数 int screenHeight; // 屏幕高度行数 } GameState;3.2 游戏主循环心跳与节奏所有实时游戏都有一个核心——游戏主循环。它每秒运行很多次比如30次或60次每一次循环称为一“帧”Frame。在每一帧里程序按顺序做以下几件事处理输入检查这段时间内玩家按了哪些键。更新逻辑根据输入和当前状态更新角色位置、障碍物位置、判断碰撞、计算分数等。渲染输出根据最新的游戏状态重新绘制整个控制台画面。这个顺序非常重要。如果先渲染再处理输入玩家会感觉到操作延迟。我们的主循环骨架如下void gameLoop(GameState* state) { clock_t lastTime clock(); const double frameTime 1.0 / 30.0; // 目标帧率30帧/秒即每帧约33.3毫秒 while (!state-gameOver) { clock_t currentTime clock(); double elapsedTime (double)(currentTime - lastTime) / CLOCKS_PER_SEC; // 如果距离上一帧的时间还没达到目标帧时间就稍微等待一下 // 这可以防止游戏在性能过剩的电脑上跑得太快。 if (elapsedTime frameTime) { continue; } lastTime currentTime; // 1. 处理输入 processInput(state); // 2. 更新游戏状态 updateGame(state, elapsedTime); // 3. 渲染画面 renderGame(state); // 一个简单的延时也可以帮助稳定帧率但不如上面的时间差判断精确 // Sleep(30); } // 游戏结束显示结算画面 renderGameOver(state); }这里用clock()函数和CLOCKS_PER_SEC来计算真实经过的时间实现一个简单的、与电脑速度无关的帧率控制。processInput、updateGame、renderGame就是我们接下来要实现的三个核心函数。4. 核心模块实现详解4.1 输入处理模块双人键盘响应的奥秘双人游戏的关键在于输入处理要公平、及时、不冲突。我们为两位玩家分配不同的按键玩家1通常用WASD键W键跳跃A键左移可选D键右移可选。在纯跑酷游戏中左右移动可能不需要角色自动向右跑跳跃是主要操作。玩家2通常用方向键↑键跳跃←键左移→键右移。processInput函数的核心逻辑如下void processInput(GameState* state) { INPUT_RECORD inBuf[128]; // 缓冲区一次读取多个事件 DWORD numEvents, numEventsRead; // 1. 窥探输入缓冲区里有多少事件 GetNumberOfConsoleInputEvents(hInput, numEvents); if (numEvents 0) return; // 没有新输入直接返回 // 2. 读取所有事件不超过缓冲区大小 ReadConsoleInput(hInput, inBuf, min(128, numEvents), numEventsRead); // 3. 遍历处理每一个事件 for (DWORD i 0; i numEventsRead; i) { if (inBuf[i].EventType KEY_EVENT inBuf[i].Event.KeyEvent.bKeyDown) { WORD vkCode inBuf[i].Event.KeyEvent.wVirtualKeyCode; // 处理玩家1按键 if (vkCode W || vkCode VK_UP) { // 为了方便也可以允许玩家1用上箭头 // 触发玩家1跳跃前提是角色在地面上 if (state-player1.y state-groundY) { state-player1.isJumping 15; // 设置跳跃帧数 state-player1.gravity -4; // 设置初始向上的速度 } } // 可以在这里添加玩家1的左右移动逻辑 // if (vkCode A) state-player1.x max(0, state-player1.x - 1); // if (vkCode D) state-player1.x min(state-screenWidth-1, state-player1.x 1); // 处理玩家2按键 if (vkCode VK_UP) { if (state-player2.y state-groundY) { state-player2.isJumping 15; state-player2.gravity -4; } } // 玩家2左右移动逻辑 // if (vkCode VK_LEFT) ... // if (vkCode VK_RIGHT) ... // 处理通用按键比如按ESC退出游戏 if (vkCode VK_ESCAPE) { state-gameOver 1; } } } // 4. 清空可能残留的鼠标等无关事件可选保持输入队列清洁 FlushConsoleInputBuffer(hInput); }实操心得ReadConsoleInput是一次性读取多个事件这比单次读取效率高得多也避免了在连续快速按键时丢失事件。FlushConsoleInputBuffer有时很有用特别是在游戏开始前或场景切换时可以清空之前积累的无效输入防止误操作。4.2 游戏逻辑更新模块让世界动起来updateGame函数是游戏的大脑负责计算下一帧所有物体的状态。1. 更新玩家状态物理模拟void updatePlayers(GameState* state) { // 更新玩家1 updateSinglePlayer((state-player1), state-groundY); // 更新玩家2 updateSinglePlayer((state-player2), state-groundY); } void updateSinglePlayer(Player* p, int groundY) { // 跳跃处理 if (p-isJumping 0) { p-y p-gravity; // 应用当前速度改变位置 p-gravity; // 模拟重力加速度速度越来越向下 p-isJumping--; // 如果跳跃时间结束或者角色已经落到地面以下则结束跳跃状态 if (p-isJumping 0 || p-y groundY) { p-isJumping 0; p-y groundY; // 确保角色稳稳站在地面上 p-gravity 0; } } // 注意这里只处理了跳跃。如果游戏设计是自动向右奔跑可以在这里增加 p-x 之类的逻辑。 }这是一个非常简化的物理模型。gravity初始为负值向上每帧增加受重力加速所以角色速度会先减小到0然后变为正值下落。2. 更新障碍物制造挑战障碍物通常从屏幕右侧出现向左移动。void updateObstacles(GameState* state) { for (int i 0; i MAX_OBSTACLES; i) { if (state-obstacles[i].isActive) { // 障碍物向左移动 state-obstacles[i].x--; // 如果障碍物移出屏幕左边界则将其重置到右侧并随机其属性 if (state-obstacles[i].x state-obstacles[i].width 0) { resetObstacle((state-obstacles[i]), state-screenWidth); } } } } void resetObstacle(Obstacle* obs, int screenWidth) { obs-x screenWidth rand() % 20; // 从屏幕右侧稍远的位置随机出现 obs-width 2 rand() % 4; // 随机宽度在2到5之间 obs-shape (rand() % 2) ? # : ^; // 随机选择障碍物外观 obs-isActive 1; }3. 碰撞检测游戏成败的关键碰撞检测决定了玩家是否碰到障碍物。由于我们使用字符网格可以采用简单的“轴对齐包围盒”AABB检测。int checkCollision(const Player* p, const Obstacle* obs) { // 玩家简化成一个点其x坐标障碍物是一个从obs-x开始宽度为obs-width的矩形 // 检测玩家的x坐标是否在障碍物的水平范围内并且玩家的y坐标是否与障碍物所在行重合或在一定容差内 // 更精确一点可以认为玩家也有宽度比如占据1列。 int playerLeft p-x; int playerRight p-x; // 如果玩家占1列则左右相同 int obstacleLeft obs-x; int obstacleRight obs-x obs-width - 1; // 水平方向有重叠 int horizontalOverlap !(playerRight obstacleLeft || playerLeft obstacleRight); // 垂直方向判断玩家是否站在“地面”上而障碍物也在地面行。如果玩家跳跃高度不足以越过障碍物高度则碰撞。 // 这里我们简化如果玩家在地面行或接近地面且水平重叠则碰撞。 int verticalOverlap (p-y obs-y); // 假设障碍物y坐标就是地面行 return (horizontalOverlap verticalOverlap); } void checkAllCollisions(GameState* state) { for (int i 0; i MAX_OBSTACLES; i) { if (state-obstacles[i].isActive) { if (checkCollision((state-player1), (state-obstacles[i]))) { state-gameOver 1; // 可以在这里记录是哪个玩家失败了 return; } if (checkCollision((state-player2), (state-obstacles[i]))) { state-gameOver 1; return; } } } }在updateGame函数中依次调用updatePlayers、updateObstacles和checkAllCollisions就完成了一帧的逻辑更新。4.3 渲染模块在控制台上“作画”渲染模块renderGame负责将内存中的游戏状态结构体数据变成屏幕上可见的字符画面。核心思想是先清屏然后从头绘制所有元素。1. 清屏与光标隐藏直接使用system(“cls”)是最简单的清屏方法但它在快速刷新时可能会导致屏幕闪烁。更高级的做法是使用“双缓冲区”但为了入门简单我们先用cls。为了美观我们还可以把光标隐藏起来。void clearScreen() { system(cls); } void hideCursor() { HANDLE hOutput GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO cursorInfo; GetConsoleCursorInfo(hOutput, cursorInfo); cursorInfo.bVisible FALSE; // 隐藏光标 SetConsoleCursorInfo(hOutput, cursorInfo); }2. 绘制游戏场景绘制顺序一般是先画背景和静态元素再画动态物体障碍物最后画角色确保角色在顶层。void renderGame(const GameState* state) { clearScreen(); // 清空上一帧画面 // 1. 绘制地面 COORD groundPos {0, state-groundY}; SetConsoleCursorPosition(hOutput, groundPos); for (int i 0; i state-screenWidth; i) { printf(-); // 用一连串的‘-’表示地面 } // 2. 绘制所有障碍物 for (int i 0; i MAX_OBSTACLES; i) { if (state-obstacles[i].isActive) { COORD obsPos {state-obstacles[i].x, state-obstacles[i].y}; SetConsoleCursorPosition(hOutput, obsPos); for (int w 0; w state-obstacles[i].width; w) { printf(%c, state-obstacles[i].shape); } } } // 3. 绘制玩家1 COORD p1Pos {state-player1.x, state-player1.y}; SetConsoleCursorPosition(hOutput, p1Pos); printf(%c, state-player1.shape); // 例如‘’ // 4. 绘制玩家2 COORD p2Pos {state-player2.x, state-player2.y}; SetConsoleCursorPosition(hOutput, p2Pos); printf(%c, state-player2.shape); // 例如‘’ // 5. 绘制分数等信息在屏幕顶部 COORD infoPos {0, 0}; SetConsoleCursorPosition(hOutput, infoPos); printf(Score: %d | Player1: | Player2: | Press ESC to quit, state-score); // 6. 强制刷新输出缓冲区在某些环境下可能需要 fflush(stdout); }注意事项频繁调用system(“cls”)和SetConsoleCursorPosition进行全屏重绘在低速终端上可能会有闪烁感。一个优化技巧是“局部更新”只重绘那些位置发生变化的字符。但对于我们这个规模的项目全屏重绘完全可接受代码也更清晰。5. 项目整合与运行调试5.1 主函数与初始化现在我们把所有模块像拼图一样组合到main函数里。int main() { // 初始化随机数种子 srand((unsigned int)time(NULL)); // 初始化控制台 HANDLE hOutput GetStdHandle(STD_OUTPUT_HANDLE); HANDLE hInput GetStdHandle(STD_INPUT_HANDLE); SetConsoleTitle(双人跑酷 - Dev-C); system(mode con cols80 lines25); hideCursor(); // 设置输入模式为非行缓冲 DWORD mode; GetConsoleMode(hInput, mode); SetConsoleMode(hInput, mode ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT) | ENABLE_WINDOW_INPUT); // 初始化游戏状态 GameState game; game.screenWidth 80; game.screenHeight 25; game.groundY 20; // 地面在第20行 game.gameOver 0; game.score 0; // 初始化玩家1 game.player1.x 10; game.player1.y game.groundY; game.player1.shape ; game.player1.isJumping 0; game.player1.gravity 0; // 初始化玩家2 game.player2.x 20; game.player2.y game.groundY; game.player2.shape ; game.player2.isJumping 0; game.player2.gravity 0; // 初始化障碍物 for (int i 0; i MAX_OBSTACLES; i) { resetObstacle((game.obstacles[i]), game.screenWidth); // 让障碍物在x轴上分散开避免扎堆出现 game.obstacles[i].x i * 15; game.obstacles[i].y game.groundY; // 障碍物也在地面行 } // 进入游戏主循环 gameLoop(game); // 游戏结束显示结束信息 COORD endPos {game.screenWidth / 2 - 10, game.screenHeight / 2}; SetConsoleCursorPosition(hOutput, endPos); printf(游戏结束最终得分%d, game.score); Sleep(3000); // 停留3秒 return 0; }你需要确保之前定义的hOutput和hInput是全局变量或者在函数间以参数传递。为了代码简洁我建议将它们定义为全局变量。5.2 编译、运行与常见问题在Dev-C中点击“编译运行”F11即可。如果代码没有语法错误一个控制台窗口会弹出你的双人跑酷游戏就运行起来了常见问题与排查技巧实录问题程序一闪而过窗口直接关闭。原因最可能的原因是main函数执行完毕正常退出了。检查你的gameLoop函数确保while (!state-gameOver)这个循环条件正确并且有逻辑能将gameOver设置为1比如碰撞检测或按ESC键。解决在main函数的return 0;前加一句system(“pause”);或getchar();可以临时让窗口停住方便查看错误信息。问题按键没反应或反应迟钝。原因A输入模式没有设置正确。确保成功调用了SetConsoleMode关闭了ENABLE_LINE_INPUT。原因B游戏帧率太快输入处理循环跑得太快ReadConsoleInput可能来不及捕获短按的按键。或者帧率太慢导致输入响应延迟。解决检查帧率控制逻辑。确保processInput函数被稳定地每帧调用。可以在processInput里打印一下读取到的虚拟键码确认是否正确捕获。问题角色或障碍物移动时屏幕闪烁严重。原因system(“cls”)清屏是导致闪烁的主要原因。解决进阶实现“双缓冲区”。原理是创建两个屏幕缓冲区在一个“后台缓冲区”里完成所有绘制然后一次性切换到前台显示。这需要用到CreateConsoleScreenBuffer、SetConsoleActiveScreenBuffer等更复杂的API。对于初学者可以尝试减少不必要的重绘或者稍微降低帧率如将frameTime改为1.0/20.0。问题两个角色或障碍物重叠时显示异常。原因后绘制的字符会覆盖先绘制的字符。这是正常的因为控制台每个位置只能显示一个字符。解决调整绘制顺序。确保角色最后绘制这样他们就会显示在最上层。如果希望障碍物覆盖角色比如角色跳到障碍物后面则需要更精细的Z轴深度管理这在本项目中比较复杂通常不需要。问题Dev-C编译时报错提示某些函数未定义或找不到标识符。原因通常是拼写错误或者没有包含必要的头文件。解决仔细检查函数名和变量名。确保#include windows.h在最前面。MinGW编译器可能需要#include conio.h来使用_kbhit()但我们主要用Windows API所以问题不大。如果使用srand和rand确保包含了stdlib.h和time.h。6. 功能扩展与优化思路一个基础版本完成后你可以像给房子装修一样为游戏添加更多有趣的功能增加游戏性分数系统每安全度过一段时间或者每跳过一个障碍物就加分。生命值碰撞后不是立即游戏结束而是减少生命值多条命增加容错率。道具系统随机出现一些字符代表道具如‘*’代表加速‘$’代表无敌玩家吃到后有临时效果。美化与反馈颜色使用SetConsoleTextAttribute(hOutput, color)函数可以为输出的字符设置前景色和背景色。让玩家、障碍物、地面拥有不同颜色画面会生动很多。音效虽然控制台本身不能播放复杂音频但可以用Beep(frequency, duration)函数发出简单的蜂鸣声作为跳跃、碰撞的音效反馈。更精细的图形用多个字符组合成更大的“精灵图”Sprite比如用/|\和/-\\可以组合成一个奔跑的小人动画。代码优化双缓冲区如前所述彻底解决闪烁问题。模块化将玩家、障碍物、渲染、输入等代码分别放入不同的.c和.h文件中使项目结构更清晰。配置文件将屏幕大小、角色速度、重力常数、按键设置等参数写到文件里方便调整而不需要重新编译。这个项目麻雀虽小五脏俱全。从环境配置、API学习、数据结构设计、主循环构建、到输入处理、物理模拟、碰撞检测、画面渲染你完整地走了一遍游戏开发的核心流程。用最原始的控制台字符画你创造了一个可以和朋友一起竞技的、充满乐趣的双人跑酷世界。这其中的成就感远比直接使用一个成熟的游戏引擎要强烈得多因为它背后的每一个像素字符的跳动都完全由你的代码逻辑所驱动。
返回列表