MTGリモート統率者戦ライフカウンタ
MTGの統率者戦で面倒なこと、それは「4人のライフと重要な情報をどう共有するか」じゃないでしょうか。(重要な情報=ターン順、統率者ダメージ、毒カウンター、統率者税など。)ましてリモートになると、これがさらに面倒になります。
これを楽にするツール、それが半自動MTGリモート統率者戦用ライフカウンタです。半自動というのは、一部手動操作があるからです。タッチしないと開かない自動ドアみたいな。
この記事を書こうと思った理由は、このカウンタが結構使いやすくて、統率者が流行るのに貢献できそうだったからです。今までMTGリモート統率者戦の情報共有は、自作プログラムを作って画面共有したり、仮想サーバを立ててwebページを作ったり、Googleドライブ上でスプレッドシートを共有したり、Spell Table(スペルテーブル)を導入したりしましたが、どれもしっくりきませんでした。でも今回はしっくりきたました。
MTGリモート統率者戦用ライフカウンタの概要

特徴
・無料
・複数の端末で同時にデータを共有できる
・PC / Andoroid / iPhone / タブレット相互対応
・速い
・好きに改造可能(スキル次第)
デメリット
・誰かひとりが犠牲になって、「英語サイトへのメール登録、ごく一部のコーディング(コピペ)、テキストファイルのアップロード作業」をする必要がある。(メール登録は「FireBase」と「GitHub」と「Vercel」、一部日本語もある)
導入の前の注意点
・このツールを使用したことによるデータの消失や不具合について、制作者は責任を負いません
・商用利用可、改変可
・サポートはやってません
・リモート統率者戦を遊ぶにはDiscodeなどチャットツールが別途必要です
以下、最短導入の方法(と、なぜそれをする必要があるのか)
①メモ帳を開く(ほとんどの作業でメモ帳(とブラウザ)を使う)
②以下をコピペ(コード本体。もちろん知識があればカスタマイズ可)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>MTG EDH Counter</title>
<link rel="icon" href="favicon.png" type="image/png">
<style>
/* 全要素のサイズ計算をボーダー含みに統一 */
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
background: #111;
color: #fff;
font-family: sans-serif;
overflow: hidden; /* スクロールを禁止 */
}
.app-header {
height: 4vh;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
background: #000;
}
.header-btns { display: flex; gap: 5px; }
.reset-btn, .order-btn {
border: none;
border-radius: 4px;
padding: 2px 8px;
font-size: 0.7rem;
cursor: pointer;
color: #fff;
}
.reset-btn { background: #444; }
.order-btn { background: #2e7d32; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: 96vh; /* ヘッダー分を引いた高さ */
width: 100%;
gap: 2px;
padding: 2px;
}
#slot-P1 { grid-area: 1 / 1 / 2 / 2; }
#slot-P2 { grid-area: 1 / 2 / 2 / 3; }
#slot-P3 { grid-area: 2 / 2 / 3 / 3; }
#slot-P4 { grid-area: 2 / 1 / 3 / 2; }
.player-box {
background: #222;
display: flex;
flex-direction: column;
justify-content: space-around;
padding: 2px;
border: 1px solid #333;
overflow: hidden;
transition: background 0.2s;
}
.player-box.shuffling { background: #444; }
.header-area {
display: flex;
align-items: center;
height: 10%;
padding: 0 2px;
gap: 4px;
width: 100%;
}
.order-num { font-size: 1rem; font-weight: bold; color: #666; flex-shrink: 0; }
.partner-wrapper { display: flex; align-items: center; gap: 1px; font-size: 0.6rem; color: #888; white-space: nowrap; flex-shrink: 0; }
.name-select { background: transparent; color: #ddd; border: none; font-size: 0.85rem; font-weight: bold; outline: none; flex-grow: 1; min-width: 0; }
.main-display-wrapper { height: 45%; display: flex; align-items: center; justify-content: center; gap: 2px; width: 100%; }
.life-center-unit { display: flex; flex-direction: column; align-items: center; width: 62%; height: 100%; justify-content: space-around; }
.life-wrapper { width: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; height: 55%; }
.life {
font-size: 10vh; font-weight: bold; border: none; background: transparent;
width: 100%; text-align: center; outline: none; padding: 0; transition: color 0.3s;
}
.life.small-font { font-size: 7vh; }
.life-btn-group { display: flex; justify-content: center; gap: 10px; width: 100%; height: 28%; }
.btn-life { flex: 1; max-width: 80px; height: 100%; font-size: 1.3rem; font-weight: bold; border: none; border-radius: 8px; cursor: pointer; }
.btn-minus { background: #d32f2f; color: #fff; }
.btn-plus { background: #388e3c; color: #fff; }
.num-select { background: #333; color: #fff; border: 1px solid #444; font-size: 0.7rem; width: 90%; border-radius: 4px; height: 15%; }
.sidebar-counters { display: flex; flex-direction: column; gap: 2px; width: 36%; height: 100%; justify-content: center; }
.side-item { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 1px 2px; display: flex; align-items: center; justify-content: space-between; }
.side-label { font-size: 0.4rem; white-space: nowrap; width: 22px; font-weight: bold; }
.side-val { font-weight: bold; font-size: 0.8rem; min-width: 12px; text-align: center; transition: color 0.3s; }
.btn-side { width: 16px; height: 16px; background: #333; border: none; color: #fff; border-radius: 2px; cursor: pointer; font-size: 0.6rem; }
.side-dice-wrapper { background: #111; border: 1px solid #333; border-radius: 4px; padding: 2px; display: flex; flex-direction: column; gap: 2px; margin-top: 1px; }
.side-dice-input-row { display: flex; gap: 1px; align-items: center; justify-content: space-between; }
.dice-input { width: 55%; background: transparent; border: none; color: #aaa; font-size: 0.6rem; text-align: center; border-bottom: 1px solid #444; outline: none; }
.dice-btn { background: #333; color: #fff; border: none; font-size: 0.5rem; padding: 2px 4px; border-radius: 2px; cursor: pointer; }
.dice-res-row { display: flex; justify-content: space-around; align-items: center; height: 12px; }
.dice-format { font-size: 0.35rem; color: #555; }
.dice-result { color: #ffeb3b; font-weight: bold; font-size: 0.75rem; min-width: 15px; }
.commander-area { height: 45%; display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 2px; padding: 2px; }
.cmd-P1 { grid-area: 1 / 1 / 2 / 2; }
.cmd-P2 { grid-area: 1 / 2 / 2 / 3; }
.cmd-P3 { grid-area: 2 / 2 / 3 / 3; }
.cmd-P4 { grid-area: 2 / 1 / 3 / 2; }
.cmd-box { background: #1a1a1a; padding: 2px; border-radius: 2px; font-size: 0.5rem; display: flex; flex-direction: column; overflow: hidden; border: 1px solid transparent; }
.cmd-box.is-self { opacity: 0.4; border: 1px dashed #333; }
.cmd-header { display: flex; align-items: center; gap: 2px; border-bottom: 1px solid #333; padding-bottom: 1px; margin-bottom: 1px; }
.cmd-order { color: #555; font-weight: bold; }
.cmd-name-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-grow: 1; color: #aaa; font-size: 0.4rem; }
.cmd-body { display: flex; justify-content: space-around; flex-grow: 1; align-items: center; }
.cmd-unit { display: flex; flex-direction: column; align-items: center; gap: 1px; width: 48%; }
.cmd-side-label { color: #444; font-size: 0.4rem; line-height: 1; }
.cmd-val-wrap { display: flex; align-items: center; gap: 1px; }
.cmd-val { font-size: 0.8rem; font-weight: bold; min-width: 12px; text-align: center; transition: color 0.3s; }
.btn-cmd { width: 14px; height: 14px; font-size: 0.6rem; background: #333; color: #fff; border: none; border-radius: 2px; cursor: pointer; }
</style>
</head>
<body>
<div class="app-header">
<span style="font-size:0.8rem; font-weight:bold; color:#777;">MTG EDH Counter</span>
<div class="header-btns">
<button class="order-btn" onclick="shuffleNames()">席順シャッフル</button>
<button class="reset-btn" onclick="resetGame()">リセット</button>
</div>
</div>
<div class="grid" id="main-grid"></div>
<script src="https://www.gstatic.com/firebasejs/9.22.0/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/9.22.0/firebase-database-compat.js"></script>
<script>
const firebaseConfig = {
apiKey: "***",
authDomain: "***",
databaseURL: "***",
projectId: "***",
storageBucket: "***",
messagingSenderId: "***",
appId: "***"
};
firebase.initializeApp(firebaseConfig);
const db = firebase.database();
const rootRef = db.ref('mtg/edh-session-final-v20');
const memberRef = db.ref('mtg/members');
const playerSlots = ['P1', 'P2', 'P3', 'P4'];
const grid = document.getElementById('main-grid');
playerSlots.forEach((slot, index) => {
let html = `
<div class="player-box" id="slot-${slot}">
<div class="header-area">
<span class="order-num">${index + 1}</span>
<div class="partner-wrapper">
<input type="checkbox" id="check-${slot}" class="partner-check" onchange="directInput('${slot}/isPartner', this.checked)">
<label for="check-${slot}">共闘</label>
</div>
<select id="name-${slot}" class="name-select" onchange="if(this.value!=='') directInput('${slot}/name', this.value)">
<option value="">名前選択</option>
</select>
</div>
<div class="main-display-wrapper">
<div class="life-center-unit">
<div class="life-wrapper">
<input type="number" id="life-${slot}" class="life" value="40" onchange="directInput('${slot}/life', parseInt(this.value))">
</div>
<div class="life-btn-group">
<button class="btn-life btn-minus" onclick="changeValue('${slot}/life', -1)">-1</button>
<button class="btn-life btn-plus" onclick="changeValue('${slot}/life', 1)">+1</button>
</div>
<select class="num-select" onchange="if(this.value !== '') { directInput('${slot}/life', parseInt(this.value)); this.value = ''; }">
<option value="">リストから</option>
${Array.from({length:150}, (_,i)=>`<option value="${i+1}">${i+1}</option>`).join('')}
</select>
</div>
<div class="sidebar-counters">
<div id="side-main-${slot}"></div>
<div class="side-dice-wrapper">
<div class="side-dice-input-row">
<input type="text" id="dice-in-${slot}" class="dice-input" value="1d20" placeholder="1d20">
<button class="dice-btn" onclick="triggerDice('${slot}')">振</button>
</div>
<div class="dice-res-row">
<div id="dice-fmt-${slot}" class="dice-format"></div>
<div id="dice-res-${slot}" class="dice-result">--</div>
</div>
</div>
</div>
</div>
<div class="commander-area" id="cmd-area-${slot}"></div>
</div>`;
grid.insertAdjacentHTML('beforeend', html);
});
function getDynamicColor(val, type) {
if (type === 'life') {
if (val <= 0) return "#f44336";
const ratio = Math.min(1, val / 40);
const hue = ratio * 120;
return `hsl(${hue}, 100%, 60%)`;
} else if (type === 'cmd') {
if (val >= 21) return "#ff00ff";
const ratio = Math.min(1, val / 21);
const hue = 120 - (ratio * 120);
return `hsl(${hue}, 100%, 60%)`;
} else if (type === 'poison') {
if (val <= 0) return "#888";
if (val >= 10) return "#ff00ff";
const hue = Math.max(0, 120 - (val * 12));
return `hsl(${hue}, 100%, 60%)`;
}
return "#888";
}
window.resetGame = () => {
if (!confirm("ダイス以外の全ての数値を初期化しますか?")) return;
playerSlots.forEach(slot => {
rootRef.child(slot).update({ life: 40, poison: 0, rad: 0, tax1: 0, tax2: 0, cmd: null });
});
};
window.shuffleNames = () => {
const currentNames = playerSlots.map(slot => document.getElementById(`name-${slot}`).value).filter(n => n !== "");
if (currentNames.length < 2) {
alert("名前を2人以上選択してください");
return;
}
let count = 0;
const maxCount = 20;
playerSlots.forEach(s => document.getElementById(`slot-${s}`).classList.add('shuffling'));
const interval = setInterval(() => {
playerSlots.forEach(slot => {
const sel = document.getElementById(`name-${slot}`);
sel.value = currentNames[Math.floor(Math.random() * currentNames.length)];
});
if (++count >= maxCount) {
clearInterval(interval);
const shuffled = [...currentNames];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
playerSlots.forEach((slot, i) => {
const newName = shuffled[i] || "";
directInput(`${slot}/name`, newName);
document.getElementById(`slot-${slot}`).classList.remove('shuffling');
});
}
}, 80);
};
window.triggerDice = (slot) => {
const input = document.getElementById(`dice-in-${slot}`);
const val = input.value.toLowerCase();
const match = val.match(/^(\d+)d(\d+)$/);
if (!match) return;
directInput(`${slot}/diceFormat`, val);
let r = 0;
const itv = setInterval(() => {
const tmp = Array.from({length:match[1]},()=>Math.floor(Math.random()*match[2])+1).reduce((a,b)=>a+b);
directInput(`${slot}/diceResult`, tmp);
if (++r > 8) {
clearInterval(itv);
const final = Array.from({length:match[1]},()=>Math.floor(Math.random()*match[2])+1).reduce((a,b)=>a+b);
directInput(`${slot}/diceResult`, final);
}
}, 80);
input.blur();
};
memberRef.on('value', s => {
const m = s.val() || [];
playerSlots.forEach(slot => {
const sel = document.getElementById(`name-${slot}`);
const cur = sel.value;
sel.innerHTML = '<option value="">名前選択</option>' + m.map(v => `<option value="${v}">${v}</option>`).join('');
if(cur) sel.value = cur;
});
});
rootRef.on('value', s => {
const d = s.val() || {};
playerSlots.forEach((target, targetIdx) => {
const p = d[target] || {};
const nameSel = document.getElementById(`name-${target}`);
if (document.activeElement !== nameSel) nameSel.value = p.name || "";
document.getElementById(`check-${target}`).checked = !!p.isPartner;
document.getElementById(`dice-fmt-${target}`).innerText = p.diceFormat || "";
document.getElementById(`dice-res-${target}`).innerText = p.diceResult || "--";
const lifeIn = document.getElementById(`life-${target}`);
if (document.activeElement !== lifeIn) {
const lifeVal = p.life ?? 40;
lifeIn.value = lifeVal;
lifeIn.style.color = getDynamicColor(lifeVal, 'life');
if (lifeVal >= 100) lifeIn.classList.add('small-font');
else lifeIn.classList.remove('small-font');
}
const sideMain = document.getElementById(`side-main-${target}`);
const poi = p.poison ?? 0;
const rad = p.rad ?? 0;
let sideHtml = `<div class="side-item"><span class="side-label" style="color:#00bcd4">統唱数</span><button class="btn-side" onclick="changeValue('${target}/tax1',-1)">-</button><span class="side-val" style="color:#00bcd4">${p.tax1??0}</span><button class="btn-side" onclick="changeValue('${target}/tax1',1)">+</button></div>`;
if(!!p.isPartner) sideHtml += `<div class="side-item"><span class="side-label" style="color:#00bcd4">統唱数</span><button class="btn-side" onclick="changeValue('${target}/tax2',-1)">-</button><span class="side-val" style="color:#00bcd4">${p.tax2??0}</span><button class="btn-side" onclick="changeValue('${target}/tax2',1)">+</button></div>`;
sideHtml += `<div class="side-item"><span class="side-label" style="color:#adff2f">毒</span><button class="btn-side" onclick="changeValue('${target}/poison',-1)">-</button><span class="side-val" style="color:${getDynamicColor(poi, 'poison')}">${poi}</span><button class="btn-side" onclick="changeValue('${target}/poison',1)">+</button></div><div class="side-item"><span class="side-label" style="color:#ffeb3b">RAD</span><button class="btn-side" onclick="changeValue('${target}/rad',-1)">-</button><span class="side-val" style="color:${rad>0?'#ffeb3b':'#888'}">${rad}</span><button class="btn-side" onclick="changeValue('${target}/rad',1)">+</button></div>`;
sideMain.innerHTML = sideHtml;
const cmdArea = document.getElementById(`cmd-area-${target}`);
cmdArea.innerHTML = '';
playerSlots.forEach((from, fromIdx) => {
const fromIsP = !!d[from]?.isPartner;
const v1 = p.cmd?.[from]?.v1 ?? 0;
const v2 = p.cmd?.[from]?.v2 ?? 0;
const isSelf = (from === target);
let boxHtml = `<div class="cmd-box cmd-${from} ${isSelf ? 'is-self' : ''}">
<div class="cmd-header"><span class="cmd-order">${fromIdx+1}</span><span class="cmd-name-label">${d[from]?.name||from}統受打</span></div>
<div class="cmd-body">
<div class="cmd-unit">${fromIsP?'<span class="cmd-side-label">A</span>':''}<div class="cmd-val-wrap"><button class="btn-cmd" onclick="changeValue('${target}/cmd/${from}/v1',-1)">-</button><span class="cmd-val ${v1>=21?'fatal':''}" style="color:${getDynamicColor(v1, 'cmd')}">${v1}</span><button class="btn-cmd" onclick="changeValue('${target}/cmd/${from}/v1',1)">+</button></div></div>
${fromIsP?`<div class="cmd-unit"><span class="cmd-side-label">B</span><div class="cmd-val-wrap"><button class="btn-cmd" onclick="changeValue('${target}/cmd/${from}/v2',-1)">-</button><span class="cmd-val ${v2>=21?'fatal':''}" style="color:${getDynamicColor(v2, 'cmd')}">${v2}</span><button class="btn-cmd" onclick="changeValue('${target}/cmd/${from}/v2',1)">+</button></div></div>`:''}
</div></div>`;
cmdArea.insertAdjacentHTML('beforeend', boxHtml);
});
});
});
window.changeValue = (path, delta) => {
rootRef.child(path).transaction(c => {
if (c === null) {
return (path.indexOf('life') !== -1 ? 40 : 0) + delta;
}
return c + delta;
});
};
window.directInput = (path, value) => rootRef.child(path).set(value);
</script>
</body>
</html>③適当な場所に「index.html」」という名前で保存。(あとでホームページにするため)
④ブラウザでFirebaseを検索し、Firebaseホームページからがんばって(精神論)ユーザ登録。(数値を高速で共有するための無料の箱をつくる)
⑤Firebaseのコンソールに移動→プロジェクトを作成→適当に名前を付ける→「アプリを追加」でプラットフォーム「ウェブ」を選択→アプリのニックネームを適当に付ける→「アプリを登録」をクリック→「次に Firebase を初期化し、使用するプロダクトの SDK の利用を開始します。」という欄の「const firebaseConfig = { ... };」をコピーして、③の「const firebaseConfig = { ... };」に上書き貼り付け→(2026/4/26追記)「const firebaseConfig = { ... };」の中に1行追加→その1行に「databaseURL: "https://forkofi1-5ee89-default-rtdb.asia-southeast1.firebasedatabase.app",」をコピペして(2026/4/26追記ここまで)③を上書き保存→「コンソールに進む」をクリック→FireBaseページの左側の「プロダクトのカテゴリ」→「DataBaseとストレージ→「Realtime Database」を選択→「データベースを作成」をクリック→ロケーション「asia-southeast1(シンガポール)」を選択→「テストモードで開始」を選択して「有効にする」をクリック→「Realtime Database」の「ルール」タブのコードを消して、以下を貼り付け(30日間フルアクセスから、無期限フルアクセスに変更)
{
"rules": {
".read": true,
".write": true
}
}→「Realtime Database」の「データ」タブの中の入力できる欄にプラスボタンで「mtg」という階層を作り、さらに「members」という階層を作り、統率者に参加する人の呼び名を0: あだ名A, 1: あだ名B... と追加していく(web全体に公開されるので、本名など個人情報は入れない)

→以上でFireBaseの作業は終わり。メール等来ますが無視でいいかと思います。
⑥ブラウザでGitHubを検索し、GitHubホームページからがんばって(精神論2)ユーザ登録。(ライフカウンタホームページの元データを置く箱を作る)
⑦③GitHubのページの「Top repositories」の「New」をクリック→新しいrepositoryを作成する画面に行き「Repository name」に適当なプログラム名を入れる→「Create Responsitory」をクリック→「Code」タブの「Create codespace」をクリック→「Create New codespace」をクリックしてコードスペースを作成→どこかに飛ばされるが、ブラウザの戻るボタンなどでGitHubに戻る→「Code」タブの中の「Add File」のプルダウンメニューから「Upload Files」をクリック→③をドラッグアンドドロップ→「Commit changes」をクリック→以上でGitHubの作業は終わり。メール等来ますが無視でいいかと思います。
⑧ブラウザでVercelを検索し、Vercelホームページからがんばって(精神論3)ユーザ登録。(高速なホームページを作る)
⑨Vercelホームページでがんばって(精神論4)GitHubのアカウントとリンクさせる
⑩Vercelホームページの「Projects」タブをクリック→「Add New」プルダウンメニューから「Project」を選択→Import Git Repositoryから⑦で作成したRepositoryの「Import」をクリック→「Deploy」をクリック→高速ホームページが作成されます。Domainsのリンクをクリックすればそのページに飛べます。→以上でVercelの作業は終わり。メール等来ますが無視でいいかと思います。
⑪⑩のリンクを統率者戦の各参加者と共有すれば、全員がライフカウンタへアクセスできます(スマホやタブレットからでも問題なく使えます)
Q&A
・動かない
→AIに症状とコードを張り付けて聞く→回答コードをメモ帳に張り付けてindex.htmlという名前で保存→GitHubにアップロード→3秒くらいでVercelが勝手にホームページを更新してくれます
・使い方は?
まずリモート統率者参加メンバを集める→必要あればFirebaseのmembersを変更→Discodeとカメラで机の上のMTGを映す→このライフカウンタを使って順番を決める→あとは流れで(もちろん使う前に各々動作確認しましょう)
・ここをこう変えたい
→AIに要望とコードを張り付けて聞く→回答コードをメモ帳に張り付けてindex.htmlという名前で保存→GitHubにアップロード→3秒くらいでVercelが勝手にホームページを更新してくれます
・ダイスの振り方がわからん
ダイスの面はデフォルトで1d20になってますが、変更できます。1d6で6面ダイス。振る機会は少ないと思いますが、その時はがんばって(精神論5)入力して振ってください。
・画面の配置を変えたい
→AIに要望とコードを張り付けて聞く→回答コードをメモ帳に張り付けてindex.htmlという名前で保存→GitHubにアップロード→3秒くらいでVercelが勝手にホームページを更新→Chromeブラウザであれば、F12キー→Dimensionsでスマホの見え方が確認できます
他あれば書き足しますかも
他のツールとの比較
・スマホアプリのライフカウンタ
かゆいところに手が届かない(RADカウンターなど)
・自作のソフトを画面共有
俺の負担がデカすぎる
・その他のwebページ
遅すぎ。ライフ1変更に3秒かかるとライフ10変更に30秒かかる。
・スペルテーブル
便利だけどかゆいところに手が届かない
・Googleスプレッドシート
これを使うまではスプレッドシートを使っていた。便利だけどダイスを振る機能や順番替え機能を作るのにマクロが必要で、せっかくマクロ組んだのに他のメンバがログインをめんどくさがってマクロを使ってくれなかった。結果俺の負担がでかい。
独り言
・去年やったときはGeminiに聞いてもVercelに対応してなかった。今年聞いたらVercelに対応できたので高速化できた。えらい。(Geminiが)
・去年やったときはコーディングしたが、今回はバイブコーディングを試した。うまくいった。頭は悪くなった。(自分が)
ここから先は空欄です。でも支援いただけたら助かります。無職なので。
ここから先は
¥ 1,000
この記事が気に入ったらチップで応援してみませんか?
