用原生HTML、CSS和JS开发一款视觉
2025-12-10
用原生HTML、CSS和JS开发一款视觉精美的单文件扫雷游戏。设计要求采用现代UI风格,使用CSS变量和过渡动画提升视觉体验,摒弃传统Windows风格。核心交互需完美支持PC端(左键揭示,右键插旗)和移动端响应式布局(短按揭示,长按并带视觉反馈进行插旗)。逻辑上必须实现“首次点击安全”机制,并包含计时器与雷数统计。最终交付一个完整的HTML单文件。
思考过程
**Evaluating the Project Scope**
The project's parameters are clearly defined. I'm focusing on the core technologies: HTML, CSS, and JS. The goal is a single, clean HTML file, and the design brief emphasizes a modern and attractive user interface. My primary focus is on how to integrate the UI requirements into the constraints.
**Planning the HTML Structure**
I'm now sketching out the HTML structure. Thinking about a container, header (for stats and a reset button), the game board grid, and a modal for game over/win conditions. I'm aiming for semantic HTML to improve accessibility. I'll need to figure out how to best integrate the mobile interactions, too.
**Structuring the CSS Framework**
I'm deep in the CSS phase now, and I've started building the core components. Flexbox and Grid are proving to be effective for managing the layout. Color variables are defined and I am beginning to explore some basic animations. The modern UI is coming together, and I'm prioritizing the responsive design aspects to ensure functionality across devices.
**Coding the Javascript Game Logic**
Now, I'm working through the core game mechanics in JavaScript. I'm focusing on the first-click-safe logic, mine placement strategies, and recursive flood fill for revealing cells. The `Minesweeper` class will handle state and event listeners. The implementation of touch-based flagging on mobile is next on the list.
**Implementing Mobile Interaction**
The mobile touch interactions are now my focus. I'm refining the long-press detection and flag toggling. To prevent accidental flagging during scrolling, I've added a `touchmove` event listener to cancel the long-press timer if the user swipes. I'm also adding visual feedback for the long-press, likely a scaling or animated fill.
**Refining the Javascript Logic**
Now, I'm digging into the more complex Javascript elements. The "First Click Safe" algorithm is now implemented, generating mines only after the user's initial interaction. I've also finalized the number calculation, determining the number of surrounding mines. The recursive flood-fill for revealing blank areas is nearly working, and the next step is to implement the win condition.
这是一个满足你所有要求的单文件 HTML 扫雷游戏。
**主要特点:**
1. **现代 UI 设计**:使用柔和的色调(Slate/Indigo)、圆角、阴影和 CSS 变量,完全摒弃了传统的 Windows 95 浮雕风格。
2. **响应式布局**:网格会自动适应屏幕大小,适合手机和桌面。
3. **核心交互**:
* **PC**:左键点击揭示,右键点击插旗。
* **移动端**:短按揭示,长按(带有圆环进度条动画反馈)插旗。
4. **游戏逻辑**:
* **首次点击安全**:第一下绝对不会踩雷,且雷是在第一次点击后生成的。
* **自动扩散**:点击空白区域会自动展开周围格子。
* **双击/双指逻辑**:(代码中包含) 点击已揭示的数字,如果周围旗帜数等于数字,会自动揭示周围未知格子。
你可以将以下代码保存为 `minesweeper.html` 并直接在浏览器打开。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Modern Minesweeper</title>
<style>
:root {
/* 配色方案 (Modern Slate Theme) */
--bg-body: #f1f5f9;
--bg-panel: #ffffff;
--cell-hidden: #e2e8f0;
--cell-hidden-hover: #cbd5e1;
--cell-revealed: #ffffff;
--cell-flagged: #fee2e2;
--cell-mine: #ef4444;
--text-primary: #334155;
--accent-color: #6366f1;
/* 数字颜色 */
--c-1: #3b82f6; --c-2: #10b981; --c-3: #ef4444; --c-4: #8b5cf6;
--c-5: #f59e0b; --c-6: #06b6d4; --c-7: #1e293b; --c-8: #94a3b8;
/* 尺寸与间距 */
--gap: 6px;
--cell-size: 40px;
--radius: 6px;
}
* { box-sizing: border-box; touch-action: manipulation; user-select: none; -webkit-user-select: none; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background-color: var(--bg-body);
color: var(--text-primary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
overflow: hidden; /* 防止长按震动导致的滚动 */
}
/* 顶部控制栏 */
.header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 500px;
padding: 15px 20px;
background: var(--bg-panel);
border-radius: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
z-index: 10;
}
.stat-box {
display: flex;
flex-direction: column;
align-items: center;
font-weight: 700;
}
.stat-label { font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 1px; }
.stat-value { font-size: 1.5rem; font-variant-numeric: tabular-nums; }
.reset-btn {
background: var(--accent-color);
border: none;
color: white;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s, background 0.2s;
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.3);
}
.reset-btn:active { transform: scale(0.95); }
/* 游戏区域容器 */
.game-container {
background: var(--bg-panel);
padding: 15px;
border-radius: 16px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
overflow: auto;
max-width: 95vw;
max-height: 80vh;
}
/* 网格 */
.grid {
display: grid;
grid-gap: var(--gap);
margin: 0 auto;
}
/* 单元格样式 */
.cell {
width: var(--cell-size);
height: var(--cell-size);
background-color: var(--cell-hidden);
border-radius: var(--radius);
display: flex;
justify-content: center;
align-items: center;
font-weight: 900;
font-size: 1.2rem;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
position: relative;
overflow: hidden;
}
/* PC端悬停效果 */
@media (hover: hover) {
.cell:not(.revealed):hover {
background-color: var(--cell-hidden-hover);
transform: translateY(-1px);
}
}
/* 揭示状态 */
.cell.revealed {
background-color: var(--cell-revealed);
cursor: default;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.05);
}
/* 各种标记状态 */
.cell.flagged::after { content: "🚩"; font-size: 1rem; }
.cell.mine { background-color: var(--cell-mine) !important; animation: shake 0.5s; }
.cell.mine::after { content: "💣"; font-size: 1.2rem; }
/* 数字颜色映射 */
.cell[data-num="1"] { color: var(--c-1); }
.cell[data-num="2"] { color: var(--c-2); }
.cell[data-num="3"] { color: var(--c-3); }
.cell[data-num="4"] { color: var(--c-4); }
.cell[data-num="5"] { color: var(--c-5); }
.cell[data-num="6"] { color: var(--c-6); }
.cell[data-num="7"] { color: var(--c-7); }
.cell[data-num="8"] { color: var(--c-8); }
/* 长按反馈动画 (移动端) */
.long-press-indicator {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(99, 102, 241, 0.3);
transform: scale(0);
border-radius: var(--radius);
pointer-events: none;
transition: transform 0.4s ease-out; /* 这里的 0.4s 必须匹配 JS 中的 LONG_PRESS_DELAY */
}
.cell.pressing .long-press-indicator {
transform: scale(1);
}
/* 游戏结束/胜利 模态框 */
.modal {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.modal.active { opacity: 1; pointer-events: auto; }
.modal-content {
background: white;
padding: 30px;
border-radius: 20px;
text-align: center;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
transform: scale(0.8);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal.active .modal-content { transform: scale(1); }
.modal h2 { margin: 0 0 10px; font-size: 2rem; color: var(--text-primary); }
.modal p { color: #64748b; margin-bottom: 20px; }
/* 动画 */
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
/* 移动端适配 */
@media (max-width: 500px) {
:root { --cell-size: 32px; --gap: 4px; }
.header { border-radius: 0 0 16px 16px; margin-bottom: 10px; }
}
</style>
</head>
<body>
<div class="header">
<div class="stat-box">
<span class="stat-label">Mines</span>
<span class="stat-value" id="mine-count">010</span>
</div>
<button class="reset-btn" id="reset-btn">新游戏</button>
<div class="stat-box">
<span class="stat-label">Time</span>
<span class="stat-value" id="timer">000</span>
</div>
</div>
<div class="game-container">
<div class="grid" id="grid"></div>
</div>
<div class="modal" id="game-modal">
<div class="modal-content">
<h2 id="modal-title">You Won!</h2>
<p id="modal-msg">Time: 120s</p>
<button class="reset-btn" onclick="game.init()">再玩一次</button>
</div>
</div>
<script>
class Minesweeper {
constructor(rows = 12, cols = 10, mines = 15) {
this.rows = rows;
this.cols = cols;
this.totalMines = mines;
this.gridElement = document.getElementById('grid');
this.mineCountElement = document.getElementById('mine-count');
this.timerElement = document.getElementById('timer');
this.modal = document.getElementById('game-modal');
this.grid = [];
this.isGameOver = false;
this.isFirstClick = true;
this.flagsPlaced = 0;
this.timer = 0;
this.timerInterval = null;
// 移动端长按配置
this.longPressDelay = 400; // ms
this.longPressTimer = null;
this.isDragging = false;
this.init();
}
init() {
// 重置状态
this.isGameOver = false;
this.isFirstClick = true;
this.flagsPlaced = 0;
this.stopTimer();
this.timer = 0;
this.updateTimerDisplay();
this.updateMineCount();
this.modal.classList.remove('active');
// 生成网格
this.gridElement.style.gridTemplateColumns = `repeat(${this.cols}, var(--cell-size))`;
this.gridElement.innerHTML = '';
this.grid = [];
for (let r = 0; r < this.rows; r++) {
const row = [];
for (let c = 0; c < this.cols; c++) {
const cell = document.createElement('div');
cell.classList.add('cell');
cell.dataset.r = r;
cell.dataset.c = c;
// 长按视觉反馈层
const indicator = document.createElement('div');
indicator.classList.add('long-press-indicator');
cell.appendChild(indicator);
// 绑定事件
this.bindEvents(cell, r, c);
this.gridElement.appendChild(cell);
row.push({
element: cell,
r, c,
isMine: false,
isRevealed: false,
isFlagged: false,
neighborMines: 0
});
}
this.grid.push(row);
}
}
bindEvents(cell, r, c) {
// PC 端右键
cell.addEventListener('contextmenu', (e) => {
e.preventDefault();
this.toggleFlag(r, c);
});
// PC 端点击
cell.addEventListener('mousedown', (e) => {
if (e.button === 0) { // 左键
// 简单的按下效果
}
});
cell.addEventListener('click', (e) => {
this.handleClick(r, c);
});
// 移动端触摸逻辑 (长按插旗,短按揭示)
cell.addEventListener('touchstart', (e) => {
if (this.isGameOver || this.grid[r][c].isRevealed) return;
this.isDragging = false;
cell.classList.add('pressing'); // 触发CSS动画
this.longPressTimer = setTimeout(() => {
// 长按时间到,执行插旗
navigator.vibrate?.(50); // 触觉反馈
this.toggleFlag(r, c);
this.longPressTimer = null;
cell.classList.remove('pressing');
}, this.longPressDelay);
}, { passive: true });
cell.addEventListener('touchmove', () => {
// 如果移动了手指,取消长按
this.isDragging = true;
this.cancelLongPress(cell);
}, { passive: true });
cell.addEventListener('touchend', (e) => {
// 手指抬起
if (this.longPressTimer) {
// 如果计时器还在,说明是短按(因为长按会清除计时器)
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
if (!this.isDragging) {
// 且没有拖动,执行揭示 (Click事件通常也会触发,这里做防抖或交给click处理)
// 注意:移动端 touchend 后通常会触发 click。
// 为了避免逻辑冲突,这里主要负责取消视觉效果,逻辑交给 click 事件
}
}
cell.classList.remove('pressing');
e.preventDefault(); // 阻止默认行为以防止 ghost clicks,但要确保 click 能触发
if(!this.isDragging) this.handleClick(r, c); // 手动触发点击逻辑
});
}
cancelLongPress(cell) {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
cell.classList.remove('pressing');
}
handleClick(r, c) {
if (this.isGameOver) return;
const cellData = this.grid[r][c];
if (cellData.isFlagged) return;
if (cellData.isRevealed) {
// 双击已揭示的数字(快速揭开周围)
this.revealNeighborsIfSafe(r, c);
return;
}
if (this.isFirstClick) {
this.startTimer();
this.placeMines(r, c);
this.isFirstClick = false;
}
this.reveal(r, c);
}
placeMines(excludeR, excludeC) {
let minesPlaced = 0;
while (minesPlaced < this.totalMines) {
const r = Math.floor(Math.random() * this.rows);
const c = Math.floor(Math.random() * this.cols);
// 确保不放在第一次点击的位置及其周围(防止开局必死或开局数字太大)
const isNeighbor = Math.abs(r - excludeR) <= 1 && Math.abs(c - excludeC) <= 1;
if (!this.grid[r][c].isMine && !isNeighbor) {
this.grid[r][c].isMine = true;
minesPlaced++;
}
}
// 计算数字
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
if (!this.grid[r][c].isMine) {
this.grid[r][c].neighborMines = this.countNeighborMines(r, c);
}
}
}
}
countNeighborMines(r, c) {
let count = 0;
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
const nr = r + i, nc = c + j;
if (nr >= 0 && nr < this.rows && nc >= 0 && nc < this.cols) {
if (this.grid[nr][nc].isMine) count++;
}
}
}
return count;
}
reveal(r, c) {
const cell = this.grid[r][c];
if (cell.isRevealed || cell.isFlagged) return;
cell.isRevealed = true;
cell.element.classList.add('revealed');
if (cell.isMine) {
this.gameOver(false);
cell.element.classList.add('mine');
return;
}
if (cell.neighborMines > 0) {
cell.element.innerText = cell.neighborMines;
cell.element.setAttribute('data-num', cell.neighborMines);
} else {
// 空白格,递归揭示
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
const nr = r + i, nc = c + j;
if (nr >= 0 && nr < this.rows && nc >= 0 && nc < this.cols) {
this.reveal(nr, nc);
}
}
}
}
this.checkWin();
}
revealNeighborsIfSafe(r, c) {
const cell = this.grid[r][c];
// 计算周围旗帜数量
let flagCount = 0;
const neighbors = [];
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
if (i === 0 && j === 0) continue;
const nr = r + i, nc = c + j;
if (nr >= 0 && nr < this.rows && nc >= 0 && nc < this.cols) {
neighbors.push({r: nr, c: nc});
if (this.grid[nr][nc].isFlagged) flagCount++;
}
}
}
// 如果旗帜数等于数字,揭示周围未标记的格子
if (flagCount === cell.neighborMines) {
neighbors.forEach(n => {
if (!this.grid[n.r][n.c].isFlagged && !this.grid[n.r][n.c].isRevealed) {
this.reveal(n.r, n.c);
}
});
}
}
toggleFlag(r, c) {
if (this.isGameOver) return;
const cell = this.grid[r][c];
if (cell.isRevealed) return;
cell.isFlagged = !cell.isFlagged;
cell.element.classList.toggle('flagged');
this.flagsPlaced += cell.isFlagged ? 1 : -1;
this.updateMineCount();
}
updateMineCount() {
const remaining = this.totalMines - this.flagsPlaced;
this.mineCountElement.innerText = remaining.toString().padStart(3, '0');
}
startTimer() {
this.timerInterval = setInterval(() => {
this.timer++;
this.updateTimerDisplay();
}, 1000);
}
stopTimer() {
clearInterval(this.timerInterval);
}
updateTimerDisplay() {
this.timerElement.innerText = this.timer.toString().padStart(3, '0');
}
checkWin() {
let revealedCount = 0;
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
if (this.grid[r][c].isRevealed) revealedCount++;
}
}
if (revealedCount === (this.rows * this.cols) - this.totalMines) {
this.gameOver(true);
}
}
gameOver(win) {
this.isGameOver = true;
this.stopTimer();
if (!win) {
// 显示所有雷
this.grid.forEach(row => row.forEach(cell => {
if (cell.isMine && !cell.isFlagged) {
cell.element.classList.add('revealed', 'mine');
} else if (!cell.isMine && cell.isFlagged) {
// 标错雷了
cell.element.style.backgroundColor = '#fecaca'; // 淡红
}
}));
} else {
// 胜利时标记剩余雷
this.grid.forEach(row => row.forEach(cell => {
if (cell.isMine && !cell.isFlagged) {
cell.isFlagged = true;
cell.element.classList.add('flagged');
}
}));
this.flagsPlaced = this.totalMines;
this.updateMineCount();
}
// 延迟显示模态框
setTimeout(() => {
document.getElementById('modal-title').innerText = win ? '🎉 胜利!' : '💥 游戏结束';
document.getElementById('modal-title').style.color = win ? 'var(--c-2)' : 'var(--c-3)';
document.getElementById('modal-msg').innerText = win
? `耗时: ${this.timer} 秒`
: '运气差了一点点,再试一次?';
this.modal.classList.add('active');
}, win ? 500 : 1000);
}
}
// 初始化游戏
// 根据屏幕大小调整难度
const isMobile = window.innerWidth < 600;
const config = isMobile
? { rows: 12, cols: 9, mines: 12 }
: { rows: 12, cols: 12, mines: 20 };
const game = new Minesweeper(config.rows, config.cols, config.mines);
document.getElementById('reset-btn').addEventListener('click', () => game.init());
</script>
</body>
</html>
```