サッカーゲーム「MultiverseSoccer」


【更新履歴】

・2026/6/16 バージョン1.0公開。
・2026/6/16 バージョン1.1公開。
・2026/6/16 バージョン1.2公開。

画像


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

【概要】

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

【操作説明】

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

・ボールを持っている時は、
 ・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}
  .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}

  /* ミニマップのサイズを70%に縮小 (336x498) */
  #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}

  #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)}
  .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="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">A MULTIVERSE FOOTBALL MATCH</div>
  <h1 class="gtitle"><span class="a">MULTIVERSE</span><br><span class="b">SOCCER</span></h1>
  <div class="gsub">CHLOE &amp; THEO ACROSS THE TIMELINES</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>
    <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 — ゲーム定数と設定
   ===================================================================== */

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

/* 1ユニット≒1m。コートとゴールのサイズ */
const HALF_X = 34;
const HALF_Z = 52;
const GOAL_HALF = 4.5;
const GOAL_H = 2.8;
const GOAL_DEPTH = 2.5;

/* カメラ設定 */
const CAM_BACK = 15;
const CAM_H = 29;
const LOOK_AHEAD = 16;
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;

/* 移動速度パラメータ */
const RUN_SPD = 10;
const DRIB_SPD = 8.2;
const AI_SPD = 8;
const AI_DRIB = 7;
const GK_SPD = 8.5;

/* ダッシュ&スライディング */
const SLIDE_SPEED = 16;
const SLIDE_DUR = 0.5;
const SLIDE_CD = 0.9;
const SLIDE_RANGE = 6.0;
const DASH_SPEED = 17;
const DASH_DUR = 0.55;
const DASH_MIN = 0.18;

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

/* ボールの物理・キックパラメータ */
const SMALL_KICK = 18;
const BIG_KICK = 24; // 【修正】シュート飛距離を制限するため威力を24に抑制
const MAX_SHOOT_DIST = HALF_Z * 1.1; // 【追加】シュートが届く限界距離の目安
const GRAV = 18;
const REST = 0.45;
const FRIC = 7;
const STOP = 0.6;

/* AIと操作の閾値 */
const LEAD = 0.9;
const PICK_R = 1.15;
const PICK_MAXSPEED = 28;
const STEAL_R = 1.0;
const GRACE = 0.5;
const STEAL_AI = 1.7;
const STEAL_HUMAN = 4.6;
const SEP = 1.0;
const CARD_DIST = 0.66;
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 G = {
  state: 'menu', mode: 'single', colorChoice: 'random', started: false,
  home: null, away: null, teams: [], half: 1, halfElapsed: 0,
  firstKicker: null, kickNext: null, celebr: 0, htTimer: 0
};

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) {
  // 【追加】tactic(戦術)パラメータを持たせる
  return { color, name, isHuman, score: 0, players: [], bench: [], defendZ: 0, attackZ: 0, dir: 1, presser: null, tactic: 'NORMAL' };
}

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);
}

/* ---- パスルート計算 (ユーティリティAI) ---- */
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) {
    p.slide.t += dt;
    const dir = p.slide.dir, k = p.slide.t / p.slide.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;
        
        p.lock = { name: 'lift', t: 0, dur: 0.55 };
        p.kickCd = 0.55;
        p.vel.set(0, 0, 0);
      }
    }
    if (p.slide && p.slide.t >= p.slide.dur) p.slide = null;
    return;
  }

  if (p.lock && p.lock.name === 'lift') {
    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);

  let autoChase = false;
  if (!hasBall && (a.smHold || a.bgHold) && ball.owner && ball.owner.team !== p.team) {
    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) {
      if ((p.slideCd || 0) <= 0) {
        startSlide(p);
        return;
      }
    } else {
      if ((h.dashT || 0) <= 0 && h.stamina > DASH_MIN) h.dashT = DASH_DUR;
      const to = new THREE.Vector3(tp.x - p.pos.x, 0, tp.z - p.pos.z).normalize();
      mv.copy(to);
    }
  }

  if (mv.lengthSq() > 0.01) {
    mv.normalize();
    const sp = hasBall ? DRIB_SPD : (dashing ? DASH_SPEED : RUN_SPD);
    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 };
}

/* ---- キックと自動連携パス ---- */
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 };
}

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 };
}

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);
  
  // 摩擦で「受け手の位置でちょうど止まる」初速 sqrt(2*FRIC*dist) を基準に、
  // 受け手が迎えに走る分だけ僅かに強め(1.08)。以前の1.4は約2倍オーバーランしていた。
  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 };
}

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;
}


/* ---- AIの思考と陣形操作 (FSM & 戦術導入版) ---- */
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;
}

