サッカーゲーム「MultiverseSoccer」


【更新履歴】

・2026/6/16 バージョン1.0公開。
        
(中略)
・2026/6/16 バージョン1.3公開。(大会モードを追加)


画像


・ダウンロードされる方はこちら。↓

・このゲームで遊ぶには、いつものテスト用ブラウザが必要です。↓

【概要】

・手軽に操作できるサッカーゲームです。

・大会モードでは、FIFAランキングの上位チームに加え、
 別の世界線では滅びずに覇権国となったローマ帝国などと
 対決することが出来ます。

【操作説明】

・方向キーで移動。
 (ボールを持っている味方選手を操作する。
  ボールを奪われた時は、そのままの選手を移動させ、
  ダッシュして追いつき、スライディング決めて
  ボールを奪い取る。)

・ボールを持っている時は、
 ・Zキーで近くにいる味方の選手にボールをパスする。
 ・Xキーで相手側のゴールに向けてシュートをする。

・ボールを持っていない時は、
 ・ZキーまたはXキーで、
  ボールを持っている相手選手に向けてダッシュする。
  (スタミナゲージを消費するので、回復するまでできない。)
 ・スライディングしてボールが取れる時は、スライディングする。

・ロングシュートは、相手側のキーパーが間に合うので
 決まりにくくなっている。

【マルチプレイモード】

・テンキー(4で左、8で上、6で右、2で下)で移動。

・ボールを持っている時は、
 ・Nキーでパス。
 ・Mキーでシュート。

・ボールを持っていない時は、
 ・NキーまたはMキーで、ダッシュやスライディング。

・ソースコードはこちら。↓

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multiverse Soccer</title>
<style>
  :root{
    --ink:#0c1410; --panel:rgba(10,20,15,.82); --line:rgba(255,255,255,.14);
    --red:#e23b3b; --blue:#2f7be0; --gold:#f2c84b; --grass:#2e7d3a;
    --font: "Helvetica Neue", "Segoe UI", system-ui, sans-serif;
  }
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;overflow:hidden;background:#06100a;font-family:var(--font);color:#eef4ee;-webkit-user-select:none;user-select:none}
  canvas#game{position:fixed;inset:0;display:block}

  /* ---- HUD ---- */
  #hud{position:fixed;inset:0;pointer-events:none;display:none}
  #hud.on{display:block}
  .board{
    position:absolute;top:14px;left:50%;transform:translateX(-50%);
    display:flex;align-items:stretch;gap:0;background:var(--panel);
    border:1px solid var(--line);border-radius:14px;overflow:hidden;backdrop-filter:blur(6px);
    box-shadow:0 10px 30px rgba(0,0,0,.4)
  }
  .side{display:flex;align-items:center;gap:10px;padding:8px 16px}
  .side .nm{font-weight:800;letter-spacing:.14em;font-size:15px;max-width:140px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
  .side .cards{display:flex;gap:3px;margin-top:3px}
  .yc{width:8px;height:11px;border-radius:1px;background:var(--gold);box-shadow:0 0 0 1px rgba(0,0,0,.3)}
  .rc{width:8px;height:11px;border-radius:1px;background:#d12;box-shadow:0 0 0 1px rgba(0,0,0,.3)}
  .col{display:flex;flex-direction:column;align-items:center}
  .score{font-size:30px;font-weight:900;line-height:1;font-variant-numeric:tabular-nums;padding:6px 18px;min-width:54px;text-align:center}
  .mid{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:6px 14px;background:rgba(0,0,0,.28);border-left:1px solid var(--line);border-right:1px solid var(--line)}
  .clock{font-size:20px;font-weight:800;font-variant-numeric:tabular-nums;color:var(--gold)}
  .half{font-size:10px;letter-spacing:.22em;opacity:.8;margin-top:2px}
  .pillR{color:var(--red)} .pillB{color:var(--blue)}

  #poss{position:absolute;top:96px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--line);
    border-radius:999px;padding:5px 14px;font-size:12px;letter-spacing:.1em;backdrop-filter:blur(6px)}
  #poss b{font-weight:800}

  #miniWrap{position:absolute;top:14px;right:14px;background:var(--panel);border:1px solid var(--line);border-radius:12px;
    padding:8px;backdrop-filter:blur(6px);box-shadow:0 8px 24px rgba(0,0,0,.4)}
  #miniWrap .ml{font-size:9px;letter-spacing:.24em;opacity:.6;text-align:center;margin-bottom:4px}
  #mini{display:block;border-radius:6px; width: 336px; height: 498px;}

  #help{position:absolute;left:14px;bottom:14px;background:var(--panel);border:1px solid var(--line);border-radius:12px;
    padding:10px 14px;font-size:11.5px;line-height:1.7;max-width:48vw;backdrop-filter:blur(6px)}
  #help .k{display:inline-block;min-width:18px;text-align:center;padding:1px 6px;margin:0 1px;border:1px solid var(--line);
    border-radius:5px;background:rgba(255,255,255,.08);font-weight:700}
  #help h4{margin:0 0 4px;font-size:11px;letter-spacing:.18em;opacity:.7;font-weight:700}

  /* ---- 戦術UI ---- */
  #tacticsUI{position:absolute;bottom:14px;right:14px;display:flex;flex-direction:column;gap:8px;pointer-events:none;z-index:50;}
  .tac-btn{pointer-events:auto;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:11px;font-weight:800;letter-spacing:.1em;cursor:pointer;backdrop-filter:blur(6px);transition:.2s;color:#fff;text-align:center;}
  .tac-btn:hover{background:rgba(255,255,255,.2);}
  .tac-btn.atk{border-color:var(--red); color:#ffb3b3; box-shadow:0 0 8px rgba(226,59,59,.4) inset;}
  .tac-btn.def{border-color:var(--blue); color:#b3d4ff; box-shadow:0 0 8px rgba(47,123,224,.4) inset;}

  #stamina{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);display:flex;gap:18px}
  .stam{display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:999px;
    padding:5px 12px;backdrop-filter:blur(6px)}
  .stam .sl{font-size:11px;font-weight:800;letter-spacing:.12em;opacity:.85}
  .sbar{width:120px;height:9px;border-radius:5px;background:rgba(255,255,255,.14);overflow:hidden}
  .sfill{height:100%;width:100%;border-radius:5px;background:linear-gradient(90deg,#ffce4d,#7be88a);transition:width .08s linear}
  .sfill.low{background:#e2553b}

  /* ---- center messages ---- */
  #banner{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;pointer-events:none;opacity:0;transition:opacity .2s}
  #banner.on{opacity:1}
  #banner .big{font-size:clamp(54px,11vw,150px);font-weight:900;letter-spacing:.04em;text-shadow:0 8px 40px rgba(0,0,0,.5);
    -webkit-text-stroke:2px rgba(0,0,0,.25);animation:pop .4s cubic-bezier(.2,1.4,.4,1)}
  #banner .sub{font-size:clamp(16px,2.6vw,26px);letter-spacing:.3em;margin-top:6px;font-weight:700;opacity:.9}
  @keyframes pop{from{transform:scale(.5);opacity:0}to{transform:scale(1);opacity:1}}

  #cardfx{position:fixed;inset:0;pointer-events:none;opacity:0;transition:opacity .12s}
  #cardfx.on{opacity:.5}

  /* ---- overlay screens ---- */
  .screen{position:fixed;inset:0;display:none;align-items:center;justify-content:center;flex-direction:column;text-align:center;
    background:radial-gradient(120% 120% at 50% 30%, rgba(8,22,14,.55), rgba(3,10,6,.92))}
  .screen.on{display:flex}
  .kicker{font-size:13px;letter-spacing:.42em;color:var(--gold);font-weight:700;margin-bottom:10px}
  .gtitle{font-size:clamp(40px,8vw,96px);font-weight:900;line-height:.96;letter-spacing:.02em;margin:0}
  .gtitle .a{color:var(--red)} .gtitle .b{color:var(--blue)}
  .gsub{font-size:13px;letter-spacing:.28em;opacity:.75;margin-top:12px}
  .sel{margin-top:30px;display:flex;flex-direction:column;gap:18px;align-items:center}
  .grp{display:flex;gap:12px;align-items:center}
  .lbl{font-size:11px;letter-spacing:.24em;opacity:.6;width:84px;text-align:right}
  .opt{pointer-events:auto;cursor:pointer;padding:11px 22px;border:1.5px solid var(--line);border-radius:11px;font-weight:800;letter-spacing:.12em;
    font-size:14px;background:rgba(255,255,255,.04);transition:.15s}
  .opt:hover{background:rgba(255,255,255,.12)}
  .opt.act{border-color:var(--gold);background:rgba(242,200,75,.16);box-shadow:0 0 0 1px var(--gold) inset}
  .opt.r.act{border-color:var(--red);background:rgba(226,59,59,.2);box-shadow:0 0 0 1px var(--red) inset}
  .opt.bl.act{border-color:var(--blue);background:rgba(47,123,224,.2);box-shadow:0 0 0 1px var(--blue) inset}
  #start{margin-top:8px;padding:14px 46px;font-size:18px;background:var(--gold);color:#1a1408;border:none}
  #start:hover{filter:brightness(1.08)}
  #ftAgain{background:var(--gold);color:#1a1408;border:none}
  #ftAgain:hover{background:var(--gold);filter:brightness(1.08)}
  #ftMenu{background:rgba(255,255,255,.18);border-color:rgba(255,255,255,.55);color:#fff}
  #ftMenu:hover{background:rgba(255,255,255,.3)}
  .hint{margin-top:22px;font-size:11px;letter-spacing:.18em;opacity:.5}
  .result{font-size:clamp(40px,8vw,90px);font-weight:900;font-variant-numeric:tabular-nums}
  .rdesc{font-size:16px;letter-spacing:.2em;opacity:.85;margin-top:8px}
  .btnrow{margin-top:28px;display:flex;gap:14px}
</style>
</head>
<body>
<canvas id="game"></canvas>

<div id="hud">
  <div class="board">
    <div class="side" id="sL"><div class="col"><div class="nm" id="nmL">RED</div><div class="cards" id="cardsL"></div></div></div>
    <div class="score" id="scL">0</div>
    <div class="mid"><div class="clock" id="clk">0'</div><div class="half" id="hlf">1ST HALF</div></div>
    <div class="score" id="scR">0</div>
    <div class="side" id="sR"><div class="col"><div class="nm" id="nmR">BLUE</div><div class="cards" id="cardsR"></div></div></div>
  </div>
  <div id="poss">BALL: <b id="possName">—</b></div>
  
  <div id="tacticsUI">
    <div id="tacP1" class="tac-btn">P1 TACTIC (A): NORMAL</div>
    <div id="tacP2" class="tac-btn" style="display:none;">P2 TACTIC (H): NORMAL</div>
  </div>

  <div id="miniWrap"><div class="ml">MINI MAP</div><canvas id="mini" width="336" height="498"></canvas></div>
  <div id="stamina">
    <div class="stam" id="stamBox1"><span class="sl" id="sl1">P1</span><div class="sbar"><div class="sfill" id="sf1"></div></div></div>
    <div class="stam" id="stamBox2"><span class="sl" id="sl2">P2</span><div class="sbar"><div class="sfill" id="sf2"></div></div></div>
  </div>
  <div id="help"></div>
</div>

<div id="banner"><div class="big" id="bnBig">GOAL!</div><div class="sub" id="bnSub"></div></div>
<div id="cardfx"></div>

<div class="screen on" id="scrTitle">
  <div class="kicker">MULTIVERSE WORLD CUP</div>
  <h1 class="gtitle"><span class="a">MULTIVERSE</span><br><span class="b">SOCCER</span></h1>
  <div class="gsub">歴史上の大帝国 VS 現代サッカー強豪国</div>
  <div class="sel">
    <div class="grp"><div class="lbl">MODE</div>
      <div class="opt act" data-mode="single" id="mSingle">ひとりで<span style="opacity:.6">/SINGLE</span></div>
      <div class="opt" data-mode="multi" id="mMulti">ふたりで<span style="opacity:.6">/MULTI</span></div>
      <div class="opt" data-mode="tournament" id="mTour">大会<span style="opacity:.6">/TOURNEY</span></div>
    </div>
    <div class="grp"><div class="lbl">LEVEL</div>
      <div class="opt" data-diff="easy">EASY</div>
      <div class="opt" data-diff="normal">NORMAL</div>
      <div class="opt act" data-diff="hard">HARD</div>
    </div>
    <div class="grp"><div class="lbl">YOUR KIT</div>
      <div class="opt r" data-color="red">RED</div>
      <div class="opt bl" data-color="blue">BLUE</div>
      <div class="opt act" data-color="random">RANDOM</div>
    </div>
    <button class="opt" id="start">KICK OFF ▶</button>
  </div>
  <div class="hint">クリック / Enter で選択・決定</div>
</div>

<div class="screen" id="scrHalf">
  <div class="kicker">HALF TIME</div>
  <div class="result" id="htScore">00</div>
  <div class="rdesc">コートチェンジ。まもなく後半開始…</div>
</div>

<div class="screen" id="scrFull">
  <div class="kicker" id="ftKick">FULL TIME</div>
  <div class="result" id="ftScore">00</div>
  <div class="rdesc" id="ftDesc"></div>
  <div class="btnrow">
    <button class="opt" id="ftAgain">もう一度 ↺</button>
    <button class="opt" id="ftMenu">メニューへ</button>
  </div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>

<script src="config.js"></script>
<script src="graphics.js"></script>
<script src="logic.js"></script>
<script src="main.js"></script>

</body>
</html>

config.js

"use strict";
/* =====================================================================
   config.js — ゲーム定数と設定(フィールド1.5倍スケール版)
   ===================================================================== */

const RED = 0xe23b3b;
const BLUE = 0x2f7be0;

/* 1ユニット≒1m。コートとゴールのサイズ (1.5倍) */
const HALF_X = 51;
const HALF_Z = 78;
const GOAL_HALF = 6.0;
const GOAL_H = 3.5;
const GOAL_DEPTH = 3.5;

/* カメラ設定 (1.5倍) */
const CAM_BACK = 22.5;
const CAM_H = 43.5;
const LOOK_AHEAD = 24;
const FOV = 52;
const LOOK_Y = 1.0;

/* 選手とボールのサイズ */
const PLAYER_SX = 0.8;
const PLAYER_SY = 0.8;
const PLAYER_SZ = 0.8;
const FOOT = 0.16;
const BALLR = 0.3;

/* 移動速度パラメータ (1.5倍) */
const RUN_SPD = 15;
const DRIB_SPD = 12.3;
const AI_SPD = 12;
const AI_DRIB = 10.5;
const GK_SPD = 12.7;

/* ダッシュ&スライディング (1.5倍) */
const SLIDE_SPEED = 24;
const SLIDE_DUR = 0.5;
const SLIDE_CD = 0.9;
const SLIDE_RANGE = 9.0;
const DASH_SPEED = 25.5;
const DASH_DUR = 0.55;
const DASH_MIN = 0.18;

/* スタミナ */
const STAM_DRAIN = 1.4;
const STAM_RECOVER = 0.7;

/* ボールの物理・キックパラメータ (1.5倍) */
const SMALL_KICK = 27;
const BIG_KICK = 36; 
const MAX_SHOOT_DIST = HALF_Z * 1.1; 
const GRAV = 27;
const REST = 0.45;
const FRIC = 10.5;
const STOP = 0.6;

/* AIと操作の閾値 (1.5倍調整) */
const LEAD = 1.35;
const PICK_R = 1.7;
const PICK_MAXSPEED = 42;
const STEAL_R = 1.5;
const GRACE = 0.5;
const STEAL_AI = 1.7;
const STEAL_HUMAN = 4.6;
const SEP = 1.5;
const CARD_DIST = 1.0;
const CARD_CD = 1.7;

/* 試合時間 */
const HALF_LEN = 90;
const CELEBR = 3.3;

/* 共通ベクトル */
const UP = new THREE.Vector3(0, 1, 0);

/* ヘルパー関数 */
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
function mix(a, b, t) {
  const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;
  const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255;
  return (Math.round(ar + (br - ar) * t) << 16) | (Math.round(ag + (bg - ag) * t) << 8) | Math.round(ab + (bb - ab) * t);
}
const faceVec = a => new THREE.Vector3(Math.sin(a), 0, Math.cos(a));
function lerpAngle(a, b, t) {
  let d = b - a;
  while (d > Math.PI) d -= 2 * Math.PI;
  while (d < -Math.PI) d += 2 * Math.PI;
  return a + d * t;
}
const smooth = t => t * t * (3 - 2 * t);
const $ = id => document.getElementById(id);

graphics.js

"use strict";
/* =====================================================================
   graphics.js — Three.jsの初期化、3Dモデル生成、アニメーション
   ===================================================================== */

let renderer, scene, cam1, cam2;
let crowd, confetti;

function matLam(c) { return new THREE.MeshLambertMaterial({ color: c }); }
function box(w, h, d, c) { return new THREE.Mesh(new THREE.BoxGeometry(w, h, d), matLam(c)); }
function grp(x, y, z) { const g = new THREE.Group(); g.position.set(x, y, z); return g; }

function blobShadow(r) {
  const m = new THREE.Mesh(
    new THREE.CircleGeometry(r, 16),
    new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.26 })
  );
  m.rotation.x = -Math.PI / 2;
  m.position.y = 0.02;
  scene.add(m);
  return m;
}

function initThree() {
  const cv = $('game');
  renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true });
  renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
  renderer.setSize(window.innerWidth, window.innerHeight);
  
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x8fb6d8);
  scene.fog = new THREE.Fog(0x8fb6d8, 130, 250);
  
  cam1 = new THREE.PerspectiveCamera(FOV, window.innerWidth / window.innerHeight, 0.1, 400);
  cam2 = new THREE.PerspectiveCamera(FOV, 1, 0.1, 400);
  
  const hemi = new THREE.HemisphereLight(0xffffff, 0x4a6a4a, 0.9);
  scene.add(hemi);
  const dir = new THREE.DirectionalLight(0xffffff, 0.85);
  dir.position.set(30, 80, 40);
  scene.add(dir);
  
  cam1.position.set(0, 30, HALF_Z + 34);
  cam1.lookAt(0, 0, 0);
  
  window.addEventListener('resize', () => {
    renderer.setSize(window.innerWidth, window.innerHeight);
  });
}

