サッカーゲーム「MultiverseSoccer」


【更新履歴】

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


画像

・お昼ごろにアップデート予定。


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


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

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

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

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

  /* ---- 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>

<!-- HUD -->
<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="168" height="124"></canvas></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>

<!-- Title -->
<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>

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

<!-- Fulltime -->
<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>
"use strict";
/* =====================================================================
   Multiverse Soccer
   3D表現・キック/パス・オートエイム矢印の発想を継承したサッカーゲーム。
   ===================================================================== */

/* ---------- 定数 ---------- */
const RED=0xe23b3b, BLUE=0x2f7be0;
const HALF_X=30, HALF_Z=46, GOAL_HALF=6.5, GOAL_H=4.5, GOAL_DEPTH=3;
const CAM_H=38, CAM_BACK=26, FOV=55, LOOK_Y=5.0, FLIP_DUR=0.7, CAM_R=HALF_Z+CAM_BACK;
const PLAYER_SX=2, PLAYER_SY=4, PLAYER_SZ=2, FOOT=0.16;
const RUN_SPD=14.5, DRIB_SPD=11.8, AI_SPD=11, AI_DRIB=9.6, GK_SPD=12.5;
const SLIDE_SPEED=25, SLIDE_DUR=0.5, SLIDE_CD=0.9;
const BALLR=0.45, SMALL_KICK=24, BIG_KICK=43, GRAV=30, REST=0.45, FRIC=11, STOP=1.0;
const LEAD=1.5, PICK_R=1.8, PICK_MAXSPEED=36, STEAL_R=1.6, GRACE=0.5, STEAL_AI=1.7, STEAL_HUMAN=4.6;
const SEP=1.8, CARD_DIST=1.1, CARD_CD=1.7;
const HALF_LEN=80, 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,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);

