不知名提交
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 重试按钮
|
||||
document.getElementById('retry-btn').addEventListener('click', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
309
InfoGenie-frontend/public/smallgame/打飞机/game.js
Normal file
309
InfoGenie-frontend/public/smallgame/打飞机/game.js
Normal 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();
|
||||
44
InfoGenie-frontend/public/smallgame/打飞机/index.html
Normal file
44
InfoGenie-frontend/public/smallgame/打飞机/index.html
Normal 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>
|
||||
65
InfoGenie-frontend/public/smallgame/打飞机/style.css
Normal file
65
InfoGenie-frontend/public/smallgame/打飞机/style.css
Normal 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); }
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
314
InfoGenie-frontend/public/smallgame/跑酷/game.js
Normal file
314
InfoGenie-frontend/public/smallgame/跑酷/game.js
Normal 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);
|
||||
})();
|
||||
33
InfoGenie-frontend/public/smallgame/跑酷/index.html
Normal file
33
InfoGenie-frontend/public/smallgame/跑酷/index.html
Normal 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>
|
||||
130
InfoGenie-frontend/public/smallgame/跑酷/style.css
Normal file
130
InfoGenie-frontend/public/smallgame/跑酷/style.css
Normal 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; }
|
||||
}
|
||||
39
InfoGenie-frontend/public/smallgame/躲树叶/index.html
Normal file
39
InfoGenie-frontend/public/smallgame/躲树叶/index.html
Normal 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>
|
||||
261
InfoGenie-frontend/public/smallgame/躲树叶/script.js
Normal file
261
InfoGenie-frontend/public/smallgame/躲树叶/script.js
Normal 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 });
|
||||
});
|
||||
})();
|
||||
106
InfoGenie-frontend/public/smallgame/躲树叶/style.css
Normal file
106
InfoGenie-frontend/public/smallgame/躲树叶/style.css
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user