// 状態遷移の決定 (FSM)
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 => {
    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 || p.slide) return;
    if (p.lock && p.lock.name === 'lift') return;

    // 【追加】オートリレーの中継地点:自分の判断(BALL_OWNER等)で蹴らず、
    // tickPlayer がセットした次パス(autoPassTgt)が発火するのを待つ。
    // これで計画されたパス連携がAIに上書きされなくなる。
    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);
            }
          }
          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);
              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;

              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 };
              }
            } 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);
            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();
        sp = AI_SPD + 2.5; 
        const dToBall = Math.hypot(ball.pos.x - p.pos.x, ball.pos.z - p.pos.z);
        if (ball.owner && dToBall < SLIDE_RANGE && (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 adv = clamp(baseAdv + push + tacticPush + (ballAdv - baseAdv) * follow, 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 * 18;
            tz = clamp(leadZ, ownGoalZ + sign * HALF_Z * 1.2, ownGoalZ + sign * HALF_Z * 1.9);
            const side = p.homePos.x > 0 ? 1 : -1;
            sx = clamp(ball.pos.x + side * 14, -GOAL_HALF - 8, GOAL_HALF + 8);
          } 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 t = 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 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(sp, 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 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')) 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 ch = (st.isControlled ? STEAL_HUMAN : STEAL_AI) * dt;
        if (Math.random() < ch) {
          ball.owner = st; st.possGrace = GRACE; ball.lastTouch = st.team; ball.passRoute = null;
        }
      }
    }
  } 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) 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;
  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.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;
}
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・UI更新、メインループ、初期化
   ===================================================================== */

/* ---------- 音 ---------- */
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 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 = [];
  // 長距離(シュートの停止位置等)まで届くようにダッシュの数を多め(60個)に設定
  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
    });
  });
}
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; });
  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();
}
function newMatch() {
  clearMatch();
  let hc = G.colorChoice; if (hc === 'random') hc = Math.random() < 0.5 ? 'red' : 'blue';
  const homeColor = hc === 'red' ? RED : BLUE, awayColor = hc === 'red' ? BLUE : RED;
  const home = makeTeam(homeColor, homeColor === RED ? 'RED' : 'BLUE', true);
  const away = makeTeam(awayColor, awayColor === RED ? 'RED' : 'BLUE', 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();
  if (G.mode === 'single') humans = [{ team: home, scheme: 'P1', cam: cam1, controlled: null, aim: buildAim(home.color), stamina: 1, currentPassRoute: null }];
  else humans = [{ team: home, scheme: 'P1', cam: cam1, controlled: null, aim: buildAim(home.color), stamina: 1, currentPassRoute: null },
                 { 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');
  updateHUD(); 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();
const GAME_CODES = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyZ', 'KeyX', 'Numpad8', 'Numpad2', 'Numpad4', 'Numpad6', 'Numpad5', 'KeyN', 'KeyM', 'Enter', 'Space']);
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(); else if (G.state === 'fulltime') newMatch(); } }
  keys.add(e.code);
});
window.addEventListener('keyup', e => keys.delete(e.code));

function axis(scheme) {
  let up, dn, lf, rt, sm, bg, smHold, bgHold;
  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');
  } 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');
  }
  return { fwd: (up ? 1 : 0) - (dn ? 1 : 0), right: (rt ? 1 : 0) - (lf ? 1 : 0), small: sm, big: bg, smHold, bgHold };
}
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 setHelp() {
  const k = s => `<span class="k">${s}</span>`;
  let html;
  if (G.mode === 'single') {
    html = `<h4>CONTROLS</h4>移動 ${k('←↑↓→')} パス/自動攻撃 ${k('Z')} シュート ${k('X')}<br>`
      + `<span style="opacity:.7">保持中… Z=パス / X=シュート(届かない場合は自動攻撃パスへ)<br>`
      + `非保持中… <b>Z/X長押し</b>で敵へダッシュ&近づくと自動奪取(奪取後は一瞬の演出あり)<br>`
      + `敵に体当たりで<b style="color:var(--gold)">イエロー</b>、3枚で<b style="color:#f55">退場</b>。</span>`;
  } else {
    html = `<h4>CONTROLS</h4><span style="color:var(--red)">P1(左)</span> 移動 ${k('←↑↓→')}${k('Z')}${k('X')}<br>`
      + `<span style="color:var(--blue)">P2(右)</span> 移動 テンキー ${k('8')}${k('4')}${k('5')}${k('6')}${k('2')}${k('N')}${k('M')}<br>`
      + `<span style="opacity:.7">長押しでオートチェイス&奪取。保持中はXでシュート、届かなければ自動連携。</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 + "'"; $('hlf').textContent = G.half === 1 ? '1ST HALF' : '2ND HALF';
  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 mx = v => 8 + (v + HALF_X) / (2 * HALF_X) * (W - 16);
  const mz = v => 8 + (v + HALF_Z) / (2 * HALF_Z) * (H - 16);
  x.fillStyle = 'rgba(255,255,255,.7)';
  x.fillRect(mx(-GOAL_HALF), H - 12, (mx(GOAL_HALF) - mx(-GOAL_HALF)), 8);
  x.fillRect(mx(-GOAL_HALF), 4, (mx(GOAL_HALF) - mx(-GOAL_HALF)), 8);
  
  if (!G.home) return;
  
  // 点滅用のフラグ(400ms周期)
  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;
  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; $('hud').classList.remove('on');
}

/* ---------- 描画 ---------- */
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);
  
  if (ball.owner === p) {
    // ボール保持中の頭上逆三角形マーカー
    A.marker.visible = true;
    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);
    
    // BIG_KICKの威力によるボールが自然停止するおおよその距離
    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;
    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);
  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) {
  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() { G.state = 'play'; 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-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(); });
  $('ftAgain').addEventListener('click', () => { showScreen('scrFull', false); newMatch(); });
  $('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);

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

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
サッカーゲーム「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