/* ---------- three セットアップ ---------- */
let renderer, scene, cam1, cam2, ball, 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,48,HALF_Z+52);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 THREE.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);});
}
function buildStands(){
  const sm=matLam(0x32313a);const off=11;
  const bank=(cx,cz,w,d)=>{const m=new THREE.Mesh(new THREE.BoxGeometry(w,4,d),sm);m.position.set(cx,2,cz);scene.add(m);};
  bank(0,-(HALF_Z+off),HALF_X*2+40,16);bank(0,(HALF_Z+off),HALF_X*2+40,16);
  bank(-(HALF_X+off),0,16,HALF_Z*2+16);bank((HALF_X+off),0,16,HALF_Z*2+16);
}
function buildCrowd(){
  const geo=new THREE.BoxGeometry(0.55,0.9,0.55);
  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=11,rows=5,gap=1.9;
  for(let r=0;r<rows;r++){const y=2.8+r*0.78;
    for(let xi=-(HALF_X+14);xi<=HALF_X+14;xi+=gap){add(xi,y,-(HALF_Z+off-2)-r*0.95);add(xi,y,(HALF_Z+off-2)+r*0.95);}
    for(let zi=-(HALF_Z+7);zi<=HALF_Z+7;zi+=gap){add(-(HALF_X+off-2)-r*0.95,y,zi);add((HALF_X+off-2)+r*0.95,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,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.62,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,9,HALF_Z*0.5],[HALF_X*0.6,9,HALF_Z*0.5],[0,11,0],[-HALF_X*0.6,9,-HALF_Z*0.5],[HALF_X*0.6,9,-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)*7;C.pos[i*3+1]=o[1]+Math.random()*3;C.pos[i*3+2]=o[2]+(Math.random()-0.5)*7;
    C.vel[i*3]=(Math.random()-0.5)*11;C.vel[i*3+1]=7+Math.random()*11;C.vel[i*3+2]=(Math.random()-0.5)*11;
    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.65:0.06,sp=bouncing?9:2.2,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(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);
  ball={mesh:m,shadow:blobShadow(0.4),pos:new THREE.Vector3(0,BALLR,0),vel:new THREE.Vector3(),owner:null,lastTouch:null};
}

/* ---------- ゲーム状態 ---------- */
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=[];
const FORM=[{role:'GK',xf:0,zf:0.06},{role:'D',xf:-0.45,zf:0.27},{role:'D',xf:0.45,zf:0.27},{role:'M',xf:0,zf:0.52},{role:'F',xf:0,zf:0.8}];
function makeTeam(color,name,isHuman){return {color,name,isHuman,score:0,players:[],bench:[],defendZ:0,attackZ:0,dir:1,presser:null};}
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 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 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);});}
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.9)});});
}
function buildBench(team){team.bench=[];for(let i=0;i<6;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*(9+k*15));r.rotation.y=sideX<0?Math.PI/2:-Math.PI/2;});});
}
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);
    [h.aim.white,h.aim.yellow].forEach(a=>{a.dashes.forEach(d=>scene.remove(d));scene.remove(a.head);});}});
  humans=[];G.home=null;G.away=null;G.teams=[];
}
/* ---- 点線矢印 ---- */
function buildDottedArrow(colorHex){
  const mat=new THREE.MeshBasicMaterial({color:colorHex,transparent:true,opacity:0.95});
  const dashes=[];for(let i=0;i<16;i++){const d=new THREE.Mesh(new THREE.BoxGeometry(0.2,0.04,0.55),mat);d.visible=false;scene.add(d);dashes.push(d);}
  const head=new THREE.Group();const cone=new THREE.Mesh(new THREE.ConeGeometry(0.55,1.1,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.4){hideArrow(arr);return;}
  const ang=Math.atan2(dx,dz),ux=dx/dist,uz=dz/dist,headLen=1.2,spacing=1.3;
  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.1,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.13,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(1.1,1.5,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);
  return {sel,white:buildDottedArrow(0xffffff),yellow:buildDottedArrow(0xf2c84b)};
}
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,defView:false,aim:buildAim(home.color),curEndSign:undefined,flipT:0}];}
  else{humans=[{team:home,scheme:'P1',cam:cam1,controlled:null,defView:false,aim:buildAim(home.color),curEndSign:undefined,flipT:0},
              {team:away,scheme:'P2',cam:cam2,controlled:null,defView:false,aim:buildAim(away.color),curEndSign:undefined,flipT:0}];}
  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 kickoff(team){
  placeFormation(G.home);placeFormation(G.away);positionBenches();
  humans.forEach(h=>{h.defView=false;h.flipT=0;h.curEndSign=undefined;});
  const f=team.players.find(p=>p.role==='F')||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;
  whistle();updateHUD();
}
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;
  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');}
  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');}
  return {fwd:(up?1:0)-(dn?1:0),right:(rt?1:0)-(lf?1:0),small:sm,big:bg};
}
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};}

/* ---------- 操作対象(注視点)の選択 ---------- */
function chooseControlled(h){
  const team=h.team,owner=ball.owner;
  if(owner&&owner.team===team&&!owner.sentOff){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 updateHuman(h,dt){
  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.4&&(!ball.owner||ball.owner.team!==p.team)&&ball.pos.y<2.0){
        ball.owner=p;p.possGrace=GRACE;ball.lastTouch=p.team;p.slide=null;}}
    if(p.slide&&p.slide.t>=p.slide.dur)p.slide=null;
    return;
  }
  const a=axis(h.scheme),dirs=camDirs(h.cam);
  const mv=new THREE.Vector3();mv.addScaledVector(dirs.fwd,a.fwd);mv.addScaledVector(dirs.right,a.right);
  const hasBall=ball.owner===p;
  if(mv.lengthSq()>0.01){mv.normalize();const sp=hasBall?DRIB_SPD: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{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){if(a.big)kickWithBall(p,'big');else if(a.small)kickWithBall(p,'small');}
  else{if((a.big||a.small)&&(p.slideCd||0)<=0)startSlide(p);}
}
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};
  tone(260,0.3,'sawtooth',0.16,80);
}
function kickWithBall(p,type){
  if(type==='small'){const t=lockNearestAlly(p);if(t)passKick(p,t);else straightKick(p,SMALL_KICK,1.6);}
  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};kickSnd();}
function shootGap(p){const gp=goalGapPoint(p.team);const to=new THREE.Vector3(gp.x-ball.pos.x,0,gp.z-ball.pos.z);
  const d=to.length()||1;to.multiplyScalar(1/d);
  ball.owner=null;ball.vel.set(to.x*BIG_KICK,7,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};kickSnd();}
function lockNearestAlly(p){let b=null,bd=1e9;p.team.players.forEach(m=>{if(m===p||m.sentOff||m.role==='GK')return;
  const d=Math.hypot(m.pos.x-p.pos.x,m.pos.z-p.pos.z);if(d<bd){bd=d;b=m;}});return b;}
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 bestPassTarget(p){
  let best=null,bs=-1e9;const sign=p.team.dir;
  p.team.players.forEach(m=>{if(m===p||m.sentOff||m.role==='GK')return;
    const d=Math.hypot(m.pos.x-p.pos.x,m.pos.z-p.pos.z);if(d<3||d>44)return;
    const fwd=(m.pos.z-p.pos.z)*sign;let open=99;
    otherTeam(p.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 sc=fwd*1.3+open*1.1-d*0.12;if(sc>bs){bs=sc;best=m;}});
  return best;
}
function passKick(p,tgt){
  const la=Math.atan2(-tgt.pos.x,p.team.attackZ-tgt.pos.z),lv=faceVec(la);
  const ax=tgt.pos.x+lv.x*2.2,az=tgt.pos.z+lv.z*2.2;
  const to=new THREE.Vector3(ax-ball.pos.x,0,az-ball.pos.z);const dist=to.length()||1;to.multiplyScalar(1/dist);
  const power=clamp(dist*1.95,16,40),lift=dist>22?3:1.3;
  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};kickSnd();
}
function pathClear(a,b,team){
  for(let t=0.15;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 drawAim(h){
  const A=h.aim,p=h.controlled;
  if(!p||p.sentOff){A.sel.visible=false;hideArrow(A.white);hideArrow(A.yellow);return;}
  A.sel.visible=true;A.sel.position.set(p.pos.x,0.05,p.pos.z);
  if(ball.owner===p){
    const ally=lockNearestAlly(p);if(ally)updateArrow(A.white,ball.pos.x,ball.pos.z,ally.pos.x,ally.pos.z);else hideArrow(A.white);
    const gp=goalGapPoint(p.team);updateArrow(A.yellow,ball.pos.x,ball.pos.z,gp.x,gp.z);
  } else {
    const tp=ball.owner?ball.owner.pos:ball.pos;
    updateArrow(A.white,p.pos.x,p.pos.z,tp.x,tp.z);hideArrow(A.yellow);
  }
}

/* ---------- AI(戦略的) ---------- */
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};kickSnd();}
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 nearestOpp(team,pos,excl){let b=null,bd=1e9;otherTeam(team).players.forEach(o=>{if(o.sentOff||o===excl)return;const d=Math.hypot(o.pos.x-pos.x,o.pos.z-pos.z);if(d<bd){bd=d;b=o;}});return b;}
function updateAI(dt){
  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;
    p.decisionCd-=dt;const team=p.team,opp=otherTeam(team),sign=team.dir,isOwner=(ball.owner===p);
    let target=null,sp=AI_SPD;
    if(p.role==='GK'){
      if(isOwner){
        let mate=null,md=1e9;team.players.forEach(m=>{if(m===p||m.sentOff||m.role==='GK')return;
          const adv=(m.pos.z-p.pos.z)*sign;if(adv<2)return;const d=Math.hypot(m.pos.x-p.pos.x,m.pos.z-p.pos.z);
          if(d<28&&opennessOf(team,m.pos)>5&&d<md){md=d;mate=m;}});
        if(mate)passKick(p,mate);else aiKick(p,new THREE.Vector3((Math.random()-0.5)*0.4,0,sign).normalize(),BIG_KICK*0.95,6);
        return;
      }
      target=new THREE.Vector3(clamp(ball.pos.x*0.55,-GOAL_HALF-1.4,GOAL_HALF+1.4),0,team.defendZ+sign*2.2);sp=GK_SPD;
    } else if(isOwner){
      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)<3.4);
        const clear=pathClear(ball.pos,new THREE.Vector3(0,0,team.attackZ),team);
        if(distGoal<30&&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,7);return;}
        const tgt=bestPassTarget(p);
        if(tgt){const adv=(tgt.pos.z-p.pos.z)*sign,open=opennessOf(team,tgt.pos);
          if(pressed||((adv>7||distGoal<42)&&open>5&&Math.random()<0.4)){passKick(p,tgt);return;}}
      }
      let tx=clamp(p.pos.x*0.85+(Math.random()-0.5)*1.5,-HALF_X+4,HALF_X-4);
      const dA=opp.players.find(o=>!o.sentOff&&(o.pos.z-p.pos.z)*sign>0&&(o.pos.z-p.pos.z)*sign<6&&Math.abs(o.pos.x-p.pos.x)<3);
      if(dA)tx=clamp(p.pos.x+(p.pos.x>=dA.pos.x?4:-4),-HALF_X+4,HALF_X-4);
      target=new THREE.Vector3(tx,0,team.attackZ);sp=AI_DRIB;
    } else if(ownerTeam===team){
      const widthSign=p.homePos.x>=0?1:-1;
      const ahead=p.role==='F'?13:p.role==='M'?4:-6;
      const tz=clamp(ball.pos.z+sign*ahead,-HALF_Z+5,HALF_Z-5);
      const tx=clamp(p.homePos.x*0.5+widthSign*(HALF_X-9)*0.6,-HALF_X+4,HALF_X-4);
      target=new THREE.Vector3(tx,0,tz);
    } else {
      if(p===team.presser&&ownerTeam===opp){target=ball.pos.clone();sp=AI_SPD+0.8;}
      else{
        const mark=nearestOpp(team,p.pos,owner);
        if(mark){const gz=team.defendZ,t=0.35;
          target=new THREE.Vector3(clamp(mark.pos.x*0.85,-HALF_X+4,HALF_X-4),0,clamp(mark.pos.z+(gz-mark.pos.z)*t,-HALF_Z+4,HALF_Z-4));}
        else target=new THREE.Vector3(clamp(ball.pos.x*0.4+p.homePos.x*0.6,-HALF_X+4,HALF_X-4),0,clamp(p.homePos.z,-HALF_Z+4,HALF_Z-4));
      }
    }
    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.5){to.multiplyScalar(1/d);const m=Math.min(sp,d*4);
        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 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;}}}
  } 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)<1.2)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;}}}
    const handled=checkBounds();if(!handled)tryPickup();
  }
}
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)return;const d=Math.hypot(p.pos.x-ball.pos.x,p.pos.z-ball.pos.z);
    if(d<bd&&(s<PICK_MAXSPEED||p.role==='GK')&&ball.pos.y<2.2){bd=d;best=p;}});
  if(best){ball.owner=best;best.possGrace=GRACE;ball.lastTouch=best.team;}
}
function checkBounds(){
  const bx=ball.pos.x,bz=ball.pos.z;
  if(bz<-HALF_Z){if(Math.abs(bx)<GOAL_HALF&&ball.pos.y<GOAL_H){doGoal(teamAttacking(-1));}else goalLineOut(-1,bx);return true;}
  if(bz>HALF_Z){if(Math.abs(bx)<GOAL_HALF&&ball.pos.y<GOAL_H){doGoal(teamAttacking(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.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;whistle();
}
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);
  G.state='celebrate';G.celebr=CELEBR;G.kickNext=otherTeam(team);
  crowdBounce=CELEBR;burstConfetti();whistle();cheer();showGoalBanner(team);updateHUD();
}

/* ---------- 衝突・カード ---------- */
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)return;
    if(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;
  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.9;p.pos.z+=dz/d*0.9;
  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;whistle();updateHUD();
}
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;flashCard(true);}
  else flashCard(false);
  updateHUD();
}

/* ---------- カメラ(コート移動で回転) ---------- */
function updateCamera(h,dt){
  const team=h.team,sign=Math.sign(team.defendZ),bz=ball.pos.z*sign;
  if(!h.defView&&bz>5)h.defView=true;if(h.defView&&bz<-5)h.defView=false;
  const def=h.defView,focus=h.controlled?h.controlled.pos:ball.pos;
  const endSign=def?-sign:sign;
  if(h.curEndSign===undefined){h.curEndSign=endSign;h.flipT=0;}
  if(endSign!==h.curEndSign&&(h.flipT||0)<=0){h.flipFrom=h.curEndSign;h.flipTo=endSign;h.flipT=FLIP_DUR;}
  if((h.flipT||0)>0){
    h.flipT-=dt;let t=smooth(1-Math.max(0,h.flipT)/FLIP_DUR);
    const p0=h.flipFrom>0?0:Math.PI,p1=h.flipTo>0?0:Math.PI,phi=p0+(p1-p0)*t;
    h.cam.position.set(CAM_R*Math.sin(phi)+focus.x*0.25,CAM_H,CAM_R*Math.cos(phi));
    h.cam.lookAt(focus.x*0.3,LOOK_Y,focus.z*0.4);
    if(h.flipT<=0)h.curEndSign=h.flipTo;
  } else {
    const endZ=h.curEndSign*CAM_R;
    h.cam.position.set(clamp(focus.x*0.55,-16,16),CAM_H,endZ);
    h.cam.lookAt(clamp(focus.x*0.6,-16,16),LOOK_Y,focus.z*0.7-Math.sign(endZ)*4);
  }
}

