はじめに
うちの地域の少年野球とか部活の試合、いつも「今って何対何だっけ?」「何回だっけ?」ってなりませんか?
プロの球場みたいにかっこいい電光掲示板なんてないですし、あっても使えないこともありませんか?観客席の保護者も、みんなスコアを把握するのに一苦労なんですよね。
「じゃあ、スマホで見られる簡易スコアボードをサクッと作っちゃえばいいじゃん!」と思い立って、週末にチャチャッと作ったものです。
レンタルサーバで動くお手軽構成にした理由
作るにあたって一番大事にしたのは、「誰でも余っている安いレンタルサーバで動かせること」です。
DockerとかNode.jsの環境を立てるのってぶっちゃけ面倒じゃないですか。なので、一番手軽なPHPを採用しました。しかも、わざわざMySQL(MariaDB)のデータベースを用意して接続設定を書くのすら面倒だったので、ファイル1個でDB管理ができるSQLiteとのコンビにしてます。
構成はこんな感じです。
- フロント: HTML/CSS/JavaScript(スマホ対応のレスポンシブ)
- バックエンド: 簡易なPHPスクリプト
- データ保存: SQLite
これなら、PHPが動く普通のレンタルサーバにファイルをぽいっとアップロードするだけで、即座に「自分専用のリアルタイムスコアボード」が爆誕します。サーバ側でスコアのデータを保持してくれるので、試合を記録する人がスマホから点数を更新すれば、見ている全員の画面にそのまま最新のスコアが反映される仕組みです。
認証機能をどうやって持たせるか問題
誰でもスコアを書き換えられちゃうと困るので、入力画面にはセキュリティ的な何かが必要です。
でも、ユーザー登録機能なんて大掛かりなものは作りたくありません。そこで今回は割り切って、レンタルサーバ側の「Basic認証(.htaccess)」を設定してページ全体に鍵をかけるか、PHPのコード内に簡易的なパスワードチェック処理を数行仕込むだけにしました。
身内しか使わないアプリですし、これで十分です。運用もラクちんですからね。
これで「スコアを更新」「保護者はページを確認」という、完璧な運用フローが出来上がりました。
まとめ
実際に試合で投入してみたんですよ。「これで見やすくなるぞ〜」ってドヤ顔で保護者LINEにURLを共有したんです。
そしたらみんなスマホを見て、「わー!便利!」「今何点かすぐわかる!」って最初は大喜びしてくれたんですよね。よし来た!って思ったじゃないですか。
でもね、試合が進むにつれて誰もスマホを見なくなっちゃったんです。
よく考えたら、みんな我が子のプレイを見たり撮影に必死で、スマホの画面なんて誰も見てないんですよ。結局、一番スマホをチラチラ見てたのは、自分でコードを書いて自分でスコアを更新していた僕だけでした。
ニッチな技術で作る便利ツール、自己満足度は最高なんですけど、現場の熱量には勝てないなって痛感した週末でした。応援に駆けつけられない方にはメリットあったようです。
PHPのコードはこちら
<?php
/**
* ⚾ 1-File Baseball Scoreboard v1.1
* PHP + SQLite3 + Vanilla JS (Mobile Optimized)
*/
// --- 1. 設定 & 認証 ---
$auth_user = 'admin';
$auth_pass = 'baseballpassword'; // ← 実際の運用時はここを変更してください
// index.php?mtmでメンテナンスモード
// --- フッター設定 ---
$copyright_text = "Baseball Scoreboard © 2026/04/30 mi25 ";
$support_message = "頑張れ!XXX野球部⚾";
/*
if (!isset($_SERVER['PHP_AUTH_USER']) || $_SERVER['PHP_AUTH_USER'] !== $auth_user || $_SERVER['PHP_AUTH_PW'] !== $auth_pass) {
header('WWW-Authenticate: Basic realm="Baseball Scoreboard"');
header('HTTP/1.0 401 Unauthorized');
exit('認証が必要です。');
}
*/
// --- 2. データベース準備 ---
$db = new SQLite3(__DIR__ . '/baseball.sqlite');
$db->exec("CREATE TABLE IF NOT EXISTS games (id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT, memo TEXT, finished INTEGER DEFAULT 0)");
// カラム追加(既存DB用)
$res = $db->query("PRAGMA table_info(games)");
$cols = [];
while($col = $res->fetchArray()) { $cols[] = $col['name']; }
if(!in_array('memo', $cols)) $db->exec("ALTER TABLE games ADD COLUMN memo TEXT");
if(!in_array('finished', $cols)) $db->exec("ALTER TABLE games ADD COLUMN finished INTEGER DEFAULT 0");
$db->exec("CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id INTEGER,
score_a INTEGER, score_b INTEGER,
inning_num INTEGER, half INTEGER,
b INTEGER, s INTEGER, o INTEGER,
runner_1 INTEGER, runner_2 INTEGER, runner_3 INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
// --- 3. API アクション処理 ---
$action = $_GET['action'] ?? null;
if ($action) {
header('Content-Type: application/json');
$game_id = intval($_REQUEST['game_id'] ?? 0);
if ($action === 'create') {
$date = date('Y/m/d');
$db->exec("INSERT INTO games (date, memo) VALUES ('$date', '')");
$new_id = $db->lastInsertRowID();
$db->exec("INSERT INTO logs (game_id, score_a, score_b, inning_num, half, b, s, o, runner_1, runner_2, runner_3)
VALUES ($new_id, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0)");
echo json_encode(['status' => 'success', 'game_id' => $new_id]);
exit;
}
if ($action === 'update_game' && $game_id) {
$data = json_decode(file_get_contents('php://input'), true);
if (isset($data['memo'])) {
$stmt = $db->prepare("UPDATE games SET memo = :memo WHERE id = :id");
$stmt->bindValue(':memo', $data['memo']);
$stmt->bindValue(':id', $game_id);
$stmt->execute();
}
if (isset($data['date'])) {
$stmt = $db->prepare("UPDATE games SET date = :date WHERE id = :id");
$stmt->bindValue(':date', $data['date']);
$stmt->bindValue(':id', $game_id);
$stmt->execute();
}
echo json_encode(['status' => 'success']);
exit;
}
if ($action === 'finish' && $game_id) {
$db->exec("UPDATE games SET finished = 1 WHERE id = $game_id");
echo json_encode(['status' => 'success']);
exit;
}
if ($action === 'reopen' && $game_id) {
$db->exec("UPDATE games SET finished = 0 WHERE id = $game_id");
echo json_encode(['status' => 'success']);
exit;
}
if ($action === 'fetch' && $game_id) {
$res = $db->querySingle("SELECT * FROM logs WHERE game_id = $game_id ORDER BY id DESC", true);
echo json_encode($res ?: []);
exit;
}
if ($action === 'update' && $game_id) {
$data = json_decode(file_get_contents('php://input'), true);
$stmt = $db->prepare("INSERT INTO logs (game_id, score_a, score_b, inning_num, half, b, s, o, runner_1, runner_2, runner_3)
VALUES (:gid, :sa, :sb, :in, :ha, :b, :s, :o, :r1, :r2, :r3)");
$stmt->bindValue(':gid', $game_id);
$stmt->bindValue(':sa', $data['score_a']);
$stmt->bindValue(':sb', $data['score_b']);
$stmt->bindValue(':in', $data['inning_num']);
$stmt->bindValue(':ha', $data['half']);
$stmt->bindValue(':b', $data['b']);
$stmt->bindValue(':s', $data['s']);
$stmt->bindValue(':o', $data['o']);
$stmt->bindValue(':r1', $data['runner_1']);
$stmt->bindValue(':r2', $data['runner_2']);
$stmt->bindValue(':r3', $data['runner_3']);
$stmt->execute();
echo json_encode(['status' => 'success']);
exit;
}
if ($action === 'undo' && $game_id) {
$count = $db->querySingle("SELECT COUNT(*) FROM logs WHERE game_id = $game_id");
if ($count > 1) {
$db->exec("DELETE FROM logs WHERE id = (SELECT MAX(id) FROM logs WHERE game_id = $game_id)");
}
echo json_encode(['status' => 'success']);
exit;
}
if ($action === 'delete' && $game_id) {
$db->exec("DELETE FROM games WHERE id = $game_id");
$db->exec("DELETE FROM logs WHERE game_id = $game_id");
echo json_encode(['status' => 'success']);
exit;
}
}
$current_game_id = intval($_GET['game_id'] ?? 0);
$is_mtm = isset($_GET['mtm']);
$is_finished = 0;
?>
<!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>野球スコアボード</title>
<style>
:root { --bg: #0b0e14; --card: #1c2128; --text: #adbac7; --accent: #3fb950; --ball: #316dca; --strike: #d73a49; --out: #e36209; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body { font-family: -apple-system, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 10px; user-select: none; }
.container { max-width: 500px; margin: 0 auto; }
/* 一覧表示 */
.list-item { background: var(--card); padding: 15px; border-radius: 12px; margin-bottom: 15px; display: block; text-decoration: none; color: white; border-left: 5px solid var(--accent); position: relative; }
.list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-weight: bold; }
.memo-box { background: #2d333b; border: 1px solid #444; border-radius: 6px; padding: 8px; font-size: 0.9rem; color: #adbac7; width: 100%; min-height: 40px; cursor: pointer; }
.memo-box:empty:before { content: "タップしてメモ(対戦相手など)を入力"; color: #666; }
.btn-create { background: var(--accent); color: white; border: none; padding: 20px; border-radius: 12px; font-size: 1.2rem; font-weight: bold; width: 100%; margin-bottom: 20px; cursor: pointer; }
.btn-delete { background: #d73a49; color: white; border: none; border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; font-weight: bold; cursor: pointer; margin-left: 10px; }
.btn-detail { background: #316dca; color: white; border: none; border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; font-weight: bold; cursor: pointer; margin-left: 10px; display: inline-block; }
.btn-finish { background: #3fb950; color: white; border: none; border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; font-weight: bold; cursor: pointer; margin-left: 10px; }
.btn-reopen { background: #d73a49; color: white; border: none; border-radius: 6px; padding: 5px 10px; font-size: 0.8rem; font-weight: bold; cursor: pointer; margin-left: 10px; }
.badge-finished { background: #555; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; margin-left: 10px; vertical-align: middle; }
.editable-date { text-decoration: underline dashed #888; cursor: pointer; }
.mtm-banner { background: #e36209; color: white; padding: 10px; text-align: center; font-weight: bold; border-radius: 8px; margin-bottom: 15px; }
.top-links { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 20px; }
.top-link { display: block; background: #2d333b; color: #adbac7; border: 1px solid #444; border-radius: 10px; padding: 14px 10px; text-align: center; text-decoration: none; font-weight: bold; }
/* フッター */
.footer { margin-top: 40px; padding: 20px 10px; text-align: center; border-top: 1px solid #333; color: #888; font-size: 0.9rem; line-height: 1.6; }
.support-msg { color: var(--accent); font-weight: bold; margin-bottom: 10px; display: block; font-size: 1.1rem; }
.footer-guide { color: #adbac7; margin-bottom: 8px; }
/* ボード表示 */
.board { background: #000; border-radius: 16px; margin-bottom: 15px; display: grid; grid-template-columns: 1fr 1fr; border: 1px solid #444; overflow: hidden; }
.inning-display { grid-column: span 2; background: #222; padding: 15px; text-align: center; font-size: 1.5rem; font-weight: bold; border-bottom: 1px solid #444; }
.score-box { padding: 30px 10px; text-align: center; }
.score-box:active { background: #222; transform: scale(0.95); transition: all 0.1s ease-out; }
.score-val { font-size: 4.5rem; font-weight: bold; font-family: monospace; color: #fff; display: block; }
.team-label { font-size: 0.9rem; color: #888; }
/* BSO & 塁 */
.status-section { display: flex; background: var(--card); border-radius: 16px; padding: 20px; margin-bottom: 15px; align-items: center; justify-content: space-around; }
.bso-grid { display: flex; flex-direction: column; gap: 15px; }
.bso-row { display: flex; align-items: center; gap: 10px; font-size: 1.5rem; font-weight: bold; }
.lamp { width: 30px; height: 30px; border-radius: 50%; border: 2px solid #333; background: #111; transition: all 0.1s ease-out; }
.lamp:active { transform: scale(0.8); }
.lamp.b-on { background: var(--ball); box-shadow: 0 0 12px var(--ball); }
.lamp.s-on { background: var(--strike); box-shadow: 0 0 12px var(--strike); }
.lamp.o-on { background: var(--out); box-shadow: 0 0 12px var(--out); }
.diamond-box { position: relative; width: 120px; height: 120px; }
.diamond-shape { position: absolute; width: 80px; height: 80px; top: 20px; left: 20px; border: 2px solid #444; transform: rotate(45deg); }
.base { position: absolute; width: 32px; height: 32px; background: #222; border: 2px solid #444; transform: rotate(-45deg); display: flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: bold; transition: all 0.1s ease-out; }
.base:active { transform: rotate(-45deg) scale(0.8); }
.base.on { background: #fff; color: #000; border-color: #fff; box-shadow: 0 0 15px #fff; }
.base-1 { top: 44px; right: -4px; }
.base-2 { top: -4px; left: 44px; }
.base-3 { top: 44px; left: -4px; }
.btn-change-box { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.btn-change { background: var(--accent); color: white; border: none; border-radius: 50%; width: 60px; height: 60px; font-size: 0.7rem; font-weight: bold; cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,0.3); border: 2px solid #fff; }
.btn-change:active { transform: scale(0.9); filter: brightness(1.2); }
.controls { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
button.ctrl { border: none; border-radius: 12px; padding: 20px; font-size: 1rem; font-weight: bold; cursor: pointer; background: #333; color: white; }
button.undo { grid-column: span 2; background: #d73a4933; color: var(--strike); border: 1px solid var(--strike); margin-top: 10px; }
button:active { filter: brightness(1.5); }
</style>
</head>
<body>
<div class="container">
<?php if (!$current_game_id): ?>
<h1>試合一覧</h1>
<?php if ($is_mtm): ?>
<div class="mtm-banner">🔧 メンテナンスモード:試合の削除が可能です</div>
<?php endif; ?>
<button onclick="createGame()" class="btn-create">新しく試合を開始</button>
<?php
$query = "
SELECT g.*, l.score_a, l.score_b, l.inning_num, l.half
FROM games g
LEFT JOIN logs l ON l.id = (SELECT MAX(id) FROM logs WHERE game_id = g.id)
ORDER BY g.id DESC
";
$res = $db->query($query);
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$memo = htmlspecialchars($row['memo'] ?? '');
$del_btn = $is_mtm ? "<button class='btn-delete' onclick='deleteGame({$row['id']}, event)'>削除</button>" : "";
$finish_btn = (!$row['finished']) ?
"<button class='btn-finish' onclick='finishGame({$row['id']}, event)'>終了</button>" :
"<span class='badge-finished'>試合終了</span><button class='btn-reopen' onclick='reopenGame({$row['id']}, event)'>終了取消</button>";
$score_text = isset($row['score_a']) ? "{$row['score_a']} - {$row['score_b']}" : "0 - 0";
$inning_text = isset($row['inning_num']) ? "{$row['inning_num']}回" . ($row['half'] == 0 ? "表" : "裏") : "1回表";
$date_display = htmlspecialchars($row['date']);
if ($is_mtm) {
$date_display = "<span class='editable-date' onclick='editDate({$row['id']}, \"{$row['date']}\", event)'>$date_display</span>";
}
echo "
<div class='list-item' id='game-{$row['id']}'>
<a href='?game_id={$row['id']}' style='text-decoration:none; color:inherit;'>
<div class='list-header'>
<span>$date_display <span class='btn-detail'>試合詳細</span> $del_btn $finish_btn</span>
<span style='font-family:monospace; font-size:1.1rem;'>$inning_text $score_text</span>
</div>
</a>
<div class='memo-box' onclick=\"editMemo({$row['id']}, this)\">$memo</div>
</div>";
}
?>
<script>
function editDate(id, current, event) {
event.preventDefault();
event.stopPropagation();
// すでに開いている入力を削除(連打対策)
const oldInput = document.getElementById('temp-date-input');
if (oldInput) oldInput.remove();
const input = document.createElement('input');
input.id = 'temp-date-input';
input.type = 'date';
input.value = current.replace(/\//g, '-');
// モバイル等で確実に反応させるためのスタイル設定
input.style.position = 'fixed';
input.style.top = '50%';
input.style.left = '50%';
input.style.opacity = '0';
input.style.pointerEvents = 'none';
input.style.zIndex = '9999';
document.body.appendChild(input);
input.onchange = async () => {
const newDate = input.value.replace(/-/g, '/');
if (newDate) {
await fetch(`?action=update_game&game_id=${id}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({date: newDate})
});
location.reload();
}
input.remove();
};
// ピッカーの起動
try {
if (typeof input.showPicker === 'function') {
input.showPicker();
} else {
input.click();
}
} catch (e) {
// showPickerが制限(セキュリティ等)で失敗した場合の予備
input.click();
}
// フォーカスが外れたら消去
input.onblur = () => {
setTimeout(() => input.remove(), 100);
};
}
async function editMemo(id, el) {
const newMemo = prompt("メモを入力してください(対戦相手など)", el.innerText);
if (newMemo !== null) {
await fetch(`?action=update_game&game_id=${id}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({memo: newMemo})
});
el.innerText = newMemo;
}
}
async function finishGame(id, event) {
event.preventDefault();
event.stopPropagation();
if (confirm("試合を終了しますか?終了後は編集できなくなります。")) {
const res = await fetch(`?action=finish&game_id=${id}`, {method: 'POST'});
const data = await res.json();
if (data.status === 'success') {
location.reload();
}
}
}
async function reopenGame(id, event) {
event.preventDefault();
event.stopPropagation();
if (confirm("試合を再開しますか?編集が可能になります。")) {
const res = await fetch(`?action=reopen&game_id=${id}`, {method: 'POST'});
const data = await res.json();
if (data.status === 'success') {
location.reload();
}
}
} async function deleteGame(id, event) {
event.preventDefault();
event.stopPropagation();
if (confirm("この試合データを削除しますか?この操作は取り消せません。")) {
const res = await fetch(`?action=delete&game_id=${id}`, {method: 'POST'});
const data = await res.json();
if (data.status === 'success') {
document.getElementById(`game-${id}`).remove();
}
}
}
async function createGame() {
const res = await fetch('?action=create', {method: 'POST'});
const data = await res.json();
location.href = '?game_id=' + data.game_id;
}
</script>
<?php else:
$game_info = $db->querySingle("SELECT * FROM games WHERE id = $current_game_id", true);
$is_finished = $game_info['finished'] ?? 0;
?>
<style>
.is-finished { opacity: 0.7; pointer-events: none; }
.finished-banner { background: #555; color: white; padding: 10px; text-align: center; font-weight: bold; border-radius: 8px; margin-bottom: 15px; }
</style>
<?php if ($is_finished): ?>
<div class="finished-banner">🔒 この試合は終了しています(閲覧のみ)</div>
<?php endif; ?>
<div class="board <?= $is_finished ? 'is-finished' : '' ?>">
<div class="inning-display" id="btn-inning"><span id="txt-inning">1</span>回<span id="txt-half">表</span></div>
<div class="score-box" id="btn-score-a"><div class="team-label">先攻</div><span id="txt-score-a" class="score-val">0</span></div>
<div class="score-box" id="btn-score-b"><div class="team-label">後攻</div><span id="txt-score-b" class="score-val">0</span></div>
</div>
<div class="status-section <?= $is_finished ? 'is-finished' : '' ?>">
<div class="bso-grid">
<div class="bso-row" onclick="<?= $is_finished ? '' : "cycleBSO('b')" ?>">B <div id="b-1" class="lamp"></div><div id="b-2" class="lamp"></div><div id="b-3" class="lamp"></div></div>
<div class="bso-row" onclick="<?= $is_finished ? '' : "cycleBSO('s')" ?>">S <div id="s-1" class="lamp"></div><div id="s-2" class="lamp"></div></div>
<div class="bso-row" onclick="<?= $is_finished ? '' : "cycleBSO('o')" ?>">O <div id="o-1" class="lamp"></div><div id="o-2" class="lamp"></div></div>
</div>
<div class="diamond-box">
<div class="diamond-shape"></div>
<div id="base-1" class="base base-1" onclick="toggleBase(1)">1</div>
<div id="base-2" class="base base-2" onclick="toggleBase(2)">2</div>
<div id="base-3" class="base base-3" onclick="toggleBase(3)">3</div>
</div>
<div class="btn-change-box">
<button id="btn-change-inning" class="btn-change">CHANGE<br>チェンジ</button>
</div>
</div>
<div class="controls">
<?php if (!$is_finished): ?>
<button class="ctrl undo" onclick="undo()">Undo (操作を1つ戻す)</button>
<?php endif; ?>
<button style="grid-column: span 2; background: transparent; color: #888;" onclick="location.href='index.php'">一覧へ戻る</button>
</div>
<?php endif; ?>
<footer class="footer">
<span class="support-msg"><?= htmlspecialchars($support_message) ?></span>
<div class="footer-guide">
<?= !$current_game_id ? 'トップページ:試合ボタンをクリックすると詳細' : '試合ページ:クリックでカウントアップ、長押しでカウントダウン' ?>
</div>
<div><?= htmlspecialchars($copyright_text) ?></div>
</footer>
</div>
<script>
const gameId = <?= $current_game_id ?>;
const isFinished = <?= $is_finished ?>;
let state = { score_a:0, score_b:0, inning_num:1, half:0, b:0, s:0, o:0, runner_1:0, runner_2:0, runner_3:0 };
async function fetchState() {
if (!gameId) return;
const res = await fetch(`?action=fetch&game_id=${gameId}`);
const newState = await res.json();
if (newState.id && JSON.stringify(newState) !== JSON.stringify(state)) {
state = newState;
render();
}
}
async function updateServer() {
if (isFinished) return;
await fetch(`?action=update&game_id=${gameId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(state)
});
render();
}
async function undo() {
if (isFinished) return;
await fetch(`?action=undo&game_id=${gameId}`, {method: 'POST'});
fetchState();
}
function render() {
if (!gameId) return;
document.getElementById('txt-score-a').innerText = state.score_a;
document.getElementById('txt-score-b').innerText = state.score_b;
document.getElementById('txt-inning').innerText = state.inning_num;
document.getElementById('txt-half').innerText = state.half == 0 ? '表' : '裏';
for(let i=1; i<=3; i++) document.getElementById(`b-${i}`)?.classList.toggle('b-on', state.b >= i);
for(let i=1; i<=2; i++) document.getElementById(`s-${i}`)?.classList.toggle('s-on', state.s >= i);
for(let i=1; i<=2; i++) document.getElementById(`o-${i}`)?.classList.toggle('o-on', state.o >= i);
document.getElementById('base-1').classList.toggle('on', !!state.runner_1);
document.getElementById('base-2').classList.toggle('on', !!state.runner_2);
document.getElementById('base-3').classList.toggle('on', !!state.runner_3);
}
function addScore(t, v) { if(t==='a') state.score_a=Math.max(0, state.score_a+v); else state.score_b=Math.max(0, state.score_b+v); updateServer(); }
function changeInning(v) {
if (v === 1) {
if(state.half==0) {
state.half=1;
} else {
state.half=0;
state.inning_num++;
}
} else {
if(state.half==1) {
state.half=0;
} else if(state.inning_num > 1) {
state.half=1;
state.inning_num--;
}
}
// チェンジ(回または表裏の移動)の際はカウントとランナーをリセット
state.b=0; state.s=0; state.o=0;
state.runner_1=0; state.runner_2=0; state.runner_3=0;
updateServer();
}
function cycleBSO(t) { if(t=='b') state.b=(state.b+1)%4; if(t=='s') state.s=(state.s+1)%3; if(t=='o') state.o=(state.o+1)%3; updateServer(); }
function toggleBase(n) {
if (isFinished) return;
state[`runner_${n}`] = state[`runner_${n}`] ? 0 : 1;
updateServer();
}
// --- 長押し・タップ処理の汎用化 ---
function initLongPressBtn(id, callback) {
if (isFinished) return;
const el = document.getElementById(id);
if (!el) return;
let timer;
let isLong = false;
el.addEventListener('touchstart', (e) => {
isLong = false;
timer = setTimeout(() => {
isLong = true;
callback(-1);
if (navigator.vibrate) navigator.vibrate(40);
}, 600);
}, {passive: true});
el.addEventListener('touchmove', () => clearTimeout(timer), {passive: true});
el.addEventListener('touchend', () => clearTimeout(timer));
el.addEventListener('click', (e) => {
e.stopPropagation();
if (isLong) {
isLong = false;
return;
}
callback(1);
});
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (!isLong) callback(-1);
});
}
if (gameId) {
fetchState();
setInterval(fetchState, 3000);
initLongPressBtn('btn-score-a', (v) => addScore('a', v));
initLongPressBtn('btn-score-b', (v) => addScore('b', v));
initLongPressBtn('btn-inning', (v) => changeInning(v));
initLongPressBtn('btn-change-inning', (v) => changeInning(v));
}</script>
</body>
</html>
</details>
タップで+1、長押しで-1です