File size: 39,683 Bytes
0ef8b55 a58e50a 08343a6 a58e50a 08343a6 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 08343a6 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 08343a6 a58e50a 08343a6 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 a58e50a 0ef8b55 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 | (()=>{
'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;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();
})();
|