HelloSun commited on
Commit
0ef8b55
·
verified ·
1 Parent(s): 1aeda7d

WMM1: Might and Magic 1 like game

Browse files
Files changed (4) hide show
  1. README.md +5 -5
  2. game.js +516 -0
  3. index.html +30 -17
  4. style.css +32 -28
README.md CHANGED
@@ -1,10 +1,10 @@
1
  ---
2
- title: WMM1
3
- emoji: 🚀
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: static
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: WMM1 魔法門傳說 太陽水晶
3
+ emoji: 🔮
4
+ colorFrom: purple
5
+ colorTo: yellow
6
  sdk: static
7
  pinned: false
8
  ---
9
 
10
+ 向《魔法門 I》致敬的第一人稱網格迷宮 RPG。純靜態、所有圖形皆由程式以 12x12 像素精靈與透視多邊形繪製,風格一致。
game.js ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (()=>{
2
+ 'use strict';
3
+ const $=id=>document.getElementById(id);
4
+ const cv=$('view'),cx=cv.getContext('2d');
5
+ const am=$('auto'),ac=am.getContext('2d');
6
+ const W0=320,H0=200,CX=160,CY=100,K=110,MAXF=6;
7
+ const DX=[0,1,0,-1],DY=[-1,0,1,0],DN=['北','東','南','西'];
8
+ const rnd=n=>Math.floor(Math.random()*n),rr=(a,b)=>a+rnd(b-a+1);
9
+ const SOLID='#wDXT123';
10
+ const isSolid=c=>SOLID.includes(c);
11
+ 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}}
12
+
13
+ /* ---------- 像素精靈 (12x12) ---------- */
14
+ const BM={
15
+ blob:["............","............","....oooo....","...oooooo...","..oooooooo..","..owdoowdo..","..oooooooo..",".oooooooooo.",".oooddddooo.",".oooooooooo.","..dddddddd..","............"],
16
+ beast:["............","..o......o..",".ooo....ooo.",".oooooooooo.","..oorooroo..","..oooooooo..","...oowwoo...","...oooooo...","..oo.oo.oo..",".oo..oo..oo.","............","............"],
17
+ human:["....oooo....","...oooooo...","...owoowo...","...oooooo...","....oddo....","..cccccccc..",".o.cccccc.o.",".o.cccccc.o.","...cccccc...","...cc..cc...","...cc..cc...","..ddd..ddd.."],
18
+ skull:["...wwwwww...","..wwwwwwww..",".wwwwwwwwww.",".wddwwwwddw.",".wddwwwwddw.",".wwwwddwwww.","..wwwwwwww..","...wdwdwdw..","..cwwwwwwc..","..cwwwwwwc..","...w.cc.w...","...w....w..."],
19
+ ghost:["....oooo....","...oooooo...","..oooooooo..","..orooooro..","..orooooro..","..oooooooo..","..oooddooo..","..oooooooo..",".oooooooooo.",".oo.oooo.oo.",".o...oo...o.","............"],
20
+ bat:["............","o..........o","oo...oo...oo","ooo.oooo.ooo","oooooooooooo","oo.orooro.oo","o...oooo...o","....owwo....",".....oo.....","............","............","............"],
21
+ golem:["..oo....oo..","..ooo..ooo..","...oooooo...","..oorooroo..","..oooooooo..",".oooooooooo.","oo.oooooo.oo","oo.oooooo.oo","oo.oooooo.oo","...oo..oo...","..ooo..ooo..","..ddd..ddd.."],
22
+ lord:["..y.y.y.y...","..yyyyyyyy..","...oooooo...","...orooro...","...oooooo...",".cccooooccc.","cccccccccccc","ccoccccccocc","cccccccccccc",".cccc..cccc.",".cccc..cccc.",".ddd....ddd."],
23
+ chest:["............","............","............","............","..yyyyyyyy..",".oooooooooo.",".oddddddddo.",".ooooyyoooo.",".oooooooooo.",".oooooooooo.","............","............"]
24
+ };
25
+
26
+ const MON={
27
+ rat:{n:'巨鼠',s:'beast',o:'#8a6a4a',c:'#5a3a2a',hp:8,a:[1,3],d:0,xp:4,g:3},
28
+ wolf:{n:'野狼',s:'beast',o:'#8a8a90',c:'#444',hp:14,a:[2,5],d:0,xp:7,g:4},
29
+ bandit:{n:'強盜',s:'human',o:'#d0a070',c:'#604030',hp:18,a:[3,6],d:1,xp:10,g:12},
30
+ bat:{n:'蝙蝠',s:'bat',o:'#5a3a6a',c:'#222',hp:6,a:[1,3],d:0,xp:3,g:0},
31
+ goblin:{n:'地精',s:'human',o:'#70b040',c:'#6a4a20',hp:14,a:[2,5],d:0,xp:8,g:8},
32
+ gobarch:{n:'地精弓手',s:'human',o:'#60a040',c:'#30602a',hp:12,a:[3,6],d:0,xp:9,g:9},
33
+ slime:{n:'史萊姆',s:'blob',o:'#40c070',c:'#000',hp:20,a:[1,4],d:2,xp:9,g:2},
34
+ gobguard:{n:'地精衛兵',s:'human',o:'#508030',c:'#808890',hp:30,a:[4,8],d:2,xp:20,g:25},
35
+ gobking:{n:'地精王',s:'lord',o:'#70b040',c:'#a03030',hp:120,a:[6,12],d:3,xp:150,g:200,boss:1},
36
+ skel:{n:'骷髏兵',s:'skull',o:'#fff',c:'#a0a0a0',hp:30,a:[5,10],d:2,xp:20,g:15,und:1},
37
+ zombie:{n:'殭屍',s:'human',o:'#7a9a6a',c:'#4a4a5a',hp:45,a:[6,11],d:1,xp:24,g:10,und:1},
38
+ ghoul:{n:'食屍鬼',s:'beast',o:'#9ab0a0',c:'#506050',hp:38,a:[7,12],d:3,xp:30,g:20,und:1},
39
+ wraith:{n:'幽魂',s:'ghost',o:'#90a0e0',c:'#000',hp:30,a:[6,11],d:4,xp:32,g:25,und:1},
40
+ bknight:{n:'骸骨騎士',s:'skull',o:'#d8d0b8',c:'#506080',hp:75,a:[9,15],d:5,xp:70,g:80,und:1},
41
+ ashlord:{n:'灰燼騎士',s:'lord',o:'#c0b8a8',c:'#503030',hp:240,a:[12,20],d:6,xp:400,g:400,boss:1,und:1},
42
+ imp:{n:'小惡魔',s:'bat',o:'#d04040',c:'#222',hp:50,a:[10,16],d:4,xp:45,g:30},
43
+ garg:{n:'石像鬼',s:'golem',o:'#808890',c:'#444',hp:85,a:[12,18],d:8,xp:60,g:35},
44
+ dmage:{n:'黑暗法師',s:'human',o:'#c0a0c0',c:'#402060',hp:55,a:[12,22],d:3,xp:65,g:50},
45
+ golem:{n:'魔像',s:'golem',o:'#a08060',c:'#604020',hp:115,a:[14,20],d:10,xp:80,g:40},
46
+ shadow:{n:'暗影守衛',s:'ghost',o:'#6040a0',c:'#000',hp:150,a:[15,24],d:8,xp:150,g:90},
47
+ vorlag:{n:'大法師弗拉格',s:'lord',o:'#d0b0e0',c:'#301050',hp:520,a:[18,30],d:10,xp:2000,g:1000,boss:1,aoe:1}
48
+ };
49
+
50
+ /* ---------- 地圖 ---------- */
51
+ const WORLD=[
52
+ "####################",
53
+ "#........#.........#",
54
+ "#.1......#....2....#",
55
+ "#........#.........#",
56
+ "#........#####.#####",
57
+ "#..................#",
58
+ "#....wwww..........#",
59
+ "#....wwww....T.....#",
60
+ "#..................#",
61
+ "#.......##.##..###.#",
62
+ "#.......#...#..#...#",
63
+ "#.......##3##..#...#",
64
+ "#..................#",
65
+ "#....#.......ww....#",
66
+ "#....#.......ww....#",
67
+ "#..................#",
68
+ "#..................#",
69
+ "#..................#",
70
+ "#..................#",
71
+ "####################"];
72
+ const ZONES={
73
+ world:{name:'瓦恩原野',tier:0,theme:'out',mons:['rat','wolf','bandit','bat'],sky:['#6aa0e0','#b0d8f0'],fl:['#3f7a35','#5aa045'],wall:[40,120,50]},
74
+ 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]},
75
+ 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]},
76
+ 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]}
77
+ };
78
+ const DUN={cellar:{W:15,H:15,seed:11},crypt:{W:19,H:19,seed:23},tower:{W:21,H:21,seed:37}};
79
+ const NOTES={
80
+ cellar:['牆上刻著:「地精王吞下了銀鑰匙,想用它打開灰燼地穴。」','一張發霉的字條:「別相信史萊姆,它們比看起來更餓。」','塗鴉:「往深處走,最遠的角落有王座。」'],
81
+ crypt:['碑文:「灰燼騎士守著餘燼印記,沒有它,弗拉格之塔不會開啟。」','骨堆旁的日記:「太陽水晶碎裂的那天,天空變成了灰色。」','碑文:「聖光能灼傷不死者。」'],
82
+ tower:['魔法卷軸:「碎片一旦重聚,太陽將再度升起。」','黑板:「大法師弗拉格正在最高層等候你們。」','殘破的日記:「他曾是科拉克的學徒,如今只剩野心。」']
83
+ };
84
+ const MAPS={};
85
+ function gridFromRows(rows){return rows.map(r=>r.split(''))}
86
+ MAPS.world={name:'world',w:20,h:WORLD[0].length,g:gridFromRows(WORLD),zone:'world'};
87
+ MAPS.world.h=WORLD.length;
88
+ function genMaze(name){
89
+ const {W,H,seed}=DUN[name],rng=mulberry(seed),Z=ZONES[name];
90
+ const g=[];for(let y=0;y<H;y++)g.push(Array(W).fill('#'));
91
+ const st=[[1,1]];g[1][1]='.';
92
+ while(st.length){
93
+ const [x,y]=st[st.length-1],nb=[];
94
+ 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)}
95
+ if(!nb.length){st.pop();continue}
96
+ const d=nb[Math.floor(rng()*nb.length)];
97
+ 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]);
98
+ }
99
+ for(let y=1;y<H-1;y++)for(let x=1;x<W-1;x++){
100
+ if(g[y][x]!=='#')continue;
101
+ const h=x%2===0&&y%2===1&&g[y][x-1]==='.'&&g[y][x+1]==='.';
102
+ const v=y%2===0&&x%2===1&&g[y-1][x]==='.'&&g[y+1][x]==='.';
103
+ if((h||v)&&rng()<.1)g[y][x]='.';
104
+ }
105
+ const dist=Array.from({length:H},()=>Array(W).fill(-1));dist[1][1]=0;
106
+ const q=[[1,1]];let far=[1,1];
107
+ 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];
108
+ 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])}}}
109
+ const open=(x,y)=>[0,1,2,3].filter(d=>g[y+DY[d]][x+DX[d]]!=='#').length;
110
+ const dead=[];
111
+ 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]);
112
+ for(let i=dead.length-1;i>0;i--){const j=Math.floor(rng()*(i+1));[dead[i],dead[j]]=[dead[j],dead[i]]}
113
+ g[far[1]][far[0]]='B';
114
+ const kinds=['H','c','M','c','n','M','c','n','c','M'];
115
+ dead.forEach((p,i)=>{g[p[1]][p[0]]=kinds[i%kinds.length]});
116
+ for(let y=2;y<H-2;y++)for(let x=2;x<W-2;x++){
117
+ if(g[y][x]!=='.'||dist[y][x]<4)continue;
118
+ const hz=g[y][x-1]!=='#'&&g[y][x+1]!=='#'&&g[y-1][x]==='#'&&g[y+1][x]==='#';
119
+ const vt=g[y-1][x]!=='#'&&g[y+1][x]!=='#'&&g[y][x-1]==='#'&&g[y][x+1]==='#';
120
+ if((hz||vt)&&rng()<.06)g[y][x]='D';
121
+ }
122
+ g[1][0]='X';
123
+ return {name,w:W,h:H,g,zone:name,far};
124
+ }
125
+ for(const k in DUN)MAPS[k]=genMaze(k);
126
+
127
+ /* ---------- 職業 ---------- */
128
+ const CLS={
129
+ knight:{n:'騎士',hp:[36,9],sp:[6,1.5],atk:[6,1.8],spells:[{n:'強擊',c:4,lv:1,k:'power'}]},
130
+ cleric:{n:'牧師',hp:[26,6],sp:[10,3],atk:[4,1],spells:[{n:'治療',c:3,lv:1,k:'heal'},{n:'聖光',c:3,lv:1,k:'holy'},{n:'群體治療',c:8,lv:4,k:'mheal'}]},
131
+ sorc:{n:'法師',hp:[20,4],sp:[14,3.5],atk:[2,.6],spells:[{n:'火花',c:2,lv:1,k:'spark'},{n:'火球術',c:6,lv:3,k:'fire'}]},
132
+ archer:{n:'弓手',hp:[28,6.5],sp:[6,2],atk:[5,1.5],spells:[{n:'連射',c:3,lv:1,k:'volley'}]}
133
+ };
134
+ const WEAP=[{n:'鐵劍',b:3,p:60},{n:'鋼劍',b:6,p:200},{n:'秘銀劍',b:10,p:600}];
135
+ const ARM=[{n:'皮甲',b:1,p:40},{n:'鎖甲',b:3,p:150},{n:'板甲',b:6,p:450},{n:'秘銀甲',b:9,p:1000}];
136
+ const atkOf=p=>Math.floor(CLS[p.cls].atk[0]+CLS[p.cls].atk[1]*(p.lv-1));
137
+ const mspOf=p=>Math.floor(CLS[p.cls].sp[0]+CLS[p.cls].sp[1]*(p.lv-1));
138
+ function mkChar(name,cls){const c=CLS[cls];return{name,cls,lv:1,xp:0,hp:c.hp[0],mhp:c.hp[0],sp:c.sp[0],msp:c.sp[0],w:0,a:0,wn:'木棍',an:'布衣',dg:false}}
139
+
140
+ /* ---------- 狀態 ---------- */
141
+ let S=null,C=null,MENU=null;
142
+ function newState(){return{map:'world',x:13,y:9,d:0,gold:100,items:{potion:3,ether:1},keys:{},mod:{},vis:{},steps:0,cd:3,mode:'explore',ret:null,won:false,
143
+ party:[mkChar('雷歐','knight'),mkChar('莉娜','cleric'),mkChar('馬格','sorc'),mkChar('席拉','archer')]}}
144
+ const SAVE='wmm1_save_v1';
145
+ function save(){try{localStorage.setItem(SAVE,JSON.stringify(S))}catch(e){}}
146
+ function load(){try{const t=localStorage.getItem(SAVE);return t?JSON.parse(t):null}catch(e){return null}}
147
+ 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]};
148
+ const setCell=(x,y,c)=>{S.mod[S.map+':'+x+','+y]=c};
149
+ const zone=()=>ZONES[MAPS[S.map].zone];
150
+ const P=()=>S.party;
151
+ const alive=()=>P().filter(p=>p.hp>0);
152
+
153
+ /* ---------- 記錄 ---------- */
154
+ 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}
155
+
156
+ /* ---------- 選單 ---------- */
157
+ function showMenu(title,text,opts){
158
+ MENU=opts;$('ovTitle').textContent=title;$('ovText').textContent=text;
159
+ const o=$('ovOpts');o.innerHTML='';
160
+ opts.forEach((op,i)=>{const b=document.createElement('button');b.textContent=(i+1)+'. '+op.t;b.onclick=()=>pick(i);o.appendChild(b)});
161
+ $('overlay').hidden=false;
162
+ }
163
+ function hideMenu(){MENU=null;$('overlay').hidden=true}
164
+ function pick(i){const op=MENU&&MENU[i];if(!op)return;hideMenu();op.f&&op.f()}
165
+ const OK=f=>[{t:'確定',f}];
166
+
167
+ /* ---------- 指令列 ---------- */
168
+ 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)})}
169
+ 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'}]])}
170
+
171
+ /* ---------- 繪圖:像素 ---------- */
172
+ function bitmap(bm,pal,x,y,px){
173
+ 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))}
174
+ }
175
+ const palOf=m=>({o:m.o,c:m.c,d:'#1a1020',w:'#f0f0e8',r:'#e03030',y:'#f0c030'});
176
+ const sh=(rgb,f)=>`rgb(${rgb.map(v=>Math.round(v*f)).join(',')})`;
177
+ const sx=(x,z)=>CX+x*K/z,sy=(y,z)=>CY+y*K/z;
178
+ const fade=z=>Math.max(.22,1-z/7.5);
179
+
180
+ function drawBg(z){
181
+ 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);
182
+ 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);
183
+ }
184
+ function baseColor(c,Z){
185
+ if(c==='w')return[50,100,200];
186
+ if(c==='D')return[130,85,40];
187
+ if(c==='X')return[200,190,150];
188
+ if(c==='T')return[190,160,110];
189
+ if(c==='1')return[110,90,70];
190
+ if(c==='2')return[80,85,100];
191
+ if(c==='3')return[110,70,140];
192
+ return Z.wall;
193
+ }
194
+ function front(f,l,c,Z){
195
+ 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);
196
+ cx.fillStyle=sh(baseColor(c,Z),fd);cx.fillRect(x1,y1,w,h);
197
+ cx.strokeStyle='rgba(0,0,0,.55)';cx.lineWidth=1;
198
+ if(c==='#'||c==='w'){
199
+ cx.beginPath();
200
+ for(let i=1;i<4;i++){const y=y1+h*i/4;cx.moveTo(x1,y);cx.lineTo(x2,y);
201
+ const off=(i%2)?w/3:w*2/3;cx.moveTo(x1+off,y);cx.lineTo(x1+off,y+h/4);}
202
+ cx.stroke();
203
+ 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()}}
204
+ }
205
+ 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)}
206
+ 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)}
207
+ 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();
208
+ 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)}
209
+ 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();
210
+ if(c==='3'){cx.fillStyle=sh([150,110,190],fd);cx.fillRect(x1+w*.4,y1-h*.25,w*.2,h*.3)}}
211
+ cx.strokeRect(x1,y1,w,h);
212
+ }
213
+ function side(f,l,left,c,Z){
214
+ const xs=left?l-.5:l+.5,za=Math.max(.3,f-.5),zb=f+.5,fd=fade((za+zb)/2)*.78;
215
+ cx.fillStyle=sh(baseColor(c,Z),fd);
216
+ 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();
217
+ cx.strokeStyle='rgba(0,0,0,.55)';cx.stroke();
218
+ 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()}
219
+ 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()}
220
+ }
221
+ function sprite(f,l,c,Z){
222
+ const z=f,x=l,s=K/z;cx.globalAlpha=fade(z);
223
+ 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)}
224
+ 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)}
225
+ 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)}
226
+ 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)}
227
+ cx.globalAlpha=1;
228
+ }
229
+ function drawExplore(){
230
+ const Z=zone();drawBg(Z);
231
+ 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);
232
+ const ls=[];for(let l=-6;l<=6;l++)ls.push(l);ls.sort((a,b)=>Math.abs(b)-Math.abs(a));
233
+ for(let f=MAXF;f>=0;f--)for(const l of ls){
234
+ if(f===0&&l!==0)continue;
235
+ const c=at(f,l);
236
+ if(isSolid(c)){if(f>0)front(f,l,c,Z)}
237
+ else{
238
+ if(l<=0){const n=at(f,l-1);if(isSolid(n))side(f,l,true,n,Z)}
239
+ if(l>=0){const n=at(f,l+1);if(isSolid(n))side(f,l,false,n,Z)}
240
+ if(f>0&&'cMBHn'.includes(c))sprite(f,l,c,Z);
241
+ }
242
+ }
243
+ }
244
+ function drawCombat(){
245
+ const Z=zone();cx.fillStyle='#0a0612';cx.fillRect(0,0,W0,H0);
246
+ 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);
247
+ const en=C.en,n=en.length,big=en.some(e=>e.boss);
248
+ const px=big?9:n>3?3.6:4.6,w=px*12;
249
+ en.forEach((e,i)=>{
250
+ const x=(W0/n)*(i+.5)-w/2,y=big?30:60;
251
+ cx.globalAlpha=e.hp>0?1:.15;bitmap(BM[e.s],palOf(e),x,y,px);cx.globalAlpha=1;
252
+ 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);
253
+ cx.fillStyle='#f0e0b0';cx.font='10px monospace';cx.textAlign='center';cx.fillText(e.n,x+w/2,y+w+18);cx.textAlign='left'}
254
+ });
255
+ }
256
+ function drawTown(){
257
+ drawBg({sky:['#6aa0e0','#c8e4f8'],fl:['#6a8a40','#4a6a30']});
258
+ const bl=[[20,'旅店','#b06040'],[120,'神殿','#d0d0e0'],[220,'商店','#c09030']];
259
+ 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();
260
+ 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);
261
+ cx.fillStyle='#fff';cx.font='12px monospace';cx.textAlign='center';cx.fillText(t,x+40,66);cx.textAlign='left'});
262
+ cx.fillStyle='#b8a070';cx.fillRect(0,140,W0,20);
263
+ }
264
+ function drawTitle(){
265
+ 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);
266
+ 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();
267
+ cx.fillStyle='#000';cx.fillRect(0,150,W0,50);
268
+ 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';
269
+ }
270
+ function drawScene(){
271
+ if(!S){drawTitle();return}
272
+ if(S.mode==='combat'&&C)drawCombat();else if(S.mode==='town')drawTown();else drawExplore();
273
+ }
274
+ function drawAuto(){
275
+ ac.fillStyle='#050308';ac.fillRect(0,0,160,160);
276
+ if(!S||S.mode==='town')return;
277
+ const M=MAPS[S.map],cs=Math.floor(160/Math.max(M.w,M.h)),v=S.vis[S.map]||{};
278
+ for(let y=0;y<M.h;y++)for(let x=0;x<M.w;x++){
279
+ if(!v[x+','+y])continue;const c=cellAt(S.map,x,y);
280
+ ac.fillStyle=isSolid(c)?(c==='#'?'#4a4258':c==='w'?'#2a4a9a':'#c8a040'):'#1e1a28';
281
+ if(!isSolid(c)&&'cMBHn'.includes(c))ac.fillStyle='#d0a030';
282
+ ac.fillRect(x*cs,y*cs,cs,cs);
283
+ }
284
+ ac.fillStyle='#ff4040';ac.fillRect(S.x*cs,S.y*cs,cs,cs);
285
+ 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);
286
+ }
287
+ function markVis(){
288
+ const v=S.vis[S.map]||(S.vis[S.map]={});
289
+ for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++)v[(S.x+dx)+','+(S.y+dy)]=1;
290
+ const r=(S.d+1)%4;
291
+ 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;
292
+ for(const l of[-1,1]){const a=x+DX[r]*l,b=y+DY[r]*l;v[a+','+b]=1}}
293
+ }
294
+ function renderParty(){
295
+ const el=$('party');el.innerHTML='';if(!S)return;
296
+ P().forEach((p,i)=>{
297
+ const d=document.createElement('div');d.className='pc'+(p.hp<=0?' dead':'')+(S.mode==='combat'&&C&&C.i===i?' act':'');
298
+ 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}/${35*p.lv*p.lv}</div>`;
299
+ el.appendChild(d);
300
+ });
301
+ $('where').textContent=S.mode==='town'?'索皮加爾鎮 金幣 '+S.gold:`${zone().name} (${S.x},${S.y}) 朝${DN[S.d]} 金幣 ${S.gold} 藥���${S.items.potion} 魔水${S.items.ether}`;
302
+ }
303
+ function renderAll(){drawScene();renderParty();drawAuto()}
304
+
305
+ /* ---------- 移動 ---------- */
306
+ function turn(t){if(!canAct())return;S.d=(S.d+t+4)%4;markVis();renderAll()}
307
+ const canAct=()=>S&&S.mode==='explore'&&!MENU;
308
+ function move(rel){
309
+ if(!canAct())return;
310
+ const d=(S.d+rel)%4,nx=S.x+DX[d],ny=S.y+DY[d],c=cellAt(S.map,nx,ny);
311
+ if(isSolid(c)){bump(c,nx,ny,d);renderAll();return}
312
+ S.x=nx;S.y=ny;S.steps++;if(S.cd>0)S.cd--;markVis();
313
+ renderAll();
314
+ if(event(c,nx,ny))return;
315
+ const z=zone(),ch=z.tier===0?1/14:1/10;
316
+ if(S.cd<=0&&Math.random()<ch)randomFight();
317
+ }
318
+ function bump(c,x,y,d){
319
+ if(c==='#'||c==='w'){say(c==='w'?'水太深了,過不去。':'前面被擋住了。');return}
320
+ if(c==='D'){setCell(x,y,'.');say('你推開了門。');return}
321
+ if(c==='T'){S.mode='town';renderAll();townMenu();return}
322
+ 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}
323
+ const dn={'1':'cellar','2':'crypt','3':'tower'}[c];
324
+ if(c==='2'&&!S.keys.silver){showMenu('地穴大門','大門被一把銀製的鎖封住了。\n(需要銀鑰匙)',OK());return}
325
+ if(c==='3'&&!S.keys.ember){showMenu('黑塔大門','大門上有一個燃燒的凹槽。\n(需要餘燼印記)',OK());return}
326
+ 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};
327
+ 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:'還是算了'}]);
328
+ }
329
+ function event(c,x,y){
330
+ const z=zone();
331
+ if(c==='c'){setCell(x,y,'.');chest(z);renderAll();return true}
332
+ 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}
333
+ if(c==='n'){setCell(x,y,'.');const ns=NOTES[S.map];say(ns?ns[rnd(ns.length)]:'一塊無字的石板。','g');return true}
334
+ if(c==='M'){setCell(x,y,'.');say('伏兵出現了!','b');startCombat(z.elite.concat(Math.random()<.5?[z.mons[rnd(4)]]:[]),{});return true}
335
+ if(c==='B'){say('你們面對守護者!','b');startCombat([z.boss],{boss:S.map});return true}
336
+ return false;
337
+ }
338
+ function chest(z){
339
+ const t=Math.max(1,z.tier),gold=rr(15,40)*t;S.gold+=gold;say(`打開寶箱,得到 ${gold} 金幣!`,'g');
340
+ if(Math.random()<.4){S.items.potion++;say('還找到了一瓶藥水。','g')}
341
+ if(Math.random()<.2){S.items.ether++;say('還找到了一瓶魔力水。','g')}
342
+ if(Math.random()<.2){const p=alive()[rnd(alive().length)];if(p){const d=rr(3,8)*t;p.hp=Math.max(1,p.hp-d);say(`陷阱!${p.name}受到 ${d} 點傷害。`,'b')}}
343
+ }
344
+ function randomFight(){
345
+ const z=zone(),n=Math.min(5,rr(1,z.tier===0?3:2+z.tier)),ids=[];
346
+ for(let i=0;i<n;i++)ids.push(z.mons[rnd(z.mons.length)]);
347
+ say('遭遇敵人!','b');startCombat(ids,{});
348
+ }
349
+
350
+ /* ---------- 戰鬥 ---------- */
351
+ function startCombat(ids,opts){
352
+ C={en:ids.map(id=>({...MON[id],id,mhp:MON[id].hp})),i:-1,opts,round:1};
353
+ S.mode='combat';P().forEach(p=>p.dg=false);
354
+ say('敵人:'+C.en.map(e=>e.n).join('、'));nextActor();
355
+ }
356
+ function nextActor(){
357
+ if(checkEnd())return;
358
+ C.i++;while(C.i<P().length&&P()[C.i].hp<=0)C.i++;
359
+ if(C.i>=P().length){enemyPhase();return}
360
+ cmdMain();renderAll();
361
+ }
362
+ function cmdMain(){
363
+ const p=P()[C.i];
364
+ setCmd([[`${p.name}:攻擊`,()=>pickEnemy(e=>{attack(p,e);nextActor()})],['施法',()=>cmdSpell(p)],['物品',()=>cmdItem(p)],['防禦',()=>{p.dg=true;say(`${p.name}採取防禦姿態。`);nextActor()}],['逃跑',()=>run(p)]]);
365
+ }
366
+ function pickEnemy(cb){
367
+ const l=C.en.filter(e=>e.hp>0);if(l.length===1){cb(l[0]);return}
368
+ setCmd(l.map(e=>[`${e.n} (${e.hp})`,()=>cb(e)]).concat([['返回',cmdMain]]));
369
+ }
370
+ function pickAlly(cb,dead){
371
+ setCmd(P().filter(p=>dead?p.hp<=0:p.hp>0).map(p=>[`${p.name} (${p.hp}/${p.mhp})`,()=>cb(p)]).concat([['返回',cmdMain]]));
372
+ }
373
+ const physDmg=p=>atkOf(p)+p.w+rr(1,6);
374
+ function dmgEnemy(e,d,pierce){
375
+ d=Math.max(1,d-(pierce?0:e.d));e.hp-=d;return d;
376
+ }
377
+ function attack(p,e){
378
+ if(Math.random()<.1){say(`${p.name}揮空了。`);return}
379
+ const d=dmgEnemy(e,physDmg(p));say(`${p.name}攻擊${e.n},造成 ${d} 點傷害。`);if(e.hp<=0)say(`${e.n}倒下了!`,'g');
380
+ }
381
+ function cmdSpell(p){
382
+ const sp=CLS[p.cls].spells.filter(s=>s.lv<=p.lv);
383
+ setCmd(sp.map(s=>[`${s.n}(${s.c})`,()=>cast(p,s),p.sp<s.c]).concat([['返回',cmdMain]]));
384
+ }
385
+ function cast(p,s){
386
+ const fin=()=>{p.sp-=s.c;nextActor()},lv=p.lv,liv=()=>C.en.filter(e=>e.hp>0);
387
+ const hit=(e,d,pierce)=>{const x=dmgEnemy(e,d,pierce);say(`${p.name}的${s.n}命中${e.n},${x} 點傷害。`);if(e.hp<=0)say(`${e.n}倒下了!`,'g')};
388
+ switch(s.k){
389
+ case 'power':pickEnemy(e=>{hit(e,Math.floor(physDmg(p)*1.8));fin()});break;
390
+ case 'spark':pickEnemy(e=>{hit(e,6+lv*3+rnd(6),true);fin()});break;
391
+ case 'holy':pickEnemy(e=>{hit(e,Math.floor((5+lv*2.5+rnd(6))*(e.und?1.8:1)),true);fin()});break;
392
+ case 'fire':liv().forEach(e=>hit(e,Math.floor(4+lv*2.5+rnd(6)),true));fin();break;
393
+ case 'volley':for(let i=0;i<2;i++){const l=liv();if(l.length)hit(l[rnd(l.length)],Math.floor(physDmg(p)*.9))}fin();break;
394
+ case 'heal':pickAlly(t=>{const h=14+lv*4;t.hp=Math.min(t.mhp,t.hp+h);say(`${p.name}治療了${t.name},恢復 ${h} HP。`,'s');fin()});break;
395
+ case 'mheal':alive().forEach(t=>{const h=10+lv*3;t.hp=Math.min(t.mhp,t.hp+h)});say(`${p.name}施放群體治療!`,'s');fin();break;
396
+ }
397
+ }
398
+ function cmdItem(p){
399
+ 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],
400
+ [`魔力水 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]]);
401
+ }
402
+ function run(p){
403
+ if(C.opts.boss){say('無法從守護者面前逃走!','b');nextActor();return}
404
+ if(Math.random()<.55){say('全隊成功逃脫。');endCombat();}else{say(`${p.name}沒能逃掉。`);nextActor()}
405
+ }
406
+ function hurt(p,d,pierce){
407
+ d=Math.max(1,d-(pierce?0:p.a+(p.dg?3+Math.floor(p.lv/2):0)));p.hp-=d;say(`${p.name}受到 ${d} 點傷害。`,'b');
408
+ if(p.hp<=0){p.hp=0;say(`${p.name}倒下了!`,'b')}
409
+ }
410
+ function enemyPhase(){
411
+ for(const e of C.en){
412
+ if(e.hp<=0)continue;const al=alive();if(!al.length)break;
413
+ if(e.aoe&&Math.random()<.3){say(`${e.n}施放黑暗風暴!`,'b');al.forEach(p=>hurt(p,rr(e.a[0],e.a[1])-4,true));continue}
414
+ const t=al[rnd(al.length)];
415
+ if(Math.random()<.85)hurt(t,rr(e.a[0],e.a[1]));else say(`${e.n}攻擊${t.name}但沒有命中。`);
416
+ }
417
+ P().forEach(p=>p.dg=false);C.i=-1;C.round++;nextActor();
418
+ }
419
+ function checkEnd(){
420
+ if(!alive().length){gameOver();return true}
421
+ if(C.en.every(e=>e.hp<=0)){victory();return true}
422
+ return false;
423
+ }
424
+ function endCombat(){S.mode='explore';S.cd=3;C=null;cmdExplore();renderAll()}
425
+ function victory(){
426
+ const xp=C.en.reduce((a,e)=>a+e.xp,0),g=C.en.reduce((a,e)=>a+e.g,0),boss=C.opts.boss;
427
+ S.gold+=g;say(`勝利!每人獲得 ${xp} 經驗,戰利品 ${g} 金幣。`,'g');
428
+ alive().forEach(p=>{p.xp+=xp;while(p.xp>=35*p.lv*p.lv){p.lv++;const c=CLS[p.cls],o=p.msp;p.mhp+=Math.round(c.hp[1])+rnd(3);p.msp=mspOf(p);p.hp=Math.min(p.mhp,p.hp+Math.round(c.hp[1]));p.sp=Math.min(p.msp,p.sp+p.msp-o);say(`${p.name}升到了 ${p.lv} 級!`,'g')}});
429
+ const b=C.opts.boss;endCombat();
430
+ if(b==='cellar'){setCell(MAPS.cellar.far[0],MAPS.cellar.far[1],'.');S.keys.silver=true;showMenu('地精王倒下了','你們從地精王的肚子裡找到了【銀鑰匙】!\n它能打開原野東北方的灰燼地穴。',OK(save))}
431
+ else if(b==='crypt'){setCell(MAPS.crypt.far[0],MAPS.crypt.far[1],'.');S.keys.ember=true;showMenu('灰燼騎士安息了','騎士的鎧甲碎裂,露出一枚燃燒的【餘燼印記】。\n它能開啟原野中央的弗拉格之塔。',OK(save))}
432
+ else if(b==='tower'){S.won=true;save();showMenu('太陽再度升起','弗拉格倒下,太陽水晶的碎片在塔頂重聚,迸出萬丈金光。\n瓦恩的天空恢復了藍色,索皮加爾鎮傳來歡呼聲。\n\n~ 全劇終 ~\n(你仍可繼續探索與練功)',[{t:'繼續冒險'},{t:'重新開始',f:()=>titleMenu(true)}])}
433
+ }
434
+ function gameOver(){
435
+ S.mode='explore';C=null;renderAll();setCmd([]);
436
+ showMenu('全軍覆沒','你們的冒險在此中斷……',[{t:'讀取存檔',f:()=>{const s=load();if(s){S=s;S.mode='explore';cmdExplore();renderAll()}else titleMenu(true)}},{t:'重新開始',f:()=>titleMenu(true)}]);
437
+ }
438
+
439
+ /* ---------- 城鎮 ---------- */
440
+ function leaveTown(){S.mode='explore';S.d=2;S.cd=3;markVis();save();cmdExplore();renderAll()}
441
+ function townMenu(){
442
+ setCmd([]);
443
+ showMenu('索皮加爾鎮',`金幣:${S.gold}\n要去哪裡?`,[
444
+ {t:'旅店 — 休息並存檔 (10G)',f:inn},{t:'神殿 — 復活 / 治療',f:temple},{t:'商店 — 武器 / 防具 / 藥水',f:shop},{t:'酒館 — 打聽消息',f:tavern},{t:'離開城鎮',f:leaveTown}]);
445
+ }
446
+ function inn(){
447
+ if(S.gold<10){showMenu('旅店','老闆搖頭:「沒錢不能住。」',OK(townMenu));return}
448
+ S.gold-=10;P().forEach(p=>{if(p.hp>0){p.hp=p.mhp;p.sp=p.msp}});save();renderAll();
449
+ showMenu('旅店','你們睡了一覺,所有存活的隊員恢復了體力。\n(已存檔)',OK(townMenu));
450
+ }
451
+ function temple(){
452
+ const dead=P().filter(p=>p.hp<=0);
453
+ 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))}}));
454
+ 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))}});
455
+ opts.push({t:'返回',f:townMenu});showMenu('神殿',`金幣:${S.gold}`,opts);
456
+ }
457
+ function shop(){
458
+ showMenu('商店',`金幣:${S.gold}`,[
459
+ {t:'藥水 (20G) 恢復40HP',f:()=>buyItem('potion',20)},{t:'魔力水 (30G) 恢復15SP',f:()=>buyItem('ether',30)},
460
+ {t:'武器',f:()=>gear('w',WEAP,'wn')},{t:'防具',f:()=>gear('a',ARM,'an')},{t:'返回',f:townMenu}]);
461
+ }
462
+ function buyItem(k,p){if(S.gold<p){showMenu('商店','金幣不夠。',OK(shop));return}S.gold-=p;S.items[k]++;renderAll();shop()}
463
+ function gear(f,list,nf){
464
+ showMenu(f==='w'?'武器店':'防具店',`金幣:${S.gold}`,list.map(it=>({t:`${it.n} +${it.b} (${it.p}G)`,f:()=>
465
+ showMenu('裝備給誰?',it.n,P().map(p=>({t:`${p.name} (${p[f]>=it.b?'已有更好':'+'+p[f]})`,f:()=>{
466
+ if(p[f]>=it.b){showMenu('商店','他已經有更好的裝備了。',OK(()=>gear(f,list,nf)));return}
467
+ if(S.gold<it.p){showMenu('商店','金幣不夠。',OK(()=>gear(f,list,nf)));return}
468
+ 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}]));
469
+ }
470
+ function tavern(){
471
+ let t='酒保壓低聲音:\n';
472
+ if(S.won)t+='「英雄!太陽又升起來了!今晚的酒算我的。」';
473
+ else if(!S.keys.silver)t+='「天空自從太陽水晶碎裂後就一直灰濛濛的。大法師弗拉格偷走了碎片……\n聽說西北方的鼠穴地窖裡,地精王吞了一把銀鑰匙。」';
474
+ else if(!S.keys.ember)t+='「有了銀鑰匙,就能進入東北方的灰燼地穴。那裡的騎士守著一枚餘燼印記。」';
475
+ else t+='「餘燼印記能開啟原野中央的黑塔。弗拉格就在頂端……祝你們好運。」';
476
+ showMenu('酒館',t+'\n\n(提示:先在原野練功,買裝備再下地牢。聖光對不死者特別有效。)',OK(townMenu));
477
+ }
478
+
479
+ /* ---------- 物品 ---------- */
480
+ function itemMenu(){
481
+ if(!canAct())return;
482
+ showMenu('物品',`藥水 x${S.items.potion} 魔力水 x${S.items.ether}`,[
483
+ {t:'使用藥水 (+40HP)',f:()=>useOn('potion')},{t:'使用魔力水 (+15SP)',f:()=>useOn('ether')},{t:'關閉'}]);
484
+ }
485
+ function useOn(k){
486
+ if(S.items[k]<1){showMenu('物品','沒有了。',OK(itemMenu));return}
487
+ showMenu('對誰使用?','',P().filter(p=>p.hp>0).map(p=>({t:`${p.name} HP${p.hp}/${p.mhp} SP${p.sp}/${p.msp}`,f:()=>{
488
+ 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}]));
489
+ }
490
+
491
+ /* ---------- 標題 / 故事 ---------- */
492
+ function startGame(s){
493
+ S=s;S.mode='explore';C=null;markVis();cmdExplore();renderAll();
494
+ }
495
+ function titleMenu(fresh){
496
+ S=null;C=null;setCmd([]);renderAll();$('log').innerHTML='';
497
+ const opts=[{t:'新遊戲',f:()=>{startGame(newState());showMenu('序章',STORY,OK(()=>{say('你們站在索皮加爾鎮外的原野上。北方就是鎮子。','g');save()}))}}];
498
+ if(load())opts.push({t:'繼續遊戲',f:()=>{startGame(load());say('讀取存檔。','g')}});
499
+ showMenu('魔法門傳說:太陽水晶','一款向《魔法門 I》致敬的第一人稱迷宮 RPG。',opts);
500
+ }
501
+ const STORY='瓦恩大陸的天空曾有一顆永不熄滅的太陽水晶。\n\n大法師科拉克的叛徒學徒弗拉格,在一夜之間擊碎了水晶,將碎片帶回自己的黑塔。從那天起,天空染成灰色,怪物從地底湧出。\n\n你們四位冒險者受索皮加爾鎮的長老之託,要找回碎片。\n\n目標:鼠穴地窖的地精王 → 灰燼地穴的灰燼騎士 → 弗拉格之塔。\n\n先到北方的鎮子補給吧!';
502
+
503
+ /* ---------- 鍵盤 ---------- */
504
+ document.addEventListener('keydown',e=>{
505
+ const k=e.key;
506
+ if(MENU){if(/^[1-9]$/.test(k))pick(+k-1);return}
507
+ if(!S||S.mode!=='explore')return;
508
+ 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'}};
509
+ if(m[k]){e.preventDefault();m[k]()}
510
+ });
511
+
512
+ window.__G={get S(){return S},get C(){return C},MAPS,MON,startGame,newState,move,turn,pick,get MENU(){return MENU},checkMaps(){
513
+ 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])}}}
514
+ out[k]={boss:seen.has(M.far.join(',')),cells:seen.size}}return out}};
515
+ titleMenu();
516
+ })();
index.html CHANGED
@@ -1,19 +1,32 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="zh-Hant">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>WMM1 · 魔法門傳說:太陽水晶</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ </head>
9
+ <body>
10
+ <div id="app">
11
+ <div id="left">
12
+ <canvas id="view" width="320" height="200"></canvas>
13
+ <div id="cmd"></div>
14
+ <div id="hint">WASD/方向鍵移動 Q/E 側移 I 物品 M 地圖 S 後退</div>
15
+ </div>
16
+ <div id="right">
17
+ <div id="where"></div>
18
+ <div id="party"></div>
19
+ <canvas id="auto" width="160" height="160"></canvas>
20
+ <div id="log"></div>
21
+ </div>
22
+ </div>
23
+ <div id="overlay" hidden>
24
+ <div id="box">
25
+ <h2 id="ovTitle"></h2>
26
+ <div id="ovText"></div>
27
+ <div id="ovOpts"></div>
28
+ </div>
29
+ </div>
30
+ <script src="game.js"></script>
31
+ </body>
32
  </html>
