指挥官 · Commander
by SwiftGalaxy492408 lines77.4 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>指挥官 · Commander</title>
<style>
:root{
--cw: min(66px, 12vw, 7.2vh);
--ch: calc(var(--cw) * 1.38);
--panel: #1c2130;
--line: #2a3350;
--accent: #4a7cff;
--gold: #ffd75e;
--text: #e6ebf5;
--dim: #7c86a6;
--red: #ff6b6b;
}
*{ box-sizing: border-box; }
html, body{ height: 100%; margin: 0; }
body{
background: radial-gradient(ellipse at 50% -10%, #1e2942 0%, #0e1220 62%);
color: var(--text);
font-family: system-ui, "Microsoft YaHei", sans-serif;
overflow: hidden;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
#app{ height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
/* 顶栏 */
.topbar{
flex: 0 0 auto; height: 42px;
display: flex; align-items: center; justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid var(--line);
background: rgba(20,24,36,.72);
}
.brand{ font-weight: 700; letter-spacing: 3px; font-size: 15px; }
.brand small{ color: var(--dim); font-weight: 400; font-size: 9px; letter-spacing: 2px; margin-left: 6px; }
.top-actions{ display: flex; align-items: center; gap: 8px; }
/* 棋盘 */
.board{
flex: 1 1 auto; min-height: 0;
width: 100%; max-width: 720px; margin: 0 auto;
display: flex; flex-direction: column; gap: 4px;
padding: 6px 8px 8px;
}
.hand-row{
flex: 0 0 auto;
display: flex; justify-content: center; align-items: center;
height: calc(var(--ch) + 4px);
}
.hand-row .card + .card{ margin-left: calc(var(--cw) * -0.22); }
.battlefield{
flex: 1 1 auto; min-height: 0;
display: flex; flex-direction: column; gap: 6px;
}
.lane{
flex: 1 1 0; min-height: 0;
display: flex; align-items: center; justify-content: center;
gap: 4px; border-radius: 10px; padding: 4px;
}
.lane-enemy{ background: rgba(255,107,107,.05); }
.lane-contested{ background: rgba(255,215,94,.05); }
.lane-player{ background: rgba(74,124,255,.07); }
.slot{
flex: 0 0 auto;
width: var(--cw); height: var(--ch);
border-radius: 9px;
border: 1px dashed rgba(74,124,255,.18);
display: grid; place-items: center;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.slot.empty.valid-target{
border-color: var(--accent);
background: rgba(74,124,255,.12);
border-style: solid; cursor: pointer;
box-shadow: 0 0 10px rgba(74,124,255,.28) inset;
}
.slot.empty.valid-target:hover{ background: rgba(74,124,255,.22); }
.hq-slot{
flex: 0 0 auto;
width: var(--cw); height: var(--ch);
border-radius: 9px;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 2px;
background: linear-gradient(180deg, #232b42, #1a2032);
border: 1px solid var(--line);
color: #c9d2e8;
transition: border-color .15s, box-shadow .15s, background .15s;
}
.hq-slot .hq-label{ font-size: calc(var(--cw) * 0.14); letter-spacing: 1px; color: var(--dim); }
.hq-slot .hq-hp{
font-size: calc(var(--cw) * 0.26); font-weight: 800;
font-variant-numeric: tabular-nums; color: var(--gold); line-height: 1;
}
.hq-slot.enemy .hq-hp{ color: var(--red); }
.hq-slot.valid-target{
cursor: pointer;
border-color: var(--gold);
box-shadow: 0 0 0 1px var(--gold), 0 0 12px rgba(255,215,94,.4);
}
/* 卡牌 */
.card{
position: relative;
width: var(--cw); height: var(--ch);
border-radius: 9px; overflow: hidden;
display: flex; flex-direction: column;
border: 2px solid #f0f2f8;
box-shadow: 0 3px 9px rgba(0,0,0,.5);
transition: transform .12s ease, box-shadow .12s ease, opacity .2s, border-color .15s;
}
.card.r-common{ background: linear-gradient(180deg, #232838 0%, #12151f 100%); }
.card.r-rare{ background: linear-gradient(180deg, #1c3a5c 0%, #0e1d30 100%); }
.card.r-epic{ background: linear-gradient(180deg, #38205c 0%, #1c0e30 100%); }
.card.r-legendary{ background: linear-gradient(180deg, #5c4a1c 0%, #30260e 100%); }
.card.r-legendary .name{ color: #ffe6a3; }
.card .cost{
position: absolute; top: 3px; left: 3px;
width: calc(var(--cw) * 0.28); height: calc(var(--cw) * 0.28);
border-radius: 50%;
display: grid; place-items: center;
background: var(--accent); color: #fff;
font-size: calc(var(--cw) * 0.16); font-weight: 700;
box-shadow: 0 1px 4px rgba(0,0,0,.4);
z-index: 1;
}
.card .name{
flex: 1 1 auto;
display: grid; place-items: center;
text-align: center;
padding: 0 4px;
font-size: calc(var(--cw) * 0.185);
font-weight: 700;
color: #e8eefc;
line-height: 1.25; word-break: break-all;
}
.card .stats{
display: flex; justify-content: space-between;
padding: 3px 6px 4px;
font-size: calc(var(--cw) * 0.2); font-weight: 700;
font-variant-numeric: tabular-nums;
}
.card .atk{ color: var(--gold); }
.card .hp{ color: #ff8a8a; }
.card.command{ background: linear-gradient(180deg, #1c3350 0%, #0f1a28 100%) !important; }
.card.command .cost{ background: #3f8fd8; }
.card.back{
display: grid; place-items: center;
background: repeating-linear-gradient(45deg, #171d2c 0 6px, #1e2740 6px 12px) !important;
border-color: #333f63;
color: #4d5980; font-size: calc(var(--cw) * 0.16);
letter-spacing: 2px; font-weight: 700;
}
.card.hand{ cursor: pointer; }
.card.hand:hover{ transform: translateY(-12px); box-shadow: 0 10px 18px rgba(0,0,0,.6); }
.card.hand.unaffordable{ opacity: .42; }
.card.hand.unaffordable:hover{ transform: translateY(-4px); }
.card.unit.exhausted{ opacity: .5; filter: grayscale(.45); }
.card.unit.can-act{ cursor: pointer; }
.card.unit.can-act:hover{ transform: translateY(-4px); }
.card.owned-enemy::before{
content: '';
position: absolute; inset: 0;
border-radius: 7px;
border: 1px solid rgba(255,107,107,.45);
pointer-events: none;
}
.card.selected{
border-color: var(--gold) !important;
box-shadow: 0 0 0 2px var(--gold), 0 8px 16px rgba(255,215,94,.35) !important;
transform: translateY(-6px);
}
.card.targetable{
cursor: pointer;
box-shadow: 0 0 0 2px var(--red), 0 0 12px rgba(255,107,107,.5) !important;
}
/* 按钮 */
.btn{
border: 0; border-radius: 9px; cursor: pointer;
padding: 8px 16px; font-size: 13px; font-weight: 600;
color: #fff; background: var(--accent);
font-family: inherit;
transition: filter .15s, transform .08s;
}
.btn:hover{ filter: brightness(1.1); }
.btn:active{ transform: scale(.97); }
.btn:disabled{ opacity: .35; cursor: not-allowed; filter: none; transform: none; }
.btn.ghost{ background: transparent; border: 1px solid var(--line); color: #9aa4bf; font-weight: 400; }
.topbar .btn{ padding: 5px 11px; font-size: 12px; }
.text-btn{
padding: 5px 12px; border-radius: 9px;
background: rgba(28,33,48,.9); border: 1px solid var(--line);
color: var(--text); font-size: 12px; font-family: inherit;
cursor: pointer;
transition: border-color .16s, background .16s, transform .1s;
}
.text-btn:hover{ border-color: #3a5aa8; background: #222b42; }
.text-btn:active{ transform: scale(.97); }
/* 信息条 */
.info-bar{
flex: 0 0 auto;
display: flex; align-items: center; gap: 10px;
padding: 6px 10px;
background: rgba(20,24,36,.6);
border-top: 1px solid var(--line);
font-size: 12px;
}
.info-side{ display: flex; align-items: center; gap: 8px; flex: 1 1 0; }
.info-side.right{ justify-content: flex-end; }
.info-hp{ color: #c9d2e8; font-variant-numeric: tabular-nums; }
.info-kredits{
font-weight: 700; color: #7fb0ff;
background: rgba(74,124,255,.13); border: 1px solid #2f4890;
padding: 2px 8px; border-radius: 99px; font-variant-numeric: tabular-nums;
}
.turn-info{
flex: 0 0 auto; font-size: 12px; color: var(--dim);
letter-spacing: .5px; text-align: center; min-width: 130px;
}
.diff-tag{
flex: 0 0 auto; font-size: 10px; font-weight: 700;
letter-spacing: 1px; padding: 2px 8px; border-radius: 99px;
border: 1px solid var(--line); color: var(--dim);
}
.diff-tag.easy{ color: #6ee7a8; border-color: #2d6b4c; background: rgba(110,231,168,.08); }
.diff-tag.normal{ color: #7fb0ff; border-color: #2f4890; background: rgba(74,124,255,.08); }
.diff-tag.hard{ color: #ffb45e; border-color: #8a5a2e; background: rgba(255,180,94,.08); }
.coin-badge{
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 99px;
background: linear-gradient(180deg, rgba(255,215,94,.16), rgba(255,183,74,.06));
border: 1px solid rgba(255,215,94,.38);
color: var(--gold); font-size: 12px; font-weight: 700;
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.coin-badge.small{ padding: 3px 9px; font-size: 11px; }
.toast{
position: fixed; left: 50%; bottom: 60px; transform: translate(-50%, 12px);
background: rgba(28,33,48,.96); border: 1px solid var(--line);
color: #cfe0ff; font-size: 12px; padding: 8px 16px; border-radius: 99px;
opacity: 0; pointer-events: none; transition: all .22s ease; z-index: 90;
white-space: nowrap;
}
.toast.show{ opacity: 1; transform: translate(-50%, 0); }
/* 弹窗通用 */
.modal{
position: fixed; inset: 0; z-index: 50;
display: none; place-items: center; padding: 16px;
background: rgba(6,9,16,.78);
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
}
.modal.show{ display: grid; }
.modal-card{
width: min(400px, 100%);
max-height: calc(100vh - 32px); overflow: auto;
background: var(--panel); border: 1px solid var(--line);
border-radius: 16px; padding: 18px;
box-shadow: 0 20px 50px rgba(0,0,0,.6);
}
.modal-head{ display: flex; align-items: center; justify-content: space-between; }
.modal-head h2{ margin: 0; font-size: 15px; letter-spacing: 1px; }
.btn-x{
background: transparent; border: 0; color: var(--dim);
font-size: 16px; cursor: pointer; padding: 2px 6px;
}
.btn-x:hover{ color: var(--text); }
.hint{ font-size: 11px; line-height: 1.7; color: var(--dim); margin: 10px 0 12px; }
.modal-card textarea{
width: 100%; height: 74px; box-sizing: border-box;
padding: 9px; border-radius: 9px; resize: none;
border: 1px solid var(--line); background: #10131d; color: #7c86a6;
font-size: 11px; font-family: ui-monospace, monospace; outline: none;
}
.modal-card textarea:focus, .modal-card input:focus{ border-color: #3a5aa8; }
.modal-card input{
width: 100%; box-sizing: border-box; margin-top: 8px;
padding: 9px 11px; border-radius: 9px;
border: 1px solid var(--line); background: #10131d; color: var(--text);
font-size: 13px; font-family: inherit; outline: none;
}
.modal-card .btn{ width: 100%; margin-top: 8px; }
.save-status{ min-height: 16px; margin-top: 10px; font-size: 12px; text-align: center; color: #6ee7a8; }
/* 卡牌详情 */
.cd-layout{ display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 4px 0; }
.cd-preview .card{ --cw: 120px; pointer-events: none; }
.cd-info{ width: 100%; display: flex; flex-direction: column; gap: 8px; }
.cd-name{ text-align: center; font-size: 18px; font-weight: 700; letter-spacing: 2px; margin: 0; }
.cd-badges{ display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; }
.cd-rarity{
font-size: 11px; font-weight: 700; letter-spacing: 1px;
padding: 3px 10px; border-radius: 99px;
background: #2a3350; color: #9aa4bf; border: 1px solid #38446a;
}
.cd-rarity.r-rare{ background: rgba(74,158,255,.16); color: #90c0ff; border-color: #3a72b8; }
.cd-rarity.r-epic{ background: rgba(160,107,255,.16); color: #c6a1ff; border-color: #6b4fb8; }
.cd-rarity.r-legendary{ background: rgba(255,183,74,.16); color: #ffd75e; border-color: #a8823a; }
.cd-cost-badge{
font-size: 11px; font-weight: 700; letter-spacing: 1px;
padding: 3px 10px; border-radius: 99px;
background: rgba(74,124,255,.15); color: #7fb0ff; border: 1px solid #2f4890;
}
.cd-stat-row{ display: flex; gap: 10px; margin-top: 4px; }
.cd-stat{
flex: 1 1 0; display: flex; flex-direction: column;
align-items: center; gap: 3px; padding: 10px 6px;
border-radius: 11px; background: rgba(12,16,32,.6); border: 1px solid var(--line);
}
.cd-stat small{ font-size: 10px; color: var(--dim); letter-spacing: 1px; }
.cd-stat b{ font-size: 20px; font-variant-numeric: tabular-nums; }
.cd-stat.atk b{ color: var(--gold); }
.cd-stat.hp b{ color: #ff8a8a; }
.cd-desc{
margin: 4px 0 0; padding: 10px 12px; border-radius: 10px;
background: rgba(12,16,32,.6); border: 1px solid var(--line);
font-size: 12.5px; line-height: 1.7; color: #b9c3da; min-height: 42px;
}
/* 结算 */
.overlay{
position: fixed; inset: 0; z-index: 55;
display: none; place-items: center; background: rgba(6,9,16,.8);
}
.overlay.show{ display: grid; }
.overlay-card{
text-align: center; padding: 30px 40px;
background: var(--panel); border: 1px solid var(--line);
border-radius: 18px; min-width: 240px;
}
.overlay-title{ font-size: 26px; font-weight: 700; letter-spacing: 6px; margin-bottom: 10px; }
.overlay-title.win{ color: var(--gold); }
.overlay-title.lose{ color: #ff7b7b; }
.overlay-reward{
font-size: 13px; font-weight: 700; color: var(--gold);
margin-bottom: 18px; letter-spacing: 1px;
}
/* 大厅 */
.lobby{
position: fixed; inset: 0; z-index: 40;
display: grid; place-items: center; padding: 20px;
background: radial-gradient(ellipse at 50% 0%, #1e2942 0%, #0b0e18 65%);
transition: opacity .28s ease, visibility .28s;
}
.lobby.hidden{ opacity: 0; visibility: hidden; pointer-events: none; }
.lobby-top{
position: absolute; top: 14px; right: 14px;
display: flex; align-items: center; gap: 8px;
}
.lobby-inner{ width: min(300px, 100%); display: flex; flex-direction: column; gap: 18px; }
.lobby-logo{ text-align: center; }
.lobby-title{
font-size: 34px; font-weight: 800; letter-spacing: 8px; text-indent: 8px;
background: linear-gradient(180deg, #ffffff 0%, #8fb2ff 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
line-height: 1.1;
}
.lobby-sub{ margin-top: 6px; font-size: 9px; letter-spacing: 3px; color: var(--dim); }
.lobby-menu{ display: flex; flex-direction: column; gap: 8px; }
.menu-btn{
display: flex; align-items: center;
width: 100%; text-align: left; padding: 10px 13px;
border-radius: 11px; cursor: pointer; font-family: inherit;
background: rgba(28,33,48,.9); border: 1px solid var(--line);
color: var(--text);
transition: background .16s, border-color .16s, transform .1s;
}
.menu-btn:hover{ border-color: #3a5aa8; background: #222b42; transform: translateY(-1px); }
.menu-btn:active{ transform: scale(.985); }
.menu-btn:disabled{ opacity: .38; cursor: not-allowed; transform: none;
border-color: var(--line); background: rgba(28,33,48,.9); }
.menu-btn.primary{
background: linear-gradient(180deg, #3f6fe8, #3559c4);
border-color: #4a7cff;
}
.menu-btn.primary:hover{ background: linear-gradient(180deg, #4b7cf5, #3a62d6); border-color: #6b93ff; }
.mb-text{ display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.mb-text b{ font-size: 13px; font-weight: 600; letter-spacing: .5px; }
.mb-text small{ font-size: 10px; color: #8d98b8; }
.menu-btn.primary .mb-text small{ color: #c6d6ff; }
.lobby-foot{ text-align: center; font-size: 10px; color: #4d5980; letter-spacing: 1px; }
/* 商店 */
.shop-balance{
display: flex; align-items: baseline; justify-content: center; gap: 8px;
margin: 12px 0 14px; padding: 11px; border-radius: 12px;
background: linear-gradient(180deg, rgba(255,215,94,.10), rgba(255,183,74,.04));
border: 1px solid rgba(255,215,94,.28);
color: var(--gold); font-size: 21px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
.shop-balance small{ font-size: 11px; font-weight: 400; color: #b39a52; letter-spacing: 1px; }
.shop-list{ display: flex; flex-direction: column; gap: 11px; }
.shop-pack{
display: flex; align-items: center; gap: 12px;
padding: 13px 14px; border-radius: 13px;
background: linear-gradient(180deg, #232b42 0%, #1a2032 100%);
border: 1px solid var(--line);
transition: border-color .16s, transform .1s;
}
.shop-pack:hover{ border-color: #3a5aa8; transform: translateY(-1px); }
.shop-pack-body{ flex: 1 1 auto; min-width: 0; }
.shop-pack-name{ font-size: 14px; font-weight: 700; letter-spacing: .5px; }
.shop-pack.officer .shop-pack-name{ color: var(--gold); }
.shop-pack-desc{ font-size: 11px; color: var(--dim); margin-top: 4px; line-height: 1.5; }
.shop-buy{
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px;
padding: 9px 14px; border-radius: 10px;
font-size: 13px; font-weight: 700;
font-family: inherit; cursor: pointer;
color: #fff; border: 0;
background: linear-gradient(180deg, #3f6fe8, #3559c4);
font-variant-numeric: tabular-nums; white-space: nowrap;
transition: filter .15s, transform .08s;
}
.shop-buy:hover{ filter: brightness(1.12); }
.shop-buy:active{ transform: scale(.96); }
.shop-buy:disabled{ opacity: .35; cursor: not-allowed; filter: none; transform: none; }
/* 战役弹窗 */
.battle-label{
font-size: 11px; color: var(--dim); letter-spacing: 1.5px;
margin: 14px 0 8px;
}
.battle-label:first-of-type{ margin-top: 8px; }
.diff-list{ display: flex; flex-direction: column; gap: 8px; }
.diff-btn{
display: flex; align-items: center; gap: 12px;
padding: 11px 14px; border-radius: 12px;
background: rgba(28,33,48,.9);
border: 1px solid var(--line);
color: var(--text); font-family: inherit;
cursor: pointer; text-align: left;
transition: border-color .15s, background .15s, transform .1s;
}
.diff-btn:hover{ border-color: #3a5aa8; }
.diff-btn.selected{
border-color: var(--accent);
background: rgba(74,124,255,.13);
box-shadow: 0 0 0 1px var(--accent);
}
.diff-btn .d-name{ font-size: 14px; font-weight: 700; letter-spacing: .5px; }
.diff-btn .d-desc{ font-size: 11px; color: var(--dim); margin-top: 2px; line-height: 1.5; }
.diff-btn .d-reward{
flex: 0 0 auto; font-size: 13px; font-weight: 800;
color: var(--gold); font-variant-numeric: tabular-nums;
padding: 4px 10px; border-radius: 99px;
background: rgba(255,215,94,.1); border: 1px solid rgba(255,215,94,.3);
}
.diff-btn.selected .d-reward{
background: rgba(255,215,94,.18);
}
.deck-pick-list{ display: flex; flex-direction: column; gap: 8px; }
.deck-pick{
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-radius: 11px;
background: rgba(28,33,48,.9);
border: 1px solid var(--line);
color: var(--text); font-family: inherit;
cursor: pointer; text-align: left;
transition: border-color .15s, background .15s;
}
.deck-pick:hover{ border-color: #3a5aa8; }
.deck-pick.selected{
border-color: var(--accent);
background: rgba(74,124,255,.13);
box-shadow: 0 0 0 1px var(--accent);
}
.deck-pick:disabled{ opacity: .35; cursor: not-allowed; }
.deck-pick .dp-name{ flex: 1 1 auto; font-size: 13px; font-weight: 600; }
.deck-pick .dp-count{ font-size: 11px; color: var(--dim); font-variant-numeric: tabular-nums; }
.deck-pick.selected .dp-count{ color: #90c0ff; }
/* 开包 */
.pack-overlay{
position: fixed; inset: 0; z-index: 70;
display: none; place-items: center; padding: 20px;
background: radial-gradient(ellipse at 50% 40%, rgba(30,41,66,.97), rgba(6,9,16,.98));
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
.pack-overlay.show{ display: grid; }
.pack-inner{ display: flex; flex-direction: column; align-items: center; gap: 22px; max-width: 100%; }
.pack-heading{
font-size: 15px; font-weight: 700; letter-spacing: 5px;
color: var(--gold); text-align: center;
}
.pack-heading small{
display: block; margin-top: 6px;
font-size: 10px; font-weight: 400; letter-spacing: 2px; color: var(--dim);
}
.pack-cards{
display: flex; gap: 10px; flex-wrap: wrap; justify-content: center;
max-width: min(620px, 96vw);
}
.pack-cards .card{
--cw: clamp(52px, 14vw, 76px);
animation: cardIn .48s cubic-bezier(.18,.9,.32,1.35) backwards;
cursor: pointer;
}
@keyframes cardIn{
from{ opacity: 0; transform: translateY(34px) scale(.55) rotate(-9deg); }
to { opacity: 1; transform: translateY(0) scale(1) rotate(0deg); }
}
/* 图鉴 + 组卡 */
.codex-card{ width: min(580px, 100%); }
.codex-tabs-row{
display: flex; align-items: center; gap: 8px;
margin: 12px 0 10px;
}
.deck-tabs{ display: flex; gap: 6px; flex: 1 1 auto; }
.deck-tab{
padding: 5px 10px;
border-radius: 7px;
background: transparent;
border: 1px solid var(--line);
color: var(--dim);
font-family: inherit; font-size: 11px;
cursor: pointer; white-space: nowrap;
transition: all .15s;
}
.deck-tab:hover{ color: var(--text); border-color: #3a5aa8; }
.deck-tab.active{
background: rgba(74,124,255,.13);
border-color: var(--accent);
color: var(--text);
}
.deck-tab small{ opacity: .7; margin-left: 3px; font-variant-numeric: tabular-nums; }
.codex-toolbar{
display: flex; align-items: center; gap: 10px;
margin: 0 0 12px;
padding: 9px 12px;
border-radius: 10px;
background: rgba(12,16,32,.55);
border: 1px solid var(--line);
font-size: 12px;
color: var(--dim);
}
.codex-toolbar .deck-num{
font-size: 15px; font-weight: 800; color: var(--gold);
font-variant-numeric: tabular-nums;
}
.codex-toolbar .spacer{ flex: 1 1 auto; }
.codex-name-row{
display: flex; gap: 8px; align-items: center;
margin: 0 0 12px;
}
.codex-name-row input{
flex: 1 1 auto;
margin: 0;
padding: 8px 11px;
font-size: 12.5px;
border-radius: 9px;
border: 1px solid var(--line);
background: #10131d;
color: var(--text);
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.codex-name-row input:focus{ border-color: #3a5aa8; }
.codex-name-row .btn{
width: auto; margin: 0;
padding: 8px 14px; font-size: 12px;
}
.codex-legend{
display: flex; flex-wrap: wrap; gap: 12px;
margin-bottom: 12px;
font-size: 10.5px; color: var(--dim);
}
.codex-legend span{ display: inline-flex; align-items: center; gap: 5px; }
.codex-legend i{
width: 11px; height: 11px; border-radius: 3px;
border: 1.5px solid #f0f2f8;
display: inline-block;
}
.codex-legend i.c-common{ background: #232838; }
.codex-legend i.c-rare{ background: #1c3a5c; }
.codex-legend i.c-epic{ background: #38205c; }
.codex-legend i.c-legendary{ background: #5c4a1c; }
.codex-grid{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
gap: 10px;
justify-items: center;
max-height: calc(100vh - 380px);
overflow-y: auto;
padding-right: 4px;
}
.codex-grid::-webkit-scrollbar{ width: 6px; }
.codex-grid::-webkit-scrollbar-thumb{ background: #2a3350; border-radius: 99px; }
.codex-grid .card{
--cw: 78px;
cursor: pointer;
position: relative;
margin: 0 auto;
}
.codex-grid .card.not-owned{
filter: grayscale(1);
opacity: .32;
}
.codex-grid .card:hover{
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0,0,0,.6);
}
.owned-badge,
.deck-badge{
position: absolute;
font-size: 9px; font-weight: 800;
padding: 1px 5px; border-radius: 6px;
z-index: 2;
font-variant-numeric: tabular-nums;
letter-spacing: .3px;
pointer-events: none;
}
.owned-badge{
top: 3px; right: 3px;
background: rgba(74,124,255,.85); color: #fff;
}
.deck-badge{
bottom: 3px; right: 3px;
background: rgba(255,215,94,.92); color: #2a1f00;
}
.settings-empty{
padding: 24px 10px; text-align: center;
color: var(--dim); font-size: 12.5px; line-height: 2;
}
</style>
</head>
<body>
<!-- ============================================================
大厅
============================================================ -->
<div class="lobby" id="lobby">
<div class="lobby-top">
<div class="coin-badge">金币 <span id="lobbyCoins">1000</span></div>
<button class="text-btn" id="settingsBtn">设置</button>
</div>
<div class="lobby-inner">
<div class="lobby-logo">
<div class="lobby-title">指挥官</div>
<div class="lobby-sub">C O M M A N D E R</div>
</div>
<div class="lobby-menu">
<button class="menu-btn primary" id="startBtn">
<span class="mb-text">
<b>开始战役</b>
<small>选择难度与卡组</small>
</span>
</button>
<button class="menu-btn" id="resumeBtn" disabled>
<span class="mb-text">
<b>继续当前对局</b>
<small>回到上一局战场</small>
</span>
</button>
<button class="menu-btn" id="loadBtn">
<span class="mb-text">
<b>读取存档</b>
<small>粘贴存档码还原进度</small>
</span>
</button>
<button class="menu-btn" id="shopBtn">
<span class="mb-text">
<b>卡包商店</b>
<small>消耗金币抽取单位卡牌</small>
</span>
</button>
<button class="menu-btn" id="codexBtn">
<span class="mb-text">
<b>卡牌图鉴</b>
<small>查看全部卡牌 · 编辑卡组</small>
</span>
</button>
</div>
<div class="lobby-foot">v0.6 · 支持密码存档</div>
</div>
</div>
<!-- ============================================================
游戏
============================================================ -->
<div id="app">
<header class="topbar">
<div class="brand">指挥官 <small>COMMANDER</small></div>
<div class="top-actions">
<div class="coin-badge small">金币 <span id="gameCoins">1000</span></div>
<button class="btn ghost" id="homeBtn">大厅</button>
<button class="btn ghost" id="saveBtn">存档</button>
</div>
</header>
<main class="board">
<div class="hand-row" id="enemyHand"></div>
<div class="battlefield">
<div class="lane lane-enemy">
<div class="slot" data-lane="enemyHome" data-idx="0"></div>
<div class="slot" data-lane="enemyHome" data-idx="1"></div>
<div class="hq-slot enemy" data-hq="enemy">
<span class="hq-label">敌方总部</span>
<span class="hq-hp" id="enemyHqHp">20</span>
</div>
<div class="slot" data-lane="enemyHome" data-idx="3"></div>
<div class="slot" data-lane="enemyHome" data-idx="4"></div>
</div>
<div class="lane lane-contested">
<div class="slot" data-lane="contested" data-idx="0"></div>
<div class="slot" data-lane="contested" data-idx="1"></div>
<div class="slot" data-lane="contested" data-idx="2"></div>
<div class="slot" data-lane="contested" data-idx="3"></div>
<div class="slot" data-lane="contested" data-idx="4"></div>
</div>
<div class="lane lane-player">
<div class="slot" data-lane="playerHome" data-idx="0"></div>
<div class="slot" data-lane="playerHome" data-idx="1"></div>
<div class="hq-slot" data-hq="player">
<span class="hq-label">我方总部</span>
<span class="hq-hp" id="playerHqHp">20</span>
</div>
<div class="slot" data-lane="playerHome" data-idx="3"></div>
<div class="slot" data-lane="playerHome" data-idx="4"></div>
</div>
</div>
<div class="hand-row" id="playerHand"></div>
</main>
<div class="info-bar">
<div class="info-side">
<span class="diff-tag" id="diffTag">普通</span>
<span class="info-hp" id="playerInfoHp">20 / 20</span>
<span class="info-kredits" id="playerKredits">0 / 0</span>
<span style="color:var(--dim);font-size:11px" id="playerMeta">手牌 0 · 牌库 0</span>
</div>
<div class="turn-info" id="turnInfo">第 1 回合 · 你的回合</div>
<div class="info-side right">
<span style="color:var(--dim);font-size:11px" id="enemyMeta">手牌 0 · 牌库 0</span>
<button class="btn" id="endTurnBtn">结束回合</button>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- ========== 战役弹窗 ========== -->
<div class="modal" id="battleModal">
<div class="modal-card">
<div class="modal-head">
<h2>开始战役</h2>
<button class="btn-x" id="closeBattle">X</button>
</div>
<div class="battle-label">选择难度</div>
<div class="diff-list" id="diffList"></div>
<div class="battle-label">选择卡组</div>
<div class="deck-pick-list" id="deckPickList"></div>
<button class="btn" id="battleStartBtn" style="margin-top:18px" disabled>开 始 战 役</button>
</div>
</div>
<!-- ========== 存档 ========== -->
<div class="modal" id="modal">
<div class="modal-card">
<div class="modal-head">
<h2>存档</h2>
<button class="btn-x" id="closeModal">X</button>
</div>
<p class="hint">
导出:设一个密码 → 生成存档码 → 复制保存(含金币与卡组)。<br>
读入:把存档码粘贴到下面,输入同样的密码即可还原。
</p>
<textarea id="saveCode" placeholder="导出后这里是存档码;读档时粘贴到这里"></textarea>
<input id="pwdInput" type="text" placeholder="密码(可留空)">
<button class="btn" id="exportBtn">导出存档</button>
<button class="btn" id="importBtn">读入存档</button>
<button class="btn ghost" id="newGameBtn">重开新游戏</button>
<div class="save-status" id="saveStatus"></div>
</div>
</div>
<!-- ========== 卡包商店 ========== -->
<div class="modal" id="shopModal">
<div class="modal-card">
<div class="modal-head">
<h2>卡包商店</h2>
<button class="btn-x" id="closeShop">X</button>
</div>
<div class="shop-balance">
<span id="shopCoins">1000</span>
<small>金币</small>
</div>
<div class="shop-list" id="shopList"></div>
</div>
</div>
<!-- ========== 卡牌图鉴 ========== -->
<div class="modal" id="codexModal">
<div class="modal-card codex-card">
<div class="modal-head">
<h2>卡牌图鉴 · 卡组编辑</h2>
<button class="btn-x" id="closeCodex">X</button>
</div>
<div class="codex-tabs-row">
<div class="deck-tabs" id="deckTabs"></div>
</div>
<div class="codex-toolbar">
<span>当前卡牌数</span>
<span class="deck-num"><span id="deckCount">0</span> / 40</span>
<span class="spacer"></span>
<span style="font-size:10.5px">点击卡牌添加 · 双击看详情</span>
</div>
<div class="codex-name-row">
<input id="deckNameInput" type="text" maxlength="12" placeholder="输入卡组名称">
<button class="btn ghost" id="clearDeckBtn">清空</button>
<button class="btn" id="saveDeckBtn">保存卡组</button>
</div>
<div class="codex-legend">
<span><i class="c-common"></i>普通</span>
<span><i class="c-rare"></i>稀有</span>
<span><i class="c-epic"></i>特殊</span>
<span><i class="c-legendary"></i>精锐</span>
<span style="margin-left:auto">未拥有卡牌显示为灰色</span>
</div>
<div class="codex-grid" id="codexGrid"></div>
</div>
</div>
<!-- ========== 卡牌详情 ========== -->
<div class="modal" id="cardDetailModal">
<div class="modal-card">
<div class="modal-head">
<h2>卡牌详情</h2>
<button class="btn-x" id="closeCardDetail">X</button>
</div>
<div class="cd-layout">
<div class="cd-preview" id="cdPreview"></div>
<div class="cd-info">
<h3 class="cd-name" id="cdName">卡牌</h3>
<div class="cd-badges">
<span class="cd-rarity" id="cdRarity">普通</span>
<span class="cd-cost-badge" id="cdCostBadge">费用 1</span>
</div>
<div class="cd-stat-row">
<div class="cd-stat atk"><small>攻击</small><b id="cdAtk">1</b></div>
<div class="cd-stat hp"><small>生命</small><b id="cdHp">1</b></div>
</div>
<p class="cd-desc" id="cdDesc">描述</p>
</div>
</div>
</div>
</div>
<!-- ========== 设置 ========== -->
<div class="modal" id="settingsModal">
<div class="modal-card">
<div class="modal-head">
<h2>设置</h2>
<button class="btn-x" id="closeSettings">X</button>
</div>
<div class="settings-empty">
设置项还在开发中……<br>
之后会加入音效、画质、语言等选项。
</div>
</div>
</div>
<!-- ========== 结算 ========== -->
<div class="overlay" id="overlay">
<div class="overlay-card">
<div class="overlay-title" id="overlayTitle">胜利</div>
<div class="overlay-reward" id="overlayReward">+50 金币</div>
<button class="btn" id="overlayBtn">再来一局</button>
</div>
</div>
<!-- ========== 开包结果 ========== -->
<div class="pack-overlay" id="packOverlay">
<div class="pack-inner">
<div class="pack-heading" id="packTitle">
普通包
<small>双击卡牌可查看详情</small>
</div>
<div class="pack-cards" id="packCards"></div>
<button class="btn" id="packOk">确 定</button>
</div>
</div>
</div>
<script>
/* =========================================================
存档模块
========================================================= */
const Save = (() => {
const PREFIX = 'SAVE1.';
const MAGIC = 'GAME-SAVE-OK';
function hashPassword(pwd) {
let h = 2166136261 >>> 0;
const s = 'x' + pwd + 'x';
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
function makeRng(seed) {
let s = (seed || 1) >>> 0;
return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s; };
}
const enc = new TextEncoder();
const dec = new TextDecoder();
function xorBytes(bytes, pwd) {
const rng = makeRng(hashPassword(pwd));
const out = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] ^ (rng() & 0xff);
return out;
}
function toBase64(bytes) {
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function fromBase64(b64) {
const s = atob(b64);
const bytes = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i);
return bytes;
}
function exportCode(state, password) {
const json = MAGIC + JSON.stringify(state);
return PREFIX + toBase64(xorBytes(enc.encode(json), password || ''));
}
function importCode(code, password) {
code = (code || '').trim();
if (!code.startsWith(PREFIX)) return { error: '存档格式不正确' };
try {
const bytes = fromBase64(code.slice(PREFIX.length));
const json = dec.decode(xorBytes(bytes, password || ''));
if (!json.startsWith(MAGIC)) return { error: '密码错误' };
return { data: JSON.parse(json.slice(MAGIC.length)) };
} catch (e) {
return { error: '存档损坏或密码错误' };
}
}
return { exportCode, importCode };
})();
/* =========================================================
稀有度
========================================================= */
const RARITY = {
common: { name: '普通', cls: 'r-common' },
rare: { name: '稀有', cls: 'r-rare' },
epic: { name: '特殊', cls: 'r-epic' },
legendary: { name: '精锐', cls: 'r-legendary' }
};
const RARITY_ORDER = ['common', 'rare', 'epic', 'legendary'];
/* =========================================================
卡牌数据
========================================================= */
const CARD_DB = {
/* 普通 */
scout: { name: '侦察兵', cost: 1, atk: 1, hp: 1, type: 'unit', rarity: 'common',
desc: '廉价的先锋,适合开局试探。' },
conscript: { name: '新兵', cost: 1, atk: 1, hp: 2, type: 'unit', rarity: 'common',
desc: '刚入伍的年轻人,便宜好用。' },
infantry: { name: '步兵连', cost: 2, atk: 2, hp: 3, type: 'unit', rarity: 'common',
desc: '可靠的主力部队,攻守均衡。' },
medic: { name: '战地医师', cost: 2, atk: 1, hp: 4, type: 'unit', rarity: 'common',
desc: '输出不高,但很耐打。' },
watchman: { name: '哨兵', cost: 3, atk: 2, hp: 5, type: 'unit', rarity: 'common',
desc: '坚守阵地的守卫。' },
/* 稀有 */
mortar: { name: '迫击炮组', cost: 3, atk: 3, hp: 2, type: 'unit', rarity: 'rare',
desc: '远程火力支援,攻高血薄。' },
sniper: { name: '狙击手', cost: 3, atk: 4, hp: 1, type: 'unit', rarity: 'rare',
desc: '一击毙命的猎手,极易被解。' },
engineer: { name: '工兵队', cost: 3, atk: 2, hp: 5, type: 'unit', rarity: 'rare',
desc: '能修能打的万金油。' },
repair: { name: '战地维修', cost: 1, atk: 0, hp: 0, type: 'command', rarity: 'rare',
target: 'player_hq', effect: { type: 'heal_hq', value: 5 },
desc: '点击我方总部:恢复 5 点生命。' },
/* 特殊 */
tank: { name: '装甲坦克', cost: 4, atk: 4, hp: 5, type: 'unit', rarity: 'epic',
desc: '坚固的钢铁巨兽,战场核心。' },
artillery: { name: '重炮阵地', cost: 4, atk: 5, hp: 3, type: 'unit', rarity: 'epic',
desc: '覆盖式轰炸,输出极高。' },
airstrike: { name: '空中打击', cost: 3, atk: 0, hp: 0, type: 'command', rarity: 'epic',
target: 'enemy_unit', effect: { type: 'damage', value: 5 },
desc: '点击敌方单位:对其造成 5 点伤害。' },
supply: { name: '军备补给', cost: 3, atk: 0, hp: 0, type: 'command', rarity: 'epic',
target: 'player_hq', effect: { type: 'buff_all', atk: 0, hp: 2 },
desc: '点击我方总部:我方全部单位 +0/+2。' },
/* 精锐 */
bomber: { name: '轰炸机', cost: 5, atk: 5, hp: 4, type: 'unit', rarity: 'legendary',
desc: '空中打击力量,攻守兼备。' },
fortress: { name: '要塞', cost: 6, atk: 4, hp: 8, type: 'unit', rarity: 'legendary',
desc: '不可撼动的堡垒,站场之王。' },
rally: { name: '集结号', cost: 2, atk: 0, hp: 0, type: 'command', rarity: 'legendary',
target: 'player_hq', effect: { type: 'buff_all', atk: 1, hp: 1 },
desc: '点击我方总部:我方全部单位 +1/+1。' }
};
const CARD_POOL = Object.keys(CARD_DB);
const CARDS_BY_RARITY = {};
Object.entries(CARD_DB).forEach(([id, def]) => {
(CARDS_BY_RARITY[def.rarity] ||= []).push(id);
});
const START_HP = 20;
const HAND_LIMIT = 6;
const MAX_KREDITS = 12;
const DECK_SIZE = 18;
const DECK_LIMIT = 40;
const DECK_SLOTS = 3;
const $ = id => document.getElementById(id);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const randId = () => Math.random().toString(36).slice(2, 9);
/* =========================================================
战役难度
========================================================= */
const DIFFICULTIES = {
easy: {
id: 'easy',
name: '简单',
reward: 50,
enemyHp: 15,
aggression: 0.45,
desc: '敌方总部 15 点生命 · 攻击欲望较弱'
},
normal: {
id: 'normal',
name: '普通',
reward: 100,
enemyHp: 20,
aggression: 0.7,
desc: '敌方总部 20 点生命 · 常规强度'
},
hard: {
id: 'hard',
name: '困难',
reward: 200,
enemyHp: 25,
aggression: 0.88,
desc: '敌方总部 25 点生命 · 攻击欲望强烈'
}
};
const DIFF_ORDER = ['easy', 'normal', 'hard'];
/* =========================================================
卡包配置
========================================================= */
const PACKS = {
normal: {
name: '普通包', price: 100, count: 5, cls: '',
desc: '5 张随机卡牌 · 保底 1 张稀有',
odds: [
{ common: 0.70, rare: 0.25, epic: 0.04, legendary: 0.01 },
{ common: 0.70, rare: 0.25, epic: 0.04, legendary: 0.01 },
{ common: 0.70, rare: 0.25, epic: 0.04, legendary: 0.01 },
{ common: 0.70, rare: 0.25, epic: 0.04, legendary: 0.01 },
{ common: 0.00, rare: 0.85, epic: 0.13, legendary: 0.02 }
]
},
officer: {
name: '军官包', price: 300, count: 7, cls: 'officer',
desc: '7 张高品质卡牌 · 保底 1 张特殊或精锐',
odds: [
{ common: 0.45, rare: 0.40, epic: 0.12, legendary: 0.03 },
{ common: 0.45, rare: 0.40, epic: 0.12, legendary: 0.03 },
{ common: 0.45, rare: 0.40, epic: 0.12, legendary: 0.03 },
{ common: 0.45, rare: 0.40, epic: 0.12, legendary: 0.03 },
{ common: 0.45, rare: 0.40, epic: 0.12, legendary: 0.03 },
{ common: 0.20, rare: 0.55, epic: 0.20, legendary: 0.05 },
{ common: 0.00, rare: 0.00, epic: 0.78, legendary: 0.22 }
]
}
};
function rollRarity(odds) {
const r = Math.random();
let acc = 0;
for (const key of RARITY_ORDER) {
acc += odds[key] || 0;
if (r < acc) return key;
}
return 'common';
}
function pickCardByRarity(rarity) {
const pool = CARDS_BY_RARITY[rarity] && CARDS_BY_RARITY[rarity].length
? CARDS_BY_RARITY[rarity] : CARDS_BY_RARITY.common;
return pool[Math.floor(Math.random() * pool.length)];
}
/* =========================================================
玩家元数据(含 3 个卡组槽)
========================================================= */
function makeEmptyDeck(i) {
return { name: `卡组 ${i + 1}`, cards: [] };
}
let meta = {
coins: 1000,
openedPacks: 0,
ownedCards: {
scout: 2, conscript: 2, infantry: 2, medic: 2, watchman: 2,
mortar: 1, sniper: 1, engineer: 1, repair: 2,
airstrike: 1
},
decks: [makeEmptyDeck(0), makeEmptyDeck(1), makeEmptyDeck(2)],
activeDeckIdx: 0
};
/* 兼容旧存档:把 meta.deck 迁移到 meta.decks[0] */
function migrateMeta(m) {
const out = Object.assign({
coins: 0, openedPacks: 0, ownedCards: {},
decks: [makeEmptyDeck(0), makeEmptyDeck(1), makeEmptyDeck(2)],
activeDeckIdx: 0
}, m || {});
if (!out.ownedCards) out.ownedCards = {};
// 旧版:meta.deck 是数组
if (Array.isArray(m && m.deck)) {
if (!Array.isArray(out.decks) || out.decks.length === 0) {
out.decks = [makeEmptyDeck(0), makeEmptyDeck(1), makeEmptyDeck(2)];
}
out.decks[0] = { name: '默认卡组', cards: m.deck.slice() };
delete out.deck;
}
// 保证 decks 是长度 3 的数组
if (!Array.isArray(out.decks) || out.decks.length !== DECK_SLOTS) {
const old = Array.isArray(out.decks) ? out.decks : [];
out.decks = [];
for (let i = 0; i < DECK_SLOTS; i++) {
const d = old[i];
if (d && typeof d === 'object') {
out.decks.push({
name: String(d.name || `卡组 ${i + 1}`).slice(0, 12),
cards: Array.isArray(d.cards) ? d.cards.slice() : []
});
} else {
out.decks.push(makeEmptyDeck(i));
}
}
}
if (typeof out.activeDeckIdx !== 'number' ||
out.activeDeckIdx < 0 || out.activeDeckIdx >= DECK_SLOTS) {
out.activeDeckIdx = 0;
}
return out;
}
function updateCoinDisplays() {
['lobbyCoins', 'gameCoins', 'shopCoins'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = meta.coins;
});
}
/* =========================================================
对局状态
========================================================= */
function buildPlayerDeck() {
const d = meta.decks[meta.activeDeckIdx];
if (d && Array.isArray(d.cards) && d.cards.length > 0) {
const copy = d.cards.slice();
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
// 随机牌库
const rnd = [];
for (let i = 0; i < DECK_SIZE; i++) {
rnd.push(CARD_POOL[Math.floor(Math.random() * CARD_POOL.length)]);
}
return rnd;
}
function buildEnemyDeck(difficulty) {
// 高难度给更高质量的牌库
const weights = {
easy: { common: 0.70, rare: 0.22, epic: 0.07, legendary: 0.01 },
normal: { common: 0.50, rare: 0.32, epic: 0.14, legendary: 0.04 },
hard: { common: 0.30, rare: 0.38, epic: 0.22, legendary: 0.10 }
}[difficulty] || { common: 0.50, rare: 0.32, epic: 0.14, legendary: 0.04 };
const d = [];
for (let i = 0; i < DECK_SIZE; i++) {
const rarity = rollRarity(weights);
d.push(pickCardByRarity(rarity));
}
return d;
}
function makeSide(owner, hp, deck, kredits) {
return {
hp: hp, maxHp: hp,
kredits: kredits ? 1 : 0,
maxKredits: kredits ? 1 : 0,
deck: deck,
hand: []
};
}
function newGame(difficulty) {
const diff = DIFFICULTIES[difficulty] || DIFFICULTIES.normal;
const st = {
turn: 1,
active: 'player',
over: null,
difficulty: diff.id,
player: makeSide('player', START_HP, buildPlayerDeck(), false),
enemy: makeSide('enemy', diff.enemyHp, buildEnemyDeck(diff.id), false),
lanes: {
playerHome: [null, null, null, null, null],
contested: [null, null, null, null, null],
enemyHome: [null, null, null, null, null]
}
};
for (let i = 0; i < 4; i++) { drawCard(st, 'player'); drawCard(st, 'enemy'); }
beginTurn(st, 'player');
return st;
}
function drawCard(st, side) {
const s = st[side];
if (!s.deck.length || s.hand.length >= HAND_LIMIT) return;
s.hand.push(s.deck.pop());
}
function beginTurn(st, side) {
const s = st[side];
s.maxKredits = Math.min(s.maxKredits + 1, MAX_KREDITS);
s.kredits = s.maxKredits;
['playerHome', 'contested', 'enemyHome'].forEach(laneName => {
st.lanes[laneName].forEach(u => {
if (u && u.owner === side) u.ready = true;
});
});
drawCard(st, side);
}
let gameState = null; // 延迟到"开始战役"时创建
let selectedCard = null;
let selectedUnit = null;
/* =========================================================
卡牌详情
========================================================= */
function showCardDetail(cardId) {
const def = CARD_DB[cardId];
if (!def) return;
const r = RARITY[def.rarity] || RARITY.common;
const preview = $('cdPreview');
preview.innerHTML = '';
const card = document.createElement('div');
card.className = 'card ' +
(def.type === 'command' ? 'command ' : '') + rarityCls(def);
card.innerHTML = cardInner(def);
preview.appendChild(card);
$('cdName').textContent = def.name;
const rarityEl = $('cdRarity');
rarityEl.textContent = r.name;
rarityEl.className = 'cd-rarity ' + r.cls;
$('cdCostBadge').textContent =
`费用 ${def.cost} · ${def.type === 'command' ? '指令' : '单位'}`;
if (def.type === 'unit') {
$('cdAtk').textContent = def.atk;
$('cdHp').textContent = def.hp;
} else {
$('cdAtk').textContent = '—';
$('cdHp').textContent = '—';
}
$('cdDesc').textContent = def.desc || '(暂无描述)';
$('cardDetailModal').classList.add('show');
}
$('closeCardDetail').onclick = () => $('cardDetailModal').classList.remove('show');
$('cardDetailModal').onclick = e => {
if (e.target === $('cardDetailModal')) $('cardDetailModal').classList.remove('show');
};
/* =========================================================
点击 / 双击
========================================================= */
function bindCardInteractions(el, { single, dbl }) {
let timer = null;
el.addEventListener('click', () => {
if (timer) { clearTimeout(timer); timer = null; return; }
if (!single) return;
timer = setTimeout(() => { timer = null; single(); }, 150);
});
el.addEventListener('dblclick', () => {
if (timer) { clearTimeout(timer); timer = null; }
if (dbl) dbl();
});
}
/* =========================================================
工具
========================================================= */
function findUnit(uid) {
for (const laneName of ['playerHome', 'contested', 'enemyHome']) {
const lane = gameState.lanes[laneName];
for (const u of lane) if (u && u.uid === uid) return u;
}
return null;
}
function findUnitSlot(uid) {
for (const laneName of ['playerHome', 'contested', 'enemyHome']) {
const lane = gameState.lanes[laneName];
for (let i = 0; i < lane.length; i++) {
if (lane[i] && lane[i].uid === uid) return { laneName, idx: i };
}
}
return null;
}
function removeUnit(uid) {
const loc = findUnitSlot(uid);
if (loc) gameState.lanes[loc.laneName][loc.idx] = null;
}
function cardInner(def) {
const isCmd = def.type === 'command';
return `
<span class="cost">${def.cost}</span>
<div class="name">${def.name}</div>
<div class="stats">
<span class="atk">${isCmd ? '—' : def.atk}</span>
<span class="hp">${isCmd ? '—' : def.hp}</span>
</div>`;
}
function cardInnerUnit(unit) {
const def = CARD_DB[unit.id];
return `
<span class="cost">${def.cost}</span>
<div class="name">${def.name}</div>
<div class="stats">
<span class="atk">${unit.atk}</span>
<span class="hp">${unit.hp}</span>
</div>`;
}
function rarityCls(def) {
return RARITY[def.rarity] ? RARITY[def.rarity].cls : 'r-common';
}
let toastTimer;
function toast(msg) {
const el = $('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 1400);
}
/* =========================================================
渲染
========================================================= */
function render() {
if (!gameState) return;
renderHand('enemy');
renderHand('player');
renderLane('enemyHome');
renderLane('contested');
renderLane('playerHome');
renderHQ();
renderInfo();
updateSelectionHighlights();
}
function renderHand(side) {
const handEl = $(side + 'Hand');
handEl.innerHTML = '';
gameState[side].hand.forEach((cardId, i) => {
handEl.appendChild(side === 'player' ? makeHandCard(cardId, i) : makeCardBack());
});
}
function renderLane(laneName) {
const lane = gameState.lanes[laneName];
const slots = document.querySelectorAll(`.slot[data-lane="${laneName}"]`);
slots.forEach(slotEl => {
const idx = parseInt(slotEl.dataset.idx);
const unit = lane[idx];
slotEl.className = 'slot' + (unit ? '' : ' empty');
slotEl.innerHTML = '';
if (unit) slotEl.appendChild(makeUnitCard(unit));
});
}
function renderHQ() {
$('playerHqHp').textContent = gameState.player.hp;
$('enemyHqHp').textContent = gameState.enemy.hp;
const pEl = document.querySelector('.hq-slot[data-hq="player"]');
const eEl = document.querySelector('.hq-slot[data-hq="enemy"]');
pEl.classList.remove('valid-target');
eEl.classList.remove('valid-target');
if (selectedCard) {
const def = CARD_DB[selectedCard.cardId];
if (def.type === 'command' && def.target === 'player_hq') {
pEl.classList.add('valid-target');
}
} else if (selectedUnit) {
eEl.classList.add('valid-target');
}
}
function renderInfo() {
const p = gameState.player;
const e = gameState.enemy;
$('playerInfoHp').textContent = `${p.hp} / ${p.maxHp}`;
$('playerKredits').textContent = `${p.kredits} / ${p.maxKredits}`;
$('playerMeta').textContent = `手牌 ${p.hand.length} · 牌库 ${p.deck.length}`;
$('enemyMeta').textContent = `手牌 ${e.hand.length} · 牌库 ${e.deck.length}`;
const diff = DIFFICULTIES[gameState.difficulty] || DIFFICULTIES.normal;
const dt = $('diffTag');
dt.textContent = diff.name;
dt.className = 'diff-tag ' + diff.id;
const t = $('turnInfo');
if (gameState.over === 'win') t.textContent = '胜 利';
else if (gameState.over === 'lose') t.textContent = '失 败';
else t.textContent = `第 ${gameState.turn} 回合 · ${gameState.active === 'player' ? '你的回合' : '对方回合'}`;
$('endTurnBtn').disabled = gameState.active !== 'player' || !!gameState.over;
$('homeBtn').disabled = gameState.active !== 'player';
}
/* 玩家只能放在自己前线 */
function isValidSlotTarget(laneName, idx) {
if (!selectedCard) return false;
const def = CARD_DB[selectedCard.cardId];
if (def.type !== 'unit') return false;
if (laneName !== 'playerHome') return false;
if (idx === 2) return false;
if (gameState.lanes[laneName][idx]) return false;
return true;
}
function isValidUnitTargetForCommand(unit) {
if (!selectedCard) return false;
const def = CARD_DB[selectedCard.cardId];
if (def.type !== 'command') return false;
if (def.target === 'enemy_unit') return unit.owner === 'enemy';
if (def.target === 'player_unit') return unit.owner === 'player';
if (def.target === 'any_unit') return true;
return false;
}
function updateSelectionHighlights() {
const handCards = $('playerHand').querySelectorAll('.card');
handCards.forEach((el, i) => {
el.classList.toggle('selected', selectedCard !== null && selectedCard.handIndex === i);
});
document.querySelectorAll('.slot').forEach(el => {
const laneName = el.dataset.lane;
const idx = parseInt(el.dataset.idx);
el.classList.toggle('valid-target', isValidSlotTarget(laneName, idx));
});
document.querySelectorAll('.slot .card').forEach(cardEl => {
const uid = cardEl.dataset.uid;
if (!uid) return;
const unit = findUnit(uid);
if (!unit) return;
cardEl.classList.toggle('targetable', isValidUnitTargetForCommand(unit));
cardEl.classList.toggle('selected', uid === selectedUnit);
});
}
/* =========================================================
卡牌构建
========================================================= */
function makeHandCard(cardId, index) {
const def = CARD_DB[cardId];
const afford = gameState.player.kredits >= def.cost;
const el = document.createElement('div');
el.className = ['card', 'hand',
def.type === 'command' ? 'command' : '',
rarityCls(def),
afford ? '' : 'unaffordable']
.filter(Boolean).join(' ');
el.innerHTML = cardInner(def);
bindCardInteractions(el, {
single: () => handleHandCardClick(index, cardId),
dbl: () => showCardDetail(cardId)
});
return el;
}
function makeUnitCard(unit) {
const def = CARD_DB[unit.id];
const el = document.createElement('div');
el.dataset.uid = unit.uid;
const ready = unit.ready;
const isMine = unit.owner === 'player';
const canAct = isMine && ready && gameState.active === 'player' && !gameState.over;
el.className = ['card', 'unit',
rarityCls(def),
ready ? '' : 'exhausted',
isMine ? '' : 'owned-enemy',
canAct ? 'can-act' : '']
.filter(Boolean).join(' ');
el.innerHTML = cardInnerUnit(unit);
bindCardInteractions(el, {
single: () => handleUnitClick(unit),
dbl: () => showCardDetail(unit.id)
});
return el;
}
function makeCardBack() {
const el = document.createElement('div');
el.className = 'card back';
el.textContent = '指挥官';
return el;
}
/* =========================================================
手牌点击
========================================================= */
function handleHandCardClick(index, cardId) {
if (!gameState || gameState.over || gameState.active !== 'player') return;
if (selectedCard && selectedCard.handIndex === index) {
selectedCard = null;
} else {
const def = CARD_DB[cardId];
if (gameState.player.kredits < def.cost) { toast('费用不足'); return; }
selectedCard = { cardId, handIndex: index };
selectedUnit = null;
}
updateSelectionHighlights();
renderHQ();
}
/* =========================================================
单位点击
========================================================= */
function handleUnitClick(unit) {
if (!gameState || gameState.over) return;
if (selectedCard) {
const def = CARD_DB[selectedCard.cardId];
if (def.type === 'command' && isValidUnitTargetForCommand(unit)) {
applyCommandToUnit(def, unit);
return;
}
}
if (unit.owner === 'player') {
if (gameState.active !== 'player' || !unit.ready) return;
if (selectedUnit === unit.uid) {
selectedUnit = null;
} else {
selectedUnit = unit.uid;
selectedCard = null;
}
updateSelectionHighlights();
renderHQ();
return;
}
if (unit.owner === 'enemy' && selectedUnit) {
const attacker = findUnit(selectedUnit);
if (!attacker || !attacker.ready) { selectedUnit = null; render(); return; }
attacker.ready = false;
unit.hp -= attacker.atk;
attacker.hp -= unit.atk;
if (unit.hp <= 0) removeUnit(unit.uid);
if (attacker.hp <= 0) removeUnit(attacker.uid);
selectedUnit = null;
render();
}
}
/* =========================================================
槽位点击
========================================================= */
document.querySelectorAll('.slot').forEach(slotEl => {
slotEl.addEventListener('click', () => {
if (!gameState || gameState.over || gameState.active !== 'player') return;
const laneName = slotEl.dataset.lane;
const idx = parseInt(slotEl.dataset.idx);
const unit = gameState.lanes[laneName][idx];
if (unit) { handleUnitClick(unit); return; }
if (selectedCard) {
const def = CARD_DB[selectedCard.cardId];
if (def.type === 'unit' && isValidSlotTarget(laneName, idx)) {
placeUnit(selectedCard.cardId, selectedCard.handIndex, laneName, idx);
return;
}
}
selectedCard = null;
selectedUnit = null;
updateSelectionHighlights();
renderHQ();
});
});
/* =========================================================
总部点击
========================================================= */
document.querySelectorAll('.hq-slot').forEach(hqEl => {
hqEl.addEventListener('click', () => {
if (!gameState || gameState.over || gameState.active !== 'player') return;
const side = hqEl.dataset.hq;
if (selectedCard) {
const def = CARD_DB[selectedCard.cardId];
if (def.type === 'command' && def.target === 'player_hq' && side === 'player') {
applyCommandToHQ(def);
return;
}
}
if (selectedUnit && side === 'enemy') {
const attacker = findUnit(selectedUnit);
if (!attacker || !attacker.ready) { selectedUnit = null; render(); return; }
attacker.ready = false;
gameState.enemy.hp = Math.max(0, gameState.enemy.hp - attacker.atk);
selectedUnit = null;
render();
if (gameState.enemy.hp <= 0) finish('win');
return;
}
selectedCard = null;
selectedUnit = null;
updateSelectionHighlights();
renderHQ();
});
});
/* =========================================================
放置单位
========================================================= */
function placeUnit(cardId, handIndex, laneName, idx) {
const def = CARD_DB[cardId];
const p = gameState.player;
if (p.kredits < def.cost) { toast('费用不足'); return; }
if (gameState.lanes[laneName][idx]) return;
if (laneName !== 'playerHome') return;
p.kredits -= def.cost;
p.hand.splice(handIndex, 1);
gameState.lanes[laneName][idx] = {
uid: randId(),
id: cardId,
owner: 'player',
atk: def.atk,
hp: def.hp,
maxHp: def.hp,
ready: false
};
selectedCard = null;
render();
}
/* =========================================================
指令
========================================================= */
function applyCommandToHQ(def) {
const p = gameState.player;
if (p.kredits < def.cost) { toast('费用不足'); return; }
p.kredits -= def.cost;
p.hand.splice(selectedCard.handIndex, 1);
const eff = def.effect;
if (eff.type === 'heal_hq') {
p.hp = Math.min(p.maxHp, p.hp + eff.value);
toast(`${def.name}:总部恢复 ${eff.value} 点生命`);
} else if (eff.type === 'buff_all') {
let count = 0;
['playerHome', 'contested'].forEach(laneName => {
gameState.lanes[laneName].forEach(u => {
if (u && u.owner === 'player') {
u.atk += eff.atk || 0;
u.hp += eff.hp || 0;
u.maxHp += eff.hp || 0;
count++;
}
});
});
toast(count === 0 ? '场上没有我方单位'
: `${def.name}:${count} 个单位获得 +${eff.atk || 0}/+${eff.hp || 0}`);
}
selectedCard = null;
render();
}
function applyCommandToUnit(def, target) {
const p = gameState.player;
if (p.kredits < def.cost) { toast('费用不足'); return; }
p.kredits -= def.cost;
p.hand.splice(selectedCard.handIndex, 1);
const eff = def.effect;
if (eff.type === 'damage') {
target.hp -= eff.value;
if (target.hp <= 0) removeUnit(target.uid);
toast(`${def.name}:造成 ${eff.value} 点伤害`);
} else if (eff.type === 'buff') {
target.atk += eff.atk || 0;
target.hp += eff.hp || 0;
target.maxHp += eff.hp || 0;
toast(`${def.name}:目标获得 +${eff.atk || 0}/+${eff.hp || 0}`);
}
selectedCard = null;
render();
}
/* =========================================================
回合流程
========================================================= */
$('endTurnBtn').onclick = endTurn;
function endTurn() {
if (!gameState || gameState.over || gameState.active !== 'player') return;
selectedCard = null;
selectedUnit = null;
gameState.active = 'enemy';
beginTurn(gameState, 'enemy');
render();
enemyTurn();
}
async function enemyTurn() {
await sleep(450);
if (!gameState || gameState.over) return;
const e = gameState.enemy;
const diff = DIFFICULTIES[gameState.difficulty] || DIFFICULTIES.normal;
// 出牌
while (true) {
let best = -1, bestCost = -1;
e.hand.forEach((id, i) => {
const def = CARD_DB[id];
if (def.type !== 'unit') return;
if (def.cost <= e.kredits && def.cost > bestCost) { best = i; bestCost = def.cost; }
});
if (best === -1) break;
const target = findEnemySlot();
if (!target) break;
const cardId = e.hand[best];
const def = CARD_DB[cardId];
e.kredits -= def.cost;
e.hand.splice(best, 1);
gameState.lanes[target.laneName][target.idx] = {
uid: randId(), id: cardId, owner: 'enemy',
atk: def.atk, hp: def.hp, maxHp: def.hp, ready: false
};
render();
await sleep(380);
}
// 攻击
for (const laneName of ['contested', 'enemyHome']) {
const lane = gameState.lanes[laneName];
for (const u of lane) {
if (!u || u.owner !== 'enemy' || !u.ready) continue;
if (gameState.over) return;
u.ready = false;
const myUnits = [];
['playerHome', 'contested'].forEach(ln => {
gameState.lanes[ln].forEach(x => { if (x && x.owner === 'player') myUnits.push(x); });
});
if (myUnits.length && Math.random() < diff.aggression) {
const tgt = myUnits[Math.floor(Math.random() * myUnits.length)];
tgt.hp -= u.atk;
u.hp -= tgt.atk;
if (tgt.hp <= 0) removeUnit(tgt.uid);
if (u.hp <= 0) removeUnit(u.uid);
} else {
gameState.player.hp = Math.max(0, gameState.player.hp - u.atk);
}
render();
await sleep(320);
if (gameState.player.hp <= 0) { finish('lose'); return; }
}
}
gameState.active = 'player';
gameState.turn++;
beginTurn(gameState, 'player');
render();
}
/* 敌方只能放在敌方前线 */
function findEnemySlot() {
const homeOrder = [1, 3, 0, 4];
for (const i of homeOrder) {
if (!gameState.lanes.enemyHome[i]) {
return { laneName: 'enemyHome', idx: i };
}
}
return null;
}
/* =========================================================
结算
========================================================= */
function finish(result) {
gameState.over = result;
hasMatch = false;
selectedCard = null;
selectedUnit = null;
const diff = DIFFICULTIES[gameState.difficulty] || DIFFICULTIES.normal;
const reward = result === 'win' ? diff.reward : Math.floor(diff.reward * 0.3);
meta.coins += reward;
updateCoinDisplays();
render();
$('overlayTitle').textContent = result === 'win' ? '胜 利' : '失 败';
$('overlayTitle').className = 'overlay-title ' + (result === 'win' ? 'win' : 'lose');
$('overlayReward').textContent = `+${reward} 金币`;
$('overlay').classList.add('show');
}
/* =========================================================
大厅 / 场景切换
========================================================= */
const lobby = $('lobby');
let hasMatch = false;
function showLobby() {
['overlay', 'modal', 'shopModal', 'settingsModal', 'cardDetailModal',
'packOverlay', 'codexModal', 'battleModal']
.forEach(id => $(id).classList.remove('show'));
$('resumeBtn').disabled = !(hasMatch && gameState && !gameState.over);
lobby.classList.remove('hidden');
updateCoinDisplays();
}
function enterGame() {
lobby.classList.add('hidden');
$('overlay').classList.remove('show');
}
$('homeBtn').onclick = showLobby;
$('resumeBtn').onclick = () => {
if (!hasMatch || !gameState || gameState.over) return;
selectedCard = null;
selectedUnit = null;
enterGame();
render();
};
$('loadBtn').onclick = () => {
$('saveCode').value = '';
$('modal').classList.add('show');
};
/* 设置 */
$('settingsBtn').onclick = () => $('settingsModal').classList.add('show');
$('closeSettings').onclick = () => $('settingsModal').classList.remove('show');
$('settingsModal').onclick = e => {
if (e.target === $('settingsModal')) $('settingsModal').classList.remove('show');
};
/* =========================================================
战役弹窗
========================================================= */
let pickedDifficulty = 'normal';
let pickedDeckIdx = 0;
function openBattleModal() {
pickedDifficulty = 'normal';
pickedDeckIdx = meta.activeDeckIdx || 0;
renderBattleModal();
$('battleModal').classList.add('show');
}
$('startBtn').onclick = openBattleModal;
$('closeBattle').onclick = () => $('battleModal').classList.remove('show');
$('battleModal').onclick = e => {
if (e.target === $('battleModal')) $('battleModal').classList.remove('show');
};
function renderBattleModal() {
// 难度
const diffList = $('diffList');
diffList.innerHTML = '';
DIFF_ORDER.forEach(id => {
const d = DIFFICULTIES[id];
const btn = document.createElement('button');
btn.className = 'diff-btn' + (pickedDifficulty === id ? ' selected' : '');
btn.innerHTML = `
<div style="flex:1 1 auto">
<div class="d-name">${d.name}</div>
<div class="d-desc">${d.desc}</div>
</div>
<div class="d-reward">+${d.reward}</div>
`;
btn.onclick = () => { pickedDifficulty = id; renderBattleModal(); };
diffList.appendChild(btn);
});
// 卡组
const deckList = $('deckPickList');
deckList.innerHTML = '';
let anyNonEmpty = false;
meta.decks.forEach((deck, i) => {
const cards = Array.isArray(deck.cards) ? deck.cards : [];
const empty = cards.length === 0;
if (!empty) anyNonEmpty = true;
const btn = document.createElement('button');
btn.className = 'deck-pick' + (pickedDeckIdx === i ? ' selected' : '');
btn.disabled = empty;
btn.innerHTML = `
<div class="dp-name">${deck.name || `卡组 ${i + 1}`}</div>
<div class="dp-count">${empty ? '空' : cards.length + ' 张'}</div>
`;
if (!empty) {
btn.onclick = () => { pickedDeckIdx = i; renderBattleModal(); };
}
deckList.appendChild(btn);
});
// 如果没有非空卡组,显示提示并允许开始(随机牌库)
if (!anyNonEmpty) {
const note = document.createElement('div');
note.style.cssText = 'font-size:11px;color:var(--dim);text-align:center;padding:6px';
note.textContent = '尚未组建卡组,将使用随机牌库开局';
deckList.appendChild(note);
}
// 校验选中卡组是否可用
const selDeck = meta.decks[pickedDeckIdx];
const selEmpty = !selDeck || !Array.isArray(selDeck.cards) || selDeck.cards.length === 0;
$('battleStartBtn').disabled = false;
$('battleStartBtn').textContent = anyNonEmpty && !selEmpty
? '开 始 战 役'
: '开 始 战 役(随机牌库)';
}
$('battleStartBtn').onclick = () => {
meta.activeDeckIdx = pickedDeckIdx;
gameState = newGame(pickedDifficulty);
hasMatch = true;
selectedCard = null;
selectedUnit = null;
$('battleModal').classList.remove('show');
enterGame();
render();
};
/* =========================================================
卡包商店
========================================================= */
$('shopBtn').onclick = () => {
renderShop();
updateCoinDisplays();
$('shopModal').classList.add('show');
};
$('closeShop').onclick = () => $('shopModal').classList.remove('show');
$('shopModal').onclick = e => {
if (e.target === $('shopModal')) $('shopModal').classList.remove('show');
};
function renderShop() {
const list = $('shopList');
list.innerHTML = '';
Object.entries(PACKS).forEach(([id, pack]) => {
const afford = meta.coins >= pack.price;
const el = document.createElement('div');
el.className = 'shop-pack ' + (pack.cls || '');
el.innerHTML = `
<div class="shop-pack-body">
<div class="shop-pack-name">${pack.name}</div>
<div class="shop-pack-desc">${pack.desc}</div>
</div>
<button class="shop-buy" data-pack="${id}" ${afford ? '' : 'disabled'}>
${pack.price} 金币
</button>
`;
list.appendChild(el);
});
list.querySelectorAll('.shop-buy').forEach(btn => {
btn.onclick = () => buyPack(btn.dataset.pack);
});
}
function buyPack(packId) {
const pack = PACKS[packId];
if (!pack) return;
if (meta.coins < pack.price) { toast('金币不足'); return; }
meta.coins -= pack.price;
meta.openedPacks++;
updateCoinDisplays();
const cardIds = [];
for (let i = 0; i < pack.count; i++) {
const odds = pack.odds[i] || pack.odds[pack.odds.length - 1];
const rarity = rollRarity(odds);
const cardId = pickCardByRarity(rarity);
cardIds.push(cardId);
meta.ownedCards[cardId] = (meta.ownedCards[cardId] || 0) + 1;
}
$('shopModal').classList.remove('show');
showPackResult(pack, cardIds);
}
function showPackResult(pack, cardIds) {
$('packTitle').innerHTML = `${pack.name}<small>双击卡牌可查看详情</small>`;
const cont = $('packCards');
cont.innerHTML = '';
cardIds.forEach((cardId, i) => {
const def = CARD_DB[cardId];
const el = document.createElement('div');
el.className = 'card ' +
(def.type === 'command' ? 'command ' : '') + rarityCls(def);
el.innerHTML = cardInner(def);
el.style.animationDelay = (i * 0.12) + 's';
bindCardInteractions(el, {
single: null,
dbl: () => showCardDetail(cardId)
});
cont.appendChild(el);
});
$('packOverlay').classList.add('show');
}
$('packOk').onclick = () => $('packOverlay').classList.remove('show');
/* =========================================================
卡牌图鉴 + 3 卡组槽编辑
========================================================= */
let editDeckIdx = 0; // 当前编辑的卡组槽
let draftDeck = []; // 编辑中的卡牌数组
function openCodex() {
editDeckIdx = meta.activeDeckIdx || 0;
loadDeckIntoDraft(editDeckIdx);
renderCodex();
$('codexModal').classList.add('show');
}
function loadDeckIntoDraft(i) {
const deck = meta.decks[i];
draftDeck = deck && Array.isArray(deck.cards) ? deck.cards.slice() : [];
$('deckNameInput').value = (deck && deck.name) || `卡组 ${i + 1}`;
}
$('codexBtn').onclick = openCodex;
$('closeCodex').onclick = () => {
$('codexModal').classList.remove('show');
};
$('codexModal').onclick = e => {
if (e.target === $('codexModal')) $('codexModal').classList.remove('show');
};
function renderCodex() {
// 卡组 tabs
const tabs = $('deckTabs');
tabs.innerHTML = '';
meta.decks.forEach((deck, i) => {
const cards = Array.isArray(deck.cards) ? deck.cards : [];
const btn = document.createElement('button');
btn.className = 'deck-tab' + (i === editDeckIdx ? ' active' : '');
btn.innerHTML = `${(deck.name || `卡组 ${i + 1}`)} <small>${cards.length}</small>`;
btn.onclick = () => switchEditDeck(i);
tabs.appendChild(btn);
});
$('deckCount').textContent = draftDeck.length;
const grid = $('codexGrid');
grid.innerHTML = '';
const deckCounts = {};
draftDeck.forEach(id => deckCounts[id] = (deckCounts[id] || 0) + 1);
Object.keys(CARD_DB).forEach(cardId => {
const def = CARD_DB[cardId];
const owned = meta.ownedCards[cardId] || 0;
const inDeck = deckCounts[cardId] || 0;
const el = document.createElement('div');
el.className = 'card ' +
(def.type === 'command' ? 'command ' : '') + rarityCls(def) +
(owned > 0 ? '' : ' not-owned');
el.innerHTML = cardInner(def);
if (owned > 0) {
const b = document.createElement('span');
b.className = 'owned-badge';
b.textContent = 'x' + owned;
el.appendChild(b);
}
if (inDeck > 0) {
const b = document.createElement('span');
b.className = 'deck-badge';
b.textContent = '组' + inDeck;
el.appendChild(b);
}
bindCardInteractions(el, {
single: () => addToDraft(cardId),
dbl: () => showCardDetail(cardId)
});
grid.appendChild(el);
});
}
function switchEditDeck(i) {
// 先自动保存当前编辑的卡组到内存(不弹提示)
commitDraftToMeta(false);
editDeckIdx = i;
loadDeckIntoDraft(i);
renderCodex();
}
function commitDraftToMeta(showToast) {
const name = ($('deckNameInput').value || '').trim() || `卡组 ${editDeckIdx + 1}`;
meta.decks[editDeckIdx] = {
name: name.slice(0, 12),
cards: draftDeck.slice()
};
if (showToast) toast(`卡组已保存(${draftDeck.length} 张)`);
}
function addToDraft(cardId) {
const owned = meta.ownedCards[cardId] || 0;
if (owned <= 0) { toast('未拥有这张卡'); return; }
if (draftDeck.length >= DECK_LIMIT) { toast('卡组已满 40 张'); return; }
const inDeck = draftDeck.filter(id => id === cardId).length;
if (inDeck >= owned) { toast('没有更多这张卡了'); return; }
draftDeck.push(cardId);
renderCodex();
}
$('clearDeckBtn').onclick = () => {
draftDeck = [];
renderCodex();
toast('已清空当前卡组');
};
$('saveDeckBtn').onclick = () => {
commitDraftToMeta(true);
renderCodex();
};
/* 自动保存:关闭弹窗时,把草稿提交到 meta */
$('closeCodex').addEventListener('click', () => {
commitDraftToMeta(false);
meta.activeDeckIdx = editDeckIdx;
});
/* =========================================================
存档 UI
========================================================= */
function saveStatus(msg) {
const el = $('saveStatus');
el.textContent = msg;
clearTimeout(saveStatus.t);
saveStatus.t = setTimeout(() => { el.textContent = ''; }, 2200);
}
$('saveBtn').onclick = () => $('modal').classList.add('show');
$('closeModal').onclick = () => $('modal').classList.remove('show');
$('modal').onclick = e => { if (e.target === $('modal')) $('modal').classList.remove('show'); };
$('exportBtn').onclick = () => {
const pwd = $('pwdInput').value;
const payload = { v: 2, game: gameState, meta: meta };
const code = Save.exportCode(payload, pwd);
$('saveCode').value = code;
$('saveCode').select();
saveStatus('存档已生成,请复制保存');
};
$('importBtn').onclick = () => {
const code = $('saveCode').value.trim();
if (!code) { saveStatus('请先粘贴存档码'); return; }
const pwd = $('pwdInput').value;
const res = Save.importCode(code, pwd);
if (res.error) { saveStatus(res.error); return; }
const data = res.data;
if (data && data.v === 2 && data.game && data.meta) {
if (!isValidState(data.game)) { saveStatus('存档内容无效'); return; }
if (typeof data.meta.coins !== 'number') { saveStatus('存档内容无效'); return; }
gameState = data.game;
meta = migrateMeta(data.meta);
} else if (isValidState(data)) {
gameState = data;
} else {
saveStatus('存档内容无效');
return;
}
gameState.active = 'player';
gameState.over = null;
gameState.turn = gameState.turn || 1;
gameState.difficulty = gameState.difficulty || 'normal';
hasMatch = true;
selectedCard = null;
selectedUnit = null;
$('modal').classList.remove('show');
enterGame();
render();
updateCoinDisplays();
toast('读取成功');
};
$('newGameBtn').onclick = () => {
gameState = newGame('normal');
hasMatch = true;
selectedCard = null;
selectedUnit = null;
$('modal').classList.remove('show');
enterGame();
render();
toast('新游戏已开始');
};
$('overlayBtn').onclick = () => {
const diff = (gameState && gameState.difficulty) || 'normal';
gameState = newGame(diff);
hasMatch = true;
selectedCard = null;
selectedUnit = null;
$('overlay').classList.remove('show');
render();
};
function isValidState(s) {
if (!s || typeof s !== 'object') return false;
if (typeof s.turn !== 'number') return false;
if (!s.lanes || typeof s.lanes !== 'object') return false;
for (const side of ['player', 'enemy']) {
const x = s[side];
if (!x || typeof x !== 'object') return false;
if (typeof x.hp !== 'number' || typeof x.maxHp !== 'number') return false;
if (!Array.isArray(x.hand) || !Array.isArray(x.deck)) return false;
if (x.hand.concat(x.deck).some(id => !CARD_DB[id])) return false;
}
for (const laneName of ['playerHome', 'contested', 'enemyHome']) {
const lane = s.lanes[laneName];
if (!Array.isArray(lane)) return false;
for (const u of lane) {
if (u && !CARD_DB[u.id]) return false;
}
}
return true;
}
/* =========================================================
启动
========================================================= */
meta = migrateMeta(meta);
updateCoinDisplays();
showLobby();
</script>
</body>
</html>Game Source: 指挥官 · Commander
Creator: SwiftGalaxy49
Libraries: none
Complexity: complex (2408 lines, 77.4 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: commander-swiftgalaxy49" to link back to the original. Then publish at arcadelab.ai/publish.