2 Commits

Author SHA1 Message Date
Qi-huanye d5f6cea2ed 进一步补充详细注释 2026-05-01 16:27:27 +08:00
Qi-huanye 84017ae6b7 再次整理文件结构 2026-05-01 16:03:34 +08:00
24 changed files with 3353 additions and 3021 deletions
+59
View File
@@ -11,6 +11,7 @@
#pragma comment(lib, "winmm.lib")
// 棋盘和窗口基础尺寸,渲染层会按当前窗口大小统一缩放这些设计稿尺寸。
constexpr int GRID = 40;
constexpr int nGameWidth = 10;
constexpr int nGameHeight = 20;
@@ -24,18 +25,27 @@ constexpr int WINDOW_CLIENT_WIDTH = WINDOW_PADDING * 2 + nGameWidth * GRID + SID
constexpr int BOARD_CLIENT_HEIGHT = WINDOW_PADDING * 2 + nGameHeight * GRID + 20;
constexpr int WINDOW_CLIENT_HEIGHT = (BOARD_CLIENT_HEIGHT > SIDE_PANEL_HEIGHT) ? BOARD_CLIENT_HEIGHT : SIDE_PANEL_HEIGHT;
/**
* @brief 棋盘坐标点,x 表示列号,y 表示行号。
*/
struct Point
{
int x;
int y;
};
/**
* @brief 主菜单导航状态。
*/
struct MenuState
{
int selectedIndex;
int optionCount;
};
/**
* @brief 帮助、规则、致谢和技能演示页面共享的导航状态。
*/
struct HelpState
{
int selectedIndex;
@@ -43,6 +53,12 @@ struct HelpState
int currentPage;
};
/**
* @brief 记录经典模式和 Rogue 模式的分数、等级、强化与临时状态。
*
* 课程要求不使用 class,因此所有与玩家成长有关的数据都集中放在结构体字段中,
* 由逻辑层函数按过程式方式读取和修改。
*/
struct PlayerStats
{
int score;
@@ -115,6 +131,9 @@ struct PlayerStats
int pieceTuningLevels[7];
};
/**
* @brief 升级界面中已经生成并显示给玩家的一个候选强化。
*/
struct UpgradeOption
{
int id;
@@ -127,6 +146,9 @@ struct UpgradeOption
const TCHAR* description;
};
/**
* @brief 强化池中的基础配置项,用于生成升级界面候选。
*/
struct UpgradeEntry
{
int id;
@@ -138,6 +160,9 @@ struct UpgradeEntry
const TCHAR* description;
};
/**
* @brief Rogue 升级选择界面的临时 UI 状态。
*/
struct UpgradeUiState
{
int selectedIndex;
@@ -150,6 +175,9 @@ struct UpgradeUiState
UpgradeOption options[6];
};
/**
* @brief 右侧战斗日志或提示条的显示状态。
*/
struct FeedbackState
{
int visibleTicks;
@@ -157,6 +185,9 @@ struct FeedbackState
TCHAR detail[128];
};
/**
* @brief 标准消行动画状态。
*/
struct ClearEffectState
{
int ticks;
@@ -165,6 +196,9 @@ struct ClearEffectState
int rows[8];
};
/**
* @brief 棋盘上浮动文字特效的单个实例。
*/
struct FloatingTextEffect
{
int ticks;
@@ -175,6 +209,9 @@ struct FloatingTextEffect
COLORREF color;
};
/**
* @brief 棋盘粒子特效的单个实例。
*/
struct ParticleEffect
{
int ticks;
@@ -187,6 +224,9 @@ struct ParticleEffect
COLORREF color;
};
/**
* @brief 被清除格子的短时闪烁高亮状态。
*/
struct CellFlashEffect
{
int ticks;
@@ -196,6 +236,9 @@ struct CellFlashEffect
COLORREF color;
};
/**
* @brief 固定方块受重力下落时的残影轨迹状态。
*/
struct GravityFallEffect
{
int ticks;
@@ -206,6 +249,9 @@ struct GravityFallEffect
int cellValue;
};
/**
* @brief 当前应用所在的大界面。
*/
enum ScreenState
{
SCREEN_MENU = 0,
@@ -214,12 +260,18 @@ enum ScreenState
SCREEN_RULES = 3
};
/**
* @brief 当前游戏玩法模式。
*/
enum GameMode
{
MODE_CLASSIC = 0,
MODE_ROGUE = 1
};
/**
* @brief 强化候选的稀有度,用于渲染不同颜色和排序说明。
*/
enum UpgradeRarity
{
UPGRADE_RARITY_COMMON = 0,
@@ -227,6 +279,7 @@ enum UpgradeRarity
UPGRADE_RARITY_RARE = 2
};
// 以下全局状态沿用老师框架的过程式组织方式,各模块通过函数集中维护这些变量。
extern int nType;
extern int type;
extern int state;
@@ -329,6 +382,12 @@ void DeleteOneLine(int number);
*/
int DeleteLines();
/**
* @brief 判断当前游戏是否已经结束。
* @return 游戏结束返回 true,否则返回 false。
*/
bool GameOver();
/**
* @brief 计算当前活动方块的预测落点。
*/
+6
View File
@@ -15,6 +15,12 @@ constexpr int GAME_TIMER_INTERVAL = 500;
constexpr int EFFECT_TIMER_INTERVAL = 16;
constexpr int CREDIT_TIMER_INTERVAL = 5;
/**
* @brief 当前窗口缩放后的布局参数。
*
* 输入命中区域和渲染坐标必须使用同一套缩放参数,才能保证鼠标点击位置
* 与屏幕上看到的按钮、卡片位置一致。
*/
struct LayoutMetrics
{
int scale;
+2
View File
@@ -8,6 +8,8 @@
#include "stdafx.h"
#include <string>
// 资源路径函数同时服务图片、音频和视频加载,调用方只传相对路径。
/**
* @brief 根据程序所在目录拼出项目资源文件的绝对路径。
* @param relativePath 相对于项目根目录的资源路径。
+51
View File
@@ -14,6 +14,8 @@ extern int pendingLineClearEffectRows[8];
extern int pendingLineClearEffectRowCount;
extern int pendingLineClearEffectLineCount;
// Internal 头文件只暴露跨 cpp 文件共享的辅助函数,外部窗口层仍通过 Tetris.h 调用公开接口。
/**
* @brief 计算指定方块在棋盘顶部的统一生成位置。
* @param brickType 方块类型编号。
@@ -21,6 +23,55 @@ extern int pendingLineClearEffectLineCount;
*/
Point GetSpawnPoint(int brickType);
/**
* @brief 收集当前方块将要固定到棋盘上的格子,并写入工作区。
* @param overflowTop 返回是否有方块格位于可视区域顶部之外。
* @param fixedCells 返回普通落地格数组。
* @param fixedCellCount 返回普通落地格数量。
* @param explosiveCells 返回爆破方块落地格数组。
* @param explosiveCellCount 返回爆破格数量。
*/
void CollectAndWriteFixedCells(
bool& overflowTop,
Point fixedCells[],
int& fixedCellCount,
Point explosiveCells[],
int& explosiveCellCount);
/**
* @brief 处理方块固定时的顶部溢出、终末清场和最后一搏。
* @param overflowTop 是否出现顶部溢出。
* @return 游戏可以继续返回 true,需要结束返回 false。
*/
bool ResolveFixingOverflow(bool overflowTop);
/**
* @brief 生成下一枚活动方块,并刷新 Hold、特殊方块和预测落点状态。
*/
void SpawnNextFallingPiece();
/**
* @brief 从底向上扫描满行并删除,记录本次消除的原始行号。
* @param clearedRows 返回最多 8 个被消除行号。
* @param clearedRowCount 返回记录的行号数量。
* @return 本次标准消行数量。
*/
int ScanAndDeleteFullLines(int clearedRows[], int& clearedRowCount);
/**
* @brief 根据当前界面状态立即播放或暂存消行动画。
* @param clearedRows 已消除行号数组。
* @param clearedRowCount 行号数量。
* @param clearedLines 本次消行数量。
*/
void DispatchLineClearEffect(const int clearedRows[], int clearedRowCount, int clearedLines);
/**
* @brief 处理连环炸弹因消行触发的一次追加爆破。
* @param clearedLines 本次标准消行数量。
*/
void ResolveChainBombFollowup(int clearedLines);
/**
* @brief 重置经典或 Rogue 模式使用的玩家统计数据。
* @param stats 需要重置的统计结构。
+9
View File
@@ -9,6 +9,8 @@
#include <objidl.h>
#include <gdiplus.h>
// 本内部头文件只给渲染拆分模块使用,外部代码仍通过 TDrawScreen 调用绘制入口。
/**
* @brief 加载并缓存主背景图片。
* @return 成功时返回缓存位图指针,失败时返回 nullptr。
@@ -21,3 +23,10 @@ Gdiplus::Bitmap* LoadBackgroundImage();
* @return 成功时返回缓存位图指针,失败或越界时返回 nullptr。
*/
Gdiplus::Bitmap* LoadCreditImage(int index);
/**
* @brief 绘制完整游戏界面,供 TDrawScreen 总入口调用。
* @param hdc 目标绘图设备上下文。
* @param hWnd 当前窗口句柄。
*/
void RenderFullScreen(HDC hdc, HWND hWnd);
+4
View File
@@ -10,8 +10,10 @@
// 供 Tetris.rc 使用
//
// 字符串资源:窗口标题等文本由 Win32 启动流程按编号读取。
#define IDS_APP_TITLE 103
// 图标、对话框、菜单和命令编号需要与 Tetris.rc 中的资源定义保持一致。
#define IDR_MAINFRAME 128
#define IDD_TETRIS_DIALOG 102
#define IDD_ABOUTBOX 103
@@ -24,11 +26,13 @@
#define IDC_MYICON 2
#ifndef IDC_STATIC
// 静态文本控件使用 -1,表示运行时不需要通过控件 ID 单独访问。
#define IDC_STATIC -1
#endif
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
// 以下编号由资源编辑器维护,手工改动容易导致新增资源编号冲突。
#define _APS_NO_MFC 130
#define _APS_NEXT_RESOURCE_VALUE 129
#define _APS_NEXT_COMMAND_VALUE 32771
+3 -2
View File
@@ -7,11 +7,12 @@
#include "targetver.h"
// 精简 Windows 头文件,缩短编译时间,同时保留本项目需要的 Win32/GDI API。
#define WIN32_LEAN_AND_MEAN // 从 Windows 头文件中排除极少使用的信息
// Windows 头文件:
#include <windows.h>
// C 运行时头文件
// C 运行时头文件:本项目使用随机数、内存工具、TCHAR 字符串和时间函数。
#include <stdlib.h>
#include <malloc.h>
#include <memory.h>
@@ -19,4 +20,4 @@
#include <time.h>
// TODO: 在此处引用程序需要的其他头文件
// 其他模块各自包含自己的业务头文件,避免预编译头承担过多项目依赖。
+3 -3
View File
@@ -5,9 +5,9 @@
* @brief 设置 Windows SDK 目标平台版本,供 Win32 头文件选择可用 API。
*/
// 包括 SDKDDKVer.h 将定义可用的最高版本 Windows 平台。
// 包括 SDKDDKVer.h 将定义可用的最高版本 Windows 平台
// 如果要为以前的 Windows 平台生成应用程序,请包括 WinSDKVer.h并将
// WIN32_WINNT 宏设置为要支持的平台,然后再包括 SDKDDKVer.h
// 若课程演示环境需要兼容更旧 Windows,可在这里先包含 WinSDKVer.h
// 再设置 WIN32_WINNT;当前项目直接使用 SDK 默认最高版本
#include <SDKDDKVer.h>
Binary file not shown.
+9
View File
@@ -194,6 +194,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
break;
case WM_COMMAND:
{
// 处理资源菜单命令;自绘菜单的鼠标和键盘输入不走这里。
int wmId = LOWORD(wParam);
switch (wmId)
@@ -214,15 +215,19 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
}
break;
case WM_CREDIT_TICK:
// 多媒体定时器线程只投递消息,真正刷新仍回到窗口线程执行。
HandleCreditTick(hWnd);
break;
case WM_TIMER:
// 所有窗口定时器统一交给应用层计时器模块分发。
HandleTimerMessage(hWnd, wParam);
break;
case WM_SIZE:
// 窗口尺寸变化后重绘,布局函数会按新客户区重新计算缩放。
InvalidateRect(hWnd, nullptr, FALSE);
break;
case WM_LBUTTONUP:
// 输入模块未消费的鼠标消息继续交给 Win32 默认处理。
if (!HandleMouseClick(hWnd, lParam))
{
return DefWindowProc(hWnd, message, wParam, lParam);
@@ -235,6 +240,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
HandleKeyDown(hWnd, wParam);
break;
case WM_ERASEBKGND:
// 背景由双缓冲完整绘制,阻止系统擦背景可以减少闪烁。
return 1;
case WM_PAINT:
{
@@ -251,6 +257,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
clientRect.bottom - clientRect.top);
HBITMAP oldBitmap = (HBITMAP)SelectObject(memDC, memBitmap);
// 所有自绘内容先画到内存位图,再一次性复制到窗口。
TDrawScreen(memDC, hWnd);
BitBlt(
hdc,
@@ -296,10 +303,12 @@ INT_PTR CALLBACK About(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam)
switch (message)
{
case WM_INITDIALOG:
// 资源模板标题是英文,这里在初始化时替换成中文标题。
SetWindowText(hDlg, _T("\u5173\u4e8e\u4fc4\u7f57\u65af\u65b9\u5757"));
return (INT_PTR)TRUE;
case WM_COMMAND:
// 关于框只需要响应确定和取消,其他命令交回默认流程。
if (LOWORD(wParam) == IDOK || LOWORD(wParam) == IDCANCEL)
{
EndDialog(hDlg, LOWORD(wParam));
+26 -308
View File
@@ -50,6 +50,7 @@ bool pendingChainBombFollowup = false;
int bricks[7][4][4][4] =
{
// 方块形状表:7 种方块、每种 4 个旋转状态、每个状态使用 4x4 矩阵描述。
{
{{0, 0, 0, 0}, {0, 0, 0, 0}, {1, 1, 1, 1}, {0, 0, 0, 0}},
{{0, 0, 1, 0}, {0, 0, 1, 0}, {0, 0, 1, 0}, {0, 0, 1, 0}},
@@ -96,6 +97,7 @@ int bricks[7][4][4][4] =
COLORREF BrickColor[7] =
{
// 渲染层按方块编号取色;数组顺序必须与 bricks 中的类型编号一致。
RGB(244, 144, 165),
RGB(255, 181, 197),
RGB(170, 215, 255),
@@ -105,77 +107,6 @@ COLORREF BrickColor[7] =
RGB(197, 170, 255)
};
/**
* @brief 计算指定方块在指定旋转状态下的最小包围盒边界。
*
* 该函数会遍历 4x4 形状矩阵,找出所有非空单元的上下左右边界,
* 供后续统一计算生成位置和对齐方式时使用。
*
* @param brickType 方块类型编号。
* @param brickState 方块旋转状态编号。
* @param minRow 返回最上方非空行号。
* @param maxRow 返回最下方非空行号。
* @param minCol 返回最左侧非空列号。
* @param maxCol 返回最右侧非空列号。
*/
static void GetBrickBounds(int brickType, int brickState, int& minRow, int& maxRow, int& minCol, int& maxCol)
{
minRow = 4;
maxRow = -1;
minCol = 4;
maxCol = -1;
for (int i = 0; i < 4; i++)
{
for (int j = 0; j < 4; j++)
{
if (bricks[brickType][brickState][i][j] != 0)
{
if (i < minRow)
{
minRow = i;
}
if (i > maxRow)
{
maxRow = i;
}
if (j < minCol)
{
minCol = j;
}
if (j > maxCol)
{
maxCol = j;
}
}
}
}
}
/**
* @brief 计算指定方块的统一生成位置。
*
* 该函数会根据方块在初始旋转状态下的最小包围盒,
* 自动把方块水平居中到游戏区附近,并将顶部非空行对齐到可视区域顶部。
* 这样不同形状的方块在生成时看起来会更加统一。
*
* @param brickType 方块类型编号。
* @return Point 计算得到的生成坐标。
*/
Point GetSpawnPoint(int brickType)
{
int minRow, maxRow, minCol, maxCol;
GetBrickBounds(brickType, 0, minRow, maxRow, minCol, maxCol);
int brickWidth = maxCol - minCol + 1;
int brickHeight = maxRow - minRow + 1;
Point spawnPoint;
spawnPoint.x = (nGameWidth - brickWidth) / 2 - minCol;
spawnPoint.y = -brickHeight;
return spawnPoint;
}
/**
* @brief 判断当前方块是否可以继续向下移动。
*
@@ -341,6 +272,7 @@ void MoveRight()
*/
void Rotate()
{
// 第一阶段:直接尝试原地旋转。
int nextState = (state + 1) % 4;
if (IsPiecePlacementValid(type, nextState, point))
{
@@ -350,6 +282,7 @@ void Rotate()
if (currentMode == MODE_ROGUE && rogueStats.perfectRotateLevel > 0)
{
// 第二阶段:Rogue 完美旋转解锁后,尝试左右各一格的墙踢修正。
if (TryRotateWithOffset(nextState, -1))
{
state = nextState;
@@ -381,129 +314,6 @@ void DropDown()
}
}
/**
* @brief 收集当前方块将要固定到棋盘上的格子,并标记是否越过顶部。
* @param overflowTop 返回是否有方块格位于可视区域顶部之外。
* @param fixedCells 返回普通落地格,用于后续特殊效果定位。
* @param fixedCellCount 返回普通落地格数量。
* @param explosiveCells 返回爆破方块落地格。
* @param explosiveCellCount 返回爆破方块落地格数量。
*/
static void CollectAndWriteFixedCells(
bool& overflowTop,
Point fixedCells[],
int& fixedCellCount,
Point explosiveCells[],
int& explosiveCellCount)
{
overflowTop = false;
fixedCellCount = 0;
explosiveCellCount = 0;
for (int i = 0; i < 4; i++)
{
for (int j = 0; j < 4; j++)
{
if (bricks[type][state][i][j] == 0)
{
continue;
}
int fixY = point.y + i;
int fixX = point.x + j;
// 顶部溢出只记录状态,真正的复活或结束逻辑在后续统一处理。
if (fixY < 0)
{
overflowTop = true;
}
if (fixY >= 0 && fixY < GetRoguePlayableHeight() && fixX >= 0 && fixX < nGameWidth)
{
workRegion[fixY][fixX] = currentPieceIsRainbow ? 8 : bricks[type][state][i][j];
if (fixedCellCount < 4)
{
fixedCells[fixedCellCount].x = fixX;
fixedCells[fixedCellCount].y = fixY;
fixedCellCount++;
}
if (currentPieceIsExplosive && explosiveCellCount < 4)
{
explosiveCells[explosiveCellCount].x = fixX;
explosiveCells[explosiveCellCount].y = fixY;
explosiveCellCount++;
}
}
}
}
}
/**
* @brief 处理方块固定时的顶部溢出、终末清场和最后一搏。
* @param overflowTop 是否出现顶部溢出。
* @return 溢出已被处理且游戏可以继续时返回 true;需要结束游戏时返回 false。
*/
static bool ResolveFixingOverflow(bool overflowTop)
{
if (!overflowTop)
{
return true;
}
if (currentMode == MODE_ROGUE && rogueStats.terminalClearLevel > 0 && rogueStats.lastChanceCount > 0 && rogueStats.screenBombCount > 0)
{
rogueStats.lastChanceCount--;
rogueStats.screenBombCount--;
int clearedByTerminal = TriggerScreenBomb();
rogueStats.feverTicks = 10;
currentFallInterval = GetRogueFallInterval();
TCHAR terminalDetail[128];
_stprintf_s(
terminalDetail,
_T("终末清场启动,清除 %d 格,并进入 10 秒狂热。"),
clearedByTerminal);
SetFeedbackMessage(_T("终末清场"), terminalDetail, 14);
return true;
}
if (currentMode == MODE_ROGUE && rogueStats.lastChanceCount > 0)
{
rogueStats.lastChanceCount--;
for (int i = 0; i < 3; i++)
{
DeleteOneLine(GetRoguePlayableHeight() - 1);
}
SetFeedbackMessage(
_T("最后一搏"),
_T("底部 3 行被清除,战局得以延续。"),
14);
return true;
}
gameOverFlag = true;
return false;
}
/**
* @brief 生成下一枚活动方块,并刷新 Hold、特殊方块和预测落点状态。
*/
static void SpawnNextFallingPiece()
{
// 消耗预览队列后重置本回合状态,确保 Hold 和特殊标记只影响新方块。
type = ConsumeNextType();
nType = nextTypes[0];
state = 0;
holdUsedThisTurn = false;
RollCurrentPieceSpecialFlags(true);
point = GetSpawnPoint(type);
target = point;
ComputeTarget();
}
/**
* @brief 将当前活动方块固定到工作区,并生成下一个活动方块。
*
@@ -516,6 +326,7 @@ static void SpawnNextFallingPiece()
*/
void Fixing()
{
// 第一阶段:收集落地格子,并把可见区域内的格子写入工作区。
bool overflowTop = false;
Point fixedCells[4] = {};
int fixedCellCount = 0;
@@ -525,13 +336,16 @@ void Fixing()
CollectAndWriteFixedCells(overflowTop, fixedCells, fixedCellCount, explosiveCells, explosiveCellCount);
// 第二阶段:彩虹方块先按落地中心行处理染色与清除。
ApplyRainbowLandingEffect(overflowTop, fixedCells, fixedCellCount);
// 第三阶段:统一处理顶部溢出,可能触发最后一搏或直接游戏结束。
if (!ResolveFixingOverflow(overflowTop))
{
return;
}
// 第四阶段:结算爆破、激光、十字和稳定结构等普通特殊落地效果。
ApplySpecialLandingEffects(fixedCells, fixedCellCount, explosiveCells, explosiveCellCount);
if (currentMode == MODE_ROGUE)
@@ -539,6 +353,7 @@ void Fixing()
currentFallInterval = GetRogueFallInterval();
}
// 第五阶段:刷新下一枚活动方块,开始新的下落回合。
SpawnNextFallingPiece();
}
@@ -567,120 +382,6 @@ void DeleteOneLine(int number)
}
}
/**
* @brief 从底向上扫描满行并删除,记录本次消除的原始行号。
* @param clearedRows 返回最多 8 个被消除行号,用于播放消行动画。
* @param clearedRowCount 返回记录的行号数量。
* @return 本次标准消行数量。
*/
static int ScanAndDeleteFullLines(int clearedRows[], int& clearedRowCount)
{
int clearedLines = 0;
clearedRowCount = 0;
int playableHeight = GetRoguePlayableHeight();
for (int i = playableHeight - 1; i >= 0; i--)
{
bool fullLine = true;
for (int j = 0; j < nGameWidth; j++)
{
if (workRegion[i][j] == 0)
{
fullLine = false;
break;
}
}
if (fullLine)
{
if (clearedRowCount < 8)
{
clearedRows[clearedRowCount] = i;
clearedRowCount++;
}
DeleteOneLine(i);
clearedLines++;
i++;
}
}
return clearedLines;
}
/**
* @brief 根据当前界面状态立即播放或暂存消行动画。
* @param clearedRows 已消除行号数组。
* @param clearedRowCount 行号数量。
* @param clearedLines 本次消行数量。
*/
static void DispatchLineClearEffect(const int clearedRows[], int clearedRowCount, int clearedLines)
{
if (currentScreen == SCREEN_UPGRADE)
{
QueueLineClearEffect(clearedRows, clearedRowCount, clearedLines);
}
else
{
TriggerLineClearEffect(clearedRows, clearedRowCount, clearedLines);
}
}
/**
* @brief 处理连环炸弹因消行触发的一次追加 3x3 爆破。
* @param clearedLines 本次标准消行数量。
*/
static void ResolveChainBombFollowup(int clearedLines)
{
if (!pendingChainBombFollowup || clearedLines <= 0)
{
pendingChainBombFollowup = false;
return;
}
pendingChainBombFollowup = false;
int followupCleared = 0;
int centerY = pendingChainBombCenter.y;
int centerX = pendingChainBombCenter.x;
Point followupCells[9] = {};
for (int y = centerY - 1; y <= centerY + 1; y++)
{
for (int x = centerX - 1; x <= centerX + 1; x++)
{
if (y >= 0 && y < GetRoguePlayableHeight() && x >= 0 && x < nGameWidth && workRegion[y][x] != 0)
{
if (followupCleared < 9)
{
followupCells[followupCleared].x = x;
followupCells[followupCleared].y = y;
}
workRegion[y][x] = 0;
followupCleared++;
}
}
}
if (currentMode == MODE_ROGUE && followupCleared > 0)
{
TriggerCellClearEffect(followupCells, followupCleared < 9 ? followupCleared : 9, true);
int followupScore = 0;
int followupExp = 0;
AwardRogueSkillClearRewards(followupCleared, followupScore, followupExp, false);
TCHAR followupDetail[128];
_stprintf_s(
followupDetail,
_T("追加爆炸清除 %d 格 +%d 分 +%d EXP"),
followupCleared,
followupScore,
followupExp);
SetFeedbackMessage(_T("连环炸弹"), followupDetail, 12);
}
}
/**
* @brief 检查并删除所有已满的行,同时更新当前得分。
*
@@ -707,6 +408,19 @@ int DeleteLines()
return clearedLines;
}
/**
* @brief 判断当前游戏是否已经结束。
*
* 老师作业框架中保留该函数名,当前项目内部的结束状态统一记录在
* gameOverFlag 中,因此这里直接返回该标记,避免改变原有流程。
*
* @return 游戏结束返回 true,否则返回 false。
*/
bool GameOver()
{
return gameOverFlag;
}
/**
* @brief 计算当前活动方块的预测落点位置。
*
@@ -742,6 +456,7 @@ void ComputeTarget()
*/
void Restart()
{
// 第一阶段:清空棋盘数组,移除上一局所有固定方块。
for (int i = 0; i < nGameHeight; i++)
{
for (int j = 0; j < nGameWidth; j++)
@@ -750,12 +465,14 @@ void Restart()
}
}
// 第二阶段:恢复基本游戏标志和默认下落速度。
gameOverFlag = false;
suspendFlag = false;
targetFlag = true;
reviveAvailable = true;
currentFallInterval = 500;
// 第三阶段:重置两种模式的统计、升级 UI、反馈和所有视觉特效。
ResetPlayerStats(classicStats, false);
ResetPlayerStats(rogueStats, true);
ResetUpgradeUiState();
@@ -772,6 +489,7 @@ void Restart()
RollCurrentPieceSpecialFlags(false);
tScore = 0;
// 第四阶段:初始化下一方块队列,并生成当前活动方块。
ResetNextQueue();
type = ConsumeNextType();
nType = nextTypes[0];
File diff suppressed because it is too large Load Diff
+9
View File
@@ -775,6 +775,7 @@ static bool HandleDemoPlayingKey(HWND hWnd, WPARAM key)
*/
static bool HandleBattleControlKey(HWND hWnd, WPARAM key)
{
// 菜单、重开和暂停只对真实战局开放,避免破坏技能演示预设流程。
if (!IsRogueSkillDemoMode() && key == 'M')
{
ReturnToMainMenu();
@@ -799,6 +800,7 @@ static bool HandleBattleControlKey(HWND hWnd, WPARAM key)
if (key == 'G')
{
// 落点提示是显示开关,不改变棋盘或方块状态。
targetFlag = !targetFlag;
InvalidateRect(hWnd, nullptr, FALSE);
return true;
@@ -806,6 +808,7 @@ static bool HandleBattleControlKey(HWND hWnd, WPARAM key)
if (gameOverFlag && reviveAvailable && key == 'V')
{
// 复活机会只有视频成功播放后才消耗,失败时保留机会并给出反馈。
if (PlayReviveVideo(hWnd))
{
ReviveAfterVideo();
@@ -832,6 +835,7 @@ static bool HandleRogueSkillKey(HWND hWnd, WPARAM key)
{
if (currentMode == MODE_ROGUE && (key == 'J' || key == 'K'))
{
// Rogue 侧栏强化列表较长,J/K 只调整说明列表的滚动位置。
int direction = (key == 'J') ? 1 : -1;
AdjustScrollOffset(upgradeListScrollOffset, direction * GetScrollStep(hWnd, 52));
InvalidateRect(hWnd, nullptr, FALSE);
@@ -840,6 +844,7 @@ static bool HandleRogueSkillKey(HWND hWnd, WPARAM key)
switch (key)
{
// 主动技能和 Hold 的按键统一在这里分发,技能内部会自行检查次数和模式。
case 'C':
case VK_SHIFT:
case VK_LSHIFT:
@@ -865,6 +870,7 @@ static bool HandleRogueSkillKey(HWND hWnd, WPARAM key)
*/
static void FixPieceAndResolveLines()
{
// 固定方块后立即检查满行,Rogue 模式还可能因为经验变化打开升级界面。
Fixing();
if (!gameOverFlag)
{
@@ -898,6 +904,7 @@ static bool HandlePieceMovementKey(WPARAM key)
return true;
case VK_DOWN:
case 'S':
// 软降被阻挡时等价于本回合落地,立即进入固定和消行流程。
if (CanMoveDown())
{
MoveDown();
@@ -912,6 +919,7 @@ static bool HandlePieceMovementKey(WPARAM key)
Rotate();
return true;
case VK_SPACE:
// 硬降先移动到最低合法位置,再一次性固定结算。
DropDown();
FixPieceAndResolveLines();
return true;
@@ -958,6 +966,7 @@ static void HandlePlayingKey(HWND hWnd, WPARAM key)
*/
void HandleKeyDown(HWND hWnd, WPARAM wParam)
{
// 按当前界面从上到下分发:菜单、帮助、升级界面优先消费按键。
if (HandleMenuKey(hWnd, wParam) ||
HandleRulesKey(hWnd, wParam) ||
HandleUpgradeKey(hWnd, wParam))
+9
View File
@@ -13,6 +13,7 @@
*/
void AdjustScrollOffset(int& scrollOffset, int delta)
{
// 先应用本次滚动增量,再统一夹紧到允许范围内。
scrollOffset += delta;
if (scrollOffset < 0)
{
@@ -46,6 +47,7 @@ LayoutMetrics GetLayoutMetrics(HWND hWnd)
RECT clientRect;
GetClientRect(hWnd, &clientRect);
// 以设计稿窗口为基准计算缩放,取较小比例保证完整界面不被裁切。
int clientWidth = clientRect.right - clientRect.left;
int clientHeight = clientRect.bottom - clientRect.top;
int scaleX = MulDiv(clientWidth, 1000, WINDOW_CLIENT_WIDTH);
@@ -60,6 +62,7 @@ LayoutMetrics GetLayoutMetrics(HWND hWnd)
metrics.scale = scale;
metrics.layoutWidth = MulDiv(WINDOW_CLIENT_WIDTH, scale, 1000);
metrics.layoutHeight = MulDiv(WINDOW_CLIENT_HEIGHT, scale, 1000);
// 横向居中显示,纵向从顶部开始,方便窗口高度不足时保持棋盘起点稳定。
metrics.offsetX = (clientWidth - metrics.layoutWidth) / 2;
metrics.offsetY = 0;
metrics.grid = MulDiv(GRID, scale, 1000);
@@ -289,6 +292,8 @@ RECT GetUpgradeCardRect(HWND hWnd, int index)
{
LayoutMetrics metrics = GetLayoutMetrics(hWnd);
RECT overlayRect = GetUpgradeOverlayRect(hWnd);
// 根据当前候选数量自动决定列数;最多三列,两行用于命运轮盘六选项。
int gap = ScaleValue(metrics, 18);
int horizontalPadding = ScaleValue(metrics, 36);
int verticalTop = overlayRect.top + ScaleValue(metrics, 138);
@@ -347,6 +352,8 @@ RECT GetOverlayButtonRect(HWND hWnd, int index, int buttonCount)
{
LayoutMetrics metrics = GetLayoutMetrics(hWnd);
RECT overlayRect = GetGameOverlayRect(hWnd);
// 游戏结束可能有三个按钮,暂停只有两个按钮,因此间距和边距分开计算。
int gap = buttonCount == 3 ? ScaleValue(metrics, 8) : ScaleValue(metrics, 18);
int sidePadding = buttonCount == 3 ? ScaleValue(metrics, 14) : ScaleValue(metrics, 34);
int width = (overlayRect.right - overlayRect.left - sidePadding * 2 - gap * (buttonCount - 1)) / buttonCount;
@@ -383,6 +390,8 @@ RECT GetBackButtonRect(HWND hWnd)
RECT GetMusicButtonRect(HWND hWnd)
{
LayoutMetrics metrics = GetLayoutMetrics(hWnd);
// 音乐按钮保持最小可点击尺寸,避免窗口缩小时变得难以点中。
int size = ScaleValue(metrics, 28);
if (size < 22)
{
+9
View File
@@ -23,6 +23,7 @@ static constexpr const wchar_t* kReviveVideoAlias = L"TereisReviveVideo";
*/
static bool TryPlayMciLoop(const std::wstring& path, bool forceMpegVideo)
{
// 资源不存在时直接失败,让上层继续尝试下一个候选路径或格式。
if (!FileExists(path))
{
return false;
@@ -61,6 +62,7 @@ static bool TryPlayMciLoop(const std::wstring& path, bool forceMpegVideo)
*/
void StopBackgroundMusic()
{
// 根据当前播放方式选择对应的释放接口,避免 MCI 设备或 PlaySound 残留。
if (bgmUsingMci)
{
mciSendStringW((std::wstring(L"stop ") + kBgmAlias).c_str(), nullptr, 0, nullptr);
@@ -80,6 +82,7 @@ void StopBackgroundMusic()
*/
void StartBackgroundMusic()
{
// 音乐被关闭或已经在播放时,不重复查找资源和启动设备。
if (!bgmEnabled || bgmPlaying)
{
return;
@@ -94,6 +97,7 @@ void StartBackgroundMusic()
for (const std::wstring& candidate : bgmWavCandidates)
{
// WAV 优先使用 PlaySound,依赖少、兼容性最好。
if (FileExists(candidate) &&
PlaySoundW(candidate.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_LOOP))
{
@@ -112,6 +116,7 @@ void StartBackgroundMusic()
for (const std::wstring& candidate : oggCandidates)
{
// OGG 通过 MCI 尝试普通打开和 mpegvideo 强制类型两条路径。
if (TryPlayMciLoop(candidate, false) || TryPlayMciLoop(candidate, true))
{
return;
@@ -127,6 +132,7 @@ void StartBackgroundMusic()
for (const std::wstring& candidate : fallbackWavCandidates)
{
// 兼容旧资源名 background.wav,保证替换素材后仍能播放。
if (FileExists(candidate) &&
PlaySoundW(candidate.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_LOOP))
{
@@ -164,6 +170,7 @@ void ToggleBackgroundMusic(HWND hWnd)
*/
bool PlayReviveVideo(HWND hWnd)
{
// 依次查找 AVI 和 MP4,并同时支持构建目录与项目根目录运行。
std::wstring videoPath = BuildAssetPath(L"assets\\video\\video.avi");
if (!FileExists(videoPath))
{
@@ -185,6 +192,7 @@ bool PlayReviveVideo(HWND hWnd)
bool shouldResumeBgm = bgmEnabled;
if (bgmPlaying)
{
// 视频播放期间暂停背景音乐,播放结束后按开关状态恢复。
StopBackgroundMusic();
}
@@ -214,6 +222,7 @@ bool PlayReviveVideo(HWND hWnd)
if (!played)
{
// MCI 全屏播放失败时退回系统默认播放器,并等待播放器进程结束。
SHELLEXECUTEINFOW shellInfo = {};
shellInfo.cbSize = sizeof(shellInfo);
shellInfo.fMask = SEE_MASK_NOCLOSEPROCESS;
+18
View File
@@ -27,6 +27,7 @@ static void CALLBACK CreditTimerCallback(UINT, UINT, DWORD_PTR userData, DWORD_P
*/
void ResetGameTimer(HWND hWnd)
{
// 下落速度会被 Rogue 强化和临时状态动态修改,因此每次变化都重新注册定时器。
KillTimer(hWnd, GAME_TIMER_ID);
SetTimer(hWnd, GAME_TIMER_ID, currentFallInterval > 0 ? currentFallInterval : GAME_TIMER_INTERVAL, nullptr);
}
@@ -37,8 +38,11 @@ void ResetGameTimer(HWND hWnd)
*/
void StartAppTimers(HWND hWnd)
{
// 主定时器负责方块下落,特效定时器负责高帧率视觉动画。
ResetGameTimer(hWnd);
SetTimer(hWnd, EFFECT_TIMER_ID, EFFECT_TIMER_INTERVAL, nullptr);
// 致谢页动画需要更高刷新频率,优先使用多媒体定时器。
creditTimerHandle = timeSetEvent(
CREDIT_TIMER_INTERVAL,
1,
@@ -58,6 +62,7 @@ void StartAppTimers(HWND hWnd)
*/
void StopAppTimers(HWND hWnd)
{
// 退出或窗口销毁时释放所有可能创建过的计时器资源。
KillTimer(hWnd, GAME_TIMER_ID);
KillTimer(hWnd, EFFECT_TIMER_ID);
if (creditTimerHandle != 0)
@@ -92,6 +97,7 @@ static bool TickRogueTimedStates(HWND hWnd)
{
bool shouldRefresh = false;
// 狂热、缓流、极限缓速和 Hold 缓速都会影响下落间隔,需要同步重置主定时器。
if (currentMode == MODE_ROGUE && !IsRogueSkillDemoMode() && rogueStats.feverTicks > 0)
{
rogueStats.feverTicks--;
@@ -139,6 +145,7 @@ static bool TickRogueTimedStates(HWND hWnd)
*/
static bool TickExtremeDanger(HWND hWnd)
{
// 极限玩家只在真实 Rogue 战局中计时,暂停、结束和技能演示都不推进危险等级。
if (currentMode != MODE_ROGUE ||
IsRogueSkillDemoMode() ||
rogueStats.extremePlayerLevel <= 0 ||
@@ -151,10 +158,12 @@ static bool TickExtremeDanger(HWND hWnd)
if (rogueStats.extremeDangerTicks > 0)
{
// 计时尚未结束时只递减倒计时,不改变速度。
rogueStats.extremeDangerTicks--;
return false;
}
// 每 30 个主计时周期未完成四消就提高危险等级,并立即刷新下落速度。
rogueStats.extremeDangerTicks = 30;
if (rogueStats.extremeDangerLevel < 5)
{
@@ -176,6 +185,7 @@ static bool TickExtremeDanger(HWND hWnd)
*/
static bool TryStartTimeDilation(HWND hWnd)
{
// 时间缓流是自动保命效果,已经在持续时不会重复触发。
if (currentMode != MODE_ROGUE ||
IsRogueSkillDemoMode() ||
rogueStats.timeDilationLevel <= 0 ||
@@ -186,6 +196,7 @@ static bool TryStartTimeDilation(HWND hWnd)
int occupiedHeight = 0;
int playableHeight = GetRoguePlayableHeight();
// 自上而下寻找第一行有方块的位置,由此换算当前堆叠高度。
for (int y = 0; y < playableHeight; y++)
{
bool hasCell = false;
@@ -231,6 +242,7 @@ static bool TickGameFall(HWND hWnd)
return false;
}
// Rogue 难度随时间推进,速度变化后需要重新安排下一次自动下落。
if (currentMode == MODE_ROGUE && !IsRogueSkillDemoMode())
{
int previousFallInterval = currentFallInterval;
@@ -243,6 +255,7 @@ static bool TickGameFall(HWND hWnd)
TryStartTimeDilation(hWnd);
// 能下落时只移动一格;被阻挡时固定方块,并进入消行与升级结算。
if (CanMoveDown())
{
MoveDown();
@@ -259,6 +272,7 @@ static bool TickGameFall(HWND hWnd)
if (!gameOverFlag)
{
// 真实方块位置变化后刷新预测落点,供渲染层绘制目标提示。
ComputeTarget();
}
@@ -274,6 +288,7 @@ void HandleTimerMessage(HWND hWnd, WPARAM timerId)
{
if (timerId == EFFECT_TIMER_ID)
{
// 视觉特效独立于主下落速度,用固定帧率推进。
if (TickVisualEffects())
{
InvalidateRect(hWnd, nullptr, FALSE);
@@ -283,6 +298,7 @@ void HandleTimerMessage(HWND hWnd, WPARAM timerId)
if (timerId == CREDIT_TIMER_ID && creditTimerHandle == 0)
{
// 多媒体定时器不可用时,普通窗口定时器承担致谢页动画刷新。
HandleCreditTick(hWnd);
return;
}
@@ -295,10 +311,12 @@ void HandleTimerMessage(HWND hWnd, WPARAM timerId)
bool shouldRefresh = false;
if (feedbackState.visibleTicks > 0)
{
// 右侧反馈信息按主计时周期自动消退。
feedbackState.visibleTicks--;
shouldRefresh = true;
}
// 主定时器集中推进演示、Rogue 临时状态、危险等级和自然下落。
if (IsRogueSkillDemoMode() && TickRogueSkillDemo())
{
shouldRefresh = true;
+3
View File
@@ -20,6 +20,7 @@ std::wstring BuildAssetPath(const wchar_t* relativePath)
wchar_t modulePath[MAX_PATH] = {};
GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
// 先取可执行文件所在目录,再根据构建目录层级回到项目根目录。
std::wstring basePath(modulePath);
size_t lastSlash = basePath.find_last_of(L"\\/");
if (lastSlash != std::wstring::npos)
@@ -56,6 +57,7 @@ std::wstring BuildWorkingDirAssetPath(const wchar_t* relativePath)
return L"";
}
// 当前工作目录可能已经是项目根目录,直接拼接相对资源路径。
std::wstring candidate = std::wstring(currentDirectory) + L"\\" + relativePath;
wchar_t fullPath[MAX_PATH] = {};
DWORD result = GetFullPathNameW(candidate.c_str(), MAX_PATH, fullPath, nullptr);
@@ -74,6 +76,7 @@ std::wstring BuildWorkingDirAssetPath(const wchar_t* relativePath)
*/
bool FileExists(const std::wstring& path)
{
// 目录不能作为媒体或图片资源使用,因此排除 FILE_ATTRIBUTE_DIRECTORY。
DWORD attributes = GetFileAttributesW(path.c_str());
return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0;
}
@@ -18,10 +18,12 @@ int pendingLineClearEffectLineCount = 0;
*/
void ResetPlayerStats(PlayerStats& stats, bool useRogueRules)
{
// 基础得分、等级和经验先恢复到新局起点。
stats.score = 0;
stats.level = 1;
stats.exp = 0;
stats.requiredExp = useRogueRules ? 10 : 0;
// 强化等级、主动技能次数和限时状态全部清零,避免跨局继承。
stats.totalLinesCleared = 0;
stats.scoreMultiplierPercent = 100;
stats.expMultiplierPercent = 100;
@@ -84,6 +86,7 @@ void ResetPlayerStats(PlayerStats& stats, bool useRogueRules)
stats.difficultyElapsedMs = 0;
stats.difficultyLevel = 0;
stats.lockedRows = 0;
// 方块改造按 7 种方块分别记录等级,重开时逐项清空。
for (int i = 0; i < 7; i++)
{
stats.pieceTuningLevels[i] = 0;
@@ -98,6 +101,7 @@ void ResetPlayerStats(PlayerStats& stats, bool useRogueRules)
*/
void SetFeedbackMessage(const TCHAR* title, const TCHAR* detail, int ticks)
{
// 使用 lstrcpyn 限长复制,避免长描述写出固定缓冲区。
feedbackState.visibleTicks = ticks;
lstrcpyn(feedbackState.title, title, sizeof(feedbackState.title) / sizeof(TCHAR));
lstrcpyn(feedbackState.detail, detail, sizeof(feedbackState.detail) / sizeof(TCHAR));
@@ -108,6 +112,7 @@ void SetFeedbackMessage(const TCHAR* title, const TCHAR* detail, int ticks)
*/
void ResetVisualEffects()
{
// 主状态和各类效果槽位只需把 ticks 清零,渲染层会自动忽略非活动项。
clearEffectState.ticks = 0;
clearEffectState.totalTicks = 0;
clearEffectState.rowCount = 0;
@@ -141,6 +146,7 @@ bool TickVisualEffects()
{
bool active = false;
// 所有效果共用倒计时推进,任意效果仍活动就请求界面刷新。
if (clearEffectState.ticks > 0)
{
clearEffectState.ticks--;
@@ -210,6 +216,7 @@ bool TickCreditAnimation()
*/
static void AddFloatingText(int boardX, int boardY, const TCHAR* text, COLORREF color)
{
// 复用第一个空闲槽位,槽位满时丢弃新效果,避免动态分配。
for (int i = 0; i < 8; i++)
{
if (floatingTextEffects[i].ticks <= 0)
@@ -589,6 +596,7 @@ bool TryRotateWithOffset(int nextState, int offsetX)
*/
void ReviveAfterVideo()
{
// 只有游戏结束且复活机会仍在时才能进入复活流程。
if (!gameOverFlag || !reviveAvailable)
{
return;
@@ -606,6 +614,7 @@ void ReviveAfterVideo()
rowsToClear = 5;
}
// 清理顶部一段空间,避免新方块刚生成又立即判定失败。
for (int y = 0; y < rowsToClear && y < playableHeight; y++)
{
for (int x = 0; x < nGameWidth; x++)
@@ -614,6 +623,7 @@ void ReviveAfterVideo()
}
}
// 复活后重新取一个活动方块,并刷新落点提示。
type = ConsumeNextType();
nType = nextTypes[0];
state = 0;
@@ -632,6 +642,7 @@ void ReviveAfterVideo()
*/
void StartGameWithMode(int mode)
{
// 模式切换后直接复用 Restart,保证经典和 Rogue 都从干净状态开始。
rogueDemoMode = false;
currentMode = mode;
currentScreen = SCREEN_PLAYING;
@@ -646,6 +657,7 @@ void StartGameWithMode(int mode)
*/
void ReturnToMainMenu()
{
// 回到主菜单时关闭所有临时战局、帮助页和升级界面状态。
rogueDemoMode = false;
currentScreen = SCREEN_MENU;
suspendFlag = false;
@@ -732,6 +744,7 @@ void ChangeCreditPage(int direction)
return;
}
// 页码循环切换,同时记录动画方向用于渲染滑动效果。
int oldPageIndex = creditPageIndex;
if (direction > 0)
{
+323
View File
@@ -0,0 +1,323 @@
#include "stdafx.h"
/**
* @file TetrisCoreHelpers.cpp
* @brief 存放基础逻辑框架函数之外的内部辅助流程。
*/
#include "Tetris.h"
#include "TetrisLogicInternal.h"
/**
* @brief 计算指定方块在指定旋转状态下的最小包围盒边界。
*
* 该函数会遍历 4x4 形状矩阵,找出所有非空单元的上下左右边界,
* 供后续统一计算生成位置和对齐方式时使用。
*
* @param brickType 方块类型编号。
* @param brickState 方块旋转状态编号。
* @param minRow 返回最上方非空行号。
* @param maxRow 返回最下方非空行号。
* @param minCol 返回最左侧非空列号。
* @param maxCol 返回最右侧非空列号。
*/
static void GetBrickBounds(int brickType, int brickState, int& minRow, int& maxRow, int& minCol, int& maxCol)
{
// 初始值设置在矩阵之外,遍历到第一个非空格后会被收缩到真实边界。
minRow = 4;
maxRow = -1;
minCol = 4;
maxCol = -1;
for (int i = 0; i < 4; i++)
{
for (int j = 0; j < 4; j++)
{
if (bricks[brickType][brickState][i][j] != 0)
{
if (i < minRow)
{
minRow = i;
}
if (i > maxRow)
{
maxRow = i;
}
if (j < minCol)
{
minCol = j;
}
if (j > maxCol)
{
maxCol = j;
}
}
}
}
}
/**
* @brief 计算指定方块的统一生成位置。
*
* 该函数会根据方块在初始旋转状态下的最小包围盒,
* 自动把方块水平居中到游戏区附近,并将顶部非空行对齐到可视区域顶部。
* 这样不同形状的方块在生成时看起来会更加统一。
*
* @param brickType 方块类型编号。
* @return Point 计算得到的生成坐标。
*/
Point GetSpawnPoint(int brickType)
{
int minRow, maxRow, minCol, maxCol;
GetBrickBounds(brickType, 0, minRow, maxRow, minCol, maxCol);
// 只使用初始状态的包围盒计算出生点,保持每种方块生成位置稳定。
int brickWidth = maxCol - minCol + 1;
int brickHeight = maxRow - minRow + 1;
Point spawnPoint;
spawnPoint.x = (nGameWidth - brickWidth) / 2 - minCol;
spawnPoint.y = -brickHeight;
return spawnPoint;
}
/**
* @brief 收集当前方块将要固定到棋盘上的格子,并标记是否越过顶部。
* @param overflowTop 返回是否有方块格位于可视区域顶部之外。
* @param fixedCells 返回普通落地格,用于后续特殊效果定位。
* @param fixedCellCount 返回普通落地格数量。
* @param explosiveCells 返回爆破方块落地格。
* @param explosiveCellCount 返回爆破方块落地格数量。
*/
void CollectAndWriteFixedCells(
bool& overflowTop,
Point fixedCells[],
int& fixedCellCount,
Point explosiveCells[],
int& explosiveCellCount)
{
overflowTop = false;
fixedCellCount = 0;
explosiveCellCount = 0;
for (int i = 0; i < 4; i++)
{
for (int j = 0; j < 4; j++)
{
if (bricks[type][state][i][j] == 0)
{
continue;
}
int fixY = point.y + i;
int fixX = point.x + j;
// 顶部溢出只记录状态,真正的复活或结束逻辑在后续统一处理。
if (fixY < 0)
{
overflowTop = true;
}
if (fixY >= 0 && fixY < GetRoguePlayableHeight() && fixX >= 0 && fixX < nGameWidth)
{
workRegion[fixY][fixX] = currentPieceIsRainbow ? 8 : bricks[type][state][i][j];
if (fixedCellCount < 4)
{
fixedCells[fixedCellCount].x = fixX;
fixedCells[fixedCellCount].y = fixY;
fixedCellCount++;
}
if (currentPieceIsExplosive && explosiveCellCount < 4)
{
explosiveCells[explosiveCellCount].x = fixX;
explosiveCells[explosiveCellCount].y = fixY;
explosiveCellCount++;
}
}
}
}
}
/**
* @brief 处理方块固定时的顶部溢出、终末清场和最后一搏。
* @param overflowTop 是否出现顶部溢出。
* @return 溢出已被处理且游戏可以继续时返回 true;需要结束游戏时返回 false。
*/
bool ResolveFixingOverflow(bool overflowTop)
{
if (!overflowTop)
{
return true;
}
// 终末清场优先级高于普通最后一搏,会消耗一次最后一搏和一枚清屏炸弹。
if (currentMode == MODE_ROGUE && rogueStats.terminalClearLevel > 0 && rogueStats.lastChanceCount > 0 && rogueStats.screenBombCount > 0)
{
rogueStats.lastChanceCount--;
rogueStats.screenBombCount--;
int clearedByTerminal = TriggerScreenBomb();
rogueStats.feverTicks = 10;
currentFallInterval = GetRogueFallInterval();
TCHAR terminalDetail[128];
_stprintf_s(
terminalDetail,
_T("终末清场启动,清除 %d 格,并进入 10 秒狂热。"),
clearedByTerminal);
SetFeedbackMessage(_T("终末清场"), terminalDetail, 14);
return true;
}
// 最后一搏只清理底部三行,让顶部溢出的局面获得一次继续机会。
if (currentMode == MODE_ROGUE && rogueStats.lastChanceCount > 0)
{
rogueStats.lastChanceCount--;
for (int i = 0; i < 3; i++)
{
DeleteOneLine(GetRoguePlayableHeight() - 1);
}
SetFeedbackMessage(
_T("最后一搏"),
_T("底部 3 行被清除,战局得以延续。"),
14);
return true;
}
gameOverFlag = true;
return false;
}
/**
* @brief 生成下一枚活动方块,并刷新 Hold、特殊方块和预测落点状态。
*/
void SpawnNextFallingPiece()
{
// 消耗预览队列后重置本回合状态,确保 Hold 和特殊标记只影响新方块。
type = ConsumeNextType();
nType = nextTypes[0];
state = 0;
holdUsedThisTurn = false;
RollCurrentPieceSpecialFlags(true);
point = GetSpawnPoint(type);
target = point;
ComputeTarget();
}
/**
* @brief 从底向上扫描满行并删除,记录本次消除的原始行号。
* @param clearedRows 返回最多 8 个被消除行号,用于播放消行动画。
* @param clearedRowCount 返回记录的行号数量。
* @return 本次标准消行数量。
*/
int ScanAndDeleteFullLines(int clearedRows[], int& clearedRowCount)
{
int clearedLines = 0;
clearedRowCount = 0;
// 从底向上扫描,删除后 i++ 让当前位置继续检查新落下来的行。
int playableHeight = GetRoguePlayableHeight();
for (int i = playableHeight - 1; i >= 0; i--)
{
bool fullLine = true;
for (int j = 0; j < nGameWidth; j++)
{
if (workRegion[i][j] == 0)
{
fullLine = false;
break;
}
}
if (fullLine)
{
if (clearedRowCount < 8)
{
clearedRows[clearedRowCount] = i;
clearedRowCount++;
}
DeleteOneLine(i);
clearedLines++;
i++;
}
}
return clearedLines;
}
/**
* @brief 根据当前界面状态立即播放或暂存消行动画。
* @param clearedRows 已消除行号数组。
* @param clearedRowCount 行号数量。
* @param clearedLines 本次消行数量。
*/
void DispatchLineClearEffect(const int clearedRows[], int clearedRowCount, int clearedLines)
{
if (currentScreen == SCREEN_UPGRADE)
{
QueueLineClearEffect(clearedRows, clearedRowCount, clearedLines);
}
else
{
TriggerLineClearEffect(clearedRows, clearedRowCount, clearedLines);
}
}
/**
* @brief 处理连环炸弹因消行触发的一次追加 3x3 爆破。
* @param clearedLines 本次标准消行数量。
*/
void ResolveChainBombFollowup(int clearedLines)
{
// 没有标准消行时,连环炸弹追加爆破不触发,并清掉挂起标记。
if (!pendingChainBombFollowup || clearedLines <= 0)
{
pendingChainBombFollowup = false;
return;
}
pendingChainBombFollowup = false;
// 追加爆破以第一次爆破落地点为中心,只执行一次 3x3 清除。
int followupCleared = 0;
int centerY = pendingChainBombCenter.y;
int centerX = pendingChainBombCenter.x;
Point followupCells[9] = {};
for (int y = centerY - 1; y <= centerY + 1; y++)
{
for (int x = centerX - 1; x <= centerX + 1; x++)
{
if (y >= 0 && y < GetRoguePlayableHeight() && x >= 0 && x < nGameWidth && workRegion[y][x] != 0)
{
if (followupCleared < 9)
{
followupCells[followupCleared].x = x;
followupCells[followupCleared].y = y;
}
workRegion[y][x] = 0;
followupCleared++;
}
}
}
if (currentMode == MODE_ROGUE && followupCleared > 0)
{
TriggerCellClearEffect(followupCells, followupCleared < 9 ? followupCleared : 9, true);
int followupScore = 0;
int followupExp = 0;
AwardRogueSkillClearRewards(followupCleared, followupScore, followupExp, false);
TCHAR followupDetail[128];
_stprintf_s(
followupDetail,
_T("追加爆炸清除 %d 格 +%d 分 +%d EXP"),
followupCleared,
followupScore,
followupExp);
SetFeedbackMessage(_T("连环炸弹"), followupDetail, 12);
}
}
+8
View File
@@ -14,6 +14,7 @@
*/
void ApplyRainbowLandingEffect(bool overflowTop, const Point* fixedCells, int fixedCellCount)
{
// 顶部溢出时优先交给失败/复活逻辑处理,避免在不可见区域触发奖励。
if (overflowTop || !currentPieceIsRainbow)
{
return;
@@ -39,6 +40,7 @@ void ApplyRainbowLandingEffect(bool overflowTop, const Point* fixedCells, int fi
rainbowAnchorRow = GetRoguePlayableHeight() - 1;
}
// 第二阶段:按锚点行执行彩虹清除和覆盖行染色。
int rainbowRecoloredCount = 0;
int rainbowClearedCount = TriggerRainbowColorShift(rainbowAnchorRow, point.y, point.y + 3, rainbowRecoloredCount);
int rainbowScore = 0;
@@ -48,6 +50,7 @@ void ApplyRainbowLandingEffect(bool overflowTop, const Point* fixedCells, int fi
int voidExp = 0;
if (currentMode == MODE_ROGUE && rainbowClearedCount > 0)
{
// Rogue 模式下特殊清除也能获得得分和经验,但不直接触发升级菜单。
AwardRogueSkillClearRewards(rainbowClearedCount, rainbowScore, rainbowExp, false);
if (rogueStats.voidCoreLevel > 0)
{
@@ -86,11 +89,13 @@ void ApplyRainbowLandingEffect(bool overflowTop, const Point* fixedCells, int fi
*/
static void ApplyExplosiveLandingEffect(const Point* explosiveCells, int explosiveCellCount)
{
// 非爆破方块直接跳过,保持普通方块落地流程轻量。
if (!currentPieceIsExplosive)
{
return;
}
// 每个落地格都作为爆心清除范围,连环炸弹会扩大底层清除函数的范围。
int explosiveCellsCleared = 0;
for (int i = 0; i < explosiveCellCount; i++)
{
@@ -128,6 +133,7 @@ static void ApplyExplosiveLandingEffect(const Point* explosiveCells, int explosi
*/
static void ApplyLaserLandingEffect(const Point* fixedCells, int fixedCellCount)
{
// 激光方块以落地格平均列作为贯穿列,减少不同形状造成的位置偏差。
if (!currentPieceIsLaser)
{
return;
@@ -172,6 +178,7 @@ static void ApplyLaserLandingEffect(const Point* fixedCells, int fixedCellCount)
*/
static void ApplyCrossLandingEffect(const Point* fixedCells, int fixedCellCount)
{
// 十字方块同时计算中心行和中心列,后续分别触发行清除与列清除。
if (!currentPieceIsCross)
{
return;
@@ -248,6 +255,7 @@ static void ApplyStableStructureEffect()
*/
void ApplySpecialLandingEffects(const Point* fixedCells, int fixedCellCount, const Point* explosiveCells, int explosiveCellCount)
{
// 多种特殊标记按固定顺序结算,保证同一落地事件的反馈和奖励稳定。
ApplyExplosiveLandingEffect(explosiveCells, explosiveCellCount);
ApplyLaserLandingEffect(fixedCells, fixedCellCount);
ApplyCrossLandingEffect(fixedCells, fixedCellCount);
+4
View File
@@ -20,11 +20,13 @@ using namespace Gdiplus;
*/
static Bitmap* TryLoadBitmap(const std::wstring& path)
{
// 空路径和不存在的文件不交给 GDI+,减少无效加载开销。
if (path.empty() || !FileExists(path))
{
return nullptr;
}
// GDI+ 返回对象后仍需检查状态,失败对象要立即释放。
Bitmap* loadedImage = Bitmap::FromFile(path.c_str(), FALSE);
if (loadedImage != nullptr && loadedImage->GetLastStatus() == Ok)
{
@@ -65,6 +67,7 @@ Bitmap* LoadBackgroundImage()
static Bitmap* backgroundImage = nullptr;
static bool attempted = false;
// 背景图只查找一次,失败后也记住结果,避免每帧重复访问磁盘。
if (!attempted)
{
attempted = true;
@@ -110,6 +113,7 @@ Bitmap* LoadCreditImage(int index)
return nullptr;
}
// 每张致谢图单独缓存,只有首次进入对应页时才加载。
if (!attempted[index])
{
attempted[index] = true;
File diff suppressed because it is too large Load Diff
+35
View File
@@ -228,6 +228,7 @@ static void StartRogueSkillDemoInternal(int demoIndex, bool autoAdvance);
*/
static int GetNextPreviewLimit()
{
// 经典模式固定只显示一个预览,Rogue 模式再按强化等级限制到 1~3 个。
if (currentMode != MODE_ROGUE)
{
return 1;
@@ -251,6 +252,7 @@ static int GetNextPreviewLimit()
*/
int GetRogueLockedRows()
{
// 底部封锁只属于 Rogue 难度系统,其他模式保持完整 20 行棋盘。
if (currentMode != MODE_ROGUE)
{
return 0;
@@ -297,6 +299,7 @@ static void ClearLockedRows()
*/
void AdvanceRogueDifficulty(int elapsedMs)
{
// 技能演示、暂停、结束和非游玩界面都不推进真实难度。
if (currentMode != MODE_ROGUE || rogueDemoMode || currentScreen != SCREEN_PLAYING || suspendFlag || gameOverFlag || elapsedMs <= 0)
{
return;
@@ -307,6 +310,7 @@ void AdvanceRogueDifficulty(int elapsedMs)
while (rogueStats.difficultyElapsedMs >= kDifficultyStepMs)
{
// 每累计一个难度周期提升速度,后面再根据等级换算封锁行数。
rogueStats.difficultyElapsedMs -= kDifficultyStepMs;
rogueStats.difficultyLevel++;
difficultyChanged = true;
@@ -1680,6 +1684,7 @@ static void MarkDestinyCursedOption(int optionCount)
*/
static void FillUpgradeOptions()
{
// 第一段:根据当前强化等级、前置条件和互斥条件收集候选池。
int selectableIndexes[kUpgradePoolSize] = { 0 };
int selectableWeights[kUpgradePoolSize] = { 0 };
int selectableCount = CollectSelectableUpgrades(selectableIndexes, selectableWeights);
@@ -1706,9 +1711,11 @@ static void FillUpgradeOptions()
*/
int GetRogueFallInterval()
{
// 基础速度由永久缓降和随时间推进的难度共同决定。
int baseInterval = 500 + rogueStats.slowFallStacks * 80;
baseInterval -= rogueStats.difficultyLevel * kDifficultySpeedStepMs;
// 风险类强化提高速度,保命或奖励类临时状态降低速度。
if (rogueStats.highPressureLevel > 0)
{
baseInterval = baseInterval * 85 / 100;
@@ -2022,6 +2029,7 @@ void AwardRogueSkillClearRewards(int clearedCells, int& scoreGain, int& expGain,
{
scoreGain = 0;
expGain = 0;
// 非 Rogue 模式或未清除格子时不发放技能奖励。
if (currentMode != MODE_ROGUE || clearedCells <= 0)
{
return;
@@ -2058,6 +2066,7 @@ void AwardRogueSkillClearRewards(int clearedCells, int& scoreGain, int& expGain,
if (allowLevelProgress && !rogueDemoMode)
{
// 主动技能允许触发升级时,经验条满会立即积累待选强化并打开升级菜单。
int levelUps = ApplyLevelProgress(rogueStats);
if (levelUps > 0)
{
@@ -2128,6 +2137,7 @@ void CheckRogueLevelProgress()
void ApplyBoardGravity()
{
int playableHeight = GetRoguePlayableHeight();
// 每一列独立压缩到可操作区域底部,保留方块颜色和特殊值。
for (int x = 0; x < nGameWidth; x++)
{
int writeY = playableHeight - 1;
@@ -2161,6 +2171,7 @@ void ApplyLineClearResult(int linesCleared)
{
if (linesCleared <= 0)
{
// Rogue 连击只在连续消行时保持,空落地会断连。
if (currentMode == MODE_ROGUE)
{
rogueStats.comboChain = 0;
@@ -2170,6 +2181,7 @@ void ApplyLineClearResult(int linesCleared)
if (currentMode == MODE_CLASSIC)
{
// 经典模式沿用老师框架的简单计分:每行 100 分。
classicStats.totalLinesCleared += linesCleared;
classicStats.score += linesCleared * 100;
tScore = classicStats.score;
@@ -2211,6 +2223,7 @@ void ApplyLineClearResult(int linesCleared)
int gamblerBonusPercent = 0;
if (rogueStats.gamblerLevel > 0)
{
// 赌徒契约让本次收益在正负区间随机波动。
int variance = 20 + (rogueStats.gamblerLevel - 1) * 10;
if (variance > 50)
{
@@ -2289,6 +2302,7 @@ void ApplyLineClearResult(int linesCleared)
if (rogueStats.chainBlastLevel > 0)
{
// 连锁火花在消行附近制造额外破坏,并按技能清除规则给少量奖励。
int chainBlastCells = 0;
for (int i = 0; i < linesCleared; i++)
{
@@ -2757,6 +2771,7 @@ void ConfirmUpgradeSelection()
*/
void HoldCurrentPiece()
{
// 第一阶段:检查模式、解锁状态和本回合是否已经使用过 Hold。
if (currentMode != MODE_ROGUE || currentScreen != SCREEN_PLAYING || suspendFlag || gameOverFlag)
{
return;
@@ -2772,6 +2787,7 @@ void HoldCurrentPiece()
return;
}
// 第二阶段:把当前方块放入备用仓,并决定换出旧备用方块还是消费下一块。
int previousHoldType = holdType;
holdType = type;
state = 0;
@@ -2793,10 +2809,12 @@ void HoldCurrentPiece()
target = point;
if (currentMode == MODE_ROGUE && rogueStats.controlMasterLevel > 0)
{
// 操控大师在 Hold 后给予短暂缓速,帮助玩家重新摆位。
rogueStats.holdSlowTicks = 4;
currentFallInterval = GetRogueFallInterval();
}
// 第三阶段:验证换出的方块能否合法生成,避免交换到无解位置。
if (!IsPiecePlacementValid(type, state, point))
{
gameOverFlag = true;
@@ -2821,6 +2839,7 @@ void HoldCurrentPiece()
*/
void UseScreenBomb()
{
// 清屏炸弹是主动技能,先检查模式、解锁状态和可用次数。
if (currentMode != MODE_ROGUE || currentScreen != SCREEN_PLAYING || suspendFlag || gameOverFlag)
{
return;
@@ -2836,6 +2855,7 @@ void UseScreenBomb()
return;
}
// 扣除次数后清理底部区域,再按技能清除格子数发放奖励。
rogueStats.screenBombCount--;
int clearedCells = TriggerScreenBomb();
int scoreGain = 0;
@@ -2860,6 +2880,7 @@ void UseScreenBomb()
*/
void UseBlackHole()
{
// 黑洞按棋盘上数量最多的固定方块类型进行吞噬。
if (currentMode != MODE_ROGUE || currentScreen != SCREEN_PLAYING || suspendFlag || gameOverFlag)
{
return;
@@ -2875,6 +2896,7 @@ void UseBlackHole()
return;
}
// 只有确实清除了格子才消耗次数,避免空棋盘误扣资源。
int clearedCells = TriggerBlackHole();
if (clearedCells <= 0)
{
@@ -2890,6 +2912,7 @@ void UseBlackHole()
if (rogueStats.voidCoreLevel > 0)
{
// 虚空核心让黑洞额外召来一个待生成的彩虹方块。
rogueStats.pendingRainbowPieceCount++;
}
@@ -2923,6 +2946,7 @@ void UseBlackHole()
*/
void UseAirReshape()
{
// 空中换形把当前方块改为 I 块,需要先确认次数和当前局面。
if (currentMode != MODE_ROGUE || currentScreen != SCREEN_PLAYING || suspendFlag || gameOverFlag)
{
return;
@@ -2946,6 +2970,7 @@ void UseAirReshape()
int originalState = state;
bool transformed = false;
// 依次尝试横/竖两种 I 块状态,以及当前列附近的多个偏移。
for (int stateIndex = 0; stateIndex < 2 && !transformed; stateIndex++)
{
int nextState = candidateStates[stateIndex];
@@ -2967,6 +2992,7 @@ void UseAirReshape()
if (!transformed)
{
// 所有候选位置都不合法时恢复原方块,不消耗换形次数。
type = originalType;
state = originalState;
point = originalPoint;
@@ -3072,6 +3098,7 @@ void RestartCurrentRogueSkillDemo()
*/
static void StartRogueSkillDemoInternal(int demoIndex, bool autoAdvance)
{
// 入口统一夹紧下标,避免帮助页传入非法序号导致数组越界。
if (demoIndex < 0)
{
demoIndex = 0;
@@ -3081,6 +3108,7 @@ static void StartRogueSkillDemoInternal(int demoIndex, bool autoAdvance)
demoIndex = kRogueDemoStepCount - 1;
}
// 先切到 Rogue 游玩界面,再用 Restart 复用完整的新局初始化流程。
currentMode = MODE_ROGUE;
currentScreen = SCREEN_PLAYING;
rogueDemoMode = true;
@@ -3089,6 +3117,7 @@ static void StartRogueSkillDemoInternal(int demoIndex, bool autoAdvance)
rogueDemoAutoAdvance = autoAdvance;
Restart();
// Restart 会重置部分状态,因此演示模式标记和显示开关需要重新写回。
rogueDemoMode = true;
reviveAvailable = false;
suspendFlag = false;
@@ -3105,6 +3134,7 @@ static void StartRogueSkillDemoInternal(int demoIndex, bool autoAdvance)
*/
bool TickRogueSkillDemo()
{
// 非演示模式不消耗计时;手动演示时 autoAdvance 为 false,只保持刷新。
if (!rogueDemoMode)
{
return false;
@@ -3127,6 +3157,7 @@ bool TickRogueSkillDemo()
*/
void AdvanceRogueSkillDemo()
{
// 循环切换演示条目,最后一项之后回到第一项。
if (!rogueDemoMode)
{
return;
@@ -3146,6 +3177,7 @@ void AdvanceRogueSkillDemo()
*/
static void ResetRogueDemoBoard()
{
// 清空真实棋盘和临时状态,保证每个演示场景互不影响。
for (int y = 0; y < nGameHeight; y++)
{
for (int x = 0; x < nGameWidth; x++)
@@ -3263,6 +3295,7 @@ static void FillRogueDemoRowExcept(int row, int gapStart, int gapWidth, int base
*/
static void SetRogueDemoCurrentPiece(int pieceType, int pieceState, int x, int y)
{
// 演示方块直接指定类型、旋转和位置,同时清空预览队列方便观察。
type = pieceType;
nType = 0;
state = pieceState;
@@ -3280,12 +3313,14 @@ static void SetRogueDemoCurrentPiece(int pieceType, int pieceState, int x, int y
*/
static void ApplyRogueSkillDemoStep()
{
// 第一阶段:清空棋盘,显示当前演示名称和操作说明。
ResetRogueDemoBoard();
const RogueDemoStep& demoStep = kRogueDemoSteps[rogueDemoStepIndex];
ShowRogueDemoFloatingName(demoStep.name);
SetFeedbackMessage(demoStep.name, demoStep.detail, 12);
// 第二阶段:根据演示类型布置预设棋盘、强化等级和当前活动方块。
switch (demoStep.kind)
{
case DEMO_SCORE_MULTIPLIER:
+7
View File
@@ -5,5 +5,12 @@
#include "stdafx.h"
/**
* @file stdafx.cpp
* @brief 预编译头源文件,用于让构建系统生成 stdafx.h 对应的预编译结果。
*
* 本文件不包含业务逻辑;保留它是为了兼容 Visual Studio 模板和现有构建结构。
*/
// TODO: 在 STDAFX.H 中
// 引用任何所需的附加头文件,而不是在此文件中引用