HelloSun commited on
Commit
22b2557
·
verified ·
1 Parent(s): fdd27d1

Upload ag1.html with huggingface_hub

Browse files
Files changed (1) hide show
  1. ag1.html +516 -0
ag1.html ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>天使帝國 · 瓦爾哈拉防衛戰 | 第一關</title>
7
+ <style>
8
+ :root{
9
+ --gold:#f5d76e; --gold-d:#b8860b; --bg:#0d1023; --panel:#1a2040;
10
+ --blue:#4da6ff; --red:#ff6b6b; --green:#7bed6f;
11
+ }
12
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
13
+ body{
14
+ margin:0;background:radial-gradient(1200px 600px at 50% -10%,#2a3570 0%,#0d1023 60%) fixed,var(--bg);
15
+ color:#eef;font-family:"Noto Sans TC","PingFang TC","Microsoft JhengHei",sans-serif;
16
+ display:flex;flex-direction:column;align-items:center;min-height:100vh;padding:10px;
17
+ }
18
+ h1{margin:8px 0 2px;font-size:22px;letter-spacing:4px;color:var(--gold);text-shadow:0 0 12px #b8860b88,2px 2px 0 #000}
19
+ .sub{font-size:12px;opacity:.8;letter-spacing:2px;margin-bottom:8px}
20
+ #wrap{width:100%;max-width:860px}
21
+ #story{
22
+ background:linear-gradient(180deg,#232a55,#141936);border:2px solid var(--gold-d);
23
+ border-radius:12px;padding:14px 16px;margin-bottom:10px;box-shadow:0 4px 24px #000a;
24
+ }
25
+ #story h2{margin:0 0 6px;font-size:16px;color:var(--gold)}
26
+ #story p{font-size:14px;line-height:1.7;margin:6px 0;min-height:86px}
27
+ #storyBtnRow{display:flex;gap:8px;justify-content:flex-end;margin-top:6px}
28
+ button{
29
+ background:linear-gradient(180deg,#f5d76e,#c9982a);border:1px solid #7a5c00;color:#2a2000;
30
+ font-weight:700;border-radius:8px;padding:8px 16px;font-size:14px;cursor:pointer;
31
+ }
32
+ button.ghost{background:#2a3270;color:#fff;border-color:#4a55aa}
33
+ button:disabled{opacity:.4;cursor:not-allowed}
34
+ button:active{transform:translateY(1px)}
35
+ #hud{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px}
36
+ .chip{background:#141936cc;border:1px solid #4a55aa;border-radius:20px;padding:4px 12px;font-size:13px}
37
+ .chip b{color:var(--gold)}
38
+ #main{display:flex;gap:10px;flex-wrap:wrap;justify-content:center}
39
+ #boardBox{background:#0a0d20;border:2px solid var(--gold-d);border-radius:12px;padding:8px;box-shadow:0 4px 24px #000}
40
+ #board{
41
+ display:grid;grid-template-columns:repeat(10,1fr);gap:2px;
42
+ width:min(92vw,560px);aspect-ratio:10/8;user-select:none;
43
+ }
44
+ .cell{
45
+ position:relative;border-radius:6px;display:flex;align-items:center;justify-content:center;
46
+ font-size:clamp(18px,4.5vw,30px);cursor:pointer;transition:transform .08s;
47
+ border:1px solid #0006;
48
+ }
49
+ .cell.plain{background:linear-gradient(180deg,#5da24d,#3d7a32)}
50
+ .cell.forest{background:linear-gradient(180deg,#2e7d46,#1a4d2a)}
51
+ .cell.mountain{background:linear-gradient(180deg,#8a8f9a,#54575f)}
52
+ .cell.castle{background:linear-gradient(180deg,#c9a86a,#8a6d35)}
53
+ .cell.road{background:linear-gradient(180deg,#a99a6b,#7a6e45)}
54
+ .cell.moveable::after{content:"";position:absolute;inset:18%;background:#4da6ffaa;border-radius:6px;border:2px solid #fff}
55
+ .cell.attackable::after{content:"";position:absolute;inset:12%;background:#ff3b3baa;border-radius:6px;border:2px dashed #fff;animation:pulse .8s infinite alternate}
56
+ .cell.healable::after{content:"";position:absolute;inset:12%;background:#43d17caa;border-radius:6px;border:2px dashed #fff}
57
+ .cell.selected{outline:3px solid var(--gold);z-index:2}
58
+ .cell .u{filter:drop-shadow(1px 2px 1px #000);line-height:1;z-index:1}
59
+ .cell .hpbar{position:absolute;left:2px;right:2px;bottom:2px;height:5px;background:#000a;border-radius:3px;overflow:hidden;z-index:2}
60
+ .cell .hpbar i{display:block;height:100%;background:linear-gradient(90deg,#43d17c,#b6ff5e)}
61
+ .cell .hpbar.low i{background:linear-gradient(90deg,#ff3b3b,#ff9a3b)}
62
+ .cell .side-dot{position:absolute;top:2px;left:3px;width:8px;height:8px;border-radius:50%;z-index:2}
63
+ .cell .terrain-tag{position:absolute;top:1px;right:2px;font-size:9px;opacity:.85;z-index:0}
64
+ .cell.acted .u{filter:grayscale(.8) brightness(.6)}
65
+ @keyframes pulse{from{opacity:.6}to{opacity:1}}
66
+ @keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-4px)}75%{transform:translateX(4px)}}
67
+ .shake{animation:shake .3s}
68
+ @keyframes floatDmg{0%{opacity:1;transform:translateY(0)}100%{opacity:0;transform:translateY(-28px)}}
69
+ .dmgFloat{position:absolute;top:2px;left:50%;transform:translateX(-50%);font-weight:900;font-size:18px;color:#ffeb3b;text-shadow:1px 1px 0 #000,0 0 8px #f00;animation:floatDmg 1s forwards;z-index:9;pointer-events:none}
70
+ .dmgFloat.heal{color:#7bff9e}
71
+ #side{background:#141936;border:2px solid #4a55aa;border-radius:12px;padding:10px;width:min(92vw,260px)}
72
+ #side h3{margin:2px 0 8px;font-size:14px;color:var(--gold);letter-spacing:2px}
73
+ #unitCard{background:#0f1430;border:1px solid #3a4488;border-radius:8px;padding:8px;font-size:13px;line-height:1.6;min-height:150px}
74
+ #unitCard .big{font-size:28px}
75
+ .stat{display:flex;justify-content:space-between}
76
+ .bar{height:8px;background:#000;border-radius:4px;overflow:hidden;margin:3px 0 6px}
77
+ .bar i{display:block;height:100%;background:linear-gradient(90deg,#ff5f6d,#ffc371)}
78
+ .bar.exp i{background:linear-gradient(90deg,#4da6ff,#b174ff)}
79
+ #menu{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap}
80
+ #log{background:#0a0d20;border:1px solid #3a4488;border-radius:8px;height:150px;overflow-y:auto;font-size:12.5px;line-height:1.6;padding:8px;margin-top:8px}
81
+ #log div{border-bottom:1px dotted #ffffff18;padding:2px 0}
82
+ #controls{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
83
+ #overlay{
84
+ position:fixed;inset:0;background:#000c;display:flex;align-items:center;justify-content:center;
85
+ z-index:50;padding:16px;
86
+ }
87
+ #overlay .card{background:linear-gradient(180deg,#232a55,#0f1430);border:2px solid var(--gold);border-radius:16px;padding:24px;max-width:440px;text-align:center;box-shadow:0 0 40px #b8860b66}
88
+ #overlay h2{color:var(--gold);letter-spacing:4px}
89
+ #overlay p{line-height:1.8;font-size:14px}
90
+ .hidden{display:none!important}
91
+ .legend{font-size:11px;opacity:.85;line-height:1.8;margin-top:8px}
92
+ kbd{background:#000;border:1px solid #666;border-radius:4px;padding:0 5px;font-size:11px}
93
+ </style>
94
+ </head>
95
+ <body>
96
+ <div id="wrap">
97
+ <h1>⚔️ 天使帝國 · 網頁版 ⚔️</h1>
98
+ <div class="sub">第一關 —— 瓦爾哈拉防衛戰 | 向1993年大宇經典致敬的同人重製</div>
99
+
100
+ <div id="story">
101
+ <h2>📜 序章</h2>
102
+ <p id="storyText"></p>
103
+ <div id="storyBtnRow">
104
+ <button class="ghost" id="skipStory">跳過劇情 ⏩</button>
105
+ <button id="nextStory">繼續 ▸</button>
106
+ </div>
107
+ </div>
108
+
109
+ <div id="hud">
110
+ <div class="chip">回合 <b id="turn">1</b></div>
111
+ <div class="chip">階段 <b id="phase">我軍行動</b></div>
112
+ <div class="chip">敵軍殘存 <b id="enemyCount">5</b></div>
113
+ <div class="chip">我軍 <b id="allyCount">4</b></div>
114
+ </div>
115
+
116
+ <div id="main">
117
+ <div id="boardBox">
118
+ <div id="board"></div>
119
+ <div id="controls">
120
+ <button id="endTurn">🔔 結束回合</button>
121
+ <button class="ghost" id="restart">↺ 重新開始</button>
122
+ <button class="ghost" id="howto">❓ 玩法</button>
123
+ </div>
124
+ </div>
125
+ <div id="side">
126
+ <h3>▌ 部隊資訊</h3>
127
+ <div id="unitCard">點選我軍單位開始行動。<br>藍色格可移動,紅色格可攻擊。</div>
128
+ <div id="menu"></div>
129
+ <div id="log"></div>
130
+ <div class="legend">
131
+ 地形:🌿草原 �🌲森林(迴避+防禦) ⛰️山(不可進入) 🏰城砦(防禦++) 🛣️道路<br>
132
+ 操作:點藍格移動 → 選攻擊/治療/待機 → 點紅格目標<br>
133
+ 僧侶可治療綠格友軍。主角 <b>愛琳</b> 陣亡即失敗。
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ <div id="overlay" class="hidden"><div class="card"><h2 id="ovTitle"></h2><p id="ovText"></p><button id="ovBtn">再戰一關</button></div></div>
140
+
141
+ <script>
142
+ // ============ 音效 (WebAudio 合成, 無需外部檔) ============
143
+ let AC=null;
144
+ function beep(f,d=0.12,type='square',v=0.12){try{AC=AC||new (window.AudioContext||window.webkitAudioContext)();const o=AC.createOscillator(),g=AC.createGain();o.type=type;o.frequency.value=f;g.gain.value=v;o.connect(g);g.connect(AC.destination);o.start();g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+d);o.stop(AC.currentTime+d);}catch(e){}}
145
+ const sMove=()=>{beep(440,.08);setTimeout(()=>beep(660,.08),70)};
146
+ const sHit=()=>{beep(160,.18,'sawtooth',.18);setTimeout(()=>beep(90,.22,'sawtooth',.18),60)};
147
+ const sMiss=()=>beep(800,.15,'sine',.1);
148
+ const sHeal=()=>{[523,659,784].forEach((f,i)=>setTimeout(()=>beep(f,.15,'sine'),i*90))};
149
+ const sWin=()=>{[523,659,784,1046,1318].forEach((f,i)=>setTimeout(()=>beep(f,.25),i*140))};
150
+ const sLose=()=>{[400,350,300,200].forEach((f,i)=>setTimeout(()=>beep(f,.3,'sawtooth'),i*180))};
151
+
152
+ // ============ 地圖 10x8 ============
153
+ // .:草原 F:森林 M:山 C:城砦 R:道路
154
+ const MAP=[
155
+ "....FF....",
156
+ "..FFFF..C.",
157
+ ".FF..RRR..",
158
+ "..F.RRR.F.",
159
+ ".M.RRC.RM.",
160
+ "..F.RRR.F.",
161
+ ".FF..RR...",
162
+ "..FFFF....",
163
+ ];
164
+ const W=10,H=8;
165
+ function terrainAt(x,y){return MAP[y][x];}
166
+ function terrainName(t){return {'.':'🌿草原',F:'🌲森林',M:'⛰️高山',C:'🏰城砦',R:'🛣️道路'}[t]}
167
+ function moveCost(t){return t==='M'?99 : t==='F'?2 : 1}
168
+ function defBonus(t){return t==='C'?0.55 : t==='F'?0.75 : 1}
169
+ function evaBonus(t){return t==='C'?25 : t==='F'?20 : 0}
170
+
171
+ // ============ 部隊 ============
172
+ function baseUnits(){return [
173
+ {id:'erin', side:'P', name:'愛琳', title:'女王近衛劍士', icon:'👸', x:3,y:6, maxhp:34, hp:34, atk:13, def:8, mov:4, rng:[1,1], exp:0, lv:1, hero:true},
174
+ {id:'leah', side:'P', name:'莉亞', title:'精靈弓箭手', icon:'🧝', x:4,y:6, maxhp:26, hp:26, atk:11, def:4, mov:4, rng:[1,2]},
175
+ {id:'mia', side:'P', name:'米雅', title:'火焰魔法師', icon:'🧙', x:5,y:6, maxhp:22, hp:22, atk:15, def:3, mov:3, rng:[1,2], magic:true},
176
+ {id:'jenny',side:'P', name:'珍妮', title:'見習僧侶', icon:'👼', x:6,y:6, maxhp:24, hp:24, atk:7, def:5, mov:3, rng:[1,2], healer:true},
177
+ {id:'sk1', side:'E', name:'骷髏戰士', title:'不死前鋒', icon:'💀', x:2,y:1, maxhp:22, hp:22, atk:9, def:5, mov:3, rng:[1,1], expV:15},
178
+ {id:'sk2', side:'E', name:'骷髏戰士', title:'不死前鋒', icon:'💀', x:7,y:1, maxhp:22, hp:22, atk:9, def:5, mov:3, rng:[1,1], expV:15},
179
+ {id:'gob', side:'E', name:'哥���林射手', title:'荒野斥候', icon:'👺', x:4,y:1, maxhp:18, hp:18, atk:9, def:3, mov:4, rng:[1,2], expV:14},
180
+ {id:'sho', side:'E', name:'暗黑斥候', title:'影之牙', icon:'👹', x:5,y:2, maxhp:20, hp:20, atk:10, def:4, mov:4, rng:[1,1], expV:16},
181
+ {id:'boss',side:'E', name:'屍龍術士 莫戈', title:'第一關首領', icon:'🧛', x:4,y:0, maxhp:34, hp:34, atk:13, def:6, mov:3, rng:[1,2], magic:true, expV:30, boss:true},
182
+ ];}
183
+
184
+ let units, turn, phase, selected, moveTiles, atkTiles, healTiles, pendingMove, gameOver, busy;
185
+ const board=document.getElementById('board'), card=document.getElementById('unitCard'),
186
+ menu=document.getElementById('menu'), logEl=document.getElementById('log');
187
+
188
+ function log(m){const d=document.createElement('div');d.innerHTML=m;logEl.prepend(d);}
189
+ function unitAt(x,y){return units.find(u=>u.hp>0&&u.x===x&&u.y===y)}
190
+ function alive(side){return units.filter(u=>u.side===side&&u.hp>0)}
191
+
192
+ function init(){
193
+ units=baseUnits(); turn=1; phase='P'; selected=null; moveTiles=[]; atkTiles=[]; healTiles=[]; pendingMove=null; gameOver=false; busy=false;
194
+ units.forEach(u=>{u.moved=false;u.acted=false;u.lv=u.lv||1;u.exp=u.exp||0;});
195
+ document.getElementById('turn').textContent=turn;
196
+ document.getElementById('phase').textContent='我軍行動';
197
+ logEl.innerHTML='';
198
+ log('⚔️ 第一關開始:消滅所有敵軍!保護 <b>愛琳</b>!');
199
+ log('僧侶珍妮 👼 可以治療鄰近友軍。');
200
+ render();
201
+ }
202
+
203
+ function render(){
204
+ document.getElementById('enemyCount').textContent=alive('E').length;
205
+ document.getElementById('allyCount').textContent=alive('P').length;
206
+ board.innerHTML='';
207
+ for(let y=0;y<H;y++)for(let x=0;x<W;x++){
208
+ const t=terrainAt(x,y);
209
+ const c=document.createElement('div');
210
+ c.className='cell '+({'.':'plain',F:'forest',M:'mountain',C:'castle',R:'road'}[t]);
211
+ c.dataset.x=x;c.dataset.y=y;
212
+ const tag=document.createElement('span');tag.className='terrain-tag';
213
+ tag.textContent=t==='F'?'🌲':t==='M'?'⛰️':t==='C'?'🏰':'';
214
+ c.appendChild(tag);
215
+ if(moveTiles.some(m=>m.x===x&&m.y===y))c.classList.add('moveable');
216
+ if(atkTiles.some(m=>m.x===x&&m.y===y))c.classList.add('attackable');
217
+ if(healTiles.some(m=>m.x===x&&m.y===y))c.classList.add('healable');
218
+ const u=unitAt(x,y);
219
+ if(u){
220
+ const s=document.createElement('span');s.className='u';s.textContent=u.icon;c.appendChild(s);
221
+ const d=document.createElement('span');d.className='side-dot';
222
+ d.style.background=u.side==='P'?'#4da6ff':'#ff4d4d';c.appendChild(d);
223
+ const hb=document.createElement('div');hb.className='hpbar'+(u.hp/u.maxhp<0.35?' low':'');
224
+ const i=document.createElement('i');i.style.width=Math.max(0,u.hp/u.maxhp*100)+'%';hb.appendChild(i);c.appendChild(hb);
225
+ if(u.acted&&u.side==='P')c.classList.add('acted');
226
+ if(selected===u)c.classList.add('selected');
227
+ }
228
+ c.onclick=()=>onCell(x,y);
229
+ c.onmouseenter=()=>{const uu=unitAt(x,y); if(uu) showCard(uu);};
230
+ board.appendChild(c);
231
+ }
232
+ }
233
+
234
+ function showCard(u){
235
+ card.innerHTML=`<span class="big">${u.icon}</span> <b>${u.name}</b> <span style="opacity:.7">Lv${u.lv} · ${u.title||''}</span>
236
+ <span style="float:right;color:${u.side==='P'?'#4da6ff':'#ff6b6b'}">${u.side==='P'?'【我軍】':'【敵軍】'}</span>
237
+ <div class="stat"><span>HP ${Math.max(0,u.hp)}/${u.maxhp}</span><span>EXP ${u.exp||0}/30</span></div>
238
+ <div class="bar"><i style="width:${u.hp/u.maxhp*100}%"></i></div>
239
+ <div class="stat"><span>⚔️攻擊 ${u.atk}</span><span>🛡️防禦 ${u.def}</span></div>
240
+ <div class="stat"><span>👟移動 ${u.mov}</span><span>🏹射程 ${u.rng[0]}-${u.rng[1]}</span></div>
241
+ <div style="opacity:.75">地形:${terrainName(terrainAt(u.x,u.y))}</div>`;
242
+ }
243
+
244
+ // ---- 移動範圍 BFS ----
245
+ function calcMove(u){
246
+ const dist=Array.from({length:H},()=>Array(W).fill(99));
247
+ dist[u.y][u.x]=0;const q=[[u.x,u.y]];
248
+ while(q.length){const [x,y]=q.shift();
249
+ for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
250
+ const nx=x+dx,ny=y+dy;
251
+ if(nx<0||ny<0||nx>=W||ny>=H)continue;
252
+ const c=moveCost(terrainAt(nx,ny));if(c>=99)continue;
253
+ const occ=unitAt(nx,ny);if(occ&&occ!==u)continue;
254
+ if(dist[ny][nx]>dist[y][x]+c){dist[ny][nx]=dist[y][x]+c;q.push([nx,ny]);}
255
+ }}
256
+ const out=[];for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(!(x===u.x&&y===u.y)&&dist[y][x]<=u.mov)out.push({x,y});
257
+ return out;
258
+ }
259
+ function tilesInRange(x,y,r0,r1,side,targetSide){
260
+ const out=[];
261
+ for(let yy=0;yy<H;yy++)for(let xx=0;xx<W;xx++){
262
+ const d=Math.abs(xx-x)+Math.abs(yy-y);
263
+ if(d<r0||d>r1)continue;
264
+ const t=unitAt(xx,yy);
265
+ if(targetSide==='enemy'&&t&&t.side!==side&&t.hp>0)out.push({x:xx,y:yy});
266
+ if(targetSide==='ally'&&t&&t.side===side&&t.hp>0&&t.hp<t.maxhp&&!(xx===x&&yy===y))out.push({x:xx,y:yy});
267
+ }
268
+ return out;
269
+ }
270
+
271
+ // ---- 點擊 ----
272
+ function onCell(x,y){
273
+ if(gameOver||busy||phase!=='P')return;
274
+ const u=unitAt(x,y);
275
+ // 1. 有待命中攻擊目標?
276
+ if(selected&&pendingMove==='attack'){
277
+ if(atkTiles.some(t=>t.x===x&&t.y===y)){doAttack(selected,u);return;}
278
+ }
279
+ if(selected&&pendingMove==='heal'){
280
+ if(healTiles.some(t=>t.x===x&&t.y===y)){doHeal(selected,u);return;}
281
+ else {pendingMove=null;showActions(selected);render();return;}
282
+ }
283
+ // 2. 點移動格?
284
+ if(selected&&!selected.moved&&moveTiles.some(t=>t.x===x&&t.y===y)){
285
+ selected.x=x;selected.y=y;selected.moved=true;sMove();
286
+ log(`${selected.icon} ${selected.name} 移動至 (${x},${y})`);
287
+ moveTiles=[];pendingMove=null;showActions(selected);render();showCard(selected);return;
288
+ }
289
+ // 3. 選取我軍?
290
+ if(u&&u.side==='P'&&!u.acted){
291
+ selected=u;pendingMove=null;
292
+ moveTiles=u.moved?[]:calcMove(u);
293
+ atkTiles=[];healTiles=[];
294
+ showCard(u);renderMenuHint(u);render();
295
+ return;
296
+ }
297
+ if(u&&u.side==='P'&&u.acted){showCard(u);menu.innerHTML='<span style="font-size:12px;opacity:.7">該單位已行動完畢。</span>';return;}
298
+ if(u)showCard(u);
299
+ }
300
+
301
+ function renderMenuHint(u){
302
+ menu.innerHTML=`<span style="font-size:12px;opacity:.8">已選擇 ${u.icon}${u.name}:點藍色格移動,或直接選行動。</span>`;
303
+ const m=document.createElement('div');m.style.cssText='display:flex;gap:6px;margin-top:6px;flex-wrap:wrap';
304
+ const bA=document.createElement('button');bA.textContent='⚔️ 攻擊';bA.style.padding='6px 10px';
305
+ bA.onclick=()=>showActions(u,'attack');
306
+ const bW=document.createElement('button');bW.textContent='待機 ⏳';bW.className='ghost';bW.style.padding='6px 10px';
307
+ bW.onclick=()=>{finishUnit(u);log(`${u.icon} ${u.name} 待機。`)};
308
+ m.appendChild(bA);
309
+ if(u.healer){const bH=document.createElement('button');bH.textContent='💚 治療';bH.style.padding='6px 10px';bH.onclick=()=>showActions(u,'heal');m.appendChild(bH);}
310
+ m.appendChild(bW);menu.appendChild(m);
311
+ }
312
+
313
+ function showActions(u,force){
314
+ atkTiles=tilesInRange(u.x,u.y,u.rng[0],u.rng[1],u.side,'enemy');
315
+ healTiles=u.healer?tilesInRange(u.x,u.y,1,2,u.side,'ally'):[];
316
+ menu.innerHTML='';
317
+ const mk=(label,fn,cls='')=>{const b=document.createElement('button');b.textContent=label;b.style.padding='6px 10px';if(cls)b.className=cls;b.onclick=fn;menu.appendChild(b);};
318
+ if(force==='attack'||(!force&&!u.healer)){
319
+ if(atkTiles.length){pendingMove='attack';mk(`⚔️ 攻擊 (${atkTiles.length}個目標)`,()=>{render();});}
320
+ else{pendingMove=null;mk('⚔️ 無可攻擊目標',()=>{},{disabled:1});}
321
+ }
322
+ if(u.healer&&(force==='heal'||!force)){
323
+ if(force==='heal'){pendingMove='heal';if(!healTiles.length)log('附近沒有需要治療的友軍。');}
324
+ }
325
+ if(!force){
326
+ if(u.healer&&healTiles.length)mk(`💚 治療 (${healTiles.length})`,()=>showActions(u,'heal'));
327
+ mk('待機 ⏳',()=>{finishUnit(u);log(`${u.icon} ${u.name} 待機。`)},'ghost');
328
+ } else if(force==='attack'){
329
+ mk('取消',()=>{pendingMove=null;atkTiles=[];showActions(u)},'ghost');
330
+ } else if(force==='heal'){
331
+ mk('取消',()=>{pendingMove=null;healTiles=[];showActions(u)},'ghost');
332
+ }
333
+ if(force==='attack'&&!atkTiles.length){pendingMove=null;}
334
+ render();
335
+ }
336
+
337
+ function finishUnit(u){u.acted=true;u.moved=true;selected=null;moveTiles=[];atkTiles=[];healTiles=[];menu.innerHTML='';checkAutoEnd();render();}
338
+
339
+ // ---- 戰鬥 ----
340
+ function calcDamage(att,def){
341
+ const t=terrainAt(def.x,def.y);
342
+ let base=att.magic? att.atk - def.def*0.35 : att.atk - def.def*0.6;
343
+ base*=defBonus(t);
344
+ base+=Math.floor(Math.random()*5)-2;
345
+ return Math.max(1,Math.round(base));
346
+ }
347
+ function hitChance(att,def){
348
+ return Math.max(55, 92 - evaBonus(terrainAt(def.x,def.y)) + (att.lv-def.lv)*3);
349
+ }
350
+ function floatText(x,y,txt,heal=false){
351
+ const idx=y*W+x;
352
+ const cell=board.children[idx];if(!cell)return;
353
+ const d=document.createElement('div');d.className='dmgFloat'+(heal?' heal':'');d.textContent=txt;
354
+ cell.appendChild(d);setTimeout(()=>d.remove(),1000);
355
+ }
356
+ function gainExp(u,n){
357
+ if(u.side!=='P'||u.hp<=0)return;
358
+ u.exp=(u.exp||0)+n;
359
+ if(u.exp>=30){u.exp-=30;u.lv++;u.maxhp+=4;u.hp=Math.min(u.maxhp,u.hp+4);u.atk+=2;u.def+=1;
360
+ log(`✨ ${u.icon} ${u.name} 升級!Lv${u.lv}(攻擊+2 防禦+1 HP+4)`);beep(880,.2,'sine');}
361
+ }
362
+ function doAttack(att,def){
363
+ if(!def||def.hp<=0)return;
364
+ busy=true;
365
+ const ch=hitChance(att,def);
366
+ const roll=Math.random()*100;
367
+ const idx=def.y*W+def.x;
368
+ if(roll<ch){
369
+ const dmg=calcDamage(att,def);
370
+ def.hp-=dmg;sHit();
371
+ if(board.children[idx]){board.children[idx].classList.add('shake');setTimeout(()=>board.children[idx]&&board.children[idx].classList.remove('shake'),320);}
372
+ floatText(def.x,def.y,'-'+dmg);
373
+ log(`${att.icon}${att.name} → ${def.icon}${def.name}:造成 <b>${dmg}</b> 傷害!${att.magic?'🔥':''}`);
374
+ gainExp(att,def.hp<=0? (def.expV||15):8);
375
+ if(def.hp<=0){def.hp=0;log(`☠️ ${def.icon} ${def.name} 被擊破!`);beep(220,.3,'sawtooth');}
376
+ else{
377
+ // 反擊
378
+ const d=Math.abs(att.x-def.x)+Math.abs(att.y-def.y);
379
+ if(d>=def.rng[0]&&d<=def.rng[1]){
380
+ setTimeout(()=>{
381
+ const ch2=hitChance(def,att);
382
+ if(Math.random()*100<ch2){
383
+ const dmg2=calcDamage(def,att);
384
+ att.hp-=dmg2;sHit();floatText(att.x,att.y,'-'+dmg2);
385
+ log(`${def.icon}${def.name} 反擊!${att.icon}${att.name} 受到 <b>${dmg2}</b> 傷害`);
386
+ if(att.hp<=0){att.hp=0;log(`☠️ ${att.icon} ${att.name} 倒下了…`);}
387
+ } else {sMiss();floatText(att.x,att.y,'MISS');log(`${def.icon}${def.name} 反擊…落空!`);}
388
+ afterCombat(att);
389
+ },650);
390
+ render();return;
391
+ }
392
+ }
393
+ } else {sMiss();floatText(def.x,def.y,'MISS');log(`${att.icon}${att.name} 的攻擊被 ${def.icon}${def.name} 閃開了!`);gainExp(att,2);}
394
+ setTimeout(()=>afterCombat(att),450);
395
+ render();
396
+ }
397
+ function afterCombat(att){
398
+ busy=false;
399
+ att.acted=true;att.moved=true;selected=null;moveTiles=[];atkTiles=[];healTiles=[];menu.innerHTML='';
400
+ render();checkEnd();if(!gameOver)checkAutoEnd();
401
+ }
402
+ function doHeal(healer,target){
403
+ const amt=9+Math.floor(Math.random()*4)+healer.lv;
404
+ target.hp=Math.min(target.maxhp,target.hp+amt);
405
+ sHeal();floatText(target.x,target.y,'+'+amt,true);
406
+ log(`${healer.icon}${healer.name} 治療 ${target.icon}${target.name},恢復 <b style="color:#7bff9e">${amt}</b> HP`);
407
+ gainExp(healer,6);
408
+ busy=false;healer.acted=true;healer.moved=true;selected=null;moveTiles=[];atkTiles=[];healTiles=[];menu.innerHTML='';
409
+ render();checkAutoEnd();
410
+ }
411
+
412
+ // ---- 回合 ----
413
+ function checkAutoEnd(){
414
+ if(gameOver)return;
415
+ if(alive('P').every(u=>u.acted)){setTimeout(()=>{if(!gameOver&&phase==='P')endTurn();},600);}
416
+ }
417
+ document.getElementById('endTurn').onclick=()=>{if(phase==='P'&&!busy&&!gameOver)endTurn();};
418
+ function endTurn(){
419
+ selected=null;moveTiles=[];atkTiles=[];healTiles=[];menu.innerHTML='';
420
+ phase='E';document.getElementById('phase').textContent='敵軍行動';render();
421
+ log(`―― 第 ${turn} 回合・敵軍行動 ――`);
422
+ setTimeout(enemyTurn,700);
423
+ }
424
+ function enemyTurn(){
425
+ const foes=alive('E');
426
+ let i=0;
427
+ busy=true;
428
+ function step(){
429
+ if(gameOver){busy=false;return;}
430
+ if(i>=foes.length){
431
+ busy=false;turn++;document.getElementById('turn').textContent=turn;
432
+ phase='P';document.getElementById('phase').textContent='我軍行動';
433
+ units.forEach(u=>{if(u.hp>0&&u.side==='P'){u.moved=false;u.acted=false;}});
434
+ log(`―― 第 ${turn} 回合・我軍行動 ――`);
435
+ render();return;
436
+ }
437
+ const e=foes[i++];
438
+ if(e.hp<=0){step();return;}
439
+ showCard(e);
440
+ // 找最近的玩家
441
+ const ps=alive('P');
442
+ if(!ps.length){step();return;}
443
+ let best=null,bd=1e9;
444
+ ps.forEach(p=>{const d=Math.abs(p.x-e.x)+Math.abs(p.y-e.y);if(d<bd){bd=d;best=p;}});
445
+ // 若已在射程內直接打
446
+ let inR=tilesInRange(e.x,e.y,e.rng[0],e.rng[1],e.side,'enemy');
447
+ if(!inR.length){
448
+ // 靠近:BFS 找可走且離目標最近的格
449
+ const opts=calcMove(e);
450
+ let bx=e.x,by=e.y,bbd=bd;
451
+ opts.forEach(o=>{const d=Math.abs(o.x-best.x)+Math.abs(o.y-best.y);if(d<bbd){bbd=d;bx=o.x;by=o.y;}});
452
+ e.x=bx;e.y=by;render();
453
+ inR=tilesInRange(e.x,e.y,e.rng[0],e.rng[1],e.side,'enemy');
454
+ }
455
+ render();
456
+ setTimeout(()=>{
457
+ if(inR.length){
458
+ // 打最弱的
459
+ inR.sort((a,b)=>{const ua=unitAt(a.x,a.y),ub=unitAt(b.x,b.y);return ua.hp-ub.hp;});
460
+ const t=unitAt(inR[0].x,inR[0].y);
461
+ // 簡化版敵方攻擊(不遞迴 busy)
462
+ const ch=hitChance(e,t);
463
+ if(Math.random()*100<ch){
464
+ const dmg=calcDamage(e,t);t.hp-=dmg;sHit();floatText(t.x,t.y,'-'+dmg);
465
+ const ec=e.y*W+e.x, tc=t.y*W+t.x;
466
+ log(`${e.icon}${e.name} → ${t.icon}${t.name}:<b>${dmg}</b> 傷害!`);
467
+ if(t.hp<=0){t.hp=0;log(`☠️ ${t.icon} ${t.name} 倒下了…`);}
468
+ } else {sMiss();floatText(t.x,t.y,'MISS');log(`${e.icon}${e.name} 攻擊 ${t.icon}${t.name}…落空!`);}
469
+ render();
470
+ if(checkEnd()){busy=false;return;}
471
+ }
472
+ setTimeout(step,650);
473
+ },550);
474
+ }
475
+ step();
476
+ }
477
+ function checkEnd(){
478
+ const erin=units.find(u=>u.id==='erin');
479
+ if(erin.hp<=0){gameOver=true;showOverlay(false,'愛琳倒下了…瓦爾哈拉的光芒熄滅了。');return true;}
480
+ if(!alive('P').length){gameOver=true;showOverlay(false,'我軍全滅…黑暗吞噬了瓦爾哈拉。');return true;}
481
+ if(!alive('E').length){gameOver=true;showOverlay(true,`第 ${turn} 回合,我軍赢得了瓦爾哈拉防衛戰!<br>愛琳: 「這只是開始…帝國的命運就掌握在我們手中!」<br><small>― 第一關・完 ―</small>`);return true;}
482
+ return false;
483
+ }
484
+ function showOverlay(win,text){
485
+ win?sWin():sLose();
486
+ document.getElementById('ovTitle').textContent=win?'🏆 勝利!':'💀 戰敗…';
487
+ document.getElementById('ovText').innerHTML=text;
488
+ document.getElementById('overlay').classList.remove('hidden');
489
+ }
490
+ document.getElementById('ovBtn').onclick=()=>{document.getElementById('overlay').classList.add('hidden');init();};
491
+ document.getElementById('restart').onclick=()=>{if(confirm('重新開始第一關?'))init();};
492
+ document.getElementById('howto').onclick=()=>alert('【玩法】\n1. 點我軍(藍點)→ 藍格移動\n2. 移動後選 攻擊/治療/待機\n3. 紅格是可攻擊的敵人,綠格是可治療的友軍\n4. 全部行動完自動換敵方回合\n5. 消滅包含首領在內全部5名敵軍即勝利\n6. 愛琳陣亡或我軍全滅即失敗\n\n森林/城砦有防禦加成,魔法師無視部分防禦。');
493
+
494
+ // ============ 劇情 ============
495
+ const STORY=[
496
+ "聖曆312年,瓦爾哈拉帝國——一個由女戰士守護的光之國度。",
497
+ "北方死靈大軍越過冰封隘口,屍龍術士「莫戈」率領骷髏與哥布林先鋒,兵臨城下。",
498
+ "女王近衛・愛琳:「姊妹們!城牆之後就是我們的家園,一步也不能退!」",
499
+ "弓箭手莉亞、魔法師米雅、見習僧侶珍妮已集結完畢——第一關:瓦爾哈拉防衛戰,開戰!",
500
+ ];
501
+ let si=0;
502
+ function showStory(){
503
+ document.getElementById('storyText').textContent=STORY[si];
504
+ document.getElementById('nextStory').textContent=(si===STORY.length-1?'開戰!⚔️':'繼續 ▸');
505
+ }
506
+ document.getElementById('nextStory').onclick=()=>{
507
+ beep(660,.08);
508
+ if(si<STORY.length-1){si++;showStory();}
509
+ else{document.getElementById('story').style.display='none';log('📜 劇情結束,戰鬥開始!');}
510
+ };
511
+ document.getElementById('skipStory').onclick=()=>{document.getElementById('story').style.display='none';};
512
+ showStory();
513
+ init();
514
+ </script>
515
+ </body>
516
+ </html>