不知名提交

This commit is contained in:
2025-12-13 20:53:50 +08:00
parent c147502b4d
commit 1221d6faf1
120 changed files with 11005 additions and 1092 deletions

View File

@@ -30,6 +30,122 @@ class Game2048 {
// 开始计时
this.startTimer();
}
// 依据分数计算权重0.1 ~ 0.95
calculateWeightByScore(score) {
const w = score / 4000; // 4000分约接近满权重
return Math.max(0.1, Math.min(0.95, w));
}
// 按权重偏向生成0~10的随机整数权重越高越偏向更大值
biasedRandomInt(maxInclusive, weight) {
const rand = Math.random();
const biased = Math.pow(rand, 1 - weight); // weight越大biased越接近1
const val = Math.floor(biased * (maxInclusive + 1));
return Math.max(0, Math.min(maxInclusive, val));
}
// 附加结束信息到界面
appendEndInfo(text, type = 'info') {
const message = document.getElementById('game-message');
if (!message) return;
const info = document.createElement('div');
info.style.marginTop = '10px';
info.style.fontSize = '16px';
info.style.color = type === 'error' ? '#d9534f' : (type === 'success' ? '#28a745' : '#776e65');
info.textContent = text;
message.appendChild(info);
}
// 游戏结束时尝试给当前登录账户加“萌芽币”
async tryAwardCoinsOnGameOver() {
try {
const token = localStorage.getItem('token');
if (!token) {
this.appendEndInfo('未登录,无法获得萌芽币');
return;
}
let email = null;
try {
const userStr = localStorage.getItem('user');
if (userStr) {
const userObj = JSON.parse(userStr);
email = userObj && (userObj.email || userObj['邮箱']);
}
} catch (e) {
// 忽略解析错误
}
if (!email) {
this.appendEndInfo('未找到账户信息email无法加币', 'error');
return;
}
// 根据分数计算权重与概率
const weight = this.calculateWeightByScore(this.score);
let awardProbability = weight; // 默认用权重作为概率
let guaranteed = false;
// 分数≥500时必定触发奖励
if (this.score >= 500) {
awardProbability = 1;
guaranteed = true;
}
const roll = Math.random();
if (roll > awardProbability) {
this.appendEndInfo('本局未获得萌芽币');
return;
}
// 生成0~10随机萌芽币数量权重越高越偏向更大值
let coins = this.biasedRandomInt(5, weight);
// 保底至少 1 个仅当分数≥500时
if (guaranteed) {
coins = Math.max(1, coins);
}
coins = Math.max(0, Math.min(10, coins));
if (coins <= 0) {
this.appendEndInfo('本局未获得萌芽币');
return;
}
// 后端 API base URL从父窗口ENV_CONFIG获取回退到本地默认
const apiBase = (window.parent && window.parent.ENV_CONFIG && window.parent.ENV_CONFIG.API_URL)
? window.parent.ENV_CONFIG.API_URL
: ((window.ENV_CONFIG && window.ENV_CONFIG.API_URL) ? window.ENV_CONFIG.API_URL : 'http://127.0.0.1:5002');
const resp = await fetch(`${apiBase}/api/user/add-coins`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ email, amount: coins })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
const msg = err && (err.message || err.error) ? (err.message || err.error) : `请求失败(${resp.status})`;
this.appendEndInfo(`加币失败:${msg}`, 'error');
return;
}
const data = await resp.json();
if (data && data.success) {
const newCoins = data.data && data.data.new_coins;
this.appendEndInfo(`恭喜获得 ${coins} 个萌芽币!当前余额:${newCoins}`, 'success');
} else {
const msg = (data && (data.message || data.error)) || '未知错误';
this.appendEndInfo(`加币失败:${msg}`, 'error');
}
} catch (e) {
console.error('加币流程发生错误:', e);
this.appendEndInfo('加币失败:网络或系统错误', 'error');
}
}
initializeGrid() {
this.grid = [];
@@ -315,6 +431,16 @@ class Game2048 {
message.className = 'game-message game-won';
message.style.display = 'flex';
message.querySelector('p').textContent = '你赢了!';
// 胜利也尝试加币异步不阻塞UI
this.tryAwardCoinsOnGameOver();
// 显示最终统计
setTimeout(() => {
if (window.gameStats) {
window.gameStats.showFinalStats();
}
}, 1000);
}
showGameOver() {
@@ -323,6 +449,16 @@ class Game2048 {
message.style.display = 'flex';
message.querySelector('p').textContent = '游戏结束!';
// 渲染排行榜
try {
this.renderLeaderboard();
} catch (e) {
console.error('渲染排行榜时发生错误:', e);
}
// 尝试加币异步不阻塞UI
this.tryAwardCoinsOnGameOver();
// 显示最终统计
setTimeout(() => {
if (window.gameStats) {
@@ -377,6 +513,92 @@ class Game2048 {
}, 1000);
}
// 构建并渲染排行榜
renderLeaderboard() {
const container = document.getElementById('leaderboard');
if (!container) return;
// 生成当前玩家数据
const today = this.formatDate(new Date());
const currentPlayer = {
"名称": "我",
"账号": "guest-local",
"分数": this.score,
"时间": today,
_current: true
};
// 合并并排序数据(分数由高到低)
const baseData = (typeof playerdata !== 'undefined' && Array.isArray(playerdata)) ? playerdata : [];
const merged = [...baseData.map(d => ({...d})), currentPlayer]
.sort((a, b) => (b["分数"] || 0) - (a["分数"] || 0));
// 计算当前玩家排名
const currentIndex = merged.findIndex(d => d._current);
const rank = currentIndex >= 0 ? currentIndex + 1 : '-';
// 仅展示前10条
const topN = merged.slice(0, 10);
// 生成 HTML
const summaryHtml = `
<div class="leaderboard-summary">
<span>本局分数:<strong>${this.score}</strong></span>
<span>用时:<strong>${this.stats.gameTime}</strong> 秒</span>
<span>你的排名:<strong>${rank}</strong></span>
</div>
`;
const headerHtml = `
<div class="leaderboard-header">
<div class="leaderboard-col rank">排名</div>
<div class="leaderboard-col name">名称</div>
<div class="leaderboard-col score">分数</div>
<div class="leaderboard-col time">日期</div>
</div>
`;
const rowsHtml = topN.map((d, i) => {
const isCurrent = !!d._current;
const rowClass = `leaderboard-row${isCurrent ? ' current' : ''}`;
return `
<div class="${rowClass}">
<div class="leaderboard-col rank">${i + 1}</div>
<div class="leaderboard-col name">${this.escapeHtml(d["名称"] || '未知')}</div>
<div class="leaderboard-col score">${d["分数"] ?? 0}</div>
<div class="leaderboard-col time">${this.escapeHtml(d["时间"] || '-')}</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="leaderboard-title">排行榜</div>
${summaryHtml}
<div class="leaderboard-table">
${headerHtml}
<div class="leaderboard-body">${rowsHtml}</div>
</div>
`;
}
// 工具:日期格式化 YYYY-MM-DD
formatDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
// 工具:简单转义以避免 XSS
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#039;');
}
bindEvents() {
// 重试按钮
document.getElementById('retry-btn').addEventListener('click', () => {

View File

@@ -22,6 +22,8 @@
<div class="game-container">
<div class="game-message" id="game-message">
<p></p>
<!-- 排行榜容器:游戏结束后动态填充 -->
<div id="leaderboard" class="leaderboard" aria-live="polite"></div>
<div class="lower">
<a class="retry-button" id="retry-btn">重新开始</a>
</div>
@@ -64,6 +66,7 @@
<script src="gamedata.js"></script>
<script src="game-logic.js"></script>
<script src="controls.js"></script>
</body>

View File

@@ -237,6 +237,91 @@ body {
transition: all 0.3s ease;
}
/* 排行榜样式(与 2048 主题一致) */
.leaderboard {
width: 100%;
max-width: 440px;
background: rgba(250, 248, 239, 0.95); /* #faf8ef */
border-radius: 12px;
padding: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
color: #776e65;
}
.leaderboard-title {
font-size: 22px;
font-weight: 700;
color: #8f7a66;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.leaderboard-summary {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 14px;
color: #8f7a66;
margin-bottom: 10px;
}
.leaderboard-summary strong {
color: #8f7a66;
}
.leaderboard-table {
border: 1px solid rgba(187, 173, 160, 0.3); /* #bbada0 */
border-radius: 10px;
overflow: hidden;
background: rgba(238, 228, 218, 0.4); /* #eee4da */
}
.leaderboard-header,
.leaderboard-row {
display: grid;
grid-template-columns: 64px 1fr 90px 120px; /* 排名/名称/分数/日期 */
align-items: center;
}
.leaderboard-header {
background: #eee4da;
color: #776e65;
font-weight: 700;
padding: 8px 10px;
border-bottom: 1px solid rgba(187, 173, 160, 0.3);
}
.leaderboard-body {
max-height: 220px;
overflow-y: auto;
background: rgba(238, 228, 218, 0.25);
}
.leaderboard-row {
padding: 8px 10px;
border-top: 1px solid rgba(187, 173, 160, 0.15);
}
.leaderboard-row:nth-child(odd) {
background: rgba(238, 228, 218, 0.22);
}
.leaderboard-row.current {
background: #f3e9d4;
box-shadow: inset 0 0 0 2px rgba(143, 122, 102, 0.35);
}
.leaderboard-col.rank {
text-align: center;
font-weight: 700;
color: #8f7a66;
}
.leaderboard-col.score {
text-align: right;
font-weight: 700;
}
.leaderboard-col.time {
text-align: right;
color: #776e65;
}
.retry-button:hover {
background: #9f8a76;
transform: translateY(-2px);

View File

@@ -1,338 +1,96 @@
// 游戏统计和成就系统
class GameStats {
constructor() {
this.achievements = [
{
id: 'first_game',
name: '初次体验',
description: '完成第一次游戏',
condition: (stats) => true
},
{
id: 'score_1000',
name: '小试牛刀',
description: '单局得分达到1000分',
condition: (stats) => stats.score >= 1000
},
{
id: 'score_5000',
name: '游戏达人',
description: '单局得分达到5000分',
condition: (stats) => stats.score >= 5000
},
{
id: 'score_10000',
name: '方块大师',
description: '单局得分达到10000分',
condition: (stats) => stats.score >= 10000
},
{
id: 'level_5',
name: '步步高升',
description: '达到第5级',
condition: (stats) => stats.level >= 5
},
{
id: 'level_10',
name: '速度之王',
description: '达到第10级',
condition: (stats) => stats.level >= 10
},
{
id: 'lines_50',
name: '消除专家',
description: '累计消除50行',
condition: (stats) => stats.lines >= 50
},
{
id: 'lines_100',
name: '清理大师',
description: '累计消除100行',
condition: (stats) => stats.lines >= 100
},
{
id: 'tetris',
name: 'Tetris!',
description: '一次消除4行',
condition: (stats) => stats.maxCombo >= 4
},
{
id: 'time_10min',
name: '持久战士',
description: '单局游戏时间超过10分钟',
condition: (stats) => stats.playTime >= 600000
},
{
id: 'efficiency',
name: '效率专家',
description: '平均每分钟得分超过500',
condition: (stats) => stats.avgScore >= 500
}
];
this.init();
}
init() {
this.setupEventListeners();
}
setupEventListeners() {
const playAgainBtn = document.getElementById('playAgainBtn');
playAgainBtn.addEventListener('click', () => {
this.hideStats();
game.restart();
});
}
showStats(gameData) {
const playTimeMinutes = gameData.playTime / 60000;
const avgScore = playTimeMinutes > 0 ? Math.round(gameData.score / playTimeMinutes) : 0;
const stats = {
...gameData,
avgScore: avgScore
};
// 更新统计显示
document.getElementById('finalScore').textContent = stats.score.toLocaleString();
document.getElementById('finalLevel').textContent = stats.level;
document.getElementById('finalLines').textContent = stats.lines;
document.getElementById('playTime').textContent = this.formatTime(stats.playTime);
document.getElementById('maxCombo').textContent = stats.maxCombo;
document.getElementById('avgScore').textContent = stats.avgScore;
// 检查成就
const achievement = this.checkAchievements(stats);
this.displayAchievement(achievement);
// 显示统计界面
document.getElementById('gameStats').style.display = 'flex';
document.getElementById('gameStats').classList.add('fade-in');
}
hideStats() {
document.getElementById('gameStats').style.display = 'none';
document.getElementById('gameStats').classList.remove('fade-in');
}
formatTime(milliseconds) {
const seconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
checkAchievements(stats) {
// 获取已获得的成就
const earnedAchievements = this.getEarnedAchievements();
// 检查新成就
for (let achievement of this.achievements) {
if (!earnedAchievements.includes(achievement.id) &&
achievement.condition(stats)) {
// 保存新成就
this.saveAchievement(achievement.id);
return achievement;
}
}
return null;
}
displayAchievement(achievement) {
const achievementEl = document.getElementById('achievement');
if (achievement) {
achievementEl.innerHTML = `
🏆 <strong>成就解锁!</strong><br>
<strong>${achievement.name}</strong><br>
${achievement.description}
`;
achievementEl.classList.add('pulse');
} else {
// 显示随机鼓励话语
const encouragements = [
'继续努力,你会变得更强!',
'每一次游戏都是进步的机会!',
'方块世界需要你的智慧!',
'熟能生巧,加油!',
'下一局一定会更好!',
'坚持就是胜利!',
'你的反应速度在提升!',
'策略思维正在增强!'
];
const randomEncouragement = encouragements[Math.floor(Math.random() * encouragements.length)];
achievementEl.innerHTML = `💪 ${randomEncouragement}`;
achievementEl.classList.remove('pulse');
}
}
getEarnedAchievements() {
const saved = localStorage.getItem('tetris_achievements');
return saved ? JSON.parse(saved) : [];
}
saveAchievement(achievementId) {
const earned = this.getEarnedAchievements();
if (!earned.includes(achievementId)) {
earned.push(achievementId);
localStorage.setItem('tetris_achievements', JSON.stringify(earned));
}
}
// 获取历史最佳记录
getBestStats() {
const saved = localStorage.getItem('tetris_best_stats');
return saved ? JSON.parse(saved) : {
score: 0,
level: 0,
lines: 0,
maxCombo: 0
};
}
// 保存最佳记录
saveBestStats(stats) {
const best = this.getBestStats();
let updated = false;
if (stats.score > best.score) {
best.score = stats.score;
updated = true;
}
if (stats.level > best.level) {
best.level = stats.level;
updated = true;
}
if (stats.lines > best.lines) {
best.lines = stats.lines;
updated = true;
}
if (stats.maxCombo > best.maxCombo) {
best.maxCombo = stats.maxCombo;
updated = true;
}
if (updated) {
localStorage.setItem('tetris_best_stats', JSON.stringify(best));
}
return updated;
}
// 显示排行榜
showLeaderboard() {
const best = this.getBestStats();
const earned = this.getEarnedAchievements();
console.log('最佳记录:', best);
console.log('已获得成就:', earned.length + '/' + this.achievements.length);
}
}
// 游戏结束排行榜展示
const gameStats = {
showStats({ score, playTime }) {
// 将毫秒转为 mm:ss
const formatDuration = (ms) => {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const m = String(Math.floor(totalSec / 60)).padStart(2, '0');
const s = String(totalSec % 60).padStart(2, '0');
return `${m}:${s}`;
};
// 高级特效系统
class GameEffects {
constructor(game) {
this.game = game;
this.particles = [];
this.effects = [];
this.init();
}
init() {
// 创建特效canvas
this.effectsCanvas = document.createElement('canvas');
this.effectsCanvas.width = this.game.canvas.width;
this.effectsCanvas.height = this.game.canvas.height;
this.effectsCanvas.style.position = 'absolute';
this.effectsCanvas.style.top = '0';
this.effectsCanvas.style.left = '0';
this.effectsCanvas.style.pointerEvents = 'none';
this.effectsCanvas.style.zIndex = '10';
this.effectsCtx = this.effectsCanvas.getContext('2d');
// 将特效canvas添加到游戏板容器中
this.game.canvas.parentElement.style.position = 'relative';
this.game.canvas.parentElement.appendChild(this.effectsCanvas);
}
// 行消除特效
lineCleared(row) {
for (let i = 0; i < 20; i++) {
this.particles.push({
x: Math.random() * this.game.canvas.width,
y: row * this.game.CELL_SIZE + this.game.CELL_SIZE / 2,
vx: (Math.random() - 0.5) * 10,
vy: (Math.random() - 0.5) * 10,
life: 1,
decay: 0.02,
color: `hsl(${Math.random() * 360}, 100%, 50%)`
});
}
}
// 方块锁定特效
pieceLocked(piece) {
const centerX = (piece.x + piece.matrix[0].length / 2) * this.game.CELL_SIZE;
const centerY = (piece.y + piece.matrix.length / 2) * this.game.CELL_SIZE;
for (let i = 0; i < 10; i++) {
this.particles.push({
x: centerX,
y: centerY,
vx: (Math.random() - 0.5) * 8,
vy: (Math.random() - 0.5) * 8,
life: 0.8,
decay: 0.03,
color: piece.color
});
}
}
// 更新特效
update() {
// 更新粒子
for (let i = this.particles.length - 1; i >= 0; i--) {
const particle = this.particles[i];
particle.x += particle.vx;
particle.y += particle.vy;
particle.life -= particle.decay;
if (particle.life <= 0) {
this.particles.splice(i, 1);
}
}
}
// 绘制特效
draw() {
this.effectsCtx.clearRect(0, 0, this.effectsCanvas.width, this.effectsCanvas.height);
// 绘制粒子
for (let particle of this.particles) {
this.effectsCtx.save();
this.effectsCtx.globalAlpha = particle.life;
this.effectsCtx.fillStyle = particle.color;
this.effectsCtx.beginPath();
this.effectsCtx.arc(particle.x, particle.y, 3, 0, Math.PI * 2);
this.effectsCtx.fill();
this.effectsCtx.restore();
}
}
}
// 构造排行榜数据(模拟),将当前成绩与 gamedata.js 合并
const todayStr = (() => {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
})();
// 创建统计系统实例
const gameStats = new GameStats();
// 当前玩家信息(可根据实际项目替换为真实用户)
const currentEntry = {
名称: localStorage.getItem('tetris_player_name') || '我',
账号: localStorage.getItem('tetris_player_account') || 'guest@local',
分数: score,
时间: formatDuration(playTime), // 排行榜展示“游戏时长”
isCurrent: true,
};
// 在适当的地方创建特效系统
// const gameEffects = new GameEffects(game);
// 注意:在浏览器中,使用 const 声明的全局变量不会挂载到 window 上
// 因此这里直接使用 playerdata而不是 window.playerdata
const baseData = (typeof playerdata !== 'undefined' && Array.isArray(playerdata)) ? playerdata : [];
// 为基础数据模拟“游戏时长”mm:ss以满足展示需求
const simulateDuration = (scoreVal) => {
const sec = Math.max(30, Math.min(30 * 60, Math.round((Number(scoreVal) || 0) * 1.2)));
return formatDuration(sec * 1000);
};
const merged = [...baseData.map((d) => ({
...d,
// 使用已有分数推导一个模拟时长
时间: simulateDuration(d.分数),
isCurrent: false,
})), currentEntry]
.sort((a, b) => (b.分数 || 0) - (a.分数 || 0));
// 3) 渲染排行榜取前10
const tbody = document.getElementById('leaderboardBody');
tbody.innerHTML = '';
const topN = merged.slice(0, 10);
topN.forEach((item, idx) => {
const tr = document.createElement('tr');
if (item.isCurrent) {
tr.classList.add('current-row');
}
const rankCell = document.createElement('td');
const nameCell = document.createElement('td');
const scoreCell = document.createElement('td');
const timeCell = document.createElement('td');
const rankBadge = document.createElement('span');
rankBadge.className = 'rank-badge';
rankBadge.textContent = String(idx + 1);
rankCell.appendChild(rankBadge);
nameCell.textContent = item.名称 || '未知';
scoreCell.textContent = item.分数 || 0;
timeCell.textContent = item.时间 || formatDuration(playTime);
tr.appendChild(rankCell);
tr.appendChild(nameCell);
tr.appendChild(scoreCell);
tr.appendChild(timeCell);
tbody.appendChild(tr);
});
// 4) 展示排行榜界面
const statsEl = document.getElementById('gameStats');
statsEl.style.display = 'flex';
// 5) 再玩一次按钮
const playAgainBtn = document.getElementById('playAgainBtn');
if (playAgainBtn) {
playAgainBtn.onclick = () => {
statsEl.style.display = 'none';
if (window.game && typeof window.game.restart === 'function') {
window.game.restart();
}
};
}
},
};
// 暴露到全局
window.gameStats = gameStats;

View File

@@ -61,40 +61,32 @@
<!-- 游戏结束统计界面 -->
<div class="game-stats" id="gameStats">
<div class="stats-content">
<h2>游戏结束</h2>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">最终分数</span>
<span class="stat-value" id="finalScore">0</span>
</div>
<div class="stat-item">
<span class="stat-label">达到等级</span>
<span class="stat-value" id="finalLevel">1</span>
</div>
<div class="stat-item">
<span class="stat-label">消除行数</span>
<span class="stat-value" id="finalLines">0</span>
</div>
<div class="stat-item">
<span class="stat-label">游戏时长</span>
<span class="stat-value" id="playTime">00:00</span>
</div>
<div class="stat-item">
<span class="stat-label">单次消除最大行数</span>
<span class="stat-value" id="maxCombo">0</span>
</div>
<div class="stat-item">
<span class="stat-label">平均每分钟分数</span>
<span class="stat-value" id="avgScore">0</span>
<h2>游戏结束排行榜</h2>
<!-- 排行榜 -->
<div class="leaderboard" id="leaderboard">
<div class="leaderboard-title">本局排行榜</div>
<div class="leaderboard-wrap">
<table class="leaderboard-table">
<thead>
<tr>
<th>排名</th>
<th>名称</th>
<th>分数</th>
<th>游戏时长</th>
</tr>
</thead>
<tbody id="leaderboardBody"></tbody>
</table>
</div>
<div class="leaderboard-tip">仅显示前10名“游戏时长”为模拟数据已与您的成绩合并</div>
</div>
<div class="achievement" id="achievement"></div>
<button class="game-btn" id="playAgainBtn">再玩一次</button>
</div>
</div>
<script src="tetris.js"></script>
<script src="game-controls.js"></script>
<script src="gamedata.js"></script>
<script src="game-stats.js"></script>
</body>
</html>

View File

@@ -319,6 +319,84 @@ body {
box-shadow: 0 4px 8px rgba(46, 125, 50, 0.3);
}
/* 排行榜样式 */
.leaderboard {
background: linear-gradient(135deg, #e8f5e8 0%, #f1f8e9 100%);
color: #2e7d32;
border: 1px solid rgba(46, 125, 50, 0.3);
border-radius: 16px;
box-shadow: 0 6px 18px rgba(46, 125, 50, 0.25);
padding: 16px;
margin-bottom: 20px;
}
.leaderboard-title {
font-weight: 700;
font-size: 1.2rem;
margin-bottom: 12px;
background: linear-gradient(135deg, #4caf50 0%, #8bc34a 50%, #cddc39 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.leaderboard-wrap {
max-height: 260px;
overflow: auto;
border-radius: 12px;
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
}
.leaderboard-table thead tr {
background: linear-gradient(135deg, #66bb6a 0%, #8bc34a 100%);
color: #fff;
}
.leaderboard-table th,
.leaderboard-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid rgba(46, 125, 50, 0.15);
font-size: 0.95rem;
}
.leaderboard-table tbody tr {
background: linear-gradient(135deg, rgba(46,125,50,0.08) 0%, rgba(46,125,50,0.03) 100%);
transition: background 0.2s ease, transform 0.2s ease;
}
.leaderboard-table tbody tr:hover {
background: linear-gradient(135deg, rgba(46,125,50,0.12) 0%, rgba(46,125,50,0.06) 100%);
transform: translateY(-1px);
}
.rank-badge {
display: inline-block;
min-width: 32px;
text-align: center;
padding: 4px 8px;
border-radius: 12px;
background: linear-gradient(45deg, #66bb6a, #8bc34a);
color: #fff;
font-weight: 700;
}
.current-row {
outline: 2px solid rgba(76, 175, 80, 0.7);
box-shadow: 0 0 0 4px rgba(76, 175, 80, 0.15) inset;
}
.leaderboard-tip {
margin-top: 10px;
font-size: 0.85rem;
color: #388e3c;
opacity: 0.85;
}
/* 响应式设计 */
@media (max-width: 768px) {
.game-container {
@@ -378,6 +456,15 @@ body {
padding: 20px;
width: 95%;
}
.leaderboard-wrap {
max-height: 200px;
}
.leaderboard-table th,
.leaderboard-table td {
padding: 8px 10px;
font-size: 0.9rem;
}
}
@media (max-width: 480px) {
@@ -449,3 +536,39 @@ body {
.pulse {
animation: pulse 2s infinite;
}
/* 摘要卡片 */
.leaderboard-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.summary-item {
background: linear-gradient(135deg, #2e7d32 0%, #388e3c 100%);
color: #fff;
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(46, 125, 50, 0.3);
box-shadow: 0 4px 8px rgba(46, 125, 50, 0.2);
}
.summary-label {
display: block;
font-size: 0.9rem;
opacity: 0.9;
}
.summary-value {
display: block;
font-size: 1.3rem;
font-weight: 700;
margin-top: 4px;
}
@media (max-width: 768px) {
.leaderboard-summary {
grid-template-columns: 1fr;
gap: 10px;
}
}

View File

@@ -84,6 +84,8 @@ function gameOver(){
// 显示最终得分和达到的最高速度
document.getElementById('final-score-value').innerHTML = myScore;
document.getElementById('final-speed-value').innerHTML = gameSpeed.toFixed(1);
// 渲染排行榜
renderLeaderboard();
// 显示游戏结束弹窗
document.getElementById('game-over-modal').style.display = 'flex';
@@ -250,6 +252,78 @@ function handleClick(e) {
checkHit(x, y);
}
// ===== 排行榜逻辑 =====
function formatDateYYYYMMDD() {
var d = new Date();
var y = d.getFullYear();
var m = String(d.getMonth() + 1).padStart(2, '0');
var day = String(d.getDate()).padStart(2, '0');
return y + '-' + m + '-' + day;
}
function escapeHtml(str) {
if (typeof str !== 'string') return str;
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderLeaderboard(){
var nowStr = formatDateYYYYMMDD();
// 当前玩家数据(模拟)
var me = {
"名称": "我",
"账号": "guest",
"分数": myScore,
"时间": nowStr,
"__isMe": true
};
// 合并现有数据与当前玩家
var data = (typeof playerdata !== 'undefined' && Array.isArray(playerdata))
? playerdata.slice() : [];
data.push(me);
// 按分数降序排序
data.sort(function(a, b){
return (b["分数"] || 0) - (a["分数"] || 0);
});
var tbody = document.getElementById('leaderboard-body');
if (!tbody) return;
tbody.innerHTML = '';
var myRank = -1;
for (var i = 0; i < data.length; i++){
var row = data[i];
var tr = document.createElement('tr');
if (row.__isMe){
myRank = i + 1;
tr.className = 'leaderboard-row-me';
}
tr.innerHTML =
'<td>' + (i + 1) + '</td>' +
'<td>' + escapeHtml(row["名称"] || '') + '</td>' +
'<td>' + (row["分数"] || 0) + '</td>' +
'<td>' + escapeHtml(row["时间"] || '') + '</td>';
// 只展示前10名
if (i < 10) tbody.appendChild(tr);
}
// 更新我的数据摘要
var rankEl = document.getElementById('my-rank');
var scoreEl = document.getElementById('my-score');
var timeEl = document.getElementById('my-time');
if (rankEl) rankEl.textContent = myRank > 0 ? myRank : '-';
if (scoreEl) scoreEl.textContent = myScore;
if (timeEl) timeEl.textContent = nowStr;
}
// 处理触摸事件
function handleTouch(e) {

View File

@@ -182,6 +182,56 @@
background: linear-gradient(45deg, #4caf50, #388e3c);
box-shadow: 0 6px 16px rgba(76,175,80,0.4);
}
/* 排行榜样式 */
.leaderboard {
margin-top: 15px;
background: rgba(255,255,255,0.6);
border: 1px solid rgba(129,199,132,0.3);
border-radius: 10px;
overflow: hidden;
}
.leaderboard-title {
background: linear-gradient(45deg, #66bb6a, #4caf50);
color: white;
font-weight: bold;
font-size: 16px;
padding: 8px 12px;
text-align: left;
box-shadow: inset 0 -1px 0 rgba(255,255,255,0.2);
}
.leaderboard-meta {
color: #2e7d32;
font-size: 13px;
padding: 8px 12px;
border-bottom: 1px solid rgba(129,199,132,0.2);
display: flex;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
}
.leaderboard-table th, .leaderboard-table td {
padding: 8px 6px;
font-size: 13px;
border-bottom: 1px solid rgba(129,199,132,0.2);
color: #1b5e20;
text-align: center;
}
.leaderboard-table th {
background: rgba(129,199,132,0.2);
font-weight: bold;
color: #1b5e20;
}
.leaderboard-row-me {
background: rgba(198,40,40,0.08);
border-left: 3px solid #c62828;
}
.leaderboard-table tr:nth-child(even) {
background: rgba(129,199,132,0.1);
}
/* 移动端适配 */
@media (max-width: 768px) {
@@ -253,11 +303,32 @@
<h2 class="modal-title">游戏结束</h2>
<div class="final-score">最终得分: <span id="final-score-value">0</span></div>
<div class="final-speed">最高速度: <span id="final-speed-value">1.0</span>x</div>
<!-- 排行榜区域 -->
<div class="leaderboard">
<div class="leaderboard-title">排行榜</div>
<div class="leaderboard-meta">
<span>我的排名:第 <span id="my-rank">-</span></span>
<span>我的分数:<span id="my-score">0</span></span>
<span>时间:<span id="my-time">--</span></span>
</div>
<table class="leaderboard-table">
<thead>
<tr>
<th>排名</th>
<th>名称</th>
<th>分数</th>
<th>时间</th>
</tr>
</thead>
<tbody id="leaderboard-body"></tbody>
</table>
</div>
<button id="restart-btn" class="modal-btn restart-btn">重新开始</button>
</div>
</div>
<audio id="music" src="MUSIC.mp3" loop></audio>
<script src="gamedata.js"></script>
<script src="game.js"></script>
</body>
</html>

View File

@@ -0,0 +1,309 @@
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('scoreVal');
const pauseBtn = document.getElementById('pauseBtn');
const restartBtn = document.getElementById('restartBtn');
const startOverlay = document.getElementById('startOverlay');
const startBtn = document.getElementById('startBtn');
const overOverlay = document.getElementById('overOverlay');
const againBtn = document.getElementById('againBtn');
const finalScoreEl = document.getElementById('finalScore');
let width = 0, height = 0;
let running = false, paused = false, gameOver = false;
let player, bullets = [], enemies = [], particles = [];
let score = 0, elapsed = 0, spawnTimer = 0, fireTimer = 0;
function fitCanvas(){
const w = canvas.clientWidth | 0;
const h = canvas.clientHeight | 0;
if (canvas.width !== w || canvas.height !== h){
canvas.width = w;
canvas.height = h;
}
width = canvas.width; height = canvas.height;
}
function clamp(v,min,max){ return v < min ? min : (v > max ? max : v); }
function rand(min,max){ return Math.random()*(max-min)+min; }
function initGame(){
fitCanvas();
score = 0;
elapsed = 0;
spawnTimer = 0;
fireTimer = 0;
bullets.length = 0;
enemies.length = 0;
particles.length = 0;
gameOver = false;
paused = false;
player = {
x: width/2,
y: height*0.82,
r: Math.max(14, Math.min(width,height)*0.02),
speed: Math.max(350, Math.min(width,height)*0.9),
alive: true
};
scoreEl.textContent = '0';
pauseBtn.textContent = '暂停';
}
function startGame(){
running = true;
startOverlay.classList.add('hide');
overOverlay.classList.add('hide');
initGame();
requestAnimationFrame(loop);
}
function restartGame(){
startOverlay.classList.add('hide');
startGame();
}
pauseBtn.addEventListener('click', ()=>{
if (!running) return;
paused = !paused;
pauseBtn.textContent = paused ? '继续' : '暂停';
});
restartBtn.addEventListener('click', ()=>{ initGame(); });
startBtn.addEventListener('click', startGame);
againBtn.addEventListener('click', ()=>{ startOverlay.classList.add('hide'); startGame(); });
window.addEventListener('resize', fitCanvas);
let pointerActive = false;
canvas.addEventListener('pointerdown', (e)=>{
pointerActive = true;
if (!running) startGame();
movePlayer(e);
canvas.setPointerCapture && canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', (e)=>{ if (pointerActive) movePlayer(e); });
canvas.addEventListener('pointerup', ()=>{ pointerActive = false; });
function movePlayer(e){
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left);
const y = (e.clientY - rect.top);
const minY = height * 0.45;
player.x = clamp(x, player.r, width - player.r);
player.y = clamp(y, minY, height - player.r);
}
function spawnEnemy(){
const d = Math.min(6, 1 + elapsed/10);
let r, x, speed, hp, color, type;
const roll = Math.random();
if (roll < 0.5 - Math.min(0.2, elapsed*0.02)) { // 普通
type = 'normal';
r = rand(12, 18 + d*1.8);
x = rand(r, width - r);
speed = rand(60 + d*20, 110 + d*30);
hp = 1; color = 'rgba(70,160,80,0.9)';
enemies.push({x, y: -r, r, speed, hp, color, type});
} else if (roll < 0.75) { // 快速
type = 'fast';
r = rand(10, 14 + d);
x = rand(r, width - r);
speed = rand(130 + d*35, 220 + d*40);
hp = 1; color = 'rgba(120,200,90,0.95)';
enemies.push({x, y: -r, r, speed, hp, color, type});
} else if (roll < 0.92) { // 之字形
type = 'zigzag';
r = rand(12, 18 + d*1.5);
x = rand(r, width - r);
speed = rand(90 + d*20, 140 + d*25);
hp = 1; color = 'rgba(90,180,110,0.95)';
const vxAmp = rand(40, 80);
const freq = rand(2, 4);
const phase = rand(0, Math.PI*2);
enemies.push({x, y: -r, r, speed, hp, color, type, vxAmp, freq, phase});
} else if (roll < 0.98) { // 坦克型(耐久)
type = 'tough';
r = rand(20, 26 + d);
x = rand(r, width - r);
speed = rand(60, 100 + d*10);
hp = 3; color = 'rgba(50,140,70,0.9)';
enemies.push({x, y: -r, r, speed, hp, color, type});
} else { // 分裂型
type = 'splitter';
r = rand(22, 28 + d);
x = rand(r, width - r);
speed = rand(70 + d*15, 100 + d*20);
hp = 2; color = 'rgba(80,170,90,0.95)';
enemies.push({x, y: -r, r, speed, hp, color, type});
}
}
function spawnChildren(parent){
const count = 2;
for (let k=0; k<count; k++){
const r = Math.max(8, parent.r*0.45);
const x = clamp(parent.x + rand(-r, r), r, width - r);
const speed = rand(120, 180);
const vx = rand(-60, 60);
enemies.push({ x, y: parent.y + 6, r, speed, hp: 1, color: 'rgba(140,220,110,0.95)', type: 'mini', vx });
}
}
function fireBullet(){
const br = Math.max(3, player.r*0.22);
bullets.push({x: player.x, y: player.y - player.r - br, r: br, vy: -420});
}
function update(dt){
if (!running || paused || gameOver) return;
elapsed += dt;
// difficulty & spawn interval decreases over time
const interval = Math.max(0.16, 0.72 - elapsed*0.018);
spawnTimer -= dt;
if (spawnTimer <= 0){ spawnEnemy(); spawnTimer = interval; }
// auto fire
const fireInterval = Math.max(0.08, 0.14 - elapsed*0.002);
fireTimer -= dt;
if (fireTimer <= 0){ fireBullet(); fireTimer = fireInterval; }
// bullets
for (let i=bullets.length-1; i>=0; i--){
const b = bullets[i];
b.y += b.vy * dt;
if (b.y + b.r < 0){ bullets.splice(i,1); }
}
// enemies
const speedBoost = Math.min(2.2, 1 + elapsed*0.015);
for (let i=enemies.length-1; i>=0; i--){
const e = enemies[i];
// 不同类型的移动方式
if (e.type === 'zigzag'){
e.y += e.speed * speedBoost * dt;
e.phase += (e.freq || 3) * dt;
e.x += Math.sin(e.phase) * (e.vxAmp || 60) * dt;
e.x = clamp(e.x, e.r, width - e.r);
} else if (e.type === 'mini'){
e.y += e.speed * speedBoost * dt;
e.x += (e.vx || 0) * dt;
e.x = clamp(e.x, e.r, width - e.r);
} else {
e.y += e.speed * speedBoost * dt;
}
// 与玩家碰撞
const dx = e.x - player.x, dy = e.y - player.y;
const rr = e.r + player.r;
if (dx*dx + dy*dy < rr*rr){ endGame(); break; }
if (e.y - e.r > height){ enemies.splice(i,1); }
}
// bullet-enemy collisions
for (let i=enemies.length-1; i>=0; i--){
const e = enemies[i];
for (let j=bullets.length-1; j>=0; j--){
const b = bullets[j];
const dx = e.x - b.x, dy = e.y - b.y;
const rr = e.r + b.r;
if (dx*dx + dy*dy <= rr*rr){
bullets.splice(j,1);
e.hp -= 1;
addBurst(e.x, e.y, e.r);
if (e.hp <= 0){
if (e.type === 'splitter'){ spawnChildren(e); }
enemies.splice(i,1);
score += (e.type === 'tough' ? 2 : 1);
scoreEl.textContent = score;
}
break;
}
}
}
// particles
for (let i=particles.length-1; i>=0; i--){
const p = particles[i];
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt;
if (p.life <= 0) particles.splice(i,1);
}
}
function addBurst(x,y,r){
for (let i=0; i<6; i++){
const a = Math.random() * Math.PI * 2;
const speed = rand(40, 140);
particles.push({ x, y, vx: Math.cos(a)*speed, vy: Math.sin(a)*speed, life: rand(0.15, 0.4) });
}
}
function draw(){
fitCanvas();
ctx.clearRect(0,0,width,height);
// soft overlay for depth
const grd = ctx.createLinearGradient(0,0,0,height);
grd.addColorStop(0, 'rgba(255,255,255,0.0)');
grd.addColorStop(1, 'rgba(255,255,255,0.05)');
ctx.fillStyle = grd; ctx.fillRect(0,0,width,height);
// player
drawPlayer();
// bullets
ctx.fillStyle = 'rgba(80,180,90,0.9)';
for (let i=0; i<bullets.length; i++){
const b = bullets[i];
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI*2); ctx.fill();
}
// enemies
for (let i=0; i<enemies.length; i++){
const e = enemies[i];
ctx.fillStyle = e.color; drawEnemy(e);
}
// particles
ctx.fillStyle = 'rgba(160,220,140,0.9)';
for (let i=0; i<particles.length; i++){
const p = particles[i];
ctx.beginPath(); ctx.arc(p.x, p.y, 2, 0, Math.PI*2); ctx.fill();
}
}
function drawPlayer(){
const x = player.x, y = player.y, r = player.r;
ctx.save(); ctx.translate(x, y);
ctx.fillStyle = 'rgba(60,150,80,0.95)';
ctx.strokeStyle = 'rgba(40,120,60,0.9)'; ctx.lineWidth = 2;
// body
ctx.beginPath();
ctx.moveTo(0, -r*1.2);
ctx.quadraticCurveTo(r*0.3, -r*0.4, r*0.25, r*0.3);
ctx.lineTo(0, r*1.1);
ctx.lineTo(-r*0.25, r*0.3);
ctx.quadraticCurveTo(-r*0.3, -r*0.4, 0, -r*1.2);
ctx.closePath(); ctx.fill(); ctx.stroke();
// wings
ctx.beginPath(); ctx.fillStyle = 'rgba(90,180,110,0.95)';
ctx.moveTo(-r*0.9, r*0.1);
ctx.lineTo(r*0.9, r*0.1);
ctx.lineTo(r*0.5, r*0.4);
ctx.lineTo(-r*0.5, r*0.4);
ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawEnemy(e){
const r = e.r;
ctx.beginPath(); ctx.arc(e.x, e.y, r, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.beginPath(); ctx.arc(e.x - r*0.3, e.y - r*0.3, r*0.4, 0, Math.PI*2); ctx.fill();
}
function endGame(){
gameOver = true; running = false;
finalScoreEl.textContent = score;
overOverlay.classList.remove('hide');
}
let last = 0;
function loop(ts){
if (!last) last = ts;
const dt = Math.min(0.033, (ts - last) / 1000);
last = ts;
update(dt);
draw();
if (running) requestAnimationFrame(loop);
}
// 初始显示开始覆盖层
fitCanvas();

View File

@@ -0,0 +1,44 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="theme-color" content="#d8f5c3">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>打飞机 · 清新休闲</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<header class="topbar">
<div class="brand">打飞机</div>
<div class="score">得分:<span id="scoreVal">0</span></div>
<div class="actions">
<button id="pauseBtn" aria-label="暂停">暂停</button>
<button id="restartBtn" aria-label="重来">重来</button>
</div>
</header>
<main class="game-wrap">
<canvas id="game" aria-label="打飞机游戏画布"></canvas>
<div id="startOverlay" class="overlay">
<div class="panel">
<h1>打飞机</h1>
<p>轻触屏幕开始,无尽模式。</p>
<p>操作:手指拖动战机移动。</p>
<button id="startBtn">开始游戏</button>
</div>
</div>
<div id="overOverlay" class="overlay hide">
<div class="panel">
<h2>游戏结束</h2>
<p>本次得分:<span id="finalScore">0</span></p>
<button id="againBtn">再来一局</button>
</div>
</div>
</main>
<script src="./game.js"></script>
</body>
</html>

View File

@@ -0,0 +1,65 @@
:root {
--header-h: 56px;
--bg-start: #d7f6d2;
--bg-end: #ecf7c8;
--accent: #6bb86f;
--accent2: #a5d67e;
--text: #274b2f;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
height: 100dvh;
width: 100vw;
color: var(--text);
background: linear-gradient(180deg, var(--bg-start), var(--bg-end));
font-family: system-ui, -apple-system, Segoe UI, Roboto, "Noto Sans SC", Arial, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.topbar {
position: fixed; top:0; left:0; right:0; height: var(--header-h);
display: flex; align-items: center; justify-content: space-between;
padding: 0 12px;
background: rgba(255,255,255,0.35);
border-bottom: 1px solid rgba(0,0,0,0.06);
backdrop-filter: saturate(120%) blur(10px);
}
.brand { font-weight: 700; letter-spacing: .5px; }
.score { font-weight: 600; }
.actions button {
background: var(--accent2); border: none; color: #1e3c27;
border-radius: 999px; padding: 6px 12px; margin-left: 8px;
font-weight: 600; box-shadow: 0 2px 6px rgba(0,0,0,0.08);
}
.actions button:active { transform: translateY(1px); }
.game-wrap { position: absolute; inset: var(--header-h) 0 0 0; }
#game {
width: 100vw; height: calc(100dvh - var(--header-h));
display: block; touch-action: none; cursor: crosshair;
}
.overlay {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(240, 250, 236, 0.6);
backdrop-filter: blur(6px);
}
.overlay.hide { display: none; }
.panel {
background: rgba(255,255,255,0.75);
border: 1px solid rgba(0,0,0,0.05);
border-radius: 16px; padding: 16px;
width: min(420px, 92vw); text-align: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.06);
}
.panel h1, .panel h2 { margin: 6px 0 8px; }
.panel p { margin: 4px 0; }
.panel button {
background: var(--accent); color: #fff; border: none;
border-radius: 12px; padding: 10px 16px; font-size: 16px; margin-top: 8px;
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
}
.panel button:active { transform: translateY(1px); }

View File

@@ -35,6 +35,114 @@ class SnakeGame {
this.init();
}
// 根据分数计算权重(权重越高,越容易触发且数量偏大)
calculateWeightByScore(score) {
const w = score / 100; // 1000分趋近高权重
return Math.max(0.1, Math.min(0.95, w));
}
// 权重偏向的随机整数weight越大越偏向更大值
biasedRandomInt(maxInclusive, weight) {
const r = Math.random();
const biased = Math.pow(r, 1 - weight);
const val = Math.floor(biased * (maxInclusive + 1));
return Math.max(0, Math.min(maxInclusive, val));
}
// 在排行榜弹层追加结束信息
appendEndInfo(text, type = 'info') {
const summary = document.getElementById('leaderboardSummary');
if (!summary) return;
const info = document.createElement('div');
info.style.marginTop = '8px';
info.style.fontSize = '14px';
info.style.color = type === 'error' ? '#d9534f' : (type === 'success' ? '#28a745' : '#333');
info.textContent = text;
summary.appendChild(info);
}
// 游戏结束后尝试加“萌芽币”
async tryAwardCoinsOnGameOver() {
try {
const token = localStorage.getItem('token');
if (!token) {
this.appendEndInfo('未登录,无法获得萌芽币');
return;
}
let email = null;
try {
const userStr = localStorage.getItem('user');
if (userStr) {
const userObj = JSON.parse(userStr);
email = userObj && (userObj.email || userObj['邮箱']);
}
} catch (_) {}
if (!email) {
this.appendEndInfo('未找到账户信息email无法加币', 'error');
return;
}
const weight = this.calculateWeightByScore(this.score);
let coins = 0;
let guaranteed = false;
// 得分大于400必定触发获得1-5个萌芽币
if (this.score > 5) {
guaranteed = true;
coins = Math.floor(Math.random() * 5) + 1; // 1~5
} else {
// 使用权重作为概率
const roll = Math.random();
if (roll > weight) {
this.appendEndInfo('本局未获得萌芽币');
return;
}
// 生成0~10随机数量权重越高越偏向更大
coins = this.biasedRandomInt(10, weight);
coins = Math.max(0, Math.min(10, coins));
if (coins <= 0) {
this.appendEndInfo('本局未获得萌芽币');
return;
}
}
// 后端 API base优先父窗口ENV_CONFIG
const apiBase = (window.parent && window.parent.ENV_CONFIG && window.parent.ENV_CONFIG.API_URL)
? window.parent.ENV_CONFIG.API_URL
: ((window.ENV_CONFIG && window.ENV_CONFIG.API_URL) ? window.ENV_CONFIG.API_URL : 'http://127.0.0.1:5002');
const resp = await fetch(`${apiBase}/api/user/add-coins`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ email, amount: coins })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
const msg = err && (err.message || err.error) ? (err.message || err.error) : `请求失败(${resp.status})`;
this.appendEndInfo(`加币失败:${msg}`, 'error');
return;
}
const data = await resp.json();
if (data && data.success) {
const newCoins = data.data && data.data.new_coins;
this.appendEndInfo(`恭喜获得 ${coins} 个萌芽币!当前余额:${newCoins}`, 'success');
} else {
const msg = (data && (data.message || data.error)) || '未知错误';
this.appendEndInfo(`加币失败:${msg}`, 'error');
}
} catch (e) {
console.error('加币流程发生错误:', e);
this.appendEndInfo('加币失败:网络或系统错误', 'error');
}
}
init() {
this.generateFood();
@@ -310,10 +418,94 @@ class SnakeGame {
this.dy = dy;
}
// 工具:格式化日期为 YYYY-MM-DD
formatDate(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
showGameOver() {
// 游戏结束时只记录最终状态,不显示弹窗
// 构建并展示排行榜弹层
const gameTime = Math.floor((Date.now() - this.startTime) / 1000);
console.log(`游戏结束! 分数: ${this.score}, 长度: ${this.snake.length}, 等级: ${this.level}, 时间: ${gameTime}`);
const overlay = document.getElementById('leaderboardOverlay');
const listEl = document.getElementById('leaderboardList');
const lbScore = document.getElementById('lbScore');
const lbLength = document.getElementById('lbLength');
const lbLevel = document.getElementById('lbLevel');
const lbGameTime = document.getElementById('lbGameTime');
const lbRank = document.getElementById('lbRank');
if (!overlay || !listEl) {
console.warn('排行榜容器不存在');
return;
}
// 汇总当前玩家数据
lbScore.textContent = this.score;
lbLength.textContent = this.snake.length;
lbLevel.textContent = this.level;
lbGameTime.textContent = `${gameTime}`;
const currentEntry = {
"名称": localStorage.getItem('snakePlayerName') || '我',
"账号": localStorage.getItem('snakePlayerAccount') || 'guest@local',
"分数": this.score,
"时间": this.formatDate(new Date()),
__isCurrent: true,
__duration: gameTime
};
// 合并并排序数据(使用 gamedata.js 中的 playerdata
const baseData = (typeof playerdata !== 'undefined' && Array.isArray(playerdata)) ? playerdata : [];
const merged = [...baseData, currentEntry];
merged.sort((a, b) => (b["分数"] || 0) - (a["分数"] || 0));
const playerIndex = merged.findIndex(e => e.__isCurrent);
lbRank.textContent = playerIndex >= 0 ? `#${playerIndex + 1}` : '—';
// 生成排行榜TOP 10
const topList = merged.slice(0, 10).map((entry, idx) => {
const isCurrent = !!entry.__isCurrent;
const name = entry["名称"] ?? '未知玩家';
const score = entry["分数"] ?? 0;
const dateStr = entry["时间"] ?? '';
const timeStr = isCurrent ? `时长:${entry.__duration}` : `时间:${dateStr}`;
return `
<div class="leaderboard-item ${isCurrent ? 'current-player' : ''}">
<span class="rank">#${idx + 1}</span>
<span class="player-name">${name}</span>
<span class="player-score">${score}分</span>
<span class="player-time">${timeStr}</span>
</div>
`;
}).join('');
listEl.innerHTML = topList;
overlay.style.display = 'flex';
// 结束时尝试加币异步不阻塞UI
this.tryAwardCoinsOnGameOver();
// 触发游戏结束事件(供统计模块使用)
const gameOverEvent = new CustomEvent('gameOver', {
detail: {
score: this.score,
length: this.snake.length,
level: this.level,
gameTime: gameTime,
foodEaten: this.foodEaten
}
});
document.dispatchEvent(gameOverEvent);
// 绑定重新开始按钮
const restartBtn = document.getElementById('leaderboardRestartBtn');
if (restartBtn) {
restartBtn.onclick = () => {
overlay.style.display = 'none';
this.restart();
};
}
}
@@ -334,6 +526,10 @@ class SnakeGame {
this.foodEaten = 0;
this.specialFood = null;
// 隐藏排行榜弹层(若可见)
const overlay = document.getElementById('leaderboardOverlay');
if (overlay) overlay.style.display = 'none';
this.generateFood();
this.updateUI();

View File

@@ -86,9 +86,6 @@ class GameStatistics {
// 保存到本地存储
localStorage.setItem('snakeHighScores', JSON.stringify(this.highScores));
// 显示高分榜
this.displayHighScores();
}
displaySessionStats() {
@@ -175,31 +172,7 @@ class GameStatistics {
}
}
// 扩展游戏核心类,添加统计事件触发
SnakeGame.prototype.showGameOver = function() {
const modal = document.getElementById('gameOverModal');
const gameTime = Math.floor((Date.now() - this.startTime) / 1000);
document.getElementById('finalScore').textContent = this.score;
document.getElementById('finalLength').textContent = this.snake.length;
document.getElementById('finalLevel').textContent = this.level;
document.getElementById('gameTime').textContent = gameTime;
document.getElementById('foodEaten').textContent = this.foodEaten;
// 触发游戏结束事件
const gameOverEvent = new CustomEvent('gameOver', {
detail: {
score: this.score,
length: this.snake.length,
level: this.level,
gameTime: gameTime,
foodEaten: this.foodEaten
}
});
document.dispatchEvent(gameOverEvent);
modal.style.display = 'flex';
};
// 原游戏结束界面已移除,保留统计模块以便响应 'gameOver' 事件
// 初始化统计模块
let gameStats;

View File

@@ -5,18 +5,6 @@ const playerdata = [
"分数":1568,
"时间":"2025-09-08"
},
{
"名称":"柚大青",
"账号":"2143323382@qq.com",
"分数":245,
"时间":"2025-09-21"
},
{
"名称":"牛马",
"账号":"2973419538@qq.com",
"分数":1123,
"时间":"2025-09-25"
},
{
"名称":"风行者",
"账号":"4456723190@qq.com",

View File

@@ -27,12 +27,32 @@
</div>
</div>
<div class="game-instructions">
<p>使用方向键或拖动手势控制蛇的方向</p>
</div>
<!-- 游戏结束排行榜弹层(替换旧的游戏结束界面) -->
<div id="leaderboardOverlay" class="modal">
<div class="modal-content">
<h2>游戏结束排行榜</h2>
<div class="leaderboard-summary" id="leaderboardSummary">
<p>
分数: <span id="lbScore">0</span>
|长度: <span id="lbLength">3</span>
|等级: <span id="lbLevel">1</span>
</p>
<p>
游戏时长: <span id="lbGameTime">0秒</span>
|你的排名: <span id="lbRank"></span>
</p>
</div>
<div class="leaderboard">
<h3>TOP 10</h3>
<div id="leaderboardList" class="leaderboard-list"></div>
</div>
<button id="leaderboardRestartBtn" class="restart-btn">重新开始</button>
</div>
</div>
<script src="gamedata.js"></script>
<script src="game-core.js"></script>

View File

@@ -184,6 +184,20 @@ body {
border: 1px solid rgba(46, 125, 50, 0.2);
}
.leaderboard-summary {
margin: 10px 0 15px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.5);
border-radius: 12px;
color: #1b5e20;
text-align: center;
border: 1px solid rgba(46, 125, 50, 0.2);
}
.leaderboard-summary p {
margin: 6px 0;
}
.leaderboard h3 {
color: #1b5e20;
margin-bottom: 15px;
@@ -234,6 +248,13 @@ body {
text-align: right;
}
.leaderboard-item .player-time {
color: #4a5568;
font-size: 0.8rem;
min-width: 80px;
text-align: right;
}
/* 手机端优化 */
@media (max-width: 768px) {
.game-container {

View File

@@ -0,0 +1,314 @@
// 清新跑酷 - Endless Runner (Mobile Portrait, Touch-friendly)
(() => {
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const restartBtn = document.getElementById('restartBtn');
const overlay = document.getElementById('overlay');
const overlayRestart = document.getElementById('overlayRestart');
const finalScoreEl = document.getElementById('finalScore');
let dpr = Math.max(1, Math.floor(window.devicePixelRatio || 1));
let running = true;
let gameOver = false;
let lastTime = performance.now();
let elapsed = 0; // seconds
let score = 0;
const world = {
width: 360,
height: 640,
groundH: 90, // 地面高度CSS像素
baseSpeed: 240, // 初始速度px/s
speed: 240, // 当前速度(随难度提升)
gravity: 1800, // 重力px/s^2
jumpV: -864, // 跳跃初速度px/s
};
const player = {
x: 72,
y: 0, // 通过 resetPlayer 设置
w: 44,
h: 54,
vy: 0,
grounded: false,
color: '#2f7d5f'
};
const obstacles = [];
const coins = [];
let obstacleTimer = 0; // ms 到下一个障碍
let coinTimer = 0; // ms 到下一个道具
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function rand(min, max) { return Math.random() * (max - min) + min; }
function resizeCanvas() {
dpr = Math.max(1, Math.floor(window.devicePixelRatio || 1));
const cssWidth = Math.min(480, document.documentElement.clientWidth);
const cssHeight = document.documentElement.clientHeight;
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
canvas.width = Math.floor(cssWidth * dpr);
canvas.height = Math.floor(cssHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 使用CSS像素绘制
world.width = cssWidth;
world.height = cssHeight;
world.groundH = Math.max(64, Math.floor(world.height * 0.14));
resetPlayer();
}
function resetPlayer() {
player.y = world.height - world.groundH - player.h;
player.vy = 0;
player.grounded = true;
}
function spawnObstacle() {
const w = rand(28, 56);
const h = rand(40, clamp(world.height * 0.28, 80, 140));
const y = world.height - world.groundH - h;
obstacles.push({ x: world.width + w, y, w, h, color: '#3ea573' });
// 以一定概率在障碍上方生成一个金币
if (Math.random() < 0.6) {
const cx = world.width + w + rand(10, 40);
const cy = y - rand(28, 56);
coins.push({ x: cx, y: cy, r: 10, color: '#f6c453' });
}
}
function spawnCoin() {
const r = 10;
const yTop = world.height * 0.35; // 道具浮在中上区域
const y = rand(yTop, world.height - world.groundH - 80);
coins.push({ x: world.width + 60, y, r, color: '#f6c453' });
}
function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
function circleRectOverlap(cx, cy, r, rx, ry, rw, rh) {
const closestX = clamp(cx, rx, rx + rw);
const closestY = clamp(cy, ry, ry + rh);
const dx = cx - closestX;
const dy = cy - closestY;
return (dx * dx + dy * dy) <= r * r;
}
function jump() {
if (gameOver) return;
if (player.grounded) {
player.vy = world.jumpV;
player.grounded = false;
}
}
function update(dt) {
// 难度递增:速度随时间上涨,生成间隔缩短
elapsed += dt;
world.speed = world.baseSpeed + elapsed * 22; // 每秒加速
obstacleTimer -= dt * 1000;
coinTimer -= dt * 1000;
const minInterval = clamp(1400 - elapsed * 20, 700, 1600); // 障碍间隔(更远)
const coinInterval = clamp(1200 - elapsed * 25, 500, 1200); // 金币间隔
if (obstacleTimer <= 0) {
spawnObstacle();
obstacleTimer = rand(minInterval, minInterval * 1.35);
}
if (coinTimer <= 0) {
spawnCoin();
coinTimer = rand(coinInterval * 0.6, coinInterval);
}
// 玩家物理
player.vy += world.gravity * dt;
player.y += player.vy * dt;
const groundY = world.height - world.groundH - player.h;
if (player.y >= groundY) {
player.y = groundY;
player.vy = 0;
player.grounded = true;
}
// 移动障碍与金币
const dx = world.speed * dt;
for (let i = obstacles.length - 1; i >= 0; i--) {
const ob = obstacles[i];
ob.x -= dx;
if (ob.x + ob.w < 0) obstacles.splice(i, 1);
}
for (let i = coins.length - 1; i >= 0; i--) {
const c = coins[i];
c.x -= dx;
if (c.x + c.r < 0) coins.splice(i, 1);
}
// 碰撞检测:障碍
for (const ob of obstacles) {
if (rectsOverlap(player.x, player.y, player.w, player.h, ob.x, ob.y, ob.w, ob.h)) {
endGame();
return;
}
}
// 拾取金币
for (let i = coins.length - 1; i >= 0; i--) {
const c = coins[i];
if (circleRectOverlap(c.x, c.y, c.r, player.x, player.y, player.w, player.h)) {
score += 100; // 金币加分
coins.splice(i, 1);
}
}
// 距离积分(随速度)
score += Math.floor(world.speed * dt * 0.2);
scoreEl.textContent = String(score);
}
function drawGround() {
const y = world.height - world.groundH;
// 地面阴影渐变
const grad = ctx.createLinearGradient(0, y, 0, world.height);
grad.addColorStop(0, 'rgba(60, 150, 110, 0.35)');
grad.addColorStop(1, 'rgba(60, 150, 110, 0.05)');
ctx.fillStyle = grad;
ctx.fillRect(0, y, world.width, world.groundH);
// 地面纹理线
ctx.strokeStyle = 'rgba(47, 79, 63, 0.25)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(world.width, y);
ctx.stroke();
}
function drawPlayer() {
ctx.fillStyle = player.color;
const r = 8; // 圆角
const x = player.x, y = player.y, w = player.w, h = player.h;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.fill();
// 前进指示条
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fillRect(x + 6, y + 10, 6, 12);
ctx.fillRect(x + 18, y + 10, 6, 12);
}
function drawObstacles() {
for (const ob of obstacles) {
// 渐变柱体
const g = ctx.createLinearGradient(ob.x, ob.y, ob.x, ob.y + ob.h);
g.addColorStop(0, '#52b985');
g.addColorStop(1, '#3ea573');
ctx.fillStyle = g;
ctx.fillRect(ob.x, ob.y, ob.w, ob.h);
// 顶部高亮
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(ob.x, ob.y, ob.w, 4);
}
}
function drawCoins() {
for (const c of coins) {
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fillStyle = c.color;
ctx.fill();
// 外圈高光
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 2;
ctx.stroke();
}
}
function draw(now) {
// 清屏CSS负责背景渐变这里仅清理
ctx.clearRect(0, 0, world.width, world.height);
drawGround();
drawPlayer();
drawObstacles();
drawCoins();
// 速度指示(右上角小提示)
ctx.fillStyle = 'rgba(47,79,63,0.35)';
ctx.font = '12px system-ui';
ctx.textAlign = 'right';
ctx.fillText(`速度 ${Math.round(world.speed)}px/s`, world.width - 8, 18);
}
function endGame() {
running = false;
gameOver = true;
finalScoreEl.textContent = String(score);
overlay.hidden = false;
}
function resetGame() {
running = true;
gameOver = false;
obstacles.length = 0;
coins.length = 0;
obstacleTimer = 0;
coinTimer = rand(400, 900);
score = 0;
elapsed = 0;
resetPlayer();
overlay.hidden = true;
lastTime = performance.now();
}
function loop(now) {
const dt = Math.min(0.033, (now - lastTime) / 1000); // 限制最大步长
lastTime = now;
if (running) {
update(dt);
draw(now);
}
requestAnimationFrame(loop);
}
// 输入事件
function onKey(e) {
if (e.code === 'Space' || e.code === 'ArrowUp') {
e.preventDefault();
jump();
}
}
function onPointer() { jump(); }
restartBtn.addEventListener('click', () => {
resetGame();
});
overlayRestart.addEventListener('click', () => {
resetGame();
});
window.addEventListener('keydown', onKey, { passive: false });
window.addEventListener('mousedown', onPointer);
window.addEventListener('touchstart', onPointer, { passive: true });
window.addEventListener('resize', () => {
resizeCanvas();
});
// 初始化并启动
resizeCanvas();
requestAnimationFrame(loop);
})();

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover" />
<title>清新跑酷 - InfoGenie</title>
<meta name="theme-color" content="#bde8c7" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div class="game-shell">
<header class="hud">
<div class="score">分数 <span id="score">0</span></div>
<button id="restartBtn" class="restart" aria-label="重新开始">重新开始</button>
</header>
<main class="stage">
<canvas id="gameCanvas" aria-label="跑酷游戏画布"></canvas>
<div class="hint">轻触屏幕或按空格跳跃</div>
<div id="overlay" class="overlay" hidden>
<div class="overlay-card">
<div class="gameover-title">游戏结束</div>
<div class="summary">分数 <span id="finalScore">0</span></div>
<button class="overlay-restart" id="overlayRestart">重新开始</button>
</div>
</div>
</main>
</div>
<script src="./game.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,130 @@
/* 清新淡绿色渐变风格与移动端适配 */
:root {
--green-1: #a8e6cf; /* 淡绿色 */
--green-2: #dcedc1; /* 淡黄绿色 */
--accent: #58c48b; /* 按钮主色 */
--accent-dark: #3ca16c;
--text: #2f4f3f; /* 深绿文字 */
--card: #ffffffd9; /* 半透明卡片 */
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "PingFang SC", "Microsoft Yahei", sans-serif;
color: var(--text);
background: linear-gradient(180deg, var(--green-1) 0%, var(--green-2) 100%);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.game-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.hud {
position: sticky;
top: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: color-mix(in oklab, var(--green-2) 65%, white 35%);
backdrop-filter: saturate(1.4) blur(6px);
box-shadow: 0 2px 10px rgb(0 0 0 / 6%);
}
.score {
font-weight: 700;
font-size: 18px;
letter-spacing: 0.5px;
}
.restart {
appearance: none;
border: none;
outline: none;
background: var(--accent);
color: #fff;
font-weight: 600;
font-size: 14px;
padding: 8px 12px;
border-radius: 999px;
box-shadow: 0 4px 12px rgb(88 196 139 / 30%);
transition: transform .05s ease, background .2s ease;
}
.restart:active { transform: scale(0.98); }
.restart:hover { background: var(--accent-dark); }
.stage {
position: relative;
flex: 1;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
.hint {
position: absolute;
bottom: max(10px, env(safe-area-inset-bottom));
left: 0;
right: 0;
text-align: center;
font-size: 12px;
color: color-mix(in oklab, var(--text), white 35%);
opacity: 0.85;
pointer-events: none;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 10%);
}
.overlay[hidden] { display: none; }
.overlay-card {
width: min(88vw, 420px);
background: var(--card);
border-radius: 16px;
box-shadow: 0 10px 30px rgb(0 0 0 / 15%);
padding: 18px 18px 16px;
text-align: center;
}
.gameover-title {
font-size: 20px;
font-weight: 800;
}
.summary {
margin: 12px 0 16px;
font-size: 16px;
}
.overlay-restart {
appearance: none;
border: none;
outline: none;
background: var(--accent);
color: #fff;
font-weight: 700;
font-size: 16px;
padding: 10px 16px;
border-radius: 12px;
box-shadow: 0 6px 16px rgb(88 196 139 / 38%);
}
.overlay-restart:active { transform: scale(0.98); }
.overlay-restart:hover { background: var(--accent-dark); }
@media (prefers-reduced-motion: reduce) {
.restart, .overlay-restart { transition: none; }
}

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
<title>清新躲避 · 无尽模式</title>
<meta name="theme-color" content="#d8f7c2" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="hud">
<div class="score">分数 <span id="score">0</span></div>
<button id="pauseBtn" class="btn" aria-label="暂停"></button>
</div>
<canvas id="game" aria-label="清新躲避游戏画布"></canvas>
<div id="startOverlay" class="overlay show" role="dialog" aria-modal="true">
<div class="card">
<h1>清新躲避</h1>
<p>按住并左右拖动,躲避下落的叶片。</p>
<p>无尽模式,难度会随时间提升。</p>
<button id="startBtn" class="btn primary">开始游戏</button>
</div>
</div>
<div id="gameOverOverlay" class="overlay" role="dialog" aria-modal="true">
<div class="card">
<h2>游戏结束</h2>
<p>本局分数:<strong id="finalScore">0</strong></p>
<button id="restartBtn" class="btn primary">再来一次</button>
</div>
</div>
<div id="rotateOverlay">请将手机竖屏以获得最佳体验</div>
<script src="./script.js"></script>
</body>
</html>

View File

@@ -0,0 +1,261 @@
(() => {
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const hudScoreEl = document.getElementById('score');
const pauseBtn = document.getElementById('pauseBtn');
const startOverlay = document.getElementById('startOverlay');
const startBtn = document.getElementById('startBtn');
const gameOverOverlay = document.getElementById('gameOverOverlay');
const finalScoreEl = document.getElementById('finalScore');
const restartBtn = document.getElementById('restartBtn');
const rotateOverlay = document.getElementById('rotateOverlay');
let width = 0, height = 0, DPR = 1;
let running = false, paused = false;
let lastTime = 0, timeElapsed = 0, score = 0, spawnTimer = 0;
const player = { x: 0, y: 0, r: 18, vx: 0, targetX: null };
const obstacles = [];
let pointerActive = false;
function clamp(v, min, max){ return Math.max(min, Math.min(max, v)); }
function rand(min, max){ return Math.random()*(max-min)+min; }
function updateOrientationOverlay(){
const landscape = window.innerWidth > window.innerHeight;
rotateOverlay.style.display = landscape ? 'flex' : 'none';
}
function resize(){
updateOrientationOverlay();
DPR = Math.min(2, window.devicePixelRatio || 1);
width = window.innerWidth;
height = window.innerHeight;
canvas.width = Math.floor(width * DPR);
canvas.height = Math.floor(height * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
if (!running){
player.x = width * 0.5;
player.y = height - Math.max(80, height * 0.12);
} else {
player.y = height - Math.max(80, height * 0.12);
player.x = clamp(player.x, player.r + 8, width - player.r - 8);
}
}
window.addEventListener('resize', resize);
resize();
function drawBackground(){
// 轻微的顶部高光,让画面更通透
const g = ctx.createLinearGradient(0,0,0,height);
g.addColorStop(0,'rgba(255,255,255,0.10)');
g.addColorStop(1,'rgba(255,255,255,0.00)');
ctx.fillStyle = g;
ctx.fillRect(0,0,width,height);
}
function drawPlayer(){
ctx.save();
ctx.translate(player.x, player.y);
const r = player.r;
const grad = ctx.createRadialGradient(-r*0.3, -r*0.3, r*0.2, 0, 0, r);
grad.addColorStop(0, '#5fca7e');
grad.addColorStop(1, '#3a9e5a');
ctx.fillStyle = grad;
// 圆形带小叶柄的简化“叶子”角色
ctx.beginPath();
ctx.arc(0,0,r,0,Math.PI*2);
ctx.fill();
ctx.strokeStyle = 'rgba(58,158,90,0.7)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -r*0.9);
ctx.quadraticCurveTo(r*0.2, -r*1.3, r*0.5, -r*1.0);
ctx.stroke();
ctx.restore();
}
function spawnObstacle(){
const difficulty = 1 + timeElapsed * 0.08; // 随时间慢慢提升
const r = rand(10, 22);
const x = rand(r+8, width - r - 8);
const speed = rand(90, 140) * (0.9 + difficulty * 0.5);
const rot = rand(-Math.PI*0.5, Math.PI*0.5);
obstacles.push({ x, y: -r - 20, r, speed, rot, swayPhase: Math.random()*Math.PI*2, swayAmp: rand(6,12) });
}
function drawObstacle(o){
ctx.save();
ctx.translate(o.x, o.y);
ctx.rotate(o.rot);
const r = o.r;
ctx.beginPath();
ctx.ellipse(0, 0, r*0.9, r*0.6, 0, 0, Math.PI*2);
const grad = ctx.createLinearGradient(-r, -r, r, r);
grad.addColorStop(0, '#d8f7c2');
grad.addColorStop(0.5, '#b9ef9f');
grad.addColorStop(1, '#9edf77');
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = 'rgba(90,150,90,0.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-r*0.5, 0);
ctx.quadraticCurveTo(0, -r*0.3, r*0.5, 0);
ctx.stroke();
ctx.restore();
}
function update(dt){
const difficulty = 1 + timeElapsed * 0.08;
const spawnInterval = Math.max(0.12, 0.9 / difficulty);
spawnTimer -= dt;
if (spawnTimer <= 0){
spawnObstacle();
spawnTimer = spawnInterval;
}
// 障碍移动 + 轻微左右摆动
for (let i = 0; i < obstacles.length; i++){
const o = obstacles[i];
o.y += o.speed * dt;
o.x += Math.sin(o.swayPhase + timeElapsed * 1.6) * (o.swayAmp * dt);
}
// 清除离开屏幕的障碍
for (let i = obstacles.length - 1; i >= 0; i--){
const o = obstacles[i];
if (o.y > height + o.r + 60){
obstacles.splice(i, 1);
}
}
// 键盘轻推(桌面端备用)
if (player.targetX != null){
const dir = player.targetX - player.x;
player.vx = clamp(dir, -500, 500);
player.x += player.vx * dt;
if (Math.abs(dir) < 2){
player.targetX = null;
player.vx = 0;
}
}
// 限制玩家范围
player.x = clamp(player.x, player.r + 8, width - player.r - 8);
// 碰撞检测(近似圆形)
for (const o of obstacles){
const dx = o.x - player.x;
const dy = o.y - player.y;
const dist = Math.hypot(dx, dy);
if (dist < player.r + o.r * 0.65){
endGame();
return;
}
}
// 计分:按生存时间累计
score += dt * 10; // 每秒约10分
hudScoreEl.textContent = Math.floor(score);
}
function render(){
ctx.clearRect(0,0,width,height);
drawBackground();
for (const o of obstacles) drawObstacle(o);
drawPlayer();
}
function loop(t){
if (!running){ return; }
if (paused){
lastTime = t;
requestAnimationFrame(loop);
return;
}
if (!lastTime) lastTime = t;
const dt = Math.min(0.033, (t - lastTime)/1000);
lastTime = t;
timeElapsed += dt;
update(dt);
render();
requestAnimationFrame(loop);
}
function startGame(){
obstacles.length = 0;
score = 0;
timeElapsed = 0;
spawnTimer = 0;
running = true;
paused = false;
lastTime = 0;
startOverlay.classList.remove('show');
gameOverOverlay.classList.remove('show');
resize();
requestAnimationFrame(loop);
}
function endGame(){
running = false;
paused = false;
finalScoreEl.textContent = Math.floor(score);
gameOverOverlay.classList.add('show');
}
function pointerToCanvasX(e){
const rect = canvas.getBoundingClientRect();
return clamp(e.clientX - rect.left, 0, rect.width);
}
// 触控与指针事件:按住并左右拖动
canvas.addEventListener('pointerdown', e => {
pointerActive = true;
player.targetX = null;
player.x = pointerToCanvasX(e);
});
canvas.addEventListener('pointermove', e => {
if (!pointerActive) return;
player.x = pointerToCanvasX(e);
});
canvas.addEventListener('pointerup', () => { pointerActive = false; });
canvas.addEventListener('pointercancel', () => { pointerActive = false; });
// 轻点屏幕:向左/右轻推一段距离
canvas.addEventListener('click', e => {
const x = pointerToCanvasX(e);
const center = width / 2;
const dir = x < center ? -1 : 1;
player.targetX = clamp(player.x + dir * Math.max(50, width * 0.12), player.r + 8, width - player.r - 8);
});
// 键盘备用控制(桌面端)
window.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft' || e.key === 'a'){
player.targetX = clamp(player.x - Math.max(50, width * 0.12), player.r + 8, width - player.r - 8);
} else if (e.key === 'ArrowRight' || e.key === 'd'){
player.targetX = clamp(player.x + Math.max(50, width * 0.12), player.r + 8, width - player.r - 8);
} else if (e.key === ' ') {
togglePause();
} else if (e.key === 'Enter' && !running){
startGame();
}
});
// 按钮
pauseBtn.addEventListener('click', togglePause);
startBtn.addEventListener('click', startGame);
restartBtn.addEventListener('click', startGame);
function togglePause(){
if (!running) return;
paused = !paused;
pauseBtn.textContent = paused ? '▶' : 'Ⅱ';
}
// 避免滚动与系统手势干扰
['touchstart','touchmove','touchend'].forEach(type => {
window.addEventListener(type, e => { if (pointerActive) e.preventDefault(); }, { passive: false });
});
})();

View File

@@ -0,0 +1,106 @@
/* 主题色:淡绿色到淡黄绿色的清新渐变 */
:root {
--bg-start: #dff9d3;
--bg-mid: #eaffd1;
--bg-end: #e9fbb5;
--accent: #4fb66d;
--accent-dark: #3a9e5a;
--text: #2f4f3f;
--hud-bg: rgba(255, 255, 255, 0.65);
--overlay-bg: rgba(255, 255, 255, 0.55);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, PingFang SC, Microsoft YaHei, sans-serif;
color: var(--text);
background: linear-gradient(180deg, var(--bg-start), var(--bg-mid), var(--bg-end));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
touch-action: none;
}
/* 顶部HUD */
#hud {
position: fixed;
top: env(safe-area-inset-top, 12px);
left: env(safe-area-inset-left, 12px);
right: env(safe-area-inset-right, 12px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
margin: 8px;
border-radius: 14px;
background: var(--hud-bg);
backdrop-filter: blur(8px);
box-shadow: 0 6px 16px rgba(0,0,0,0.08);
}
#hud .score { font-weight: 600; letter-spacing: 0.5px; }
/* 画布填充屏幕,适配竖屏 */
#game {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
display: block;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
/* 通用按钮样式 */
.btn {
appearance: none;
border: none;
outline: none;
padding: 8px 12px;
border-radius: 12px;
color: #fff;
background: linear-gradient(180deg, var(--accent), var(--accent-dark));
box-shadow: 0 6px 14px rgba(79,182,109,0.35);
cursor: pointer;
}
.btn:active { transform: translateY(1px); }
.btn.primary { font-weight: 600; }
/* 覆盖层样式 */
.overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
}
.overlay.show { display: flex; }
.card {
width: min(520px, 92vw);
padding: 20px 18px;
border-radius: 16px;
background: var(--overlay-bg);
backdrop-filter: blur(8px);
box-shadow: 0 10px 22px rgba(0,0,0,0.12);
text-align: center;
}
.card h1, .card h2 { margin: 8px 0 12px; }
.card p { margin: 6px 0 12px; }
/* 横屏提示覆盖层 */
#rotateOverlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
font-weight: 600;
color: var(--accent-dark);
background: rgba(255,255,255,0.6);
backdrop-filter: blur(6px);
}