function buildPitchTexture() {
  const cw = 512, ch = 768, cv = document.createElement('canvas');
  cv.width = cw; cv.height = ch;
  const x = cv.getContext('2d');
  
  for (let i = 0; i < ch; i += 42) {
    x.fillStyle = ((i / 42) & 1) ? '#2f7d3a' : '#296f30';
    x.fillRect(0, i, cw, 42);
  }
  x.strokeStyle = 'rgba(255,255,255,.85)';
  x.lineWidth = 4;
  
  const ix = cw * 0.018, iy = ch * 0.013;
  x.strokeRect(ix, iy, cw - 2 * ix, ch - 2 * iy);
  x.beginPath(); x.moveTo(ix, ch / 2); x.lineTo(cw - ix, ch / 2); x.stroke();
  x.beginPath(); x.arc(cw / 2, ch / 2, 72, 0, 6.283); x.stroke();
  
  x.fillStyle = '#fff';
  x.beginPath(); x.arc(cw / 2, ch / 2, 5, 0, 6.283); x.fill();
  
  const pbW = cw * 0.5, pbH = ch * 0.155, pbX = (cw - pbW) / 2;
  x.strokeRect(pbX, iy, pbW, pbH); x.strokeRect(pbX, ch - iy - pbH, pbW, pbH);
  
  const gbW = cw * 0.26, gbH = ch * 0.06, gbX = (cw - gbW) / 2;
  x.strokeRect(gbX, iy, gbW, gbH); x.strokeRect(gbX, ch - iy - gbH, gbW, gbH);
  
  const t = new THREE.CanvasTexture(cv);
  t.anisotropy = 8;
  return t;
}

function buildField() {
  const surround = new THREE.Mesh(new THREE.PlaneGeometry(440, 440), matLam(0x244a26));
  surround.rotation.x = -Math.PI / 2;
  surround.position.y = -0.05;
  scene.add(surround);
  
  const pitch = new THREE.Mesh(new THREE.PlaneGeometry(HALF_X * 2, HALF_Z * 2), new THREE.MeshLambertMaterial({ map: buildPitchTexture() }));
  pitch.rotation.x = -Math.PI / 2;
  pitch.position.y = 0;
  scene.add(pitch);
  
  buildGoal(-1);
  buildGoal(1);
  buildStands();
  crowd = buildCrowd();
  confetti = buildConfetti();
}

function buildGoal(sign) {
  const g = new THREE.Group();
  scene.add(g);
  const z = sign * HALF_Z;
  const pm = matLam(0xffffff);
  
  const post = xx => {
    const m = new THREE.Mesh(new THREE.BoxGeometry(0.22, GOAL_H, 0.22), pm);
    m.position.set(xx, GOAL_H / 2, z);
    g.add(m);
  };
  post(-GOAL_HALF);
  post(GOAL_HALF);
  
  const bar = new THREE.Mesh(new THREE.BoxGeometry(GOAL_HALF * 2 + 0.22, 0.22, 0.22), pm);
  bar.position.set(0, GOAL_H, z);
  g.add(bar);
  
  const nm = new MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.15, side: THREE.DoubleSide });
  
  const back = new THREE.Mesh(new THREE.PlaneGeometry(GOAL_HALF * 2, GOAL_H), nm);
  back.position.set(0, GOAL_H / 2, z + sign * GOAL_DEPTH);
  g.add(back);
  
  const top = new THREE.Mesh(new THREE.PlaneGeometry(GOAL_HALF * 2, GOAL_DEPTH), nm);
  top.rotation.x = Math.PI / 2;
  top.position.set(0, GOAL_H, z + sign * GOAL_DEPTH / 2);
  g.add(top);
  
  [-1, 1].forEach(s => {
    const sd = new THREE.Mesh(new THREE.PlaneGeometry(GOAL_DEPTH, GOAL_H), nm);
    sd.rotation.y = Math.PI / 2;
    sd.position.set(GOAL_HALF * s, GOAL_H / 2, z + sign * GOAL_DEPTH / 2);
    g.add(sd);
  });
}

// 修正用ヘルパー:元のコードに合わせてTHREEを付与
function MeshLambertMaterial(params) {
  return new THREE.MeshLambertMaterial(params);
}

function buildStands() {
  const sm = matLam(0x32313a);
  const off = 7;
  const bank = (cx, cz, w, d) => {
    const m = new THREE.Mesh(new THREE.BoxGeometry(w, 2.4, d), sm);
    m.position.set(cx, 1.2, cz);
    scene.add(m);
  };
  bank(0, -(HALF_Z + off), HALF_X * 2 + 24, 9);
  bank(0, (HALF_Z + off), HALF_X * 2 + 24, 9);
  bank(-(HALF_X + off), 0, 9, HALF_Z * 2 + 10);
  bank((HALF_X + off), 0, 9, HALF_Z * 2 + 10);
}

function buildCrowd() {
  const geo = new THREE.BoxGeometry(0.5, 0.8, 0.5);
  const pts = [], ph = [], cols = [];
  const pal = [RED, BLUE, 0xffffff, 0xf2c84b, 0xc9ced6, 0x20242c];
  
  const add = (x, y, z) => {
    pts.push([x, y, z]);
    ph.push(Math.random() * 6.283);
    cols.push(pal[(Math.random() * pal.length) | 0]);
  };
  
  const off = 7, rows = 3, gap = 1.7;
  for (let r = 0; r < rows; r++) {
    const y = 1.7 + r * 0.62;
    for (let xi = -(HALF_X + 10); xi <= HALF_X + 10; xi += gap) {
      add(xi, y, -(HALF_Z + off - 1.5) - r * 0.8);
      add(xi, y, (HALF_Z + off - 1.5) + r * 0.8);
    }
    for (let zi = -(HALF_Z + 5); zi <= HALF_Z + 5; zi += gap) {
      add(-(HALF_X + off - 1.5) - r * 0.8, y, zi);
      add((HALF_X + off - 1.5) + r * 0.8, y, zi);
    }
  }
  
  const N = pts.length;
  const mesh = new THREE.InstancedMesh(geo, new THREE.MeshLambertMaterial(), N);
  const m = new THREE.Matrix4(), col = new THREE.Color();
  
  for (let i = 0; i < N; i++) {
    m.makeTranslation(pts[i][0], pts[i][1], pts[i][2]);
    mesh.setMatrixAt(i, m);
    col.setHex(cols[i]);
    mesh.setColorAt(i, col);
  }
  mesh.instanceMatrix.needsUpdate = true;
  if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
  scene.add(mesh);
  return { mesh, bases: pts, phases: ph, N, m };
}

function buildConfetti() {
  const N = 720;
  const g = new THREE.BufferGeometry();
  const pos = new Float32Array(N * 3), col = new Float32Array(N * 3);
  for (let i = 0; i < N; i++) pos[i * 3 + 1] = -200;
  
  g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  g.setAttribute('color', new THREE.BufferAttribute(col, 3));
  
  const mat = new THREE.PointsMaterial({ size: 0.32, vertexColors: true, transparent: true, opacity: 0.95 });
  const p = new THREE.Points(g, mat);
  p.frustumCulled = false;
  scene.add(p);
  return { pts: p, pos, col, vel: new Float32Array(N * 3), N, life: 0 };
}

function burstConfetti() {
  const C = confetti;
  const pal = [[0.9, 0.2, 0.2], [0.2, 0.5, 0.9], [1, 0.8, 0.2], [0.3, 0.9, 0.45], [1, 1, 1], [0.8, 0.35, 0.9]];
  const org = [[-HALF_X * 0.6, 5, HALF_Z * 0.5], [HALF_X * 0.6, 5, HALF_Z * 0.5], [0, 6, 0], [-HALF_X * 0.6, 5, -HALF_Z * 0.5], [HALF_X * 0.6, 5, -HALF_Z * 0.5]];
  
  for (let i = 0; i < C.N; i++) {
    const o = org[i % org.length];
    C.pos[i * 3] = o[0] + (Math.random() - 0.5) * 5;
    C.pos[i * 3 + 1] = o[1] + Math.random() * 2;
    C.pos[i * 3 + 2] = o[2] + (Math.random() - 0.5) * 5;
    C.vel[i * 3] = (Math.random() - 0.5) * 7;
    C.vel[i * 3 + 1] = 5 + Math.random() * 7;
    C.vel[i * 3 + 2] = (Math.random() - 0.5) * 7;
    const c = pal[(Math.random() * pal.length) | 0];
    C.col[i * 3] = c[0]; C.col[i * 3 + 1] = c[1]; C.col[i * 3 + 2] = c[2];
  }
  C.pts.geometry.attributes.color.needsUpdate = true;
  C.life = CELEBR;
}

function updateConfetti(dt) {
  const C = confetti;
  if (C.life <= 0) return;
  C.life -= dt;
  for (let i = 0; i < C.N; i++) {
    C.vel[i * 3 + 1] -= 14 * dt;
    C.pos[i * 3] += C.vel[i * 3] * dt;
    C.pos[i * 3 + 1] += C.vel[i * 3 + 1] * dt;
    C.pos[i * 3 + 2] += C.vel[i * 3 + 2] * dt;
    
    if (C.pos[i * 3 + 1] < 0.12) {
      C.pos[i * 3 + 1] = 0.12;
      C.vel[i * 3] *= 0.6;
      C.vel[i * 3 + 2] *= 0.6;
      C.vel[i * 3 + 1] = 0;
    }
    C.vel[i * 3] += Math.sin((C.life + i) * 3) * 2 * dt;
  }
  C.pts.geometry.attributes.position.needsUpdate = true;
  C.pts.material.opacity = Math.min(0.95, C.life);
  if (C.life <= 0) {
    for (let i = 0; i < C.N; i++) C.pos[i * 3 + 1] = -200;
    C.pts.geometry.attributes.position.needsUpdate = true;
  }
}

let crowdBounce = 0, frameN = 0;
function updateCrowd(dt) {
  const cr = crowd;
  if (!cr) return;
  crowdBounce = Math.max(0, crowdBounce - dt);
  frameN++;
  const bouncing = crowdBounce > 0;
  if (!bouncing && (frameN & 3)) return;
  
  const amp = bouncing ? 0.4 : 0.04;
  const sp = bouncing ? 9 : 2.2;
  const t = performance.now() * 0.001;
  
  for (let i = 0; i < cr.N; i++) {
    const b = cr.bases[i];
    const y = b[1] + Math.abs(Math.sin(t * sp + cr.phases[i])) * amp;
    cr.m.makeTranslation(b[0], y, b[2]);
    cr.mesh.setMatrixAt(i, cr.m);
  }
  cr.mesh.instanceMatrix.needsUpdate = true;
}

/* ---------- 選手モデル生成とアニメーション ---------- */
function makePlayer(color, role) {
  const jersey = role === 'GK' ? mix(color, 0x111111, 0.4) : color;
  const shorts = 0xffffff, socks = jersey, skin = 0xf1c8a0;
  const root = grp(0, 0, 0);
  const P = { root };
  
  function leg(s) {
    const hip = grp(0.17 * s, 0.95, 0); root.add(hip);
    const th = box(0.26, 0.5, 0.28, shorts); th.position.y = -0.25; hip.add(th);
    const shin = grp(0, -0.5, 0); hip.add(shin);
    const calf = box(0.22, 0.5, 0.24, socks); calf.position.y = -0.25; shin.add(calf);
    const shoe = box(0.26, 0.18, 0.42, 0x101014); shoe.position.set(0, -0.52, 0.08); shin.add(shoe);
    return { hip, shin };
  }
  const L = leg(-1), R = leg(1);
  P.legL = L.hip; P.legR = R.hip; P.shinL = L.shin; P.shinR = R.shin;
  
  const torso = grp(0, 1.5, 0); root.add(torso); P.torso = torso;
  const chest = box(0.6, 0.7, 0.34, jersey); torso.add(chest);
  const waist = box(0.5, 0.18, 0.3, shorts); waist.position.y = -0.42; torso.add(waist);
  const num = box(0.3, 0.3, 0.02, mix(jersey, 0xffffff, 0.6)); num.position.set(0, 0.05, -0.18); torso.add(num);
  
  function arm(s) {
    const sh = grp(0.36 * s, 1.78, 0); root.add(sh);
    const up = box(0.16, 0.42, 0.18, jersey); up.position.y = -0.21; sh.add(up);
    const fore = grp(0, -0.42, 0); sh.add(fore);
    const fm = box(0.14, 0.4, 0.16, skin); fm.position.y = -0.2; fore.add(fm);
    if (role === 'GK') {
      const gl = box(0.17, 0.16, 0.18, 0xffffff); gl.position.y = -0.42; fore.add(gl);
    }
    return { sh, fore };
  }
  const aL = arm(-1), aR = arm(1);
  P.armL = aL.sh; P.foreL = aL.fore; P.armR = aR.sh; P.foreR = aR.fore;
  
  const head = grp(0, 2.12, 0); root.add(head); P.head = head;
  head.add(box(0.34, 0.36, 0.34, skin));
  const hair = box(0.38, 0.16, 0.38, 0x2a1a10); hair.position.y = 0.2; head.add(hair);
  [-1, 1].forEach(s => {
    const e = box(0.05, 0.06, 0.02, 0x1a1a22); e.position.set(0.08 * s, 0.02, 0.18); head.add(e);
  });
  
  root.scale.set(PLAYER_SX, PLAYER_SY, PLAYER_SZ);
  return { root, parts: P };
}

function animatePlayer(p, dt, moving, speed) {
  const P = p.parts;
  p.animT += dt;
  
  P.legL.rotation.set(0, 0, 0); P.legR.rotation.set(0, 0, 0);
  P.shinL.rotation.set(0, 0, 0); P.shinR.rotation.set(0, 0, 0);
  P.armL.rotation.set(0, 0, 0); P.armR.rotation.set(0, 0, 0);
  P.foreL.rotation.set(0, 0, 0); P.foreR.rotation.set(0, 0, 0);
  P.torso.rotation.set(0, 0, 0); P.head.rotation.set(0, 0, 0);
  P.torso.position.y = 1.5;
  
  let lock = p.lock;
  if (lock) {
    lock.t += dt;
    if (lock.t >= lock.dur) p.lock = lock = null;
  }
  
  if (lock && lock.name === 'kick') {
    const k = Math.min(1, lock.t / lock.dur), sw = Math.sin(k * Math.PI);
    P.legR.rotation.x = -1.7 * sw - 0.1;
    P.shinR.rotation.x = 0.5 * (1 - sw);
    P.legL.rotation.x = 0.3;
    P.armL.rotation.x = -0.5;
    P.armR.rotation.x = -0.7;
    P.torso.rotation.x = 0.12;
    P.torso.rotation.y = sw * 0.22;
  } else if (lock && lock.name === 'slide') {
    P.legR.rotation.x = -1.25; P.shinR.rotation.x = 0.2;
    P.legL.rotation.x = 0.55; P.shinL.rotation.x = 0.95;
    P.armL.rotation.x = -1.3; P.armR.rotation.x = -1.3;
    P.foreL.rotation.x = -0.3; P.foreR.rotation.x = -0.3;
    P.torso.rotation.x = -0.5; P.torso.position.y = 1.05;
    P.head.rotation.x = 0.32;
  } else if (lock && lock.name === 'lift') {
    // 奪取直後のリフティング(膝蹴り)アニメーション
    const k = lock.t / lock.dur;
    const sw = Math.abs(Math.sin(k * Math.PI * 4)); // 1秒間で数回リフティング
    P.legR.rotation.x = -1.5 * sw; // 右膝を高く上げる
    P.shinR.rotation.x = 1.0 * sw;
    P.armL.rotation.x = -0.4; P.armR.rotation.x = -0.4;
    P.torso.rotation.x = -0.1;
  } else if (moving) {
    const a = Math.min(1, speed / 13), ph = p.animT * (10 + speed * 0.45), s = Math.sin(ph);
    P.legL.rotation.x = s * 1.0 * a; P.legR.rotation.x = -s * 1.0 * a;
    P.shinL.rotation.x = Math.max(0, Math.cos(ph)) * 1.0 * a;
    P.shinR.rotation.x = Math.max(0, Math.cos(ph + Math.PI)) * 1.0 * a;
    P.armL.rotation.x = -s * 0.7 * a; P.armR.rotation.x = s * 0.7 * a;
    P.foreL.rotation.x = -0.5; P.foreR.rotation.x = -0.5;
    P.torso.rotation.x = 0.14; P.torso.position.y = 1.5 + Math.abs(s) * 0.06 * a;
    P.head.rotation.x = -0.05;
  } else {
    const s = Math.sin(p.animT * 1.8);
    P.armL.rotation.x = 0.05; P.armR.rotation.x = 0.05;
    P.foreL.rotation.x = -0.2; P.foreR.rotation.x = -0.2;
    P.torso.position.y = 1.5 + s * 0.02;
    P.head.rotation.y = Math.sin(p.animT * 0.7) * 0.15;
  }
  p.root.position.set(p.pos.x, FOOT * PLAYER_SY, p.pos.z);
  p.root.rotation.y = p.facing;
}

/* ---------- ボール生成 ---------- */
function ballTex() {
  const cv = document.createElement('canvas');
  cv.width = cv.height = 128;
  const x = cv.getContext('2d');
  x.fillStyle = '#fff';
  x.fillRect(0, 0, 128, 128);
  x.fillStyle = '#1c1c22';
  for (let i = 0; i < 6; i++) {
    x.beginPath();
    x.arc(16 + Math.random() * 96, 16 + Math.random() * 96, 9, 0, 6.283);
    x.fill();
  }
  return new THREE.CanvasTexture(cv);
}

function buildBall() {
  const m = new THREE.Mesh(new THREE.SphereGeometry(BALLR, 16, 12), new THREE.MeshLambertMaterial({ map: ballTex() }));
  scene.add(m);
  // 【追加】passRoute: オートリレー用の経路配列を保持
  ball = {
    mesh: m, shadow: blobShadow(0.28), pos: new THREE.Vector3(0, BALLR, 0),
    vel: new THREE.Vector3(), owner: null, lastTouch: null, passRoute: null
  };
}

logic.js

"use strict";
/* =====================================================================
   logic.js — AI、操作、物理判定、ゲームルール、マルチバース設定
   ===================================================================== */

// マルチバース出場国(現代強豪国 & 歴史上の大帝国)
const MULTIVERSE_NATIONS = [
  { name: "大清帝国", color: 0xffcc00 },
  { name: "ローマ帝国", color: 0x800020 },
  { name: "モンゴル帝国", color: 0x0066cc },
  { name: "神聖ローマ", color: 0x222222 },
  { name: "アステカ", color: 0x008000 },
  { name: "アルゼンチン", color: 0x43a1d5 },
  { name: "フランス", color: 0x002395 },
  { name: "ブラジル", color: 0xf2c84b },
  { name: "イングランド", color: 0xffffff },
  { name: "スペイン", color: 0xc60b1e },
  { name: "日本", color: 0x000555 }
];

const G = {
  state: 'menu', mode: 'single', colorChoice: 'random', difficulty: 'hard', started: false,
  home: null, away: null, teams: [], half: 1, halfElapsed: 0,
  firstKicker: null, kickNext: null, celebr: 0, htTimer: 0,
  tourney: null // トーナメント進行状態を保持
};

let humans = [];
let ball = null;

const FORM = [
  { role: 'GK',  xf:  0.00, zf: 0.05 },
  { role: 'DEF', xf: -0.78, zf: 0.22 }, { role: 'DEF', xf: -0.28, zf: 0.18 },
  { role: 'DEF', xf:  0.28, zf: 0.18 }, { role: 'DEF', xf:  0.78, zf: 0.22 },
  { role: 'MID', xf: -0.74, zf: 0.48 }, { role: 'MID', xf: -0.26, zf: 0.45 },
  { role: 'MID', xf:  0.26, zf: 0.45 }, { role: 'MID', xf:  0.74, zf: 0.48 },
  { role: 'FWD', xf: -0.26, zf: 0.74 }, { role: 'FWD', xf:  0.26, zf: 0.74 },
];

function makeTeam(color, name, isHuman) {
  return { color, name, isHuman, score: 0, players: [], bench: [], defendZ: 0, attackZ: 0, dir: 1, presser: null, tactic: 'NORMAL', manualTactic: false };
}

function allPlayers() { return G.home.players.concat(G.away.players); }
function otherTeam(t) { return t === G.home ? G.away : G.home; }
function teamAttacking(sign) { return G.teams.find(t => t.dir === sign); }
function teamDefending(sign) { return G.teams.find(t => t.defendZ === sign * HALF_Z); }

function formPos(team, slot) {
  const ownGoalZ = team.defendZ, dir = team.dir;
  return new THREE.Vector3(slot.xf * (HALF_X - 5), 0, ownGoalZ + dir * slot.zf * HALF_Z);
}

function placeFormation(team) {
  team.players.forEach((p, i) => {
    const s = FORM[i], pos = formPos(team, s);
    p.pos.copy(pos);
    p.homePos.copy(pos);
    p.facing = Math.atan2(-p.pos.x, team.attackZ - p.pos.z);
    p.aiState = 'IDLE'; 
  });
}

function nearestTeamPlayer(team, pos) {
  let b = null, bd = 1e9;
  team.players.forEach(p => {
    if (p.sentOff) return;
    const d = Math.hypot(p.pos.x - pos.x, p.pos.z - pos.z);
    if (d < bd) { bd = d; b = p; }
  });
  return b;
}

function chooseControlled(h) {
  const team = h.team, owner = ball.owner;
  const relayActive = (ball.passRoute && ball.passRoute.length > 0) || (owner && owner.autoPassTgt);

  if (owner && owner.team === team && !owner.sentOff && !relayActive) { h.controlled = owner; return; }
  
  let best = null, bd = 1e9;
  team.players.forEach(p => {
    if (p.sentOff || p.role === 'GK') return;
    const d = Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z);
    if (d < bd) { bd = d; best = p; }
  });
  
  if (h.controlled && !h.controlled.sentOff && h.controlled.role !== 'GK') {
    const cur = Math.hypot(h.controlled.pos.x - ball.pos.x, h.controlled.pos.z - ball.pos.z);
    if (cur <= bd + 2.2) return;
  }
  h.controlled = best;
}

function clampPlayer(p) {
  const m = 1;
  p.pos.x = clamp(p.pos.x, -HALF_X + m, HALF_X - m);
  const zl = HALF_Z - (p.role === 'GK' ? 0.4 : m);
  p.pos.z = clamp(p.pos.z, -zl, zl);
}

function calculatePassRoute(startPlayer, isAttackingGoal = false) {
  const route = [];
  const visited = new Set([startPlayer]);
  let curr = startPlayer;
  const sign = startPlayer.team.dir;
  
  const isAllOut = startPlayer.team.tactic === 'ATTACK';
  const isDefending = startPlayer.team.tactic === 'DEFEND';
  
  for (let i = 0; i < 3; i++) {
    let best = null, bs = -1e9;
    startPlayer.team.players.forEach(m => {
      if (m === curr || m.sentOff || m.role === 'GK' || visited.has(m)) return;
      const d = Math.hypot(m.pos.x - curr.pos.x, m.pos.z - curr.pos.z);
      if (d < 3 || d > 45) return; 

      const fwd = (m.pos.z - curr.pos.z) * sign;
      let open = 99;
      otherTeam(startPlayer.team).players.forEach(o => {
        if (o.sentOff) return;
        const dd = Math.hypot(o.pos.x - m.pos.x, o.pos.z - m.pos.z);
        if (dd < open) open = dd;
      });
      
      const fwdWeight = isAttackingGoal ? 2.5 : (isAllOut ? 2.2 : 1.5);
      const openWeight = isDefending ? 2.0 : (isAllOut ? 0.8 : 1.2);
      let sc = fwd * fwdWeight + open * openWeight - d * 0.1;

      if (!pathClear(curr.pos, m.pos, startPlayer.team)) sc -= 12;
      if (sc > bs) { bs = sc; best = m; }
    });
    
    if (!best) {
      let nf = null, nd = 1e9;
      startPlayer.team.players.forEach(m => {
        if (m === curr || m.sentOff || m.role === 'GK' || visited.has(m)) return;
        const d = Math.hypot(m.pos.x - curr.pos.x, m.pos.z - curr.pos.z);
        const fwd = (m.pos.z - curr.pos.z) * sign;
        if (fwd > 2 && d < 45 && d < nd) { nd = d; nf = m; }
      });
      best = nf;
    }
    if (!best) break;
    route.push(best);
    visited.add(best);
    curr = best;
  }
  return route;
}

function updateHuman(h, dt, inputAxis, camDirs) {
  const p = h.controlled;
  if (!p || p.sentOff) return;

  if (p.slide) { tickSlide(p, dt); return; }

  if (p.lock && (p.lock.name === 'lift' || p.lock.name === 'stun')) {
    p.vel.set(0, 0, 0);
    return;
  }

  if (p.autoPassTgt) {
    p.vel.set(0, 0, 0);
    return;
  }

  const dashing = (h.dashT || 0) > 0;
  const a = inputAxis;
  const mv = new THREE.Vector3();
  mv.addScaledVector(camDirs.fwd, a.fwd);
  mv.addScaledVector(camDirs.right, a.right);
  
  const hasBall = (ball.owner === p);
  const enemyHasBall = ball.owner && ball.owner.team !== p.team;

  // 【フェイントの発動】
  if (hasBall && a.feint && (p.feintCd || 0) <= 0) {
    p.feintCd = 4.0; // クールダウン
    p.feintBoostT = 0.6; // スピードアップ時間
    if (typeof feintSnd === 'function') feintSnd();

    // 周囲の敵をスタン(足止め)させる
    otherTeam(p.team).players.forEach(o => {
      if (o.sentOff || o.role === 'GK') return;
      const d = Math.hypot(o.pos.x - p.pos.x, o.pos.z - p.pos.z);
      if (d < 8.0) { // スタン有効半径
        o.lock = { name: 'stun', t: 0, dur: 0.8 };
        o.vel.set(0, 0, 0);
      }
    });
  }

  if (!hasBall && enemyHasBall && (a.small || a.big || a.smHold || a.bgHold)) {
    h.chaseLatch = true;
  }
  if (hasBall || !ball.owner || (ball.owner && ball.owner.team === p.team)) {
    h.chaseLatch = false;
  }

  const manualInput = (Math.abs(a.fwd) + Math.abs(a.right)) > 0.1;
  let autoChase = false;
  if (!hasBall && enemyHasBall && h.chaseLatch) {
    autoChase = true;
    const tp = ball.owner.pos;
    const dist = Math.hypot(tp.x - p.pos.x, tp.z - p.pos.z);

    if (dist <= SLIDE_RANGE && (p.slideCd || 0) <= 0) {
      startSlide(p);
      return;
    }
    if ((h.dashT || 0) <= 0 && h.stamina > DASH_MIN) h.dashT = DASH_DUR;
    if (!manualInput) {
      const to = new THREE.Vector3(tp.x - p.pos.x, 0, tp.z - p.pos.z);
      if (to.lengthSq() > 0.0001) { to.normalize(); mv.copy(to); }
    }
  }

  // フェイント中は移動速度をブースト
  const spMult = (p.feintBoostT || 0) > 0 ? 1.6 : 1.0;

  if (mv.lengthSq() > 0.01) {
    mv.normalize();
    const sp = (hasBall ? DRIB_SPD : (dashing ? DASH_SPEED : RUN_SPD)) * spMult;
    p.pos.x += mv.x * sp * dt;
    p.pos.z += mv.z * sp * dt;
    p.facing = Math.atan2(mv.x, mv.z);
    p.vel.set(mv.x * sp, 0, mv.z * sp);
  } else {
    if (ball.passRoute && ball.passRoute[0] === p) {
      const toBall = new THREE.Vector3(ball.pos.x - p.pos.x, 0, ball.pos.z - p.pos.z);
      if (toBall.lengthSq() > 0.05) {
        toBall.normalize();
        const sp = RUN_SPD;
        p.pos.x += toBall.x * sp * dt;
        p.pos.z += toBall.z * sp * dt;
        p.facing = Math.atan2(toBall.x, toBall.z);
        p.vel.set(toBall.x * sp, 0, toBall.z * sp);
      } else {
        p.vel.set(0, 0, 0);
      }
    } else {
      p.vel.set(0, 0, 0);
      if (hasBall) {
        const ga = Math.atan2(-p.pos.x, p.team.attackZ - p.pos.z);
        p.facing = lerpAngle(p.facing, ga, Math.min(1, dt * 4));
      }
    }
  }
  
  clampPlayer(p);

  if (hasBall && p.kickCd <= 0) {
    if (a.big) kickWithBall(p, 'big');
    else if (a.small) kickWithBall(p, 'small');
  }
}

function startSlide(p) {
  const tp = ball.owner ? ball.owner.pos : ball.pos;
  const dir = new THREE.Vector3(tp.x - p.pos.x, 0, tp.z - p.pos.z);
  if (dir.lengthSq() < 0.01) dir.set(Math.sin(p.facing), 0, Math.cos(p.facing));
  dir.normalize();
  p.slide = { t: 0, dur: SLIDE_DUR, dir };
  p.slideCd = SLIDE_CD;
  p.lock = { name: 'slide', t: 0, dur: SLIDE_DUR };
  if (typeof slideSnd === 'function') slideSnd();
}

function tickSlide(p, dt) {
  const s = p.slide;
  if (!s) return;
  s.t += dt;
  const dir = s.dir, k = s.t / s.dur, sp = SLIDE_SPEED * (1 - 0.45 * k);
  p.pos.x += dir.x * sp * dt;
  p.pos.z += dir.z * sp * dt;
  p.facing = Math.atan2(dir.x, dir.z);
  p.vel.set(dir.x * sp, 0, dir.z * sp);
  clampPlayer(p);

  if (ball.owner !== p) {
    const d = Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z);
    if (d < STEAL_R + 0.3 && (!ball.owner || ball.owner.team !== p.team) && ball.pos.y < 1.2) {
      ball.owner = p;
      p.possGrace = 1.0;
      ball.lastTouch = p.team;
      p.slide = null;
      ball.passRoute = null;
      if (typeof stealSnd === 'function') stealSnd();
      if (p.isControlled) {
        p.lock = { name: 'lift', t: 0, dur: 0.55 };
        p.kickCd = 0.55;
      } else {
        p.kickCd = 0.3;
      }
      p.vel.set(0, 0, 0);
      return;
    }
  }
  if (p.slide && p.slide.t >= p.slide.dur) p.slide = null;
}

function kickWithBall(p, type) {
  if (type === 'small') {
    const h = humans.find(hm => hm.controlled === p);
    let route = h ? h.currentPassRoute : calculatePassRoute(p);
    if (route && route.length > 0) {
      ball.passRoute = [...route];
      passKick(p, route[0]);
    } else {
      straightKick(p, SMALL_KICK, 0.8);
    }
  } else {
    const gp = goalGapPoint(p.team);
    const distToGoal = Math.hypot(gp.x - p.pos.x, gp.z - p.pos.z);
    
    if (distToGoal <= MAX_SHOOT_DIST) {
      shootGap(p);
    } else {
      const route = calculatePassRoute(p, true);
      if (route && route.length > 0) {
        ball.passRoute = [...route];
        passKick(p, route[0]);
      } else {
        shootGap(p);
      }
    }
  }
}

function straightKick(p, power, lift) {
  const f = faceVec(p.facing);
  ball.owner = null;
  ball.vel.set(f.x * power, lift, f.z * power);
  ball.lastTouch = p.team;
  p.kickCd = 0.45; p.possGrace = 0;
  p.lock = { name: 'kick', t: 0, dur: 0.34 };
  if (typeof kickSnd === 'function') kickSnd();
}

function shootGap(p) {
  ball.passRoute = null;
  const gp = goalGapPoint(p.team);
  const to = new THREE.Vector3(gp.x - ball.pos.x, 0, gp.z - ball.pos.z);
  if (Math.sign(to.z) !== Math.sign(p.team.dir)) {
    to.z = p.team.dir * Math.max(0.1, Math.abs(to.z)); 
  }
  const d = to.length() || 1;
  to.multiplyScalar(1 / d);
  
  ball.owner = null;
  ball.vel.set(to.x * BIG_KICK, 4, to.z * BIG_KICK);
  ball.lastTouch = p.team;
  p.kickCd = 0.45; p.possGrace = 0;
  p.facing = Math.atan2(to.x, to.z);
  p.lock = { name: 'kick', t: 0, dur: 0.34 };
  if (typeof shootSnd === 'function') shootSnd();
}

function goalGapPoint(team) {
  const gk = otherTeam(team).players.find(o => o.role === 'GK' && !o.sentOff);
  const gkx = gk ? gk.pos.x : 0;
  const side = gkx >= 0 ? -1 : 1;
  return new THREE.Vector3(side * (GOAL_HALF - 1.0), 1.2, team.attackZ);
}

function passKick(p, tgt) {
  const to = new THREE.Vector3(tgt.pos.x - ball.pos.x, 0, tgt.pos.z - ball.pos.z);
  const dist = to.length() || 1;
  to.multiplyScalar(1 / dist);
  const power = clamp(Math.sqrt(2 * FRIC * dist) * 1.08, 13, 34);
  const lift = dist > 22 ? 1.2 : 0.6;
  
  ball.owner = null;
  ball.vel.set(to.x * power, lift, to.z * power);
  ball.lastTouch = p.team;
  p.kickCd = 0.4; p.possGrace = 0;
  p.facing = Math.atan2(to.x, to.z);
  p.lock = { name: 'kick', t: 0, dur: 0.3 };
  if (typeof kickSnd === 'function') kickSnd();
}

function pathClear(a, b, team) {
  for (let t = 0; t < 0.95; t += 0.1) {
    const x = a.x + (b.x - a.x) * t, z = a.z + (b.z - a.z) * t;
    for (const o of otherTeam(team).players) {
      if (o.sentOff) continue;
      if (Math.hypot(o.pos.x - x, o.pos.z - z) < 2.3) return false;
    }
  }
  return true;
}

function aiKick(p, fv, power, lift) {
  ball.owner = null;
  ball.vel.set(fv.x * power, lift, fv.z * power);
  ball.lastTouch = p.team;
  p.kickCd = 0.4; p.possGrace = 0;
  p.lock = { name: 'kick', t: 0, dur: 0.3 };
}

function opennessOf(team, pos) {
  let m = 99;
  otherTeam(team).players.forEach(o => {
    if (o.sentOff) return;
    const d = Math.hypot(o.pos.x - pos.x, o.pos.z - pos.z);
    if (d < m) m = d;
  });
  return m;
}

function determineAiState(p, team, opp, isOwner, attacking) {
  if (p.role === 'GK') return 'GK_PROCESS';
  if (isOwner) return 'BALL_OWNER';
  if (ball.passRoute && ball.passRoute[0] === p) return 'RECEIVER';
  if (!attacking && p === team.presser) return 'PRESS';
  if (attacking) return 'SUPPORT';
  return 'DEFEND';
}

function updateAI(dt) {
  const len = typeof HALF_LEN !== 'undefined' ? HALF_LEN : 90;
  const timeRemaining = len - G.halfElapsed;
  
  G.teams.forEach(t => {
    // プレイヤーが手動で戦術を変更していない場合のみ、自動切り替えを行う
    if (!t.manualTactic) {
      t.tactic = 'NORMAL';
      if (G.half === 2 && timeRemaining < 45) { 
        const diff = t.score - otherTeam(t).score;
        if (diff < 0) t.tactic = 'ATTACK'; 
        else if (diff > 0) t.tactic = 'DEFEND';
      }
    }
  });

  const owner = ball.owner, ownerTeam = owner ? owner.team : null;
  
  G.teams.forEach(team => {
    let pr = null, pd = 1e9;
    team.players.forEach(p => {
      if (p.sentOff || p.role === 'GK') return;
      const d = Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z);
      if (d < pd) { pd = d; pr = p; }
    });
    team.presser = pr;
  });

  allPlayers().forEach(p => {
    if (p.isControlled || p.sentOff) return;
    if (p.slide) { tickSlide(p, dt); return; }

    // スタン判定等でロックされているAIは移動スキップ
    if (p.lock) {
      if (p.lock.name === 'stun' || p.lock.name === 'lift') {
        p.vel.set(0, 0, 0);
        return;
      }
    }

    if (p.autoPassTgt) { p.vel.set(0, 0, 0); return; }

    p.decisionCd -= dt;
    const team = p.team, opp = otherTeam(team), sign = team.dir;
    const ownGoalZ = team.defendZ, isOwner = (ball.owner === p);
    const attacking = (ownerTeam === team);
    
    p.aiState = determineAiState(p, team, opp, isOwner, attacking);
    
    let target = null;
    let sp = AI_SPD;

    switch (p.aiState) {
      case 'GK_PROCESS':
        const defSign = Math.sign(ownGoalZ);
        if (isOwner) {
          p.vel.set(0, 0, 0);
          if (p.decisionCd <= 0) {
            p.decisionCd = 0.5;
            let route = calculatePassRoute(p, false);
            if (route && route.length > 0) {
              ball.passRoute = [...route];
              passKick(p, route[0]);
            } else {
              aiKick(p, new THREE.Vector3((Math.random() - 0.5) * 0.4, 0, sign).normalize(), BIG_KICK * 0.95, 4);
            }
            if (typeof gkKickSnd === 'function') gkKickSnd();
          }
          return;
        }
        
        const towardsGoal = (ball.vel.z * defSign) > 8;
        if (towardsGoal && !p.diving && !ball.owner) {
          const timeToGoal = (ownGoalZ - ball.pos.z) / ball.vel.z;
          if (timeToGoal > 0 && timeToGoal < 3.0) {
            const targetX = ball.pos.x + ball.vel.x * timeToGoal;
            if (Math.abs(targetX) <= GOAL_HALF + 1.5) {
              const dist = Math.abs(ball.pos.z - ownGoalZ);
              const difLvlGK = p.team.isHuman ? 2 : (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2);
              
              let goalProb = 1.0;
              if (dist > HALF_Z) goalProb = 0.05;
              else if (dist > HALF_Z * 0.6) goalProb = 0.15;
              else if (dist > HALF_Z * 0.3) goalProb = 0.30;
              else goalProb = 1.0;

              if (difLvlGK === 0) goalProb = Math.min(1.0, goalProb + 0.3);
              else if (difLvlGK === 1) goalProb = Math.min(1.0, goalProb + 0.15);

              p.diving = true;
              p.diveTargetX = targetX;
              p.diveSuccess = Math.random() >= goalProb;
            }
          }
        }

        if (p.diving) {
          if (ball.owner || (ball.vel.z * defSign) <= 0 || Math.abs(ball.pos.z - ownGoalZ) > HALF_Z * 1.5) {
            p.diving = false;
          } else {
            if (p.diveSuccess) {
              const currentTimeToGoal = (ownGoalZ - ball.pos.z) / ball.vel.z;
              const dx = p.diveTargetX - p.pos.x;
              sp = currentTimeToGoal > 0 ? Math.min(50, Math.abs(dx) / Math.max(0.05, currentTimeToGoal)) : 50;
              target = new THREE.Vector3(p.diveTargetX, 0, ownGoalZ - defSign * 0.8);

              if (Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z) < 3.5 && ball.pos.y < GOAL_H + 0.5) {
                ball.owner = p; p.possGrace = 1.0; ball.lastTouch = team;
                p.diving = false; p.decisionCd = 0.6;
                p.lock = { name: 'slide', t: 0, dur: 0.6 };
                if (typeof gkCatchSnd === 'function') gkCatchSnd();
              }
            } else {
              sp = GK_SPD * 0.2;
              target = new THREE.Vector3(p.pos.x + Math.sign(p.diveTargetX - p.pos.x) * 0.5, 0, ownGoalZ - defSign * 0.8);
            }
          }
        } else {
          target = new THREE.Vector3(clamp(ball.pos.x * 0.5, -GOAL_HALF - 1.0, GOAL_HALF + 1.0), 0, ownGoalZ - defSign * 1.5);
          sp = GK_SPD;
        }
        break;

      case 'BALL_OWNER':
        if (p.decisionCd <= 0) {
          p.decisionCd = 0.22;
          const distGoal = Math.abs(team.attackZ - p.pos.z);
          const pressed = opp.players.some(o => !o.sentOff && Math.hypot(o.pos.x - p.pos.x, o.pos.z - p.pos.z) < 2.5);
          const clear = pathClear(ball.pos, new THREE.Vector3(0, 0, team.attackZ), team);
          
          if (team.tactic === 'DEFEND' && distGoal > HALF_Z * 1.0 && pressed) {
            aiKick(p, new THREE.Vector3((Math.random() - 0.5) * 0.4, 0, sign).normalize(), BIG_KICK, 4);
            return;
          }

          if (distGoal < HALF_Z * 0.42 && clear) {
            const gp = goalGapPoint(team);
            const to = new THREE.Vector3(gp.x - ball.pos.x, 0, gp.z - ball.pos.z).normalize();
            aiKick(p, to, BIG_KICK, 4);
            if (typeof shootSnd === 'function') shootSnd();
            return;
          }
          
          let route = calculatePassRoute(p, false);
          if (route && route.length > 0) {
            const tgt = route[0];
            const adv = (tgt.pos.z - p.pos.z) * sign, open = opennessOf(team, tgt.pos);
            if (pressed || ((adv > 6 || distGoal < HALF_Z * 0.6) && open > 4 && Math.random() < 0.4)) {
              ball.passRoute = [...route];
              passKick(p, tgt);
              return;
            }
          }
        }
        let tx = clamp(p.pos.x * 0.88 + (Math.random() - 0.5) * 1.0, -HALF_X + 5, HALF_X - 5);
        const dA = opp.players.find(o => !o.sentOff && (o.pos.z - p.pos.z) * sign > 0 && (o.pos.z - p.pos.z) * sign < 4 && Math.abs(o.pos.x - p.pos.x) < 2.2);
        if (dA) tx = clamp(p.pos.x + (p.pos.x >= dA.pos.x ? 2.5 : -2.5), -HALF_X + 5, HALF_X - 5);
        target = new THREE.Vector3(tx, 0, team.attackZ);
        sp = AI_DRIB;
        break;

      case 'RECEIVER':
        target = ball.pos.clone();
        sp = AI_SPD + 3.0; 
        break;

      case 'PRESS':
        target = ball.pos.clone();
        const difLvlPress = team.isHuman ? 2 : (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2);
        const spMultPress = difLvlPress === 0 ? 0.8 : difLvlPress === 1 ? 0.9 : 1.0;
        sp = (AI_SPD + 2.5) * spMultPress; 
        const dToBall = Math.hypot(ball.pos.x - p.pos.x, ball.pos.z - p.pos.z);
        
        const slideR = difLvlPress === 0 ? 0.0 : difLvlPress === 1 ? SLIDE_RANGE * 0.7 : SLIDE_RANGE;
        if (ball.owner && dToBall < slideR && (p.slideCd || 0) <= 0) {
            startSlide(p);
        }
        break;

      case 'SUPPORT':
      case 'DEFEND':
        const role = p.role;
        const baseAdv = (p.homePos.z - ownGoalZ) * sign, ballAdv = (ball.pos.z - ownGoalZ) * sign;
        let push, follow, lo, hi;

        if (role === 'DEF') {
          push = attacking ? HALF_Z * 0.40 : -HALF_Z * 0.20; 
          follow = 0.12; lo = HALF_Z * 0.05; hi = HALF_Z * 1.20;
        } else if (role === 'MID') {
          push = attacking ? HALF_Z * 0.60 : -HALF_Z * 0.10; 
          follow = 0.30; lo = HALF_Z * 0.20; hi = HALF_Z * 1.62;
        } else {
          push = attacking ? HALF_Z * 0.80 : -HALF_Z * 0.05; 
          follow = 0.42; lo = HALF_Z * 0.40; hi = HALF_Z * 1.96;
        }
        
        let tacticPush = 0;
        if (team.tactic === 'ATTACK') {
          tacticPush = attacking ? HALF_Z * 0.35 : HALF_Z * 0.15; 
          hi = HALF_Z * 1.9; 
        } else if (team.tactic === 'DEFEND') {
          tacticPush = attacking ? -HALF_Z * 0.15 : -HALF_Z * 0.4; 
          lo = HALF_Z * 0.05; 
        }

        let threatPull = 0;
        if (!attacking) {
          const intrusion = (HALF_Z * 0.95 - ballAdv) / (HALF_Z * 0.95); 
          if (intrusion > 0) {
            threatPull = HALF_Z * 0.5 * intrusion;
            lo = Math.min(lo, HALF_Z * 0.05); 
          }
        }

        let adv = clamp(baseAdv + push + tacticPush + (ballAdv - baseAdv) * follow - threatPull, lo, hi);
        let tz = ownGoalZ + sign * adv;
        let sx = clamp(p.homePos.x * 0.72 + ball.pos.x * 0.28, -HALF_X + 5, HALF_X - 5);

        if (p.aiState === 'SUPPORT' && ballAdv > HALF_Z * 0.3) {
          if (role === 'FWD') {
            const leadZ = ball.pos.z + sign * 14;
            tz = clamp(leadZ, ownGoalZ + sign * HALF_Z * 1.25, ownGoalZ + sign * HALF_Z * 1.82);
            const side = p.homePos.x > 0 ? 1 : -1;
            sx = clamp(ball.pos.x * 0.3 + side * (GOAL_HALF + 4), -HALF_X + 6, HALF_X - 6);
          } else if (role === 'MID') {
            let openSpace = sx;
            const enemyInWay = opp.players.some(o => !o.sentOff && Math.abs(o.pos.z - tz) < 4 && Math.abs(o.pos.x - sx) < 5);
            if (enemyInWay) { openSpace = sx > 0 ? sx - 8 : sx + 8; }
            sx = clamp(openSpace, -HALF_X + 3, HALF_X - 3);
            tz = ownGoalZ + sign * HALF_Z * 1.1;
          }
        } else if (p.aiState === 'DEFEND') {
          if (team.tactic === 'DEFEND') {
            tz -= sign * HALF_Z * 0.2;
          }
          if (ballAdv < HALF_Z * 1.2 && role === 'DEF') {
            tz = ownGoalZ + sign * 6;
            const side = p.homePos.x > 0 ? 1 : -1;
            sx = clamp(ball.pos.x * 0.6 + side * GOAL_HALF, -GOAL_HALF - 5, GOAL_HALF + 5);
          } else {
            let mk = null, mdd = 1e9;
            opp.players.forEach(o => {
              if (o.sentOff || o.role === 'GK' || o === owner) return;
              if (Math.abs(o.pos.x - p.homePos.x) > HALF_X * 0.55) return;
              const d = Math.hypot(o.pos.x - p.pos.x, o.pos.z - p.pos.z);
              if (d < mdd) { mdd = d; mk = o; }
            });
            if (mk) {
              const difLvlMk = team.isHuman ? 2 : (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2);
              const t = difLvlMk === 0 ? 0.08 : difLvlMk === 1 ? 0.22 : 0.35; 
              sx = clamp(mk.pos.x, -HALF_X + 5, HALF_X - 5);
              tz = clamp(mk.pos.z + (ownGoalZ - mk.pos.z) * t, ownGoalZ + sign * lo, ownGoalZ + sign * hi);
            }
          }
        }
        target = new THREE.Vector3(sx, 0, tz);
        break;
    }

    let moving = false;
    if (target) {
      const difLvlSpd = p.team.isHuman ? 2 : (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2);
      const spMult = difLvlSpd === 0 ? 0.75 : difLvlSpd === 1 ? 0.88 : 1.0;
      const adjustedSp = sp * spMult;

      const to = new THREE.Vector3(target.x - p.pos.x, 0, target.z - p.pos.z), d = to.length();
      if (d > 0.6) {
        to.multiplyScalar(1 / d);
        const m = Math.min(adjustedSp, d * 3.2);
        p.pos.x += to.x * m * dt; p.pos.z += to.z * m * dt;
        p.facing = Math.atan2(to.x, to.z);
        p.vel.set(to.x * m, 0, to.z * m);
        moving = true;
      }
    }
    if (!moving) p.vel.set(0, 0, 0);
    clampPlayer(p);
  });
}

function checkGoalCollisions() {
  const bx = ball.pos.x, by = ball.pos.y, bz = ball.pos.z;
  const sign = Math.sign(bz), absZ = Math.abs(bz);
  
  if (absZ > HALF_Z - BALLR && absZ < HALF_Z + GOAL_DEPTH + BALLR) {
    if (Math.abs(Math.abs(bx) - GOAL_HALF) < 0.25 && by < GOAL_H) {
      ball.vel.x *= -REST; ball.vel.z *= -REST;
      ball.pos.x = Math.sign(bx) * (GOAL_HALF + (bx > 0 ? BALLR : -BALLR));
    } else if (Math.abs(bx) <= GOAL_HALF && Math.abs(by - GOAL_H) < 0.25 && absZ < HALF_Z + 0.22) {
      ball.vel.y *= -REST; ball.vel.z *= -REST;
      ball.pos.y = GOAL_H + BALLR;
    } else if (Math.abs(bx) <= GOAL_HALF && absZ >= HALF_Z + GOAL_DEPTH - BALLR && by < GOAL_H) {
      ball.vel.z *= -0.2;
      ball.pos.z = sign * (HALF_Z + GOAL_DEPTH - BALLR);
    }
  }
}

function tryDeflect() {
  if (ball.owner) return false;
  const s = Math.hypot(ball.vel.x, ball.vel.z);
  if (s < 6) return false;
  for (const p of allPlayers()) {
    if (p.sentOff || p.role === 'GK') continue;
    if (p.team === ball.lastTouch) continue;
    if (ball.passRoute && ball.passRoute[0] === p) continue;
    if (p.kickCd > 0 || (p.lock && (p.lock.name === 'lift' || p.lock.name === 'stun'))) continue;
    
    const difLvl = p.team.isHuman ? 2 : (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2);
    const deflectR = difLvl === 0 ? 0.25 : difLvl === 1 ? 0.55 : 0.85;

    const d = Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z);
    if (d < deflectR && ball.pos.y < 1.7) {
      const nx = (ball.pos.x - p.pos.x) / (d || 1), nz = (ball.pos.z - p.pos.z) / (d || 1);
      const dot = ball.vel.x * nx + ball.vel.z * nz;
      if (dot < 0) { ball.vel.x -= 2 * dot * nx; ball.vel.z -= 2 * dot * nz; }
      ball.vel.x *= 0.45; ball.vel.z *= 0.45;
      ball.vel.y = Math.max(ball.vel.y, 1.6);
      ball.lastTouch = p.team;
      ball.passRoute = null;
      if (typeof kickSnd === 'function') kickSnd();
      return true;
    }
  }
  return false;
}

