[HTML] 纯文本查看 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>坦克大战</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body { width:100%; height:100%; overflow:hidden; background:#0a0a0a;
font-family:'Courier New',Courier,monospace; user-select:none; -webkit-user-select:none; touch-action:none; }
#app { width:100%; height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; }
#canvas-wrap { position:relative; line-height:0; flex:none; }
canvas { display:block; image-rendering:pixelated; image-rendering:crisp-edges; background:#000; border:2px solid #333; }
#overlay { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,.72); line-height:1.6; }
#overlay.hidden { display:none; }
.screen { display:none; flex-direction:column; align-items:center; text-align:center; color:#ddd;
padding:12px; width:100%; max-height:100%; overflow:auto; }
.screen.active { display:flex; }
.title { font-size:clamp(28px,7vmin,52px); color:#e8c422; text-shadow:3px 3px 0 #7a3010;
letter-spacing:8px; font-weight:bold; }
.subtitle { color:#888; font-size:clamp(11px,2.5vmin,15px); letter-spacing:4px; margin:4px 0 14px; }
.instructions { font-size:clamp(11px,2.4vmin,14px); color:#aaa; margin-bottom:16px; }
.instructions p { margin:3px 0; }
.btn { font-family:inherit; font-size:clamp(15px,3vmin,19px); font-weight:bold; color:#fff;
background:#b02020; border:3px solid #e8c422; border-radius:8px; padding:10px 34px;
cursor:pointer; letter-spacing:3px; box-shadow:0 4px 0 #601010; }
.btn:active { transform:translateY(3px); box-shadow:0 1px 0 #601010; }
.best { margin-top:14px; color:#ffd040; font-size:13px; }
.title2 { font-size:clamp(24px,6vmin,40px); font-weight:bold; color:#fff; margin-bottom:12px; letter-spacing:4px; }
.title2.red { color:#e84040; }
#controls { display:none; width:100%; max-width:560px; padding:10px 18px 14px;
align-items:center; justify-content:space-between; flex:none; }
#controls.show { display:flex; }
#dpad { position:relative; width:156px; height:156px; flex:none; }
.dbtn { position:absolute; width:52px; height:52px; background:rgba(255,255,255,.10);
border:2px solid rgba(255,255,255,.28); border-radius:12px; color:#ccc; font-size:18px;
display:flex; align-items:center; justify-content:center; }
.dbtn.on { background:rgba(232,196,34,.35); }
#d-up{left:52px;top:0} #d-down{left:52px;top:104px} #d-left{left:0;top:52px} #d-right{left:104px;top:52px}
#firebtn { width:86px; height:86px; border-radius:50%; background:rgba(200,40,40,.55);
border:3px solid rgba(255,120,120,.6); color:#fff; font-size:17px; font-weight:bold;
display:flex; align-items:center; justify-content:center; letter-spacing:2px; flex:none; }
#firebtn.on { background:rgba(255,80,80,.8); }
[url=home.php?mod=space&uid=945662]@media[/url] (orientation:landscape) {
#controls.show { position:fixed; left:0; right:0; bottom:0; max-width:none;
justify-content:space-between; padding:0 16px 12px; pointer-events:none; }
#dpad,#firebtn { pointer-events:auto; }
}
#floatbtns { position:fixed; top:6px; right:6px; z-index:20; display:flex; gap:6px; }
#floatbtns button { width:38px; height:38px; border-radius:50%; border:2px solid #555;
background:rgba(30,30,30,.8); color:#fff; font-size:16px; cursor:pointer; }
</style>
</head>
<body>
<div id="app">
<div id="canvas-wrap">
<canvas id="game"></canvas>
<div id="overlay">
<div class="screen" id="menuScreen">
<div class="title">坦 克 大 战</div>
<div class="subtitle">BATTLE CITY</div>
<div class="instructions">
<p>🎮 电脑:方向键 / WASD 移动,空格键射击</p>
<p>📱 手机:左下方向键移动,右下按钮射击</p>
<p>🎯 消灭全部敌方坦克,守护底部老鹰基地!</p>
<p>💊 打红色闪烁坦克掉落道具 | P 键暂停</p>
</div>
<button class="btn" id="startBtn">开 始 游 戏</button>
<div class="best">最高分:<span id="bestScore">0</span></div>
</div>
<div class="screen" id="pauseScreen">
<div class="title2">已 暂 停</div>
<button class="btn" id="resumeBtn">继 续</button>
</div>
<div class="screen" id="overScreen">
<div class="title2 red">游 戏 结 束</div>
<p style="margin-bottom:8px">得分:<span id="finalScore" style="color:#ffd040">0</span></p>
<p id="newRecord" style="color:#7af07a;margin-bottom:8px">🏆 新纪录!</p>
<button class="btn" id="restartBtn">再 来 一 局</button>
</div>
</div>
</div>
<div id="controls">
<div id="dpad">
<div class="dbtn" id="d-up" data-dir="up">▲</div>
<div class="dbtn" id="d-left" data-dir="left">◀</div>
<div class="dbtn" id="d-right" data-dir="right">▶</div>
<div class="dbtn" id="d-down" data-dir="down">▼</div>
</div>
<div id="firebtn">射击</div>
</div>
</div>
<div id="floatbtns">
<button id="pauseBtn">⏸</button>
<button id="muteBtn">🔊</button>
</div>
<script>
'use strict';
/* ================= 基础常量 ================= */
const $ = id => document.getElementById(id);
const canvas = $('game'), ctx = canvas.getContext('2d');
const COLS = 13, ROWS = 13, CELL = 48;
const FIELD = COLS * CELL;
const PANEL_W = 88;
const CVW = FIELD + PANEL_W, CVH = FIELD;
const TS = 42;
const DX = [0,1,0,-1], DY = [-1,0,1,0];
const T_EMPTY=0, T_BRICK=1, T_STEEL=2, T_WATER=3, T_TREE=4, T_ICE=5;
const QUADS = [[0,0,1],[1,0,2],[0,1,4],[1,1,8]];
const MAX_ENEMIES = 4, TOTAL_ENEMIES = 20, SPAWN_TIME = 1.0, PU_SIZE = 36;
const SPAWN_CELLS = [[0,0],[6,0],[12,0]];
const FORTRESS = [[11,5],[11,6],[11,7],[12,5],[12,7]];
const ALIGN_SPEED = 320; // 侧向收敛速度(像素/秒)
/* ================= 关卡地图 ================= */
const MAPS = [
[".............",".BB..B.B..BB.",".BB..B.B..BB.",".............","..B.BB.BB.B..",
"..B.BB.BB.B..",".............",".BB..S.S..BB.",".BB..S.S..BB.",".....B.B.....",
".BB..B.B..BB.",".....BBB.....",".....BEB....."],
[".............",".T..BB.BB..T.",".T..BB.BB..T.","..W.......W..","..W..BBB..W..",
".....B.B.....",".BB..B.B..BB.",".BB.......BB.",".....SSS.....",".T...B.B...T.",
".T...B.B...T.",".....BBB.....",".....BEB....."],
[".............",".S.BB.B.BB.S.",".S.BB.B.BB.S.",".............","..BB.S.B.SB..",
"..BB.....BB..","..S..BBB..S..",".....B.B.....",".BB..B.B..BB.",".BB..S.S..BB.",
".............",".....BBB.....",".....BEB....."],
[".............",".B...B.B...B.",".B.II...II.B.","....I...I....",".BB.I.B.I.BB.",
"....I...I....",".B.II...II.B.",".B...B.B...B.",".............","..BBB...BBB..",
".............",".....BBB.....",".....BEB....."],
[".............",".BB.S...S.BB.",".BB.S...S.BB.",".....T.T.....",".BB..T.T..BB.",
".....B.B.....",".SS..B.B..SS.",".....B.B.....",".BB..T.T..BB.",".....T.T.....",
".BB.S...S.BB.",".....BBB.....",".....BEB....."]
];
/* ================= 坦克数据 ================= */
const STATS = {
basic:{speed:55, hp:1, score:100, bulletSpeed:170, fireRate:0.8, power:1},
fast: {speed:105,hp:1, score:200, bulletSpeed:170, fireRate:0.9, power:1},
power:{speed:70, hp:1, score:300, bulletSpeed:290, fireRate:1.5, power:2},
armor:{speed:50, hp:4, score:400, bulletSpeed:170, fireRate:1.0, power:1}
};
const PALS = {
player:{body:'#e0c422',dark:'#8a7814',light:'#fff27a',track:'#6b6b52',trackDark:'#3f3f2e'},
basic: {body:'#c9c9c9',dark:'#7a7a7a',light:'#f2f2f2',track:'#5a5a5a',trackDark:'#333333'},
fast: {body:'#5ab0e8',dark:'#2a6a9a',light:'#a8dcff',track:'#4a5a6a',trackDark:'#2a343e'},
power: {body:'#5ac85a',dark:'#2a7a2a',light:'#a8f0a8',track:'#4a6a4a',trackDark:'#2a3e2a'},
armor: {body:'#9aa878',dark:'#5a6a3a',light:'#d8e8b0',track:'#5a5a4a',trackDark:'#34342a'},
bonus: {body:'#e85050',dark:'#8a2020',light:'#ffa0a0',track:'#6a4a4a',trackDark:'#3e2a2a'}
};
const ARMOR_COLORS = {4:'#9aa878',3:'#d8c858',2:'#d89848',1:'#d86048'};
/* ================= 游戏状态 ================= */
let grid=[], player=null, enemies=[], bullets=[], explosions=[], powerups=[];
let queue=[], spawnTimer=0, spawnIdx=0, spawnCount=0;
let state='menu', level=1, score=0, lives=3;
let shieldTimer=0, freezeTimer=0, shovelTimer=0;
let bannerText='', bannerT=0, bannerColor='#ffdc40';
let gameOverT=0, clearT=0, eagleAlive=true, animTime=0, muted=false;
let best = 0;
try { best = +localStorage.getItem('tankBest') || 0; } catch(e){}
/* ================= 音效 ================= */
let actx = null;
function initAudio(){
if (!actx) { try { actx = new (window.AudioContext||window.webkitAudioContext)(); } catch(e){} }
if (actx && actx.state === 'suspended') actx.resume();
}
function tone(freq, dur, type, vol, slideTo, delay){
if (!actx || muted) return;
type = type||'square'; vol = vol||0.15; delay = delay||0;
const t0 = actx.currentTime + delay;
const o = actx.createOscillator(), g = actx.createGain();
o.type = type; o.frequency.setValueAtTime(freq, t0);
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t0+dur);
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0+dur);
o.connect(g); g.connect(actx.destination);
o.start(t0); o.stop(t0+dur+0.03);
}
function noise(dur, vol){
if (!actx || muted) return;
const len = (actx.sampleRate*dur)|0;
const buf = actx.createBuffer(1, len, actx.sampleRate);
const d = buf.getChannelData(0);
for (let i=0;i<len;i++) d[i] = (Math.random()*2-1)*(1-i/len);
const src = actx.createBufferSource(); src.buffer = buf;
const g = actx.createGain(); g.gain.value = vol||0.3;
src.connect(g); g.connect(actx.destination); src.start();
}
const SFX = {
shoot(){ tone(900,.07,'square',.08,300); },
hit(){ tone(300,.06,'square',.12,150); noise(.04,.08); },
boom(){ noise(.35,.35); tone(150,.3,'sawtooth',.15,50); },
bigboom(){ noise(.6,.45); tone(90,.55,'sawtooth',.2,35); },
power(){ [523,659,784,1046].forEach((f,i)=>tone(f,.09,'square',.12,null,i*.08)); },
spawn(){ tone(250,.2,'square',.07,500); },
start(){ [392,523,659,784].forEach((f,i)=>tone(f,.12,'square',.12,null,i*.13)); },
gameover(){ tone(330,.3,'sawtooth',.16,165); tone(165,.5,'sawtooth',.16,82,.32); }
};
/* ================= 工具函数 ================= */
function rectsOverlap(x1,y1,w1,h1,x2,y2,w2,h2){
return x1 < x2+w2 && x1+w1 > x2 && y1 < y2+h2 && y1+h1 > y2;
}
function snapAxis(v){
return Math.round((v - (CELL-TS)/2)/CELL)*CELL + (CELL-TS)/2;
}
function addExplosion(x,y,big){ explosions.push({x,y,big,t:0,dur:big?0.5:0.28}); }
function setBanner(text,color,dur){ bannerText=text; bannerColor=color||'#ffdc40'; bannerT=dur||1.6; }
/* ================= 关卡加载 ================= */
function loadMap(idx){
const m = MAPS[idx % MAPS.length];
grid = [];
for (let r=0;r<ROWS;r++){
const row = [];
const line = (m[r]||'').padEnd(COLS,'.');
for (let c=0;c<COLS;c++){
const ch = line[c];
if (ch==='B') row.push({t:T_BRICK,q:15});
else if (ch==='S') row.push({t:T_STEEL,q:0});
else if (ch==='W') row.push({t:T_WATER,q:0});
else if (ch==='T') row.push({t:T_TREE,q:0});
else if (ch==='I') row.push({t:T_ICE,q:0});
else row.push({t:T_EMPTY,q:0});
}
grid.push(row);
}
eagleAlive = true;
}
function setFortress(type){
for (const [r,c] of FORTRESS) grid[r][c] = {t:type, q:type===T_BRICK?15:0};
}
function buildQueue(lv){
const w = {
basic: Math.max(8, 55-(lv-1)*9),
fast: 22+(lv-1)*2,
power: 14+(lv-1)*3,
armor: 6+(lv-1)*4
};
const total = w.basic+w.fast+w.power+w.armor;
const q = [];
for (let i=0;i<TOTAL_ENEMIES;i++){
const r = Math.random()*total; let type;
if (r < w.basic) type='basic';
else if (r < w.basic+w.fast) type='fast';
else if (r < w.basic+w.fast+w.power) type='power';
else type='armor';
q.push(type);
}
return q;
}
function makePlayer(){
return { x:4*CELL+(CELL-TS)/2, y:12*CELL+(CELL-TS)/2, dir:0, speed:95,
alive:true, spawning:false, spawnT:0, level:1, cool:0, anim:0, respawnT:0, flash:0 };
}
function startLevel(){
loadMap(level-1);
enemies=[]; bullets=[]; explosions=[]; powerups=[];
queue = buildQueue(level);
spawnTimer = 0.5; spawnIdx = 0; spawnCount = 0;
player = makePlayer();
shieldTimer = 3; freezeTimer = 0; shovelTimer = 0; gameOverT = 0;
setBanner('第 '+level+' 关', '#ffdc40', 2);
state = 'playing';
}
function startGame(){
initAudio();
score = 0; lives = 3; level = 1;
startLevel();
showScreen(null);
SFX.start();
}
/* ================= 碰撞检测 ================= */
function cellBlocksTank(nx,ny,r,c){
const cell = grid[r][c];
if (cell.t === T_STEEL || cell.t === T_WATER) return true;
if (cell.t === T_BRICK && cell.q){
const h = CELL/2, qx = c*CELL, qy = r*CELL;
for (const [dx,dy,bit] of QUADS){
if (cell.q & bit){
if (rectsOverlap(nx,ny,TS,TS, qx+dx*h, qy+dy*h, h, h)) return true;
}
}
}
return false;
}
function canMove(tank,nx,ny){
if (nx<0 || ny<0 || nx+TS>FIELD || ny+TS>FIELD) return false;
const c0 = Math.floor(nx/CELL), c1 = Math.floor((nx+TS-0.001)/CELL);
const r0 = Math.floor(ny/CELL), r1 = Math.floor((ny+TS-0.001)/CELL);
for (let r=r0;r<=r1;r++) for (let c=c0;c<=c1;c++)
if (cellBlocksTank(nx,ny,r,c)) return false;
const ex = 6*CELL, ey = 12*CELL;
if (rectsOverlap(nx,ny,TS,TS,ex,ey,CELL,CELL)) return false;
if (player && player.alive && !player.spawning && tank !== player)
if (rectsOverlap(nx,ny,TS,TS,player.x,player.y,TS,TS)) return false;
for (const e of enemies){
if (e===tank || e.dead || e.spawning) continue;
if (rectsOverlap(nx,ny,TS,TS,e.x,e.y,TS,TS)) return false;
}
return true;
}
/* ====== 侧向收敛:每帧朝最近网格中心逐像素靠拢,只减偏移、绝不引入偏移 ====== */
function alignPerp(tank, dt){
const horiz = (tank.dir === 1 || tank.dir === 3); // 水平运动 → 收敛 y
const cur = horiz ? tank.y : tank.x;
const t = snapAxis(cur);
const d = t - cur;
if (Math.abs(d) < 0.01) return;
const sgn = Math.sign(d);
const maxStep = Math.min(Math.abs(d), ALIGN_SPEED * dt);
let best = 0;
for (let s = 1; s <= Math.ceil(maxStep); s++){
const off = sgn * Math.min(s, maxStep);
const nx = horiz ? tank.x : cur + off;
const ny = horiz ? cur + off : tank.y;
if (canMove(tank, nx, ny)) best = off; else break; // 撞墙即停,绝不越过
}
if (best !== 0){ if (horiz) tank.y = cur + best; else tank.x = cur + best; }
}
/* ====== 移动:先收敛侧向,再纯轴向前进(前进绝不动侧向坐标) ====== */
function moveTank(tank, dt){
alignPerp(tank, dt);
const dist = tank.speed * dt;
let nx = tank.x, ny = tank.y;
if (tank.dir===0) ny -= dist;
else if (tank.dir===1) nx += dist;
else if (tank.dir===2) ny += dist;
else nx -= dist;
if (canMove(tank, nx, ny)){ tank.x = nx; tank.y = ny; return true; }
return false;
}
/* ================= 玩家 ================= */
function updatePlayer(dt){
if (!player.alive){
player.respawnT -= dt;
if (player.respawnT <= 0 && lives >= 0) respawnPlayer();
return;
}
if (player.spawning){
player.spawnT -= dt;
if (player.spawnT <= 0) player.spawning = false;
return;
}
player.cool -= dt;
const inp = getInput();
let dir = -1;
if (inp.up) dir=0; else if (inp.right) dir=1; else if (inp.down) dir=2; else if (inp.left) dir=3;
if (dir >= 0){
player.dir = dir; // 转向即时生效,无停顿状态
if (moveTank(player, dt)) player.anim += player.speed*dt;
}
if (inp.fire && player.cool <= 0){
const maxB = player.level >= 2 ? 2 : 1;
const cnt = bullets.filter(b=>b.isPlayer && !b.dead).length;
if (cnt < maxB){ spawnBullet(player,true); player.cool = player.level>=2 ? 0.22 : 0.32; }
}
}
function respawnPlayer(){
const sx = 4*CELL+(CELL-TS)/2, sy = 12*CELL+(CELL-TS)/2;
for (const e of enemies)
if (!e.dead && rectsOverlap(sx,sy,TS,TS,e.x,e.y,TS,TS)){ player.respawnT = 0.5; return; }
Object.assign(player,{x:sx,y:sy,dir:0,alive:true,spawning:true,spawnT:0.8,level:1,cool:0});
shieldTimer = 3;
}
function hitPlayer(){
addExplosion(player.x+TS/2, player.y+TS/2, true);
SFX.boom();
if (navigator.vibrate) navigator.vibrate([60,40,60]);
lives--;
player.alive = false;
player.respawnT = 1.2;
if (lives < 0) gameOverT = 1.6;
}
/* ================= 敌人 ================= */
function chooseDirection(e){
const targets = [];
if (player.alive) targets.push({x:player.x,y:player.y});
targets.push({x:6*CELL,y:12*CELL});
const t = targets[(Math.random()*targets.length)|0];
const prefer = Math.abs(t.x-e.x) > Math.abs(t.y-e.y) ? (t.x>e.x?1:3) : (t.y>e.y?2:0);
e.dir = (Math.random() < 0.45) ? prefer : (Math.random()*4)|0;
}
function updateSpawning(dt){
if (queue.length === 0) return;
if (enemies.filter(e=>!e.dead).length >= MAX_ENEMIES) return;
spawnTimer -= dt;
if (spawnTimer > 0) return;
const [sc,sr] = SPAWN_CELLS[spawnIdx % 3];
const sx = sc*CELL+(CELL-TS)/2, sy = sr*CELL+(CELL-TS)/2;
let blocked = player.alive && rectsOverlap(sx,sy,TS,TS,player.x,player.y,TS,TS);
for (const e of enemies) if (!e.dead && rectsOverlap(sx,sy,TS,TS,e.x,e.y,TS,TS)) blocked = true;
if (blocked){ spawnTimer = 0.5; spawnIdx++; return; }
const type = queue.shift(), st = STATS[type];
enemies.push({
x:sx, y:sy, dir:2, type,
speed: st.speed*(1+Math.min(0.3,(level-1)*0.03)),
hp:st.hp, score:st.score, bulletSpeed:st.bulletSpeed,
power:st.power, fireRate:st.fireRate,
bonus: spawnCount % 4 === 3,
spawning:true, spawnT:SPAWN_TIME,
aiT:Math.random()*1.5, cool:1+Math.random(),
anim:0, flash:0, bullet:null, dead:false
});
spawnIdx++; spawnCount++;
spawnTimer = Math.max(1.6, 3.2-level*0.15);
SFX.spawn();
}
function updateEnemies(dt){
for (const e of enemies){
if (e.dead) continue;
if (e.flash > 0) e.flash -= dt;
if (e.spawning){ e.spawnT -= dt; if (e.spawnT<=0) e.spawning=false; continue; }
e.cool -= dt;
if (freezeTimer > 0) continue;
e.aiT -= dt;
if (e.aiT <= 0){ chooseDirection(e); e.aiT = 0.8+Math.random()*2; }
if (moveTank(e, dt)) e.anim += e.speed*dt;
else { chooseDirection(e); e.aiT = 0.6+Math.random()*1.6; } // 撞墙才换向
if (e.cool <= 0 && (!e.bullet || e.bullet.dead)){
if (Math.random() < dt*e.fireRate*1.3){
spawnBullet(e,false);
e.cool = 0.5+Math.random()*0.8;
}
}
}
enemies = enemies.filter(e=>!e.dead);
}
function killEnemy(e){
e.dead = true;
addExplosion(e.x+TS/2, e.y+TS/2, true);
SFX.boom();
score += e.score;
if (navigator.vibrate) navigator.vibrate(30);
if (e.bonus) spawnPowerup();
}
/* ================= 子弹 ================= */
function spawnBullet(tank,isPlayer){
bullets.push({
x: tank.x+TS/2 + DX[tank.dir]*(TS/2+4),
y: tank.y+TS/2 + DY[tank.dir]*(TS/2+4),
dir: tank.dir,
speed: isPlayer ? (player.level>=3?380:320) : tank.bulletSpeed,
isPlayer,
power: isPlayer ? (player.level>=3?2:1) : (tank.power||1),
dead:false, owner:tank
});
if (!isPlayer) tank.bullet = bullets[bullets.length-1];
SFX.shoot();
}
function bulletHitsBrick(b,r,c){
const cell = grid[r][c];
const h = CELL/2, qx = c*CELL, qy = r*CELL;
let hit = false;
for (const [dx,dy,bit] of QUADS){
if ((cell.q & bit) && rectsOverlap(b.x-5,b.y-5,10,10, qx+dx*h,qy+dy*h,h,h)){
cell.q &= ~bit; hit = true;
}
}
return hit;
}
function destroyEagle(){
eagleAlive = false;
addExplosion(6*CELL+CELL/2, 12*CELL+CELL/2, true);
SFX.bigboom();
if (navigator.vibrate) navigator.vibrate([100,60,100,60,100]);
gameOverT = 1.8;
}
function updateBullets(dt){
for (const b of bullets){
if (b.dead) continue;
b.x += DX[b.dir]*b.speed*dt;
b.y += DY[b.dir]*b.speed*dt;
if (b.x<0 || b.y<0 || b.x>FIELD || b.y>FIELD){
b.dead = true; addExplosion(b.x,b.y,false); SFX.hit(); continue;
}
const bx=b.x-5, by=b.y-5;
let c0=Math.floor(bx/CELL), c1=Math.floor((bx+9.99)/CELL);
let r0=Math.floor(by/CELL), r1=Math.floor((by+9.99)/CELL);
if (c0<0) c0=0; if (c1>=COLS) c1=COLS-1; // 命中盒越界时钳制到合法网格
if (r0<0) r0=0; if (r1>=ROWS) r1=ROWS-1;
let hitWall = false;
for (let r=r0; r<=r1 && !hitWall; r++) for (let c=c0; c<=c1; c++){
const cell = grid[r][c];
if (cell.t===T_BRICK && cell.q){
if (bulletHitsBrick(b,r,c)) hitWall = true;
} else if (cell.t===T_STEEL){
if (rectsOverlap(bx,by,10,10,c*CELL,r*CELL,CELL,CELL)){
if (b.power>=2){ cell.t=T_EMPTY; addExplosion(c*CELL+CELL/2,r*CELL+CELL/2,false); }
hitWall = true;
}
}
}
if (hitWall){ b.dead=true; addExplosion(b.x,b.y,false); SFX.hit(); continue; }
if (eagleAlive && rectsOverlap(bx,by,10,10,6*CELL,12*CELL,CELL,CELL)){
b.dead = true; destroyEagle(); continue;
}
if (b.isPlayer){
for (const e of enemies){
if (e.spawning || e.dead) continue;
if (rectsOverlap(bx,by,10,10,e.x,e.y,TS,TS)){
b.dead = true; e.hp--; e.flash = 0.15;
if (e.hp<=0) killEnemy(e); else SFX.hit();
break;
}
}
} else if (player.alive && !player.spawning &&
rectsOverlap(bx,by,10,10,player.x,player.y,TS,TS)){
b.dead = true;
if (shieldTimer <= 0) hitPlayer(); else SFX.hit();
}
}
for (const a of bullets) for (const b of bullets){
if (a.isPlayer !== b.isPlayer && !a.dead && !b.dead &&
Math.abs(a.x-b.x)<10 && Math.abs(a.y-b.y)<10){ a.dead = b.dead = true; }
}
bullets = bullets.filter(b=>!b.dead);
}
/* ================= 道具 ================= */
function spawnPowerup(){
const types = ['star','shield','bomb','timer','shovel','life'];
const type = types[(Math.random()*types.length)|0];
for (let tries=0; tries<200; tries++){
const r = (Math.random()*ROWS)|0, c = (Math.random()*COLS)|0;
if (grid[r][c].t !== T_EMPTY) continue;
if (r===12 && c>=5 && c<=7) continue;
if (r===0 && (c===0||c===6||c===12)) continue;
powerups = [{type, x:c*CELL+6, y:r*CELL+6, t:16}];
return;
}
}
function applyPowerup(p){
SFX.power();
if (navigator.vibrate) navigator.vibrate(40);
switch(p.type){
case 'star':
if (player.level < 3) player.level++;
setBanner('火力升级!', '#ffdc40'); break;
case 'shield': shieldTimer = 12; setBanner('无敌护盾!', '#7ac8ff'); break;
case 'bomb':
for (const e of enemies) if (!e.dead && !e.spawning) killEnemy(e);
setBanner('全灭!', '#ff8080'); break;
case 'timer': freezeTimer = 10; setBanner('敌人冻结!', '#7ac8ff'); break;
case 'shovel':
setFortress(T_STEEL); shovelTimer = 16; setBanner('基地加固!', '#d0d0d0'); break;
case 'life': lives++; setBanner('生命 +1!', '#7af07a'); break;
}
}
function updatePowerups(dt){
for (const p of powerups){
p.t -= dt;
if (player.alive && !player.spawning &&
rectsOverlap(player.x,player.y,TS,TS,p.x,p.y,PU_SIZE,PU_SIZE)){
applyPowerup(p); p.t = 0;
}
}
powerups = powerups.filter(p=>p.t>0);
}
/* ================= 主更新 ================= */
function updateEffects(dt){
for (const e of explosions) e.t += dt;
explosions = explosions.filter(e=>e.t<e.dur);
if (bannerT > 0) bannerT -= dt;
}
function update(dt){
animTime += dt;
if (bannerT > 0) bannerT -= dt;
if (gameOverT > 0){
gameOverT -= dt;
updateEffects(dt);
if (gameOverT <= 0) gameOver();
return;
}
if (shieldTimer > 0) shieldTimer -= dt;
if (freezeTimer > 0) freezeTimer -= dt;
if (shovelTimer > 0){ shovelTimer -= dt; if (shovelTimer<=0) setFortress(T_BRICK); }
updatePlayer(dt);
updateSpawning(dt);
updateEnemies(dt);
updateBullets(dt);
updatePowerups(dt);
updateEffects(dt);
if (eagleAlive && queue.length===0 && enemies.length===0){
state = 'clear'; clearT = 3; score += 500; SFX.power();
}
}
function gameOver(){
state = 'over';
const isRecord = score > best;
if (isRecord){ best = score; try{ localStorage.setItem('tankBest', best); }catch(e){} }
$('finalScore').textContent = score;
$('newRecord').style.display = isRecord ? 'block' : 'none';
showScreen('overScreen');
SFX.gameover();
}
/* ================= 绘制 ================= */
function drawTankShape(cx,cy,dir,size,pal,frame){
ctx.save();
ctx.translate(cx,cy);
ctx.rotate(dir*Math.PI/2);
const s=size, h=s/2, tw=Math.max(4,s*0.2);
ctx.fillStyle = pal.track;
ctx.fillRect(-h,-h,tw,s); ctx.fillRect(h-tw,-h,tw,s);
ctx.fillStyle = pal.trackDark;
const step = s/5;
let off = frame % step; if (off<0) off += step;
for (let y=-h+off-step; y<h; y+=step){
ctx.fillRect(-h,y,tw,step*0.45); ctx.fillRect(h-tw,y,tw,step*0.45);
}
ctx.fillStyle = pal.dark;
ctx.fillRect(-h+tw-1,-h*0.8,s-tw*2+2,s*0.85);
ctx.fillStyle = pal.body;
ctx.fillRect(-h+tw,-h*0.75,s-tw*2,s*0.78);
ctx.fillStyle = pal.light;
ctx.fillRect(-h+tw,-h*0.75,s-tw*2,s*0.1);
ctx.fillStyle = pal.dark;
ctx.fillRect(-s*0.07,-h-1,s*0.14,h*0.9);
ctx.fillStyle = pal.body;
ctx.fillRect(-s*0.045,-h,s*0.09,h*0.85);
ctx.fillStyle = pal.body;
ctx.beginPath(); ctx.arc(0,s*0.05,s*0.24,0,Math.PI*2); ctx.fill();
ctx.strokeStyle = pal.dark; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(0,s*0.05,s*0.24,0,Math.PI*2); ctx.stroke();
ctx.fillStyle = pal.light;
ctx.beginPath(); ctx.arc(-s*0.06,0,s*0.09,0,Math.PI*2); ctx.fill();
ctx.restore();
}
function drawSpawnAnim(cx,cy,t){
const ph = Math.floor(t*10)%3;
const r = TS/2*[0.5,0.8,1.05][ph];
ctx.strokeStyle = ph===1 ? '#6af' : '#fff';
ctx.lineWidth = 3;
ctx.beginPath();
for (let i=0;i<8;i++){
const ang = i*Math.PI/4 + Math.PI/8;
const rr = i%2===0 ? r : r*0.5;
const x = cx+Math.cos(ang)*rr, y = cy+Math.sin(ang)*rr;
i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
}
ctx.closePath(); ctx.stroke();
}
function drawTank(t,isPlayer){
if (t.spawning){ drawSpawnAnim(t.x+TS/2,t.y+TS/2,t.spawnT); return; }
let pal;
if (isPlayer) pal = PALS.player;
else {
pal = (t.bonus && Math.floor(animTime*6)%2===0) ? PALS.bonus : PALS[t.type];
if (t.type==='armor' && t.hp<4) pal = Object.assign({},pal,{body:ARMOR_COLORS[t.hp]});
}
drawTankShape(t.x+TS/2, t.y+TS/2, t.dir, TS, pal, t.anim/6);
if (t.flash > 0){
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillRect(t.x,t.y,TS,TS);
}
}
function drawBrickQuad(x,y){
const h = CELL/2;
ctx.fillStyle = '#5a2808'; ctx.fillRect(x,y,h,h);
ctx.fillStyle = '#b85a18';
const rh = 6;
for (let i=0;i<4;i++){
const by = y+i*rh;
if (i%2===0){
ctx.fillRect(x+0.5,by+0.5,11,rh-1);
ctx.fillRect(x+12.5,by+0.5,11,rh-1);
} else {
ctx.fillRect(x+0.5,by+0.5,5,rh-1);
ctx.fillRect(x+6.5,by+0.5,11,rh-1);
ctx.fillRect(x+18.5,by+0.5,5,rh-1);
}
}
}
function drawSteel(x,y){
const s=CELL, h=s/2;
ctx.fillStyle = '#7a7a7a'; ctx.fillRect(x,y,s,s);
for (const [dx,dy] of [[0,0],[1,0],[0,1],[1,1]]){
const bx=x+dx*h, by=y+dy*h;
ctx.fillStyle = '#e0e0e0';
ctx.fillRect(bx+1,by+1,h-2,3); ctx.fillRect(bx+1,by+1,3,h-2);
ctx.fillStyle = '#4a4a4a';
ctx.fillRect(bx+1,by+h-4,h-2,3); ctx.fillRect(bx+h-4,by+1,3,h-2);
ctx.fillStyle = '#a8a8a8';
ctx.fillRect(bx+5,by+5,h-10,h-10);
}
}
function drawWater(x,y){
ctx.fillStyle = '#1a3a9a'; ctx.fillRect(x,y,CELL,CELL);
ctx.fillStyle = '#3a6ae0';
const frame = Math.floor(animTime*2)%2;
for (let i=0;i<4;i++){
const wy = y+4+i*12+(frame?3:0);
for (let j=0;j<3;j++){
const wx = x+2+j*15+((i+frame)%2?6:0);
ctx.fillRect(wx,wy,8,3);
}
}
}
function drawIce(x,y){
ctx.fillStyle = '#b8d8f0'; ctx.fillRect(x,y,CELL,CELL);
ctx.fillStyle = '#e8f6ff';
ctx.fillRect(x+4,y+6,16,4); ctx.fillRect(x+24,y+20,18,4); ctx.fillRect(x+8,y+34,14,4);
ctx.strokeStyle = '#8ab8d8'; ctx.strokeRect(x+0.5,y+0.5,CELL-1,CELL-1);
}
function drawTree(x,y){
const colors = ['#0e7a1e','#1a9a2e','#0a5a14'];
for (let dy=0;dy<3;dy++) for (let dx=0;dx<3;dx++){
ctx.fillStyle = colors[(dx+dy)%3];
ctx.beginPath();
ctx.arc(x+6+dx*18, y+6+dy*18, 8, 0, Math.PI*2);
ctx.fill();
}
}
function drawTiles(trees){
const h = CELL/2;
for (let r=0;r<ROWS;r++) for (let c=0;c<COLS;c++){
const cell = grid[r][c], x=c*CELL, y=r*CELL;
if (trees){ if (cell.t===T_TREE) drawTree(x,y); continue; }
if (cell.t===T_BRICK){
for (const [dx,dy,bit] of QUADS) if (cell.q & bit) drawBrickQuad(x+dx*h,y+dy*h);
}
else if (cell.t===T_STEEL) drawSteel(x,y);
else if (cell.t===T_WATER) drawWater(x,y);
else if (cell.t===T_ICE) drawIce(x,y);
}
}
function drawEagle(){
const x=6*CELL, y=12*CELL;
if (!eagleAlive){
ctx.fillStyle = '#4a4a4a';
ctx.beginPath();
ctx.moveTo(x+4,y+44); ctx.lineTo(x+14,y+26); ctx.lineTo(x+24,y+38);
ctx.lineTo(x+32,y+22); ctx.lineTo(x+44,y+44); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#6a6a6a'; ctx.fillRect(x+6,y+40,36,6);
return;
}
ctx.fillStyle = '#9a9a9a'; ctx.fillRect(x+6,y+38,36,8);
ctx.fillStyle = '#c0c0c0'; ctx.fillRect(x+10,y+34,28,5);
ctx.fillStyle = '#4a3018';
ctx.beginPath(); ctx.moveTo(x+24,y+18); ctx.lineTo(x+3,y+6); ctx.lineTo(x+10,y+26); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(x+24,y+18); ctx.lineTo(x+45,y+6); ctx.lineTo(x+38,y+26); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#6a4a22';
ctx.beginPath(); ctx.ellipse(x+24,y+24,7,11,0,0,Math.PI*2); ctx.fill();
ctx.fillStyle = '#e8e8e8';
ctx.beginPath(); ctx.arc(x+24,y+12,4.5,0,Math.PI*2); ctx.fill();
ctx.fillStyle = '#e8a020';
ctx.beginPath(); ctx.moveTo(x+24,y+11); ctx.lineTo(x+30,y+13); ctx.lineTo(x+24,y+15); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#000'; ctx.fillRect(x+23,y+10,2,2);
}
function drawPowerupIcon(type,cx,cy,color){
ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 2;
switch(type){
case 'star':
ctx.beginPath();
for (let i=0;i<10;i++){
const ang = -Math.PI/2+i*Math.PI/5, r = i%2===0?10:4.5;
const x = cx+Math.cos(ang)*r, y = cy+Math.sin(ang)*r;
i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
}
ctx.closePath(); ctx.fill(); break;
case 'shield':
ctx.beginPath();
ctx.moveTo(cx-8,cy-9); ctx.lineTo(cx+8,cy-9); ctx.lineTo(cx+8,cy+1);
ctx.lineTo(cx,cy+10); ctx.lineTo(cx-8,cy+1); ctx.closePath(); ctx.fill(); break;
case 'bomb':
ctx.beginPath(); ctx.arc(cx,cy+2,7,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.moveTo(cx+3,cy-4); ctx.lineTo(cx+7,cy-9); ctx.stroke();
ctx.fillStyle = '#ffd040';
ctx.beginPath(); ctx.arc(cx+8,cy-10,2.5,0,Math.PI*2); ctx.fill(); break;
case 'timer':
ctx.beginPath(); ctx.arc(cx,cy+1,8,0,Math.PI*2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx,cy+1); ctx.lineTo(cx,cy-4);
ctx.moveTo(cx,cy+1); ctx.lineTo(cx+4,cy+1); ctx.stroke();
ctx.fillRect(cx-3,cy-11,6,3); break;
case 'shovel':
ctx.fillRect(cx-1.5,cy-11,3,9);
ctx.beginPath();
ctx.moveTo(cx-6,cy-2); ctx.lineTo(cx+6,cy-2); ctx.lineTo(cx+5,cy+5);
ctx.lineTo(cx,cy+10); ctx.lineTo(cx-5,cy+5); ctx.closePath(); ctx.fill(); break;
case 'life':
ctx.beginPath();
ctx.moveTo(cx,cy+7);
ctx.bezierCurveTo(cx-11,cy-1,cx-7,cy-10,cx,cy-4);
ctx.bezierCurveTo(cx+7,cy-10,cx+11,cy-1,cx,cy+7);
ctx.fill(); break;
}
}
function drawPowerups(){
for (const p of powerups){
if (p.t<3 && Math.floor(animTime*8)%2===0) continue;
const flash = Math.floor(animTime*5)%2===0;
ctx.fillStyle = flash ? '#c8c8c8' : '#d03030';
ctx.fillRect(p.x,p.y,PU_SIZE,PU_SIZE);
ctx.strokeStyle = '#303030'; ctx.lineWidth = 2;
ctx.strokeRect(p.x+1,p.y+1,PU_SIZE-2,PU_SIZE-2);
drawPowerupIcon(p.type, p.x+PU_SIZE/2, p.y+PU_SIZE/2, flash?'#c02020':'#fff');
}
}
function drawBullets(){
for (const b of bullets){
ctx.fillStyle = b.isPlayer ? '#ffe860' : '#f0f0f0';
ctx.fillRect(b.x-3,b.y-3,6,6);
ctx.fillStyle = b.isPlayer ? '#c89020' : '#a0a0a0';
ctx.fillRect(b.x-1.5,b.y-1.5,3,3);
}
}
function circle(x,y,r){ ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill(); }
function drawExplosions(){
for (const e of explosions){
const p = e.t/e.dur;
const grow = p<0.4 ? p/0.4 : 1;
const fade = p>0.7 ? 1-(p-0.7)/0.3 : 1;
const R = (e.big?24:12)*grow;
ctx.globalAlpha = fade;
ctx.fillStyle = '#f05020'; circle(e.x,e.y,R);
ctx.fillStyle = '#f8a030'; circle(e.x,e.y,R*0.7);
ctx.fillStyle = '#fff0a0'; circle(e.x,e.y,R*0.4);
ctx.globalAlpha = 1;
}
}
function drawShield(){
const cx = player.x+TS/2, cy = player.y+TS/2;
const blink = shieldTimer<3 && Math.floor(animTime*10)%2===0;
ctx.strokeStyle = blink ? '#fff' : '#6af';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(cx,cy,TS/2+4+Math.sin(animTime*10)*1.5,0,Math.PI*2); ctx.stroke();
ctx.strokeStyle = 'rgba(120,180,255,0.5)';
ctx.beginPath(); ctx.arc(cx,cy,TS/2+8,0,Math.PI*2); ctx.stroke();
}
function drawBanner(){
let text=null, color='#ffdc40';
if (bannerT > 0){ text = bannerText; color = bannerColor; }
else if (state === 'clear'){ text = '关卡通过!+500'; color = '#7af07a'; }
if (!text) return;
ctx.fillStyle = 'rgba(0,0,0,0.85)';
ctx.fillRect(0, FIELD/2-34, FIELD, 68);
ctx.fillStyle = color;
ctx.font = 'bold 30px "Courier New",monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, FIELD/2, FIELD/2);
ctx.textBaseline = 'alphabetic';
}
function drawMiniTank(x,y,color){
ctx.fillStyle = color;
ctx.fillRect(x,y,4,13); ctx.fillRect(x+10,y,4,13);
ctx.fillRect(x+4,y+3,6,8); ctx.fillRect(x+6,y-2,2,6);
}
function drawFlag(x,y){
ctx.fillStyle = '#d8d8d8'; ctx.fillRect(x,y,2,16);
ctx.fillStyle = '#e03030';
ctx.beginPath(); ctx.moveTo(x+2,y); ctx.lineTo(x+14,y+4); ctx.lineTo(x+2,y+8);
ctx.closePath(); ctx.fill();
}
function drawPanel(){
const px = FIELD;
ctx.fillStyle = '#5f5f5f'; ctx.fillRect(px,0,PANEL_W,CVH);
ctx.fillStyle = '#4a4a4a'; ctx.fillRect(px,0,2,CVH);
ctx.textAlign = 'center';
ctx.fillStyle = '#fff';
ctx.font = 'bold 13px "Courier New",monospace';
ctx.fillText('SCORE', px+PANEL_W/2, 26);
ctx.font = 'bold 15px "Courier New",monospace';
ctx.fillText(String(score).padStart(6,'0'), px+PANEL_W/2, 46);
ctx.fillStyle = '#ffd040';
ctx.font = 'bold 11px "Courier New",monospace';
ctx.fillText('BEST '+String(best).padStart(6,'0'), px+PANEL_W/2, 66);
ctx.fillStyle = '#3f3f3f'; ctx.fillRect(px+10,78,PANEL_W-20,2);
ctx.fillStyle = '#fff'; ctx.font = 'bold 12px sans-serif';
ctx.fillText('敌 军', px+PANEL_W/2, 98);
const rem = queue.length + enemies.filter(e=>!e.dead).length;
for (let i=0;i<rem;i++) drawMiniTank(px+22+(i%2)*26, 108+((i/2)|0)*17, '#111');
ctx.fillStyle = '#3f3f3f'; ctx.fillRect(px+10,288,PANEL_W-20,2);
ctx.fillStyle = '#fff'; ctx.font = 'bold 12px sans-serif';
ctx.fillText('玩 家', px+PANEL_W/2, 308);
drawMiniTank(px+22,318,'#e0c422');
ctx.textAlign = 'left';
ctx.fillStyle = '#fff'; ctx.font = 'bold 14px "Courier New",monospace';
ctx.fillText('×'+Math.max(0,lives), px+44, 331);
drawFlag(px+24,346);
ctx.fillText(String(level), px+44, 360);
ctx.fillStyle = '#3f3f3f'; ctx.fillRect(px+10,376,PANEL_W-20,2);
const legends = [['star','升级火力'],['shield','无敌护盾'],['bomb','全灭敌人'],
['timer','冻结敌人'],['shovel','加固基地'],['life','生命+1']];
ctx.font = '11px sans-serif';
legends.forEach((lg,i)=>{
const ly = 396+i*34;
drawPowerupIcon(lg[0], px+24, ly+6, '#ffd040');
ctx.fillStyle = '#e8e8e8'; ctx.textAlign = 'left';
ctx.fillText(lg[1], px+42, ly+10);
});
}
function render(){
ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0,0,CVW,CVH);
ctx.fillStyle = '#000'; ctx.fillRect(0,0,FIELD,FIELD);
drawTiles(false);
drawEagle();
drawPowerups();
if (player && player.alive) drawTank(player,true);
for (const e of enemies) drawTank(e,false);
drawBullets();
drawTiles(true);
drawExplosions();
if (shieldTimer>0 && player && player.alive) drawShield();
drawBanner();
drawPanel();
}
/* ================= 输入 ================= */
const keyInput = {up:false,down:false,left:false,right:false,fire:false};
const touchInput = {up:false,down:false,left:false,right:false,fire:false};
function getInput(){
return {
up: keyInput.up||touchInput.up, down: keyInput.down||touchInput.down,
left: keyInput.left||touchInput.left, right: keyInput.right||touchInput.right,
fire: keyInput.fire||touchInput.fire
};
}
const KEYMAP = {ArrowUp:'up',KeyW:'up',ArrowDown:'down',KeyS:'down',
ArrowLeft:'left',KeyA:'left',ArrowRight:'right',KeyD:'right',Space:'fire',KeyJ:'fire'};
window.addEventListener('keydown', e=>{
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
initAudio();
if (e.code === 'Enter'){
if (state==='menu' || state==='over') startGame();
else if (state==='paused') togglePause();
return;
}
if (e.code==='KeyP' || e.code==='Escape'){
if (state==='playing' || state==='paused') togglePause();
return;
}
const k = KEYMAP[e.code];
if (k) keyInput[k] = true;
});
window.addEventListener('keyup', e=>{
const k = KEYMAP[e.code];
if (k) keyInput[k] = false;
});
const dpad = $('dpad');
function dpadHandle(touches){
touchInput.up = touchInput.down = touchInput.left = touchInput.right = false;
for (const t of touches){
const el = document.elementFromPoint(t.clientX, t.clientY);
if (el && el.dataset && el.dataset.dir){
touchInput[el.dataset.dir] = true;
el.classList.add('on');
}
}
for (const el of document.querySelectorAll('.dbtn')){
let on = false;
for (const t of touches){
const p = document.elementFromPoint(t.clientX,t.clientY);
if (p === el) on = true;
}
el.classList.toggle('on', on);
}
}
['touchstart','touchmove','touchend','touchcancel'].forEach(ev=>{
dpad.addEventListener(ev, e=>{ e.preventDefault(); initAudio(); dpadHandle(e.touches); }, {passive:false});
});
const fireBtn = $('firebtn');
fireBtn.addEventListener('touchstart', e=>{ e.preventDefault(); initAudio(); touchInput.fire=true; fireBtn.classList.add('on'); }, {passive:false});
['touchend','touchcancel'].forEach(ev=>fireBtn.addEventListener(ev, e=>{ e.preventDefault(); touchInput.fire=false; fireBtn.classList.remove('on'); }, {passive:false}));
document.addEventListener('touchmove', e=>e.preventDefault(), {passive:false});
document.addEventListener('contextmenu', e=>e.preventDefault());
document.addEventListener('visibilitychange', ()=>{ if (document.hidden && state==='playing') togglePause(); });
/* ================= UI ================= */
function showScreen(id){
const ov = $('overlay');
if (!id){ ov.classList.add('hidden'); return; }
ov.classList.remove('hidden');
for (const s of ['menuScreen','pauseScreen','overScreen'])
$(s).classList.toggle('active', s===id);
}
function togglePause(){
if (state==='playing'){ state='paused'; showScreen('pauseScreen'); }
else if (state==='paused'){ state='playing'; showScreen(null); }
}
$('startBtn').addEventListener('click', startGame);
$('restartBtn').addEventListener('click', startGame);
$('resumeBtn').addEventListener('click', togglePause);
$('pauseBtn').addEventListener('click', ()=>{ if (state==='playing'||state==='paused') togglePause(); });
$('muteBtn').addEventListener('click', ()=>{ muted=!muted; $('muteBtn').textContent = muted?'🔇':''; });
/* ================= 自适应尺寸 ================= */
const isTouch = matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
if (isTouch) $('controls').classList.add('show');
function resize(){
const landscapeOverlay = isTouch && window.innerWidth > window.innerHeight;
const ctrlH = (isTouch && !landscapeOverlay) ? $('controls').offsetHeight+8 : 0;
const availW = window.innerWidth-4;
const availH = window.innerHeight-ctrlH-4;
const scale = Math.min(availW/CVW, availH/CVH);
const w = Math.max(220, Math.floor(CVW*scale));
const h = Math.floor(w*CVH/CVW);
canvas.style.width = w+'px'; canvas.style.height = h+'px';
$('canvas-wrap').style.width = w+'px'; $('canvas-wrap').style.height = h+'px';
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', ()=>setTimeout(resize,100));
/* ================= 启动 ================= */
(function init(){
const dpr = Math.min(2, window.devicePixelRatio||1);
canvas.width = CVW*dpr; canvas.height = CVH*dpr;
ctx.setTransform(dpr,0,0,dpr,0,0);
loadMap(0);
player = makePlayer();
$('bestScore').textContent = best;
$('newRecord').style.display = 'none';
showScreen('menuScreen');
resize();
let last = performance.now();
function loop(now){
const dt = Math.min(0.05, (now-last)/1000);
last = now;
try {
if (state === 'playing') update(dt);
else if (state === 'clear'){
animTime += dt; clearT -= dt; updateEffects(dt);
if (clearT <= 0){ level++; startLevel(); }
}
render();
} catch(err){
console.error('game loop error:', err); // 记录错误但不断开 rAF 链
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();
</script>
</body>
</html>