From 37891b14858f5d94a548cc4f0ab821315a7dba86 Mon Sep 17 00:00:00 2001 From: Fstarnb <78671757+Fstarnb@users.noreply.github.com> Date: Sat, 29 Nov 2025 12:32:06 +0800 Subject: [PATCH 01/10] Add web tank battle mini game --- _posts/2024-05-01-tank-battle.md | 10 + tank-battle.html | 638 +++++++++++++++++++++++++++++++ 2 files changed, 648 insertions(+) create mode 100644 _posts/2024-05-01-tank-battle.md create mode 100644 tank-battle.html diff --git a/_posts/2024-05-01-tank-battle.md b/_posts/2024-05-01-tank-battle.md new file mode 100644 index 0000000..46671fc --- /dev/null +++ b/_posts/2024-05-01-tank-battle.md @@ -0,0 +1,10 @@ +--- +layout: slide +title: "坦克大战小游戏" +--- + +点击下方链接即可开启纯前端的网页坦克大战。 + +- 👉 [立即开战](/tank-battle.html) +- 键盘:WASD / 方向键移动与转向,空格射击,Shift 冲刺,P 暂停,R 重置。 +- 特色:波次进阶敌人、拾取补给、连击加成、移动端虚拟摇杆支持。 diff --git a/tank-battle.html b/tank-battle.html new file mode 100644 index 0000000..20933ad --- /dev/null +++ b/tank-battle.html @@ -0,0 +1,638 @@ + + + + + + 网页坦克大战 + + + +
+

网页坦克大战

+
真·纯前端,无需安装,随时开战!
+
+
+
WASD / ↑↓←→ 移动与转向
+
空格射击 · Shift 冲刺 · P 暂停
+
每波敌人更强,拾取能量包保命
+
+
+
+
+
生命 +
+
+
能量 +
+
+
分数 0
+
波次 1
+
+ +
+
+

玩法说明

+ +
+ + +
+

提示:在移动端浏览,可点击屏幕左下角方向盘与右下角射击区。

+
+ +
+ + +
+
+

开发者小记:简化的物理与 AI,适合快速上手;想挑战高难度,就让敌人堆满屏幕吧。