function tryPickup() {
  if (ball.owner) return;
  const s = Math.hypot(ball.vel.x, ball.vel.z);
  let best = null, bd = PICK_R;
  allPlayers().forEach(p => {
    if (p.sentOff || p.kickCd > 0 || (p.lock && (p.lock.name === 'lift' || p.lock.name === 'stun'))) return;
    
    if (ball.passRoute && ball.passRoute.length > 0) {
      if (p.team === ball.lastTouch && ball.passRoute[0] !== p) return; 
    }

    const d = Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z);
    const isPassTarget = ball.passRoute && ball.passRoute[0] === p;
    if (d < bd && (s < PICK_MAXSPEED || p.role === 'GK' || isPassTarget) && ball.pos.y < 1.2) { bd = d; best = p; }
  });
  
  if (best) {
    ball.owner = best; best.possGrace = GRACE; ball.lastTouch = best.team;
    
    if (ball.passRoute && ball.passRoute.length > 0) {
      if (ball.passRoute[0] === best) {
        ball.passRoute.shift();
        if (ball.passRoute.length > 0) {
          best.autoPassTgt = ball.passRoute[0];
          best.autoPassDelay = 0.15;
        } else {
          ball.passRoute = null;
        }
      } else if (best.team !== ball.lastTouch) {
        ball.passRoute = null;
      }
    }
  }
}

function ballUpdate(dt) {
  if (ball.owner && !ball.owner.sentOff) {
    const p = ball.owner, f = faceVec(p.facing);
    ball.pos.set(p.pos.x + f.x * LEAD, BALLR, p.pos.z + f.z * LEAD);
    ball.vel.set(0, 0, 0); ball.lastTouch = p.team;
    if (p.possGrace <= 0) {
      let st = null, sd = STEAL_R;
      otherTeam(p.team).players.forEach(o => {
        if (o.sentOff) return;
        const d = Math.hypot(o.pos.x - ball.pos.x, o.pos.z - ball.pos.z);
        if (d < sd) { sd = d; st = o; }
      });
      if (st) {
        const difLvlSteal = st.team.isHuman ? 2 : (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2);
        const stealMult = difLvlSteal === 0 ? 0.15 : difLvlSteal === 1 ? 0.5 : 1.0;
        const ch = (st.isControlled ? STEAL_HUMAN : STEAL_AI * stealMult) * dt;
        if (Math.random() < ch) {
          ball.owner = st; st.possGrace = GRACE; ball.lastTouch = st.team; ball.passRoute = null;
          if (typeof stealSnd === 'function') stealSnd();
        }
      }
    }
  } else {
    if (ball.owner && ball.owner.sentOff) ball.owner = null;
    ball.vel.y -= GRAV * dt;
    ball.pos.addScaledVector(ball.vel, dt);
    
    if (ball.pos.y <= BALLR) {
      ball.pos.y = BALLR;
      if (ball.vel.y < 0) ball.vel.y = -ball.vel.y * REST;
      if (Math.abs(ball.vel.y) < 0.8) ball.vel.y = 0;
      const s = Math.hypot(ball.vel.x, ball.vel.z);
      if (s > 0) {
        const ns = Math.max(0, s - FRIC * dt), k = ns / s;
        ball.vel.x *= k; ball.vel.z *= k;
        if (ns < STOP) { ball.vel.x = 0; ball.vel.z = 0; }
      }
    }
    checkGoalCollisions();
    const handled = checkBounds();
    if (!handled) { if (!tryDeflect()) tryPickup(); }
  }
}

function checkBounds() {
  const bx = ball.pos.x, bz = ball.pos.z;
  if (bz < -HALF_Z) {
    if (Math.abs(bx) < GOAL_HALF - 0.22 && ball.pos.y < GOAL_H) doGoal(otherTeam(teamDefending(-1)));
    else goalLineOut(-1, bx);
    return true;
  }
  if (bz > HALF_Z) {
    if (Math.abs(bx) < GOAL_HALF - 0.22 && ball.pos.y < GOAL_H) doGoal(otherTeam(teamDefending(1)));
    else goalLineOut(1, bx);
    return true;
  }
  if (bx < -HALF_X || bx > HALF_X) { throwIn(bx, bz); return true; }
  return false;
}

function goalLineOut(sign, bx) {
  const att = teamAttacking(sign), def = otherTeam(att);
  if (ball.lastTouch === att) placeRestart(def, clamp(bx * 0.3, -8, 8), sign * (HALF_Z - 5), 'goalkick');
  else { const cx = bx >= 0 ? HALF_X - 1.2 : -(HALF_X - 1.2); placeRestart(att, cx, sign * (HALF_Z - 1.2), 'corner'); }
}
function throwIn(bx, bz) {
  const team = otherTeam(ball.lastTouch || G.home);
  placeRestart(team, bx < 0 ? -(HALF_X - 1.2) : (HALF_X - 1.2), clamp(bz, -HALF_Z + 3, HALF_Z - 3), 'throw');
}
function placeRestart(team, x, z, kind) {
  ball.passRoute = null;
  allPlayers().forEach(pl => { pl.autoPassTgt = null; pl.autoPassDelay = 0; });
  ball.vel.set(0, 0, 0); ball.pos.set(x, BALLR, z);
  let taker = kind === 'goalkick' ? (team.players.find(p => p.role === 'GK' && !p.sentOff) || nearestTeamPlayer(team, ball.pos)) : nearestTeamPlayer(team, ball.pos);
  if (!taker) return;
  const fa = Math.atan2(-x, team.attackZ - z), fv = faceVec(fa);
  taker.facing = fa; taker.slide = null; taker.lock = null;
  taker.pos.set(clamp(x - fv.x * LEAD, -HALF_X + 1, HALF_X - 1), 0, clamp(z - fv.z * LEAD, -HALF_Z + 1, HALF_Z - 1));
  ball.owner = taker; taker.possGrace = 0.9; taker.kickCd = 0.3; ball.lastTouch = team;
  if (typeof restartSnd === 'function') restartSnd(kind);
}
function doGoal(team) {
  if (G.state !== 'play') return;
  team.score++; ball.owner = null; ball.vel.set(0, 0, 0); ball.pos.set(0, BALLR, 0);
  ball.passRoute = null;
  G.state = 'celebrate'; G.celebr = CELEBR; G.kickNext = otherTeam(team);
  crowdBounce = CELEBR; burstConfetti();
}

function collisions(dt) {
  const ps = allPlayers().filter(p => !p.sentOff);
  for (let i = 0; i < ps.length; i++) {
    for (let j = i + 1; j < ps.length; j++) {
      const a = ps[i], b = ps[j], dx = b.pos.x - a.pos.x, dz = b.pos.z - a.pos.z, d = Math.hypot(dx, dz);
      if (d < SEP && d > 0.0001) {
        const push = (SEP - d) / 2, nx = dx / d, nz = dz / d;
        a.pos.x -= nx * push; a.pos.z -= nz * push;
        b.pos.x += nx * push; b.pos.z += nz * push;
      }
    }
  }
  humans.forEach(h => {
    const p = h.controlled;
    if (!p || p.sentOff || p.cardCd > 0 || p.vel.lengthSq() < 1) return;
    otherTeam(p.team).players.forEach(o => {
      if (o.sentOff) return;
      const dx = o.pos.x - p.pos.x, dz = o.pos.z - p.pos.z, d = Math.hypot(dx, dz);
      if (d < CARD_DIST) {
        const dot = p.vel.x * dx + p.vel.z * dz;
        if (dot > 0 || ball.owner === o) foul(p, o);
      }
    });
  });
}

function foul(p, victim) {
  p.cardCd = CARD_CD;
  giveCard(p);
  p.slide = null; ball.passRoute = null;
  const dx = p.pos.x - victim.pos.x, dz = p.pos.z - victim.pos.z, d = Math.hypot(dx, dz) || 1;
  p.pos.x += dx / d * 0.5; p.pos.z += dz / d * 0.5;
  ball.owner = null; ball.vel.set(0, 0, 0); ball.pos.set(victim.pos.x, BALLR, victim.pos.z);
  ball.owner = victim; victim.possGrace = 0.9; ball.lastTouch = victim.team;
}
function giveCard(p) {
  p.yellow++;
  if (p.yellow >= 3) { p.sentOff = true; p.root.visible = false; p.shadow.visible = false; if (ball.owner === p) ball.owner = null; }
}

main.js

"use strict";
/* =====================================================================
   main.js — 入力、HUD更新、メインループ、トーナメント管理
   ===================================================================== */

let ac = null;
function audioInit() { if (ac) return; try { ac = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {} }
function tone(f, dur, type, vol, slide) {
  if (!ac) return; if (ac.state === 'suspended') ac.resume();
  const o = ac.createOscillator(), g = ac.createGain();
  o.type = type || 'sine'; o.frequency.value = f;
  if (slide) o.frequency.exponentialRampToValueAtTime(slide, ac.currentTime + dur);
  o.connect(g); g.connect(ac.destination);
  g.gain.setValueAtTime(0.0001, ac.currentTime);
  g.gain.exponentialRampToValueAtTime(vol || 0.2, ac.currentTime + 0.01);
  g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + dur);
  o.start(); o.stop(ac.currentTime + dur + 0.02);
}
function whistle() { tone(2100, 0.16, 'square', 0.1, 2300); setTimeout(() => tone(2300, 0.12, 'square', 0.09, 2000), 110); }
function kickSnd() { tone(150, 0.1, 'square', 0.22, 60); }
function shootSnd() { tone(120, 0.13, 'square', 0.3, 46); setTimeout(() => tone(90, 0.1, 'sine', 0.18, 60), 12); }
function gkKickSnd() { tone(110, 0.14, 'square', 0.26, 50); }
function stealSnd() { tone(540, 0.07, 'square', 0.2, 360); setTimeout(() => tone(780, 0.06, 'square', 0.16, 520), 45); }
function gkCatchSnd() { tone(190, 0.12, 'sine', 0.24, 110); }
function refWhistle() { tone(2050, 0.13, 'square', 0.09, 2250); }
function feintSnd() { tone(800, 0.15, 'sawtooth', 0.2, 1200); } // フェイント時のシュパッ!という音

function slideSnd() {
  if (!ac) return; if (ac.state === 'suspended') ac.resume();
  const b = ac.createBuffer(1, ac.sampleRate * 0.26, ac.sampleRate), d = b.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 2) * 0.5;
  const s = ac.createBufferSource(); s.buffer = b;
  const f = ac.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 1300;
  const g = ac.createGain(); g.gain.value = 0.32;
  s.connect(f); f.connect(g); g.connect(ac.destination); s.start();
}
function restartSnd(kind) { if (kind === 'throw') tone(880, 0.05, 'sine', 0.12); else refWhistle(); }
function cheer() {
  if (!ac) return; if (ac.state === 'suspended') ac.resume();
  const b = ac.createBuffer(1, ac.sampleRate * 1.2, ac.sampleRate), d = b.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.4) * 0.45;
  const s = ac.createBufferSource(); s.buffer = b;
  const f = ac.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 900;
  const g = ac.createGain(); g.gain.value = 0.5;
  s.connect(f); f.connect(g); g.connect(ac.destination); s.start();
  for (let k = 0; k < 6; k++) setTimeout(() => tone(400 + Math.random() * 500, 0.4, 'triangle', 0.05, 300 + Math.random() * 400), k * 60);
}

function buildDottedArrow(colorHex) {
  const mat = new THREE.MeshBasicMaterial({ color: colorHex, transparent: true, opacity: 0.95 });
  const dashes = [];
  for (let i = 0; i < 60; i++) {
    const d = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.03, 0.4), mat);
    d.visible = false; scene.add(d); dashes.push(d);
  }
  const head = new THREE.Group();
  const cone = new THREE.Mesh(new THREE.ConeGeometry(0.34, 0.7, 10), mat);
  cone.rotation.x = Math.PI / 2; head.add(cone); head.visible = false; scene.add(head);
  return { dashes, head, mat };
}

function updateArrow(arr, ax, az, bx, bz) {
  const dx = bx - ax, dz = bz - az, dist = Math.hypot(dx, dz);
  if (dist < 1.0) { hideArrow(arr); return; }
  const ang = Math.atan2(dx, dz), ux = dx / dist, uz = dz / dist, headLen = 0.8, spacing = 0.95;
  let n = Math.floor((dist - headLen) / spacing);
  if (n > arr.dashes.length) n = arr.dashes.length; if (n < 0) n = 0;
  for (let i = 0; i < arr.dashes.length; i++) {
    const d = arr.dashes[i];
    if (i < n) { const t = (i + 0.5) * spacing; d.position.set(ax + ux * t, 0.08, az + uz * t); d.rotation.y = ang; d.visible = true; }
    else d.visible = false;
  }
  arr.head.position.set(ax + ux * (dist - headLen * 0.5), 0.1, az + uz * (dist - headLen * 0.5));
  arr.head.rotation.y = ang; arr.head.visible = true;
}

function hideArrow(arr) { arr.dashes.forEach(d => d.visible = false); arr.head.visible = false; }

function buildAim(teamColor) {
  const sel = new THREE.Mesh(new THREE.RingGeometry(0.66, 0.95, 28), new THREE.MeshBasicMaterial({ color: teamColor, transparent: true, opacity: 0.9, side: THREE.DoubleSide }));
  sel.rotation.x = -Math.PI / 2; sel.visible = false; scene.add(sel);
  const markerMat = new THREE.MeshBasicMaterial({ color: teamColor, transparent: true, opacity: 0.95 });
  const marker = new THREE.Mesh(new THREE.ConeGeometry(0.4, 0.9, 4), markerMat);
  marker.rotation.x = Math.PI; marker.rotation.y = Math.PI / 4; marker.visible = false; scene.add(marker);
  return { sel, marker, whites: [buildDottedArrow(0xffffff), buildDottedArrow(0xffffff), buildDottedArrow(0xffffff)], yellow: buildDottedArrow(0xf2c84b) };
}

