数字华容道
by SonicWizard80781 lines22.8 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数字华容道</title>
<style>
:root {
--bg-top: #f5f9ff;
--bg-bottom: #d9e6f4;
--accent: #4f8cff;
--accent-soft: #e8f0ff;
--tile: #ffffff;
--text: #2c3e50;
--text-muted: #8a97a8;
--correct: #d7f3e3;
--correct-text: #1f9d63;
--shadow: 0 4px 12px rgba(60, 90, 140, 0.12);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
background:
radial-gradient(1100px 760px at 12% -8%, rgba(120, 185, 255, 0.42), transparent 60%),
radial-gradient(950px 720px at 105% 8%, rgba(140, 210, 255, 0.38), transparent 55%),
radial-gradient(900px 760px at 50% 115%, rgba(165, 205, 245, 0.40), transparent 58%),
linear-gradient(160deg, var(--bg-top), var(--bg-bottom));
color: var(--text);
-webkit-user-select: none;
user-select: none;
}
/* 漂浮的柔光光斑 */
body::before,
body::after {
content: '';
position: fixed;
border-radius: 50%;
filter: blur(64px);
opacity: 0.55;
pointer-events: none;
z-index: 0;
}
body::before {
width: 360px;
height: 360px;
top: -90px;
left: -70px;
background: rgba(150, 195, 255, 0.6);
animation: drift1 18s ease-in-out infinite;
}
body::after {
width: 320px;
height: 320px;
bottom: -80px;
right: -60px;
background: rgba(185, 220, 255, 0.6);
animation: drift2 24s ease-in-out infinite;
}
@keyframes drift1 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(46px, 34px) scale(1.12); }
}
@keyframes drift2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-40px, -28px) scale(1.16); }
}
.app {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 24px;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 2px;
}
.modes {
display: flex;
gap: 6px;
padding: 5px;
background: rgba(255, 255, 255, 0.6);
border-radius: 13px;
box-shadow: var(--shadow);
}
button.mode {
padding: 7px 18px;
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0;
color: var(--text-muted);
background: transparent;
border-radius: 9px;
box-shadow: none;
}
button.mode:hover {
transform: none;
box-shadow: none;
background: var(--accent-soft);
}
button.mode.active,
button.mode.active:hover {
color: #fff;
background: var(--accent);
}
.stats {
display: flex;
gap: 16px;
align-items: center;
}
.stat {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
background: rgba(255, 255, 255, 0.7);
border-radius: 14px;
box-shadow: var(--shadow);
font-size: 0.95rem;
color: var(--text-muted);
}
.stat b {
font-size: 1.15rem;
font-variant-numeric: tabular-nums;
color: var(--text);
min-width: 46px;
text-align: center;
}
.best {
font-size: 0.85rem;
color: var(--text-muted);
}
.best b {
color: var(--accent);
font-weight: 700;
}
.hint-used {
display: none;
font-size: 0.82rem;
color: #e08a00;
}
#board {
position: relative;
background: rgba(120, 150, 190, 0.14);
border-radius: 18px;
box-shadow: inset 0 2px 8px rgba(60, 90, 140, 0.10);
}
.tile {
position: absolute;
top: 0;
left: 0;
transform: translate(var(--tx, 0px), var(--ty, 0px));
display: flex;
align-items: center;
justify-content: center;
background: var(--tile);
border-radius: 12px;
box-shadow: var(--shadow);
font-size: 1.6rem;
font-weight: 600;
color: var(--text);
cursor: default;
transition: transform 0.18s cubic-bezier(0.25, 0.8, 0.35, 1);
will-change: transform;
}
.tile.movable {
cursor: pointer;
}
.tile.movable:hover {
background: var(--accent-soft);
color: var(--accent);
transform: translate(var(--tx), var(--ty)) scale(1.05);
}
.tile.correct {
background: var(--correct);
color: var(--correct-text);
}
button {
padding: 12px 28px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 1px;
color: #fff;
background: var(--accent);
border: none;
border-radius: 14px;
cursor: pointer;
box-shadow: 0 6px 16px rgba(79, 140, 255, 0.35);
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
button:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(79, 140, 255, 0.4); }
button:active { transform: translateY(0); }
.actions { display: flex; gap: 12px; }
button.secondary {
color: var(--accent);
background: #fff;
box-shadow: 0 4px 12px rgba(60, 90, 140, 0.15);
}
button.secondary:hover {
background: var(--accent-soft);
box-shadow: 0 8px 18px rgba(60, 90, 140, 0.2);
}
.note { font-size: 0.8rem; color: var(--text-muted); }
/* 提示高亮:脉冲光晕(只动 box-shadow,不影响定位 transform) */
.tile.hint-glow {
animation: hintPulse 0.9s ease-in-out infinite;
z-index: 2;
}
@keyframes hintPulse {
0%, 100% { box-shadow: 0 0 0 3px var(--accent), 0 0 14px rgba(79, 140, 255, 0.35); }
50% { box-shadow: 0 0 0 6px var(--accent), 0 0 30px rgba(79, 140, 255, 0.7); }
}
/* 胜利遮罩 */
#overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(30, 45, 70, 0.35);
backdrop-filter: blur(3px);
z-index: 10;
}
#overlay.show { display: flex; }
/* 提示警告弹窗 */
#warn-overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(30, 45, 70, 0.35);
backdrop-filter: blur(3px);
z-index: 10;
}
#warn-overlay.show { display: flex; }
.warn-card .actions { justify-content: center; }
.warn-text {
color: var(--text-muted);
margin: 0 0 20px;
max-width: 260px;
}
.card {
background: #fff;
border-radius: 20px;
padding: 32px 40px;
text-align: center;
box-shadow: 0 20px 50px rgba(30, 45, 70, 0.3);
animation: pop 0.35s cubic-bezier(0.2, 1.4, 0.4, 1);
}
.card .emoji { font-size: 3rem; }
.card h2 { margin: 12px 0 6px; }
.stars {
font-size: 1.6rem;
letter-spacing: 6px;
color: #ffb020;
margin: 4px 0 14px;
}
.summary {
display: flex;
gap: 12px;
justify-content: center;
margin-bottom: 16px;
}
.sum-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 22px;
background: #f5f8fc;
border-radius: 12px;
min-width: 104px;
}
.sum-label { font-size: 0.78rem; color: var(--text-muted); }
.sum-value { font-size: 1.5rem; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
.record {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
font-size: 0.9rem;
color: var(--text-muted);
}
.rec-line { display: flex; align-items: center; gap: 8px; }
.rec-line b { color: var(--text); margin-left: auto; font-variant-numeric: tabular-nums; }
.badge {
display: none;
font-size: 0.65rem;
font-weight: 700;
color: #fff;
background: var(--correct-text);
padding: 2px 7px;
border-radius: 999px;
letter-spacing: 0.5px;
}
.hint-note {
display: none;
font-size: 0.82rem;
color: #e08a00;
margin: -8px 0 16px;
}
@keyframes pop {
from { transform: scale(0.7); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
</style>
</head>
<body>
<div class="app">
<h1>数字华容道</h1>
<div class="modes">
<button class="mode" data-size="3">3×3</button>
<button class="mode active" data-size="4">4×4</button>
<button class="mode" data-size="5">5×5</button>
</div>
<div class="stats">
<div class="stat">步数 <b id="steps">0</b></div>
<div class="stat">用时 <b id="time">00:00</b></div>
</div>
<div class="best" id="best"></div>
<div class="hint-used" id="hint-used">💡 已使用提示,本局成绩不计入最佳</div>
<div id="board"></div>
<div class="actions">
<button id="hint-btn" class="secondary">💡 提示</button>
<button id="restart">新 一 局</button>
</div>
<div class="note">点击空白格相邻的数字滑动 · 方向键也可操作</div>
</div>
<div id="overlay">
<div class="card">
<div class="emoji">🎉</div>
<h2>恭喜通关!</h2>
<div class="stars" id="stars">★★★</div>
<div class="summary">
<div class="sum-item">
<span class="sum-label">本局步数</span>
<span class="sum-value" id="sum-steps">0</span>
</div>
<div class="sum-item">
<span class="sum-label">本局用时</span>
<span class="sum-value" id="sum-time">00:00</span>
</div>
</div>
<div class="record">
<div class="rec-line"><span>最少步数</span><b id="rec-steps">—</b><span class="badge" id="badge-steps">NEW</span></div>
<div class="rec-line"><span>最短用时</span><b id="rec-time">—</b><span class="badge" id="badge-time">NEW</span></div>
</div>
<p class="hint-note" id="hint-note">💡 本局使用了提示,未计入最佳成绩</p>
<button id="again">再来一局</button>
</div>
</div>
<div id="warn-overlay">
<div class="card warn-card">
<div class="emoji">⚠️</div>
<h2>确定使用提示?</h2>
<p class="warn-text">使用提示后,本局成绩将不再计入最佳记录。</p>
<div class="actions">
<button id="warn-cancel" class="secondary">取消</button>
<button id="warn-confirm">确认使用</button>
</div>
</div>
</div>
<script>
(function () {
let SIZE = 4; // 当前模式:3 / 4 / 5
const GAP = 6; // 格子间距(px)
let CELL = 0; // 单格边长(px),按模式与窗口计算
const boardEl = document.getElementById('board');
const stepsEl = document.getElementById('steps');
const timeEl = document.getElementById('time');
const overlayEl = document.getElementById('overlay');
const bestEl = document.getElementById('best');
// ---------- 最佳成绩(按模式分别存本地) ----------
function loadBest() {
const blank = { 3: { steps: null, time: null }, 4: { steps: null, time: null }, 5: { steps: null, time: null } };
try {
const raw = localStorage.getItem('huarongdao_best');
if (raw) {
const d = JSON.parse(raw);
for (const k of ['3', '4', '5']) {
if (d[k]) blank[k] = { steps: d[k].steps ?? null, time: d[k].time ?? null };
}
}
} catch (e) { /* 忽略损坏数据 */ }
return blank;
}
function saveBest() {
try { localStorage.setItem('huarongdao_best', JSON.stringify(best)); } catch (e) {}
}
let best = loadBest();
function renderBest() {
const b = best[SIZE];
const st = b.steps === null ? '—' : b.steps + ' 步';
const tm = b.time === null ? '—' : fmt(b.time);
bestEl.innerHTML = '🏆 最佳成绩:' + st + ' · ' + tm;
}
// 棋盘状态:0 表示空白
let board = [];
let steps = 0;
let startTime = null; // 首次移动开始计时
let timerId = null;
let solved = false;
let usedHint = false; // 本局是否用过提示(用过则不计入最佳)
let hintWarned = false; // 本局是否已弹过「使用提示」警告
let lastMoved = -1; // 打乱时避免来回无效移动
let tileEls = {}; // 数字 -> DOM 元素
// ---------- 尺寸计算 ----------
function computeCell() {
const maxCell = SIZE === 3 ? 112 : SIZE === 4 ? 96 : 76;
const avail = Math.min(window.innerWidth, 460) - 40;
CELL = Math.min(maxCell, Math.floor((avail - GAP * (SIZE - 1)) / SIZE));
boardEl.style.width = boardEl.style.height = (CELL * SIZE + GAP * (SIZE - 1)) + 'px';
}
// ---------- 初始化 DOM 元素 ----------
function buildTiles() {
boardEl.innerHTML = '';
tileEls = {};
const fontSize = Math.round(CELL * (SIZE === 3 ? 0.55 : SIZE === 4 ? 0.5 : 0.4));
for (let n = 1; n < SIZE * SIZE; n++) {
const el = document.createElement('div');
el.className = 'tile';
el.textContent = n;
el.style.width = el.style.height = CELL + 'px';
el.style.fontSize = fontSize + 'px';
el.addEventListener('click', () => moveTile(n));
boardEl.appendChild(el);
tileEls[n] = el;
}
}
// 根据棋盘状态更新每个数字格子的位置
function render() {
for (let n = 1; n < SIZE * SIZE; n++) {
const idx = board.indexOf(n);
const row = Math.floor(idx / SIZE);
const col = idx % SIZE;
const tx = col * (CELL + GAP);
const ty = row * (CELL + GAP);
const el = tileEls[n];
el.style.setProperty('--tx', tx + 'px');
el.style.setProperty('--ty', ty + 'px');
el.classList.toggle('correct', idx === n - 1); // 数字 n 正确位置为 n-1
el.classList.toggle('movable', isAdjacent(n));
}
}
function isAdjacent(n) {
const a = board.indexOf(n);
const e = board.indexOf(0);
const ar = Math.floor(a / SIZE), ac = a % SIZE;
const er = Math.floor(e / SIZE), ec = e % SIZE;
return Math.abs(ar - er) + Math.abs(ac - ec) === 1;
}
// ---------- 移动 ----------
function moveTile(n) {
if (solved || !isAdjacent(n)) return;
const a = board.indexOf(n);
const e = board.indexOf(0);
[board[a], board[e]] = [board[e], board[a]];
if (steps === 0) startTimer();
steps++;
stepsEl.textContent = steps;
Object.values(tileEls).forEach(el => el.classList.remove('hint-glow'));
render();
checkWin();
}
// ---------- 打乱(保证有解:从已解状态做大量合法移动) ----------
function shuffle() {
board = Array.from({ length: SIZE * SIZE }, (_, i) => (i + 1) % (SIZE * SIZE)); // 已解状态 [1..N, 0]
lastMoved = -1;
const shuffleMoves = SIZE * SIZE * 60;
for (let i = 0; i < shuffleMoves; i++) {
const empty = board.indexOf(0);
const er = Math.floor(empty / SIZE), ec = empty % SIZE;
const candidates = [];
[[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
const r = er + dr, c = ec + dc;
if (r >= 0 && r < SIZE && c >= 0 && c < SIZE) {
const v = board[r * SIZE + c];
if (v !== lastMoved) candidates.push(r * SIZE + c);
}
});
// 若都被排除则允许回退,保证仍有移动
if (candidates.length === 0) {
[[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
const r = er + dr, c = ec + dc;
if (r >= 0 && r < SIZE && c >= 0 && c < SIZE) candidates.push(r * SIZE + c);
});
}
const pick = candidates[Math.floor(Math.random() * candidates.length)];
lastMoved = board[pick];
[board[empty], board[pick]] = [board[pick], board[empty]];
}
// 极低概率打乱后恰为已解,重新打乱
if (board.every((v, i) => v === (i + 1) % (SIZE * SIZE))) shuffle();
}
// ---------- 计时 ----------
function startTimer() {
startTime = Date.now();
timerId = setInterval(() => {
const s = Math.floor((Date.now() - startTime) / 1000);
timeEl.textContent = fmt(s);
}, 200);
}
function stopTimer() {
if (timerId) { clearInterval(timerId); timerId = null; }
}
function fmt(s) {
const m = String(Math.floor(s / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
return m + ':' + ss;
}
// ---------- 提示(曼哈顿距离 + 线性冲突的一步前瞻) ----------
function heuristic(b) {
let dist = 0;
for (let i = 0; i < SIZE * SIZE; i++) {
const v = b[i];
if (v === 0) continue;
const tr = Math.floor((v - 1) / SIZE), tc = (v - 1) % SIZE;
const cr = Math.floor(i / SIZE), cc = i % SIZE;
dist += Math.abs(tr - cr) + Math.abs(tc - cc);
}
// 行线性冲突:同目标行内两个数字相对顺序颠倒
for (let r = 0; r < SIZE; r++) {
for (let c1 = 0; c1 < SIZE; c1++) {
const v1 = b[r * SIZE + c1];
if (v1 === 0 || Math.floor((v1 - 1) / SIZE) !== r) continue;
for (let c2 = c1 + 1; c2 < SIZE; c2++) {
const v2 = b[r * SIZE + c2];
if (v2 === 0 || Math.floor((v2 - 1) / SIZE) !== r) continue;
if ((v1 - 1) % SIZE > (v2 - 1) % SIZE) dist += 2;
}
}
}
// 列线性冲突:同目标列内两个数字相对顺序颠倒
for (let c = 0; c < SIZE; c++) {
for (let r1 = 0; r1 < SIZE; r1++) {
const v1 = b[r1 * SIZE + c];
if (v1 === 0 || (v1 - 1) % SIZE !== c) continue;
for (let r2 = r1 + 1; r2 < SIZE; r2++) {
const v2 = b[r2 * SIZE + c];
if (v2 === 0 || (v2 - 1) % SIZE !== c) continue;
if (Math.floor((v1 - 1) / SIZE) > Math.floor((v2 - 1) / SIZE)) dist += 2;
}
}
}
return dist;
}
let lastHintN = -1;
// 束搜索(beam search):多步前瞻,比单步贪心更强
function beamHint() {
if (solved) return -1;
const cfg = SIZE === 3 ? { depth: 6, width: 600 }
: SIZE === 4 ? { depth: 4, width: 400 }
: { depth: 3, width: 250 };
let beams = [{ state: board.slice(), first: null }];
for (let d = 0; d < cfg.depth; d++) {
const next = [];
for (const node of beams) {
const s = node.state;
const empty = s.indexOf(0);
const er = Math.floor(empty / SIZE), ec = empty % SIZE;
[[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
const r = er + dr, c = ec + dc;
if (r < 0 || r >= SIZE || c < 0 || c >= SIZE) return;
const idx = r * SIZE + c;
const ns = s.slice();
[ns[empty], ns[idx]] = [ns[idx], ns[empty]];
next.push({ state: ns, h: heuristic(ns), first: node.first ?? s[idx] });
});
}
next.sort((a, b) => a.h - b.h);
const seen = new Set();
beams = [];
for (const node of next) {
const key = node.state.join(',');
if (seen.has(key)) continue;
seen.add(key);
beams.push(node);
if (beams.length >= cfg.width) break;
}
if (!beams.length) break;
}
if (!beams.length) return -1;
beams.sort((a, b) => a.h - b.h);
// 优先排除「撤销上一步提示」,减少来回循环
const diff = beams.filter(b => b.first !== lastHintN && b.first !== null);
const pool = diff.length ? diff : beams;
const chosen = pool[0].first;
if (chosen !== null) lastHintN = chosen;
return chosen ?? -1;
}
function showHint() {
if (solved) return;
if (!hintWarned) {
document.getElementById('warn-overlay').classList.add('show');
return;
}
doHint();
}
function doHint() {
const n = beamHint();
if (n < 0) return;
usedHint = true;
document.getElementById('hint-used').style.display = 'block';
Object.values(tileEls).forEach(el => el.classList.remove('hint-glow'));
tileEls[n].classList.add('hint-glow');
}
// ---------- 胜利判定 + 通关结算 ----------
function checkWin() {
if (board.every((v, i) => v === (i + 1) % (SIZE * SIZE))) {
solved = true;
stopTimer();
const s = Math.floor((Date.now() - startTime) / 1000);
const b = best[SIZE];
let brokeSteps = false, brokeTime = false;
if (!usedHint) {
brokeSteps = b.steps === null || steps < b.steps;
brokeTime = b.time === null || s < b.time;
if (brokeSteps) b.steps = steps;
if (brokeTime) b.time = s;
saveBest();
}
// 填充结算面板
document.getElementById('sum-steps').textContent = steps;
document.getElementById('sum-time').textContent = fmt(s);
document.getElementById('rec-steps').textContent = b.steps + ' 步';
document.getElementById('rec-time').textContent = fmt(b.time);
document.getElementById('badge-steps').style.display = brokeSteps ? 'inline-block' : 'none';
document.getElementById('badge-time').style.display = brokeTime ? 'inline-block' : 'none';
document.getElementById('hint-note').style.display = usedHint ? 'block' : 'none';
document.getElementById('stars').textContent = rating(steps);
renderBest();
overlayEl.classList.add('show');
}
}
// 星级评价:按步数划分(阈值为经验值,可自行调整)
function rating(st) {
let a, b;
if (SIZE === 3) { a = 35; b = 60; }
else if (SIZE === 4) { a = 120; b = 200; }
else { a = 350; b = 600; }
if (st <= a) return '★★★';
if (st <= b) return '★★☆';
return '★☆☆';
}
// ---------- 新游戏 ----------
function newGame() {
stopTimer();
steps = 0;
startTime = null;
solved = false;
usedHint = false;
hintWarned = false;
lastHintN = -1;
stepsEl.textContent = '0';
timeEl.textContent = '00:00';
overlayEl.classList.remove('show');
document.getElementById('hint-used').style.display = 'none';
renderBest();
computeCell();
buildTiles();
shuffle();
render();
}
// ---------- 键盘方向键 ----------
document.addEventListener('keydown', (e) => {
const empty = board.indexOf(0);
const er = Math.floor(empty / SIZE), ec = empty % SIZE;
const dir = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] }[e.key];
if (!dir) return;
e.preventDefault();
const r = er + dir[0], c = ec + dir[1];
if (r >= 0 && r < SIZE && c >= 0 && c < SIZE) moveTile(board[r * SIZE + c]);
});
// 窗口尺寸变化时重新计算格子大小(保留当前模式,不重载)
window.addEventListener('resize', () => {
computeCell();
buildTiles();
render();
});
document.getElementById('restart').addEventListener('click', newGame);
document.getElementById('again').addEventListener('click', newGame);
document.getElementById('hint-btn').addEventListener('click', showHint);
document.getElementById('warn-confirm').addEventListener('click', () => {
hintWarned = true;
document.getElementById('warn-overlay').classList.remove('show');
doHint();
});
document.getElementById('warn-cancel').addEventListener('click', () => {
document.getElementById('warn-overlay').classList.remove('show');
});
document.querySelectorAll('.mode').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mode').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
SIZE = parseInt(btn.dataset.size, 10);
newGame();
});
});
// ---------- 启动 ----------
newGame();
})();
</script>
</body>
</html>
Game Source: 数字华容道
Creator: SonicWizard80
Libraries: none
Complexity: complex (781 lines, 22.8 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-sonicwizard80" to link back to the original. Then publish at arcadelab.ai/publish.