你这样做的方式没有持久的绘图。您只是直接打印到 shell/命令提示符。你试图做事的方式是行不通的。绘制后无法编辑根据提示绘制的内容,您需要基本上清除屏幕然后再次绘制,但使用指定的制造商。
我不知道你是否能够在你的作业中使用库,但是一个非常好的库可以让你这样做是ncurses
编辑 完全重写答案
在 CMD 中将事物相互叠加
好吧,我在工作中有一些停机时间,所以我编写了一个项目来做你需要的事情,我将发布代码并解释它的作用以及你为什么需要它。
首先,您将需要一个基本的渲染缓冲区或渲染上下文。每当您在诸如 OpenGL 之类的图形 API 中进行编程时,您不只是直接渲染到屏幕上,而是将您拥有的每个对象渲染到一个缓冲区,该缓冲区将您的内容光栅化并将其转换为像素。一旦采用这种形式,API 就会将渲染的图片推送到屏幕上。我们将采用类似的方法,而不是绘制到 GPU 上的像素缓冲区,而是绘制到字符缓冲区。将每个字符视为屏幕上的一个像素。
这是完整源代码的 pastebin:
项目的完整源代码
RenderContext
我们班会做这件事的RenderContext
班。它具有保存宽度和高度的字段以及一个数组chars
和一个特殊的字符,我们在清除缓冲区时填充缓冲区。
这个类只包含一个数组和函数,让我们渲染它。它确保当我们吸引它时,我们在界限之内。对象可能会尝试在剪切空间之外(屏幕外)进行绘制。但是,在那里绘制的任何内容都将被丢弃。
class RenderContext {
private:
int m_width, m_height; // Width and Height of this canvas
char* m_renderBuffer; // Array to hold "pixels" of canvas
char m_clearChar; // What to clear the array to
public:
RenderContext() : m_width(50), m_height(20), m_clearChar(' ') {
m_renderBuffer = new char[m_width * m_height];
}
RenderContext(int width, int height) : m_width(width), m_height(height), m_clearChar(' ') {
m_renderBuffer = new char[m_width * m_height];
}
~RenderContext();
char getContentAt(int x, int y);
void setContentAt(int x, int y, char val);
void setClearChar(char clearChar);
void render();
void clear();
};
这个类的两个最重要的功能是setContentAt
和render
setContentAt
是一个对象调用来填充“像素”值。为了使它更灵活一点,我们的类使用指向字符数组的指针,而不是直接数组(甚至是二维数组)。这让我们可以在运行时设置画布的大小。因此,我们访问此数组的元素,用x + (y * m_width)
它替换二维取消引用,例如arr[i][j]
// Fill a specific "pixel" on the canvas
void RenderContext::setContentAt(int x, int y, char val) {
if (((0 <= x) && (x < m_width)) && ((0 <= y) && (y < m_height))) {
m_renderBuffer[(x + (y * m_width))] = val;
}
}
render
是真正吸引提示的原因。它所做的只是遍历缓冲区中的所有“像素”并将它们放在屏幕上,然后移动到下一行。
// Paint the canvas to the shell
void RenderContext::render() {
int row, column;
for (row = 0; row < m_height; row++) {
for (column = 0; column < m_width; column++) {
printf("%c", getContentAt(column, row));
}
printf("\n");
}
}
I_Drawable
我们的下一个类是Interface
允许我们与可以绘制到我们的 RenderContext 的对象进行合同。它是纯虚拟的,因为我们不想实际实例化它,我们只想从中派生。它的唯一功能是draw
接受 RenderContext。派生类使用此调用来接收 RenderContext,然后使用 RenderContext 的 setContentAt 将“像素”放入缓冲区。
class I_Drawable {
public:
virtual void draw(RenderContext&) = 0;
};
GameBoard
第一个实现 I_Drawable 从而能够渲染到我们的 RenderContext 的类是 GameBoard 类。这是大部分逻辑的用武之地。它具有宽度、高度字段和一个整数数组,该数组保存板上元素的值。它还有另外两个用于间距的字段。因为当您使用代码绘制电路板时,每个元素之间都有空格。我们不需要将它合并到板子的底层结构中,我们只需要在绘制时使用它们。
class GameBoard : public I_Drawable {
private:
int m_width, m_height; // Width and height of the board
int m_verticalSpacing, m_horizontalSpacing; // Spaces between each element on the board
Marker m_marker; // The cursor that will draw on this board
int* m_board; // Array of elements on this board
void setAtPos(int x, int y, int val);
void generateBoard();
public:
GameBoard() : m_width(10), m_height(10), m_verticalSpacing(5), m_horizontalSpacing(3), m_marker(Marker()) {
m_board = new int[m_width * m_height];
generateBoard();
}
GameBoard(int width, int height) : m_width(width), m_height(height), m_verticalSpacing(5), m_horizontalSpacing(3), m_marker(Marker()) {
m_board = new int[m_width * m_height];
generateBoard();
}
~GameBoard();
int getAtPos(int x, int y);
void draw(RenderContext& renderTarget);
void handleInput(MoveDirection moveDirection);
int getWidth();
int getHeight();
};
它的关键函数是generateBoard
,handleInput
和派生的虚函数draw
。但是,请注意,它在其构造函数中创建了一个新的 int 数组并将其提供给它的指针。然后它的析构函数会在板子消失时自动删除分配的内存。
generateBoard
是我们用来实际创建板并用数字填充的。它将遍历板上的每个位置。每次,它都会直接查看左侧和上方的元素并存储它们。然后它将生成一个随机数,直到它生成的数字与存储的任何一个元素都不匹配,然后它将该数字存储在数组中。我重写了这个以摆脱标志的使用。这个函数在类的构造过程中被调用。
// Actually create the board
void GameBoard::generateBoard() {
int row, column, randomNumber, valToLeft, valToTop;
// Iterate over all rows and columns
for (row = 0; row < m_height; row++) {
for (column = 0; column < m_width; column++) {
// Get the previous elements
valToLeft = getAtPos(column - 1, row);
valToTop = getAtPos(column, row - 1);
// Generate random numbers until we have one
// that is not the same as an adjacent element
do {
randomNumber = (2 + (rand() % 7));
} while ((valToLeft == randomNumber) || (valToTop == randomNumber));
setAtPos(column, row, randomNumber);
}
}
}
handleInput
是处理在板上移动光标的方法。它基本上是一个免费赠品,是您在将光标移到板上之后的下一步。我需要一种方法来测试绘图。它接受一个枚举,我们打开它以知道将光标移动到下一个位置。如果您可能希望在到达边缘时让光标环绕在棋盘上,您会想在此处执行此操作。
void GameBoard::handleInput(MoveDirection moveDirection) {
switch (moveDirection) {
case MD_UP:
if (m_marker.getYPos() > 0)
m_marker.setYPos(m_marker.getYPos() - 1);
break;
case MD_DOWN:
if (m_marker.getYPos() < m_height - 1)
m_marker.setYPos(m_marker.getYPos() + 1);
break;
case MD_LEFT:
if (m_marker.getXPos() > 0)
m_marker.setXPos(m_marker.getXPos() - 1);
break;
case MD_RIGHT:
if (m_marker.getXPos() < m_width - 1)
m_marker.setXPos(m_marker.getXPos() + 1);
break;
}
}
draw
非常重要,因为它是将数字输入 RenderContext 的原因。总而言之,它遍历板上的每个元素,并在画布上的正确位置绘制,将元素放置在正确的“像素”下。这是我们合并间距的地方。另外,请注意我们在此函数中渲染光标。
这是一个选择问题,但是您可以在 GameBoard 类之外存储一个标记并在主循环中自己渲染它(这将是一个不错的选择,因为它放松了 GameBoard 类和 Marker 类之间的耦合。但是,由于它们是相当耦合的,我选择让 GameBoard 渲染它。如果我们使用场景图,就像我们可能会使用更复杂的场景/游戏一样,Marker 可能是 GameBoard 的子节点,所以它类似于这个实现,但通过不在 GameBoard 类中存储显式标记而更加通用。
// Function to draw to the canvas
void GameBoard::draw(RenderContext& renderTarget) {
int row, column;
char buffer[8];
// Iterate over every element
for (row = 0; row < m_height; row++) {
for (column = 0; column < m_width; column++) {
// Convert the integer to a char
sprintf(buffer, "%d", getAtPos(column, row));
// Set the canvas "pixel" to the char at the
// desired position including the padding
renderTarget.setContentAt(
((column * m_verticalSpacing) + 1),
((row * m_horizontalSpacing) + 1),
buffer[0]);
}
}
// Draw the marker
m_marker.draw(renderTarget);
}
Marker
说到Marker
班级,现在让我们来看看。Marker 类实际上与 GameBoard 类非常相似。但是,它缺少很多 GameBoard 所具有的逻辑,因为它不需要担心板上的一堆元素。重要的是绘图功能。
class Marker : public I_Drawable {
private:
int m_xPos, m_yPos; // Position of cursor
public:
Marker() : m_xPos(0), m_yPos(0) {
}
Marker(int xPos, int yPos) : m_xPos(xPos), m_yPos(yPos) {
}
void draw(RenderContext& renderTarget);
int getXPos();
int getYPos();
void setXPos(int xPos);
void setYPos(int yPos);
};
draw
只需将四个符号放在 RenderContext 上以勾勒板上选定的元素。请注意,Marker 对 GameBoard 类一无所知。它没有引用它,它不知道它有多大,或者它包含什么元素。不过你应该注意到,我变得懒惰并且没有取出硬编码的偏移量,这种偏移量取决于 GameBoard 的填充。您应该为此实施更好的解决方案,因为如果您更改 GameBoard 类中的填充,您的光标将关闭。
除此之外,每当绘制符号时,它们都会覆盖 ContextBuffer 中的任何内容。这很重要,因为您问题的重点是如何在 GameBoard 上绘制光标。这也说明了绘制顺序的重要性。假设每当我们绘制 GameBoard 时,我们在每个元素之间绘制了一个“=”。如果我们先绘制光标再绘制棋盘,GameBoard 会在光标上方绘制,使其不可见。
如果这是一个更复杂的场景,我们可能需要做一些花哨的事情,比如使用深度缓冲区来记录z-index
元素的值。然后,每当我们绘制时,我们都会检查新元素的 z-index 是否比 RenderContext 缓冲区中已有的内容更近或更远。根据这一点,我们可能会完全跳过绘制“像素”。
但是我们没有,所以请注意订购您的抽奖电话!
// Draw the cursor to the canvas
void Marker::draw(RenderContext& renderTarget) {
// Adjust marker by board spacing
// (This is kind of a hack and should be changed)
int tmpX, tmpY;
tmpX = ((m_xPos * 5) + 1);
tmpY = ((m_yPos * 3) + 1);
// Set surrounding elements
renderTarget.setContentAt(tmpX - 0, tmpY - 1, '-');
renderTarget.setContentAt(tmpX - 1, tmpY - 0, '|');
renderTarget.setContentAt(tmpX - 0, tmpY + 1, '-');
renderTarget.setContentAt(tmpX + 1, tmpY - 0, '|');
}
CmdPromptHelper
我要讨论的最后一个类是 CmdPromptHelper。你原来的问题没有这样的东西。但是,您很快就会担心它。这个类也只在 Windows 上有用,所以如果你在 linux/unix 上,你需要担心自己处理绘制到 shell 的问题。
class CmdPromptHelper {
private:
DWORD inMode; // Attributes of std::in before we change them
DWORD outMode; // Attributes of std::out before we change them
HANDLE hstdin; // Handle to std::in
HANDLE hstdout; // Handle to std::out
public:
CmdPromptHelper();
void reset();
WORD getKeyPress();
void clearScreen();
};
每一项功能都很重要。构造函数获取当前命令提示符的std::in
和句柄。std::out
该getKeyPress
函数返回用户按下的键(键向上事件被忽略)。并且该clearScreen
函数清除提示(实际上,它实际上将提示中已经存在的任何内容向上移动)。
getKeyPress
只需确保您有一个句柄,然后读取已输入控制台的内容。它确保无论它是什么,它都是一个键并且它被按下。然后它将密钥代码作为 Windows 特定的枚举返回,通常以VK_
.
// See what key is pressed by the user and return it
WORD CmdPromptHelper::getKeyPress() {
if (hstdin != INVALID_HANDLE_VALUE) {
DWORD count;
INPUT_RECORD inrec;
// Get Key Press
ReadConsoleInput(hstdin, &inrec, 1, &count);
// Return key only if it is key down
if (inrec.Event.KeyEvent.bKeyDown) {
return inrec.Event.KeyEvent.wVirtualKeyCode;
} else {
return 0;
}
// Flush input
FlushConsoleInputBuffer(hstdin);
} else {
return 0;
}
}
clearScreen
有点骗人。您会认为它会清除提示中的文本。据我所知,它没有。我很确定它实际上将所有内容向上移动,然后在提示符中写入大量字符,以使其看起来像屏幕已被清除。
这个函数提出的一个重要概念是缓冲渲染的概念。同样,如果这是一个更健壮的系统,我们将希望实现双缓冲的概念,这意味着渲染到一个不可见的缓冲区并等待直到所有绘制完成,然后将不可见的缓冲区与可见的缓冲区交换。这使得渲染视图更加清晰,因为我们在它们仍在绘制时看不到它们。我们在这里做事的方式,我们看到渲染过程发生在我们面前。这不是一个主要问题,它有时看起来很丑。
// Flood the console with empty space so that we can
// simulate single buffering (I have no idea how to double buffer this)
void CmdPromptHelper::clearScreen() {
if (hstdout != INVALID_HANDLE_VALUE) {
CONSOLE_SCREEN_BUFFER_INFO csbi;
DWORD cellCount; // How many cells to paint
DWORD count; // How many we painted
COORD homeCoord = {0, 0}; // Where to put the cursor to clear
// Get console info
if (!GetConsoleScreenBufferInfo(hstdout, &csbi)) {
return;
}
// Get cell count
cellCount = csbi.dwSize.X * csbi.dwSize.Y;
// Fill the screen with spaces
FillConsoleOutputCharacter(
hstdout,
(TCHAR) ' ',
cellCount,
homeCoord,
&count
);
// Set cursor position
SetConsoleCursorPosition(hstdout, homeCoord);
}
}
main
您需要担心的最后一件事是如何使用所有这些东西。这就是 main 的用武之地。你需要一个游戏循环。游戏循环可能是任何游戏中最重要的事情。你看到的任何游戏都会有一个游戏循环。
这个想法是:
- 在屏幕上显示一些东西
- 读取输入
- 处理输入
- 转到 1
这个程序也不例外。它做的第一件事是创建一个 GameBoard 和一个 RenderContext。它还制作了一个 CmdPromptHelper,它可以与命令提示符交互。之后,它开始循环并让循环继续,直到我们达到退出条件(对我们来说,这是按退出)。我们可以有一个单独的类或函数来分派输入,但是由于我们只是将输入分派给另一个输入处理程序,所以我将它保留在主循环中。获得输入后,将 if 发送到 GameBoard,GameBoard 会相应地改变自身。下一步是清除 RenderContext 和屏幕/提示。如果没有按下转义,则重新运行循环。
int main() {
WORD key;
GameBoard gb(5, 5);
RenderContext rc(25, 15);
CmdPromptHelper cph;
do {
gb.draw(rc);
rc.render();
key = cph.getKeyPress();
switch (key) {
case VK_UP:
gb.handleInput(MD_UP);
break;
case VK_DOWN:
gb.handleInput(MD_DOWN);
break;
case VK_LEFT:
gb.handleInput(MD_LEFT);
break;
case VK_RIGHT:
gb.handleInput(MD_RIGHT);
break;
}
rc.clear();
cph.clearScreen();
} while (key != VK_ESCAPE);
}
在你考虑了所有这些事情之后,你就会明白为什么以及在哪里需要绘制你的光标。这不是一个接一个地调用函数的问题,您需要合成您的绘图。你不能只画游戏板然后画标记。至少不是在命令提示符下。我希望这有帮助。它确实减轻了工作中的停机时间。