style.css CHANGED
@@ -1,28 +1,32 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
 
 
 
 
1
+ *{box-sizing:border-box}
2
+ body{margin:0;background:#0b0710;color:#e8dcc0;font-family:"Courier New","Noto Sans Mono CJK TC",monospace;font-size:14px}
3
+ #app{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;padding:10px;max-width:1040px;margin:0 auto}
4
+ #left{flex:0 1 640px;min-width:300px}
5
+ #right{flex:1 1 300px;min-width:280px;display:flex;flex-direction:column;gap:6px}
6
+ #view{width:100%;aspect-ratio:8/5;image-rendering:pixelated;border:4px double #8a6a2a;background:#000;display:block}
7
+ #cmd{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px;min-height:40px}
8
+ #hint{color:#8a7a5a;font-size:12px;margin-top:4px}
9
+ button{background:#2a1c3a;color:#f0e0b0;border:2px outset #8a6a2a;padding:7px 10px;font:inherit;cursor:pointer}
10
+ button:hover{background:#432a5a}
11
+ button:active{border-style:inset}
12
+ button:disabled{opacity:.4}
13
+ #where{color:#f0c030;font-weight:bold}
14
+ #party{display:flex;flex-direction:column;gap:3px}
15
+ .pc{border:2px solid #4a3a2a;background:#160f20;padding:3px 6px;font-size:13px}
16
+ .pc.act{border-color:#f0c030;background:#2a2010}
17
+ .pc.dead{color:#e05050;opacity:.7}
18
+ .bar{height:5px;background:#2a2020;margin-top:2px}
19
+ .bar i{display:block;height:100%;background:#d04040}
20
+ .bar.sp i{background:#4080e0}
21
+ .eq{color:#8a7a5a;font-size:11px}
22
+ #auto{width:160px;height:160px;image-rendering:pixelated;border:2px solid #4a3a2a;background:#050308;align-self:flex-start}
23
+ #log{flex:1;min-height:120px;max-height:200px;overflow-y:auto;border:2px solid #4a3a2a;background:#0e0a14;padding:5px;font-size:13px;line-height:1.4}
24
+ #log .g{color:#f0c030}#log .b{color:#ff7070}#log .s{color:#70c0ff}
25
+ #overlay{position:fixed;inset:0;background:rgba(0,0,0,.75);display:flex;align-items:center;justify-content:center;z-index:9}
26
+ #overlay[hidden]{display:none}
27
+ #box{background:#160f20;border:4px double #c8a040;padding:16px;max-width:520px;width:92%;max-height:90vh;overflow-y:auto}
28
+ #box h2{margin:0 0 8px;color:#f0c030}
29
+ #ovText{white-space:pre-wrap;line-height:1.5;margin-bottom:10px}
30
+ #ovOpts{display:flex;flex-direction:column;gap:6px}
31
+ #ovOpts button{text-align:left}
32
+ @media(max-width:700px){#auto{display:none}}