Download ag1.html from HelloSun/ag1: direct link, hf CLI and curl.
- Browser
- Download file 26.6 kB
-
https://huggingface.co/spaces/HelloSun/ag1/resolve/main/ag1.html
- Command line
-
hf download hf://spaces/HelloSun/ag1/ag1.html
-
curl -L -o ag1.html https://huggingface.co/spaces/HelloSun/ag1/resolve/main/ag1.html
26.6 kB
| <html lang="zh-Hant"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>天使帝國 · 瓦爾哈拉防衛戰 | 第一關</title> | |
| <style> | |
| :root{ | |
| --gold:#f5d76e; --gold-d:#b8860b; --bg:#0d1023; --panel:#1a2040; | |
| --blue:#4da6ff; --red:#ff6b6b; --green:#7bed6f; | |
| } | |
| *{box-sizing:border-box;-webkit-tap-highlight-color:transparent} | |
| body{ | |
| margin:0;background:radial-gradient(1200px 600px at 50% -10%,#2a3570 0%,#0d1023 60%) fixed,var(--bg); | |
| color:#eef;font-family:"Noto Sans TC","PingFang TC","Microsoft JhengHei",sans-serif; | |
| display:flex;flex-direction:column;align-items:center;min-height:100vh;padding:10px; | |
| } | |
| h1{margin:8px 0 2px;font-size:22px;letter-spacing:4px;color:var(--gold);text-shadow:0 0 12px #b8860b88,2px 2px 0 #000} | |
| .sub{font-size:12px;opacity:.8;letter-spacing:2px;margin-bottom:8px} | |
| #wrap{width:100%;max-width:860px} | |
| #story{ | |
| background:linear-gradient(180deg,#232a55,#141936);border:2px solid var(--gold-d); | |
| border-radius:12px;padding:14px 16px;margin-bottom:10px;box-shadow:0 4px 24px #000a; | |
| } | |
| #story h2{margin:0 0 6px;font-size:16px;color:var(--gold)} | |
| #story p{font-size:14px;line-height:1.7;margin:6px 0;min-height:86px} | |
| #storyBtnRow{display:flex;gap:8px;justify-content:flex-end;margin-top:6px} | |
| button{ | |
| background:linear-gradient(180deg,#f5d76e,#c9982a);border:1px solid #7a5c00;color:#2a2000; | |
| font-weight:700;border-radius:8px;padding:8px 16px;font-size:14px;cursor:pointer; | |
| } | |
| button.ghost{background:#2a3270;color:#fff;border-color:#4a55aa} | |
| button:disabled{opacity:.4;cursor:not-allowed} | |
| button:active{transform:translateY(1px)} | |
| #hud{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px} | |
| .chip{background:#141936cc;border:1px solid #4a55aa;border-radius:20px;padding:4px 12px;font-size:13px} | |
| .chip b{color:var(--gold)} | |
| #main{display:flex;gap:10px;flex-wrap:wrap;justify-content:center} | |
| #boardBox{background:#0a0d20;border:2px solid var(--gold-d);border-radius:12px;padding:8px;box-shadow:0 4px 24px #000} | |
| #board{ | |
| display:grid;grid-template-columns:repeat(10,1fr);gap:2px; | |
| width:min(92vw,560px);aspect-ratio:10/8;user-select:none; | |
| } | |
| .cell{ | |
| position:relative;border-radius:6px;display:flex;align-items:center;justify-content:center; | |
| font-size:clamp(18px,4.5vw,30px);cursor:pointer;transition:transform .08s; | |
| border:1px solid #0006; | |
| } | |
| .cell.plain{background:linear-gradient(180deg,#5da24d,#3d7a32)} | |
| .cell.forest{background:linear-gradient(180deg,#2e7d46,#1a4d2a)} | |
| .cell.mountain{background:linear-gradient(180deg,#8a8f9a,#54575f)} | |
| .cell.castle{background:linear-gradient(180deg,#c9a86a,#8a6d35)} | |
| .cell.road{background:linear-gradient(180deg,#a99a6b,#7a6e45)} | |
| .cell.moveable::after{content:"";position:absolute;inset:18%;background:#4da6ffaa;border-radius:6px;border:2px solid #fff} | |
| .cell.attackable::after{content:"";position:absolute;inset:12%;background:#ff3b3baa;border-radius:6px;border:2px dashed #fff;animation:pulse .8s infinite alternate} | |
| .cell.healable::after{content:"";position:absolute;inset:12%;background:#43d17caa;border-radius:6px;border:2px dashed #fff} | |
| .cell.selected{outline:3px solid var(--gold);z-index:2} | |
| .cell .u{filter:drop-shadow(1px 2px 1px #000);line-height:1;z-index:1} | |
| .cell .hpbar{position:absolute;left:2px;right:2px;bottom:2px;height:5px;background:#000a;border-radius:3px;overflow:hidden;z-index:2} | |
| .cell .hpbar i{display:block;height:100%;background:linear-gradient(90deg,#43d17c,#b6ff5e)} | |
| .cell .hpbar.low i{background:linear-gradient(90deg,#ff3b3b,#ff9a3b)} | |
| .cell .side-dot{position:absolute;top:2px;left:3px;width:8px;height:8px;border-radius:50%;z-index:2} | |
| .cell .terrain-tag{position:absolute;top:1px;right:2px;font-size:9px;opacity:.85;z-index:0} | |
| .cell.acted .u{filter:grayscale(.8) brightness(.6)} | |
| @keyframes pulse{from{opacity:.6}to{opacity:1}} | |
| @keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-4px)}75%{transform:translateX(4px)}} | |
| .shake{animation:shake .3s} | |
| @keyframes floatDmg{0%{opacity:1;transform:translateY(0)}100%{opacity:0;transform:translateY(-28px)}} | |
| .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} | |
| .dmgFloat.heal{color:#7bff9e} | |
| #side{background:#141936;border:2px solid #4a55aa;border-radius:12px;padding:10px;width:min(92vw,260px)} | |
| #side h3{margin:2px 0 8px;font-size:14px;color:var(--gold);letter-spacing:2px} | |
| #unitCard{background:#0f1430;border:1px solid #3a4488;border-radius:8px;padding:8px;font-size:13px;line-height:1.6;min-height:150px} | |
| #unitCard .big{font-size:28px} | |
| .stat{display:flex;justify-content:space-between} | |
| .bar{height:8px;background:#000;border-radius:4px;overflow:hidden;margin:3px 0 6px} | |
| .bar i{display:block;height:100%;background:linear-gradient(90deg,#ff5f6d,#ffc371)} | |
| .bar.exp i{background:linear-gradient(90deg,#4da6ff,#b174ff)} | |
| #menu{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap} | |
| #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} | |
| #log div{border-bottom:1px dotted #ffffff18;padding:2px 0} | |
| #controls{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap} | |
| #overlay{ | |
| position:fixed;inset:0;background:#000c;display:flex;align-items:center;justify-content:center; | |
| z-index:50;padding:16px; | |
| } | |
| #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} | |
| #overlay h2{color:var(--gold);letter-spacing:4px} | |
| #overlay p{line-height:1.8;font-size:14px} | |
| .hidden{display:none} | |
| .legend{font-size:11px;opacity:.85;line-height:1.8;margin-top:8px} | |
| kbd{background:#000;border:1px solid #666;border-radius:4px;padding:0 5px;font-size:11px} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="wrap"> | |
| <h1>⚔️ 天使帝國 · 網頁版 ⚔️</h1> | |
| <div class="sub">第一關 —— 瓦爾哈拉防衛戰 | 向1993年大宇經典致敬的同人重製</div> | |
| <div id="story"> | |
| <h2>📜 序章</h2> | |
| <p id="storyText"></p> | |
| <div id="storyBtnRow"> | |
| <button class="ghost" id="skipStory">跳過劇情 ⏩</button> | |
| <button id="nextStory">繼續 ▸</button> | |
| </div> | |
| </div> | |
| <div id="hud"> | |
| <div class="chip">回合 <b id="turn">1</b></div> | |
| <div class="chip">階段 <b id="phase">我軍行動</b></div> | |
| <div class="chip">敵軍殘存 <b id="enemyCount">5</b></div> | |
| <div class="chip">我軍 <b id="allyCount">4</b></div> | |
| </div> | |
| <div id="main"> | |
| <div id="boardBox"> | |
| <div id="board"></div> | |
| <div id="controls"> | |
| <button id="endTurn">🔔 結束回合</button> | |
| <button class="ghost" id="restart">↺ 重新開始</button> | |
| <button class="ghost" id="howto">❓ 玩法</button> | |
| </div> | |
| </div> | |
| <div id="side"> | |
| <h3>▌ 部隊資訊</h3> | |
| <div id="unitCard">點選我軍單位開始行動。<br>藍色格可移動,紅色格可攻擊。</div> | |
| <div id="menu"></div> | |
| <div id="log"></div> | |
| <div class="legend"> | |
| 地形:🌿草原 �🌲森林(迴避+防禦) ⛰️山(不可進入) 🏰城砦(防禦++) 🛣️道路<br> | |
| 操作:點藍格移動 → 選攻擊/治療/待機 → 點紅格目標<br> | |
| 僧侶可治療綠格友軍。主角 <b>愛琳</b> 陣亡即失敗。 | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="overlay" class="hidden"><div class="card"><h2 id="ovTitle"></h2><p id="ovText"></p><button id="ovBtn">再戰一關</button></div></div> | |
| <script> | |
| // ============ 音效 (WebAudio 合成, 無需外部檔) ============ | |
| let AC=null; | |
| 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){}} | |
| const sMove=()=>{beep(440,.08);setTimeout(()=>beep(660,.08),70)}; | |
| const sHit=()=>{beep(160,.18,'sawtooth',.18);setTimeout(()=>beep(90,.22,'sawtooth',.18),60)}; | |
| const sMiss=()=>beep(800,.15,'sine',.1); | |
| const sHeal=()=>{[523,659,784].forEach((f,i)=>setTimeout(()=>beep(f,.15,'sine'),i*90))}; | |
| const sWin=()=>{[523,659,784,1046,1318].forEach((f,i)=>setTimeout(()=>beep(f,.25),i*140))}; | |
| const sLose=()=>{[400,350,300,200].forEach((f,i)=>setTimeout(()=>beep(f,.3,'sawtooth'),i*180))}; | |
| // ============ 地圖 10x8 ============ | |
| // .:草原 F:森林 M:山 C:城砦 R:道路 | |
| const MAP=[ | |
| "....FF....", | |
| "..FFFF..C.", | |
| ".FF..RRR..", | |
| "..F.RRR.F.", | |
| ".M.RRC.RM.", | |
| "..F.RRR.F.", | |
| ".FF..RR...", | |
| "..FFFF....", | |
| ]; | |
| const W=10,H=8; | |
| function terrainAt(x,y){return MAP[y][x];} | |
| function terrainName(t){return {'.':'🌿草原',F:'🌲森林',M:'⛰️高山',C:'🏰城砦',R:'🛣️道路'}[t]} | |
| function moveCost(t){return t==='M'?99 : t==='F'?2 : 1} | |
| function defBonus(t){return t==='C'?0.55 : t==='F'?0.75 : 1} | |
| function evaBonus(t){return t==='C'?25 : t==='F'?20 : 0} | |
| // ============ 部隊 ============ | |
| function baseUnits(){return [ | |
| {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}, | |
| {id:'leah', side:'P', name:'莉亞', title:'精靈弓箭手', icon:'🧝', x:4,y:6, maxhp:26, hp:26, atk:11, def:4, mov:4, rng:[1,2]}, | |
| {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}, | |
| {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}, | |
| {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}, | |
| {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}, | |
| {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}, | |
| {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}, | |
| {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}, | |
| ];} | |
| let units, turn, phase, selected, moveTiles, atkTiles, healTiles, pendingMove, gameOver, busy; | |
| const board=document.getElementById('board'), card=document.getElementById('unitCard'), | |
| menu=document.getElementById('menu'), logEl=document.getElementById('log'); | |
| function log(m){const d=document.createElement('div');d.innerHTML=m;logEl.prepend(d);} | |
| function unitAt(x,y){return units.find(u=>u.hp>0&&u.x===x&&u.y===y)} | |
| function alive(side){return units.filter(u=>u.side===side&&u.hp>0)} | |
| function init(){ | |
| units=baseUnits(); turn=1; phase='P'; selected=null; moveTiles=[]; atkTiles=[]; healTiles=[]; pendingMove=null; gameOver=false; busy=false; | |
| units.forEach(u=>{u.moved=false;u.acted=false;u.lv=u.lv||1;u.exp=u.exp||0;}); | |
| document.getElementById('turn').textContent=turn; | |
| document.getElementById('phase').textContent='我軍行動'; | |
| logEl.innerHTML=''; | |
| log('⚔️ 第一關開始:消滅所有敵軍!保護 <b>愛琳</b>!'); | |
| log('僧侶珍妮 👼 可以治療鄰近友軍。'); | |
| render(); | |
| } | |
| function render(){ | |
| document.getElementById('enemyCount').textContent=alive('E').length; | |
| document.getElementById('allyCount').textContent=alive('P').length; | |
| board.innerHTML=''; | |
| for(let y=0;y<H;y++)for(let x=0;x<W;x++){ | |
| const t=terrainAt(x,y); | |
| const c=document.createElement('div'); | |
| c.className='cell '+({'.':'plain',F:'forest',M:'mountain',C:'castle',R:'road'}[t]); | |
| c.dataset.x=x;c.dataset.y=y; | |
| const tag=document.createElement('span');tag.className='terrain-tag'; | |
| tag.textContent=t==='F'?'🌲':t==='M'?'⛰️':t==='C'?'🏰':''; | |
| c.appendChild(tag); | |
| if(moveTiles.some(m=>m.x===x&&m.y===y))c.classList.add('moveable'); | |
| if(atkTiles.some(m=>m.x===x&&m.y===y))c.classList.add('attackable'); | |
| if(healTiles.some(m=>m.x===x&&m.y===y))c.classList.add('healable'); | |
| const u=unitAt(x,y); | |
| if(u){ | |
| const s=document.createElement('span');s.className='u';s.textContent=u.icon;c.appendChild(s); | |
| const d=document.createElement('span');d.className='side-dot'; | |
| d.style.background=u.side==='P'?'#4da6ff':'#ff4d4d';c.appendChild(d); | |
| const hb=document.createElement('div');hb.className='hpbar'+(u.hp/u.maxhp<0.35?' low':''); | |
| const i=document.createElement('i');i.style.width=Math.max(0,u.hp/u.maxhp*100)+'%';hb.appendChild(i);c.appendChild(hb); | |
| if(u.acted&&u.side==='P')c.classList.add('acted'); | |
| if(selected===u)c.classList.add('selected'); | |
| } | |
| c.onclick=()=>onCell(x,y); | |
| c.onmouseenter=()=>{const uu=unitAt(x,y); if(uu) showCard(uu);}; | |
| board.appendChild(c); | |
| } | |
| } | |
| function showCard(u){ | |
| card.innerHTML=`<span class="big">${u.icon}</span> <b>${u.name}</b> <span style="opacity:.7">Lv${u.lv} · ${u.title||''}</span> | |
| <span style="float:right;color:${u.side==='P'?'#4da6ff':'#ff6b6b'}">${u.side==='P'?'【我軍】':'【敵軍】'}</span> | |
| <div class="stat"><span>HP ${Math.max(0,u.hp)}/${u.maxhp}</span><span>EXP ${u.exp||0}/30</span></div> | |
| <div class="bar"><i style="width:${u.hp/u.maxhp*100}%"></i></div> | |
| <div class="stat"><span>⚔️攻擊 ${u.atk}</span><span>🛡️防禦 ${u.def}</span></div> | |
| <div class="stat"><span>👟移動 ${u.mov}</span><span>🏹射程 ${u.rng[0]}-${u.rng[1]}</span></div> | |
| <div style="opacity:.75">地形:${terrainName(terrainAt(u.x,u.y))}</div>`; | |
| } | |
| // ---- 移動範圍 BFS ---- | |
| function calcMove(u){ | |
| const dist=Array.from({length:H},()=>Array(W).fill(99)); | |
| dist[u.y][u.x]=0;const q=[[u.x,u.y]]; | |
| while(q.length){const [x,y]=q.shift(); | |
| for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){ | |
| const nx=x+dx,ny=y+dy; | |
| if(nx<0||ny<0||nx>=W||ny>=H)continue; | |
| const c=moveCost(terrainAt(nx,ny));if(c>=99)continue; | |
| const occ=unitAt(nx,ny);if(occ&&occ!==u)continue; | |
| if(dist[ny][nx]>dist[y][x]+c){dist[ny][nx]=dist[y][x]+c;q.push([nx,ny]);} | |
| }} | |
| 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}); | |
| return out; | |
| } | |
| function tilesInRange(x,y,r0,r1,side,targetSide){ | |
| const out=[]; | |
| for(let yy=0;yy<H;yy++)for(let xx=0;xx<W;xx++){ | |
| const d=Math.abs(xx-x)+Math.abs(yy-y); | |
| if(d<r0||d>r1)continue; | |
| const t=unitAt(xx,yy); | |
| if(targetSide==='enemy'&&t&&t.side!==side&&t.hp>0)out.push({x:xx,y:yy}); | |
| if(targetSide==='ally'&&t&&t.side===side&&t.hp>0&&t.hp<t.maxhp&&!(xx===x&&yy===y))out.push({x:xx,y:yy}); | |
| } | |
| return out; | |
| } | |
| // ---- 點擊 ---- | |
| function onCell(x,y){ | |
| if(gameOver||busy||phase!=='P')return; | |
| const u=unitAt(x,y); | |
| // 1. 有待命中攻擊目標? | |
| if(selected&&pendingMove==='attack'){ | |
| if(atkTiles.some(t=>t.x===x&&t.y===y)){doAttack(selected,u);return;} | |
| } | |
| if(selected&&pendingMove==='heal'){ | |
| if(healTiles.some(t=>t.x===x&&t.y===y)){doHeal(selected,u);return;} | |
| else {pendingMove=null;showActions(selected);render();return;} | |
| } | |
| // 2. 點移動格? | |
| if(selected&&!selected.moved&&moveTiles.some(t=>t.x===x&&t.y===y)){ | |
| selected.x=x;selected.y=y;selected.moved=true;sMove(); | |
| log(`${selected.icon} ${selected.name} 移動至 (${x},${y})`); | |
| moveTiles=[];pendingMove=null;showActions(selected);render();showCard(selected);return; | |
| } | |
| // 3. 選取我軍? | |
| if(u&&u.side==='P'&&!u.acted){ | |
| selected=u;pendingMove=null; | |
| moveTiles=u.moved?[]:calcMove(u); | |
| atkTiles=[];healTiles=[]; | |
| showCard(u);renderMenuHint(u);render(); | |
| return; | |
| } | |
| if(u&&u.side==='P'&&u.acted){showCard(u);menu.innerHTML='<span style="font-size:12px;opacity:.7">該單位已行動完畢。</span>';return;} | |
| if(u)showCard(u); | |
| } | |
| function renderMenuHint(u){ | |
| menu.innerHTML=`<span style="font-size:12px;opacity:.8">已選擇 ${u.icon}${u.name}:點藍色格移動,或直接選行動。</span>`; | |
| const m=document.createElement('div');m.style.cssText='display:flex;gap:6px;margin-top:6px;flex-wrap:wrap'; | |
| const bA=document.createElement('button');bA.textContent='⚔️ 攻擊';bA.style.padding='6px 10px'; | |
| bA.onclick=()=>showActions(u,'attack'); | |
| const bW=document.createElement('button');bW.textContent='待機 ⏳';bW.className='ghost';bW.style.padding='6px 10px'; | |
| bW.onclick=()=>{finishUnit(u);log(`${u.icon} ${u.name} 待機。`)}; | |
| m.appendChild(bA); | |
| if(u.healer){const bH=document.createElement('button');bH.textContent='💚 治療';bH.style.padding='6px 10px';bH.onclick=()=>showActions(u,'heal');m.appendChild(bH);} | |
| m.appendChild(bW);menu.appendChild(m); | |
| } | |
| function showActions(u,force){ | |
| atkTiles=tilesInRange(u.x,u.y,u.rng[0],u.rng[1],u.side,'enemy'); | |
| healTiles=u.healer?tilesInRange(u.x,u.y,1,2,u.side,'ally'):[]; | |
| menu.innerHTML=''; | |
| 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);}; | |
| if(force==='attack'||(!force&&!u.healer)){ | |
| if(atkTiles.length){pendingMove='attack';mk(`⚔️ 攻擊 (${atkTiles.length}個目標)`,()=>{render();});} | |
| else{pendingMove=null;mk('⚔️ 無可攻擊目標',()=>{},{disabled:1});} | |
| } | |
| if(u.healer&&(force==='heal'||!force)){ | |
| if(force==='heal'){pendingMove='heal';if(!healTiles.length)log('附近沒有需要治療的友軍。');} | |
| } | |
| if(!force){ | |
| if(u.healer&&healTiles.length)mk(`💚 治療 (${healTiles.length})`,()=>showActions(u,'heal')); | |
| mk('待機 ⏳',()=>{finishUnit(u);log(`${u.icon} ${u.name} 待機。`)},'ghost'); | |
| } else if(force==='attack'){ | |
| mk('取消',()=>{pendingMove=null;atkTiles=[];showActions(u)},'ghost'); | |
| } else if(force==='heal'){ | |
| mk('取消',()=>{pendingMove=null;healTiles=[];showActions(u)},'ghost'); | |
| } | |
| if(force==='attack'&&!atkTiles.length){pendingMove=null;} | |
| render(); | |
| } | |
| function finishUnit(u){u.acted=true;u.moved=true;selected=null;moveTiles=[];atkTiles=[];healTiles=[];menu.innerHTML='';checkAutoEnd();render();} | |
| // ---- 戰鬥 ---- | |
| function calcDamage(att,def){ | |
| const t=terrainAt(def.x,def.y); | |
| let base=att.magic? att.atk - def.def*0.35 : att.atk - def.def*0.6; | |
| base*=defBonus(t); | |
| base+=Math.floor(Math.random()*5)-2; | |
| return Math.max(1,Math.round(base)); | |
| } | |
| function hitChance(att,def){ | |
| return Math.max(55, 92 - evaBonus(terrainAt(def.x,def.y)) + (att.lv-def.lv)*3); | |
| } | |
| function floatText(x,y,txt,heal=false){ | |
| const idx=y*W+x; | |
| const cell=board.children[idx];if(!cell)return; | |
| const d=document.createElement('div');d.className='dmgFloat'+(heal?' heal':'');d.textContent=txt; | |
| cell.appendChild(d);setTimeout(()=>d.remove(),1000); | |
| } | |
| function gainExp(u,n){ | |
| if(u.side!=='P'||u.hp<=0)return; | |
| u.exp=(u.exp||0)+n; | |
| 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; | |
| log(`✨ ${u.icon} ${u.name} 升級!Lv${u.lv}(攻擊+2 防禦+1 HP+4)`);beep(880,.2,'sine');} | |
| } | |
| function doAttack(att,def){ | |
| if(!def||def.hp<=0)return; | |
| busy=true; | |
| const ch=hitChance(att,def); | |
| const roll=Math.random()*100; | |
| const idx=def.y*W+def.x; | |
| if(roll<ch){ | |
| const dmg=calcDamage(att,def); | |
| def.hp-=dmg;sHit(); | |
| if(board.children[idx]){board.children[idx].classList.add('shake');setTimeout(()=>board.children[idx]&&board.children[idx].classList.remove('shake'),320);} | |
| floatText(def.x,def.y,'-'+dmg); | |
| log(`${att.icon}${att.name} → ${def.icon}${def.name}:造成 <b>${dmg}</b> 傷害!${att.magic?'🔥':''}`); | |
| gainExp(att,def.hp<=0? (def.expV||15):8); | |
| if(def.hp<=0){def.hp=0;log(`☠️ ${def.icon} ${def.name} 被擊破!`);beep(220,.3,'sawtooth');} | |
| else{ | |
| // 反擊 | |
| const d=Math.abs(att.x-def.x)+Math.abs(att.y-def.y); | |
| if(d>=def.rng[0]&&d<=def.rng[1]){ | |
| setTimeout(()=>{ | |
| const ch2=hitChance(def,att); | |
| if(Math.random()*100<ch2){ | |
| const dmg2=calcDamage(def,att); | |
| att.hp-=dmg2;sHit();floatText(att.x,att.y,'-'+dmg2); | |
| log(`${def.icon}${def.name} 反擊!${att.icon}${att.name} 受到 <b>${dmg2}</b> 傷害`); | |
| if(att.hp<=0){att.hp=0;log(`☠️ ${att.icon} ${att.name} 倒下了…`);} | |
| } else {sMiss();floatText(att.x,att.y,'MISS');log(`${def.icon}${def.name} 反擊…落空!`);} | |
| afterCombat(att); | |
| },650); | |
| render();return; | |
| } | |
| } | |
| } else {sMiss();floatText(def.x,def.y,'MISS');log(`${att.icon}${att.name} 的攻擊被 ${def.icon}${def.name} 閃開了!`);gainExp(att,2);} | |
| setTimeout(()=>afterCombat(att),450); | |
| render(); | |
| } | |
| function afterCombat(att){ | |
| busy=false; | |
| att.acted=true;att.moved=true;selected=null;moveTiles=[];atkTiles=[];healTiles=[];menu.innerHTML=''; | |
| render();checkEnd();if(!gameOver)checkAutoEnd(); | |
| } | |
| function doHeal(healer,target){ | |
| const amt=9+Math.floor(Math.random()*4)+healer.lv; | |
| target.hp=Math.min(target.maxhp,target.hp+amt); | |
| sHeal();floatText(target.x,target.y,'+'+amt,true); | |
| log(`${healer.icon}${healer.name} 治療 ${target.icon}${target.name},恢復 <b style="color:#7bff9e">${amt}</b> HP`); | |
| gainExp(healer,6); | |
| busy=false;healer.acted=true;healer.moved=true;selected=null;moveTiles=[];atkTiles=[];healTiles=[];menu.innerHTML=''; | |
| render();checkAutoEnd(); | |
| } | |
| // ---- 回合 ---- | |
| function checkAutoEnd(){ | |
| if(gameOver)return; | |
| if(alive('P').every(u=>u.acted)){setTimeout(()=>{if(!gameOver&&phase==='P')endTurn();},600);} | |
| } | |
| document.getElementById('endTurn').onclick=()=>{if(phase==='P'&&!busy&&!gameOver)endTurn();}; | |
| function endTurn(){ | |
| selected=null;moveTiles=[];atkTiles=[];healTiles=[];menu.innerHTML=''; | |
| phase='E';document.getElementById('phase').textContent='敵軍行動';render(); | |
| log(`―― 第 ${turn} 回合・敵軍行動 ――`); | |
| setTimeout(enemyTurn,700); | |
| } | |
| function enemyTurn(){ | |
| const foes=alive('E'); | |
| let i=0; | |
| busy=true; | |
| function step(){ | |
| if(gameOver){busy=false;return;} | |
| if(i>=foes.length){ | |
| busy=false;turn++;document.getElementById('turn').textContent=turn; | |
| phase='P';document.getElementById('phase').textContent='我軍行動'; | |
| units.forEach(u=>{if(u.hp>0&&u.side==='P'){u.moved=false;u.acted=false;}}); | |
| log(`―― 第 ${turn} 回合・我軍行動 ――`); | |
| render();return; | |
| } | |
| const e=foes[i++]; | |
| if(e.hp<=0){step();return;} | |
| showCard(e); | |
| // 找最近的玩家 | |
| const ps=alive('P'); | |
| if(!ps.length){step();return;} | |
| let best=null,bd=1e9; | |
| ps.forEach(p=>{const d=Math.abs(p.x-e.x)+Math.abs(p.y-e.y);if(d<bd){bd=d;best=p;}}); | |
| // 若已在射程內直接打 | |
| let inR=tilesInRange(e.x,e.y,e.rng[0],e.rng[1],e.side,'enemy'); | |
| if(!inR.length){ | |
| // 靠近:BFS 找可走且離目標最近的格 | |
| const opts=calcMove(e); | |
| let bx=e.x,by=e.y,bbd=bd; | |
| 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;}}); | |
| e.x=bx;e.y=by;render(); | |
| inR=tilesInRange(e.x,e.y,e.rng[0],e.rng[1],e.side,'enemy'); | |
| } | |
| render(); | |
| setTimeout(()=>{ | |
| if(inR.length){ | |
| // 打最弱的 | |
| inR.sort((a,b)=>{const ua=unitAt(a.x,a.y),ub=unitAt(b.x,b.y);return ua.hp-ub.hp;}); | |
| const t=unitAt(inR[0].x,inR[0].y); | |
| // 簡化版敵方攻擊(不遞迴 busy) | |
| const ch=hitChance(e,t); | |
| if(Math.random()*100<ch){ | |
| const dmg=calcDamage(e,t);t.hp-=dmg;sHit();floatText(t.x,t.y,'-'+dmg); | |
| const ec=e.y*W+e.x, tc=t.y*W+t.x; | |
| log(`${e.icon}${e.name} → ${t.icon}${t.name}:<b>${dmg}</b> 傷害!`); | |
| if(t.hp<=0){t.hp=0;log(`☠️ ${t.icon} ${t.name} 倒下了…`);} | |
| } else {sMiss();floatText(t.x,t.y,'MISS');log(`${e.icon}${e.name} 攻擊 ${t.icon}${t.name}…落空!`);} | |
| render(); | |
| if(checkEnd()){busy=false;return;} | |
| } | |
| setTimeout(step,650); | |
| },550); | |
| } | |
| step(); | |
| } | |
| function checkEnd(){ | |
| const erin=units.find(u=>u.id==='erin'); | |
| if(erin.hp<=0){gameOver=true;showOverlay(false,'愛琳倒下了…瓦爾哈拉的光芒熄滅了。');return true;} | |
| if(!alive('P').length){gameOver=true;showOverlay(false,'我軍全滅…黑暗吞噬了瓦爾哈拉。');return true;} | |
| if(!alive('E').length){gameOver=true;showOverlay(true,`第 ${turn} 回合,我軍赢得了瓦爾哈拉防衛戰!<br>愛琳: 「這只是開始…帝國的命運就掌握在我們手中!」<br><small>― 第一關・完 ―</small>`);return true;} | |
| return false; | |
| } | |
| function showOverlay(win,text){ | |
| win?sWin():sLose(); | |
| document.getElementById('ovTitle').textContent=win?'🏆 勝利!':'💀 戰敗…'; | |
| document.getElementById('ovText').innerHTML=text; | |
| document.getElementById('overlay').classList.remove('hidden'); | |
| } | |
| document.getElementById('ovBtn').onclick=()=>{document.getElementById('overlay').classList.add('hidden');init();}; | |
| document.getElementById('restart').onclick=()=>{if(confirm('重新開始第一關?'))init();}; | |
| document.getElementById('howto').onclick=()=>alert('【玩法】\n1. 點我軍(藍點)→ 藍格移動\n2. 移動後選 攻擊/治療/待機\n3. 紅格是可攻擊的敵人,綠格是可治療的友軍\n4. 全部行動完自動換敵方回合\n5. 消滅包含首領在內全部5名敵軍即勝利\n6. 愛琳陣亡或我軍全滅即失敗\n\n森林/城砦有防禦加成,魔法師無視部分防禦。'); | |
| // ============ 劇情 ============ | |
| const STORY=[ | |
| "聖曆312年,瓦爾哈拉帝國——一個由女戰士守護的光之國度。", | |
| "北方死靈大軍越過冰封隘口,屍龍術士「莫戈」率領骷髏與哥布林先鋒,兵臨城下。", | |
| "女王近衛・愛琳:「姊妹們!城牆之後就是我們的家園,一步也不能退!」", | |
| "弓箭手莉亞、魔法師米雅、見習僧侶珍妮已集結完畢——第一關:瓦爾哈拉防衛戰,開戰!", | |
| ]; | |
| let si=0; | |
| function showStory(){ | |
| document.getElementById('storyText').textContent=STORY[si]; | |
| document.getElementById('nextStory').textContent=(si===STORY.length-1?'開戰!⚔️':'繼續 ▸'); | |
| } | |
| document.getElementById('nextStory').onclick=()=>{ | |
| beep(660,.08); | |
| if(si<STORY.length-1){si++;showStory();} | |
| else{document.getElementById('story').style.display='none';log('📜 劇情結束,戰鬥開始!');} | |
| }; | |
| document.getElementById('skipStory').onclick=()=>{document.getElementById('story').style.display='none';}; | |
| showStory(); | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |