(()=>{ 'use strict'; const $=id=>document.getElementById(id); const cv=$('view'),cx=cv.getContext('2d'); const am=$('auto'),ac=am.getContext('2d'); const W0=320,H0=200,CX=160,CY=100,K=110,MAXF=6; const DX=[0,1,0,-1],DY=[-1,0,1,0],DN=['北','東','南','西']; const rnd=n=>Math.floor(Math.random()*n),rr=(a,b)=>a+rnd(b-a+1); const SOLID='#wDXT123'; const isSolid=c=>SOLID.includes(c); function mulberry(a){return()=>{a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}} /* ---------- 像素精靈 (12x12) ---------- */ const BM={ blob:["............","............","....oooo....","...oooooo...","..oooooooo..","..owdoowdo..","..oooooooo..",".oooooooooo.",".oooddddooo.",".oooooooooo.","..dddddddd..","............"], beast:["............","..o......o..",".ooo....ooo.",".oooooooooo.","..oorooroo..","..oooooooo..","...oowwoo...","...oooooo...","..oo.oo.oo..",".oo..oo..oo.","............","............"], human:["....oooo....","...oooooo...","...owoowo...","...oooooo...","....oddo....","..cccccccc..",".o.cccccc.o.",".o.cccccc.o.","...cccccc...","...cc..cc...","...cc..cc...","..ddd..ddd.."], skull:["...wwwwww...","..wwwwwwww..",".wwwwwwwwww.",".wddwwwwddw.",".wddwwwwddw.",".wwwwddwwww.","..wwwwwwww..","...wdwdwdw..","..cwwwwwwc..","..cwwwwwwc..","...w.cc.w...","...w....w..."], ghost:["....oooo....","...oooooo...","..oooooooo..","..orooooro..","..orooooro..","..oooooooo..","..oooddooo..","..oooooooo..",".oooooooooo.",".oo.oooo.oo.",".o...oo...o.","............"], bat:["............","o..........o","oo...oo...oo","ooo.oooo.ooo","oooooooooooo","oo.orooro.oo","o...oooo...o","....owwo....",".....oo.....","............","............","............"], golem:["..oo....oo..","..ooo..ooo..","...oooooo...","..oorooroo..","..oooooooo..",".oooooooooo.","oo.oooooo.oo","oo.oooooo.oo","oo.oooooo.oo","...oo..oo...","..ooo..ooo..","..ddd..ddd.."], lord:["..y.y.y.y...","..yyyyyyyy..","...oooooo...","...orooro...","...oooooo...",".cccooooccc.","cccccccccccc","ccoccccccocc","cccccccccccc",".cccc..cccc.",".cccc..cccc.",".ddd....ddd."], chest:["............","............","............","............","..yyyyyyyy..",".oooooooooo.",".oddddddddo.",".ooooyyoooo.",".oooooooooo.",".oooooooooo.","............","............"] }; const MON={ rat:{n:'巨鼠',s:'beast',o:'#8a6a4a',c:'#5a3a2a',hp:8,a:[1,3],d:0,xp:60,g:5}, wolf:{n:'野狼',s:'beast',o:'#8a8a90',c:'#444',hp:14,a:[2,5],d:0,xp:110,g:8}, bandit:{n:'強盜',s:'human',o:'#d0a070',c:'#604030',hp:18,a:[2,6],d:1,xp:170,g:20}, bat:{n:'蝙蝠',s:'bat',o:'#5a3a6a',c:'#222',hp:6,a:[1,2],d:0,xp:50,g:2}, goblin:{n:'地精',s:'human',o:'#70b040',c:'#6a4a20',hp:14,a:[2,5],d:0,xp:150,g:15}, gobarch:{n:'地精弓手',s:'human',o:'#60a040',c:'#30602a',hp:12,a:[2,6],d:0,xp:170,g:18}, slime:{n:'史萊姆',s:'blob',o:'#40c070',c:'#000',hp:20,a:[1,3],d:2,xp:190,g:5}, gobguard:{n:'地精衛兵',s:'human',o:'#508030',c:'#808890',hp:30,a:[3,5],d:2,xp:500,g:50}, gobking:{n:'地精王',s:'lord',o:'#70b040',c:'#a03030',hp:120,a:[4,8],d:3,xp:5000,g:400,boss:1}, skel:{n:'骷髏兵',s:'skull',o:'#fff',c:'#a0a0a0',hp:30,a:[4,8],d:2,xp:600,g:40,und:1}, zombie:{n:'殭屍',s:'human',o:'#7a9a6a',c:'#4a4a5a',hp:45,a:[5,9],d:1,xp:650,g:30,und:1,spd:5}, ghoul:{n:'食屍鬼',s:'beast',o:'#9ab0a0',c:'#506050',hp:38,a:[5,10],d:3,xp:800,g:50,und:1}, wraith:{n:'幽魂',s:'ghost',o:'#90a0e0',c:'#000',hp:30,a:[5,9],d:4,xp:900,g:60,und:1}, bknight:{n:'骸骨騎士',s:'skull',o:'#d8d0b8',c:'#506080',hp:75,a:[6,11],d:5,xp:2400,g:200,und:1}, ashlord:{n:'灰燼騎士',s:'lord',o:'#c0b8a8',c:'#503030',hp:260,a:[8,14],d:6,xp:34000,g:1000,boss:1,und:1}, imp:{n:'小惡魔',s:'bat',o:'#d04040',c:'#222',hp:50,a:[7,12],d:4,xp:2400,g:100}, garg:{n:'石像鬼',s:'golem',o:'#808890',c:'#444',hp:85,a:[9,14],d:8,xp:3100,g:120}, dmage:{n:'黑暗法師',s:'human',o:'#c0a0c0',c:'#402060',hp:55,a:[9,16],d:3,xp:2600,g:200}, golem:{n:'魔像',s:'golem',o:'#a08060',c:'#604020',hp:115,a:[10,16],d:10,xp:3900,g:150}, shadow:{n:'暗影守衛',s:'ghost',o:'#6040a0',c:'#000',hp:150,a:[10,15],d:8,xp:8000,g:400}, vorlag:{n:'大法師弗拉格',s:'lord',o:'#d0b0e0',c:'#301050',hp:550,a:[12,20],d:10,xp:80000,g:3000,boss:1,aoe:1} }; const SPDS={beast:12,bat:13,human:9,skull:8,ghost:10,blob:6,golem:5,lord:11}; for(const k in MON)MON[k].spd=MON[k].spd||SPDS[MON[k].s]; /* ---------- 地圖 ---------- */ const WORLD=[ "####################", "#........#.........#", "#.1......#....2....#", "#........#.........#", "#........#####.#####", "#..................#", "#....wwww..........#", "#....wwww....T.....#", "#..................#", "#.......##.##..###.#", "#.......#...#..#...#", "#.......##3##..#...#", "#..................#", "#....#.......ww....#", "#....#.......ww....#", "#..................#", "#..................#", "#..................#", "#..................#", "####################"]; const ZONES={ world:{name:'瓦恩原野',tier:0,theme:'out',mons:['rat','wolf','bandit','bat'],sky:['#6aa0e0','#b0d8f0'],fl:['#3f7a35','#5aa045'],wall:[40,120,50]}, cellar:{name:'鼠穴地窖',tier:1,theme:'dun',mons:['rat','goblin','gobarch','slime'],elite:['gobguard','gobguard'],boss:'gobking',sky:['#0c0c14','#1c1c28'],fl:['#1c1610','#3a2e20'],wall:[120,105,90]}, crypt:{name:'灰燼地穴',tier:2,theme:'dun',mons:['skel','zombie','ghoul','wraith'],elite:['bknight','skel'],boss:'ashlord',sky:['#080a12','#161a26'],fl:['#14161c','#2c3038'],wall:[85,100,115]}, tower:{name:'弗拉格之塔',tier:3,theme:'dun',mons:['imp','garg','dmage','golem'],elite:['shadow','dmage'],boss:'vorlag',sky:['#100818','#241236'],fl:['#1a1020','#382448'],wall:[115,80,140]} }; const DUN={cellar:{W:15,H:15,seed:11},crypt:{W:19,H:19,seed:23},tower:{W:21,H:21,seed:37}}; const NOTES={ cellar:['牆上刻著:「地精王吞下了銀鑰匙,想用它打開灰燼地穴。」','一張發霉的字條:「別相信史萊姆,它們比看起來更餓。」','塗鴉:「往深處走,最遠的角落有王座。」'], crypt:['碑文:「灰燼騎士守著餘燼印記,沒有它,弗拉格之塔不會開啟。」','骨堆旁的日記:「太陽水晶碎裂的那天,天空變成了灰色。」','碑文:「聖光能灼傷不死者。」'], tower:['魔法卷軸:「碎片一旦重聚,太陽將再度升起。」','黑板:「大法師弗拉格正在最高層等候你們。」','殘破的日記:「他曾是科拉克的學徒,如今只剩野心。」'] }; const MAPS={}; function gridFromRows(rows){return rows.map(r=>r.split(''))} MAPS.world={name:'world',w:20,h:WORLD[0].length,g:gridFromRows(WORLD),zone:'world'}; MAPS.world.h=WORLD.length; function genMaze(name){ const {W,H,seed}=DUN[name],rng=mulberry(seed),Z=ZONES[name]; const g=[];for(let y=0;y0&&ny>0&&nxArray(W).fill(-1));dist[1][1]=0; const q=[[1,1]];let far=[1,1]; for(let i=0;idist[far[1]][far[0]])far=[x,y]; for(let d=0;d<4;d++){const nx=x+DX[d],ny=y+DY[d];if(g[ny][nx]!=='#'&&dist[ny][nx]<0){dist[ny][nx]=dist[y][x]+1;q.push([nx,ny])}}} const open=(x,y)=>[0,1,2,3].filter(d=>g[y+DY[d]][x+DX[d]]!=='#').length; const dead=[]; for(let y=1;y0;i--){const j=Math.floor(rng()*(i+1));[dead[i],dead[j]]=[dead[j],dead[i]]} g[far[1]][far[0]]='B'; const kinds=['H','c','M','c','n','M','c','n','c','M']; dead.forEach((p,i)=>{g[p[1]][p[0]]=kinds[i%kinds.length]}); for(let y=2;y0,atk:[6,1.8],spd:10,spells:[]}, cleric:{n:'牧師',hd:8,sp:lv=>3*lv,atk:[4,1],spd:9,spells:['firstaid','bless','undead','cure','pcure'],lvAt:L=>2*L-1}, sorc:{n:'法師',hd:6,sp:lv=>3*lv,atk:[2,.6],spd:8,spells:['farrow','sleep','eblast','earrow','fire'],lvAt:L=>2*L-1}, archer:{n:'弓手',hd:10,sp:lv=>lv<3?0:2*(lv-2),atk:[5,1.5],spd:14,spells:['farrow','sleep','eblast','earrow'],lvAt:L=>2*L+1} }; const WEAP=[{n:'鐵劍',b:3,p:60},{n:'鋼劍',b:6,p:200},{n:'秘銀劍',b:10,p:600}]; const ARM=[{n:'皮甲',b:1,p:40},{n:'鎖甲',b:2,p:150},{n:'板甲',b:3,p:450},{n:'秘銀甲',b:4,p:1000}]; const atkOf=p=>Math.floor(CLS[p.cls].atk[0]+CLS[p.cls].atk[1]*(p.lv-1)); const mspOf=p=>CLS[p.cls].sp(p.lv); const need=p=>2000*Math.pow(2,p.lv-1); const trainCost=p=>30*p.lv; const spellCost=(p,s)=>s.pl?p.lv:s.c; const spellsOf=p=>{const c=CLS[p.cls];return c.spells.map(k=>SP[k]).filter(s=>p.lv>=c.lvAt(s.L))}; function mkChar(name,cls){const c=CLS[cls];return{name,cls,lv:1,xp:0,hp:c.hd,mhp:c.hd,sp:c.sp(1),msp:c.sp(1),w:0,a:0,wn:'木棍',an:'布衣',dg:false}} /* ---------- 狀態 ---------- */ let S=null,C=null,MENU=null; function newState(){return{map:'world',x:13,y:9,d:0,gold:100,gems:3,items:{potion:3,ether:1},keys:{},mod:{},vis:{},steps:0,cd:3,mode:'explore',ret:null,won:false, party:[mkChar('雷歐','knight'),mkChar('莉娜','cleric'),mkChar('馬格','sorc'),mkChar('席拉','archer')]}} const SAVE='wmm1_save_v2'; function save(){try{localStorage.setItem(SAVE,JSON.stringify(S))}catch(e){}} function load(){try{const t=localStorage.getItem(SAVE);return t?JSON.parse(t):null}catch(e){return null}} const cellAt=(m,x,y)=>{const M=MAPS[m];if(x<0||y<0||x>=M.w||y>=M.h)return'#';const o=S.mod[m+':'+x+','+y];return o!==undefined?o:M.g[y][x]}; const setCell=(x,y,c)=>{S.mod[S.map+':'+x+','+y]=c}; const zone=()=>ZONES[MAPS[S.map].zone]; const P=()=>S.party; const alive=()=>P().filter(p=>p.hp>0); /* ---------- 記錄 ---------- */ function say(t,c){const l=$('log'),d=document.createElement('div');if(c)d.className=c;d.textContent=t;l.appendChild(d);while(l.children.length>80)l.removeChild(l.firstChild);l.scrollTop=l.scrollHeight} /* ---------- 選單 ---------- */ function showMenu(title,text,opts){ MENU=opts;$('ovTitle').textContent=title;$('ovText').textContent=text; const o=$('ovOpts');o.innerHTML=''; opts.forEach((op,i)=>{const b=document.createElement('button');b.textContent=(i+1)+'. '+op.t;b.onclick=()=>pick(i);o.appendChild(b)}); $('overlay').hidden=false; } function hideMenu(){MENU=null;$('overlay').hidden=true} function pick(i){const op=MENU&&MENU[i];if(!op)return;hideMenu();op.f&&op.f()} const OK=f=>[{t:'確定',f}]; /* ---------- 指令列 ---------- */ function setCmd(list){const c=$('cmd');c.innerHTML='';list.forEach(([t,f,dis])=>{const b=document.createElement('button');b.textContent=t;b.onclick=f;if(dis)b.disabled=true;c.appendChild(b)})} function cmdExplore(){setCmd([['↶ 左轉(A)',()=>turn(-1)],['▲ 前進(W)',()=>move(0)],['↷ 右轉(D)',()=>turn(1)],['◀ 左移(Q)',()=>move(3)],['▼ 後退(S)',()=>move(2)],['▶ 右移(E)',()=>move(1)],['物品(I)',itemMenu],['地圖(M)',()=>{am.style.display=am.style.display==='none'?'block':'none'}]])} /* ---------- 繪圖:像素 ---------- */ function bitmap(bm,pal,x,y,px){ for(let r=0;r<12;r++)for(let c=0;c<12;c++){const ch=bm[r][c];if(ch==='.')continue;cx.fillStyle=pal[ch];cx.fillRect(Math.round(x+c*px),Math.round(y+r*px),Math.ceil(px),Math.ceil(px))} } const palOf=m=>({o:m.o,c:m.c,d:'#1a1020',w:'#f0f0e8',r:'#e03030',y:'#f0c030'}); const sh=(rgb,f)=>`rgb(${rgb.map(v=>Math.round(v*f)).join(',')})`; const sx=(x,z)=>CX+x*K/z,sy=(y,z)=>CY+y*K/z; const fade=z=>Math.max(.22,1-z/7.5); function drawBg(z){ let g=cx.createLinearGradient(0,0,0,CY);g.addColorStop(0,z.sky[0]);g.addColorStop(1,z.sky[1]);cx.fillStyle=g;cx.fillRect(0,0,W0,CY); g=cx.createLinearGradient(0,CY,0,H0);g.addColorStop(0,z.fl[0]);g.addColorStop(1,z.fl[1]);cx.fillStyle=g;cx.fillRect(0,CY,W0,H0-CY); } function baseColor(c,Z){ if(c==='w')return[50,100,200]; if(c==='D')return[130,85,40]; if(c==='X')return[200,190,150]; if(c==='T')return[190,160,110]; if(c==='1')return[110,90,70]; if(c==='2')return[80,85,100]; if(c==='3')return[110,70,140]; return Z.wall; } function front(f,l,c,Z){ const z=f-.5,x1=sx(l-.5,z),x2=sx(l+.5,z),y1=sy(-.5,z),y2=sy(.5,z),w=x2-x1,h=y2-y1,fd=fade(z); cx.fillStyle=sh(baseColor(c,Z),fd);cx.fillRect(x1,y1,w,h); cx.strokeStyle='rgba(0,0,0,.55)';cx.lineWidth=1; if(c==='#'||c==='w'){ cx.beginPath(); for(let i=1;i<4;i++){const y=y1+h*i/4;cx.moveTo(x1,y);cx.lineTo(x2,y); const off=(i%2)?w/3:w*2/3;cx.moveTo(x1+off,y);cx.lineTo(x1+off,y+h/4);} cx.stroke(); if(Z.theme==='out'&&c==='#'){cx.fillStyle=sh([25,85,35],fd);for(let i=0;i<5;i++){cx.beginPath();cx.arc(x1+w*(.15+.18*i),y1+h*(.2+.12*(i%3)),w*.12,0,7);cx.fill()}} } if(c==='D'){cx.fillStyle=sh([70,45,20],fd);cx.fillRect(x1+w*.15,y1+h*.1,w*.7,h*.9);cx.fillStyle=sh([240,200,60],fd);cx.fillRect(x1+w*.68,y1+h*.55,w*.07,w*.07)} if(c==='X'){cx.fillStyle=sh([30,30,40],fd);cx.fillRect(x1+w*.25,y1+h*.2,w*.5,h*.8);cx.fillStyle=sh([255,240,120],fd);cx.fillRect(x1+w*.4,y1+h*.3,w*.2,h*.1)} if(c==='T'){cx.fillStyle=sh([170,50,40],fd);cx.beginPath();cx.moveTo(x1,y1+h*.3);cx.lineTo(x1+w/2,y1);cx.lineTo(x2,y1+h*.3);cx.fill(); cx.fillStyle=sh([60,35,20],fd);cx.fillRect(x1+w*.38,y1+h*.55,w*.24,h*.45);cx.fillStyle=sh([255,230,120],fd);cx.fillRect(x1+w*.12,y1+h*.45,w*.15,h*.15);cx.fillRect(x1+w*.73,y1+h*.45,w*.15,h*.15)} if(c==='1'||c==='2'||c==='3'){cx.fillStyle=sh([10,5,15],fd);cx.fillRect(x1+w*.25,y1+h*.35,w*.5,h*.65);cx.beginPath();cx.arc(x1+w/2,y1+h*.35,w*.25,Math.PI,0);cx.fill(); if(c==='3'){cx.fillStyle=sh([150,110,190],fd);cx.fillRect(x1+w*.4,y1-h*.25,w*.2,h*.3)}} cx.strokeRect(x1,y1,w,h); } function side(f,l,left,c,Z){ const xs=left?l-.5:l+.5,za=Math.max(.3,f-.5),zb=f+.5,fd=fade((za+zb)/2)*.78; cx.fillStyle=sh(baseColor(c,Z),fd); cx.beginPath();cx.moveTo(sx(xs,za),sy(-.5,za));cx.lineTo(sx(xs,zb),sy(-.5,zb));cx.lineTo(sx(xs,zb),sy(.5,zb));cx.lineTo(sx(xs,za),sy(.5,za));cx.closePath();cx.fill(); cx.strokeStyle='rgba(0,0,0,.55)';cx.stroke(); if(c==='#'||c==='w'){cx.beginPath();for(let i=1;i<4;i++){const t=i/4,ya=sy(-.5+t,za),yb=sy(-.5+t,zb);cx.moveTo(sx(xs,za),ya);cx.lineTo(sx(xs,zb),yb)}cx.stroke()} if(c==='D'){cx.fillStyle=sh([70,45,20],fd);cx.beginPath();const z1=za+(zb-za)*.2,z2=zb-(zb-za)*.2;cx.moveTo(sx(xs,z1),sy(-.35,z1));cx.lineTo(sx(xs,z2),sy(-.35,z2));cx.lineTo(sx(xs,z2),sy(.5,z2));cx.lineTo(sx(xs,z1),sy(.5,z1));cx.fill()} } function sprite(f,l,c,Z){ const z=f,x=l,s=K/z;cx.globalAlpha=fade(z); if(c==='c'){bitmap(BM.chest,{o:'#a06a2a',d:'#3a2010',y:'#f0c030'},sx(x,z)-s*.4,sy(.5,z)-s*.8,s*.8/12)} else if(c==='M'||c==='B'){const id=c==='B'?Z.boss:Z.elite[0],m=MON[id],k=c==='B'?1.1:.8;bitmap(BM[m.s],palOf(m),sx(x,z)-s*k/2,sy(.5,z)-s*k,s*k/12)} else if(c==='H'){cx.fillStyle='#9a9aa8';cx.fillRect(sx(x,z)-s*.3,sy(.5,z)-s*.35,s*.6,s*.35);cx.fillStyle='#40a0ff';cx.fillRect(sx(x,z)-s*.25,sy(.5,z)-s*.45,s*.5,s*.12)} else if(c==='n'){cx.fillStyle='#6a4a20';cx.fillRect(sx(x,z)-s*.04,sy(.5,z)-s*.6,s*.08,s*.6);cx.fillStyle='#d8c090';cx.fillRect(sx(x,z)-s*.25,sy(.5,z)-s*.65,s*.5,s*.3)} cx.globalAlpha=1; } function drawExplore(){ const Z=zone();drawBg(Z); const r=(S.d+1)%4,at=(f,l)=>cellAt(S.map,S.x+DX[S.d]*f+DX[r]*l,S.y+DY[S.d]*f+DY[r]*l); const ls=[];for(let l=-6;l<=6;l++)ls.push(l);ls.sort((a,b)=>Math.abs(b)-Math.abs(a)); for(let f=MAXF;f>=0;f--)for(const l of ls){ if(f===0&&l!==0)continue; const c=at(f,l); if(isSolid(c)){if(f>0)front(f,l,c,Z)} else{ if(l<=0){const n=at(f,l-1);if(isSolid(n))side(f,l,true,n,Z)} if(l>=0){const n=at(f,l+1);if(isSolid(n))side(f,l,false,n,Z)} if(f>0&&'cMBHn'.includes(c))sprite(f,l,c,Z); } } } function drawCombat(){ const Z=zone();cx.fillStyle='#0a0612';cx.fillRect(0,0,W0,H0); const g=cx.createLinearGradient(0,0,0,H0);g.addColorStop(0,'#120a20');g.addColorStop(1,Z.theme==='out'?'#1c3018':'#1c1410');cx.fillStyle=g;cx.fillRect(0,0,W0,H0); const en=C.en,n=en.length,big=en.some(e=>e.boss); const px=big?9:n>3?3.6:4.6,w=px*12; en.forEach((e,i)=>{ const x=(W0/n)*(i+.5)-w/2,y=big?30:60; cx.globalAlpha=e.hp>0?1:.15;bitmap(BM[e.s],palOf(e),x,y,px);cx.globalAlpha=1; if(e.hp>0){cx.fillStyle='#300';cx.fillRect(x,y+w+4,w,4);cx.fillStyle='#e04040';cx.fillRect(x,y+w+4,w*e.hp/e.mhp,4); cx.fillStyle='#f0e0b0';cx.font='10px monospace';cx.textAlign='center';cx.fillText(e.n,x+w/2,y+w+18);cx.textAlign='left'} }); } function drawTown(){ drawBg({sky:['#6aa0e0','#c8e4f8'],fl:['#6a8a40','#4a6a30']}); const bl=[[20,'旅店','#b06040'],[120,'神殿','#d0d0e0'],[220,'商店','#c09030']]; bl.forEach(([x,t,col])=>{cx.fillStyle=col;cx.fillRect(x,70,80,70);cx.fillStyle='#703020';cx.beginPath();cx.moveTo(x-6,70);cx.lineTo(x+40,40);cx.lineTo(x+86,70);cx.fill(); cx.fillStyle='#3a2010';cx.fillRect(x+32,105,16,35);cx.fillStyle='#ffe070';cx.fillRect(x+10,85,14,14);cx.fillRect(x+56,85,14,14); cx.fillStyle='#fff';cx.font='12px monospace';cx.textAlign='center';cx.fillText(t,x+40,66);cx.textAlign='left'}); cx.fillStyle='#b8a070';cx.fillRect(0,140,W0,20); } function drawTitle(){ const g=cx.createLinearGradient(0,0,0,H0);g.addColorStop(0,'#1a0a30');g.addColorStop(1,'#502010');cx.fillStyle=g;cx.fillRect(0,0,W0,H0); cx.fillStyle='#f0c030';cx.beginPath();cx.arc(160,120,34,0,7);cx.fill();cx.fillStyle='#fff6a0';cx.beginPath();cx.arc(160,120,22,0,7);cx.fill(); cx.fillStyle='#000';cx.fillRect(0,150,W0,50); cx.fillStyle='#f0c030';cx.font='bold 22px monospace';cx.textAlign='center';cx.fillText('魔法門傳說',160,40);cx.font='14px monospace';cx.fillStyle='#e8dcc0';cx.fillText('太陽水晶 · WMM1',160,62);cx.textAlign='left'; } function drawScene(){ if(!S){drawTitle();return} if(S.mode==='combat'&&C)drawCombat();else if(S.mode==='town')drawTown();else drawExplore(); } function drawAuto(){ ac.fillStyle='#050308';ac.fillRect(0,0,160,160); if(!S||S.mode==='town')return; const M=MAPS[S.map],cs=Math.floor(160/Math.max(M.w,M.h)),v=S.vis[S.map]||{}; for(let y=0;y{ const d=document.createElement('div');d.className='pc'+(p.hp<=0?' dead':'')+(S.mode==='combat'&&C&&C.i===i?' act':''); d.innerHTML=`${p.name} ${CLS[p.cls].n} Lv${p.lv} ${p.hp<=0?'【死亡】':''}
HP ${Math.max(0,p.hp)}/${p.mhp} SP ${p.sp}/${p.msp}
${p.wn}(+${p.w}) ${p.an}(+${p.a}) XP ${p.xp}/${need(p)}${p.xp>=need(p)?' ★可訓練':''}
`; el.appendChild(d); }); $('where').textContent=S.mode==='town'?'索皮加爾鎮 金幣 '+S.gold:`${zone().name} (${S.x},${S.y}) 朝${DN[S.d]} 金幣 ${S.gold} 藥水${S.items.potion} 魔水${S.items.ether} 寶石${S.gems}`; } function renderAll(){drawScene();renderParty();drawAuto()} /* ---------- 移動 ---------- */ function turn(t){if(!canAct())return;S.d=(S.d+t+4)%4;markVis();renderAll()} const canAct=()=>S&&S.mode==='explore'&&!MENU; function move(rel){ if(!canAct())return; const d=(S.d+rel)%4,nx=S.x+DX[d],ny=S.y+DY[d],c=cellAt(S.map,nx,ny); if(isSolid(c)){bump(c,nx,ny,d);renderAll();return} S.px=S.x;S.py=S.y;S.x=nx;S.y=ny;S.steps++;if(S.cd>0)S.cd--;markVis(); renderAll(); if(event(c,nx,ny))return; const z=zone(),ch=z.tier===0?1/14:1/10; if(S.cd<=0&&Math.random(){const r=S.ret;S.map=r.map;S.x=r.x;S.y=r.y;S.d=r.d;S.cd=3;markVis();save();renderAll()}},{t:'留下'}]);return} const dn={'1':'cellar','2':'crypt','3':'tower'}[c]; if(c==='2'&&!S.keys.silver){showMenu('地穴大門','大門被一把銀製的鎖封住了。\n(需要銀鑰匙)',OK());return} if(c==='3'&&!S.keys.ember){showMenu('黑塔大門','大門上有一個燃燒的凹槽。\n(需要餘燼印記)',OK());return} showMenu(ZONES[dn].name,`要進入「${ZONES[dn].name}」嗎?\n進入前最好先補給並存檔。`,[{t:'進入',f:()=>{S.ret={map:S.map,x:S.x,y:S.y,d:(d+2)%4}; const M=MAPS[dn];S.map=dn;S.x=1;S.y=1;S.d=1;S.cd=3;markVis();save();renderAll();say(`你進入了${ZONES[dn].name}。`,'g')}},{t:'還是算了'}]); } function event(c,x,y){ const z=zone(); if(c==='c'){setCell(x,y,'.');chest(z);renderAll();return true} if(c==='H'){setCell(x,y,'.');P().forEach(p=>{if(p.hp>0){p.hp=p.mhp;p.sp=p.msp}});say('清澈的泉水讓全隊恢復了精力!','s');renderAll();return true} if(c==='n'){setCell(x,y,'.');const ns=NOTES[S.map];say(ns?ns[rnd(ns.length)]:'一塊無字的石板。','g');return true} if(c==='M'){setCell(x,y,'.');say('伏兵出現了!','b');encounter(z.elite.concat(Math.random()<.5?[z.mons[rnd(4)]]:[]),{});return true} if(c==='B'){say('你們面對守護者!','b');startCombat([z.boss],{boss:S.map});return true} return false; } function chest(z){ const t=Math.max(1,z.tier),gold=rr(15,40)*[1,1,4,10][t];S.gold+=gold;say(`打開寶箱,得到 ${gold} 金幣!`,'g'); if(Math.random()<.4){S.items.potion++;say('還找到了一瓶藥水。','g')} if(Math.random()<.35){const n=rr(1,3);S.gems+=n;say(`還找到了 ${n} 顆魔法寶石。`,'g')} if(Math.random()<.2){S.items.ether++;say('還找到了一瓶魔力水。','g')} if(Math.random()<.2){const p=alive()[rnd(alive().length)];if(p){const d=rr(1,3)*t*t;p.hp=Math.max(1,p.hp-d);say(`陷阱!${p.name}受到 ${d} 點傷害。`,'b')}} } function randomFight(){ const z=zone(),n=Math.min(5,rr(1,z.tier===0?3:2+z.tier)),ids=[]; for(let i=0;iMON[id].n).join('、'); const go=()=>startCombat(ids,opts); showMenu('遭遇敵人!',`前方出現:${names}\n要怎麼辦?`,[ {t:'攻擊',f:go}, {t:'賄賂(交出所有金幣)',f:()=>{if(S.gold<1){showMenu('賄賂','你身上沒有半毛錢。',OK(()=>encounter(ids,opts)));return} if(Math.random()<.5){S.gold=0;say('怪物收下賄款離開了。','g');renderAll()}else{say('賄賂失敗!','b');go()}}}, {t:'撤退',f:()=>{if(Math.random()<.5){say('你們成功撤退。');S.x=S.px===undefined?S.x:S.px;S.y=S.py===undefined?S.y:S.py;S.cd=3;markVis();renderAll()}else{say('撤退失敗!','b');go()}}}, {t:'投降(失去所有金幣)',f:()=>{if(Math.random()<.65){S.gold=0;const M=MAPS[S.map],cells=[];for(let y=1;y({...MON[id],id,mhp:MON[id].hp})),i:-1,opts,round:0,q:[],bless:false}; S.mode='combat';P().forEach(p=>p.dg=false); say('戰鬥開始:'+C.en.map(e=>e.n).join('、'));newRound();nextActor(); } function newRound(){ C.round++;const bp=Math.random()<.5?rnd(8):0,be=bp?0:rnd(8); const q=[]; P().forEach((p,i)=>{if(p.hp>0)q.push({p,i,s:CLS[p.cls].spd+rnd(6)+bp})}); C.en.forEach(e=>{if(e.hp>0)q.push({e,s:e.spd+rnd(6)+be})}); q.sort((a,b)=>b.s-a.s);C.q=q; } function nextActor(){ for(;;){ if(checkEnd())return; if(!C.q.length){P().forEach(p=>p.dg=false);newRound()} const a=C.q.shift(); if(a.p){if(a.p.hp<=0)continue;C.i=a.i;cmdMain();renderAll();return} if(a.e.hp>0)enemyAct(a.e); } } function cmdMain(){ const p=P()[C.i]; setCmd([[`${p.name}:攻擊`,()=>pickEnemy(e=>{attack(p,e);nextActor()})],['施法',()=>cmdSpell(p),!spellsOf(p).length&&p.cls!=='archer'],['物品',()=>cmdItem(p)],['防禦',()=>{p.dg=true;say(`${p.name}採取防禦姿態。`);nextActor()}],['逃跑',()=>run(p)]]); } function pickEnemy(cb){ const l=C.en.filter(e=>e.hp>0);if(l.length===1){cb(l[0]);return} setCmd(l.map(e=>[`${e.n} (${e.hp})${e.sleep?' zZ':''}`,()=>cb(e)]).concat([['返回',cmdMain]])); } function pickAlly(cb,dead){ setCmd(P().filter(p=>dead?p.hp<=0:p.hp>0).map(p=>[`${p.name} (${p.hp}/${p.mhp})`,()=>cb(p)]).concat([['返回',cmdMain]])); } const physDmg=p=>atkOf(p)+p.w+rr(1,6); function dmgEnemy(e,d,pierce){d=Math.max(1,d-(pierce?0:e.d));e.hp-=d;e.sleep=false;return d} function attack(p,e){ const hits=1+(p.cls==='knight'?Math.floor(p.lv/5):0); for(let i=0;ix.hp>0);if(!l.length)return;e=l[rnd(l.length)]} if(Math.random()>(C.bless?.95:.8)){say(`${p.name}揮空了。`);continue} const d=dmgEnemy(e,physDmg(p));say(`${p.name}攻擊${e.n},造成 ${d} 點傷害。`);if(e.hp<=0)say(`${e.n}倒下了!`,'g'); } } function cmdSpell(p){ const list=spellsOf(p).map(s=>{const c=spellCost(p,s),g=s.g||0;return[`${s.n}(${c}SP${g?'+'+g+'寶石':''})`,()=>cast(p,s,c,g),p.sp{const l=C.en.filter(e=>e.hp>0);for(let i=0;i<2;i++){const t=C.en.filter(e=>e.hp>0);if(t.length)attack1(p,t[rnd(t.length)],.9)}nextActor()}]); setCmd(list.concat([['返回',cmdMain]])); } function attack1(p,e,m){ if(Math.random()>.8){say(`${p.name}射偏了。`);return} const d=dmgEnemy(e,Math.floor(physDmg(p)*m));say(`${p.name}射中${e.n},${d} 點傷害。`);if(e.hp<=0)say(`${e.n}倒下了!`,'g'); } const liv=()=>C.en.filter(e=>e.hp>0); function cast(p,s,c,g){ const fin=()=>{p.sp-=c;S.gems-=g;nextActor()},lv=p.lv; const hit=(e,d)=>{const x=dmgEnemy(e,d,true);say(`${p.name}的${s.n}命中${e.n},${x} 點傷害。`);if(e.hp<=0)say(`${e.n}倒下了!`,'g')}; const dice=(n,sd)=>{let t=0;for(let i=0;i{hit(e,dice(1,6));fin()});break; case 'earrow':pickEnemy(e=>{hit(e,dice(2,6));fin()});break; case 'eblast':pickEnemy(e=>{hit(e,dice(lv,4));fin()});break; case 'fire':liv().forEach(e=>hit(e,dice(lv,6)));fin();break; case 'sleep':{let n=0;liv().slice(0,5).forEach(e=>{if(!e.boss&&Math.random()<.75){e.sleep=true;n++}});say(`${p.name}施放催眠,${n} 隻怪物睡著了。`,'s');fin();break} case 'bless':C.bless=true;say(`${p.name}為全隊祝福,命中率提升!`,'s');fin();break; case 'undead':{let n=0;liv().filter(e=>e.und).forEach(e=>{const ch=Math.min(.95,Math.max(.05,(25+10*lv-e.hp/6)/100));if(!e.boss&&Math.random(){t.hp=Math.min(t.mhp,t.hp+8);say(`${p.name}為${t.name}急救,恢復 8 HP。`,'s');fin()});break; case 'cure':pickAlly(t=>{t.hp=Math.min(t.mhp,t.hp+15);say(`${p.name}治療了${t.name},恢復 15 HP。`,'s');fin()});break; case 'pcure':pickAlly(t=>{const h=dice(lv,10);t.hp=Math.min(t.mhp,t.hp+h);say(`${p.name}的強力治療讓${t.name}恢復 ${h} HP。`,'s');fin()});break; } } function cmdItem(p){ setCmd([[`藥水 x${S.items.potion}`,()=>pickAlly(t=>{S.items.potion--;t.hp=Math.min(t.mhp,t.hp+40);say(`${t.name}喝下藥水,恢復 40 HP。`,'s');nextActor()}),S.items.potion<1], [`魔力水 x${S.items.ether}`,()=>pickAlly(t=>{S.items.ether--;t.sp=Math.min(t.msp,t.sp+15);say(`${t.name}喝下魔力水。`,'s');nextActor()}),S.items.ether<1],['返回',cmdMain]]); } function run(p){ if(C.opts.boss){say('無法從守護者面前逃走!','b');nextActor();return} if(Math.random()<.55){say('全隊成功逃脫。');endCombat()}else{say(`${p.name}沒能逃掉。`);nextActor()} } function hurt(p,d,pierce){ d=Math.max(1,d-(pierce?0:p.a+(p.dg?2+Math.floor(p.lv/4):0)));p.hp-=d;say(`${p.name}受到 ${d} 點傷害。`,'b'); if(p.hp<=0){p.hp=0;say(`${p.name}倒下了!`,'b')} } function enemyAct(e){ if(e.sleep){say(`${e.n}正在熟睡。`);return} const al=alive();if(!al.length)return; if(e.aoe&&Math.random()<.3){say(`${e.n}施放黑暗風暴!`,'b');al.forEach(p=>hurt(p,rr(8,13),true));return} const t=al[rnd(al.length)]; if(Math.random()<.65)hurt(t,rr(e.a[0],e.a[1]));else say(`${e.n}攻擊${t.name}但沒有命中。`); } function checkEnd(){ if(!alive().length){gameOver();return true} if(C.en.every(e=>e.hp<=0)){victory();return true} return false; } function endCombat(){S.mode='explore';S.cd=3;C=null;cmdExplore();renderAll()} function victory(){ const xp=C.en.reduce((a,e)=>a+e.xp,0),g=C.en.reduce((a,e)=>a+e.g,0); S.gold+=g;say(`勝利!每位存活者獲得 ${xp} 經驗,戰利品 ${g} 金幣。`,'g'); alive().forEach(p=>{const was=p.xp>=need(p);p.xp+=xp;if(!was&&p.xp>=need(p))say(`${p.name}經驗足夠了,到城鎮的訓練所就能升級!`,'g')}); const b=C.opts.boss;endCombat(); if(b==='cellar'){setCell(MAPS.cellar.far[0],MAPS.cellar.far[1],'.');S.keys.silver=true;showMenu('地精王倒下了','你們從地精王的肚子裡找到了【銀鑰匙】!\n它能打開原野東北方的灰燼地穴。',OK(save))} else if(b==='crypt'){setCell(MAPS.crypt.far[0],MAPS.crypt.far[1],'.');S.keys.ember=true;showMenu('灰燼騎士安息了','騎士的鎧甲碎裂,露出一枚燃燒的【餘燼印記】。\n它能開啟原野中央的弗拉格之塔。',OK(save))} else if(b==='tower'){S.won=true;save();showMenu('太陽再度升起','弗拉格倒下,太陽水晶的碎片在塔頂重聚,迸出萬丈金光。\n瓦恩的天空恢復了藍色,索皮加爾鎮傳來歡呼聲。\n\n~ 全劇終 ~\n(你仍可繼續探索與練功)',[{t:'繼續冒險'},{t:'重新開始',f:()=>titleMenu(true)}])} } function gameOver(){ S.mode='explore';C=null;renderAll();setCmd([]); showMenu('全軍覆沒','你們的冒險在此中斷……',[{t:'讀取存檔',f:()=>{const s=load();if(s){S=s;S.mode='explore';cmdExplore();renderAll()}else titleMenu(true)}},{t:'重新開始',f:()=>titleMenu(true)}]); } /* ---------- 城鎮 ---------- */ function leaveTown(){S.mode='explore';S.d=2;S.cd=3;markVis();save();cmdExplore();renderAll()} function townMenu(){ setCmd([]); showMenu('索皮加爾鎮',`金幣:${S.gold}\n要去哪裡?`,[ {t:'旅店 — 休息並存檔 (10G)',f:inn},{t:'訓練所 — 付費升級',f:training},{t:'神殿 — 復活 / 治療',f:temple},{t:'商店 — 武器 / 防具 / 藥水',f:shop},{t:'酒館 — 打聽消息',f:tavern},{t:'離開城鎮',f:leaveTown}]); } function training(){ const opts=P().map(p=>{const ok=p.xp>=need(p),c=trainCost(p); return{t:`${p.name} Lv${p.lv} XP ${p.xp}/${need(p)} ${ok?`升級 (${c}G)`:'經驗不足'}`,f:()=>{ if(!ok){showMenu('訓練所','經驗值還不夠。',OK(training));return} if(S.gold{if(p.hp>0){p.hp=p.mhp;p.sp=p.msp}});save();renderAll(); showMenu('旅店','你們睡了一覺,所有存活的隊員恢復了體力。\n(已存檔)',OK(townMenu)); } function temple(){ const dead=P().filter(p=>p.hp<=0); const opts=dead.map(p=>({t:`復活 ${p.name} (${60*p.lv}G)`,f:()=>{const c=60*p.lv;if(S.gold{if(S.gold<5){showMenu('神殿','金幣不夠。',OK(temple));return}S.gold-=5;P().forEach(p=>{if(p.hp>0)p.hp=p.mhp});renderAll();showMenu('神殿','溫暖的光芒治癒了你們。',OK(temple))}}); opts.push({t:'返回',f:townMenu});showMenu('神殿',`金幣:${S.gold}`,opts); } function shop(){ showMenu('商店',`金幣:${S.gold}`,[ {t:'藥水 (20G) 恢復40HP',f:()=>buyItem('potion',20)},{t:'魔力水 (30G) 恢復15SP',f:()=>buyItem('ether',30)},{t:'魔法寶石 (40G)',f:()=>{if(S.gold<40){showMenu('商店','金幣不夠。',OK(shop));return}S.gold-=40;S.gems++;renderAll();shop()}}, {t:'武器',f:()=>gear('w',WEAP,'wn')},{t:'防具',f:()=>gear('a',ARM,'an')},{t:'返回',f:townMenu}]); } function buyItem(k,p){if(S.gold({t:`${it.n} +${it.b} (${it.p}G)`,f:()=> showMenu('裝備給誰?',it.n,P().map(p=>({t:`${p.name} (${p[f]>=it.b?'已有更好':'+'+p[f]})`,f:()=>{ if(p[f]>=it.b){showMenu('商店','他已經有更好的裝備了。',OK(()=>gear(f,list,nf)));return} if(S.goldgear(f,list,nf)));return} S.gold-=it.p;p[f]=it.b;p[nf]=it.n;renderAll();gear(f,list,nf)}})).concat([{t:'取消',f:()=>gear(f,list,nf)}]))})).concat([{t:'返回',f:shop}])); } function tavern(){ let t='酒保壓低聲音:\n'; if(S.won)t+='「英雄!太陽又升起來了!今晚的酒算我的。」'; else if(!S.keys.silver)t+='「天空自從太陽水晶碎裂後就一直灰濛濛的。大法師弗拉格偷走了碎片……\n聽說西北方的鼠穴地窖裡,地精王吞了一把銀鑰匙。」'; else if(!S.keys.ember)t+='「有了銀鑰匙,就能進入東北方的灰燼地穴。那裡的騎士守著一枚餘燼印記。」'; else t+='「餘燼印記能開啟原野中央的黑塔。弗拉格就在頂端……祝你們好運。」'; showMenu('酒館',t+'\n\n(提示:升級要先累積經驗,再到訓練所付費。法師的強力法術需要魔法寶石。牧師的驅逐不死對骷髏、殭屍特別有效。)',OK(townMenu)); } /* ---------- 物品 ---------- */ function itemMenu(){ if(!canAct())return; showMenu('物品',`藥水 x${S.items.potion} 魔力水 x${S.items.ether}`,[ {t:'使用藥水 (+40HP)',f:()=>useOn('potion')},{t:'使用魔力水 (+15SP)',f:()=>useOn('ether')},{t:'關閉'}]); } function useOn(k){ if(S.items[k]<1){showMenu('物品','沒有了。',OK(itemMenu));return} showMenu('對誰使用?','',P().filter(p=>p.hp>0).map(p=>({t:`${p.name} HP${p.hp}/${p.mhp} SP${p.sp}/${p.msp}`,f:()=>{ S.items[k]--;if(k==='potion')p.hp=Math.min(p.mhp,p.hp+40);else p.sp=Math.min(p.msp,p.sp+15);renderAll();itemMenu()}})).concat([{t:'返回',f:itemMenu}])); } /* ---------- 標題 / 故事 ---------- */ function startGame(s){ S=s;S.mode='explore';C=null;markVis();cmdExplore();renderAll(); } function titleMenu(fresh){ S=null;C=null;setCmd([]);renderAll();$('log').innerHTML=''; const opts=[{t:'新遊戲',f:()=>{startGame(newState());showMenu('序章',STORY,OK(()=>{say('你們站在索皮加爾鎮外的原野上。北方就是鎮子。','g');save()}))}}]; if(load())opts.push({t:'繼續遊戲',f:()=>{startGame(load());say('讀取存檔。','g')}}); showMenu('魔法門傳說:太陽水晶','一款向《魔法門 I》致敬的第一人稱迷宮 RPG。',opts); } const STORY='瓦恩大陸的天空曾有一顆永不熄滅的太陽水晶。\n\n大法師科拉克的叛徒學徒弗拉格,在一夜之間擊碎了水晶,將碎片帶回自己的黑塔。從那天起,天空染成灰色,怪物從地底湧出。\n\n你們四位冒險者受索皮加爾鎮的長老之託,要找回碎片。\n\n目標:鼠穴地窖的地精王 → 灰燼地穴的灰燼騎士 → 弗拉格之塔。\n\n先到北方的鎮子補給吧!'; /* ---------- 鍵盤 ---------- */ document.addEventListener('keydown',e=>{ const k=e.key; if(MENU){if(/^[1-9]$/.test(k))pick(+k-1);return} if(!S||S.mode!=='explore')return; const m={ArrowUp:()=>move(0),w:()=>move(0),W:()=>move(0),ArrowDown:()=>move(2),s:()=>move(2),S:()=>move(2),ArrowLeft:()=>turn(-1),a:()=>turn(-1),A:()=>turn(-1),ArrowRight:()=>turn(1),d:()=>turn(1),D:()=>turn(1),q:()=>move(3),Q:()=>move(3),e:()=>move(1),E:()=>move(1),i:itemMenu,I:itemMenu,m:()=>{am.style.display=am.style.display==='none'?'block':'none'},M:()=>{am.style.display=am.style.display==='none'?'block':'none'}}; if(m[k]){e.preventDefault();m[k]()} }); window.__G={fight:(ids,o)=>startCombat(ids,o||{}),ZONES,need,mspOf,CLS,get S(){return S},get C(){return C},MAPS,MON,startGame,newState,move,turn,pick,get MENU(){return MENU},checkMaps(){ const out={};for(const k in DUN){const M=MAPS[k];const seen=new Set(['1,1']),q=[[1,1]];for(let i=0;i