サッカーゲーム「MultiverseSoccer」


【更新履歴】

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


画像


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


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

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>マルチバース・サッカー</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}

  #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:46vw;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(36px,7vw,82px);font-weight:900;line-height:.98;letter-spacing:.01em;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="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 TIME-TRAVEL FOOTBALL MATCH</div>
  <h1 class="gtitle">Multiverse Soccer</h1>
  <div class="gsub">CHLOE &amp; THEO'S TIME-TRAVEL SOCCER</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";
/* =====================================================================
   MultiverseSoccer
   元プロジェクトの 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=34, CAM_BACK=23, FOV=56;
const RUN_SPD=14.5, DRIB_SPD=11.8, AI_SPD=11, AI_DRIB=9.6, GK_SPD=12.5;
const BALLR=0.45, SMALL_KICK=24, BIG_KICK=43, GRAV=30, REST=0.45, FRIC=11, STOP=1.0;
const LEAD=1.3, PICK_R=1.7, PICK_MAXSPEED=36, STEAL_R=1.55, GRACE=0.5, STEAL_AI=1.7, STEAL_HUMAN=4.6;
const CARD_DIST=0.98, 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 $=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, 120, 230);
  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,42,HALF_Z+46);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/2;i+=42){x.fillStyle=((i/42)&1)?'#2f7d3a':'#296f30';x.fillRect(0,i,cw,42);}
  for(let j=0;j<cw;j+=42){x.fillStyle=((j/42)&1)?'#368e46':'#2e7c3c';x.fillRect(j,ch/2,42,ch/2);}
  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(400,400),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=9;
  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+34,14);bank(0,(HALF_Z+off),HALF_X*2+34,14);
  bank(-(HALF_X+off),0,14,HALF_Z*2+12);bank((HALF_X+off),0,14,HALF_Z*2+12);
}
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=9,rows=5,gap=1.8;
  for(let r=0;r<rows;r++){const y=2.8+r*0.78;
    for(let xi=-(HALF_X+12);xi<=HALF_X+12;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+5);zi<=HALF_Z+5;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);});
  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(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,0.16,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:[],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,decisionCd:0,isControlled:false,shadow:blobShadow(0.62)});});
}
function clearMatch(){
  if(G.home){allPlayers().forEach(p=>{scene.remove(p.root);scene.remove(p.shadow);});}
  humans.forEach(h=>{if(h.aim){['group','ring','line','sel'].forEach(k=>scene.remove(h.aim[k]));}});
  humans=[];G.home=null;G.away=null;G.teams=[];
}
function buildAim(teamColor){
  const group=new THREE.Group();scene.add(group);
  const shaft=new THREE.Mesh(new THREE.BoxGeometry(0.32,0.05,1),new THREE.MeshBasicMaterial({color:0x44dd66,transparent:true,opacity:0.85}));shaft.position.z=0.5;group.add(shaft);
  const head=new THREE.Mesh(new THREE.ConeGeometry(0.55,1.1,12),new THREE.MeshBasicMaterial({color:0x44dd66,transparent:true,opacity:0.9}));head.rotation.x=Math.PI/2;group.add(head);
  const ring=new THREE.Mesh(new THREE.RingGeometry(0.75,1.05,22),new THREE.MeshBasicMaterial({color:0x66ffd0,transparent:true,opacity:0.85,side:THREE.DoubleSide}));ring.rotation.x=-Math.PI/2;scene.add(ring);
  const line=new THREE.Mesh(new THREE.BoxGeometry(0.13,0.04,1),new THREE.MeshBasicMaterial({color:0x66ffd0,transparent:true,opacity:0.6}));scene.add(line);
  const sel=new THREE.Mesh(new THREE.RingGeometry(0.9,1.2,26),new THREE.MeshBasicMaterial({color:teamColor,transparent:true,opacity:0.9,side:THREE.DoubleSide}));sel.rotation.x=-Math.PI/2;scene.add(sel);
  group.visible=ring.visible=line.visible=sel.visible=false;
  return {group,shaft,head,ring,line,sel};
}
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);placeFormation(home);placeFormation(away);
  // humans
  if(G.mode==='single'){humans=[{team:home,scheme:'solo',cam:cam1,controlled:null,defView:false,aim:buildAim(home.color)}];}
  else{humans=[{team:home,scheme:'P1',cam:cam1,controlled:null,defView:false,aim:buildAim(home.color)},
              {team:away,scheme:'P2',cam:cam2,controlled:null,defView:false,aim:buildAim(away.color)}];}
  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);humans.forEach(h=>h.defView=false);
  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;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);
}

/* ---------- 入力 ---------- */
const keys=new Set(),justPressed=new Set();
const GAME_CODES=new Set(['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight',
  'KeyJ','KeyK','KeyL','KeyF','KeyG','KeyV','Comma','Period','Slash','Space','Enter']);
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,ps;
  if(scheme==='solo'){up=keys.has('KeyW')||keys.has('ArrowUp');dn=keys.has('KeyS')||keys.has('ArrowDown');
    lf=keys.has('KeyA')||keys.has('ArrowLeft');rt=keys.has('KeyD')||keys.has('ArrowRight');
    sm=justPressed.has('KeyJ');bg=justPressed.has('KeyK');ps=justPressed.has('KeyL');}
  else if(scheme==='P1'){up=keys.has('KeyW');dn=keys.has('KeyS');lf=keys.has('KeyA');rt=keys.has('KeyD');
    sm=justPressed.has('KeyF');bg=justPressed.has('KeyG');ps=justPressed.has('KeyV');}
  else{up=keys.has('ArrowUp');dn=keys.has('ArrowDown');lf=keys.has('ArrowLeft');rt=keys.has('ArrowRight');
    sm=justPressed.has('Comma');bg=justPressed.has('Period');ps=justPressed.has('Slash');}
  return {fwd:(up?1:0)-(dn?1:0),right:(rt?1:0)-(lf?1:0),small:sm,big:bg,pass:ps};
}
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;
  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)doKick(p,'big');else if(a.small)doKick(p,'small');else if(a.pass)doPass(p);}
}
function doKick(p,type){
  const f=faceVec(p.facing),power=type==='big'?BIG_KICK:SMALL_KICK,lift=type==='big'?6:1.6;
  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 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 doPass(p){const t=bestPassTarget(p);if(!t){doKick(p,'small');return;}passKick(p,t);}
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;}else{A.sel.visible=true;A.sel.position.set(p.pos.x,0.04,p.pos.z);}
  if(!p||ball.owner!==p){A.group.visible=A.head.visible=A.shaft.visible=A.ring.visible=A.line.visible=false;return;}
  const f=faceVec(p.facing),goalC=new THREE.Vector3(0,0,p.team.attackZ);
  const distGoal=Math.abs(p.team.attackZ-ball.pos.z),attack=distGoal<36,clear=pathClear(ball.pos,goalC,p.team);
  const colHex=attack?(clear?0x44dd66:0xee8833):0x66c0ff;
  A.group.visible=A.shaft.visible=A.head.visible=true;
  A.group.position.set(ball.pos.x,0.06,ball.pos.z);A.group.rotation.y=Math.atan2(f.x,f.z);
  const len=6.5;A.shaft.scale.z=len;A.shaft.position.z=len*0.5;A.head.position.z=len+0.4;
  A.shaft.material.color.setHex(colHex);A.head.material.color.setHex(colHex);
  const tgt=bestPassTarget(p);
  if(tgt){A.ring.visible=true;A.ring.position.set(tgt.pos.x,0.05,tgt.pos.z);
    A.line.visible=true;const mx=(ball.pos.x+tgt.pos.x)/2,mz=(ball.pos.z+tgt.pos.z)/2;
    const dx=tgt.pos.x-ball.pos.x,dz=tgt.pos.z-ball.pos.z,dl=Math.hypot(dx,dz)||1;
    A.line.position.set(mx,0.05,mz);A.line.rotation.y=Math.atan2(dx,dz);A.line.scale.z=dl;}
  else{A.ring.visible=false;A.line.visible=false;}
}

/* ---------- 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 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)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){aiKick(p,new THREE.Vector3((Math.random()-0.5)*0.5,0,sign).normalize(),BIG_KICK*0.92,6);return;}
      target=new THREE.Vector3(clamp(ball.pos.x*0.5,-GOAL_HALF-1.5,GOAL_HALF+1.5),0,team.defendZ+sign*2.2);sp=GK_SPD;
    } else if(isOwner){
      if(p.decisionCd<=0){p.decisionCd=0.25;
        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);
        if(distGoal<28&&Math.random()<0.55){const ga=Math.atan2(-p.pos.x+(Math.random()-0.5)*5,team.attackZ-p.pos.z);
          aiKick(p,faceVec(ga),BIG_KICK*(0.9+Math.random()*0.2),5+Math.random()*3);return;}
        if(pressed){const t=bestPassTarget(p);if(t){passKick(p,t);return;}}}
      target=new THREE.Vector3(clamp(p.pos.x*0.85+(Math.random()-0.5)*2,-HALF_X+4,HALF_X-4),0,team.attackZ);sp=AI_DRIB;
    } else if(ownerTeam===team){
      const off=p.role==='F'?10:p.role==='M'?2:-8;
      target=new THREE.Vector3(clamp(p.homePos.x+ball.pos.x*0.4,-HALF_X+4,HALF_X-4),0,clamp(ball.pos.z+sign*off,-HALF_Z+4,HALF_Z-4));
    } else {
      if(p===team.presser&&ownerTeam===opp){target=ball.pos.clone();sp=AI_SPD+0.6;}
      else{target=new THREE.Vector3(clamp(ball.pos.x*0.5+p.homePos.x*0.5,-HALF_X+4,HALF_X-4),0,
        clamp(p.homePos.z+(ball.pos.z-p.homePos.z)*0.3,-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.4){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.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<1.5&&d>0.0001){const push=(1.5-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);
  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,p.team);}
  else flashCard(false,p.team);
  updateHUD();
}

/* ---------- カメラ ---------- */
function updateCamera(h){
  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 endZ=(def?-sign:sign)*(HALF_Z+CAM_BACK);
  h.cam.position.set(clamp(focus.x*0.5,-16,16),CAM_H,endZ);
  h.cam.lookAt(clamp(focus.x*0.45,-14,14),2.6,focus.z*0.55-Math.sign(endZ)*6);
}

/* ---------- ステップ ---------- */
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);}
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));
  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('W')}${k('A')}${k('S')}${k('D')}/${k('←↑↓→')} `
      +`小キック ${k('J')} 大キック ${k('K')} パス ${k('L')}<br>`
      +`<span style="opacity:.65">ボールを持つ味方が自動で操作対象に切替。敵に体当たりすると<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('W')}${k('A')}${k('S')}${k('D')} 小 ${k('F')} 大 ${k('G')} パス ${k('V')}<br>`
      +`<span style="color:var(--blue)">P2(右)</span> 移動 ${k('←↑↓→')} 小 ${k(',')} 大 ${k('.')} パス ${k('/')}<br>`
      +`<span style="opacity:.65">体当たりはイエロー、3枚で退場。</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;
}
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,team){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));}
  updateConfetti(dt);updateCrowd(dt);
  if(G.started)updateHUD();
  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,42,HALF_Z+46);cam1.lookAt(0,0,0);});
}

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

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

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

コメント

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