ag1 / index.html
HelloSun's picture
Upload index.html with huggingface_hub
fdd27d1 verified
Raw History Blame Contribute Delete
26.6 kB
<!DOCTYPE html>
<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!important}
.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>