/* ---------- ステップ ---------- */
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);}
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=>updateCamera(h,dt));
  humans.forEach(h=>updateHuman(h,dt));
  updateAI(dt);
  ballUpdate(dt);
  collisions(dt);
  G.halfElapsed+=dt;checkClock();
}
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 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);
}

/* ---------- 音 ---------- */
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);}

/* ---------- HUD / ミニマップ / 画面 ---------- */
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">ボール保持中… 小=近い味方へパス(白矢印) / 大=ゴールの隙へシュート(黄矢印)<br>`
      +`非保持中… Z/X でボール保持者へスライディング(白点線)・届けば奪取<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">保持中: 小=パス / 大=シュート。非保持: スライディングで奪取。体当たりはカード。</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;
  drawMinimap();
}
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=1;x.strokeRect(2,2,W-4,H-4);
  x.beginPath();x.moveTo(2,H/2);x.lineTo(W-2,H/2);x.stroke();
  // goals
  const mx=v=>4+(v+HALF_X)/(2*HALF_X)*(W-8);
  const mz=v=>4+(v+HALF_Z)/(2*HALF_Z)*(H-8); // +Z(自陣) を下に
  x.fillStyle='rgba(255,255,255,.7)';
  x.fillRect(mx(-GOAL_HALF),H-5,(mx(GOAL_HALF)-mx(-GOAL_HALF)),3);
  x.fillRect(mx(-GOAL_HALF),2,(mx(GOAL_HALF)-mx(-GOAL_HALF)),3);
  if(!G.home)return;
  allPlayers().forEach(p=>{if(p.sentOff)return;
    x.fillStyle='#'+p.team.color.toString(16).padStart(6,'0');
    x.beginPath();x.arc(mx(p.pos.x),mz(p.pos.z),3,0,6.283);x.fill();
    if(p.isControlled){x.strokeStyle='#fff';x.lineWidth=1.6;x.stroke();}});
  x.fillStyle='#fff';x.beginPath();x.arc(mx(ball.pos.x),mz(ball.pos.z),2.6,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');
}

/* ---------- ループ ---------- */
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);}
  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,48,HALF_Z+52);cam1.lookAt(0,0,0);});
}

/* ---------- 起動 ---------- */
initThree();buildField();buildBall();wireMenu();
requestAnimationFrame(loop);
</script>
</body>
</html>

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

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとか要望、 コメント欄か、Xのリプライ欄に書いてみて下さい。 ひまをみて対応します。 (未管理著作物裁定制度に定められた問い合わせも受付中。)
サッカーゲーム「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