function clearMatch() {
  if (G.home) {
    allPlayers().forEach(p => { scene.remove(p.root); scene.remove(p.shadow); });
    G.teams.forEach(t => { if (t.bench) t.bench.forEach(r => scene.remove(r)); });
  }
  humans.forEach(h => {
    if (h.aim) {
      scene.remove(h.aim.sel); scene.remove(h.aim.marker);
      h.aim.whites.forEach(w => { w.dashes.forEach(d => scene.remove(d)); scene.remove(w.head); });
      h.aim.yellow.dashes.forEach(d => scene.remove(d)); scene.remove(h.aim.yellow.head);
    }
  });
  humans = []; G.home = null; G.away = null; G.teams = [];
}

function createPlayers(team) {
  FORM.forEach(slot => {
    const v = makePlayer(team.color, slot.role); scene.add(v.root);
    team.players.push({
      root: v.root, parts: v.parts, team, role: slot.role, pos: new THREE.Vector3(), homePos: new THREE.Vector3(),
      facing: 0, vel: new THREE.Vector3(), lock: null, animT: Math.random() * 6, yellow: 0, sentOff: false,
      kickCd: 0, possGrace: 0, cardCd: 0, slideCd: 0, slide: null, decisionCd: 0, isControlled: false, shadow: blobShadow(0.5),
      autoPassTgt: null, autoPassDelay: 0, feintCd: 0, feintBoostT: 0
    });
  });
}
function buildBench(team) { team.bench = []; for (let i = 0; i < 4; i++) { const v = makePlayer(team.color, 'F'); scene.add(v.root); team.bench.push(v.root); } }
function positionBenches() {
  G.teams.forEach(team => {
    const sign = Math.sign(team.defendZ);
    team.bench.forEach((r, i) => { const sideX = (i % 2 === 0 ? -1 : 1) * (HALF_X + 4.5); const k = Math.floor(i / 2); r.position.set(sideX, FOOT * PLAYER_SY, sign * (10 + k * 14)); r.rotation.y = sideX < 0 ? Math.PI / 2 : -Math.PI / 2; });
  });
}
function kickoff(team) {
  placeFormation(G.home); placeFormation(G.away); positionBenches();
  humans.forEach(h => { h.defView = false; h.flipT = 0; h.camPhi = undefined; h.stamina = 1; h.dashT = 0; h.currentPassRoute = null; h.feintFired = false; });
  const f = team.players.find(p => p.role === 'FWD') || team.players[team.players.length - 1];
  f.facing = Math.atan2(0, team.attackZ); const fv = faceVec(f.facing);
  f.pos.set(-fv.x * LEAD, 0, -fv.z * LEAD);
  ball.pos.set(0, BALLR, 0); ball.vel.set(0, 0, 0); ball.owner = f; f.possGrace = 0.7; f.kickCd = 0; f.slide = null; ball.lastTouch = team; ball.passRoute = null;
  whistle(); updateHUD(); updateTacticsUI();
}

function getRandomNation(exclude) {
  let pool = MULTIVERSE_NATIONS.filter(n => !exclude || n.name !== exclude.name);
  return pool[Math.floor(Math.random() * pool.length)];
}

function newMatch() {
  clearMatch();
  const hc = G.colorChoice === 'random' ? (Math.random() < 0.5 ? 'red' : 'blue') : G.colorChoice;
  const homeColor = hc === 'red' ? RED : BLUE, awayColor = hc === 'red' ? BLUE : RED;
  
  let hNat = getRandomNation();
  let aNat = getRandomNation(hNat);

  const home = makeTeam(homeColor, hNat.name, true);
  const away = makeTeam(awayColor, aNat.name, G.mode === 'multi');
  G.home = home; G.away = away; G.teams = [home, away];
  home.defendZ = HALF_Z; home.attackZ = -HALF_Z; home.dir = -1;
  away.defendZ = -HALF_Z; away.attackZ = HALF_Z; away.dir = 1;
  createPlayers(home); createPlayers(away); buildBench(home); buildBench(away);
  placeFormation(home); placeFormation(away); positionBenches();
  
  humans = [{ team: home, scheme: 'P1', cam: cam1, controlled: null, aim: buildAim(home.color), stamina: 1, currentPassRoute: null }];
  if (G.mode === 'multi') humans.push({ team: away, scheme: 'P2', cam: cam2, controlled: null, aim: buildAim(away.color), stamina: 1, currentPassRoute: null });
  
  G.half = 1; G.halfElapsed = 0; G.firstKicker = home; G.started = true; G.state = 'play';
  audioInit(); setHelp(); showScreen('scrTitle', false); $('hud').classList.add('on');
  kickoff(home);
}

// 【トーナメント関連】
function startTourneyMatch() {
  clearMatch();
  const pNat = G.tourney.playerNation;
  const eNat = G.tourney.enemies[G.tourney.round];
  
  // トーナメントモードでは自国カラーを固定(UIのRed/Blue指定があれば反映、Randomなら国のテーマカラー)
  const hc = G.colorChoice === 'random' ? pNat.color : (G.colorChoice === 'red' ? RED : BLUE);
  const home = makeTeam(hc, pNat.name, true);
  const away = makeTeam(eNat.color, eNat.name, false);
  
  G.home = home; G.away = away; G.teams = [home, away];
  home.defendZ = HALF_Z; home.attackZ = -HALF_Z; home.dir = -1;
  away.defendZ = -HALF_Z; away.attackZ = HALF_Z; away.dir = 1;
  createPlayers(home); createPlayers(away); buildBench(home); buildBench(away);
  placeFormation(home); placeFormation(away); positionBenches();
  
  humans = [{ team: home, scheme: 'P1', cam: cam1, controlled: null, aim: buildAim(home.color), stamina: 1, currentPassRoute: null }];
  
  G.half = 1; G.halfElapsed = 0; G.firstKicker = home; G.started = true; G.state = 'play';
  audioInit(); setHelp(); showScreen('scrTitle', false); $('hud').classList.add('on');
  kickoff(home);
}

function swapSides() { G.teams.forEach(t => { const d = t.defendZ; t.defendZ = t.attackZ; t.attackZ = d; t.dir = -t.dir; }); placeFormation(G.home); placeFormation(G.away); positionBenches(); }

/* ---------- 入力 ---------- */
const keys = new Set(), justPressed = new Set();
// AキーとHキーを追加(戦術切り替え)
const GAME_CODES = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyZ', 'KeyX', 'Numpad8', 'Numpad2', 'Numpad4', 'Numpad6', 'Numpad5', 'KeyN', 'KeyM', 'Enter', 'Space', 'KeyA', 'KeyH']);
window.addEventListener('keydown', e => {
  if (GAME_CODES.has(e.code)) e.preventDefault();
  if (!e.repeat) { justPressed.add(e.code); if (e.code === 'Enter') { if (G.state === 'menu') startMatch(); } }
  keys.add(e.code);
});
window.addEventListener('keyup', e => keys.delete(e.code));

function axis(scheme) {
  let up, dn, lf, rt, sm, bg, smHold, bgHold, feint = false;
  if (scheme === 'P1') {
    up = keys.has('ArrowUp'); dn = keys.has('ArrowDown'); lf = keys.has('ArrowLeft'); rt = keys.has('ArrowRight');
    sm = justPressed.has('KeyZ'); bg = justPressed.has('KeyX');
    smHold = keys.has('KeyZ'); bgHold = keys.has('KeyX');
    // フェイント判定: ZとXの同時押し(ホールド)エッジ検知
    if (smHold && bgHold) { if (!window._feintP1) { feint = true; window._feintP1 = true; } } else { window._feintP1 = false; }
  } else {
    up = keys.has('Numpad8'); dn = keys.has('Numpad2'); lf = keys.has('Numpad4'); rt = keys.has('Numpad6');
    sm = justPressed.has('KeyN'); bg = justPressed.has('KeyM');
    smHold = keys.has('KeyN'); bgHold = keys.has('KeyM');
    if (smHold && bgHold) { if (!window._feintP2) { feint = true; window._feintP2 = true; } } else { window._feintP2 = false; }
  }
  return { fwd: (up ? 1 : 0) - (dn ? 1 : 0), right: (rt ? 1 : 0) - (lf ? 1 : 0), small: sm, big: bg, smHold, bgHold, feint };
}
function camDirs(cam) { const d = new THREE.Vector3(); cam.getWorldDirection(d); d.y = 0; if (d.lengthSq() < 1e-6) d.set(0, 0, -1); d.normalize(); const r = new THREE.Vector3().crossVectors(d, UP).normalize(); return { fwd: d, right: r }; }

/* ---------- HUD / UI ---------- */
function toggleTactic(team, uiId) {
  const arr = ['NORMAL', 'ATTACK', 'DEFEND'];
  team.tactic = arr[(arr.indexOf(team.tactic) + 1) % 3];
  team.manualTactic = true;
  updateTacticsUI();
}

function updateTacticsUI() {
  if (humans[0]) {
    $('tacP1').textContent = `P1 TACTIC (A): ${humans[0].team.tactic}`;
    $('tacP1').className = 'tac-btn ' + (humans[0].team.tactic === 'ATTACK' ? 'atk' : humans[0].team.tactic === 'DEFEND' ? 'def' : '');
  }
  if (humans[1]) {
    $('tacP2').style.display = 'block';
    $('tacP2').textContent = `P2 TACTIC (H): ${humans[1].team.tactic}`;
    $('tacP2').className = 'tac-btn ' + (humans[1].team.tactic === 'ATTACK' ? 'atk' : humans[1].team.tactic === 'DEFEND' ? 'def' : '');
  } else {
    $('tacP2').style.display = 'none';
  }
}

function setHelp() {
  const k = s => `<span class="k">${s}</span>`;
  let html;
  if (G.mode === 'single' || G.mode === 'tournament') {
    html = `<h4>CONTROLS</h4>移動 ${k('←↑↓→')} パス/自動攻撃 ${k('Z')} シュート ${k('X')}<br>`
      + `<span style="opacity:.7"><b>フェイント: ZとX同時押し</b>(突破&スタン)<br>`
      + `非保持中… <b>Z/X</b>で敵へダッシュ追跡&自動スライディング(押すだけ/長押し可)<br>`
      + `戦術変更: <b>Aキー</b> または右下ボタン</span>`;
  } else {
    html = `<h4>CONTROLS</h4><span style="color:var(--red)">P1(左)</span> 移動 ${k('←↑↓→')}${k('Z')}${k('X')} 戦術 ${k('A')}<br>`
      + `<span style="color:var(--blue)">P2(右)</span> 移動 テンキー ${k('8')}${k('4')}${k('5')}${k('6')}${k('2')}${k('N')}${k('M')} 戦術 ${k('H')}<br>`
      + `<span style="opacity:.7">フェイントはZ+X(P2はN+M)同時押し。</span>`;
  }
  $('help').innerHTML = html;
}
function cardHtml(team) { let s = ''; for (let i = 0; i < team.players.length; i++) { const p = team.players[i]; for (let y = 0; y < Math.min(2, p.yellow); y++) s += '<div class="yc"></div>'; if (p.sentOff) s += '<div class="rc"></div>'; } return s; }

