Download game.js from HelloSun/WMM1: direct link, hf CLI and curl.
- Browser
- Download file 39.7 kB
-
https://huggingface.co/spaces/HelloSun/WMM1/resolve/main/game.js
- Command line
-
hf download hf://spaces/HelloSun/WMM1/game.js
-
curl -L -o game.js https://huggingface.co/spaces/HelloSun/WMM1/resolve/main/game.js
39.7 kB
| (()=>{ | |
| ; | |
| 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;y<H;y++)g.push(Array(W).fill('#')); | |
| const st=[[1,1]];g[1][1]='.'; | |
| while(st.length){ | |
| const [x,y]=st[st.length-1],nb=[]; | |
| for(let d=0;d<4;d++){const nx=x+DX[d]*2,ny=y+DY[d]*2;if(nx>0&&ny>0&&nx<W-1&&ny<H-1&&g[ny][nx]==='#')nb.push(d)} | |
| if(!nb.length){st.pop();continue} | |
| const d=nb[Math.floor(rng()*nb.length)]; | |
| g[y+DY[d]][x+DX[d]]='.';g[y+DY[d]*2][x+DX[d]*2]='.';st.push([x+DX[d]*2,y+DY[d]*2]); | |
| } | |
| for(let y=1;y<H-1;y++)for(let x=1;x<W-1;x++){ | |
| if(g[y][x]!=='#')continue; | |
| const h=x%2===0&&y%2===1&&g[y][x-1]==='.'&&g[y][x+1]==='.'; | |
| const v=y%2===0&&x%2===1&&g[y-1][x]==='.'&&g[y+1][x]==='.'; | |
| if((h||v)&&rng()<.1)g[y][x]='.'; | |
| } | |
| const dist=Array.from({length:H},()=>Array(W).fill(-1));dist[1][1]=0; | |
| const q=[[1,1]];let far=[1,1]; | |
| for(let i=0;i<q.length;i++){const [x,y]=q[i];if(dist[y][x]>dist[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;y<H-1;y++)for(let x=1;x<W-1;x++)if(g[y][x]==='.'&&open(x,y)===1&&!(x===1&&y===1)&&!(x===far[0]&&y===far[1]))dead.push([x,y]); | |
| for(let i=dead.length-1;i>0;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;y<H-2;y++)for(let x=2;x<W-2;x++){ | |
| if(g[y][x]!=='.'||dist[y][x]<4)continue; | |
| const hz=g[y][x-1]!=='#'&&g[y][x+1]!=='#'&&g[y-1][x]==='#'&&g[y+1][x]==='#'; | |
| const vt=g[y-1][x]!=='#'&&g[y+1][x]!=='#'&&g[y][x-1]==='#'&&g[y][x+1]==='#'; | |
| if((hz||vt)&&rng()<.06)g[y][x]='D'; | |
| } | |
| g[1][0]='X'; | |
| return {name,w:W,h:H,g,zone:name,far}; | |
| } | |
| for(const k in DUN)MAPS[k]=genMaze(k); | |
| /* ---------- 職業(依原版手冊:每級 HP 1~HD、法術等級 n 需角色 2n-1 級、升級需訓練) ---------- */ | |
| // L=法術等級(SP 花費=L,標 pl 者花費=施法者等級),g=寶石 | |
| const SP={ | |
| firstaid:{n:'急救',L:1,c:1,k:'firstaid'},bless:{n:'祝福',L:1,c:1,k:'bless'},undead:{n:'驅逐不死',L:1,c:1,k:'undead'}, | |
| cure:{n:'治療傷口',L:2,c:2,k:'cure'},pcure:{n:'強力治療',L:1,pl:1,g:1,k:'pcure'}, | |
| farrow:{n:'火焰箭',L:1,c:1,k:'farrow'},sleep:{n:'催眠',L:1,c:1,k:'sleep'},eblast:{n:'能量爆',L:1,pl:1,g:1,k:'eblast'}, | |
| earrow:{n:'電擊箭',L:2,c:2,k:'earrow'},fire:{n:'火球術',L:3,pl:1,g:1,k:'fire'} | |
| }; | |
| const CLS={ | |
| knight:{n:'騎士',hd:12,sp:lv=>0,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<M.h;y++)for(let x=0;x<M.w;x++){ | |
| if(!v[x+','+y])continue;const c=cellAt(S.map,x,y); | |
| ac.fillStyle=isSolid(c)?(c==='#'?'#4a4258':c==='w'?'#2a4a9a':'#c8a040'):'#1e1a28'; | |
| if(!isSolid(c)&&'cMBHn'.includes(c))ac.fillStyle='#d0a030'; | |
| ac.fillRect(x*cs,y*cs,cs,cs); | |
| } | |
| ac.fillStyle='#ff4040';ac.fillRect(S.x*cs,S.y*cs,cs,cs); | |
| ac.fillStyle='#fff';ac.fillRect(S.x*cs+cs/2-1+DX[S.d]*cs/2,S.y*cs+cs/2-1+DY[S.d]*cs/2,2,2); | |
| } | |
| function markVis(){ | |
| const v=S.vis[S.map]||(S.vis[S.map]={}); | |
| for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++)v[(S.x+dx)+','+(S.y+dy)]=1; | |
| const r=(S.d+1)%4; | |
| for(let f=1;f<=MAXF;f++){const x=S.x+DX[S.d]*f,y=S.y+DY[S.d]*f,c=cellAt(S.map,x,y);v[x+','+y]=1;if(isSolid(c))break; | |
| for(const l of[-1,1]){const a=x+DX[r]*l,b=y+DY[r]*l;v[a+','+b]=1}} | |
| } | |
| function renderParty(){ | |
| const el=$('party');el.innerHTML='';if(!S)return; | |
| P().forEach((p,i)=>{ | |
| const d=document.createElement('div');d.className='pc'+(p.hp<=0?' dead':'')+(S.mode==='combat'&&C&&C.i===i?' act':''); | |
| d.innerHTML=`${p.name} <small>${CLS[p.cls].n} Lv${p.lv}</small> ${p.hp<=0?'【死亡】':''}<br>HP ${Math.max(0,p.hp)}/${p.mhp} SP ${p.sp}/${p.msp}<div class="bar"><i style="width:${Math.max(0,p.hp)/p.mhp*100}%"></i></div><div class="bar sp"><i style="width:${p.msp?p.sp/p.msp*100:0}%"></i></div><div class="eq">${p.wn}(+${p.w}) ${p.an}(+${p.a}) XP ${p.xp}/${need(p)}${p.xp>=need(p)?' ★可訓練':''}</div>`; | |
| 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()<ch)randomFight(); | |
| } | |
| function bump(c,x,y,d){ | |
| if(c==='#'||c==='w'){say(c==='w'?'水太深了,過不去。':'前面被擋住了。');return} | |
| if(c==='D'){setCell(x,y,'.');say('你推開了門。');return} | |
| if(c==='T'){S.mode='town';renderAll();townMenu();return} | |
| if(c==='X'){showMenu('地牢出口','要離開這裡回到地表嗎?',[{t:'離開',f:()=>{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;i<n;i++)ids.push(z.mons[rnd(z.mons.length)]); | |
| say('遭遇敵人!','b');encounter(ids,{}); | |
| } | |
| /* ---------- 戰鬥 ---------- */ | |
| function encounter(ids,opts){ | |
| const names=ids.map(id=>MON[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<M.h-1;y++)for(let x=1;x<M.w-1;x++)if(M.g[y][x]==='.')cells.push([x,y]); | |
| const c=cells[rnd(cells.length)];S.x=c[0];S.y=c[1];S.cd=3;say('怪物搶走你的金幣,把你們丟到了別處。','b');markVis();renderAll()}else{say('投降被拒絕!','b');go()}}}]); | |
| } | |
| function startCombat(ids,opts){ | |
| C={en:ids.map(id=>({...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;i<hits;i++){ | |
| if(e.hp<=0){const l=C.en.filter(x=>x.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<c||S.gems<g]}); | |
| if(p.cls==='archer')list.unshift([`連射(2箭)`,()=>{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<n;i++)t+=rr(1,sd);return t}; | |
| switch(s.k){ | |
| case 'farrow':pickEnemy(e=>{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()<ch){e.hp=0;n++}});say(n?`${p.name}驅逐了 ${n} 隻不死生物!`:`${p.name}的驅逐沒有效果。`,'s');fin();break} | |
| case 'firstaid':pickAlly(t=>{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<c){showMenu('訓練所','金幣不夠。',OK(training));return} | |
| S.gold-=c;const hd=CLS[p.cls].hd,gain=rr(1,hd),o=p.msp;p.lv++;p.mhp+=gain;p.hp+=gain;p.msp=mspOf(p);p.sp+=p.msp-o; | |
| say(`${p.name}升到 ${p.lv} 級!最大 HP +${gain}`,'g');renderAll();training()}}}); | |
| opts.push({t:'返回',f:townMenu}); | |
| showMenu('訓練所',`金幣:${S.gold}\n升級需要足夠經驗值(約每級加倍)並付訓練費。每級 HP 增加 1~職業骰(騎士12、弓手10、牧師8、法師6)。`,opts); | |
| } | |
| function inn(){ | |
| if(S.gold<10){showMenu('旅店','老闆搖頭:「沒錢不能住。」',OK(townMenu));return} | |
| S.gold-=10;P().forEach(p=>{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<c){showMenu('神殿','金幣不夠。',OK(temple));return}S.gold-=c;p.hp=Math.ceil(p.mhp/2);renderAll();showMenu('神殿',`${p.name}重回人間!`,OK(temple))}})); | |
| opts.push({t:'祈福 — 治療全隊 HP (5G)',f:()=>{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<p){showMenu('商店','金幣不夠。',OK(shop));return}S.gold-=p;S.items[k]++;renderAll();shop()} | |
| function gear(f,list,nf){ | |
| showMenu(f==='w'?'武器店':'防具店',`金幣:${S.gold}`,list.map(it=>({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.gold<it.p){showMenu('商店','金幣不夠。',OK(()=>gear(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<q.length;i++){const[x,y]=q[i];for(let d=0;d<4;d++){const nx=x+DX[d],ny=y+DY[d];const c=M.g[ny][nx];if(c!=='#'&&c!=='X'&&!seen.has(nx+','+ny)){seen.add(nx+','+ny);q.push([nx,ny])}}} | |
| out[k]={boss:seen.has(M.far.join(',')),cells:seen.size}}return out}}; | |
| titleMenu(); | |
| })(); | |