体验了一下AI的游戏开发能力,模仿以前的韩国休闲游戏泡泡堂,开发了一个《水弹泡泡岛》游戏,游戏一共10个关卡,每个关卡增加1个怪物,玩家需要炸死所有怪物才可以通过“水门”进入下一关,支持电脑和手机端,在线体验地址和源码如下,欢迎体验。
在线体验:https://zjkpi.com/paopao.html

verify.js
const fs=require('fs');const html=fs.readFileSync(process.argv[2],'utf8');const m=html.match(/<script>([\s\S]*?)<\/script>/g);if(!m||!m.length){console.error('no script block');process.exit(1);}const code=m.map(s=>s.replace(/<\/?script>/g,'')).join('\n');new Function(code);console.log('JS syntax OK');const mapsEval=code.match(/const MAPS=(\[[\s\S]*?\]);\n/);if(!mapsEval){console.error('MAPS not found');process.exit(1);}const MAPS=eval(mapsEval[1]);
// 模型:'.' 'S' 'M' 可走(M=怪物出生点);'X'(内部) 'B' 可炸穿;'~' 实心水墙与最外圈 X 不可炸(当永久墙)// 校验:水门(B)、怪物出生点(M) 都能从玩家出生点经"可炸穿"路径到达let allOk=true;for(let i=0;i<MAPS.length;i++){const M=MAPS[i];let spawns=0,gates=0,mons=0,badRows=0;for(let r=0;r<M.h;r++){if(M.rows[r].length!==M.w){ badRows++; console.log(`map ${i+1} row ${r} width ${M.rows[r].length} != ${M.w}`); }for(const ch of M.rows[r]){if(!'X~BSM.'.includes(ch)){ console.log(`map ${i+1} row ${r} bad char "${ch}"`); allOk=false; }if(ch==='S')spawns++;if(ch==='B')gates++;if(ch==='M')mons++;}}let s=null;M.rows.forEach((row,r)=>[...row].forEach((ch,c)=>{ if(ch==='S') s=[c,r]; }));const seen=Array.from({length:M.h},()=>Array(M.w).fill(false));const q=[s]; seen[s[1]][s[0]]=true;while(q.length){const [c,r]=q.shift();for(const [dc,dr] of [[1,0],[-1,0],[0,1],[0,-1]]){const nc=c+dc,nr=r+dr;if(nc<0||nr<0||nc>=M.w||nr>=M.h) continue;if(seen[nr][nc]) continue;const ch=M.rows[nr][nc];const border=(nr===0||nr===M.h-1||nc===0||nc===M.w-1);const pass = ch==='.'||ch==='S'||ch==='M' || ch==='B' || (ch==='X'&&!border);if(!pass) continue;seen[nr][nc]=true; q.push([nc,nr]);}}let rg=0,rm=0;M.rows.forEach((row,r)=>[...row].forEach((ch,c)=>{if(ch==='B'&&seen[r][c]) rg++;if(ch==='M'&&seen[r][c]) rm++;}));const ok=spawns===1&&gates>0&&rg===gates&&mons>0&&rm===mons&&badRows===0;console.log(`map ${i+1} ${M.name}: ${M.w}x${M.h} spawns=${spawns} gates=${gates}(ok=${rg}) monSpots=${mons}(ok=${rm}) ${ok?'OK':'FAIL'}`);if(!ok) allOk=false;}console.log(allOk?`All ${MAPS.length} maps valid (every gate & monster-spawn reachable)`:'FIX NEEDED');process.exit(allOk?0:1);
paopao.html
<!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, viewport-fit=cover"><meta name="theme-color" content="#081524"><title>水弹泡泡岛 · 键盘 / 触屏闯关</title><style>* { margin:0; padding:0; box-sizing:border-box; user-select:none; -webkit-user-select:none; }html { background:#081524; }html,body { height:100%; overflow:hidden; } /* 锁死滚动,防下拉(配合 overscroll-behavior + JS 双保险) */body {background: radial-gradient(ellipse at 50% 30%, #10304d 0%, #081524 70%);display:flex; flex-direction:column; align-items:center;justify-content:center; justify-content:safe center;font-family:"Microsoft YaHei","PingFang SC",sans-serif; color:#eef6ff;user-select:none; overscroll-behavior:none; -webkit-tap-highlight-color:transparent;touch-action:none;}h1 { font-size:18px; letter-spacing:1px; margin:2px 0; color:#8fd0ff;text-shadow:0 0 10px rgba(64,158,255,.45); }#info { display:flex; gap:6px; flex-wrap:wrap; justify-content:center; margin:4px 0; }#info span {background:rgba(20,52,84,.75); border:1px solid rgba(96,168,230,.35);border-radius:6px; padding:2px 8px; font-size:12px; min-width:60px; text-align:center;}#stage { position:relative; }canvas {display:block; border-radius:12px;box-shadow:0 0 0 3px rgba(70,140,210,.35), 0 14px 44px rgba(0,0,0,.55);background:#0a1f38;width:min(560px, calc(100vw - 20px)); height:auto; aspect-ratio:1/1;}#stage{ touch-action:none; }.keys { margin-top:6px; font-size:13px; line-height:1.6; text-align:center; max-width:900px; color:#eaf5ff; }.k { display:inline-block; min-width:18px; text-align:center; background:#1c3a5c;border:1px solid #3d6a99; border-bottom-width:3px; border-radius:4px;padding:0 6px; margin:0 2px; color:#cfeaff; font-size:11px; }.dot { display:inline-block; width:10px; height:10px; border-radius:3px; vertical-align:middle; margin:0 4px 0 10px; }.touch-hints{ display:none; }/* ==== 手机端触控 UI ==== */#pausebtn{ display:none; position:absolute; top:3px; right:2px; z-index:5;width:44px; height:44px; border-radius:12px;background:rgba(20,52,84,.78); border:1px solid rgba(96,168,230,.5);color:#bfe3ff; font-size:18px; line-height:1;touch-action:none; -webkit-tap-highlight-color:transparent; user-select:none; -webkit-touch-callout:none; }#pausebtn.pressed{ background:rgba(46,100,160,.95); }#controls{ display:none; align-items:center; justify-content:space-between;width:min(560px, calc(100vw - 20px)); margin:10px auto 2px; touch-action:none; }#controls button{ -webkit-touch-callout:none; }#joy{ position:relative; width:124px; height:124px; border-radius:50%;background:radial-gradient(circle at 50% 42%, rgba(34,78,124,.9), rgba(16,42,70,.9) 70%);border:1px solid rgba(96,168,230,.55);box-shadow:inset 0 0 14px rgba(0,0,0,.45), 0 4px 12px rgba(0,0,0,.35);touch-action:none; -webkit-tap-highlight-color:transparent; user-select:none; -webkit-touch-callout:none; }#joy-knob{ position:absolute; left:50%; top:50%; width:56px; height:56px; margin:-28px 0 0 -28px;border-radius:50%;background:radial-gradient(circle at 35% 30%, rgba(235,250,255,.95), rgba(140,205,255,.9) 45%, rgba(50,130,225,.9));border:2px solid rgba(20,80,160,.7);box-shadow:0 3px 10px rgba(0,0,0,.45);display:flex; align-items:center; justify-content:center;pointer-events:none; will-change:transform; }#joy-knob::after{ content:""; position:absolute; width:18px; height:18px; border-radius:50%;background:rgba(255,255,255,.55); box-shadow:0 0 8px rgba(255,255,255,.6); }#joy.active{ background:radial-gradient(circle at 50% 42%, rgba(46,100,160,.95), rgba(18,48,82,.95) 70%); }#bombbtn{ width:78px; height:78px; border-radius:50%;background:radial-gradient(circle at 35% 30%, rgba(230,250,255,.95), rgba(120,195,255,.9) 45%, rgba(40,120,220,.9));border:2px solid rgba(20,80,160,.7); font-size:30px; line-height:1;box-shadow:0 6px 18px rgba(0,0,0,.4);touch-action:none; -webkit-tap-highlight-color:transparent; user-select:none; -webkit-touch-callout:none; }#bombbtn.pressed{ filter:brightness(1.3); }@media (pointer:coarse), (max-width:700px){#pausebtn{ display:block; }#controls{ display:flex; }.kb-hints{ display:none !important; }.touch-hints{ display:block !important; }}</style></head><body><h1>💦 水弹泡泡岛</h1><div id="info"><span id="iv-level">关卡 1</span><span id="iv-time">时间 120</span><span id="iv-life">❤️ 3</span><span id="iv-score">得分 0</span><span id="iv-bomb">水弹 1</span><span id="iv-mon">👾 0</span></div><div id="stage"><canvas id="cv" width="560" height="560"></canvas><button id="pausebtn" type="button" aria-label="暂停/继续">⏸</button></div><div id="controls"><div id="joy" aria-label="移动摇杆"><div id="joy-knob"></div></div><button id="bombbtn" type="button" aria-label="放水弹">💧</button></div><div class="keys kb-hints"><span class="k">W</span><span class="k">A</span><span class="k">S</span><span class="k">D</span> / <span class="k">↑</span><span class="k">↓</span><span class="k">←</span><span class="k">→</span> 移动<span class="k">空格</span> 放水弹 · <span class="k">P</span> 暂停 · <span class="k">回车</span> 结束重开<br>水弹放置后十字爆炸 · 射线炸到第一块墙即停(不穿墙,一次只炸开一块墙体) · 紫色👾怪物开局固定刷出,会被墙/水弹挡住、会绕路追你<br>水弹可炸死怪物、炸开砖(实心水墙🌊炸不掉,当永久墙挡路) · 金色水门打光全部怪物后 变绿、可直接走进 · 过关=全怪已清 + 走进水门 · 怪碰到你扣 1 命并重生(进度保留)<br><span class="dot" style="background:#62e26b"></span>加速 <span class="dot" style="background:#59a8ff"></span>射程+1(最多3格) <span class="dot" style="background:#ff7ab8"></span>水弹+1(最多3颗) <span class="dot" style="background:#ff4d6d"></span>爱心+1命(最多5)</div><div class="keys touch-hints">左下 <b>摇杆</b> 移动(按住拖动只走直路、不斜走,偏得越多跑得越快) · 右下 <b>💧</b> 放水弹 · 右上 <b>⏸</b> 暂停/继续<br>点按画布:开始 · 继续 · 重新开始<br>水弹十字爆炸 · 射线只炸到第一块墙(不穿墙) · 砖可炸、🌊实心水墙炸不掉 · 水门清光全部怪物后变绿、可直接走进<br>紫色👾怪开局固定刷出、会被墙挡住并绕路追你 · 碰到你扣 1 命并重生(进度保留)</div><script>"use strict";/* ============ 常量 ============ */const TILE=40, W=560, H=560;const cv=document.getElementById("cv"), ctx=cv.getContext("2d");const TIME_LIMIT=120, BASE_SPEED=210, SPEED_MUL=1.4, MAX_RANGE=3, MAX_BOMBS=3, MAX_LIVES=5;const BOMB_ARM=2.4, BLAST_DUR=0.5;const MON_SPEED=88; // 小怪移动速度(px/s)const CHASE_RANGE=6; // 玩家在这格距离内→怪物转向追击const PU_TYPES=[{id:"speed", color:"#62e26b", label:"速"},{id:"range", color:"#59a8ff", label:"长"},{id:"bomb", color:"#ff7ab8", label:"多"},{id:"life", color:"#ff4d6d", label:"❤"}];// 道具掉落:常规三件套 + 少量爱心(命),让爱心偏稀有function pickPU(){if(Math.random()<0.18) return PU_TYPES[3]; // ~18% 爱心return PU_TYPES[(Math.random()*3)|0]; // 其余 速/长/多}/* 地图:X 砖 | ~ 实心水墙(不可炸,永久墙) | B 水门 | M 怪物出生点(可走空地) | S 玩家出生点 | . 水面 */const MAPS=[{name:"新手浅滩", w:13, h:13, rows:["XXXXXXXXXXXXX","X.........~~X","X..X..S.....X","XX....X.....X","X...X.......X","XX..........X","X...........X","X.XM......X.X","X...X...~..XX","X.B.........X","XX........M.X","X...........X","XXXXXXXXXXXXX"]},{name:"回水回廊", w:13, h:13, rows:["XXXXXXXXXXXXX","X.X.XX...X..X","X..X..S.....X","X.X~.X.X....X","X........X..X","XM.....X....X","X...X.XX..X.X","X.X....~.X.XX","X.......X...X","X....M....BXX","X...M.X.X...X","X...........X","XXXXXXXXXXXXX"]},{name:"双池迷境", w:13, h:13, rows:["XXXXXXXXXXXXX","XXX...X.....X","X....~S..X..X","X......X....X","XX......~.X.X","X.XXX.......X","XM.X..X.X...X","X.~X....M...X","XX..XXX.....X","X..B.X.X....X","XXXM...M....X","X...........X","XXXXXXXXXXXXX"]},{name:"漩涡回廊", w:13, h:13, rows:["XXXXXXXXXXXXX","X..........XX","XX..~.S...XXX","X.....X.....X","X........X..X","X..X.X..X...X","XXM.MX.XM~..X","X.XM.X..X..XX","XXX.XXX.....X","X..X.X.BX.X.X","XX...M.X....X","X...........X","XXXXXXXXXXXXX"]},{name:"大漩涡深处", w:13, h:13, rows:["XXXXXXXXXXXXX","X..X.X.X....X","XX~.X.S.~.X.X","XXX..~...X..X","X...........X","X....X.....XX","X..X..X.....X","X...M.X.X.XXX","X.....M.M..XX","X..M.X....M.X","X.B..MXX....X","X...........X","XXXXXXXXXXXXX"]},{name:"逆流暗礁", w:13, h:13, rows:["XXXXXXXXXXXXX","X......XX...X","X.X...S....XX","X...........X","X.....~.X...X","X...........X","XX..XX......X","XX..~..XM..MX","X.~..M.X..X.X","XX...X.M.M..X","X.BM...XM.~.X","X...........X","XXXXXXXXXXXXX"]},{name:"深渊回廊", w:13, h:13, rows:["XXXXXXXXXXXXX","X..X.~......X","X.~...S...~.X","X.X.......X.X","X.X.........X","XM.M....X..XX","X..X......X.X","X.M.........X","X...M.XM....X","X.B...M...X.X","XX.M.XX..M..X","X...........X","XXXXXXXXXXXXX"]},{name:"碎浪迷宫", w:13, h:13, rows:["XXXXXXXXXXXXX","X....X......X","X.....S.X...X","X...........X","X...........X","X..M......M.X","X.X.....X..MX","XX........XXX","XX.X...X..M.X","X..M..BM.X.MX","XMX.~XX.MX~.X","X...........X","XXXXXXXXXXXXX"]},{name:"漩涡核心", w:13, h:13, rows:["XXXXXXXXXXXXX","X.X.....XX..X","X.....S...X.X","X....X.X..X.X","X.~.........X","X.......XM..X","XX.X.....XX.X","XM.XM...M...X","X...~MB...MXX","X.....MXX.X~X","X..M.M..M.XXX","X...........X","XXXXXXXXXXXXX"]},{name:"无尽深潭", w:13, h:13, rows:["XXXXXXXXXXXXX","X....X...X.~X","X.....S.XX..X","X...........X","X..X..X.....X","XX.M.....M..X","XMX.M.X..XXXX","X~XXXXX.M...X","X....XM..X..X","X....MX...XMX","X.MXM..MXB.~X","X...........X","XXXXXXXXXXXXX"]},];
/* ============ 音效(WebAudio 合成) ============ */let AC=null;function ensureAudio(){if(AC) return;try{ AC=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ AC=null; }}function tone(f,d,type,vol,slide){if(!AC) return;const t0=AC.currentTime;const o=AC.createOscillator(), g=AC.createGain();o.type=type||"sine"; o.frequency.setValueAtTime(f,t0);if(slide) o.frequency.exponentialRampToValueAtTime(slide,t0+d);g.gain.setValueAtTime(vol||0.12,t0);g.gain.exponentialRampToValueAtTime(0.0001,t0+d);o.connect(g); g.connect(AC.destination);o.start(t0); o.stop(t0+d+0.03);}function noise(d,vol,fc){if(!AC) return;const t0=AC.currentTime;const n=Math.floor(AC.sampleRate*d), buf=AC.createBuffer(1,n,AC.sampleRate);const ch=buf.getChannelData(0);for(let i=0;i<n;i++) ch[i]=(Math.random()*2-1)*(1-i/n);const src=AC.createBufferSource(); src.buffer=buf;const f=AC.createBiquadFilter(); f.type="lowpass"; f.frequency.value=fc||900;const g=AC.createGain(); g.gain.setValueAtTime(vol||0.25,t0);g.gain.exponentialRampToValueAtTime(0.0001,t0+d);src.connect(f); f.connect(g); g.connect(AC.destination); src.start(t0);}function sfxPlace(){ tone(860,0.06,"triangle",0.1,620); }function sfxBoom(){ noise(0.3,0.4,700); tone(70,0.32,"sine",0.35,38); }function sfxPower(){ [523,659,784].forEach((f,i)=>setTimeout(()=>tone(f,0.13,"sine",0.14),i*55)); }function sfxComplete(){ [523,659,784,1047].forEach((f,i)=>setTimeout(()=>tone(f,0.22,"sine",0.14),i*95)); }function sfxDie(){ tone(330,0.5,"sawtooth",0.15,120); }
/* ============ 状态 ============ */let state="play", stateT=0;let levelIdx=0, score=0, lives=3;let grid=[], exitCols=[], powerups=[], bombs=[], blasts=[], particles=[], startCell=null, monSpots=[];let monsters=[]; // 固定怪物(开局按 M 出生点刷出)let levelTime=TIME_LIMIT;const player={ x:1,y:1, px:0,py:0, speed:BASE_SPEED, range:1, bombsMax:1,face:{x:0,y:1}, invuln:0 };
function inBounds(cx,cy){ return cx>=0&&cy>=0&&cx<grid.length&&cy<grid[0].length; }function tileAt(cx,cy){ return inBounds(cx,cy)?grid[cy][cx]:2; }function isSolidTile(t){ return t===1||t===2||t===3||t===4; } // 砖(1)、水波(2)、水门(3)、边界石壁(4) 阻挡移动;M 是可走出生点// 玩家能否站在某格:怪物全清后水门(3)解锁、可直接走进;否则水门当实心墙挡路const MARG=6; // 玩家碰撞盒内缩(px)function playerCellPassable(c,r){const t=tileAt(c,r);if(t===3) return monsters.length===0; // 水门:全清后可进return !isSolidTile(t);}// 检查 28×28 碰撞盒 [px+MARG,px+TILE-MARG]×[py+MARG,py+TILE-MARG] 是否压到不可站立格(越界按不可)function boxBlocked(px,py){const c0=Math.floor((px+MARG)/TILE), c1=Math.floor((px+TILE-MARG)/TILE);const r0=Math.floor((py+MARG)/TILE), r1=Math.floor((py+TILE-MARG)/TILE);for(let r=r0;r<=r1;r++) for(let c=c0;c<=c1;c++)if(!playerCellPassable(c,r)) return true;return false;}function bombAt(cx,cy){ return bombs.some(b=>b.x===cx&&b.y===cy); } // 水弹为实体,挡路
function initLevel(){const M=MAPS[levelIdx];exitCols=[]; powerups=[]; bombs=[]; blasts=[]; particles=[];monSpots=[]; monsters=[];grid=[]; startCell=null;for(let r=0;r<M.h;r++){const row=[];for(let c=0;c<M.w;c++){const ch=M.rows[r][c];let t=0;if(ch==="X") t=(r===0||r===M.h-1||c===0||c===M.w-1)?4:1; // 边界圈=石壁(4)不可炸,内部=砖(1)可炸else if(ch==="~") t=2;else if(ch==="B"){ t=3; exitCols.push([c,r]); } // 水门:怪物全清后变绿、可直接走进过关else if(ch==="M"){ t=0; monSpots.push([c,r]); } // 怪物出生点:可走空地else if(ch==="S") startCell={x:c,y:r};row.push(t);}grid.push(row);}player.x=startCell.x; player.y=startCell.y;player.px=player.x*TILE; player.py=player.y*TILE;player.speed=BASE_SPEED; player.range=1; player.bombsMax=1; player.invuln=0;levelTime=TIME_LIMIT;// 固定刷怪:每个 M 出生点放一只,打完不再刷新for(const [c,r] of monSpots) monsters.push(makeMonster(c*TILE+TILE/2, r*TILE+TILE/2));state="play"; stateT=0;updateHud();}
function updateHud(){document.getElementById("iv-level").textContent="关卡 "+(levelIdx+1)+"/"+MAPS.length+" "+MAPS[levelIdx].name;document.getElementById("iv-time").textContent="时间 "+Math.max(0,Math.ceil(levelTime));document.getElementById("iv-life").textContent="❤️ "+Math.max(0,lives);document.getElementById("iv-score").textContent="得分 "+score;document.getElementById("iv-bomb").textContent="水弹 "+bombs.length+"/"+player.bombsMax;document.getElementById("iv-mon").textContent="👾 "+monsters.length;}
/* ============ 爆炸 ============ */function detonate(b){const cx=b.x, cy=b.y;const cells=[[cx,cy]];const destroyedCells=[]; // 真正被炸毁的格(区别于整片爆炸范围 cells)let destroyed=0, killedMon=0;for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){for(let i=1;i<=player.range;i++){const nx=cx+dx*i, ny=cy+dy*i;if(!inBounds(nx,ny)) break;cells.push([nx,ny]);const t=grid[ny][nx];if(t===4||t===2) break; // 边界石壁/实心水墙:射线止于此,不可炸if(t===3) break; // 水门:射线止于此,不再可炸(怪物全清后改由直接走进过关)if(t===1){grid[ny][nx]=0; destroyed++;destroyedCells.push([nx,ny]);if(Math.random()<0.2) powerups.push({x:nx,y:ny,type:pickPU(),t:0}); // 砖 20% 掉道具break;}// t===0 空地:射线穿透}}// 炸死爆炸范围内的小怪for(let i=monsters.length-1;i>=0;i--){const mo=monsters[i];const mcx=Math.round(mo.px/TILE), mcy=Math.round(mo.py/TILE);if(cells.some(([x,y])=>x===mcx&&y===mcy)){monsters.splice(i,1); killedMon++;for(let j=0;j<12;j++)particles.push({x:mcx*TILE+TILE/2,y:mcy*TILE+TILE/2,vx:(Math.random()-0.5)*180,vy:(Math.random()-0.5)*180-20,life:0.45,max:0.45,r:2.5+Math.random()*2,bubble:false});}}const chain=[];for(const bb of bombs){if(bb!==b && cells.some(([x,y])=>x===bb.x&&y===bb.y)) chain.push(bb);}blasts.push({cells,dur:0});for(const [bx,by] of cells){for(let i=0;i<6;i++){particles.push({x:bx*TILE+Math.random()*TILE, y:by*TILE+Math.random()*TILE,vx:(Math.random()-0.5)*130, vy:(Math.random()-0.5)*130-30,life:0.5+Math.random()*0.35, max:0.85, r:2+Math.random()*3.5,bubble:Math.random()<0.4});}}score += cells.length*2 + destroyed*10 + killedMon*30;sfxBoom();// 自己溅到if(cells.some(([x,y])=>x===player.x&&y===player.y) && player.invuln<=0 && state==="play") die();for(const cb of chain){const k=bombs.indexOf(cb);if(k>=0) bombs.splice(k,1);detonate(cb);}}
// 死亡重生:保留关卡进度(炸开的砖、打掉的怪、剩的水弹、水门解锁状态),只重生玩家function respawn(){state="play"; stateT=0;player.x=startCell.x; player.y=startCell.y;player.px=player.x*TILE; player.py=player.y*TILE;if(levelTime<=0) levelTime=TIME_LIMIT; // 超时被杀:重生后重新计满倒计时,避免同一关连续掉命player.invuln=2.0; // 2 秒无敌,防止出生点被贴// 把贴脸的怪暂时推开,避免刚出生就再被撞for(const mo of monsters){const dx=mo.px-player.px, dy=mo.py-player.py, d=Math.hypot(dx,dy)||1;if(d<TILE*1.6){ mo.px=player.px+dx/d*TILE*1.6; mo.py=player.py+dy/d*TILE*1.6; }}updateHud();}function complete(){if(state!=="play") return;state="complete"; stateT=1.5;score+=100;sfxComplete();}function die(){if(state!=="play") return;state="dying"; stateT=0.9;sfxDie();}
/* ============ 怪物(网格寻路:巡逻+追击,受墙体阻挡) ============ */function cellFree(cx,cy){ return inBounds(cx,cy) && grid[cy][cx]===0; }function manhattan(ax,ay,bx,by){ return Math.abs(ax-bx)+Math.abs(ay-by); }// 从 (sx,sy) 到 (tx,ty) 的 BFS 完整路径(格坐标数组),走不到返回 nullfunction bfsPath(sx,sy,tx,ty){if(sx===tx&&sy===ty) return [];const Wd=grid[0].length, Hd=grid.length;const prev=new Array(Wd*Hd).fill(-1);prev[sy*Wd+sx]=sy*Wd+sx;const q=[sy*Wd+sx];const goal=ty*Wd+tx;let found=false;while(q.length){const cur=q.shift();const cx=cur%Wd, cy=(cur/Wd)|0;if(cur===goal){ found=true; break; }for(const [dc,dr] of [[1,0],[-1,0],[0,1],[0,-1]]){const nc=cx+dc, nr=cy+dr, ni=nr*Wd+nc;if(!inBounds(nc,nr)||grid[nr][nc]!==0||bombAt(nc,nr)) continue; // 墙/水弹都挡路if(prev[ni]!==-1) continue;prev[ni]=cur; q.push(ni);}}if(!found) return null;const startIdx=sy*Wd+sx;const path=[]; let n=goal;while(n!==startIdx){ path.push([n%Wd,(n/Wd)|0]); n=prev[n]; }path.reverse();return path;}// 生成怪物们的随机巡逻目标(可走格)function randomWalkableCell(){const Wd=grid[0].length, Hd=grid.length;for(let k=0;k<40;k++){const cx=(Math.random()*Wd)|0, cy=(Math.random()*Hd)|0;if(grid[cy][cx]===0 && !bombAt(cx,cy)) return [cx,cy];}return null;}function makeMonster(px,py){return { px, py, cx:Math.round((px-TILE/2)/TILE), cy:Math.round((py-TILE/2)/TILE),wx:0, wy:0, path:[], repath:0, wob:Math.random()*7 };}function stepMonsters(dt){for(let i=monsters.length-1;i>=0;i--){const mo=monsters[i];mo.wob+=dt;mo.cx=Math.round((mo.px-TILE/2)/TILE);mo.cy=Math.round((mo.py-TILE/2)/TILE);mo.repath-=dt;const distToPlayer=manhattan(mo.cx,mo.cy,player.x,player.y);const chasing = distToPlayer<=CHASE_RANGE;let tx=-1, ty=-1, recompute=false;if(chasing){tx=player.x; ty=player.y;recompute = mo.repath<=0; // 追击中每 0.35s 重算一次(玩家可能移动)} else {if(mo.path.length===0){ const w=randomWalkableCell(); if(w){ mo.wx=w[0]; mo.wy=w[1]; } }if(mo.wx>=0){ tx=mo.wx; ty=mo.wy; recompute=true; } // 到达巡逻点即换新目标}if(recompute && tx>=0 && (tx!==mo.cx||ty!==mo.cy)){const p=bfsPath(mo.cx,mo.cy,tx,ty);if(p){ mo.path=p; mo.repath = chasing?0.35:0; }}// 路径首格被水弹挡住则作废重寻if(mo.path.length && bombAt(mo.path[0][0],mo.path[0][1])){mo.path=[]; mo.repath=0;}// 沿路径逐格平滑移动if(mo.path.length){const [nx,ny]=mo.path[0];if(!bombAt(nx,ny)){const tcx=nx*TILE+TILE/2, tcy=ny*TILE+TILE/2;const ddx=tcx-mo.px, ddy=tcy-mo.py, d=Math.hypot(ddx,ddy)||1;const mv=MON_SPEED*dt;if(d<=mv){ mo.px=tcx; mo.py=tcy; mo.path.shift(); }else { mo.px+=ddx/d*mv; mo.py+=ddy/d*mv; }}}// 接触扣命const pdx=(player.px+TILE/2)-mo.px, pdy=(player.py+TILE/2)-mo.py;if(Math.hypot(pdx,pdy)<TILE*0.42 && player.invuln<=0 && state==="play") die();}}
/* ============ 主循环 ============ */let lastT=0;function frame(now){const dt=Math.min(0.05,(now-lastT)/1000||0);lastT=now;step(dt);draw(now/1000);requestAnimationFrame(frame);}
function step(dt){if(state==="ready"||state==="pause"){ updateHud(); return; }if(state==="dying"){stateT-=dt;if(stateT<=0){lives--;if(lives<=0) state="over";else respawn();}updateHud();return;}if(state==="complete"){stateT-=dt;if(stateT<=0){levelIdx++;if(levelIdx>=MAPS.length) state="win";else initLevel();}return;}if(state==="over"||state==="win") return;
levelTime-=dt;if(levelTime<=0){ die(); return; }
let dx=0, dy=0;if(keys.has("ArrowLeft")||keys.has("KeyA")) dx--;if(keys.has("ArrowRight")||keys.has("KeyD")) dx++;if(keys.has("ArrowUp")||keys.has("KeyW")) dy--;if(keys.has("ArrowDown")||keys.has("KeyS")) dy++;dx+=joyVec.x; dy+=joyVec.y; // 摇杆模拟量(手机端)const dLen=Math.hypot(dx,dy);if(dLen>1){ dx/=dLen; dy/=dLen; } // 键盘+摇杆叠加幅度封顶 1,保持速度一致if(dx!==0||dy!==0) player.face={x:dx,y:dy};const v=player.speed*dt;if(dx!==0){const px=player.px+dx*v;if(!boxBlocked(px,player.py)) player.px=px; // 整盒 28×28 碰撞,防卡墙}if(dy!==0){const py=player.py+dy*v;if(!boxBlocked(player.px,py)) player.py=py;}if(dx===0){const tx=player.x*TILE, diff=tx-player.px, s=v*2;const nx=Math.abs(diff)<=s ? tx : player.px+Math.max(-s,Math.min(s,diff));if(!boxBlocked(nx,player.py)) player.px=nx; // 对心时也不进墙}if(dy===0){const ty=player.y*TILE, diff=ty-player.py, s=v*2;const ny=Math.abs(diff)<=s ? ty : player.py+Math.max(-s,Math.min(s,diff));if(!boxBlocked(player.px,ny)) player.py=ny;}player.x=Math.round(player.px/TILE);player.y=Math.round(player.py/TILE);if(player.invuln>0) player.invuln-=dt;
// 走进已解锁的水门(怪物全清后变绿、可直接进)→ 立即过关if(state==="play" && monsters.length===0 &&exitCols.some(([ex,ey])=>ex===player.x&&ey===player.y))complete();
for(let i=powerups.length-1;i>=0;i--){const p=powerups[i]; p.t+=dt;if(p.x===player.x&&p.y===player.y){if(p.type.id==="speed") player.speed=BASE_SPEED*SPEED_MUL;else if(p.type.id==="range") player.range=Math.min(MAX_RANGE,player.range+1);else if(p.type.id==="life") lives=Math.min(MAX_LIVES,lives+1); // 爱心:+1 命else player.bombsMax=Math.min(MAX_BOMBS,player.bombsMax+1); // 上限+1,可再铺一颗score+=20;powerups.splice(i,1);sfxPower();for(let j=0;j<10;j++)particles.push({x:p.x*TILE+TILE/2,y:p.y*TILE+TILE/2,vx:(Math.random()-0.5)*160,vy:(Math.random()-0.5)*160,life:0.4,max:0.4,r:2+Math.random()*2.5,bubble:false});updateHud();}}
for(let i=bombs.length-1;i>=0;i--){const b=bombs[i];b.t+=dt;if(b.t>=BOMB_ARM){bombs.splice(i,1);detonate(b);}}/* 怪物:网格寻路(巡逻+追击,被墙体挡住) */stepMonsters(dt);for(let i=blasts.length-1;i>=0;i--){blasts[i].dur+=dt;if(blasts[i].dur>BLAST_DUR) blasts.splice(i,1);}for(let i=particles.length-1;i>=0;i--){const p=particles[i];p.life-=dt;p.x+=p.vx*dt; p.y+=p.vy*dt;p.vy-= (p.bubble?-50:120)*dt;if(p.life<=0) particles.splice(i,1);}updateHud();}
/* ============ 输入 ============ */const keys=new Set();const KEYMAP={ "Space":1, ArrowUp:1, ArrowDown:1, ArrowLeft:1, ArrowRight:1,KeyW:1, KeyA:1, KeyS:1, KeyD:1 };window.addEventListener("keydown",e=>{ensureAudio();if(KEYMAP[e.code]) e.preventDefault();if(e.code==="KeyP"){if(state==="play") state="pause";else if(state==="pause") state="play";return;}if(e.repeat) return;keys.add(e.code);if(state==="ready" && (e.code==="Space"||e.code==="Enter")){state="play"; sfxPower(); updateHud(); return; // 空格开始游戏}if(e.code==="Space" && state==="play") placeBomb();if((e.code==="Enter"||e.code==="Space") && (state==="over"||state==="win")){levelIdx=0; score=0; lives=3; initLevel(); state="ready"; updateHud();}});window.addEventListener("keyup",e=>keys.delete(e.code));window.addEventListener("blur",()=>{ if(state==="play") state="pause"; });
function placeBomb(){const cx=player.x, cy=player.y;if(bombs.some(b=>b.x===cx&&b.y===cy)) return; // 脚下已有水弹if(bombs.length>=player.bombsMax) return; // 场上已铺满上限bombs.push({x:cx,y:cy,t:0});sfxPlace();updateHud();}
/* ============ 手机触控(方向键 / 💧放弹 / ⏸暂停 / 点按画布) ============ */const IS_TOUCH = (window.matchMedia && matchMedia("(pointer:coarse)").matches) || "ontouchstart" in window;const pausebtn=document.getElementById("pausebtn");const bombbtn=document.getElementById("bombbtn");
// 左下摇杆:按住 360° 拖动,模拟量方向向量驱动移动(含死区),松手回中const joy=document.getElementById("joy"), joyKnob=document.getElementById("joy-knob");const joyVec={x:0,y:0};const JOY_MAX=44; // 摇杆头最大偏移(px)const JOY_DEAD=0.18; // 死区(避免微抖动)const JOY_HYST=1.35; // 轴锁滞回系数:副轴须达到主轴 1.35 倍才切换,防止对角线附近频繁跳轴let joyPointer=null, joyAxis=0; // joyAxis: 0=未定,1=水平轴,-1=垂直轴function joyUpdate(e){const r=joy.getBoundingClientRect();const rawX=e.clientX-(r.left+r.width/2), rawY=e.clientY-(r.top+r.height/2);const d=Math.hypot(rawX,rawY);// 摇杆头随手指 360° 自由转动(圆形限幅),保证拖动手感流畅;只有移动方向做轴锁定let kx=rawX, ky=rawY;if(d>JOY_MAX){ kx=rawX/d*JOY_MAX; ky=rawY/d*JOY_MAX; }joyKnob.style.transform=`translate(${kx}px,${ky}px)`;const ax=Math.abs(kx), ay=Math.abs(ky);if(d<=JOY_MAX*JOY_DEAD){ joyVec.x=0; joyVec.y=0; joyAxis=0; }else {if(joyAxis===0) joyAxis = ax>=ay ? 1 : -1; // 首次出死区:取当前主轴else if(joyAxis===1 && ay>ax*JOY_HYST) joyAxis=-1; // 明显更偏垂直 → 切垂直轴else if(joyAxis===-1 && ax>ay*JOY_HYST) joyAxis=1; // 明显更偏水平 → 切水平轴if(joyAxis===1){ joyVec.x=Math.max(-1,Math.min(1,kx/JOY_MAX)); joyVec.y=0; }else { joyVec.x=0; joyVec.y=Math.max(-1,Math.min(1,ky/JOY_MAX)); }}joy.classList.add("active");}function joyRelease(){joyVec.x=0; joyVec.y=0; joyAxis=0;joyKnob.style.transform="translate(0,0)";joy.classList.remove("active");}joy.addEventListener("pointerdown",e=>{e.preventDefault(); ensureAudio();joyPointer=e.pointerId;joy.setPointerCapture(e.pointerId);joyUpdate(e);});joy.addEventListener("pointermove",e=>{ if(e.pointerId===joyPointer) joyUpdate(e); });for(const ev of ["pointerup","pointercancel"]){joy.addEventListener(ev,e=>{ if(e.pointerId===joyPointer){ joyPointer=null; joyRelease(); } });}// 右下 💧:按下放水弹bombbtn.addEventListener("pointerdown",e=>{e.preventDefault(); ensureAudio(); bombbtn.classList.add("pressed");if(state==="play") placeBomb();});for(const ev of ["pointerup","pointercancel","pointerleave"])bombbtn.addEventListener(ev,()=>bombbtn.classList.remove("pressed"));// 右上 ⏸:暂停 / 继续pausebtn.addEventListener("pointerdown",e=>{e.preventDefault(); ensureAudio();if(state==="play") state="pause";else if(state==="pause") state="play";updateHud();});// 点按画布:开始 / 继续 / 重新开始(手机专用;桌面仍走键盘)cv.addEventListener("pointerdown",e=>{if(!IS_TOUCH) return;e.preventDefault(); ensureAudio();if(state==="ready"){ state="play"; sfxPower(); updateHud(); }else if(state==="pause"){ state="play"; updateHud(); }else if(state==="over"||state==="win"){ levelIdx=0; score=0; lives=3; initLevel(); state="play"; sfxPower(); updateHud(); }});// 防止长按菜单 / 双击缩放等干扰游戏操作document.addEventListener("contextmenu",e=>{if(e.target.closest && e.target.closest("#controls,#cv,#pausebtn")) e.preventDefault();});document.addEventListener("dblclick",e=>{if(e.target.closest && e.target.closest("#controls,#cv")) e.preventDefault();});// 彻底禁止页面拖动/下拉(锁死滚动);摇杆/放弹键已用 pointer 事件,这里拦 touch 滚动不影响游戏操作document.addEventListener("touchstart",e=>{ if(e.target.closest && e.target.closest("#cv")) e.preventDefault(); },{passive:false});document.addEventListener("touchmove",e=>{// 任何触摸滑动都阻止默认滚动(CSS 已 overflow:hidden,这里是双保险防 iOS 橡皮筋回弹)if(e.target.closest && e.target.closest("#controls,#cv,#pausebtn")) e.preventDefault();},{passive:false});document.addEventListener("gesturestart",e=>e.preventDefault()); // iOS 双击/捏合缩放
/* ============ 绘制 ============ */function draw(t){ctx.clearRect(0,0,W,H);const bg=ctx.createLinearGradient(0,0,0,H);bg.addColorStop(0,"#0d2b4a"); bg.addColorStop(1,"#081c33");ctx.fillStyle=bg; ctx.fillRect(0,0,W,H);
const cols=grid[0].length, rows=grid.length;const ox=Math.floor((W-cols*TILE)/2), oy=Math.floor((H-rows*TILE)/2);ctx.save();ctx.translate(ox,oy);
for(let r=0;r<rows;r++) for(let c=0;c<cols;c++){const x=c*TILE, y=r*TILE;const shim=0.5+0.5*Math.sin(t*1.7+c*0.8+r*1.3);ctx.fillStyle=`rgb(${14+shim*10|0},${60+shim*22|0},${118+shim*30|0})`;ctx.fillRect(x,y,TILE,TILE);ctx.strokeStyle="rgba(8,26,48,0.55)";ctx.strokeRect(x+0.5,y+0.5,TILE-1,TILE-1);if((c*7+r*13)%11===0){ctx.strokeStyle="rgba(46,150,110,0.5)";ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(x+10,y+34); ctx.quadraticCurveTo(x+14+shim*4,y+16,x+8,y+6);ctx.moveTo(x+26,y+34); ctx.quadraticCurveTo(x+24-shim*4,y+18,x+30,y+8);ctx.stroke();}if((c*5+r*9)%7===2){const by=(t*14+(c*31+r*17))%40;ctx.fillStyle="rgba(160,220,255,0.28)";ctx.beginPath(); ctx.arc(x+8+(c%3)*8, y+36-by, 2, 0, 7); ctx.fill();}}
for(let r=0;r<rows;r++) for(let c=0;c<cols;c++){const x=c*TILE, y=r*TILE, tl=grid[r][c];if(tl===1) drawBrick(x,y,false,false);else if(tl===4) drawBrick(x,y,false,true);else if(tl===2) drawWater(x,y,t);else if(tl===3) drawGate(x,y,t);}
for(const p of powerups){const bob=Math.sin(p.t*3)*3;const x=p.x*TILE+TILE/2, y=p.y*TILE+TILE/2+bob;ctx.save();ctx.shadowColor=p.type.color; ctx.shadowBlur=10;ctx.fillStyle=p.type.color;ctx.beginPath(); roundRect(x-11,y-11,22,22,6); ctx.fill();ctx.fillStyle="rgba(255,255,255,0.85)";ctx.beginPath(); ctx.arc(x-4,y-5,3,0,7); ctx.fill();ctx.shadowBlur=0;ctx.fillStyle="#fff"; ctx.font="bold 13px sans-serif";ctx.textAlign="center"; ctx.textBaseline="middle";ctx.fillText(p.type.label,x+1,y+1);ctx.restore();}
for(const mo of monsters){drawMonster(mo.px,mo.py,t+mo.wob);}
for(const bl of blasts){const k=bl.dur/BLAST_DUR;for(const [bx,by] of bl.cells){const x=bx*TILE+TILE/2, y=by*TILE+TILE/2;const rad=TILE*0.55+k*TILE*0.25;ctx.globalAlpha=(1-k)*0.8;const g=ctx.createRadialGradient(x,y,2,x,y,rad);g.addColorStop(0,"rgba(255,255,255,0.95)");g.addColorStop(0.5,"rgba(150,220,255,0.6)");g.addColorStop(1,"rgba(60,140,255,0)");ctx.fillStyle=g;ctx.beginPath(); ctx.arc(x,y,rad,0,7); ctx.fill();ctx.globalAlpha=1;}}
for(const b of bombs){const x=b.x*TILE+TILE/2, y=b.y*TILE+TILE/2;const blink=b.t>BOMB_ARM-0.45 ? (Math.sin(b.t*28)>0?1:0.45) : 1;const pulse=1+0.07*Math.sin(b.t*9);ctx.save();ctx.globalAlpha=blink;const g=ctx.createRadialGradient(x-6,y-8,3,x,y,TILE*0.46*pulse);g.addColorStop(0,"rgba(230,250,255,0.98)");g.addColorStop(0.35,"rgba(120,195,255,0.92)");g.addColorStop(1,"rgba(40,120,220,0.85)");ctx.fillStyle=g;ctx.beginPath(); ctx.arc(x,y,TILE*0.44*pulse,0,7); ctx.fill();ctx.strokeStyle="rgba(20,80,160,0.6)"; ctx.lineWidth=2; ctx.stroke();ctx.fillStyle="rgba(255,255,255,0.75)";ctx.beginPath(); ctx.arc(x-8,y-9,5,0,7); ctx.fill();ctx.fillStyle="#fff";ctx.beginPath(); ctx.arc(x+10,y-14,2+Math.random()*2,0,7); ctx.fill();ctx.restore();}
drawPlayer(t);
for(const p of particles){ctx.globalAlpha=Math.max(0,p.life/p.max);ctx.fillStyle=p.bubble?"rgba(180,235,255,0.9)":"rgba(255,255,255,0.9)";ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill();ctx.globalAlpha=1;}ctx.restore();
const startSub = IS_TOUCH ? "点按画布开始 · 打完怪物 且 走进水门 过关" : "按 空格键 开始游戏 · 打完怪物 且 走进水门 过关";const pauseSub = IS_TOUCH ? "点按画布 或 ⏸ 继续" : "按 P 继续";const againSub = IS_TOUCH ? "点按画布" : "按 回车/空格";if(state==="ready") overlay("💧 水弹泡泡岛",startSub);if(state==="pause") overlay("⏸ 已暂停",pauseSub);if(state==="complete") overlay("🌊 水门通过!过关 +100","即将进入下一关…");if(state==="dying") overlay("💥 被水花溅中!","生命 -1");if(state==="over") overlay("💀 游戏结束","最终得分 "+score+" · "+againSub+" 重新开始");if(state==="win") overlay("🏆 全部关卡通过!","总得分 "+score+" · "+againSub+" 再玩一次");}
function overlay(title,sub){ctx.fillStyle="rgba(4,14,28,0.62)";ctx.fillRect(0,0,W,H);ctx.textAlign="center"; ctx.textBaseline="middle";ctx.fillStyle="#cfeaff"; ctx.font="bold 40px sans-serif";ctx.shadowColor="#3d9bff"; ctx.shadowBlur=18;ctx.fillText(title,W/2,H/2-26);ctx.shadowBlur=0;ctx.fillStyle="#9cc4e8"; ctx.font="18px sans-serif";ctx.fillText(sub,W/2,H/2+26);}
function roundRect(x,y,w,h,r){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();}
function drawWater(x,y,t){// 实心水块(障碍):深色 + 亮边框 + 气泡,与"可走的水面"明确区分// 底色:深池ctx.fillStyle="#08304f";ctx.fillRect(x+1,y+1,TILE-2,TILE-2);// 内层渐变水const g=ctx.createLinearGradient(x,y,x,y+TILE);g.addColorStop(0,"#1a6fa6"); g.addColorStop(1,"#0c456f");ctx.fillStyle=g; ctx.fillRect(x+3,y+3,TILE-6,TILE-6);// 波纹(更明显的白色浪)ctx.strokeStyle="rgba(190,240,255,0.85)"; ctx.lineWidth=2;for(let i=0;i<3;i++){const ph=Math.sin(t*2.6+i*1.5+(x+y)*0.05)*2;ctx.beginPath();ctx.moveTo(x+6, y+10+i*9+ph);ctx.quadraticCurveTo(x+TILE/2, y+10+i*9+ph+4, x+TILE-6, y+10+i*9+ph);ctx.stroke();}// 关键:亮色粗边框,让它读作"一块固体障碍"而非水面ctx.strokeStyle="#7fe3ff"; ctx.lineWidth=3;ctx.strokeRect(x+2,y+2,TILE-4,TILE-4);ctx.strokeStyle="rgba(255,255,255,0.35)"; ctx.lineWidth=1;ctx.strokeRect(x+5,y+5,TILE-10,TILE-10);}function drawGate(x,y,t){// 水门:怪物全清前金色(当实心墙挡路、不可进);清光后边框/发光变绿(已解锁、可直接走进)const pulse=0.55+0.45*Math.sin(t*3);const unlocked = monsters.length===0;const frameColor = unlocked ? "#5fe98b" : "#ffce5a";const glowColor = unlocked ? "rgba(95,233,139," : "rgba(255,210,90,";const labelColor = unlocked ? "#bff5cf" : "#ffe6a8";ctx.fillStyle="#14324a"; ctx.fillRect(x,y,TILE,TILE);ctx.save();ctx.shadowColor=glowColor+(0.5+0.4*pulse)+")"; ctx.shadowBlur=12;// 门框ctx.strokeStyle=frameColor; ctx.lineWidth=4;ctx.strokeRect(x+5,y+3,TILE-10,TILE-6);// 门内水面const g=ctx.createLinearGradient(x,y,x,y+TILE);g.addColorStop(0,"#7fd4ff"); g.addColorStop(1,"#2f8fd6");ctx.fillStyle=g;ctx.fillRect(x+7,y+5,TILE-14,TILE-10);// 波纹ctx.strokeStyle="rgba(255,255,255,0.6)"; ctx.lineWidth=1.5;const ph=Math.sin(t*3)*2;ctx.beginPath();ctx.moveTo(x+9,y+TILE/2+ph);ctx.quadraticCurveTo(x+TILE/2,y+TILE/2+ph+2,x+TILE-9,y+TILE/2-ph);ctx.stroke();ctx.restore();// 标签ctx.fillStyle=labelColor; ctx.font="bold 12px sans-serif";ctx.textAlign="center"; ctx.textBaseline="middle";ctx.fillText(unlocked?"水门·可进":"水门",x+TILE/2,y-6);}function drawMonster(x,y,t){const w=Math.sin(t*6+x)*1.5;ctx.save();ctx.translate(x,y);// 身体(紫色小怪)ctx.fillStyle="#7a3fd1";ctx.beginPath(); ctx.arc(0,0,10,0,7); ctx.fill();ctx.fillStyle="#5a28a8";ctx.beginPath(); ctx.arc(0,3,10,0,Math.PI); ctx.fill();// 眼睛ctx.fillStyle="#fff";ctx.beginPath(); ctx.arc(-3.5+w*0.3,-2,3,0,7); ctx.arc(3.5+w*0.3,-2,3,0,7); ctx.fill();ctx.fillStyle="#1c0a30";ctx.beginPath(); ctx.arc(-3.2+w*0.3,-2,1.4,0,7); ctx.arc(3.8+w*0.3,-2,1.4,0,7); ctx.fill();// 小刺ctx.strokeStyle="#9a5cf0"; ctx.lineWidth=2;for(let i=-1;i<=1;i++){ctx.beginPath();ctx.moveTo(i*5, -9);ctx.lineTo(i*5+1, -13+Math.abs(i));ctx.stroke();}ctx.restore();}function drawBrick(x,y,isExit,isBorder){if(isBorder){ctx.fillStyle="#2a3242";ctx.fillRect(x,y,TILE,TILE);ctx.fillStyle="rgba(255,255,255,0.06)";ctx.fillRect(x,y,TILE,5);ctx.strokeStyle="#151b26";ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x,y+TILE/2); ctx.lineTo(x+TILE,y+TILE/2);ctx.moveTo(x+TILE/4,y); ctx.lineTo(x+TILE/4,y+TILE/2);ctx.moveTo(x+TILE*3/4,y+TILE/2); ctx.lineTo(x+TILE*3/4,y+TILE);ctx.stroke();ctx.strokeStyle="#10151f";ctx.strokeRect(x+1,y+1,TILE-2,TILE-2);return;}ctx.fillStyle=isExit?"#315c37":"#5a6b85";ctx.fillRect(x,y,TILE,TILE);ctx.fillStyle=isExit?"rgba(120,255,150,0.25)":"rgba(255,255,255,0.22)";ctx.fillRect(x,y,TILE,7);ctx.strokeStyle=isExit?"#234028":"#2b3547";ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x,y+TILE/2); ctx.lineTo(x+TILE,y+TILE/2);ctx.moveTo(x+TILE/2,y); ctx.lineTo(x+TILE/2,y+TILE/2);ctx.moveTo(x+TILE/4,y+TILE/2); ctx.lineTo(x+TILE/4,y+TILE);ctx.moveTo(x+TILE*3/4,y+TILE/2); ctx.lineTo(x+TILE*3/4,y+TILE);ctx.stroke();ctx.strokeStyle=isExit?"#1c3320":"#2a3446";ctx.strokeRect(x+1,y+1,TILE-2,TILE-2);if(isExit){const pulse=0.6+0.4*Math.sin(performance.now()/1000*3);ctx.save();ctx.globalAlpha=pulse;ctx.fillStyle="#ffd94a";ctx.shadowColor="#ffd94a"; ctx.shadowBlur=10;ctx.font="bold 16px sans-serif";ctx.textAlign="center"; ctx.textBaseline="middle";ctx.fillText("水门",x+TILE/2,y+TILE/2+1);ctx.restore();}}
function drawPlayer(t){if(state==="dying") return;const blink=player.invuln>0 && Math.floor(t*10)%2===0;ctx.save();if(blink) ctx.globalAlpha=0.45;const x=player.px+TILE/2, y=player.py+TILE/2;ctx.fillStyle="rgba(0,0,0,0.28)";ctx.beginPath(); ctx.ellipse(x,y+13,11,4.5,0,0,7); ctx.fill();const rr=(t*40)%TILE;ctx.strokeStyle=`rgba(140,210,255,${0.3*(1-rr/TILE)})`;ctx.lineWidth=1.5;ctx.beginPath(); ctx.ellipse(x,y+10,rr*0.5,rr*0.2,0,0,7); ctx.stroke();ctx.fillStyle="#ffd25e";ctx.beginPath(); ctx.arc(x,y+1,11.5,0,7); ctx.fill();ctx.strokeStyle="#b8860f"; ctx.lineWidth=1.5; ctx.stroke();ctx.fillStyle="#274f7c";ctx.beginPath(); ctx.arc(x,y-2,10.5,Math.PI*1.05,Math.PI*1.95); ctx.fill();const fx=player.face.x*3, fy=player.face.y*3;ctx.fillStyle="#fff";ctx.beginPath(); ctx.arc(x-4+fx,y-1+fy,3,0,7); ctx.arc(x+4+fx,y-1+fy,3,0,7); ctx.fill();ctx.fillStyle="#26334d";ctx.beginPath(); ctx.arc(x-4+fx*1.4,y-1+fy*1.4,1.5,0,7); ctx.arc(x+4+fx*1.4,y-1+fy*1.4,1.5,0,7); ctx.fill();ctx.strokeStyle="#8a5a12"; ctx.lineWidth=1.2;ctx.beginPath(); ctx.arc(x+fx*0.5,y+4+fy*0.5,3.5,0.15*Math.PI,0.85*Math.PI); ctx.stroke();ctx.restore();}
/* ============ 启动 ============ */initLevel();state="ready"; // 先停在启动画面,按空格才开始updateHud();requestAnimationFrame(frame);</script></body></html>