サッカーゲーム「MultiverseSoccer」
【更新履歴】
・2026/6/16 バージョン1.0公開。
・2026/6/16 バージョン1.1公開。
・2026/6/16 バージョン1.2公開。
・ダウンロードされる方はこちら。↓
【概要】
・手軽に操作できるサッカーゲームです。
【操作説明】
・方向キーで移動。
(ボールを持っている味方選手を操作する。
ボールを奪われた時は、そのままの選手を移動させ、
ダッシュして追いつき、スライディング決めて
ボールを奪い取る。)
・ボールを持っている時は、
・Zキーで近くにいる味方の選手にボールをパスする。
・Xキーで相手側のゴールに向けてシュートをする。
・ボールを持っていない時は、
・ZキーまたはXキーで、
ボールを持っている相手選手に向けてダッシュする。
(スタミナゲージを消費するので、回復するまでできない。)
・スライディングしてボールが取れる時は、スライディングする。
・ロングシュートは、相手側のキーパーが間に合うので
決まりにくくなっている。
【マルチプレイモード】
・テンキー(4で左、8で上、6で右、2で下)で移動。
・ボールを持っている時は、
・Nキーでパス。
・Mキーでシュート。
・ボールを持っていない時は、
・NキーまたはMキーで、ダッシュやスライディング。
・ソースコードはこちら。↓
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multiverse Soccer</title>
<style>
:root{
--ink:#0c1410; --panel:rgba(10,20,15,.82); --line:rgba(255,255,255,.14);
--red:#e23b3b; --blue:#2f7be0; --gold:#f2c84b; --grass:#2e7d3a;
--font: "Helvetica Neue", "Segoe UI", system-ui, sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#06100a;font-family:var(--font);color:#eef4ee;-webkit-user-select:none;user-select:none}
canvas#game{position:fixed;inset:0;display:block}
/* ---- HUD ---- */
#hud{position:fixed;inset:0;pointer-events:none;display:none}
#hud.on{display:block}
.board{
position:absolute;top:14px;left:50%;transform:translateX(-50%);
display:flex;align-items:stretch;gap:0;background:var(--panel);
border:1px solid var(--line);border-radius:14px;overflow:hidden;backdrop-filter:blur(6px);
box-shadow:0 10px 30px rgba(0,0,0,.4)
}
.side{display:flex;align-items:center;gap:10px;padding:8px 16px}
.side .nm{font-weight:800;letter-spacing:.14em;font-size:15px}
.side .cards{display:flex;gap:3px;margin-top:3px}
.yc{width:8px;height:11px;border-radius:1px;background:var(--gold);box-shadow:0 0 0 1px rgba(0,0,0,.3)}
.rc{width:8px;height:11px;border-radius:1px;background:#d12;box-shadow:0 0 0 1px rgba(0,0,0,.3)}
.col{display:flex;flex-direction:column;align-items:center}
.score{font-size:30px;font-weight:900;line-height:1;font-variant-numeric:tabular-nums;padding:6px 18px;min-width:54px;text-align:center}
.mid{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:6px 14px;background:rgba(0,0,0,.28);border-left:1px solid var(--line);border-right:1px solid var(--line)}
.clock{font-size:20px;font-weight:800;font-variant-numeric:tabular-nums;color:var(--gold)}
.half{font-size:10px;letter-spacing:.22em;opacity:.8;margin-top:2px}
.pillR{color:var(--red)} .pillB{color:var(--blue)}
#poss{position:absolute;top:96px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--line);
border-radius:999px;padding:5px 14px;font-size:12px;letter-spacing:.1em;backdrop-filter:blur(6px)}
#poss b{font-weight:800}
/* ミニマップのサイズを70%に縮小 (336x498) */
#miniWrap{position:absolute;top:14px;right:14px;background:var(--panel);border:1px solid var(--line);border-radius:12px;
padding:8px;backdrop-filter:blur(6px);box-shadow:0 8px 24px rgba(0,0,0,.4)}
#miniWrap .ml{font-size:9px;letter-spacing:.24em;opacity:.6;text-align:center;margin-bottom:4px}
#mini{display:block;border-radius:6px; width: 336px; height: 498px;}
#help{position:absolute;left:14px;bottom:14px;background:var(--panel);border:1px solid var(--line);border-radius:12px;
padding:10px 14px;font-size:11.5px;line-height:1.7;max-width:48vw;backdrop-filter:blur(6px)}
#help .k{display:inline-block;min-width:18px;text-align:center;padding:1px 6px;margin:0 1px;border:1px solid var(--line);
border-radius:5px;background:rgba(255,255,255,.08);font-weight:700}
#help h4{margin:0 0 4px;font-size:11px;letter-spacing:.18em;opacity:.7;font-weight:700}
#stamina{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);display:flex;gap:18px}
.stam{display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:999px;
padding:5px 12px;backdrop-filter:blur(6px)}
.stam .sl{font-size:11px;font-weight:800;letter-spacing:.12em;opacity:.85}
.sbar{width:120px;height:9px;border-radius:5px;background:rgba(255,255,255,.14);overflow:hidden}
.sfill{height:100%;width:100%;border-radius:5px;background:linear-gradient(90deg,#ffce4d,#7be88a);transition:width .08s linear}
.sfill.low{background:#e2553b}
/* ---- center messages ---- */
#banner{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;pointer-events:none;opacity:0;transition:opacity .2s}
#banner.on{opacity:1}
#banner .big{font-size:clamp(54px,11vw,150px);font-weight:900;letter-spacing:.04em;text-shadow:0 8px 40px rgba(0,0,0,.5);
-webkit-text-stroke:2px rgba(0,0,0,.25);animation:pop .4s cubic-bezier(.2,1.4,.4,1)}
#banner .sub{font-size:clamp(16px,2.6vw,26px);letter-spacing:.3em;margin-top:6px;font-weight:700;opacity:.9}
@keyframes pop{from{transform:scale(.5);opacity:0}to{transform:scale(1);opacity:1}}
#cardfx{position:fixed;inset:0;pointer-events:none;opacity:0;transition:opacity .12s}
#cardfx.on{opacity:.5}
/* ---- overlay screens ---- */
.screen{position:fixed;inset:0;display:none;align-items:center;justify-content:center;flex-direction:column;text-align:center;
background:radial-gradient(120% 120% at 50% 30%, rgba(8,22,14,.55), rgba(3,10,6,.92))}
.screen.on{display:flex}
.kicker{font-size:13px;letter-spacing:.42em;color:var(--gold);font-weight:700;margin-bottom:10px}
.gtitle{font-size:clamp(40px,8vw,96px);font-weight:900;line-height:.96;letter-spacing:.02em;margin:0}
.gtitle .a{color:var(--red)} .gtitle .b{color:var(--blue)}
.gsub{font-size:13px;letter-spacing:.28em;opacity:.75;margin-top:12px}
.sel{margin-top:30px;display:flex;flex-direction:column;gap:18px;align-items:center}
.grp{display:flex;gap:12px;align-items:center}
.lbl{font-size:11px;letter-spacing:.24em;opacity:.6;width:84px;text-align:right}
.opt{pointer-events:auto;cursor:pointer;padding:11px 22px;border:1.5px solid var(--line);border-radius:11px;font-weight:800;letter-spacing:.12em;
font-size:14px;background:rgba(255,255,255,.04);transition:.15s}
.opt:hover{background:rgba(255,255,255,.12)}
.opt.act{border-color:var(--gold);background:rgba(242,200,75,.16);box-shadow:0 0 0 1px var(--gold) inset}
.opt.r.act{border-color:var(--red);background:rgba(226,59,59,.2);box-shadow:0 0 0 1px var(--red) inset}
.opt.bl.act{border-color:var(--blue);background:rgba(47,123,224,.2);box-shadow:0 0 0 1px var(--blue) inset}
#start{margin-top:8px;padding:14px 46px;font-size:18px;background:var(--gold);color:#1a1408;border:none}
#start:hover{filter:brightness(1.08)}
.hint{margin-top:22px;font-size:11px;letter-spacing:.18em;opacity:.5}
.result{font-size:clamp(40px,8vw,90px);font-weight:900;font-variant-numeric:tabular-nums}
.rdesc{font-size:16px;letter-spacing:.2em;opacity:.85;margin-top:8px}
.btnrow{margin-top:28px;display:flex;gap:14px}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud">
<div class="board">
<div class="side" id="sL"><div class="col"><div class="nm" id="nmL">RED</div><div class="cards" id="cardsL"></div></div></div>
<div class="score" id="scL">0</div>
<div class="mid"><div class="clock" id="clk">0'</div><div class="half" id="hlf">1ST HALF</div></div>
<div class="score" id="scR">0</div>
<div class="side" id="sR"><div class="col"><div class="nm" id="nmR">BLUE</div><div class="cards" id="cardsR"></div></div></div>
</div>
<div id="poss">BALL: <b id="possName">—</b></div>
<div id="miniWrap"><div class="ml">MINI MAP</div><canvas id="mini" width="336" height="498"></canvas></div>
<div id="stamina">
<div class="stam" id="stamBox1"><span class="sl" id="sl1">P1</span><div class="sbar"><div class="sfill" id="sf1"></div></div></div>
<div class="stam" id="stamBox2"><span class="sl" id="sl2">P2</span><div class="sbar"><div class="sfill" id="sf2"></div></div></div>
</div>
<div id="help"></div>
</div>
<div id="banner"><div class="big" id="bnBig">GOAL!</div><div class="sub" id="bnSub"></div></div>
<div id="cardfx"></div>
<div class="screen on" id="scrTitle">
<div class="kicker">A MULTIVERSE FOOTBALL MATCH</div>
<h1 class="gtitle"><span class="a">MULTIVERSE</span><br><span class="b">SOCCER</span></h1>
<div class="gsub">CHLOE & THEO ACROSS THE TIMELINES</div>
<div class="sel">
<div class="grp"><div class="lbl">MODE</div>
<div class="opt act" data-mode="single" id="mSingle">ひとりで<span style="opacity:.6">/SINGLE</span></div>
<div class="opt" data-mode="multi" id="mMulti">ふたりで<span style="opacity:.6">/MULTI</span></div>
</div>
<div class="grp"><div class="lbl">YOUR KIT</div>
<div class="opt r" data-color="red">RED</div>
<div class="opt bl" data-color="blue">BLUE</div>
<div class="opt act" data-color="random">RANDOM</div>
</div>
<button class="opt" id="start">KICK OFF ▶</button>
</div>
<div class="hint">クリック / Enter で選択・決定</div>
</div>
<div class="screen" id="scrHalf">
<div class="kicker">HALF TIME</div>
<div class="result" id="htScore">0 – 0</div>
<div class="rdesc">コートチェンジ。まもなく後半開始…</div>
</div>
<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 src="config.js"></script>
<script src="graphics.js"></script>
<script src="logic.js"></script>
<script src="main.js"></script>
</body>
</html>config.js
"use strict";
/* =====================================================================
config.js — ゲーム定数と設定
===================================================================== */
const RED = 0xe23b3b;
const BLUE = 0x2f7be0;
/* 1ユニット≒1m。コートとゴールのサイズ */
const HALF_X = 34;
const HALF_Z = 52;
const GOAL_HALF = 4.5;
const GOAL_H = 2.8;
const GOAL_DEPTH = 2.5;
/* カメラ設定 */
const CAM_BACK = 15;
const CAM_H = 29;
const LOOK_AHEAD = 16;
const FOV = 52;
const LOOK_Y = 1.0;
/* 選手とボールのサイズ */
const PLAYER_SX = 0.8;
const PLAYER_SY = 0.8;
const PLAYER_SZ = 0.8;
const FOOT = 0.16;
const BALLR = 0.3;
/* 移動速度パラメータ */
const RUN_SPD = 10;
const DRIB_SPD = 8.2;
const AI_SPD = 8;
const AI_DRIB = 7;
const GK_SPD = 8.5;
/* ダッシュ&スライディング */
const SLIDE_SPEED = 16;
const SLIDE_DUR = 0.5;
const SLIDE_CD = 0.9;
const SLIDE_RANGE = 6.0;
const DASH_SPEED = 17;
const DASH_DUR = 0.55;
const DASH_MIN = 0.18;
/* スタミナ */
const STAM_DRAIN = 1.4;
const STAM_RECOVER = 0.7;
/* ボールの物理・キックパラメータ */
const SMALL_KICK = 18;
const BIG_KICK = 24; // 【修正】シュート飛距離を制限するため威力を24に抑制
const MAX_SHOOT_DIST = HALF_Z * 1.1; // 【追加】シュートが届く限界距離の目安
const GRAV = 18;
const REST = 0.45;
const FRIC = 7;
const STOP = 0.6;
/* AIと操作の閾値 */
const LEAD = 0.9;
const PICK_R = 1.15;
const PICK_MAXSPEED = 28;
const STEAL_R = 1.0;
const GRACE = 0.5;
const STEAL_AI = 1.7;
const STEAL_HUMAN = 4.6;
const SEP = 1.0;
const CARD_DIST = 0.66;
const CARD_CD = 1.7;
/* 試合時間 */
const HALF_LEN = 90;
const CELEBR = 3.3;
/* 共通ベクトル */
const UP = new THREE.Vector3(0, 1, 0);
/* ヘルパー関数 */
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
function mix(a, b, t) {
const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;
const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255;
return (Math.round(ar + (br - ar) * t) << 16) | (Math.round(ag + (bg - ag) * t) << 8) | Math.round(ab + (bb - ab) * t);
}
const faceVec = a => new THREE.Vector3(Math.sin(a), 0, Math.cos(a));
function lerpAngle(a, b, t) {
let d = b - a;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
return a + d * t;
}
const smooth = t => t * t * (3 - 2 * t);
const $ = id => document.getElementById(id);graphics.js
"use strict";
/* =====================================================================
graphics.js — Three.jsの初期化、3Dモデル生成、アニメーション
===================================================================== */
let renderer, scene, cam1, cam2;
let crowd, confetti;
function matLam(c) { return new THREE.MeshLambertMaterial({ color: c }); }
function box(w, h, d, c) { return new THREE.Mesh(new THREE.BoxGeometry(w, h, d), matLam(c)); }
function grp(x, y, z) { const g = new THREE.Group(); g.position.set(x, y, z); return g; }
function blobShadow(r) {
const m = new THREE.Mesh(
new THREE.CircleGeometry(r, 16),
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.26 })
);
m.rotation.x = -Math.PI / 2;
m.position.y = 0.02;
scene.add(m);
return m;
}
function initThree() {
const cv = $('game');
renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.setSize(window.innerWidth, window.innerHeight);
scene = new THREE.Scene();
scene.background = new THREE.Color(0x8fb6d8);
scene.fog = new THREE.Fog(0x8fb6d8, 130, 250);
cam1 = new THREE.PerspectiveCamera(FOV, window.innerWidth / window.innerHeight, 0.1, 400);
cam2 = new THREE.PerspectiveCamera(FOV, 1, 0.1, 400);
const hemi = new THREE.HemisphereLight(0xffffff, 0x4a6a4a, 0.9);
scene.add(hemi);
const dir = new THREE.DirectionalLight(0xffffff, 0.85);
dir.position.set(30, 80, 40);
scene.add(dir);
cam1.position.set(0, 30, HALF_Z + 34);
cam1.lookAt(0, 0, 0);
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
});
}
function buildPitchTexture() {
const cw = 512, ch = 768, cv = document.createElement('canvas');
cv.width = cw; cv.height = ch;
const x = cv.getContext('2d');
for (let i = 0; i < ch; i += 42) {
x.fillStyle = ((i / 42) & 1) ? '#2f7d3a' : '#296f30';
x.fillRect(0, i, cw, 42);
}
x.strokeStyle = 'rgba(255,255,255,.85)';
x.lineWidth = 4;
const ix = cw * 0.018, iy = ch * 0.013;
x.strokeRect(ix, iy, cw - 2 * ix, ch - 2 * iy);
x.beginPath(); x.moveTo(ix, ch / 2); x.lineTo(cw - ix, ch / 2); x.stroke();
x.beginPath(); x.arc(cw / 2, ch / 2, 72, 0, 6.283); x.stroke();
x.fillStyle = '#fff';
x.beginPath(); x.arc(cw / 2, ch / 2, 5, 0, 6.283); x.fill();
const pbW = cw * 0.5, pbH = ch * 0.155, pbX = (cw - pbW) / 2;
x.strokeRect(pbX, iy, pbW, pbH); x.strokeRect(pbX, ch - iy - pbH, pbW, pbH);
const gbW = cw * 0.26, gbH = ch * 0.06, gbX = (cw - gbW) / 2;
x.strokeRect(gbX, iy, gbW, gbH); x.strokeRect(gbX, ch - iy - gbH, gbW, gbH);
const t = new THREE.CanvasTexture(cv);
t.anisotropy = 8;
return t;
}
function buildField() {
const surround = new THREE.Mesh(new THREE.PlaneGeometry(440, 440), matLam(0x244a26));
surround.rotation.x = -Math.PI / 2;
surround.position.y = -0.05;
scene.add(surround);
const pitch = new THREE.Mesh(new THREE.PlaneGeometry(HALF_X * 2, HALF_Z * 2), new THREE.MeshLambertMaterial({ map: buildPitchTexture() }));
pitch.rotation.x = -Math.PI / 2;
pitch.position.y = 0;
scene.add(pitch);
buildGoal(-1);
buildGoal(1);
buildStands();
crowd = buildCrowd();
confetti = buildConfetti();
}
function buildGoal(sign) {
const g = new THREE.Group();
scene.add(g);
const z = sign * HALF_Z;
const pm = matLam(0xffffff);
const post = xx => {
const m = new THREE.Mesh(new THREE.BoxGeometry(0.22, GOAL_H, 0.22), pm);
m.position.set(xx, GOAL_H / 2, z);
g.add(m);
};
post(-GOAL_HALF);
post(GOAL_HALF);
const bar = new THREE.Mesh(new THREE.BoxGeometry(GOAL_HALF * 2 + 0.22, 0.22, 0.22), pm);
bar.position.set(0, GOAL_H, z);
g.add(bar);
const nm = new MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.15, side: THREE.DoubleSide });
const back = new THREE.Mesh(new THREE.PlaneGeometry(GOAL_HALF * 2, GOAL_H), nm);
back.position.set(0, GOAL_H / 2, z + sign * GOAL_DEPTH);
g.add(back);
const top = new THREE.Mesh(new THREE.PlaneGeometry(GOAL_HALF * 2, GOAL_DEPTH), nm);
top.rotation.x = Math.PI / 2;
top.position.set(0, GOAL_H, z + sign * GOAL_DEPTH / 2);
g.add(top);
[-1, 1].forEach(s => {
const sd = new THREE.Mesh(new THREE.PlaneGeometry(GOAL_DEPTH, GOAL_H), nm);
sd.rotation.y = Math.PI / 2;
sd.position.set(GOAL_HALF * s, GOAL_H / 2, z + sign * GOAL_DEPTH / 2);
g.add(sd);
});
}
// 修正用ヘルパー:元のコードに合わせてTHREEを付与
function MeshLambertMaterial(params) {
return new THREE.MeshLambertMaterial(params);
}
function buildStands() {
const sm = matLam(0x32313a);
const off = 7;
const bank = (cx, cz, w, d) => {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, 2.4, d), sm);
m.position.set(cx, 1.2, cz);
scene.add(m);
};
bank(0, -(HALF_Z + off), HALF_X * 2 + 24, 9);
bank(0, (HALF_Z + off), HALF_X * 2 + 24, 9);
bank(-(HALF_X + off), 0, 9, HALF_Z * 2 + 10);
bank((HALF_X + off), 0, 9, HALF_Z * 2 + 10);
}
function buildCrowd() {
const geo = new THREE.BoxGeometry(0.5, 0.8, 0.5);
const pts = [], ph = [], cols = [];
const pal = [RED, BLUE, 0xffffff, 0xf2c84b, 0xc9ced6, 0x20242c];
const add = (x, y, z) => {
pts.push([x, y, z]);
ph.push(Math.random() * 6.283);
cols.push(pal[(Math.random() * pal.length) | 0]);
};
const off = 7, rows = 3, gap = 1.7;
for (let r = 0; r < rows; r++) {
const y = 1.7 + r * 0.62;
for (let xi = -(HALF_X + 10); xi <= HALF_X + 10; xi += gap) {
add(xi, y, -(HALF_Z + off - 1.5) - r * 0.8);
add(xi, y, (HALF_Z + off - 1.5) + r * 0.8);
}
for (let zi = -(HALF_Z + 5); zi <= HALF_Z + 5; zi += gap) {
add(-(HALF_X + off - 1.5) - r * 0.8, y, zi);
add((HALF_X + off - 1.5) + r * 0.8, y, zi);
}
}
const N = pts.length;
const mesh = new THREE.InstancedMesh(geo, new THREE.MeshLambertMaterial(), N);
const m = new THREE.Matrix4(), col = new THREE.Color();
for (let i = 0; i < N; i++) {
m.makeTranslation(pts[i][0], pts[i][1], pts[i][2]);
mesh.setMatrixAt(i, m);
col.setHex(cols[i]);
mesh.setColorAt(i, col);
}
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
scene.add(mesh);
return { mesh, bases: pts, phases: ph, N, m };
}
function buildConfetti() {
const N = 720;
const g = new THREE.BufferGeometry();
const pos = new Float32Array(N * 3), col = new Float32Array(N * 3);
for (let i = 0; i < N; i++) pos[i * 3 + 1] = -200;
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
const mat = new THREE.PointsMaterial({ size: 0.32, vertexColors: true, transparent: true, opacity: 0.95 });
const p = new THREE.Points(g, mat);
p.frustumCulled = false;
scene.add(p);
return { pts: p, pos, col, vel: new Float32Array(N * 3), N, life: 0 };
}
function burstConfetti() {
const C = confetti;
const pal = [[0.9, 0.2, 0.2], [0.2, 0.5, 0.9], [1, 0.8, 0.2], [0.3, 0.9, 0.45], [1, 1, 1], [0.8, 0.35, 0.9]];
const org = [[-HALF_X * 0.6, 5, HALF_Z * 0.5], [HALF_X * 0.6, 5, HALF_Z * 0.5], [0, 6, 0], [-HALF_X * 0.6, 5, -HALF_Z * 0.5], [HALF_X * 0.6, 5, -HALF_Z * 0.5]];
for (let i = 0; i < C.N; i++) {
const o = org[i % org.length];
C.pos[i * 3] = o[0] + (Math.random() - 0.5) * 5;
C.pos[i * 3 + 1] = o[1] + Math.random() * 2;
C.pos[i * 3 + 2] = o[2] + (Math.random() - 0.5) * 5;
C.vel[i * 3] = (Math.random() - 0.5) * 7;
C.vel[i * 3 + 1] = 5 + Math.random() * 7;
C.vel[i * 3 + 2] = (Math.random() - 0.5) * 7;
const c = pal[(Math.random() * pal.length) | 0];
C.col[i * 3] = c[0]; C.col[i * 3 + 1] = c[1]; C.col[i * 3 + 2] = c[2];
}
C.pts.geometry.attributes.color.needsUpdate = true;
C.life = CELEBR;
}
function updateConfetti(dt) {
const C = confetti;
if (C.life <= 0) return;
C.life -= dt;
for (let i = 0; i < C.N; i++) {
C.vel[i * 3 + 1] -= 14 * dt;
C.pos[i * 3] += C.vel[i * 3] * dt;
C.pos[i * 3 + 1] += C.vel[i * 3 + 1] * dt;
C.pos[i * 3 + 2] += C.vel[i * 3 + 2] * dt;
if (C.pos[i * 3 + 1] < 0.12) {
C.pos[i * 3 + 1] = 0.12;
C.vel[i * 3] *= 0.6;
C.vel[i * 3 + 2] *= 0.6;
C.vel[i * 3 + 1] = 0;
}
C.vel[i * 3] += Math.sin((C.life + i) * 3) * 2 * dt;
}
C.pts.geometry.attributes.position.needsUpdate = true;
C.pts.material.opacity = Math.min(0.95, C.life);
if (C.life <= 0) {
for (let i = 0; i < C.N; i++) C.pos[i * 3 + 1] = -200;
C.pts.geometry.attributes.position.needsUpdate = true;
}
}
let crowdBounce = 0, frameN = 0;
function updateCrowd(dt) {
const cr = crowd;
if (!cr) return;
crowdBounce = Math.max(0, crowdBounce - dt);
frameN++;
const bouncing = crowdBounce > 0;
if (!bouncing && (frameN & 3)) return;
const amp = bouncing ? 0.4 : 0.04;
const sp = bouncing ? 9 : 2.2;
const t = performance.now() * 0.001;
for (let i = 0; i < cr.N; i++) {
const b = cr.bases[i];
const y = b[1] + Math.abs(Math.sin(t * sp + cr.phases[i])) * amp;
cr.m.makeTranslation(b[0], y, b[2]);
cr.mesh.setMatrixAt(i, cr.m);
}
cr.mesh.instanceMatrix.needsUpdate = true;
}
/* ---------- 選手モデル生成とアニメーション ---------- */
function makePlayer(color, role) {
const jersey = role === 'GK' ? mix(color, 0x111111, 0.4) : color;
const shorts = 0xffffff, socks = jersey, skin = 0xf1c8a0;
const root = grp(0, 0, 0);
const P = { root };
function leg(s) {
const hip = grp(0.17 * s, 0.95, 0); root.add(hip);
const th = box(0.26, 0.5, 0.28, shorts); th.position.y = -0.25; hip.add(th);
const shin = grp(0, -0.5, 0); hip.add(shin);
const calf = box(0.22, 0.5, 0.24, socks); calf.position.y = -0.25; shin.add(calf);
const shoe = box(0.26, 0.18, 0.42, 0x101014); shoe.position.set(0, -0.52, 0.08); shin.add(shoe);
return { hip, shin };
}
const L = leg(-1), R = leg(1);
P.legL = L.hip; P.legR = R.hip; P.shinL = L.shin; P.shinR = R.shin;
const torso = grp(0, 1.5, 0); root.add(torso); P.torso = torso;
const chest = box(0.6, 0.7, 0.34, jersey); torso.add(chest);
const waist = box(0.5, 0.18, 0.3, shorts); waist.position.y = -0.42; torso.add(waist);
const num = box(0.3, 0.3, 0.02, mix(jersey, 0xffffff, 0.6)); num.position.set(0, 0.05, -0.18); torso.add(num);
function arm(s) {
const sh = grp(0.36 * s, 1.78, 0); root.add(sh);
const up = box(0.16, 0.42, 0.18, jersey); up.position.y = -0.21; sh.add(up);
const fore = grp(0, -0.42, 0); sh.add(fore);
const fm = box(0.14, 0.4, 0.16, skin); fm.position.y = -0.2; fore.add(fm);
if (role === 'GK') {
const gl = box(0.17, 0.16, 0.18, 0xffffff); gl.position.y = -0.42; fore.add(gl);
}
return { sh, fore };
}
const aL = arm(-1), aR = arm(1);
P.armL = aL.sh; P.foreL = aL.fore; P.armR = aR.sh; P.foreR = aR.fore;
const head = grp(0, 2.12, 0); root.add(head); P.head = head;
head.add(box(0.34, 0.36, 0.34, skin));
const hair = box(0.38, 0.16, 0.38, 0x2a1a10); hair.position.y = 0.2; head.add(hair);
[-1, 1].forEach(s => {
const e = box(0.05, 0.06, 0.02, 0x1a1a22); e.position.set(0.08 * s, 0.02, 0.18); head.add(e);
});
root.scale.set(PLAYER_SX, PLAYER_SY, PLAYER_SZ);
return { root, parts: P };
}
function animatePlayer(p, dt, moving, speed) {
const P = p.parts;
p.animT += dt;
P.legL.rotation.set(0, 0, 0); P.legR.rotation.set(0, 0, 0);
P.shinL.rotation.set(0, 0, 0); P.shinR.rotation.set(0, 0, 0);
P.armL.rotation.set(0, 0, 0); P.armR.rotation.set(0, 0, 0);
P.foreL.rotation.set(0, 0, 0); P.foreR.rotation.set(0, 0, 0);
P.torso.rotation.set(0, 0, 0); P.head.rotation.set(0, 0, 0);
P.torso.position.y = 1.5;
let lock = p.lock;
if (lock) {
lock.t += dt;
if (lock.t >= lock.dur) p.lock = lock = null;
}
if (lock && lock.name === 'kick') {
const k = Math.min(1, lock.t / lock.dur), sw = Math.sin(k * Math.PI);
P.legR.rotation.x = -1.7 * sw - 0.1;
P.shinR.rotation.x = 0.5 * (1 - sw);
P.legL.rotation.x = 0.3;
P.armL.rotation.x = -0.5;
P.armR.rotation.x = -0.7;
P.torso.rotation.x = 0.12;
P.torso.rotation.y = sw * 0.22;
} else if (lock && lock.name === 'slide') {
P.legR.rotation.x = -1.25; P.shinR.rotation.x = 0.2;
P.legL.rotation.x = 0.55; P.shinL.rotation.x = 0.95;
P.armL.rotation.x = -1.3; P.armR.rotation.x = -1.3;
P.foreL.rotation.x = -0.3; P.foreR.rotation.x = -0.3;
P.torso.rotation.x = -0.5; P.torso.position.y = 1.05;
P.head.rotation.x = 0.32;
} else if (lock && lock.name === 'lift') {
// 奪取直後のリフティング(膝蹴り)アニメーション
const k = lock.t / lock.dur;
const sw = Math.abs(Math.sin(k * Math.PI * 4)); // 1秒間で数回リフティング
P.legR.rotation.x = -1.5 * sw; // 右膝を高く上げる
P.shinR.rotation.x = 1.0 * sw;
P.armL.rotation.x = -0.4; P.armR.rotation.x = -0.4;
P.torso.rotation.x = -0.1;
} else if (moving) {
const a = Math.min(1, speed / 13), ph = p.animT * (10 + speed * 0.45), s = Math.sin(ph);
P.legL.rotation.x = s * 1.0 * a; P.legR.rotation.x = -s * 1.0 * a;
P.shinL.rotation.x = Math.max(0, Math.cos(ph)) * 1.0 * a;
P.shinR.rotation.x = Math.max(0, Math.cos(ph + Math.PI)) * 1.0 * a;
P.armL.rotation.x = -s * 0.7 * a; P.armR.rotation.x = s * 0.7 * a;
P.foreL.rotation.x = -0.5; P.foreR.rotation.x = -0.5;
P.torso.rotation.x = 0.14; P.torso.position.y = 1.5 + Math.abs(s) * 0.06 * a;
P.head.rotation.x = -0.05;
} else {
const s = Math.sin(p.animT * 1.8);
P.armL.rotation.x = 0.05; P.armR.rotation.x = 0.05;
P.foreL.rotation.x = -0.2; P.foreR.rotation.x = -0.2;
P.torso.position.y = 1.5 + s * 0.02;
P.head.rotation.y = Math.sin(p.animT * 0.7) * 0.15;
}
p.root.position.set(p.pos.x, FOOT * PLAYER_SY, p.pos.z);
p.root.rotation.y = p.facing;
}
/* ---------- ボール生成 ---------- */
function ballTex() {
const cv = document.createElement('canvas');
cv.width = cv.height = 128;
const x = cv.getContext('2d');
x.fillStyle = '#fff';
x.fillRect(0, 0, 128, 128);
x.fillStyle = '#1c1c22';
for (let i = 0; i < 6; i++) {
x.beginPath();
x.arc(16 + Math.random() * 96, 16 + Math.random() * 96, 9, 0, 6.283);
x.fill();
}
return new THREE.CanvasTexture(cv);
}
function buildBall() {
const m = new THREE.Mesh(new THREE.SphereGeometry(BALLR, 16, 12), new THREE.MeshLambertMaterial({ map: ballTex() }));
scene.add(m);
// 【追加】passRoute: オートリレー用の経路配列を保持
ball = {
mesh: m, shadow: blobShadow(0.28), pos: new THREE.Vector3(0, BALLR, 0),
vel: new THREE.Vector3(), owner: null, lastTouch: null, passRoute: null
};
}logic.js
"use strict";
/* =====================================================================
logic.js — AI、操作、物理判定、ゲームルール
===================================================================== */
const G = {
state: 'menu', mode: 'single', colorChoice: 'random', started: false,
home: null, away: null, teams: [], half: 1, halfElapsed: 0,
firstKicker: null, kickNext: null, celebr: 0, htTimer: 0
};
let humans = [];
let ball = null;
const FORM = [
{ role: 'GK', xf: 0.00, zf: 0.05 },
{ role: 'DEF', xf: -0.78, zf: 0.22 }, { role: 'DEF', xf: -0.28, zf: 0.18 },
{ role: 'DEF', xf: 0.28, zf: 0.18 }, { role: 'DEF', xf: 0.78, zf: 0.22 },
{ role: 'MID', xf: -0.74, zf: 0.48 }, { role: 'MID', xf: -0.26, zf: 0.45 },
{ role: 'MID', xf: 0.26, zf: 0.45 }, { role: 'MID', xf: 0.74, zf: 0.48 },
{ role: 'FWD', xf: -0.26, zf: 0.74 }, { role: 'FWD', xf: 0.26, zf: 0.74 },
];
function makeTeam(color, name, isHuman) {
// 【追加】tactic(戦術)パラメータを持たせる
return { color, name, isHuman, score: 0, players: [], bench: [], defendZ: 0, attackZ: 0, dir: 1, presser: null, tactic: 'NORMAL' };
}
function allPlayers() { return G.home.players.concat(G.away.players); }
function otherTeam(t) { return t === G.home ? G.away : G.home; }
function teamAttacking(sign) { return G.teams.find(t => t.dir === sign); }
function teamDefending(sign) { return G.teams.find(t => t.defendZ === sign * HALF_Z); }
function formPos(team, slot) {
const ownGoalZ = team.defendZ, dir = team.dir;
return new THREE.Vector3(slot.xf * (HALF_X - 5), 0, ownGoalZ + dir * slot.zf * HALF_Z);
}
function placeFormation(team) {
team.players.forEach((p, i) => {
const s = FORM[i], pos = formPos(team, s);
p.pos.copy(pos);
p.homePos.copy(pos);
p.facing = Math.atan2(-p.pos.x, team.attackZ - p.pos.z);
p.aiState = 'IDLE';
});
}
function nearestTeamPlayer(team, pos) {
let b = null, bd = 1e9;
team.players.forEach(p => {
if (p.sentOff) return;
const d = Math.hypot(p.pos.x - pos.x, p.pos.z - pos.z);
if (d < bd) { bd = d; b = p; }
});
return b;
}
/* ---- 操作対象(注視点)の選択 ---- */
function chooseControlled(h) {
const team = h.team, owner = ball.owner;
// 【追加】オートリレー進行中(まだ次の経由先がある)は、その都度ワンタッチで
// 蹴る中継選手に操作を固定しない。連携が終わった最終受け手で操作を握らせる。
const relayActive = (ball.passRoute && ball.passRoute.length > 0) || (owner && owner.autoPassTgt);
if (owner && owner.team === team && !owner.sentOff && !relayActive) { h.controlled = owner; return; }
let best = null, bd = 1e9;
team.players.forEach(p => {
if (p.sentOff || p.role === 'GK') return;
const d = Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z);
if (d < bd) { bd = d; best = p; }
});
if (h.controlled && !h.controlled.sentOff && h.controlled.role !== 'GK') {
const cur = Math.hypot(h.controlled.pos.x - ball.pos.x, h.controlled.pos.z - ball.pos.z);
if (cur <= bd + 2.2) return;
}
h.controlled = best;
}
function clampPlayer(p) {
const m = 1;
p.pos.x = clamp(p.pos.x, -HALF_X + m, HALF_X - m);
const zl = HALF_Z - (p.role === 'GK' ? 0.4 : m);
p.pos.z = clamp(p.pos.z, -zl, zl);
}
/* ---- パスルート計算 (ユーティリティAI) ---- */
function calculatePassRoute(startPlayer, isAttackingGoal = false) {
const route = [];
const visited = new Set([startPlayer]);
let curr = startPlayer;
const sign = startPlayer.team.dir;
// 【追加】チームの戦術によってパスの評価基準を変える
const isAllOut = startPlayer.team.tactic === 'ATTACK';
const isDefending = startPlayer.team.tactic === 'DEFEND';
for (let i = 0; i < 3; i++) {
let best = null, bs = -1e9;
startPlayer.team.players.forEach(m => {
if (m === curr || m.sentOff || m.role === 'GK' || visited.has(m)) return;
const d = Math.hypot(m.pos.x - curr.pos.x, m.pos.z - curr.pos.z);
if (d < 3 || d > 45) return;
const fwd = (m.pos.z - curr.pos.z) * sign;
let open = 99;
otherTeam(startPlayer.team).players.forEach(o => {
if (o.sentOff) return;
const dd = Math.hypot(o.pos.x - m.pos.x, o.pos.z - m.pos.z);
if (dd < open) open = dd;
});
// パス評価式:戦術に応じた重み付け
const fwdWeight = isAttackingGoal ? 2.5 : (isAllOut ? 2.2 : 1.5);
const openWeight = isDefending ? 2.0 : (isAllOut ? 0.8 : 1.2);
let sc = fwd * fwdWeight + open * openWeight - d * 0.1;
// 【追加】パスの通り道に敵がいる(カットされやすい)候補は大幅減点。
// これにより「受け手はフリーでも、間に敵がいて通らない」パスを避ける。
if (!pathClear(curr.pos, m.pos, startPlayer.team)) sc -= 12;
if (sc > bs) { bs = sc; best = m; }
});
if (!best) {
let nf = null, nd = 1e9;
startPlayer.team.players.forEach(m => {
if (m === curr || m.sentOff || m.role === 'GK' || visited.has(m)) return;
const d = Math.hypot(m.pos.x - curr.pos.x, m.pos.z - curr.pos.z);
const fwd = (m.pos.z - curr.pos.z) * sign;
if (fwd > 2 && d < 45 && d < nd) { nd = d; nf = m; }
});
best = nf;
}
if (!best) break;
route.push(best);
visited.add(best);
curr = best;
}
return route;
}
/* ---- プレイヤー操作とオートチェイス ---- */
function updateHuman(h, dt, inputAxis, camDirs) {
const p = h.controlled;
if (!p || p.sentOff) return;
if (p.slide) {
p.slide.t += dt;
const dir = p.slide.dir, k = p.slide.t / p.slide.dur, sp = SLIDE_SPEED * (1 - 0.45 * k);
p.pos.x += dir.x * sp * dt;
p.pos.z += dir.z * sp * dt;
p.facing = Math.atan2(dir.x, dir.z);
p.vel.set(dir.x * sp, 0, dir.z * sp);
clampPlayer(p);
if (ball.owner !== p) {
const d = Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z);
if (d < STEAL_R + 0.3 && (!ball.owner || ball.owner.team !== p.team) && ball.pos.y < 1.2) {
ball.owner = p;
p.possGrace = 1.0;
ball.lastTouch = p.team;
p.slide = null;
ball.passRoute = null;
p.lock = { name: 'lift', t: 0, dur: 0.55 };
p.kickCd = 0.55;
p.vel.set(0, 0, 0);
}
}
if (p.slide && p.slide.t >= p.slide.dur) p.slide = null;
return;
}
if (p.lock && p.lock.name === 'lift') {
p.vel.set(0, 0, 0);
return;
}
// 【追加】オートリレーの中継地点:次のパスを出すまで一瞬待機(ワンタッチ連携の演出)。
// ここで操作入力に奪われると白矢印どおりの連携が途切れるため、待機させる。
if (p.autoPassTgt) {
p.vel.set(0, 0, 0);
return;
}
const dashing = (h.dashT || 0) > 0;
const a = inputAxis;
const mv = new THREE.Vector3();
mv.addScaledVector(camDirs.fwd, a.fwd);
mv.addScaledVector(camDirs.right, a.right);
const hasBall = (ball.owner === p);
let autoChase = false;
if (!hasBall && (a.smHold || a.bgHold) && ball.owner && ball.owner.team !== p.team) {
autoChase = true;
const tp = ball.owner.pos;
const dist = Math.hypot(tp.x - p.pos.x, tp.z - p.pos.z);
if (dist <= SLIDE_RANGE) {
if ((p.slideCd || 0) <= 0) {
startSlide(p);
return;
}
} else {
if ((h.dashT || 0) <= 0 && h.stamina > DASH_MIN) h.dashT = DASH_DUR;
const to = new THREE.Vector3(tp.x - p.pos.x, 0, tp.z - p.pos.z).normalize();
mv.copy(to);
}
}
if (mv.lengthSq() > 0.01) {
mv.normalize();
const sp = hasBall ? DRIB_SPD : (dashing ? DASH_SPEED : RUN_SPD);
p.pos.x += mv.x * sp * dt;
p.pos.z += mv.z * sp * dt;
p.facing = Math.atan2(mv.x, mv.z);
p.vel.set(mv.x * sp, 0, mv.z * sp);
} else {
if (ball.passRoute && ball.passRoute[0] === p) {
const toBall = new THREE.Vector3(ball.pos.x - p.pos.x, 0, ball.pos.z - p.pos.z);
if (toBall.lengthSq() > 0.05) {
toBall.normalize();
const sp = RUN_SPD;
p.pos.x += toBall.x * sp * dt;
p.pos.z += toBall.z * sp * dt;
p.facing = Math.atan2(toBall.x, toBall.z);
p.vel.set(toBall.x * sp, 0, toBall.z * sp);
} else {
p.vel.set(0, 0, 0);
}
} else {
p.vel.set(0, 0, 0);
if (hasBall) {
const ga = Math.atan2(-p.pos.x, p.team.attackZ - p.pos.z);
p.facing = lerpAngle(p.facing, ga, Math.min(1, dt * 4));
}
}
}
clampPlayer(p);
if (hasBall && p.kickCd <= 0) {
if (a.big) kickWithBall(p, 'big');
else if (a.small) kickWithBall(p, 'small');
}
}
function startSlide(p) {
const tp = ball.owner ? ball.owner.pos : ball.pos;
const dir = new THREE.Vector3(tp.x - p.pos.x, 0, tp.z - p.pos.z);
if (dir.lengthSq() < 0.01) dir.set(Math.sin(p.facing), 0, Math.cos(p.facing));
dir.normalize();
p.slide = { t: 0, dur: SLIDE_DUR, dir };
p.slideCd = SLIDE_CD;
p.lock = { name: 'slide', t: 0, dur: SLIDE_DUR };
}
/* ---- キックと自動連携パス ---- */
function kickWithBall(p, type) {
if (type === 'small') {
const h = humans.find(hm => hm.controlled === p);
let route = h ? h.currentPassRoute : calculatePassRoute(p);
if (route && route.length > 0) {
ball.passRoute = [...route];
passKick(p, route[0]);
} else {
straightKick(p, SMALL_KICK, 0.8);
}
} else {
const gp = goalGapPoint(p.team);
const distToGoal = Math.hypot(gp.x - p.pos.x, gp.z - p.pos.z);
if (distToGoal <= MAX_SHOOT_DIST) {
shootGap(p);
} else {
const route = calculatePassRoute(p, true);
if (route && route.length > 0) {
ball.passRoute = [...route];
passKick(p, route[0]);
} else {
shootGap(p);
}
}
}
}
function straightKick(p, power, lift) {
const f = faceVec(p.facing);
ball.owner = null;
ball.vel.set(f.x * power, lift, f.z * power);
ball.lastTouch = p.team;
p.kickCd = 0.45; p.possGrace = 0;
p.lock = { name: 'kick', t: 0, dur: 0.34 };
}
function shootGap(p) {
ball.passRoute = null;
const gp = goalGapPoint(p.team);
const to = new THREE.Vector3(gp.x - ball.pos.x, 0, gp.z - ball.pos.z);
if (Math.sign(to.z) !== Math.sign(p.team.dir)) {
to.z = p.team.dir * Math.max(0.1, Math.abs(to.z));
}
const d = to.length() || 1;
to.multiplyScalar(1 / d);
ball.owner = null;
ball.vel.set(to.x * BIG_KICK, 4, to.z * BIG_KICK);
ball.lastTouch = p.team;
p.kickCd = 0.45; p.possGrace = 0;
p.facing = Math.atan2(to.x, to.z);
p.lock = { name: 'kick', t: 0, dur: 0.34 };
}
function goalGapPoint(team) {
const gk = otherTeam(team).players.find(o => o.role === 'GK' && !o.sentOff);
const gkx = gk ? gk.pos.x : 0;
const side = gkx >= 0 ? -1 : 1;
return new THREE.Vector3(side * (GOAL_HALF - 1.0), 1.2, team.attackZ);
}
function passKick(p, tgt) {
const to = new THREE.Vector3(tgt.pos.x - ball.pos.x, 0, tgt.pos.z - ball.pos.z);
const dist = to.length() || 1;
to.multiplyScalar(1 / dist);
// 摩擦で「受け手の位置でちょうど止まる」初速 sqrt(2*FRIC*dist) を基準に、
// 受け手が迎えに走る分だけ僅かに強め(1.08)。以前の1.4は約2倍オーバーランしていた。
const power = clamp(Math.sqrt(2 * FRIC * dist) * 1.08, 13, 34);
const lift = dist > 22 ? 1.2 : 0.6;
ball.owner = null;
ball.vel.set(to.x * power, lift, to.z * power);
ball.lastTouch = p.team;
p.kickCd = 0.4; p.possGrace = 0;
p.facing = Math.atan2(to.x, to.z);
p.lock = { name: 'kick', t: 0, dur: 0.3 };
}
function pathClear(a, b, team) {
for (let t = 0; t < 0.95; t += 0.1) {
const x = a.x + (b.x - a.x) * t, z = a.z + (b.z - a.z) * t;
for (const o of otherTeam(team).players) {
if (o.sentOff) continue;
if (Math.hypot(o.pos.x - x, o.pos.z - z) < 2.3) return false;
}
}
return true;
}
/* ---- AIの思考と陣形操作 (FSM & 戦術導入版) ---- */
function aiKick(p, fv, power, lift) {
ball.owner = null;
ball.vel.set(fv.x * power, lift, fv.z * power);
ball.lastTouch = p.team;
p.kickCd = 0.4; p.possGrace = 0;
p.lock = { name: 'kick', t: 0, dur: 0.3 };
}
function opennessOf(team, pos) {
let m = 99;
otherTeam(team).players.forEach(o => {
if (o.sentOff) return;
const d = Math.hypot(o.pos.x - pos.x, o.pos.z - pos.z);
if (d < m) m = d;
});
return m;
}
// 状態遷移の決定 (FSM)
function determineAiState(p, team, opp, isOwner, attacking) {
if (p.role === 'GK') {
return 'GK_PROCESS';
}
if (isOwner) {
return 'BALL_OWNER';
}
if (ball.passRoute && ball.passRoute[0] === p) {
return 'RECEIVER';
}
if (!attacking && p === team.presser) {
return 'PRESS';
}
if (attacking) {
return 'SUPPORT';
}
return 'DEFEND';
}
function updateAI(dt) {
// 【追加】戦術の更新 (後半残り時間と点差で判断)
const len = typeof HALF_LEN !== 'undefined' ? HALF_LEN : 90;
const timeRemaining = len - G.halfElapsed;
G.teams.forEach(t => {
t.tactic = 'NORMAL';
if (G.half === 2 && timeRemaining < 45) { // 後半残り少ない場合
const diff = t.score - otherTeam(t).score;
if (diff < 0) t.tactic = 'ATTACK'; // 負けていればパワープレイ(全員攻撃)
else if (diff > 0) t.tactic = 'DEFEND'; // 勝っていればリトリート(全員守備)
}
});
const owner = ball.owner, ownerTeam = owner ? owner.team : null;
// プレス担当の決定
G.teams.forEach(team => {
let pr = null, pd = 1e9;
team.players.forEach(p => {
if (p.sentOff || p.role === 'GK') return;
const d = Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z);
if (d < pd) { pd = d; pr = p; }
});
team.presser = pr;
});
allPlayers().forEach(p => {
if (p.isControlled || p.sentOff || p.slide) return;
if (p.lock && p.lock.name === 'lift') return;
// 【追加】オートリレーの中継地点:自分の判断(BALL_OWNER等)で蹴らず、
// tickPlayer がセットした次パス(autoPassTgt)が発火するのを待つ。
// これで計画されたパス連携がAIに上書きされなくなる。
if (p.autoPassTgt) { p.vel.set(0, 0, 0); return; }
p.decisionCd -= dt;
const team = p.team, opp = otherTeam(team), sign = team.dir;
const ownGoalZ = team.defendZ, isOwner = (ball.owner === p);
const attacking = (ownerTeam === team);
p.aiState = determineAiState(p, team, opp, isOwner, attacking);
let target = null;
let sp = AI_SPD;
switch (p.aiState) {
case 'GK_PROCESS':
const defSign = Math.sign(ownGoalZ);
if (isOwner) {
p.vel.set(0, 0, 0);
if (p.decisionCd <= 0) {
p.decisionCd = 0.5;
let route = calculatePassRoute(p, false);
if (route && route.length > 0) {
ball.passRoute = [...route];
passKick(p, route[0]);
} else {
aiKick(p, new THREE.Vector3((Math.random() - 0.5) * 0.4, 0, sign).normalize(), BIG_KICK * 0.95, 4);
}
}
return;
}
const towardsGoal = (ball.vel.z * defSign) > 8;
if (towardsGoal && !p.diving && !ball.owner) {
const timeToGoal = (ownGoalZ - ball.pos.z) / ball.vel.z;
if (timeToGoal > 0 && timeToGoal < 3.0) {
const targetX = ball.pos.x + ball.vel.x * timeToGoal;
if (Math.abs(targetX) <= GOAL_HALF + 1.5) {
const dist = Math.abs(ball.pos.z - ownGoalZ);
let goalProb = 1.0;
if (dist > HALF_Z) goalProb = 0.05;
else if (dist > HALF_Z * 0.6) goalProb = 0.15;
else if (dist > HALF_Z * 0.3) goalProb = 0.30;
else goalProb = 1.0;
p.diving = true;
p.diveTargetX = targetX;
p.diveSuccess = Math.random() >= goalProb;
}
}
}
if (p.diving) {
if (ball.owner || (ball.vel.z * defSign) <= 0 || Math.abs(ball.pos.z - ownGoalZ) > HALF_Z * 1.5) {
p.diving = false;
} else {
if (p.diveSuccess) {
const currentTimeToGoal = (ownGoalZ - ball.pos.z) / ball.vel.z;
const dx = p.diveTargetX - p.pos.x;
sp = currentTimeToGoal > 0 ? Math.min(50, Math.abs(dx) / Math.max(0.05, currentTimeToGoal)) : 50;
target = new THREE.Vector3(p.diveTargetX, 0, ownGoalZ - defSign * 0.8);
if (Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z) < 3.5 && ball.pos.y < GOAL_H + 0.5) {
ball.owner = p; p.possGrace = 1.0; ball.lastTouch = team;
p.diving = false; p.decisionCd = 0.6;
p.lock = { name: 'slide', t: 0, dur: 0.6 };
}
} else {
sp = GK_SPD * 0.2;
target = new THREE.Vector3(p.pos.x + Math.sign(p.diveTargetX - p.pos.x) * 0.5, 0, ownGoalZ - defSign * 0.8);
}
}
} else {
target = new THREE.Vector3(clamp(ball.pos.x * 0.5, -GOAL_HALF - 1.0, GOAL_HALF + 1.0), 0, ownGoalZ - defSign * 1.5);
sp = GK_SPD;
}
break;
case 'BALL_OWNER':
if (p.decisionCd <= 0) {
p.decisionCd = 0.22;
const distGoal = Math.abs(team.attackZ - p.pos.z);
const pressed = opp.players.some(o => !o.sentOff && Math.hypot(o.pos.x - p.pos.x, o.pos.z - p.pos.z) < 2.5);
const clear = pathClear(ball.pos, new THREE.Vector3(0, 0, team.attackZ), team);
// 【追加】勝っていて守備固めなら、無理に繋がずクリア優先
if (team.tactic === 'DEFEND' && distGoal > HALF_Z * 1.0 && pressed) {
aiKick(p, new THREE.Vector3((Math.random() - 0.5) * 0.4, 0, sign).normalize(), BIG_KICK, 4);
return;
}
if (distGoal < HALF_Z * 0.42 && clear) {
const gp = goalGapPoint(team);
const to = new THREE.Vector3(gp.x - ball.pos.x, 0, gp.z - ball.pos.z).normalize();
aiKick(p, to, BIG_KICK, 4);
return;
}
let route = calculatePassRoute(p, false);
if (route && route.length > 0) {
const tgt = route[0];
const adv = (tgt.pos.z - p.pos.z) * sign, open = opennessOf(team, tgt.pos);
if (pressed || ((adv > 6 || distGoal < HALF_Z * 0.6) && open > 4 && Math.random() < 0.4)) {
ball.passRoute = [...route];
passKick(p, tgt);
return;
}
}
}
let tx = clamp(p.pos.x * 0.88 + (Math.random() - 0.5) * 1.0, -HALF_X + 5, HALF_X - 5);
const dA = opp.players.find(o => !o.sentOff && (o.pos.z - p.pos.z) * sign > 0 && (o.pos.z - p.pos.z) * sign < 4 && Math.abs(o.pos.x - p.pos.x) < 2.2);
if (dA) tx = clamp(p.pos.x + (p.pos.x >= dA.pos.x ? 2.5 : -2.5), -HALF_X + 5, HALF_X - 5);
target = new THREE.Vector3(tx, 0, team.attackZ);
sp = AI_DRIB;
break;
case 'RECEIVER':
target = ball.pos.clone();
sp = AI_SPD + 3.0;
break;
case 'PRESS':
target = ball.pos.clone();
sp = AI_SPD + 2.5;
const dToBall = Math.hypot(ball.pos.x - p.pos.x, ball.pos.z - p.pos.z);
if (ball.owner && dToBall < SLIDE_RANGE && (p.slideCd || 0) <= 0) {
startSlide(p);
}
break;
case 'SUPPORT':
case 'DEFEND':
// 基本陣形計算
const role = p.role;
const baseAdv = (p.homePos.z - ownGoalZ) * sign, ballAdv = (ball.pos.z - ownGoalZ) * sign;
let push, follow, lo, hi;
if (role === 'DEF') {
push = attacking ? HALF_Z * 0.40 : -HALF_Z * 0.20;
follow = 0.12; lo = HALF_Z * 0.05; hi = HALF_Z * 1.20;
} else if (role === 'MID') {
push = attacking ? HALF_Z * 0.60 : -HALF_Z * 0.10;
follow = 0.30; lo = HALF_Z * 0.20; hi = HALF_Z * 1.62;
} else {
push = attacking ? HALF_Z * 0.80 : -HALF_Z * 0.05;
follow = 0.42; lo = HALF_Z * 0.40; hi = HALF_Z * 1.96;
}
// 【追加】戦術に応じた陣形の押し上げ・引き下げ
let tacticPush = 0;
if (team.tactic === 'ATTACK') {
tacticPush = attacking ? HALF_Z * 0.35 : HALF_Z * 0.15; // 全体的に押し上げる
hi = HALF_Z * 1.9; // 上がりやすくする
} else if (team.tactic === 'DEFEND') {
tacticPush = attacking ? -HALF_Z * 0.15 : -HALF_Z * 0.4; // 全体的に下がる
lo = HALF_Z * 0.05; // 下がりやすくする
}
let adv = clamp(baseAdv + push + tacticPush + (ballAdv - baseAdv) * follow, lo, hi);
let tz = ownGoalZ + sign * adv;
let sx = clamp(p.homePos.x * 0.72 + ball.pos.x * 0.28, -HALF_X + 5, HALF_X - 5);
if (p.aiState === 'SUPPORT' && ballAdv > HALF_Z * 0.3) {
if (role === 'FWD') {
const leadZ = ball.pos.z + sign * 18;
tz = clamp(leadZ, ownGoalZ + sign * HALF_Z * 1.2, ownGoalZ + sign * HALF_Z * 1.9);
const side = p.homePos.x > 0 ? 1 : -1;
sx = clamp(ball.pos.x + side * 14, -GOAL_HALF - 8, GOAL_HALF + 8);
} else if (role === 'MID') {
let openSpace = sx;
const enemyInWay = opp.players.some(o => !o.sentOff && Math.abs(o.pos.z - tz) < 4 && Math.abs(o.pos.x - sx) < 5);
if (enemyInWay) { openSpace = sx > 0 ? sx - 8 : sx + 8; }
sx = clamp(openSpace, -HALF_X + 3, HALF_X - 3);
tz = ownGoalZ + sign * HALF_Z * 1.1;
}
} else if (p.aiState === 'DEFEND') {
// 【追加】リトリート戦術時はさらに自陣深くに引く
if (team.tactic === 'DEFEND') {
tz -= sign * HALF_Z * 0.2;
}
if (ballAdv < HALF_Z * 1.2 && role === 'DEF') {
tz = ownGoalZ + sign * 6;
const side = p.homePos.x > 0 ? 1 : -1;
sx = clamp(ball.pos.x * 0.6 + side * GOAL_HALF, -GOAL_HALF - 5, GOAL_HALF + 5);
} else {
let mk = null, mdd = 1e9;
opp.players.forEach(o => {
if (o.sentOff || o.role === 'GK' || o === owner) return;
if (Math.abs(o.pos.x - p.homePos.x) > HALF_X * 0.55) return;
const d = Math.hypot(o.pos.x - p.pos.x, o.pos.z - p.pos.z);
if (d < mdd) { mdd = d; mk = o; }
});
if (mk) {
const t = 0.35; sx = clamp(mk.pos.x, -HALF_X + 5, HALF_X - 5);
tz = clamp(mk.pos.z + (ownGoalZ - mk.pos.z) * t, ownGoalZ + sign * lo, ownGoalZ + sign * hi);
}
}
}
target = new THREE.Vector3(sx, 0, tz);
break;
}
// 移動実行
let moving = false;
if (target) {
const to = new THREE.Vector3(target.x - p.pos.x, 0, target.z - p.pos.z), d = to.length();
if (d > 0.6) {
to.multiplyScalar(1 / d);
const m = Math.min(sp, d * 3.2);
p.pos.x += to.x * m * dt; p.pos.z += to.z * m * dt;
p.facing = Math.atan2(to.x, to.z);
p.vel.set(to.x * m, 0, to.z * m);
moving = true;
}
}
if (!moving) p.vel.set(0, 0, 0);
clampPlayer(p);
});
}
/* ---- ボールの物理判定とオートリレー ---- */
function checkGoalCollisions() {
const bx = ball.pos.x, by = ball.pos.y, bz = ball.pos.z;
const sign = Math.sign(bz), absZ = Math.abs(bz);
if (absZ > HALF_Z - BALLR && absZ < HALF_Z + GOAL_DEPTH + BALLR) {
if (Math.abs(Math.abs(bx) - GOAL_HALF) < 0.25 && by < GOAL_H) {
ball.vel.x *= -REST; ball.vel.z *= -REST;
ball.pos.x = Math.sign(bx) * (GOAL_HALF + (bx > 0 ? BALLR : -BALLR));
} else if (Math.abs(bx) <= GOAL_HALF && Math.abs(by - GOAL_H) < 0.25 && absZ < HALF_Z + 0.22) {
ball.vel.y *= -REST; ball.vel.z *= -REST;
ball.pos.y = GOAL_H + BALLR;
} else if (Math.abs(bx) <= GOAL_HALF && absZ >= HALF_Z + GOAL_DEPTH - BALLR && by < GOAL_H) {
ball.vel.z *= -0.2;
ball.pos.z = sign * (HALF_Z + GOAL_DEPTH - BALLR);
}
}
}
function tryPickup() {
if (ball.owner) return;
const s = Math.hypot(ball.vel.x, ball.vel.z);
let best = null, bd = PICK_R;
allPlayers().forEach(p => {
if (p.sentOff || p.kickCd > 0 || (p.lock && p.lock.name === 'lift')) return;
if (ball.passRoute && ball.passRoute.length > 0) {
if (p.team === ball.lastTouch && ball.passRoute[0] !== p) {
return;
}
}
const d = Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z);
const isPassTarget = ball.passRoute && ball.passRoute[0] === p;
if (d < bd && (s < PICK_MAXSPEED || p.role === 'GK' || isPassTarget) && ball.pos.y < 1.2) { bd = d; best = p; }
});
if (best) {
ball.owner = best; best.possGrace = GRACE; ball.lastTouch = best.team;
if (ball.passRoute && ball.passRoute.length > 0) {
if (ball.passRoute[0] === best) {
ball.passRoute.shift();
if (ball.passRoute.length > 0) {
best.autoPassTgt = ball.passRoute[0];
best.autoPassDelay = 0.15;
} else {
ball.passRoute = null;
}
} else if (best.team !== ball.lastTouch) {
ball.passRoute = null;
}
}
}
}
function ballUpdate(dt) {
if (ball.owner && !ball.owner.sentOff) {
const p = ball.owner, f = faceVec(p.facing);
ball.pos.set(p.pos.x + f.x * LEAD, BALLR, p.pos.z + f.z * LEAD);
ball.vel.set(0, 0, 0); ball.lastTouch = p.team;
if (p.possGrace <= 0) {
let st = null, sd = STEAL_R;
otherTeam(p.team).players.forEach(o => {
if (o.sentOff) return;
const d = Math.hypot(o.pos.x - ball.pos.x, o.pos.z - ball.pos.z);
if (d < sd) { sd = d; st = o; }
});
if (st) {
const ch = (st.isControlled ? STEAL_HUMAN : STEAL_AI) * dt;
if (Math.random() < ch) {
ball.owner = st; st.possGrace = GRACE; ball.lastTouch = st.team; ball.passRoute = null;
}
}
}
} else {
if (ball.owner && ball.owner.sentOff) ball.owner = null;
ball.vel.y -= GRAV * dt;
ball.pos.addScaledVector(ball.vel, dt);
if (ball.pos.y <= BALLR) {
ball.pos.y = BALLR;
if (ball.vel.y < 0) ball.vel.y = -ball.vel.y * REST;
if (Math.abs(ball.vel.y) < 0.8) ball.vel.y = 0;
const s = Math.hypot(ball.vel.x, ball.vel.z);
if (s > 0) {
const ns = Math.max(0, s - FRIC * dt), k = ns / s;
ball.vel.x *= k; ball.vel.z *= k;
if (ns < STOP) { ball.vel.x = 0; ball.vel.z = 0; }
}
}
checkGoalCollisions();
const handled = checkBounds();
if (!handled) tryPickup();
}
}
function checkBounds() {
const bx = ball.pos.x, bz = ball.pos.z;
if (bz < -HALF_Z) {
if (Math.abs(bx) < GOAL_HALF - 0.22 && ball.pos.y < GOAL_H) doGoal(otherTeam(teamDefending(-1)));
else goalLineOut(-1, bx);
return true;
}
if (bz > HALF_Z) {
if (Math.abs(bx) < GOAL_HALF - 0.22 && ball.pos.y < GOAL_H) doGoal(otherTeam(teamDefending(1)));
else goalLineOut(1, bx);
return true;
}
if (bx < -HALF_X || bx > HALF_X) { throwIn(bx, bz); return true; }
return false;
}
function goalLineOut(sign, bx) {
const att = teamAttacking(sign), def = otherTeam(att);
if (ball.lastTouch === att) placeRestart(def, clamp(bx * 0.3, -8, 8), sign * (HALF_Z - 5), 'goalkick');
else { const cx = bx >= 0 ? HALF_X - 1.2 : -(HALF_X - 1.2); placeRestart(att, cx, sign * (HALF_Z - 1.2), 'corner'); }
}
function throwIn(bx, bz) {
const team = otherTeam(ball.lastTouch || G.home);
placeRestart(team, bx < 0 ? -(HALF_X - 1.2) : (HALF_X - 1.2), clamp(bz, -HALF_Z + 3, HALF_Z - 3), 'throw');
}
function placeRestart(team, x, z, kind) {
ball.passRoute = null;
ball.vel.set(0, 0, 0); ball.pos.set(x, BALLR, z);
let taker = kind === 'goalkick' ? (team.players.find(p => p.role === 'GK' && !p.sentOff) || nearestTeamPlayer(team, ball.pos)) : nearestTeamPlayer(team, ball.pos);
if (!taker) return;
const fa = Math.atan2(-x, team.attackZ - z), fv = faceVec(fa);
taker.facing = fa; taker.slide = null;
taker.pos.set(clamp(x - fv.x * LEAD, -HALF_X + 1, HALF_X - 1), 0, clamp(z - fv.z * LEAD, -HALF_Z + 1, HALF_Z - 1));
ball.owner = taker; taker.possGrace = 0.9; taker.kickCd = 0.3; ball.lastTouch = team;
}
function doGoal(team) {
if (G.state !== 'play') return;
team.score++; ball.owner = null; ball.vel.set(0, 0, 0); ball.pos.set(0, BALLR, 0);
ball.passRoute = null;
G.state = 'celebrate'; G.celebr = CELEBR; G.kickNext = otherTeam(team);
crowdBounce = CELEBR; burstConfetti();
}
/* ---- 衝突・カード ---- */
function collisions(dt) {
const ps = allPlayers().filter(p => !p.sentOff);
for (let i = 0; i < ps.length; i++) {
for (let j = i + 1; j < ps.length; j++) {
const a = ps[i], b = ps[j], dx = b.pos.x - a.pos.x, dz = b.pos.z - a.pos.z, d = Math.hypot(dx, dz);
if (d < SEP && d > 0.0001) {
const push = (SEP - d) / 2, nx = dx / d, nz = dz / d;
a.pos.x -= nx * push; a.pos.z -= nz * push;
b.pos.x += nx * push; b.pos.z += nz * push;
}
}
}
humans.forEach(h => {
const p = h.controlled;
if (!p || p.sentOff || p.cardCd > 0 || p.vel.lengthSq() < 1) return;
otherTeam(p.team).players.forEach(o => {
if (o.sentOff) return;
const dx = o.pos.x - p.pos.x, dz = o.pos.z - p.pos.z, d = Math.hypot(dx, dz);
if (d < CARD_DIST) {
const dot = p.vel.x * dx + p.vel.z * dz;
if (dot > 0 || ball.owner === o) foul(p, o);
}
});
});
}
function foul(p, victim) {
p.cardCd = CARD_CD;
giveCard(p);
p.slide = null; ball.passRoute = null;
const dx = p.pos.x - victim.pos.x, dz = p.pos.z - victim.pos.z, d = Math.hypot(dx, dz) || 1;
p.pos.x += dx / d * 0.5; p.pos.z += dz / d * 0.5;
ball.owner = null; ball.vel.set(0, 0, 0); ball.pos.set(victim.pos.x, BALLR, victim.pos.z);
ball.owner = victim; victim.possGrace = 0.9; ball.lastTouch = victim.team;
}
function giveCard(p) {
p.yellow++;
if (p.yellow >= 3) { p.sentOff = true; p.root.visible = false; p.shadow.visible = false; if (ball.owner === p) ball.owner = null; }
}main.js
"use strict";
/* =====================================================================
main.js — 入力、HUD・UI更新、メインループ、初期化
===================================================================== */
/* ---------- 音 ---------- */
let ac = null;
function audioInit() { if (ac) return; try { ac = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {} }
function tone(f, dur, type, vol, slide) {
if (!ac) return; if (ac.state === 'suspended') ac.resume();
const o = ac.createOscillator(), g = ac.createGain();
o.type = type || 'sine'; o.frequency.value = f;
if (slide) o.frequency.exponentialRampToValueAtTime(slide, ac.currentTime + dur);
o.connect(g); g.connect(ac.destination);
g.gain.setValueAtTime(0.0001, ac.currentTime);
g.gain.exponentialRampToValueAtTime(vol || 0.2, ac.currentTime + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + dur);
o.start(); o.stop(ac.currentTime + dur + 0.02);
}
function whistle() { tone(2100, 0.16, 'square', 0.1, 2300); setTimeout(() => tone(2300, 0.12, 'square', 0.09, 2000), 110); }
function kickSnd() { tone(150, 0.1, 'square', 0.22, 60); }
function cheer() {
if (!ac) return; if (ac.state === 'suspended') ac.resume();
const b = ac.createBuffer(1, ac.sampleRate * 1.2, ac.sampleRate), d = b.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.4) * 0.45;
const s = ac.createBufferSource(); s.buffer = b;
const f = ac.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 900;
const g = ac.createGain(); g.gain.value = 0.5;
s.connect(f); f.connect(g); g.connect(ac.destination); s.start();
for (let k = 0; k < 6; k++) setTimeout(() => tone(400 + Math.random() * 500, 0.4, 'triangle', 0.05, 300 + Math.random() * 400), k * 60);
}
/* ---------- 矢印生成・逆三角形マーカー ---------- */
function buildDottedArrow(colorHex) {
const mat = new THREE.MeshBasicMaterial({ color: colorHex, transparent: true, opacity: 0.95 });
const dashes = [];
// 長距離(シュートの停止位置等)まで届くようにダッシュの数を多め(60個)に設定
for (let i = 0; i < 60; i++) {
const d = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.03, 0.4), mat);
d.visible = false; scene.add(d); dashes.push(d);
}
const head = new THREE.Group();
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.34, 0.7, 10), mat);
cone.rotation.x = Math.PI / 2; head.add(cone); head.visible = false; scene.add(head);
return { dashes, head, mat };
}
function updateArrow(arr, ax, az, bx, bz) {
const dx = bx - ax, dz = bz - az, dist = Math.hypot(dx, dz);
if (dist < 1.0) { hideArrow(arr); return; }
const ang = Math.atan2(dx, dz), ux = dx / dist, uz = dz / dist, headLen = 0.8, spacing = 0.95;
let n = Math.floor((dist - headLen) / spacing);
if (n > arr.dashes.length) n = arr.dashes.length; if (n < 0) n = 0;
for (let i = 0; i < arr.dashes.length; i++) {
const d = arr.dashes[i];
if (i < n) { const t = (i + 0.5) * spacing; d.position.set(ax + ux * t, 0.08, az + uz * t); d.rotation.y = ang; d.visible = true; }
else d.visible = false;
}
arr.head.position.set(ax + ux * (dist - headLen * 0.5), 0.1, az + uz * (dist - headLen * 0.5));
arr.head.rotation.y = ang; arr.head.visible = true;
}
function hideArrow(arr) { arr.dashes.forEach(d => d.visible = false); arr.head.visible = false; }
function buildAim(teamColor) {
// 足元の選択リング
const sel = new THREE.Mesh(new THREE.RingGeometry(0.66, 0.95, 28), new THREE.MeshBasicMaterial({ color: teamColor, transparent: true, opacity: 0.9, side: THREE.DoubleSide }));
sel.rotation.x = -Math.PI / 2; sel.visible = false; scene.add(sel);
// 頭上の逆三角形マーカー
const markerMat = new THREE.MeshBasicMaterial({ color: teamColor, transparent: true, opacity: 0.95 });
const marker = new THREE.Mesh(new THREE.ConeGeometry(0.4, 0.9, 4), markerMat);
marker.rotation.x = Math.PI; // 逆さにする
marker.rotation.y = Math.PI / 4; // ひし形っぽく
marker.visible = false;
scene.add(marker);
return {
sel,
marker,
whites: [buildDottedArrow(0xffffff), buildDottedArrow(0xffffff), buildDottedArrow(0xffffff)],
yellow: buildDottedArrow(0xf2c84b)
};
}
/* ---------- 試合管理 ---------- */
function clearMatch() {
if (G.home) {
allPlayers().forEach(p => { scene.remove(p.root); scene.remove(p.shadow); });
G.teams.forEach(t => { if (t.bench) t.bench.forEach(r => scene.remove(r)); });
}
humans.forEach(h => {
if (h.aim) {
scene.remove(h.aim.sel);
scene.remove(h.aim.marker);
h.aim.whites.forEach(w => { w.dashes.forEach(d => scene.remove(d)); scene.remove(w.head); });
h.aim.yellow.dashes.forEach(d => scene.remove(d)); scene.remove(h.aim.yellow.head);
}
});
humans = []; G.home = null; G.away = null; G.teams = [];
}
function createPlayers(team) {
FORM.forEach(slot => {
const v = makePlayer(team.color, slot.role); scene.add(v.root);
team.players.push({
root: v.root, parts: v.parts, team, role: slot.role, pos: new THREE.Vector3(), homePos: new THREE.Vector3(),
facing: 0, vel: new THREE.Vector3(), lock: null, animT: Math.random() * 6, yellow: 0, sentOff: false,
kickCd: 0, possGrace: 0, cardCd: 0, slideCd: 0, slide: null, decisionCd: 0, isControlled: false, shadow: blobShadow(0.5),
autoPassTgt: null, autoPassDelay: 0
});
});
}
function buildBench(team) { team.bench = []; for (let i = 0; i < 4; i++) { const v = makePlayer(team.color, 'F'); scene.add(v.root); team.bench.push(v.root); } }
function positionBenches() {
G.teams.forEach(team => {
const sign = Math.sign(team.defendZ);
team.bench.forEach((r, i) => { const sideX = (i % 2 === 0 ? -1 : 1) * (HALF_X + 4.5); const k = Math.floor(i / 2); r.position.set(sideX, FOOT * PLAYER_SY, sign * (10 + k * 14)); r.rotation.y = sideX < 0 ? Math.PI / 2 : -Math.PI / 2; });
});
}
function kickoff(team) {
placeFormation(G.home); placeFormation(G.away); positionBenches();
humans.forEach(h => { h.defView = false; h.flipT = 0; h.camPhi = undefined; h.stamina = 1; h.dashT = 0; h.currentPassRoute = null; });
const f = team.players.find(p => p.role === 'FWD') || team.players[team.players.length - 1];
f.facing = Math.atan2(0, team.attackZ); const fv = faceVec(f.facing);
f.pos.set(-fv.x * LEAD, 0, -fv.z * LEAD);
ball.pos.set(0, BALLR, 0); ball.vel.set(0, 0, 0); ball.owner = f; f.possGrace = 0.7; f.kickCd = 0; f.slide = null; ball.lastTouch = team; ball.passRoute = null;
whistle(); updateHUD();
}
function newMatch() {
clearMatch();
let hc = G.colorChoice; if (hc === 'random') hc = Math.random() < 0.5 ? 'red' : 'blue';
const homeColor = hc === 'red' ? RED : BLUE, awayColor = hc === 'red' ? BLUE : RED;
const home = makeTeam(homeColor, homeColor === RED ? 'RED' : 'BLUE', true);
const away = makeTeam(awayColor, awayColor === RED ? 'RED' : 'BLUE', G.mode === 'multi');
G.home = home; G.away = away; G.teams = [home, away];
home.defendZ = HALF_Z; home.attackZ = -HALF_Z; home.dir = -1;
away.defendZ = -HALF_Z; away.attackZ = HALF_Z; away.dir = 1;
createPlayers(home); createPlayers(away); buildBench(home); buildBench(away);
placeFormation(home); placeFormation(away); positionBenches();
if (G.mode === 'single') humans = [{ team: home, scheme: 'P1', cam: cam1, controlled: null, aim: buildAim(home.color), stamina: 1, currentPassRoute: null }];
else humans = [{ team: home, scheme: 'P1', cam: cam1, controlled: null, aim: buildAim(home.color), stamina: 1, currentPassRoute: null },
{ team: away, scheme: 'P2', cam: cam2, controlled: null, aim: buildAim(away.color), stamina: 1, currentPassRoute: null }];
G.half = 1; G.halfElapsed = 0; G.firstKicker = home; G.started = true; G.state = 'play';
audioInit(); setHelp(); showScreen('scrTitle', false); $('hud').classList.add('on');
updateHUD(); kickoff(home);
}
function swapSides() { G.teams.forEach(t => { const d = t.defendZ; t.defendZ = t.attackZ; t.attackZ = d; t.dir = -t.dir; }); placeFormation(G.home); placeFormation(G.away); positionBenches(); }
/* ---------- 入力 ---------- */
const keys = new Set(), justPressed = new Set();
const GAME_CODES = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyZ', 'KeyX', 'Numpad8', 'Numpad2', 'Numpad4', 'Numpad6', 'Numpad5', 'KeyN', 'KeyM', 'Enter', 'Space']);
window.addEventListener('keydown', e => {
if (GAME_CODES.has(e.code)) e.preventDefault();
if (!e.repeat) { justPressed.add(e.code); if (e.code === 'Enter') { if (G.state === 'menu') startMatch(); else if (G.state === 'fulltime') newMatch(); } }
keys.add(e.code);
});
window.addEventListener('keyup', e => keys.delete(e.code));
function axis(scheme) {
let up, dn, lf, rt, sm, bg, smHold, bgHold;
if (scheme === 'P1') {
up = keys.has('ArrowUp'); dn = keys.has('ArrowDown'); lf = keys.has('ArrowLeft'); rt = keys.has('ArrowRight');
sm = justPressed.has('KeyZ'); bg = justPressed.has('KeyX');
smHold = keys.has('KeyZ'); bgHold = keys.has('KeyX');
} else {
up = keys.has('Numpad8'); dn = keys.has('Numpad2'); lf = keys.has('Numpad4'); rt = keys.has('Numpad6');
sm = justPressed.has('KeyN'); bg = justPressed.has('KeyM');
smHold = keys.has('KeyN'); bgHold = keys.has('KeyM');
}
return { fwd: (up ? 1 : 0) - (dn ? 1 : 0), right: (rt ? 1 : 0) - (lf ? 1 : 0), small: sm, big: bg, smHold, bgHold };
}
function camDirs(cam) { const d = new THREE.Vector3(); cam.getWorldDirection(d); d.y = 0; if (d.lengthSq() < 1e-6) d.set(0, 0, -1); d.normalize(); const r = new THREE.Vector3().crossVectors(d, UP).normalize(); return { fwd: d, right: r }; }
/* ---------- HUD / UI ---------- */
function setHelp() {
const k = s => `<span class="k">${s}</span>`;
let html;
if (G.mode === 'single') {
html = `<h4>CONTROLS</h4>移動 ${k('←↑↓→')} パス/自動攻撃 ${k('Z')} シュート ${k('X')}<br>`
+ `<span style="opacity:.7">保持中… Z=パス / X=シュート(届かない場合は自動攻撃パスへ)<br>`
+ `非保持中… <b>Z/X長押し</b>で敵へダッシュ&近づくと自動奪取(奪取後は一瞬の演出あり)<br>`
+ `敵に体当たりで<b style="color:var(--gold)">イエロー</b>、3枚で<b style="color:#f55">退場</b>。</span>`;
} else {
html = `<h4>CONTROLS</h4><span style="color:var(--red)">P1(左)</span> 移動 ${k('←↑↓→')} 小 ${k('Z')} 大 ${k('X')}<br>`
+ `<span style="color:var(--blue)">P2(右)</span> 移動 テンキー ${k('8')}${k('4')}${k('5')}${k('6')}${k('2')} 小 ${k('N')} 大 ${k('M')}<br>`
+ `<span style="opacity:.7">長押しでオートチェイス&奪取。保持中はXでシュート、届かなければ自動連携。</span>`;
}
$('help').innerHTML = html;
}
function cardHtml(team) { let s = ''; for (let i = 0; i < team.players.length; i++) { const p = team.players[i]; for (let y = 0; y < Math.min(2, p.yellow); y++) s += '<div class="yc"></div>'; if (p.sentOff) s += '<div class="rc"></div>'; } return s; }
function updateHUD() {
const L = G.home, R = G.away; if (!L) return;
$('nmL').textContent = L.name; $('nmR').textContent = R.name;
$('nmL').className = 'nm ' + (L.color === RED ? 'pillR' : 'pillB'); $('nmR').className = 'nm ' + (R.color === RED ? 'pillR' : 'pillB');
$('scL').textContent = L.score; $('scR').textContent = R.score;
$('cardsL').innerHTML = cardHtml(L); $('cardsR').innerHTML = cardHtml(R);
const minute = Math.floor(((G.half - 1) * HALF_LEN + G.halfElapsed) / (2 * HALF_LEN) * 90);
$('clk').textContent = minute + "'"; $('hlf').textContent = G.half === 1 ? '1ST HALF' : '2ND HALF';
let pn = '—'; if (ball.owner) pn = ball.owner.team.name + ' ' + ball.owner.role;
$('possName').textContent = pn;
updateStamina(); drawMinimap();
}
function updateStamina() {
const setBar = (id, h) => { const f = $(id); if (!f) return; const s = Math.max(0, Math.min(1, h ? h.stamina : 0)); f.style.width = (s * 100) + '%'; f.classList.toggle('low', s < DASH_MIN); };
const multi = G.mode === 'multi';
$('stamBox1').style.display = 'flex'; $('stamBox2').style.display = multi ? 'flex' : 'none';
$('sl1').style.color = humans[0] ? (humans[0].team.color === RED ? '#ff8d8d' : '#8db9ff') : '#fff'; setBar('sf1', humans[0]);
if (multi) { $('sl2').style.color = humans[1] ? (humans[1].team.color === RED ? '#ff8d8d' : '#8db9ff') : '#fff'; setBar('sf2', humans[1]); }
}
function drawMinimap() {
const cv = $('mini'); if (!cv) return; const x = cv.getContext('2d'), W = cv.width, H = cv.height;
x.fillStyle = '#1f5a2a'; x.fillRect(0, 0, W, H);
x.strokeStyle = 'rgba(255,255,255,.5)'; x.lineWidth = 2; x.strokeRect(4, 4, W - 8, H - 8);
x.beginPath(); x.moveTo(4, H / 2); x.lineTo(W - 4, H / 2); x.stroke();
const mx = v => 8 + (v + HALF_X) / (2 * HALF_X) * (W - 16);
const mz = v => 8 + (v + HALF_Z) / (2 * HALF_Z) * (H - 16);
x.fillStyle = 'rgba(255,255,255,.7)';
x.fillRect(mx(-GOAL_HALF), H - 12, (mx(GOAL_HALF) - mx(-GOAL_HALF)), 8);
x.fillRect(mx(-GOAL_HALF), 4, (mx(GOAL_HALF) - mx(-GOAL_HALF)), 8);
if (!G.home) return;
// 点滅用のフラグ(400ms周期)
const blink = (Date.now() % 400 < 200);
allPlayers().forEach(p => {
if (p.sentOff) return;
// 操作中、またはボールを持っている選手は強調(点滅)させる
if ((p.isControlled || ball.owner === p) && blink) {
x.fillStyle = '#ffffff';
} else {
x.fillStyle = '#' + p.team.color.toString(16).padStart(6, '0');
}
x.beginPath(); x.arc(mx(p.pos.x), mz(p.pos.z), 5.6, 0, 6.283); x.fill();
if (p.isControlled) { x.strokeStyle = '#fff'; x.lineWidth = 2.4; x.stroke(); }
});
// ボール単体も点滅させて見失わないように
x.fillStyle = (blink && !ball.owner) ? '#ffea00' : '#fff';
x.beginPath(); x.arc(mx(ball.pos.x), mz(ball.pos.z), 5.2, 0, 6.283); x.fill();
x.strokeStyle = '#000'; x.lineWidth = 1; x.stroke();
}
function showGoalBanner(team) {
const c = team.color === RED ? 'var(--red)' : 'var(--blue)';
$('bnBig').style.color = c; $('bnBig').textContent = 'GOAL!';
$('bnSub').textContent = team.name + ' ' + G.home.score + ' - ' + G.away.score;
$('banner').classList.add('on');
}
function hideGoalBanner() { $('banner').classList.remove('on'); }
function flashCard(red) { const fx = $('cardfx'); fx.style.background = red ? '#cc1122' : '#e8b400'; fx.classList.add('on'); setTimeout(() => fx.classList.remove('on'), 260); }
function showScreen(id, on) { $(id).classList.toggle('on', on); }
function showHalftime() { $('htScore').textContent = G.home.score + ' – ' + G.away.score; showScreen('scrHalf', true); }
function hideHalftime() { showScreen('scrHalf', false); }
function showFulltime() {
showScreen('scrFull', true); $('ftScore').textContent = G.home.score + ' – ' + G.away.score;
let desc; if (G.home.score === G.away.score) desc = 'DRAW — 引き分け'; else { const w = G.home.score > G.away.score ? G.home : G.away; desc = w.name + ' の勝利!'; }
$('ftDesc').textContent = desc; $('hud').classList.remove('on');
}
/* ---------- 描画 ---------- */
function drawAim(h) {
const A = h.aim, p = h.controlled;
if (!p || p.sentOff) {
A.sel.visible = false;
A.marker.visible = false;
A.whites.forEach(hideArrow);
hideArrow(A.yellow);
return;
}
// 足元の操作中リング
A.sel.visible = true; A.sel.position.set(p.pos.x, 0.05, p.pos.z);
if (ball.owner === p) {
// ボール保持中の頭上逆三角形マーカー
A.marker.visible = true;
const bounce = Math.sin(Date.now() / 150) * 0.2;
A.marker.position.set(p.pos.x, 3.5 + bounce, p.pos.z);
// --- 白色:パスルートの矢印 ---
const route = calculatePassRoute(p, false);
h.currentPassRoute = route;
A.whites.forEach(hideArrow);
if (route && route.length > 0) {
let startPt = ball.pos;
for (let i = 0; i < route.length && i < A.whites.length; i++) {
updateArrow(A.whites[i], startPt.x, startPt.z, route[i].pos.x, route[i].pos.z);
startPt = route[i].pos;
}
}
// --- 黄色:シュートの矢印 ---
const gp = goalGapPoint(p.team);
const distToGoal = Math.hypot(gp.x - ball.pos.x, gp.z - ball.pos.z);
// BIG_KICKの威力によるボールが自然停止するおおよその距離
const shootMaxReach = 52.0;
if (distToGoal <= MAX_SHOOT_DIST) {
// シュートが届く距離:点滅させる
if (Date.now() % 400 < 200) {
updateArrow(A.yellow, ball.pos.x, ball.pos.z, gp.x, gp.z);
} else {
hideArrow(A.yellow);
}
} else {
// シュートが届かない距離:ボールが停止する限界位置まで矢印を伸ばす
const toGoal = new THREE.Vector3(gp.x - ball.pos.x, 0, gp.z - ball.pos.z).normalize().multiplyScalar(shootMaxReach);
updateArrow(A.yellow, ball.pos.x, ball.pos.z, ball.pos.x + toGoal.x, ball.pos.z + toGoal.z);
}
} else {
// 非保持時はパスの受け手などにマーカー・矢印を表示
A.marker.visible = false;
h.currentPassRoute = null;
const tp = ball.owner ? ball.owner.pos : ball.pos;
A.whites.forEach(hideArrow); updateArrow(A.whites[0], p.pos.x, p.pos.z, tp.x, tp.z);
hideArrow(A.yellow);
}
}
function updateCamera(h, dt) {
const team = h.team, attackDir = team.dir;
const focus = h.controlled ? h.controlled.pos : ball.pos;
const offZ = -attackDir * CAM_BACK;
h.cam.position.set(focus.x * 0.7, CAM_H, focus.z + offZ);
h.cam.lookAt(focus.x * 0.85, LOOK_Y, focus.z + attackDir * LOOK_AHEAD);
}
function animateAll(dt) {
allPlayers().forEach(p => {
if (p.sentOff) { p.root.visible = false; p.shadow.visible = false; return; }
p.root.visible = true; p.shadow.visible = true;
const sp = Math.sqrt(p.vel.lengthSq());
animatePlayer(p, dt, sp > 0.6, sp);
p.shadow.position.set(p.pos.x, 0.02, p.pos.z);
});
ball.mesh.position.copy(ball.pos);
ball.mesh.rotation.x += ball.vel.z * dt * 0.6; ball.mesh.rotation.z -= ball.vel.x * dt * 0.6;
ball.shadow.position.set(ball.pos.x, 0.02, ball.pos.z);
}
/* ---------- ループ ---------- */
function tickPlayer(p, dt) {
p.cardCd = Math.max(0, p.cardCd - dt); p.kickCd = Math.max(0, p.kickCd - dt);
p.possGrace = Math.max(0, p.possGrace - dt); p.slideCd = Math.max(0, (p.slideCd || 0) - dt);
if (p.autoPassTgt && p.autoPassDelay > 0) {
p.autoPassDelay -= dt;
if (p.autoPassDelay <= 0) {
if (ball.owner === p && !p.sentOff) passKick(p, p.autoPassTgt);
p.autoPassTgt = null;
}
}
}
function checkClock() {
if (G.halfElapsed >= HALF_LEN) {
if (G.half === 1) { G.state = 'halftime'; G.htTimer = 3.6; whistle(); showHalftime(); }
else { G.state = 'fulltime'; whistle(); showFulltime(); }
}
}
function step(dt) {
allPlayers().forEach(p => { p.isControlled = false; tickPlayer(p, dt); });
humans.forEach(h => { chooseControlled(h); if (h.controlled) h.controlled.isControlled = true; });
humans.forEach(h => {
if ((h.dashT || 0) > 0) { h.dashT -= dt; h.stamina = Math.max(0, (h.stamina || 0) - STAM_DRAIN * dt); if (h.stamina <= 0) h.dashT = 0; }
else h.stamina = Math.min(1, (h.stamina || 0) + STAM_RECOVER * dt);
});
humans.forEach(h => updateCamera(h, dt));
humans.forEach(h => updateHuman(h, dt, axis(h.scheme), camDirs(h.cam)));
updateAI(dt);
ballUpdate(dt);
collisions(dt);
G.halfElapsed += dt; checkClock();
}
let last = performance.now();
function loop(now) {
requestAnimationFrame(loop);
let dt = (now - last) / 1000; last = now; if (dt > 0.05) dt = 0.05;
if (G.state === 'play') {
step(dt);
if (G.state === 'celebrate') { whistle(); cheer(); showGoalBanner(otherTeam(G.kickNext)); }
} else if (G.state === 'celebrate') {
G.celebr -= dt; if (G.celebr <= 0) { hideGoalBanner(); G.state = 'play'; kickoff(G.kickNext); }
} else if (G.state === 'halftime') {
G.htTimer -= dt; if (G.htTimer <= 0) { swapSides(); G.half = 2; G.halfElapsed = 0; hideHalftime(); G.state = 'play'; kickoff(G.firstKicker === G.home ? G.away : G.home); }
}
if (G.started) { animateAll(dt); humans.forEach(h => drawAim(h)); updateHUD(); }
updateConfetti(dt); updateCrowd(dt);
renderScene();
justPressed.clear();
}
function renderScene() {
const W = window.innerWidth, H = window.innerHeight;
if (G.mode === 'multi' && G.started) {
renderer.setScissorTest(true);
renderer.setViewport(0, 0, W / 2, H); renderer.setScissor(0, 0, W / 2, H); cam1.aspect = (W / 2) / H; cam1.updateProjectionMatrix(); renderer.render(scene, cam1);
renderer.setViewport(W / 2, 0, W / 2, H); renderer.setScissor(W / 2, 0, W / 2, H); cam2.aspect = (W / 2) / H; cam2.updateProjectionMatrix(); renderer.render(scene, cam2);
renderer.setScissorTest(false);
} else {
renderer.setViewport(0, 0, W, H); renderer.setScissorTest(false); cam1.aspect = W / H; cam1.updateProjectionMatrix(); renderer.render(scene, cam1);
}
}
/* ---------- メニュー・起動 ---------- */
function startMatch() { G.state = 'play'; newMatch(); }
function wireMenu() {
document.querySelectorAll('[data-mode]').forEach(el => el.addEventListener('click', () => {
document.querySelectorAll('[data-mode]').forEach(x => x.classList.remove('act')); el.classList.add('act'); G.mode = el.dataset.mode; }));
document.querySelectorAll('[data-color]').forEach(el => el.addEventListener('click', () => {
document.querySelectorAll('[data-color]').forEach(x => x.classList.remove('act')); el.classList.add('act'); G.colorChoice = el.dataset.color; }));
$('start').addEventListener('click', () => { audioInit(); startMatch(); });
$('ftAgain').addEventListener('click', () => { showScreen('scrFull', false); newMatch(); });
$('ftMenu').addEventListener('click', () => { showScreen('scrFull', false); G.started = false; G.state = 'menu'; clearMatch(); showScreen('scrTitle', true); cam1.position.set(0, 30, HALF_Z + 34); cam1.lookAt(0, 0, 0); });
}
// 起動シーケンス
initThree();
buildField();
buildBall();
wireMenu();
requestAnimationFrame(loop);

コメント