function updateHUD() {
  const L = G.home, R = G.away; if (!L) return;
  $('nmL').textContent = L.name; $('nmR').textContent = R.name;
  $('nmL').className = 'nm ' + (L.color === RED ? 'pillR' : 'pillB'); $('nmR').className = 'nm ' + (R.color === RED ? 'pillR' : 'pillB');
  $('scL').textContent = L.score; $('scR').textContent = R.score;
  $('cardsL').innerHTML = cardHtml(L); $('cardsR').innerHTML = cardHtml(R);
  
  const minute = Math.floor(((G.half - 1) * HALF_LEN + G.halfElapsed) / (2 * HALF_LEN) * 90);
  $('clk').textContent = minute + "'"; 
  
  let hstr = G.half === 1 ? '1ST HALF' : '2ND HALF';
  if (G.mode === 'tournament') {
     const rname = ['QUARTER FINAL', 'SEMI FINAL', 'FINAL MATCH'][G.tourney.round];
     hstr = rname + ' | ' + hstr;
  }
  $('hlf').textContent = hstr;

  let pn = '—'; if (ball.owner) pn = ball.owner.team.name + ' ' + ball.owner.role;
  $('possName').textContent = pn;
  updateStamina(); drawMinimap();
}
function updateStamina() {
  const setBar = (id, h) => { const f = $(id); if (!f) return; const s = Math.max(0, Math.min(1, h ? h.stamina : 0)); f.style.width = (s * 100) + '%'; f.classList.toggle('low', s < DASH_MIN); };
  const multi = G.mode === 'multi';
  $('stamBox1').style.display = 'flex'; $('stamBox2').style.display = multi ? 'flex' : 'none';
  $('sl1').style.color = humans[0] ? (humans[0].team.color === RED ? '#ff8d8d' : '#8db9ff') : '#fff'; setBar('sf1', humans[0]);
  if (multi) { $('sl2').style.color = humans[1] ? (humans[1].team.color === RED ? '#ff8d8d' : '#8db9ff') : '#fff'; setBar('sf2', humans[1]); }
}
function drawMinimap() {
  const cv = $('mini'); if (!cv) return; const x = cv.getContext('2d'), W = cv.width, H = cv.height;
  x.fillStyle = '#1f5a2a'; x.fillRect(0, 0, W, H);
  x.strokeStyle = 'rgba(255,255,255,.5)'; x.lineWidth = 2; x.strokeRect(4, 4, W - 8, H - 8);
  x.beginPath(); x.moveTo(4, H / 2); x.lineTo(W - 4, H / 2); x.stroke();
  const p1 = humans && humans[0];
  const flip = !!(p1 && p1.team && p1.team.dir > 0);
  const mx = v => { const t = (v + HALF_X) / (2 * HALF_X); return 8 + (flip ? 1 - t : t) * (W - 16); };
  const mz = v => { const t = (v + HALF_Z) / (2 * HALF_Z); return 8 + (flip ? 1 - t : t) * (H - 16); };
  x.fillStyle = 'rgba(255,255,255,.7)';
  const gxl = Math.min(mx(-GOAL_HALF), mx(GOAL_HALF)), gw = Math.abs(mx(GOAL_HALF) - mx(-GOAL_HALF));
  x.fillRect(gxl, H - 12, gw, 8);
  x.fillRect(gxl, 4, gw, 8);
  
  if (!G.home) return;
  const blink = (Date.now() % 400 < 200);

  allPlayers().forEach(p => {
    if (p.sentOff) return;
    if ((p.isControlled || ball.owner === p) && blink) x.fillStyle = '#ffffff';
    else x.fillStyle = '#' + p.team.color.toString(16).padStart(6, '0');
    x.beginPath(); x.arc(mx(p.pos.x), mz(p.pos.z), 5.6, 0, 6.283); x.fill();
    if (p.isControlled) { x.strokeStyle = '#fff'; x.lineWidth = 2.4; x.stroke(); }
  });
  
  x.fillStyle = (blink && !ball.owner) ? '#ffea00' : '#fff';
  x.beginPath(); x.arc(mx(ball.pos.x), mz(ball.pos.z), 5.2, 0, 6.283); x.fill();
  x.strokeStyle = '#000'; x.lineWidth = 1; x.stroke();
}
function showGoalBanner(team) {
  const c = team.color === RED ? 'var(--red)' : 'var(--blue)';
  $('bnBig').style.color = c; $('bnBig').textContent = 'GOAL!';
  $('bnSub').textContent = team.name + '  ' + G.home.score + ' - ' + G.away.score;
  $('banner').classList.add('on');
}
function hideGoalBanner() { $('banner').classList.remove('on'); }
function flashCard(red) { const fx = $('cardfx'); fx.style.background = red ? '#cc1122' : '#e8b400'; fx.classList.add('on'); setTimeout(() => fx.classList.remove('on'), 260); }
function showScreen(id, on) { $(id).classList.toggle('on', on); }
function showHalftime() { $('htScore').textContent = G.home.score + ' – ' + G.away.score; showScreen('scrHalf', true); }
function hideHalftime() { showScreen('scrHalf', false); }
function showFulltime() {
  showScreen('scrFull', true); $('ftScore').textContent = G.home.score + ' – ' + G.away.score;
  $('hud').classList.remove('on');

  if (G.mode === 'tournament') {
      if (G.home.score > G.away.score) {
          if (G.tourney.round >= 2) {
             $('ftDesc').textContent = "🏆 マルチバース制覇!世界一! 🏆";
             $('ftAgain').textContent = "タイトルへ戻る";
             $('ftAgain').onclick = () => { showScreen('scrFull', false); showScreen('scrTitle', true); };
          } else {
             $('ftDesc').textContent = "見事な勝利!次のラウンドへ進出";
             $('ftAgain').textContent = "NEXT MATCH ▶";
             $('ftAgain').onclick = () => { showScreen('scrFull', false); G.tourney.round++; startTourneyMatch(); };
          }
      } else if (G.home.score < G.away.score) {
          $('ftDesc').textContent = "敗北... 歴史の闇に飲まれた";
          $('ftAgain').textContent = "タイトルへ戻る";
          $('ftAgain').onclick = () => { showScreen('scrFull', false); showScreen('scrTitle', true); };
      } else {
          $('ftDesc').textContent = "DRAW - PK戦なしの再試合";
          $('ftAgain').textContent = "REMATCH ↺";
          $('ftAgain').onclick = () => { showScreen('scrFull', false); startTourneyMatch(); };
      }
  } else {
      let desc; 
      if (G.home.score === G.away.score) desc = 'DRAW — 引き分け'; 
      else { const w = G.home.score > G.away.score ? G.home : G.away; desc = w.name + ' の勝利!'; }
      $('ftDesc').textContent = desc; 
      $('ftAgain').textContent = "もう一度 ↺";
      $('ftAgain').onclick = () => { showScreen('scrFull', false); newMatch(); };
  }
}

/* ---------- 描画 ---------- */
function drawAim(h) {
  const A = h.aim, p = h.controlled;
  if (!p || p.sentOff) { A.sel.visible = false; A.marker.visible = false; A.whites.forEach(hideArrow); hideArrow(A.yellow); return; }
  
  A.sel.visible = true; A.sel.position.set(p.pos.x, 0.05, p.pos.z);
  
  // フェイント中はマーカーを激しく点滅させる
  const feinting = (p.feintBoostT || 0) > 0;
  
  if (ball.owner === p) {
    A.marker.visible = !feinting || (Date.now() % 100 < 50);
    const bounce = Math.sin(Date.now() / 150) * 0.2;
    A.marker.position.set(p.pos.x, 3.5 + bounce, p.pos.z);
    
    const route = calculatePassRoute(p, false);
    h.currentPassRoute = route; 
    A.whites.forEach(hideArrow);
    if (route && route.length > 0) {
       let startPt = ball.pos;
       for (let i = 0; i < route.length && i < A.whites.length; i++) {
           updateArrow(A.whites[i], startPt.x, startPt.z, route[i].pos.x, route[i].pos.z);
           startPt = route[i].pos;
       }
    }
    
    const gp = goalGapPoint(p.team);
    const distToGoal = Math.hypot(gp.x - ball.pos.x, gp.z - ball.pos.z);
    const shootMaxReach = 52.0;
    if (distToGoal <= MAX_SHOOT_DIST) {
       if (Date.now() % 400 < 200) updateArrow(A.yellow, ball.pos.x, ball.pos.z, gp.x, gp.z);
       else hideArrow(A.yellow);
    } else {
       const toGoal = new THREE.Vector3(gp.x - ball.pos.x, 0, gp.z - ball.pos.z).normalize().multiplyScalar(shootMaxReach);
       updateArrow(A.yellow, ball.pos.x, ball.pos.z, ball.pos.x + toGoal.x, ball.pos.z + toGoal.z);
    }
  } else {
    A.marker.visible = false;
    h.currentPassRoute = null;
    const tp = ball.owner ? ball.owner.pos : ball.pos;
    A.whites.forEach(hideArrow); updateArrow(A.whites[0], p.pos.x, p.pos.z, tp.x, tp.z);
    hideArrow(A.yellow);
  }
}

function updateCamera(h, dt) {
  const team = h.team, attackDir = team.dir;
  const focus = h.controlled ? h.controlled.pos : ball.pos;
  const offZ = -attackDir * CAM_BACK;
  h.cam.position.set(focus.x * 0.7, CAM_H, focus.z + offZ);
  h.cam.lookAt(focus.x * 0.85, LOOK_Y, focus.z + attackDir * LOOK_AHEAD);
}

function animateAll(dt) {
  allPlayers().forEach(p => {
    if (p.sentOff) { p.root.visible = false; p.shadow.visible = false; return; }
    p.root.visible = true; p.shadow.visible = true;
    
    // スタン時のプルプル演出
    if (p.lock && p.lock.name === 'stun') {
       p.parts.torso.rotation.x = 0.3;
       p.parts.head.rotation.x = Math.sin(Date.now() / 20) * 0.2;
       p.parts.armL.rotation.x = 0.5; p.parts.armR.rotation.x = 0.5;
       p.shadow.position.set(p.pos.x, 0.02, p.pos.z);
       return;
    }
    
    const sp = Math.sqrt(p.vel.lengthSq());
    animatePlayer(p, dt, sp > 0.6, sp);
    p.shadow.position.set(p.pos.x, 0.02, p.pos.z);
  });
  ball.mesh.position.copy(ball.pos);
  ball.mesh.rotation.x += ball.vel.z * dt * 0.6; ball.mesh.rotation.z -= ball.vel.x * dt * 0.6;
  ball.shadow.position.set(ball.pos.x, 0.02, ball.pos.z);
}

/* ---------- ループ ---------- */
function tickPlayer(p, dt) {
  p.cardCd = Math.max(0, p.cardCd - dt); p.kickCd = Math.max(0, p.kickCd - dt);
  p.possGrace = Math.max(0, p.possGrace - dt); p.slideCd = Math.max(0, (p.slideCd || 0) - dt);
  p.feintCd = Math.max(0, (p.feintCd || 0) - dt);
  p.feintBoostT = Math.max(0, (p.feintBoostT || 0) - dt);

  if (p.lock) {
      p.lock.t += dt;
      if (p.lock.t >= p.lock.dur) p.lock = null;
  }

  if (p.autoPassTgt && p.autoPassDelay > 0) {
    p.autoPassDelay -= dt;
    if (p.autoPassDelay <= 0) {
       if (ball.owner === p && !p.sentOff) passKick(p, p.autoPassTgt);
       p.autoPassTgt = null;
    }
  }
}

function checkClock() {
  if (G.halfElapsed >= HALF_LEN) {
    if (G.half === 1) { G.state = 'halftime'; G.htTimer = 3.6; whistle(); showHalftime(); }
    else { G.state = 'fulltime'; whistle(); showFulltime(); }
  }
}

function step(dt) {
  // 戦術のキーボード切り替え判定
  if (justPressed.has('KeyA') && humans[0]) toggleTactic(humans[0].team, 'tacP1');
  if (justPressed.has('KeyH') && humans[1]) toggleTactic(humans[1].team, 'tacP2');

  allPlayers().forEach(p => { p.isControlled = false; tickPlayer(p, dt); });
  humans.forEach(h => { chooseControlled(h); if (h.controlled) h.controlled.isControlled = true; });
  humans.forEach(h => {
    if ((h.dashT || 0) > 0) { h.dashT -= dt; h.stamina = Math.max(0, (h.stamina || 0) - STAM_DRAIN * dt); if (h.stamina <= 0) h.dashT = 0; }
    else h.stamina = Math.min(1, (h.stamina || 0) + STAM_RECOVER * dt);
  });
  humans.forEach(h => updateCamera(h, dt));
  humans.forEach(h => updateHuman(h, dt, axis(h.scheme), camDirs(h.cam)));
  updateAI(dt);
  ballUpdate(dt);
  collisions(dt);
  G.halfElapsed += dt; checkClock();
}

let last = performance.now();
function loop(now) {
  requestAnimationFrame(loop);
  let dt = (now - last) / 1000; last = now; if (dt > 0.05) dt = 0.05;
  if (G.state === 'play') {
    step(dt);
    if (G.state === 'celebrate') { whistle(); cheer(); showGoalBanner(otherTeam(G.kickNext)); }
  } else if (G.state === 'celebrate') {
    G.celebr -= dt; if (G.celebr <= 0) { hideGoalBanner(); G.state = 'play'; kickoff(G.kickNext); }
  } else if (G.state === 'halftime') {
    G.htTimer -= dt; if (G.htTimer <= 0) { swapSides(); G.half = 2; G.halfElapsed = 0; hideHalftime(); G.state = 'play'; kickoff(G.firstKicker === G.home ? G.away : G.home); }
  }
  
  if (G.started) { animateAll(dt); humans.forEach(h => drawAim(h)); updateHUD(); }
  updateConfetti(dt); updateCrowd(dt);
  renderScene();
  justPressed.clear();
}

function renderScene() {
  const W = window.innerWidth, H = window.innerHeight;
  if (G.mode === 'multi' && G.started) {
    renderer.setScissorTest(true);
    renderer.setViewport(0, 0, W / 2, H); renderer.setScissor(0, 0, W / 2, H); cam1.aspect = (W / 2) / H; cam1.updateProjectionMatrix(); renderer.render(scene, cam1);
    renderer.setViewport(W / 2, 0, W / 2, H); renderer.setScissor(W / 2, 0, W / 2, H); cam2.aspect = (W / 2) / H; cam2.updateProjectionMatrix(); renderer.render(scene, cam2);
    renderer.setScissorTest(false);
  } else {
    renderer.setViewport(0, 0, W, H); renderer.setScissorTest(false); cam1.aspect = W / H; cam1.updateProjectionMatrix(); renderer.render(scene, cam1);
  }
}

/* ---------- メニュー・起動 ---------- */
function startMatch() { 
  if (G.mode === 'tournament') {
     const pNat = getRandomNation();
     const enemies = [];
     while(enemies.length < 3) {
        const e = getRandomNation(pNat);
        if (!enemies.includes(e)) enemies.push(e);
     }
     G.tourney = { round: 0, playerNation: pNat, enemies: enemies };
     startTourneyMatch();
  } else {
     newMatch(); 
  }
}

function wireMenu() {
  document.querySelectorAll('[data-mode]').forEach(el => el.addEventListener('click', () => {
    document.querySelectorAll('[data-mode]').forEach(x => x.classList.remove('act')); el.classList.add('act'); G.mode = el.dataset.mode; }));
  
  document.querySelectorAll('[data-diff]').forEach(el => el.addEventListener('click', () => {
    document.querySelectorAll('[data-diff]').forEach(x => x.classList.remove('act')); el.classList.add('act'); G.difficulty = el.dataset.diff; }));
  
  document.querySelectorAll('[data-color]').forEach(el => el.addEventListener('click', () => {
    document.querySelectorAll('[data-color]').forEach(x => x.classList.remove('act')); el.classList.add('act'); G.colorChoice = el.dataset.color; }));
  
  $('start').addEventListener('click', () => { audioInit(); startMatch(); });
  
  // HUD上の戦術切り替えボタンのイベント登録
  $('tacP1').addEventListener('click', () => { if (humans[0]) toggleTactic(humans[0].team, 'tacP1'); });
  $('tacP2').addEventListener('click', () => { if (humans[1]) toggleTactic(humans[1].team, 'tacP2'); });

  $('ftMenu').addEventListener('click', () => { showScreen('scrFull', false); G.started = false; G.state = 'menu'; clearMatch(); showScreen('scrTitle', true); cam1.position.set(0, 30, HALF_Z + 34); cam1.lookAt(0, 0, 0); });
}

initThree();
buildField();
buildBall();
wireMenu();
requestAnimationFrame(loop);

いいなと思ったら応援しよう!

この記事が参加している募集

ピックアップされています

気楽に遊べるゲーム

  • 27本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
サッカーゲーム「MultiverseSoccer」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1