+
+
+ + + + From e0ab14726a8fc70d429c4c8ca4f0d83163caaa44 Mon Sep 17 00:00:00 2001 From: Fstarnb <78671757+Fstarnb@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:14:50 +0800 Subject: [PATCH 02/10] Add instructions for downloading files --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 9938239..39048da 100755 --- a/README.md +++ b/README.md @@ -11,3 +11,23 @@ I'm the GitHub Learning Lab bot and I'm here to help guide you in your journey t I'll meet you over there, can't wait to get started! This course is using the :sparkles: open source project [reveal.js](https://github.com/hakimel/reveal.js/). In some cases we’ve made changes to the history so it would behave during class, so head to the original project repo to learn more about the cool people behind this project. + +## 如何将生成的文件下载到本地 + +你可以通过以下几种方式把仓库里生成的文件(例如 `tank-battle.html` 或 `_posts/2024-05-01-tank-battle.md`)下载到本地: + +1. **使用 Git 克隆仓库**(适合需要持续更新的场景) + ```bash + git clone https://github.com/你的用户名/github-slideshow.git + ``` + 克隆完成后,文件会出现在新目录 `github-slideshow/` 中,后续可用 `git pull` 获取最新更新。 + +2. **在 GitHub 网页直接下载单个文件** + - 打开目标文件页面,点击右上角的 **Raw** 按钮。 + - 在打开的纯文本页面上,右键选择“另存为”,即可保存到本地。 + +3. **下载仓库 ZIP 压缩包**(一次性获取全部内容) + - 在仓库主页点击绿色的 **Code** 按钮,选择 **Download ZIP**。 + - 解压后即可在本地查看和使用所有文件。 + +选择最适合你工作流的方式即可把生成的页面和文章带到本地进行预览或二次开发。 From db0a9502e31981df43eae415d9f6e4366976ef42 Mon Sep 17 00:00:00 2001 From: Fstarnb <78671757+Fstarnb@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:14:58 +0800 Subject: [PATCH 03/10] Add destructible obstacles and difficulty settings --- tank-battle.html | 204 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 186 insertions(+), 18 deletions(-) diff --git a/tank-battle.html b/tank-battle.html index 20933ad..6600c73 100644 --- a/tank-battle.html +++ b/tank-battle.html @@ -145,7 +145,7 @@ flex-wrap: wrap; margin-top: 8px; } - .btn { + .btn { border: none; padding: 10px 14px; border-radius: 10px; @@ -157,6 +157,9 @@ transition: transform 0.1s ease, box-shadow 0.1s ease; } .btn:active { transform: translateY(1px); box-shadow: 0 4px 10px rgba(0,0,0,0.35); } + .field { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 4px; } + label { color: var(--muted); font-size: 13px; } + select { padding: 10px; border-radius: 10px; background: rgba(255,255,255,0.04); color: var(--text); border: 1px solid rgba(148,163,184,0.25); } .muted { color: var(--muted); font-size: 13px; } .badge { color: #fbbf24; font-weight: 700; } .mobile-hints { display: none; } @@ -191,6 +194,8 @@

网页坦克大战

分数 0
波次 1
+
关卡 第一关 · 开阔练习场
+
难度 标准
@@ -206,6 +211,15 @@

玩法说明

+
+ + +
+

当前关卡:第一关以木墙为主,可练习绕障碍射击;每 3 波自动切换下一关布局。

提示:在移动端浏览,可点击屏幕左下角方向盘与右下角射击区。

@@ -225,6 +239,10 @@

玩法说明

const energyBar = document.getElementById('energy-bar'); const scoreEl = document.getElementById('score'); const waveEl = document.getElementById('wave'); + const levelNameEl = document.getElementById('level-name'); + const levelBriefEl = document.getElementById('level-brief'); + const difficultyLabel = document.getElementById('difficulty-label'); + const difficultySelect = document.getElementById('difficulty'); const restartBtn = document.getElementById('restart'); const pauseBtn = document.getElementById('toggle-pause'); const mobileShoot = document.getElementById('mobile-shoot'); @@ -236,11 +254,19 @@

玩法说明

height: canvas.height, score: 0, wave: 1, + levelIndex: 0, combo: 0, lastTime: 0, + difficulty: 'normal', inputs: { up:false, down:false, left:false, right:false, shoot:false, dash:false }, }; + const difficulties = { + easy: { label: '轻松', enemyHp: 0.85, enemySpeed: 0.9, spawnScale: 0.9, score: 0.9 }, + normal: { label: '标准', enemyHp: 1, enemySpeed: 1, spawnScale: 1, score: 1 }, + hard: { label: '噩梦', enemyHp: 1.35, enemySpeed: 1.15, spawnScale: 1.25, score: 1.25 }, + }; + const playerDefaults = { x: canvas.width / 2, y: canvas.height / 2, @@ -258,36 +284,122 @@

玩法说明

const enemies = []; const particles = []; const pickups = []; + let obstacles = []; + + const levelLayouts = [ + { + name: '第一关 · 开阔练习场', + brief: '中央木墙与边缘石墙组合,适合练习绕障碍与迂回射击。', + layout: [ + { type: 'stone', px: 0.08, py: 0.15, pw: 0.84, ph: 0.04 }, + { type: 'stone', px: 0.08, py: 0.81, pw: 0.84, ph: 0.04 }, + { type: 'wood', px: 0.3, py: 0.38, pw: 0.4, ph: 0.03, hp: 70 }, + { type: 'wood', px: 0.3, py: 0.55, pw: 0.4, ph: 0.03, hp: 70 }, + { type: 'stone', px: 0.48, py: 0.28, pw: 0.04, ph: 0.46 }, + ], + }, + { + name: '第二关 · 走廊与拐角', + brief: '多条石墙走廊限制走位,穿插木墙可用火力开道。', + layout: [ + { type: 'stone', px: 0.14, py: 0.2, pw: 0.05, ph: 0.6 }, + { type: 'stone', px: 0.81, py: 0.2, pw: 0.05, ph: 0.6 }, + { type: 'stone', px: 0.25, py: 0.45, pw: 0.5, ph: 0.05 }, + { type: 'wood', px: 0.28, py: 0.3, pw: 0.12, ph: 0.05, hp: 60 }, + { type: 'wood', px: 0.6, py: 0.62, pw: 0.12, ph: 0.05, hp: 60 }, + ], + }, + { + name: '第三关 · 中央堡垒', + brief: '中心石墙堡垒需绕行,外围木墙可被炮弹摧毁出入口。', + layout: [ + { type: 'stone', px: 0.36, py: 0.36, pw: 0.28, ph: 0.04 }, + { type: 'stone', px: 0.36, py: 0.6, pw: 0.28, ph: 0.04 }, + { type: 'stone', px: 0.36, py: 0.4, pw: 0.04, ph: 0.2 }, + { type: 'stone', px: 0.6, py: 0.4, pw: 0.04, ph: 0.2 }, + { type: 'wood', px: 0.2, py: 0.25, pw: 0.12, ph: 0.05, hp: 75 }, + { type: 'wood', px: 0.68, py: 0.72, pw: 0.12, ph: 0.05, hp: 75 }, + ], + }, + ]; function randRange(min, max) { return Math.random() * (max - min) + min; } + function obstacleFromLayout(item) { + return { + type: item.type, + x: item.px * state.width, + y: item.py * state.height, + w: item.pw * state.width, + h: item.ph * state.height, + hp: item.type === 'wood' ? (item.hp || 60) : Infinity, + }; + } + + function buildLevel(index = 0) { + const def = levelLayouts[index % levelLayouts.length]; + obstacles = def.layout.map(obstacleFromLayout); + state.levelIndex = index % levelLayouts.length; + levelNameEl.textContent = def.name; + levelBriefEl.textContent = def.brief; + } + + function circleRectCollision(cx, cy, r, rect) { + const closestX = Math.max(rect.x, Math.min(cx, rect.x + rect.w)); + const closestY = Math.max(rect.y, Math.min(cy, rect.y + rect.h)); + const dx = cx - closestX; + const dy = cy - closestY; + return (dx * dx + dy * dy) < (r * r); + } + + function rectsCollide(ax, ay, aw, ah, b) { + return ax < b.x + b.w && ax + aw > b.x && ay < b.y + b.h && ay + ah > b.y; + } + function spawnEnemy() { + const diff = difficulties[state.difficulty]; const edge = Math.floor(Math.random() * 4); const margin = 40; - const speed = randRange(60, 110) + state.wave * 4; - const hp = 30 + state.wave * 5; + const speed = (randRange(60, 110) + state.wave * 4) * diff.enemySpeed; + const hp = (30 + state.wave * 5) * diff.enemyHp; let x, y; - if (edge === 0) { x = randRange(margin, state.width - margin); y = -margin; } - else if (edge === 1) { x = state.width + margin; y = randRange(margin, state.height - margin); } - else if (edge === 2) { x = randRange(margin, state.width - margin); y = state.height + margin; } - else { x = -margin; y = randRange(margin, state.height - margin); } - enemies.push({ x, y, angle: 0, size: 22, speed, hp, reload: randRange(0.8, 1.4), fireCooldown: 0 }); + const size = 22; + for (let attempt = 0; attempt < 10; attempt++) { + if (edge === 0) { x = randRange(margin, state.width - margin); y = -margin; } + else if (edge === 1) { x = state.width + margin; y = randRange(margin, state.height - margin); } + else if (edge === 2) { x = randRange(margin, state.width - margin); y = state.height + margin; } + else { x = -margin; y = randRange(margin, state.height - margin); } + const candidate = { x: x - size, y: y - size, w: size * 2, h: size * 2 }; + const blocked = obstacles.some(ob => rectsCollide(candidate.x, candidate.y, candidate.w, candidate.h, ob)); + if (!blocked) break; + } + enemies.push({ x, y, angle: 0, size, speed, hp, reload: randRange(0.8, 1.4), fireCooldown: 0 }); } function spawnPickup() { - pickups.push({ - x: randRange(80, state.width - 80), - y: randRange(80, state.height - 80), - size: 14, - ttl: 10, - }); + let placed = false; + for (let attempt = 0; attempt < 12; attempt++) { + const candidate = { + x: randRange(80, state.width - 80), + y: randRange(80, state.height - 80), + size: 14, + ttl: 10, + }; + const blocked = obstacles.some(ob => circleRectCollision(candidate.x, candidate.y, candidate.size + 4, ob)); + if (!blocked) { pickups.push(candidate); placed = true; break; } + } + if (!placed) { + pickups.push({ x: state.width / 2, y: state.height / 2, size: 14, ttl: 10 }); + } } function resetGame() { Object.assign(player, playerDefaults, { x: canvas.width/2, y: canvas.height/2 }); enemies.length = 0; bullets.length = 0; particles.length = 0; pickups.length = 0; state.score = 0; state.wave = 1; state.combo = 0; state.running = true; state.lastTime = 0; + buildLevel(0); for (let i = 0; i < 5; i++) spawnEnemy(); + updateDifficultyUi(); } function addParticle(x, y, color) { @@ -326,8 +438,15 @@

玩法说明

for (let i = 0; i < 10; i++) addParticle(player.x, player.y, '#22d3ee'); } + const prevX = player.x; + const prevY = player.y; player.x = Math.max(0, Math.min(state.width, player.x + vx * delta)); player.y = Math.max(0, Math.min(state.height, player.y + vy * delta)); + const hitObstacle = obstacles.some(ob => circleRectCollision(player.x, player.y, player.size, ob)); + if (hitObstacle) { + player.x = prevX; + player.y = prevY; + } player.fireCooldown -= delta; player.dashCooldown -= delta; @@ -345,6 +464,17 @@

玩法说明

if (b.life <= 0) { bullets.splice(i, 1); continue; } b.x += Math.cos(b.angle) * b.speed * delta; b.y += Math.sin(b.angle) * b.speed * delta; + const collideIndex = obstacles.findIndex(ob => circleRectCollision(b.x, b.y, 4, ob)); + if (collideIndex !== -1) { + const ob = obstacles[collideIndex]; + if (ob.type === 'wood') { + ob.hp -= b.friendly ? 28 : 18; + addParticle(b.x, b.y, '#fbbf24'); + if (ob.hp <= 0) { obstacles.splice(collideIndex, 1); } + } + bullets.splice(i, 1); + continue; + } if (b.x < -20 || b.x > state.width + 20 || b.y < -20 || b.y > state.height + 20) { bullets.splice(i, 1); continue; @@ -364,8 +494,13 @@

玩法说明

for (let i = enemies.length - 1; i >= 0; i--) { const e = enemies[i]; e.angle = Math.atan2(player.y - e.y, player.x - e.x); - e.x += Math.cos(e.angle) * e.speed * delta; - e.y += Math.sin(e.angle) * e.speed * delta; + const ex = e.x + Math.cos(e.angle) * e.speed * delta; + const ey = e.y + Math.sin(e.angle) * e.speed * delta; + const willHit = obstacles.some(ob => circleRectCollision(ex, ey, e.size, ob)); + if (!willHit) { + e.x = ex; + e.y = ey; + } e.fireCooldown -= delta; if (e.fireCooldown <= 0) { @@ -387,7 +522,8 @@

玩法说明

bullets.splice(j, 1); e.hp -= 20; state.combo += 1; - state.score += Math.round(10 * (1 + state.combo * 0.05)); + const diff = difficulties[state.difficulty]; + state.score += Math.round(10 * (1 + state.combo * 0.05) * diff.score); addParticle(e.x, e.y, '#f472b6'); if (e.hp <= 0) { for (let k = 0; k < 12; k++) addParticle(e.x, e.y, '#22c55e'); @@ -422,7 +558,10 @@

玩法说明

if (enemies.length === 0) { state.wave += 1; - for (let i = 0; i < 3 + state.wave; i++) spawnEnemy(); + const nextLevel = Math.floor((state.wave - 1) / 3) % levelLayouts.length; + if (nextLevel !== state.levelIndex) buildLevel(nextLevel); + const diff = difficulties[state.difficulty]; + for (let i = 0; i < Math.round((3 + state.wave) * diff.spawnScale); i++) spawnEnemy(); if (Math.random() > 0.4) spawnPickup(); } @@ -472,6 +611,23 @@

玩法说明

}); } + function drawObstacles() { + obstacles.forEach(ob => { + ctx.save(); + ctx.translate(ob.x, ob.y); + ctx.fillStyle = ob.type === 'stone' ? '#475569' : '#b45309'; + ctx.globalAlpha = ob.type === 'wood' ? Math.max(0.4, ob.hp / 100) : 0.95; + ctx.fillRect(0, 0, ob.w, ob.h); + if (ob.type === 'stone') { + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; + ctx.lineWidth = 2; + ctx.strokeRect(4, 4, ob.w - 8, ob.h - 8); + } + ctx.restore(); + }); + ctx.globalAlpha = 1; + } + function drawParticles() { particles.forEach(p => { ctx.fillStyle = p.color; @@ -507,6 +663,7 @@

玩法说明

function draw() { ctx.clearRect(0,0,state.width,state.height); drawBackground(); + drawObstacles(); drawPickups(); drawBullets(); drawParticles(); @@ -527,6 +684,12 @@

玩法说明

energyBar.style.width = `${Math.max(0, player.energy)}%`; scoreEl.textContent = state.score; waveEl.textContent = state.wave; + difficultyLabel.textContent = difficulties[state.difficulty].label; + } + + function updateDifficultyUi() { + difficultySelect.value = state.difficulty; + difficultyLabel.textContent = difficulties[state.difficulty].label; } function showGameOver() { @@ -566,6 +729,10 @@

玩法说明

window.addEventListener('keyup', (e) => { handleKey(e, false); }); restartBtn.addEventListener('click', resetGame); pauseBtn.addEventListener('click', togglePause); + difficultySelect.addEventListener('change', (e) => { + state.difficulty = e.target.value; + resetGame(); + }); mobileShoot?.addEventListener('click', () => state.inputs.shoot = true); mobileDash?.addEventListener('click', () => state.inputs.dash = true); @@ -625,6 +792,7 @@

玩法说明

state.width = canvas.width; state.height = canvas.height; ctx.scale(ratio, ratio); + buildLevel(state.levelIndex); } window.addEventListener('resize', resize); From 3d2945d95ce51da00d3f0d4f9f7eab60ad1ecfb8 Mon Sep 17 00:00:00 2001 From: Fstarnb <78671757+Fstarnb@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:24:21 +0800 Subject: [PATCH 04/10] Add safe spawns and two-player co-op --- tank-battle.html | 280 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 192 insertions(+), 88 deletions(-) diff --git a/tank-battle.html b/tank-battle.html index 6600c73..4e358d5 100644 --- a/tank-battle.html +++ b/tank-battle.html @@ -93,22 +93,26 @@ top: 12px; left: 12px; right: 12px; - display: flex; - align-items: center; - justify-content: space-between; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 8px; pointer-events: none; font-weight: 600; + align-items: start; } .stat { padding: 8px 12px; - background: rgba(17, 24, 39, 0.8); + background: rgba(17, 24, 39, 0.86); border-radius: 10px; border: 1px solid rgba(148, 163, 184, 0.2); box-shadow: 0 8px 18px rgba(0,0,0,0.35); display: inline-flex; align-items: center; gap: 8px; + min-height: 44px; } + .stat-col { display: grid; gap: 6px; } + .stat strong { color: #fbbf24; font-size: 13px; letter-spacing: 0.5px; } .bar { flex: 1; height: 10px; @@ -179,18 +183,28 @@

网页坦克大战

真·纯前端,无需安装,随时开战!
-
WASD / ↑↓←→ 移动与转向
-
空格射击 · Shift 冲刺 · P 暂停
+
玩家一:WASD 移动/转向 · 空格射击 · Shift 冲刺
+
玩家二:方向键移动/转向 · . 射击 · / 冲刺
每波敌人更强,拾取能量包保命
-
生命 -
+
+
玩家一 生命 +
+
+
能量 +
+
-
能量 -
+
+
玩家二 生命 +
+
+
能量 +
+
分数 0
波次 1
@@ -235,8 +249,10 @@

玩法说明

From 09037e5c8345091b03899ee80f4db5ae6459b253 Mon Sep 17 00:00:00 2001 From: Fstarnb <78671757+Fstarnb@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:34:50 +0800 Subject: [PATCH 06/10] Improve safe spawn logic for players --- tank-battle.html | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/tank-battle.html b/tank-battle.html index b225de2..f29a669 100644 --- a/tank-battle.html +++ b/tank-battle.html @@ -404,14 +404,35 @@

玩法说明

} function findSafeSpawn(preferred, radius = 28, self) { + // Try expanding around the preferred point first for (let attempt = 0; attempt < 24; attempt++) { const ang = Math.random() * Math.PI * 2; - const dist = 20 + attempt * 10; + const dist = 20 + attempt * 12; const x = Math.min(Math.max(radius, preferred.x + Math.cos(ang) * dist), state.width - radius); const y = Math.min(Math.max(radius, preferred.y + Math.sin(ang) * dist), state.height - radius); if (isFreeSpot(x, y, radius, self)) return { x, y }; } - return { x: Math.min(Math.max(radius, preferred.x), state.width - radius), y: Math.min(Math.max(radius, preferred.y), state.height - radius) }; + + // If nearby spots are blocked, sample random safe points inside the arena + for (let attempt = 0; attempt < 40; attempt++) { + const x = randRange(radius, state.width - radius); + const y = randRange(radius, state.height - radius); + if (isFreeSpot(x, y, radius, self)) return { x, y }; + } + + // Exhaustive fallback sweep to guarantee a valid position + const step = Math.max(radius * 2, 48); + for (let y = radius; y <= state.height - radius; y += step) { + for (let x = radius; x <= state.width - radius; x += step) { + if (isFreeSpot(x, y, radius, self)) return { x, y }; + } + } + + // Final clamp to keep the player on screen if everything else failed + return { + x: Math.min(Math.max(radius, preferred.x), state.width - radius), + y: Math.min(Math.max(radius, preferred.y), state.height - radius), + }; } function buildLevel(index = 0) { @@ -489,9 +510,13 @@

玩法说明

{ x: state.width * 0.72, y: state.height * 0.65 }, ]; players.forEach((p, idx) => { - const safe = findSafeSpawn(spawnPoints[idx] || { x: state.width / 2, y: state.height / 2 }, p.size, p); + const preferred = spawnPoints[idx] || { x: state.width / 2, y: state.height / 2 }; + const safe = findSafeSpawn(preferred, p.size, p); Object.assign(p, playerDefaults, safe, { angle: -Math.PI / 2, alive: true }); }); + + // Double-check after assignment to prevent accidental wall spawns + ensurePlayersSafe(); } function resetGame() { From 6a6d3b5414979139c18d58489061edca0fa0e28c Mon Sep 17 00:00:00 2001 From: Fstarnb <78671757+Fstarnb@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:41:36 +0800 Subject: [PATCH 07/10] Add arena boundaries for spawns and movement --- tank-battle.html | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/tank-battle.html b/tank-battle.html index f29a669..0b24242 100644 --- a/tank-battle.html +++ b/tank-battle.html @@ -298,6 +298,8 @@

玩法说明

const startSolo = document.getElementById('start-solo'); const startCoop = document.getElementById('start-coop'); + const arenaPadding = 28; + const state = { running: false, started: false, @@ -397,7 +399,8 @@

玩法说明

} function isFreeSpot(x, y, radius, self) { - if (x < radius || x > state.width - radius || y < radius || y > state.height - radius) return false; + const pad = arenaPadding + radius; + if (x < pad || x > state.width - pad || y < pad || y > state.height - pad) return false; const hitsObstacle = obstacles.some(ob => circleRectCollision(x, y, radius, ob)); const hitsPlayer = players.some(p => p !== self && p.alive && Math.hypot(p.x - x, p.y - y) < radius * 2); return !hitsObstacle && !hitsPlayer; @@ -429,9 +432,10 @@

玩法说明

} // Final clamp to keep the player on screen if everything else failed + const pad = arenaPadding + radius; return { - x: Math.min(Math.max(radius, preferred.x), state.width - radius), - y: Math.min(Math.max(radius, preferred.y), state.height - radius), + x: Math.min(Math.max(pad, preferred.x), state.width - pad), + y: Math.min(Math.max(pad, preferred.y), state.height - pad), }; } @@ -467,19 +471,19 @@

玩法说明

function spawnEnemy() { const diff = difficulties[state.difficulty]; const edge = Math.floor(Math.random() * 4); - const margin = 40; + const size = 22; + const margin = Math.max(arenaPadding + 4, size + 12); const speed = (randRange(60, 110) + state.wave * 4) * diff.enemySpeed; const hp = (30 + state.wave * 5) * diff.enemyHp; let x, y; - const size = 22; for (let attempt = 0; attempt < 10; attempt++) { - if (edge === 0) { x = randRange(margin, state.width - margin); y = -margin; } - else if (edge === 1) { x = state.width + margin; y = randRange(margin, state.height - margin); } - else if (edge === 2) { x = randRange(margin, state.width - margin); y = state.height + margin; } - else { x = -margin; y = randRange(margin, state.height - margin); } + if (edge === 0) { x = randRange(margin, state.width - margin); y = margin; } + else if (edge === 1) { x = state.width - margin; y = randRange(margin, state.height - margin); } + else if (edge === 2) { x = randRange(margin, state.width - margin); y = state.height - margin; } + else { x = margin; y = randRange(margin, state.height - margin); } const candidate = { x: x - size, y: y - size, w: size * 2, h: size * 2 }; const blocked = obstacles.some(ob => rectsCollide(candidate.x, candidate.y, candidate.w, candidate.h, ob)); - if (!blocked) break; + if (!blocked && x >= margin && x <= state.width - margin && y >= margin && y <= state.height - margin) break; } enemies.push({ x, y, angle: 0, size, speed, hp, reload: randRange(0.8, 1.4), fireCooldown: 0 }); } @@ -572,8 +576,9 @@

玩法说明

const prevX = player.x; const prevY = player.y; - player.x = Math.max(player.size, Math.min(state.width - player.size, player.x + vx * delta)); - player.y = Math.max(player.size, Math.min(state.height - player.size, player.y + vy * delta)); + const pad = arenaPadding + player.size; + player.x = Math.max(pad, Math.min(state.width - pad, player.x + vx * delta)); + player.y = Math.max(pad, Math.min(state.height - pad, player.y + vy * delta)); const hitObstacle = obstacles.some(ob => circleRectCollision(player.x, player.y, player.size, ob)); if (hitObstacle) { player.x = prevX; @@ -638,10 +643,13 @@

玩法说明

e.angle = Math.atan2(target.y - e.y, target.x - e.x); const ex = e.x + Math.cos(e.angle) * e.speed * delta; const ey = e.y + Math.sin(e.angle) * e.speed * delta; - const willHit = obstacles.some(ob => circleRectCollision(ex, ey, e.size, ob)); + const bound = arenaPadding + e.size; + const clampedX = Math.max(bound, Math.min(state.width - bound, ex)); + const clampedY = Math.max(bound, Math.min(state.height - bound, ey)); + const willHit = obstacles.some(ob => circleRectCollision(clampedX, clampedY, e.size, ob)); if (!willHit) { - e.x = ex; - e.y = ey; + e.x = clampedX; + e.y = clampedY; } e.fireCooldown -= delta; @@ -978,8 +986,9 @@

玩法说明

const ratio = window.devicePixelRatio || 1; canvas.width = rect.width * ratio; canvas.height = rect.height * ratio; - state.width = canvas.width; - state.height = canvas.height; + state.width = rect.width; + state.height = rect.height; + ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(ratio, ratio); buildLevel(state.levelIndex); } From 03ea423046b56bee503782b0d48ed170b1b0c301 Mon Sep 17 00:00:00 2001 From: Fstarnb <78671757+Fstarnb@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:54:10 +0800 Subject: [PATCH 08/10] Harden enemy spawning to prevent stalls --- tank-battle.html | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/tank-battle.html b/tank-battle.html index 0b24242..b7a1316 100644 --- a/tank-battle.html +++ b/tank-battle.html @@ -470,22 +470,38 @@

玩法说明

function spawnEnemy() { const diff = difficulties[state.difficulty]; - const edge = Math.floor(Math.random() * 4); const size = 22; - const margin = Math.max(arenaPadding + 4, size + 12); + const margin = Math.max(arenaPadding + size + 6, size + 12); const speed = (randRange(60, 110) + state.wave * 4) * diff.enemySpeed; const hp = (30 + state.wave * 5) * diff.enemyHp; - let x, y; - for (let attempt = 0; attempt < 10; attempt++) { - if (edge === 0) { x = randRange(margin, state.width - margin); y = margin; } - else if (edge === 1) { x = state.width - margin; y = randRange(margin, state.height - margin); } - else if (edge === 2) { x = randRange(margin, state.width - margin); y = state.height - margin; } - else { x = margin; y = randRange(margin, state.height - margin); } - const candidate = { x: x - size, y: y - size, w: size * 2, h: size * 2 }; - const blocked = obstacles.some(ob => rectsCollide(candidate.x, candidate.y, candidate.w, candidate.h, ob)); - if (!blocked && x >= margin && x <= state.width - margin && y >= margin && y <= state.height - margin) break; + + function clearForEnemy(cx, cy) { + const candidate = { x: cx - size, y: cy - size, w: size * 2, h: size * 2 }; + const blockedByWalls = obstacles.some(ob => rectsCollide(candidate.x, candidate.y, candidate.w, candidate.h, ob)); + const tooCloseToPlayers = players.some(p => p.alive && Math.hypot(p.x - cx, p.y - cy) < p.size + size + 10); + return !blockedByWalls && !tooCloseToPlayers && cx >= margin && cx <= state.width - margin && cy >= margin && cy <= state.height - margin; + } + + let pos = null; + for (let attempt = 0; attempt < 24 && !pos; attempt++) { + const edge = attempt % 4; + const cx = edge === 0 ? randRange(margin, state.width - margin) : edge === 1 ? state.width - margin : edge === 2 ? randRange(margin, state.width - margin) : margin; + const cy = edge === 0 ? margin : edge === 1 ? randRange(margin, state.height - margin) : edge === 2 ? state.height - margin : randRange(margin, state.height - margin); + if (clearForEnemy(cx, cy)) pos = { x: cx, y: cy }; } - enemies.push({ x, y, angle: 0, size, speed, hp, reload: randRange(0.8, 1.4), fireCooldown: 0 }); + + if (!pos) { + const step = Math.max(size * 2, 48); + for (let cy = margin; cy <= state.height - margin && !pos; cy += step) { + for (let cx = margin; cx <= state.width - margin && !pos; cx += step) { + if (clearForEnemy(cx, cy)) pos = { x: cx, y: cy }; + } + } + } + + if (!pos) pos = { x: state.width / 2, y: state.height / 2 }; + + enemies.push({ x: pos.x, y: pos.y, angle: 0, size, speed, hp, reload: randRange(0.8, 1.4), fireCooldown: 0 }); } function spawnPickup() { @@ -631,6 +647,7 @@

玩法说明

for (let i = enemies.length - 1; i >= 0; i--) { const e = enemies[i]; + if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) { enemies.splice(i, 1); continue; } let target = null; let nearest = Infinity; for (const player of players) { From 0b543abe6b90f86eec6cd7b17befa75baad62227 Mon Sep 17 00:00:00 2001 From: Fstarnb <78671757+Fstarnb@users.noreply.github.com> Date: Sat, 29 Nov 2025 15:01:50 +0800 Subject: [PATCH 09/10] Add PvP mode and rebalance arena --- tank-battle.html | 228 ++++++++++++++++++++++++++++++----------------- 1 file changed, 145 insertions(+), 83 deletions(-) diff --git a/tank-battle.html b/tank-battle.html index b7a1316..6fa629e 100644 --- a/tank-battle.html +++ b/tank-battle.html @@ -216,6 +216,7 @@

准备开战

+
@@ -297,6 +298,7 @@

玩法说明

const startMenu = document.getElementById('start-menu'); const startSolo = document.getElementById('start-solo'); const startCoop = document.getElementById('start-coop'); + const startPvp = document.getElementById('start-pvp'); const arenaPadding = 28; @@ -311,6 +313,7 @@

玩法说明

combo: 0, lastTime: 0, difficulty: 'normal', + mode: 'solo', playerCount: 1, inputs: { p1: { up:false, down:false, left:false, right:false, shoot:false, dash:false }, @@ -347,36 +350,46 @@

玩法说明

const levelLayouts = [ { name: '第一关 · 开阔练习场', - brief: '中央木墙与边缘石墙组合,适合练习绕障碍与迂回射击。', + brief: '石墙留出通道,木墙短隔断方便绕行与对射。', layout: [ - { type: 'stone', px: 0.08, py: 0.15, pw: 0.84, ph: 0.04 }, - { type: 'stone', px: 0.08, py: 0.81, pw: 0.84, ph: 0.04 }, - { type: 'wood', px: 0.3, py: 0.38, pw: 0.4, ph: 0.03, hp: 70 }, - { type: 'wood', px: 0.3, py: 0.55, pw: 0.4, ph: 0.03, hp: 70 }, - { type: 'stone', px: 0.48, py: 0.28, pw: 0.04, ph: 0.46 }, + { type: 'stone', px: 0.08, py: 0.14, pw: 0.32, ph: 0.04 }, + { type: 'stone', px: 0.6, py: 0.14, pw: 0.32, ph: 0.04 }, + { type: 'stone', px: 0.12, py: 0.8, pw: 0.28, ph: 0.04 }, + { type: 'stone', px: 0.58, py: 0.8, pw: 0.3, ph: 0.04 }, + { type: 'wood', px: 0.22, py: 0.36, pw: 0.22, ph: 0.035, hp: 70 }, + { type: 'wood', px: 0.54, py: 0.5, pw: 0.22, ph: 0.035, hp: 70 }, + { type: 'wood', px: 0.26, py: 0.62, pw: 0.22, ph: 0.035, hp: 70 }, + { type: 'stone', px: 0.46, py: 0.25, pw: 0.06, ph: 0.26 }, + { type: 'stone', px: 0.46, py: 0.56, pw: 0.06, ph: 0.18 }, ], }, { name: '第二关 · 走廊与拐角', - brief: '多条石墙走廊限制走位,穿插木墙可用火力开道。', + brief: '拆分的石墙走廊留出穿插路线,破坏木墙能打开捷径。', layout: [ - { type: 'stone', px: 0.14, py: 0.2, pw: 0.05, ph: 0.6 }, - { type: 'stone', px: 0.81, py: 0.2, pw: 0.05, ph: 0.6 }, - { type: 'stone', px: 0.25, py: 0.45, pw: 0.5, ph: 0.05 }, - { type: 'wood', px: 0.28, py: 0.3, pw: 0.12, ph: 0.05, hp: 60 }, - { type: 'wood', px: 0.6, py: 0.62, pw: 0.12, ph: 0.05, hp: 60 }, + { type: 'stone', px: 0.14, py: 0.2, pw: 0.06, ph: 0.22 }, + { type: 'stone', px: 0.14, py: 0.55, pw: 0.06, ph: 0.22 }, + { type: 'stone', px: 0.78, py: 0.2, pw: 0.06, ph: 0.22 }, + { type: 'stone', px: 0.78, py: 0.55, pw: 0.06, ph: 0.22 }, + { type: 'stone', px: 0.22, py: 0.42, pw: 0.56, ph: 0.04 }, + { type: 'wood', px: 0.32, py: 0.26, pw: 0.16, ph: 0.045, hp: 60 }, + { type: 'wood', px: 0.58, py: 0.58, pw: 0.16, ph: 0.045, hp: 60 }, + { type: 'wood', px: 0.42, py: 0.7, pw: 0.2, ph: 0.04, hp: 60 }, ], }, { name: '第三关 · 中央堡垒', - brief: '中心石墙堡垒需绕行,外围木墙可被炮弹摧毁出入口。', + brief: '堡垒敞开多向缺口,绕墙和破墙都有路线。', layout: [ - { type: 'stone', px: 0.36, py: 0.36, pw: 0.28, ph: 0.04 }, - { type: 'stone', px: 0.36, py: 0.6, pw: 0.28, ph: 0.04 }, - { type: 'stone', px: 0.36, py: 0.4, pw: 0.04, ph: 0.2 }, - { type: 'stone', px: 0.6, py: 0.4, pw: 0.04, ph: 0.2 }, - { type: 'wood', px: 0.2, py: 0.25, pw: 0.12, ph: 0.05, hp: 75 }, - { type: 'wood', px: 0.68, py: 0.72, pw: 0.12, ph: 0.05, hp: 75 }, + { type: 'stone', px: 0.34, py: 0.34, pw: 0.14, ph: 0.05 }, + { type: 'stone', px: 0.52, py: 0.34, pw: 0.14, ph: 0.05 }, + { type: 'stone', px: 0.34, py: 0.6, pw: 0.14, ph: 0.05 }, + { type: 'stone', px: 0.52, py: 0.6, pw: 0.14, ph: 0.05 }, + { type: 'stone', px: 0.48, py: 0.42, pw: 0.05, ph: 0.18 }, + { type: 'wood', px: 0.18, py: 0.26, pw: 0.14, ph: 0.05, hp: 75 }, + { type: 'wood', px: 0.68, py: 0.7, pw: 0.14, ph: 0.05, hp: 75 }, + { type: 'wood', px: 0.26, py: 0.58, pw: 0.12, ph: 0.05, hp: 75 }, + { type: 'wood', px: 0.64, py: 0.38, pw: 0.12, ph: 0.05, hp: 75 }, ], }, ]; @@ -474,26 +487,34 @@

玩法说明

const margin = Math.max(arenaPadding + size + 6, size + 12); const speed = (randRange(60, 110) + state.wave * 4) * diff.enemySpeed; const hp = (30 + state.wave * 5) * diff.enemyHp; + const minEnemyGap = size * 3.2; function clearForEnemy(cx, cy) { const candidate = { x: cx - size, y: cy - size, w: size * 2, h: size * 2 }; const blockedByWalls = obstacles.some(ob => rectsCollide(candidate.x, candidate.y, candidate.w, candidate.h, ob)); const tooCloseToPlayers = players.some(p => p.alive && Math.hypot(p.x - cx, p.y - cy) < p.size + size + 10); - return !blockedByWalls && !tooCloseToPlayers && cx >= margin && cx <= state.width - margin && cy >= margin && cy <= state.height - margin; + const tooCloseToEnemies = enemies.some(en => Math.hypot(en.x - cx, en.y - cy) < minEnemyGap); + return !blockedByWalls && !tooCloseToPlayers && !tooCloseToEnemies && cx >= margin && cx <= state.width - margin && cy >= margin && cy <= state.height - margin; } let pos = null; - for (let attempt = 0; attempt < 24 && !pos; attempt++) { - const edge = attempt % 4; - const cx = edge === 0 ? randRange(margin, state.width - margin) : edge === 1 ? state.width - margin : edge === 2 ? randRange(margin, state.width - margin) : margin; - const cy = edge === 0 ? margin : edge === 1 ? randRange(margin, state.height - margin) : edge === 2 ? state.height - margin : randRange(margin, state.height - margin); + const edges = [0,1,2,3].sort(() => Math.random() - 0.5); + for (let attempt = 0; attempt < 28 && !pos; attempt++) { + const edge = edges[attempt % 4]; + const spread = Math.max(0.28, 0.5 - attempt * 0.01); + const cx = edge === 0 ? randRange(state.width * (0.5 - spread), state.width * (0.5 + spread)) : edge === 1 ? state.width - margin : edge === 2 ? randRange(state.width * (0.5 - spread), state.width * (0.5 + spread)) : margin; + const cy = edge === 0 ? margin : edge === 1 ? randRange(state.height * (0.5 - spread), state.height * (0.5 + spread)) : edge === 2 ? state.height - margin : randRange(state.height * (0.5 - spread), state.height * (0.5 + spread)); if (clearForEnemy(cx, cy)) pos = { x: cx, y: cy }; } if (!pos) { const step = Math.max(size * 2, 48); + const offset = (state.wave % 3) * (step / 2); for (let cy = margin; cy <= state.height - margin && !pos; cy += step) { for (let cx = margin; cx <= state.width - margin && !pos; cx += step) { + const sx = cx + (offset % step); + const sy = cy + (offset % step); + if (clearForEnemy(sx, sy)) pos = { x: sx, y: sy }; if (clearForEnemy(cx, cy)) pos = { x: cx, y: cy }; } } @@ -521,6 +542,21 @@

玩法说明

} } + function damagePlayer(player, amount) { + if (!player.alive) return; + player.health -= amount; + addParticle(player.x, player.y, '#f87171'); + if (player.health <= 0) { + player.alive = false; + for (let k = 0; k < 16; k++) addParticle(player.x, player.y, '#f87171'); + if (state.mode === 'pvp') { + const winner = players.find(p => p.id !== player.id && p.alive); + showGameOver(winner ? `${winner.id === 'p1' ? '玩家一' : '玩家二'} 胜利!` : '平局'); + state.running = false; + } + } + } + function resetPlayers() { players.length = 0; players.push(createPlayer('p1', '#22c55e', '#e2e8f0')); @@ -565,6 +601,8 @@

玩法说明

angle: source.angle + spread, speed, friendly, + ownerType: friendly ? 'player' : 'enemy', + ownerId: source.id || null, life: 2.2, }); } @@ -572,7 +610,17 @@

玩法说明

function update(delta) { if (!state.started || !state.running) { updateHud(); return; } const livingPlayers = players.filter(p => p.alive); - if (livingPlayers.length === 0) { showGameOver(); state.running = false; return; } + if (state.mode === 'pvp') { + if (livingPlayers.length === 0) { showGameOver('平局'); state.running = false; return; } + if (livingPlayers.length === 1) { + const winner = livingPlayers[0]; + showGameOver(`${winner.id === 'p1' ? '玩家一' : '玩家二'} 胜利!`); + state.running = false; + return; + } + } else if (livingPlayers.length === 0) { + showGameOver(); state.running = false; return; + } livingPlayers.forEach(player => { const dir = state.inputs[player.id] || {}; @@ -632,7 +680,17 @@

玩法说明

bullets.splice(i, 1); continue; } - if (!b.friendly) { + if (b.ownerType === 'player' && state.mode === 'pvp') { + for (const player of players) { + if (!player.alive || player.id === b.ownerId) continue; + if (Math.hypot(b.x - player.x, b.y - player.y) < player.size) { + bullets.splice(i, 1); + damagePlayer(player, 16); + break; + } + } + } + if (b.ownerType === 'enemy') { for (const player of players) { if (!player.alive) continue; const dist = Math.hypot(b.x - player.x, b.y - player.y); @@ -645,60 +703,62 @@

玩法说明

} } - for (let i = enemies.length - 1; i >= 0; i--) { - const e = enemies[i]; - if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) { enemies.splice(i, 1); continue; } - let target = null; - let nearest = Infinity; - for (const player of players) { - if (!player.alive) continue; - const d = Math.hypot(player.x - e.x, player.y - e.y); - if (d < nearest) { nearest = d; target = player; } - } - if (!target) continue; - - e.angle = Math.atan2(target.y - e.y, target.x - e.x); - const ex = e.x + Math.cos(e.angle) * e.speed * delta; - const ey = e.y + Math.sin(e.angle) * e.speed * delta; - const bound = arenaPadding + e.size; - const clampedX = Math.max(bound, Math.min(state.width - bound, ex)); - const clampedY = Math.max(bound, Math.min(state.height - bound, ey)); - const willHit = obstacles.some(ob => circleRectCollision(clampedX, clampedY, e.size, ob)); - if (!willHit) { - e.x = clampedX; - e.y = clampedY; - } - - e.fireCooldown -= delta; - if (e.fireCooldown <= 0) { - shoot(e, false); - e.fireCooldown = e.reload; - } + if (state.mode !== 'pvp') { + for (let i = enemies.length - 1; i >= 0; i--) { + const e = enemies[i]; + if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) { enemies.splice(i, 1); continue; } + let target = null; + let nearest = Infinity; + for (const player of players) { + if (!player.alive) continue; + const d = Math.hypot(player.x - e.x, player.y - e.y); + if (d < nearest) { nearest = d; target = player; } + } + if (!target) continue; + + e.angle = Math.atan2(target.y - e.y, target.x - e.x); + const ex = e.x + Math.cos(e.angle) * e.speed * delta; + const ey = e.y + Math.sin(e.angle) * e.speed * delta; + const bound = arenaPadding + e.size; + const clampedX = Math.max(bound, Math.min(state.width - bound, ex)); + const clampedY = Math.max(bound, Math.min(state.height - bound, ey)); + const willHit = obstacles.some(ob => circleRectCollision(clampedX, clampedY, e.size, ob)); + if (!willHit) { + e.x = clampedX; + e.y = clampedY; + } - players.forEach(player => { - if (!player.alive) return; - const distToPlayer = Math.hypot(e.x - player.x, e.y - player.y); - if (distToPlayer < e.size + player.size - 6) { - damagePlayer(player, 20 * delta); + e.fireCooldown -= delta; + if (e.fireCooldown <= 0) { + shoot(e, false); + e.fireCooldown = e.reload; } - }); - - for (let j = bullets.length - 1; j >= 0; j--) { - const b = bullets[j]; - if (!b.friendly) continue; - const dist = Math.hypot(b.x - e.x, b.y - e.y); - if (dist < e.size) { - bullets.splice(j, 1); - e.hp -= 20; - state.combo += 1; - const diff = difficulties[state.difficulty]; - state.score += Math.round(10 * (1 + state.combo * 0.05) * diff.score); - addParticle(e.x, e.y, '#f472b6'); - if (e.hp <= 0) { - for (let k = 0; k < 12; k++) addParticle(e.x, e.y, '#22c55e'); - enemies.splice(i, 1); + + players.forEach(player => { + if (!player.alive) return; + const distToPlayer = Math.hypot(e.x - player.x, e.y - player.y); + if (distToPlayer < e.size + player.size - 6) { + damagePlayer(player, 20 * delta); + } + }); + + for (let j = bullets.length - 1; j >= 0; j--) { + const b = bullets[j]; + if (!b.friendly) continue; + const dist = Math.hypot(b.x - e.x, b.y - e.y); + if (dist < e.size) { + bullets.splice(j, 1); + e.hp -= 20; + state.combo += 1; + const diff = difficulties[state.difficulty]; + state.score += Math.round(10 * (1 + state.combo * 0.05) * diff.score); + addParticle(e.x, e.y, '#f472b6'); + if (e.hp <= 0) { + for (let k = 0; k < 12; k++) addParticle(e.x, e.y, '#22c55e'); + enemies.splice(i, 1); + } + break; } - break; } } } @@ -727,7 +787,7 @@

玩法说明

p.x += p.vx * delta; p.y += p.vy * delta; p.size *= 0.98; } - if (enemies.length === 0) { + if (state.mode !== 'pvp' && enemies.length === 0) { state.wave += 1; const nextLevel = Math.floor((state.wave - 1) / 3) % levelLayouts.length; if (nextLevel !== state.levelIndex) buildLevel(nextLevel); @@ -889,14 +949,14 @@

玩法说明

difficultyLabel.textContent = difficulties[state.difficulty].label; } - function showGameOver() { + function showGameOver(title = '战斗结束') { ctx.save(); ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0,0,state.width,state.height); ctx.fillStyle = '#e2e8f0'; ctx.font = 'bold 42px "Segoe UI"'; ctx.textAlign = 'center'; - ctx.fillText('战斗结束', state.width/2, state.height/2 - 10); + ctx.fillText(title, state.width/2, state.height/2 - 10); ctx.font = '20px "Segoe UI"'; ctx.fillText(`最终得分:${state.score} | 波次 ${state.wave}`, state.width/2, state.height/2 + 28); ctx.fillText('点击重新开局,或按 R 重置', state.width/2, state.height/2 + 60); @@ -918,7 +978,7 @@

玩法说明

case '.': state.inputs.p2.shoot = pressed; break; case '/': state.inputs.p2.dash = pressed; break; case 'p': if (pressed) togglePause(); break; - case 'r': if (pressed) { state.started ? resetGame() : startGame(state.playerCount > 1 ? 'coop' : 'solo'); } break; + case 'r': if (pressed) { state.started ? resetGame() : startGame(state.mode); } break; } } @@ -931,7 +991,7 @@

玩法说明

function setupControls() { window.addEventListener('keydown', (e) => { handleKey(e, true); }); window.addEventListener('keyup', (e) => { handleKey(e, false); }); - restartBtn.addEventListener('click', () => startGame(state.playerCount > 1 ? 'coop' : 'solo')); + restartBtn.addEventListener('click', () => startGame(state.mode)); pauseBtn.addEventListener('click', togglePause); difficultySelect.addEventListener('change', (e) => { state.difficulty = e.target.value; @@ -939,6 +999,7 @@

玩法说明

}); startSolo?.addEventListener('click', () => startGame('solo')); startCoop?.addEventListener('click', () => startGame('coop')); + startPvp?.addEventListener('click', () => startGame('pvp')); mobileShoot?.addEventListener('click', () => state.inputs.p1.shoot = true); mobileDash?.addEventListener('click', () => state.inputs.p1.dash = true); @@ -991,7 +1052,8 @@

玩法说明

} function startGame(mode = 'solo') { - state.playerCount = mode === 'coop' ? 2 : 1; + state.mode = mode; + state.playerCount = mode === 'solo' ? 1 : 2; state.started = true; state.running = true; startMenu?.classList.add('hidden'); From 1a19ebeea78689c0587f1dbc11ffdecf73a700af Mon Sep 17 00:00:00 2001 From: Fstarnb <78671757+Fstarnb@users.noreply.github.com> Date: Sat, 29 Nov 2025 15:28:31 +0800 Subject: [PATCH 10/10] Improve level selection and controls --- tank-battle.html | 287 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 212 insertions(+), 75 deletions(-) diff --git a/tank-battle.html b/tank-battle.html index 6fa629e..7406fdb 100644 --- a/tank-battle.html +++ b/tank-battle.html @@ -74,7 +74,7 @@ gap: 16px; padding: 16px 24px 32px; } - .panel { + .panel { background: rgba(15, 23, 42, 0.9); border: 1px solid rgba(148, 163, 184, 0.15); border-radius: 16px; @@ -82,6 +82,31 @@ overflow: hidden; position: relative; } + .status-banner { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + padding: 10px 16px; + border-radius: 12px; + background: rgba(17,24,39,0.9); + border: 1px solid rgba(148, 163, 184, 0.3); + box-shadow: 0 10px 20px rgba(0,0,0,0.35); + color: var(--text); + font-weight: 700; + letter-spacing: 0.3px; + z-index: 6; + opacity: 0; + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; + } + .status-banner.show { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + .status-banner.success { border-color: rgba(16, 185, 129, 0.4); color: #34d399; } + .status-banner.warn { border-color: rgba(251, 191, 36, 0.45); color: #fbbf24; } + .status-banner.danger { border-color: rgba(248, 113, 113, 0.5); color: #f87171; } .menu-overlay { position: absolute; inset: 0; @@ -208,6 +233,7 @@

网页坦克大战

+