サッカーゲーム「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 & 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">0 – 0</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">0 – 0</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>

コメント