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();
})();