サッカーゲーム「MultiverseSoccer」
【更新履歴】
・2026/6/16 バージョン1.0公開。
(中略)
・2026/6/16 バージョン1.3公開。(大会モードを追加)
・2026/6/17 バージョン1.4公開。(スタジアムを改装)
・ダウンロードされる方はこちら。↓
・このゲームで遊ぶには、いつものテスト用ブラウザが必要です。↓
【概要】
・手軽に操作できるサッカーゲームです。
・大会モードでは、FIFAランキングの上位チームに加え、
別の世界線では滅びずに覇権国となったローマ帝国などと
対決することが出来ます。
・他の世界線が攻めてきたら、
おちおち寝ておれないということで、
スポーツで決着を付けることにしたとか何とか。
(名前が出るだけなんですけどね。)
【操作説明】
・方向キーで移動。
(ボールを持っている味方選手を操作する。
ボールを奪われた時は、そのままの選手を移動させ、
ダッシュして追いつき、スライディング決めて
ボールを奪い取る。)
・ボールを持っている時は、
・Zキーで近くにいる味方の選手にボールをパスする。
・Xキーで相手側のゴールに向けてシュートをする。
・ボールを持っていない時は、
・ZキーまたはXキーで、
ボールを持っている相手選手に向けてダッシュする。
(スタミナゲージを消費するので、回復するまでできない。)
・スライディングしてボールが取れる時は、スライディングする。
・ZキーとXキーを同時押しすると、フェイントがかかります。
・Aキーを押すと、作戦を切り替えることができます。
(作戦ボタンをクリックしても切り替わります。)
・ロングシュートは、相手側のキーパーが間に合うので
決まりにくくなっている。
【マルチプレイモード】
・テンキー(4で左、8で上、6で右、2で下)で移動。
・ボールを持っている時は、
・Nキーでパス。
・Mキーでシュート。
・ボールを持っていない時は、
・NキーまたはMキーで、ダッシュやスライディング。
・NキーとMキーを同時押しすると、フェイントがかかります。
・Hキーを押すと、作戦を切り替えることができます。
(作戦ボタンをクリックしても切り替わります。)
・ソースコードはこちら。↓
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: #080d0a;
--panel: rgba(15, 25, 20, 0.75);
--line: rgba(255, 255, 255, 0.2);
--red: #ff4a4a;
--blue: #3b8cff;
--gold: #ffcf40;
--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; 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: 20px; left: 50%; transform: translateX(-50%);
display: flex; align-items: stretch; background: var(--panel);
border: 1px solid var(--line); border-radius: 16px; overflow: hidden; backdrop-filter: blur(10px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
.side { display: flex; align-items: center; gap: 12px; padding: 10px 20px; }
.side .nm { font-weight: 900; letter-spacing: .15em; font-size: 16px; max-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #ffffff !important; text-shadow: 0 2px 4px rgba(0,0,0,0.8); }
.pillR { background: linear-gradient(135deg, rgba(255,74,74,0.6), rgba(200,30,30,0.8)); box-shadow: 0 0 0 1px var(--red) inset; border-radius: 8px; padding: 4px 10px; }
.pillB { background: linear-gradient(135deg, rgba(59,140,255,0.6), rgba(30,90,200,0.8)); box-shadow: 0 0 0 1px var(--blue) inset; border-radius: 8px; padding: 4px 10px; }
.side .cards { display: flex; gap: 4px; margin-top: 4px; }
.yc, .rc { width: 9px; height: 13px; border-radius: 2px; box-shadow: 0 1px 3px rgba(0,0,0,0.5); }
.yc { background: var(--gold); }
.rc { background: #e31b1b; }
.col { display: flex; flex-direction: column; align-items: center; }
.score { font-size: 34px; font-weight: 900; line-height: 1; font-variant-numeric: tabular-nums; padding: 8px 20px; min-width: 60px; text-align: center; background: rgba(0,0,0,0.4); text-shadow: 0 2px 5px rgba(0,0,0,0.8); }
.mid { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 8px 18px; background: rgba(0,0,0,0.6); border-left: 1px solid var(--line); border-right: 1px solid var(--line); }
.clock { font-size: 22px; font-weight: 900; font-variant-numeric: tabular-nums; color: var(--gold); text-shadow: 0 0 8px rgba(255, 207, 64, 0.5); }
.half { font-size: 12px; letter-spacing: .25em; margin-top: 3px; color: #ffffff !important; font-weight: 900; text-shadow: 0 1px 3px rgba(0,0,0,0.8); }
#poss { position: absolute; top: 110px; left: 50%; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 6px 18px; font-size: 13px; letter-spacing: .12em; backdrop-filter: blur(8px); box-shadow: 0 4px 15px rgba(0,0,0,0.3); }
#poss b { font-weight: 900; color: var(--gold); }
#miniWrap { position: absolute; top: 20px; right: 20px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 10px; backdrop-filter: blur(8px); box-shadow: 0 12px 30px rgba(0,0,0,0.5); transition: transform 0.3s; }
#miniWrap:hover { transform: scale(1.05); }
#miniWrap .ml { font-size: 10px; font-weight: 800; letter-spacing: .25em; opacity: .8; text-align: center; margin-bottom: 6px; }
#mini { display: block; border-radius: 8px; width: 336px; height: 498px; border: 1px solid rgba(255,255,255,0.1); }
#help { position: absolute; left: 20px; bottom: 20px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px 18px; font-size: 12px; line-height: 1.8; max-width: 50vw; backdrop-filter: blur(8px); box-shadow: 0 8px 25px rgba(0,0,0,0.4); }
#help .k { display: inline-block; min-width: 22px; text-align: center; padding: 2px 8px; margin: 0 2px; border: 1px solid var(--line); border-radius: 6px; background: rgba(255,255,255,.15); font-weight: 800; box-shadow: 0 2px 0 rgba(0,0,0,0.3); }
#help h4 { margin: 0 0 6px; font-size: 12px; letter-spacing: .2em; opacity: .9; font-weight: 900; color: var(--gold); }
/* ---- 戦術UI ---- */
#tacticsUI { position: absolute; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 10px; pointer-events: none; z-index: 50; }
.tac-btn { pointer-events: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 16px; font-size: 12px; font-weight: 900; letter-spacing: .12em; cursor: pointer; backdrop-filter: blur(8px); transition: all 0.2s cubic-bezier(0.2, 1.4, 0.4, 1); color: #fff; text-align: center; box-shadow: 0 6px 15px rgba(0,0,0,0.4); }
.tac-btn:hover { transform: translateY(-2px) scale(1.02); background: rgba(255,255,255,.25); }
.tac-btn.atk { border-color: var(--red); color: #ffcccc; box-shadow: 0 0 15px rgba(255,74,74,.5) inset, 0 6px 15px rgba(0,0,0,0.4); }
.tac-btn.def { border-color: var(--blue); color: #cce0ff; box-shadow: 0 0 15px rgba(59,140,255,.5) inset, 0 6px 15px rgba(0,0,0,0.4); }
#stamina { position: absolute; left: 50%; bottom: 20px; transform: translateX(-50%); display: flex; gap: 24px; }
.stam { display: flex; align-items: center; gap: 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 6px 16px; backdrop-filter: blur(8px); box-shadow: 0 4px 15px rgba(0,0,0,0.3); }
.stam .sl { font-size: 13px; font-weight: 900; letter-spacing: .15em; opacity: .9; }
.sbar { width: 140px; height: 10px; border-radius: 6px; background: rgba(0,0,0,.6); overflow: hidden; box-shadow: inset 0 1px 3px rgba(0,0,0,0.8); }
.sfill { height: 100%; width: 100%; border-radius: 6px; background: linear-gradient(90deg, #ffcf40, #4ade80); transition: width 0.1s ease-out; }
.sfill.low { background: linear-gradient(90deg, #e31b1b, #ff4a4a); animation: blink 0.5s infinite alternate; }
@keyframes blink { from { opacity: 0.6; } to { opacity: 1; } }
/* ---- PK UI ---- */
#pkUI { position: absolute; top: 160px; left: 50%; transform: translateX(-50%); display: none; flex-direction: column; align-items: center; gap: 10px; background: var(--panel); border: 1px solid var(--gold); border-radius: 12px; padding: 12px 24px; backdrop-filter: blur(8px); box-shadow: 0 10px 30px rgba(0,0,0,0.6); }
#pkUI.on { display: flex; }
.pk-title { font-size: 16px; font-weight: 900; letter-spacing: 0.2em; color: var(--gold); text-shadow: 0 2px 4px rgba(0,0,0,0.8); }
.pk-history { display: flex; gap: 20px; }
.pk-team-hist { display: flex; gap: 6px; }
.pk-dot { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--line); background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; }
.pk-dot.o { border-color: #4ade80; background: rgba(74,222,128,0.3); color: #4ade80; }
.pk-dot.x { border-color: #ff4a4a; background: rgba(255,74,74,0.3); color: #ff4a4a; }
#pkAimHint { margin-top: 5px; font-size: 13px; font-weight: 800; color: #fff; background: rgba(0,0,0,0.6); padding: 4px 12px; border-radius: 6px; }
/* ---- 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; z-index: 100; }
#banner.on { opacity: 1; }
#banner .big { font-size: clamp(60px, 12vw, 160px); font-weight: 900; font-style: italic; letter-spacing: .05em; text-shadow: 0 10px 50px rgba(0,0,0,.6); -webkit-text-stroke: 3px rgba(0,0,0,.4); animation: pop .5s cubic-bezier(.175, 1.885, .32, 1); }
#banner .sub { font-size: clamp(18px, 3vw, 30px); letter-spacing: .35em; margin-top: 10px; font-weight: 900; background: rgba(0,0,0,0.5); padding: 5px 20px; border-radius: 8px; backdrop-filter: blur(4px); }
@keyframes pop { 0% { transform: scale(.4) translateY(50px); opacity: 0; } 100% { transform: scale(1) translateY(0); opacity: 1; } }
#cardfx { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity .15s; z-index: 90; }
#cardfx.on { opacity: .6; }
/* ---- overlay screens ---- */
.screen { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; flex-direction: column; text-align: center; background: radial-gradient(circle at center, rgba(10,25,15,0.8), rgba(0,5,2,1)); z-index: 200; backdrop-filter: blur(6px); }
.screen.on { display: flex; }
.kicker { font-size: 16px; letter-spacing: .5em; color: var(--gold); font-weight: 900; margin-bottom: 15px; text-shadow: 0 0 15px rgba(255,207,64,0.5); }
.gtitle { font-size: clamp(48px, 9vw, 110px); font-weight: 900; line-height: .95; letter-spacing: .02em; margin: 0; text-shadow: 0 10px 30px rgba(0,0,0,0.8); }
.gtitle .a { color: var(--red); } .gtitle .b { color: var(--blue); }
.gsub { font-size: 15px; letter-spacing: .3em; opacity: .85; margin-top: 16px; font-weight: 800; background: rgba(255,255,255,0.05); padding: 8px 20px; border-radius: 8px; }
.sel { margin-top: 40px; display: flex; flex-direction: column; gap: 24px; align-items: center; }
.grp { display: flex; gap: 16px; align-items: center; background: rgba(0,0,0,0.3); padding: 12px 24px; border-radius: 16px; border: 1px solid rgba(255,255,255,0.05); box-shadow: inset 0 2px 10px rgba(0,0,0,0.5); }
.lbl { font-size: 12px; font-weight: 900; letter-spacing: .25em; opacity: .7; width: 90px; text-align: right; color: var(--gold); }
.opt { pointer-events: auto; cursor: pointer; padding: 14px 28px; border: 2px solid var(--line); border-radius: 12px; font-weight: 900; letter-spacing: .15em; font-size: 15px; background: rgba(255,255,255,.05); transition: all .2s cubic-bezier(0.2, 1.4, 0.4, 1); box-shadow: 0 4px 10px rgba(0,0,0,0.3); color: #fff; }
.opt:hover { transform: translateY(-3px) scale(1.05); background: rgba(255,255,255,.15); box-shadow: 0 8px 20px rgba(0,0,0,0.4); }
.opt.act { border-color: var(--gold); background: rgba(255,207,64,.2); box-shadow: 0 0 0 2px var(--gold) inset, 0 5px 15px rgba(255,207,64,0.3); color: var(--gold); }
.opt.r.act { border-color: var(--red); background: rgba(255,74,74,.2); box-shadow: 0 0 0 2px var(--red) inset, 0 5px 15px rgba(255,74,74,0.3); color: #ff8888; }
.opt.bl.act { border-color: var(--blue); background: rgba(59,140,255,.2); box-shadow: 0 0 0 2px var(--blue) inset, 0 5px 15px rgba(59,140,255,0.3); color: #88bbff; }
#start { margin-top: 15px; padding: 18px 60px; font-size: 20px; background: linear-gradient(135deg, var(--gold), #d4a017); color: #111; border: none; box-shadow: 0 10px 30px rgba(255,207,64,0.4); }
#start:hover { filter: brightness(1.15); transform: translateY(-4px) scale(1.08); box-shadow: 0 15px 40px rgba(255,207,64,0.6); }
.hint { margin-top: 25px; font-size: 12px; font-weight: 800; letter-spacing: .2em; opacity: .6; background: rgba(0,0,0,0.5); padding: 6px 16px; border-radius: 8px; }
.result { font-size: clamp(50px, 10vw, 120px); font-weight: 900; font-variant-numeric: tabular-nums; text-shadow: 0 10px 30px rgba(0,0,0,0.8); margin: 10px 0; }
.rdesc { font-size: 20px; font-weight: 900; letter-spacing: .25em; opacity: .9; margin-top: 10px; color: var(--gold); }
.btnrow { margin-top: 35px; display: flex; gap: 20px; }
/* ---- トーナメント表UI ---- */
.bracket-container { display: flex; gap: 30px; align-items: stretch; margin-top: 30px; height: 360px; background: rgba(0,0,0,0.3); padding: 20px; border-radius: 20px; border: 1px solid rgba(255,255,255,0.05); box-shadow: inset 0 5px 20px rgba(0,0,0,0.5); }
.b-col { display: flex; flex-direction: column; justify-content: space-around; width: 160px; position: relative; }
.b-col::after { content: ''; position: absolute; right: -15px; top: 10%; bottom: 10%; width: 2px; background: rgba(255,255,255,0.1); }
.b-col:last-child::after { display: none; }
.b-match { background: linear-gradient(180deg, rgba(30,40,35,0.95), rgba(15,20,15,0.95)); border: 2px solid var(--line); border-radius: 10px; overflow: hidden; box-shadow: 0 8px 20px rgba(0,0,0,0.6); transition: all 0.3s cubic-bezier(0.2, 1.4, 0.4, 1); position: relative; }
.b-match.active { border-color: var(--gold); box-shadow: 0 0 25px rgba(255,207,64,0.5), 0 10px 30px rgba(0,0,0,0.8); transform: scale(1.1); animation: pulse 2s infinite; z-index: 10; }
@keyframes pulse { 0% { border-color: #a88820; box-shadow: 0 0 15px rgba(255,207,64,0.3); } 50% { border-color: var(--gold); box-shadow: 0 0 30px rgba(255,207,64,0.7); } 100% { border-color: #a88820; box-shadow: 0 0 15px rgba(255,207,64,0.3); } }
.b-team { padding: 8px 12px; font-size: 14px; font-weight: 900; border-bottom: 1px solid rgba(255,255,255,0.15); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.b-team:last-child { border-bottom: none; }
.b-team.dead { opacity: 0.3; text-decoration: line-through; background: rgba(0,0,0,0.5); }
.b-team.player { color: var(--gold); text-shadow: 0 0 8px rgba(255,207,64,0.5); background: rgba(255,207,64,0.1); }
.b-champ { background: linear-gradient(135deg, #ffcf40, #d4a017); color: #111; padding: 16px; border-radius: 12px; font-weight: 900; font-size: 18px; text-align: center; box-shadow: 0 0 30px rgba(255,207,64,0.7); display: flex; flex-direction: column; align-items: center; justify-content: center; height: 70px; transform: scale(1.1); }
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud">
<div class="board">
<div class="side" id="sL"><div class="col"><div class="nm pillR" 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 pillB" id="nmR">BLUE</div><div class="cards" id="cardsR"></div></div></div>
</div>
<div id="poss">BALL: <b id="possName">—</b></div>
<div id="pkUI">
<div class="pk-title">PENALTY SHOOTOUT</div>
<div class="pk-history">
<div class="col">
<span id="pkNameL" style="font-size:12px; font-weight:bold; margin-bottom:4px;">RED</span>
<div class="pk-team-hist" id="pkHistL"></div>
</div>
<div class="col">
<span id="pkNameR" style="font-size:12px; font-weight:bold; margin-bottom:4px;">BLUE</span>
<div class="pk-team-hist" id="pkHistR"></div>
</div>
</div>
<div id="pkAimHint">コースを狙え!(矢印キー / テンキー)</div>
</div>
<div id="tacticsUI">
<div id="tacP1" class="tac-btn">P1 TACTIC (A): NORMAL</div>
<div id="tacP2" class="tac-btn" style="display:none;">P2 TACTIC (H): NORMAL</div>
</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">MULTIVERSE WORLD CUP</div>
<h1 class="gtitle"><span class="a">MULTIVERSE</span><br><span class="b">SOCCER</span></h1>
<div class="gsub">歴史上の大帝国 VS 現代サッカー強豪国</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 class="opt" data-mode="tournament" id="mTour">大会<span style="opacity:.6">/TOURNEY</span></div>
</div>
<div class="grp"><div class="lbl">LEVEL</div>
<div class="opt" data-diff="easy">EASY</div>
<div class="opt" data-diff="normal">NORMAL</div>
<div class="opt act" data-diff="hard">HARD</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="scrTourney">
<div class="kicker">MULTIVERSE TOURNAMENT</div>
<h2 class="gtitle" style="font-size:54px; margin-bottom:15px; text-shadow: 0 5px 15px rgba(0,0,0,0.8);">トーナメント表</h2>
<div class="bracket-container" id="bracketUI">
</div>
<div class="sel" style="margin-top:30px;">
<button class="opt act" id="btnTourneyNext" style="background:linear-gradient(135deg, var(--gold), #d4a017); color:#111; font-size:20px; padding:16px 50px; box-shadow: 0 10px 30px rgba(255,207,64,0.4);">NEXT MATCH ▶</button>
</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 act" 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 — ゲーム定数と設定(フィールド1.5倍スケール版+PK拡張)
===================================================================== */
const RED = 0xe23b3b;
const BLUE = 0x2f7be0;
/* 1ユニット≒1m。コートとゴールのサイズ (1.5倍) */
const HALF_X = 51;
const HALF_Z = 78;
const GOAL_HALF = 6.0;
const GOAL_H = 3.5;
const GOAL_DEPTH = 3.5;
const PK_SPOT = 11.0; // ゴールラインからの距離
/* カメラ設定 (1.5倍) */
const CAM_BACK = 22.5;
const CAM_H = 43.5;
const LOOK_AHEAD = 24;
const FOV = 52;
const LOOK_Y = 1.0;
/* PK用カメラ設定 */
const PK_CAM_BACK = 12;
const PK_CAM_H = 8;
const PK_CAM_LOOK_Y = 2;
/* 選手とボールのサイズ */
const PLAYER_SX = 0.8;
const PLAYER_SY = 0.8;
const PLAYER_SZ = 0.8;
const FOOT = 0.16;
const BALLR = 0.3;
/* 移動速度パラメータ (1.5倍) */
const RUN_SPD = 16; // 爽快感のため微増
const DRIB_SPD = 13.5; // 同上
const AI_SPD = 11.5; // プレイヤー有利に微減
const AI_DRIB = 10.0;
const GK_SPD = 13.0;
/* ダッシュ&スライディング (1.5倍) */
const SLIDE_SPEED = 28; // 爽快感アップ
const SLIDE_DUR = 0.45; // キビキビさせる
const SLIDE_CD = 0.8;
const SLIDE_RANGE = 9.5;
const DASH_SPEED = 28.0; // 加速強化
const DASH_DUR = 0.6;
const DASH_MIN = 0.15;
/* スタミナ */
const STAM_DRAIN = 1.3;
const STAM_RECOVER = 0.8;
/* ボールの物理・キックパラメータ (1.5倍) */
const SMALL_KICK = 29; // パススピード向上
const BIG_KICK = 40; // シュート威力向上
const MAX_SHOOT_DIST = HALF_Z * 1.2;
const GRAV = 28;
const REST = 0.45;
const FRIC = 10.0;
const STOP = 0.6;
/* PK用キック力 */
const PK_KICK_POWER = 42;
/* AIと操作の閾値 (1.5倍調整) */
const LEAD = 1.35;
const PICK_R = 1.8;
const PICK_MAXSPEED = 45;
const STEAL_R = 1.6;
const GRACE = 0.4;
const STEAL_AI = 1.4; // EASYなどで下げやすくする基準値
const STEAL_HUMAN = 5.0;
const SEP = 1.5;
const CARD_DIST = 1.0;
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;
let ballTrail; // シュート時の軌跡エフェクト
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, 170, 380);
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.95);
scene.add(hemi);
const dir = new THREE.DirectionalLight(0xffffff, 0.9);
dir.position.set(40, 90, 50);
dir.castShadow = false; // パフォーマンス優先
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);
// PKスポットの描画
x.beginPath(); x.arc(cw / 2, iy + ch * (PK_SPOT / (HALF_Z * 2)), 3, 0, 6.283); x.fill();
x.beginPath(); x.arc(cw / 2, ch - iy - ch * (PK_SPOT / (HALF_Z * 2)), 3, 0, 6.283); x.fill();
const t = new THREE.CanvasTexture(cv);
t.anisotropy = 8;
return t;
}
function buildField() {
const surround = new THREE.Mesh(new THREE.PlaneGeometry(700, 700), 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);
buildAdBoards();
buildStands();
buildFloodlights();
buildBigScreens();
crowd = buildCrowd();
confetti = buildConfetti();
buildBallTrail();
}
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 stadium = new THREE.Group(); scene.add(stadium);
const tierMats = [matLam(0x3b3a44), matLam(0x33323c), matLam(0x2b2a33)];
const roofMat = matLam(0x17171d);
const facadeMat = matLam(0x21202a);
const baseOff = 6.5, tiers = 3, tierStep = 6.0, tierRise = 4.4, tierH = 3.2;
const lenX = HALF_X * 2 + baseOff * 2 + 44, lenZ = HALF_Z * 2 + baseOff * 2 + 32, depth = 5.6;
for (let t = 0; t < tiers; t++) {
const off = baseOff + t * tierStep;
const y = 1.2 + t * tierRise;
const mat = tierMats[t];
[-1, 1].forEach(s => {
const m = new THREE.Mesh(new THREE.BoxGeometry(lenX, tierH, depth), mat);
m.position.set(0, y, s * (HALF_Z + off)); stadium.add(m);
});
[-1, 1].forEach(s => {
const m = new THREE.Mesh(new THREE.BoxGeometry(depth, tierH, lenZ), mat);
m.position.set(s * (HALF_X + off), y, 0); stadium.add(m);
});
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sz]) => {
const m = new THREE.Mesh(new THREE.BoxGeometry(depth + 12, tierH, depth + 12), mat);
m.position.set(sx * (HALF_X + off), y, sz * (HALF_Z + off)); stadium.add(m);
});
}
}
function buildFloodlights() {
const poleMat = matLam(0xb8bcc4), lampOn = new THREE.MeshBasicMaterial({ color: 0xfffbe0 }), boardMat = matLam(0x26262c);
const off = 6.5 + 3 * 6.0 + 9, poleH = 36;
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sz]) => {
const x = sx * (HALF_X + off), z = sz * (HALF_Z + off);
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.85, poleH, 8), poleMat);
pole.position.set(x, poleH / 2, z); scene.add(pole);
const g = new THREE.Group();
g.position.set(x, poleH, z);
g.rotation.y = Math.atan2(-x, -z);
const board = new THREE.Mesh(new THREE.BoxGeometry(9, 4.4, 0.8), boardMat);
board.position.set(0, 0, 0.5); g.add(board);
for (let i = 0; i < 4; i++) for (let j = 0; j < 2; j++) {
const lamp = new THREE.Mesh(new THREE.BoxGeometry(1.8, 1.7, 0.3), lampOn);
lamp.position.set(-3.0 + i * 2.0, -1.0 + j * 2.0, 1.0); g.add(lamp);
}
scene.add(g);
});
}
function buildBigScreens() {
const frameMat = matLam(0x111114);
const off = 6.5 + 3 * 6.0 + 5, y = 22;
[-1, 1].forEach(s => {
const g = new THREE.Group();
g.position.set(0, y, s * (HALF_Z + off));
g.rotation.y = s > 0 ? Math.PI : 0;
const frame = new THREE.Mesh(new THREE.BoxGeometry(22, 11, 1.2), frameMat);
g.add(frame);
const scr = new THREE.Mesh(new THREE.PlaneGeometry(20, 9.4), new THREE.MeshBasicMaterial({ color: 0x0b1d30 }));
scr.position.z = 0.7; g.add(scr);
scene.add(g);
});
}
function buildAdBoards() {
const mats = [matLam(0xffffff), matLam(0xf2c84b), matLam(0xe23b3b), matLam(0x2f7be0), matLam(0x10c070), matLam(0xe07b20)];
const h = 1.0, off = 2.4, seg = 6;
let idx = 0;
const place = (x, z, w, d) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mats[idx++ % mats.length]); m.position.set(x, h / 2, z); scene.add(m); };
for (let x = -HALF_X; x < HALF_X; x += seg) { place(x + seg / 2, -(HALF_Z + off), seg - 0.5, 0.4); place(x + seg / 2, (HALF_Z + off), seg - 0.5, 0.4); }
for (let z = -HALF_Z; z < HALF_Z; z += seg) { place(-(HALF_X + off), z + seg / 2, 0.4, seg - 0.5); place((HALF_X + off), z + seg / 2, 0.4, seg - 0.5); }
}
function buildCrowd() {
const geo = new THREE.BoxGeometry(0.55, 0.85, 0.55);
const pts = [], ph = [], cols = [];
const pal = [RED, BLUE, 0xffffff, 0xf2c84b, 0xc9ced6, 0x20242c, 0x10c070, 0xe07b20];
const pick = () => pal[(Math.random() * pal.length) | 0];
const add = (x, y, z, c) => { pts.push([x, y, z]); ph.push(Math.random() * 6.283); cols.push(c); };
const baseOff = 7.0, rows = 10, rowStep = 1.6, rise = 1.05, gap = 1.45;
for (let r = 0; r < rows; r++) {
const y = 2.1 + r * rise, out = baseOff + r * rowStep;
for (let xi = -(HALF_X + 14); xi <= HALF_X + 14; xi += gap) {
add(xi + (Math.random() - 0.5) * 0.4, y, -(HALF_Z + out), pick());
add(xi + (Math.random() - 0.5) * 0.4, y, (HALF_Z + out), pick());
}
for (let zi = -(HALF_Z + 9); zi <= HALF_Z + 9; zi += gap) {
add(-(HALF_X + out), y, zi + (Math.random() - 0.5) * 0.4, pick());
add((HALF_X + out), y, zi + (Math.random() - 0.5) * 0.4, pick());
}
}
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, sp = bouncing ? 9 : 2.2, t = performance.now() * 0.001;
for (let i = 0; i < cr.N; i++) {
const b = cr.bases[i];
const y = b[1] + Math.abs(Math.sin(t * sp + cr.phases[i])) * amp;
cr.m.makeTranslation(b[0], y, b[2]);
cr.mesh.setMatrixAt(i, cr.m);
}
cr.mesh.instanceMatrix.needsUpdate = true;
}
/* ---------- 選手モデル生成とアニメーション ---------- */
function makePlayer(color, role) {
const jersey = role === 'GK' ? mix(color, 0x111111, 0.4) : color;
const shorts = 0xffffff, socks = jersey, skin = 0xf1c8a0;
const root = grp(0, 0, 0);
const P = { root };
function leg(s) {
const hip = grp(0.17 * s, 0.95, 0); root.add(hip);
const th = box(0.26, 0.5, 0.28, shorts); th.position.y = -0.25; hip.add(th);
const shin = grp(0, -0.5, 0); hip.add(shin);
const calf = box(0.22, 0.5, 0.24, socks); calf.position.y = -0.25; shin.add(calf);
const shoe = box(0.26, 0.18, 0.42, 0x101014); shoe.position.set(0, -0.52, 0.08); shin.add(shoe);
return { hip, shin };
}
const L = leg(-1), R = leg(1);
P.legL = L.hip; P.legR = R.hip; P.shinL = L.shin; P.shinR = R.shin;
const torso = grp(0, 1.5, 0); root.add(torso); P.torso = torso;
const chest = box(0.6, 0.7, 0.34, jersey); torso.add(chest);
const waist = box(0.5, 0.18, 0.3, shorts); waist.position.y = -0.42; torso.add(waist);
const num = box(0.3, 0.3, 0.02, mix(jersey, 0xffffff, 0.6)); num.position.set(0, 0.05, -0.18); torso.add(num);
function arm(s) {
const sh = grp(0.36 * s, 1.78, 0); root.add(sh);
const up = box(0.16, 0.42, 0.18, jersey); up.position.y = -0.21; sh.add(up);
const fore = grp(0, -0.42, 0); sh.add(fore);
const fm = box(0.14, 0.4, 0.16, skin); fm.position.y = -0.2; fore.add(fm);
if (role === 'GK') { const gl = box(0.17, 0.16, 0.18, 0xffffff); gl.position.y = -0.42; fore.add(gl); }
return { sh, fore };
}
const aL = arm(-1), aR = arm(1);
P.armL = aL.sh; P.foreL = aL.fore; P.armR = aR.sh; P.foreR = aR.fore;
const head = grp(0, 2.12, 0); root.add(head); P.head = head;
head.add(box(0.34, 0.36, 0.34, skin));
const hair = box(0.38, 0.16, 0.38, 0x2a1a10); hair.position.y = 0.2; head.add(hair);
[-1, 1].forEach(s => { const e = box(0.05, 0.06, 0.02, 0x1a1a22); e.position.set(0.08 * s, 0.02, 0.18); head.add(e); });
root.scale.set(PLAYER_SX, PLAYER_SY, PLAYER_SZ);
return { root, parts: P };
}
function animatePlayer(p, dt, moving, speed) {
const P = p.parts;
p.animT += dt;
P.legL.rotation.set(0, 0, 0); P.legR.rotation.set(0, 0, 0);
P.shinL.rotation.set(0, 0, 0); P.shinR.rotation.set(0, 0, 0);
P.armL.rotation.set(0, 0, 0); P.armR.rotation.set(0, 0, 0);
P.foreL.rotation.set(0, 0, 0); P.foreR.rotation.set(0, 0, 0);
P.torso.rotation.set(0, 0, 0); P.head.rotation.set(0, 0, 0);
P.torso.position.y = 1.5;
let lock = p.lock;
if (lock) {
lock.t += dt;
if (lock.t >= lock.dur) p.lock = lock = null;
}
if (lock && lock.name === 'kick') {
const k = Math.min(1, lock.t / lock.dur), sw = Math.sin(k * Math.PI);
P.legR.rotation.x = -2.0 * sw - 0.1; // 蹴り足をより高く
P.shinR.rotation.x = 0.8 * (1 - sw);
P.legL.rotation.x = 0.4;
P.armL.rotation.x = -0.8; P.armR.rotation.x = -1.0;
P.torso.rotation.x = 0.15; P.torso.rotation.y = sw * 0.3;
} else if (lock && lock.name === 'slide') {
P.legR.rotation.x = -1.35; P.shinR.rotation.x = 0.3;
P.legL.rotation.x = 0.65; P.shinL.rotation.x = 1.0;
P.armL.rotation.x = -1.5; P.armR.rotation.x = -1.5;
P.torso.rotation.x = -0.6; P.torso.position.y = 0.95; // より低く鋭く
P.head.rotation.x = 0.4;
} else if (lock && lock.name === 'dive') {
// GKのダイビングアニメーション
const k = Math.min(1, lock.t / lock.dur), sw = Math.sin(k * Math.PI);
P.torso.rotation.z = lock.side * 1.5 * sw;
P.torso.rotation.x = -0.5 * sw;
P.torso.position.y = 1.5 - sw * 0.5;
P.armL.rotation.z = lock.side * 2.0 * sw; P.armR.rotation.z = lock.side * 2.0 * sw;
P.legL.rotation.x = -0.5 * sw; P.legR.rotation.x = -0.5 * sw;
} else if (moving) {
const a = Math.min(1, speed / 13), ph = p.animT * (12 + speed * 0.5), s = Math.sin(ph);
P.legL.rotation.x = s * 1.1 * a; P.legR.rotation.x = -s * 1.1 * a;
P.shinL.rotation.x = Math.max(0, Math.cos(ph)) * 1.1 * a;
P.shinR.rotation.x = Math.max(0, Math.cos(ph + Math.PI)) * 1.1 * a;
P.armL.rotation.x = -s * 0.8 * a; P.armR.rotation.x = s * 0.8 * a;
P.foreL.rotation.x = -0.6; P.foreR.rotation.x = -0.6;
P.torso.rotation.x = 0.18; P.torso.position.y = 1.5 + Math.abs(s) * 0.08 * a;
} else {
const s = Math.sin(p.animT * 1.8);
P.armL.rotation.x = 0.05; P.armR.rotation.x = 0.05;
P.foreL.rotation.x = -0.2; P.foreR.rotation.x = -0.2;
P.torso.position.y = 1.5 + s * 0.02;
P.head.rotation.y = Math.sin(p.animT * 0.7) * 0.15;
}
p.root.position.set(p.pos.x, FOOT * PLAYER_SY, p.pos.z);
p.root.rotation.y = p.facing;
}
/* ---------- ボールとエフェクト ---------- */
function ballTex() {
const cv = document.createElement('canvas'); cv.width = cv.height = 128;
const x = cv.getContext('2d');
x.fillStyle = '#fff'; x.fillRect(0, 0, 128, 128);
x.fillStyle = '#1c1c22';
for (let i = 0; i < 6; i++) { x.beginPath(); x.arc(16 + Math.random() * 96, 16 + Math.random() * 96, 9, 0, 6.283); x.fill(); }
return new THREE.CanvasTexture(cv);
}
function buildBall() {
const m = new THREE.Mesh(new THREE.SphereGeometry(BALLR, 16, 12), new THREE.MeshLambertMaterial({ map: ballTex() }));
scene.add(m);
ball = { mesh: m, shadow: blobShadow(0.28), pos: new THREE.Vector3(0, BALLR, 0), vel: new THREE.Vector3(), owner: null, lastTouch: null, passRoute: null };
}
function buildBallTrail() {
const N = 20;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(N * 3);
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.LineBasicMaterial({ color: 0xffe600, transparent: true, opacity: 0.6, linewidth: 2 });
const line = new THREE.Line(geo, mat);
scene.add(line);
ballTrail = { line, positions: pos, idx: 0, N, active: false };
}
function updateBallTrail(dt) {
if (!ballTrail) return;
const speed = ball.vel.length();
if (speed > 25 && ball.pos.y > BALLR) {
ballTrail.active = true;
ballTrail.line.material.opacity = Math.min(0.8, (speed - 25) / 15);
for (let i = ballTrail.N - 1; i > 0; i--) {
ballTrail.positions[i * 3] = ballTrail.positions[(i - 1) * 3];
ballTrail.positions[i * 3 + 1] = ballTrail.positions[(i - 1) * 3 + 1];
ballTrail.positions[i * 3 + 2] = ballTrail.positions[(i - 1) * 3 + 2];
}
ballTrail.positions[0] = ball.pos.x;
ballTrail.positions[1] = ball.pos.y;
ballTrail.positions[2] = ball.pos.z;
ballTrail.line.geometry.attributes.position.needsUpdate = true;
ballTrail.line.visible = true;
} else {
if (ballTrail.active) {
ballTrail.active = false;
ballTrail.line.visible = false;
for (let i = 0; i < ballTrail.N * 3; i++) ballTrail.positions[i] = 0;
}
}
}logic.js
"use strict";
/* =====================================================================
logic.js — AI、操作、物理判定、ゲームルール、PK戦システム
===================================================================== */
const MULTIVERSE_NATIONS = [
{ name: "大清帝国", color: 0xffcc00 }, { name: "ローマ帝国", color: 0x800020 },
{ name: "モンゴル帝国", color: 0x0066cc }, { name: "神聖ローマ", color: 0x222222 },
{ name: "アステカ", color: 0x008000 }, { name: "アルゼンチン", color: 0x43a1d5 },
{ name: "フランス", color: 0x002395 }, { name: "ブラジル", color: 0xf2c84b },
{ name: "イングランド", color: 0xffffff }, { name: "スペイン", color: 0xc60b1e },
{ name: "日本", color: 0x000555 }
];
const G = {
state: 'menu', mode: 'single', colorChoice: 'random', difficulty: 'hard', started: false,
home: null, away: null, teams: [], half: 1, halfElapsed: 0,
firstKicker: null, kickNext: null, celebr: 0, htTimer: 0,
tourney: null,
// PK戦用ステート
pkRound: 0, pkScore1: 0, pkScore2: 0, pkTurn: 0, pkPhase: null,
pkKicker: null, pkGk: null, pkTimer: 0, pkWinner: null
};
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) {
return { color, name, isHuman, score: 0, players: [], bench: [], defendZ: 0, attackZ: 0, dir: 1, presser: null, tactic: 'NORMAL', manualTactic: false, pkHistory: [] };
}
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) {
if (G.state === 'pk_shootout') return; // PK中は強制アサイン
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);
}
// 難易度を考慮したパスルート探索
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';
const difLvl = startPlayer.team.isHuman ? (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2) : 1; // プレイヤーの難易度恩恵
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 = (difLvl === 0 ? 2.5 : 1.2) + (isDefending ? 1.0 : 0); // EASYならオープンスペースを重視
let sc = fwd * fwdWeight + open * openWeight - d * 0.1;
if (!pathClear(curr.pos, m.pos, startPlayer.team)) sc -= (difLvl === 0 ? 8 : 15);
if (sc > bs) { bs = sc; best = m; }
});
if (!best) break;
route.push(best); visited.add(best); curr = best;
}
return route;
}
function updateHuman(h, dt, inputAxis, camDirs) {
if (G.state === 'pk_shootout') return; // PK中は別処理
const p = h.controlled;
if (!p || p.sentOff) return;
if (p.slide) { tickSlide(p, dt); return; }
if (p.lock && (p.lock.name === 'lift' || p.lock.name === 'stun')) { 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);
const enemyHasBall = ball.owner && ball.owner.team !== p.team;
if (hasBall && a.feint && (p.feintCd || 0) <= 0) {
p.feintCd = 4.0; p.feintBoostT = 0.8;
if (typeof feintSnd === 'function') feintSnd();
otherTeam(p.team).players.forEach(o => {
if (o.sentOff || o.role === 'GK') return;
if (Math.hypot(o.pos.x - p.pos.x, o.pos.z - p.pos.z) < 8.0) { o.lock = { name: 'stun', t: 0, dur: 0.9 }; o.vel.set(0, 0, 0); }
});
}
if (!hasBall && enemyHasBall && (a.small || a.big || a.smHold || a.bgHold)) h.chaseLatch = true;
if (hasBall || !ball.owner || (ball.owner && ball.owner.team === p.team)) h.chaseLatch = false;
const manualInput = (Math.abs(a.fwd) + Math.abs(a.right)) > 0.1;
let autoChase = false;
if (!hasBall && enemyHasBall && h.chaseLatch) {
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 && (p.slideCd || 0) <= 0) { startSlide(p); return; }
if ((h.dashT || 0) <= 0 && h.stamina > DASH_MIN) h.dashT = DASH_DUR;
if (!manualInput) { const to = new THREE.Vector3(tp.x - p.pos.x, 0, tp.z - p.pos.z); if (to.lengthSq() > 0.0001) { to.normalize(); mv.copy(to); } }
}
const spMult = (p.feintBoostT || 0) > 0 ? 1.6 : 1.0;
if (mv.lengthSq() > 0.01) {
mv.normalize();
const sp = (hasBall ? DRIB_SPD : (dashing ? DASH_SPEED : RUN_SPD)) * spMult;
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) p.facing = lerpAngle(p.facing, Math.atan2(-p.pos.x, p.team.attackZ - p.pos.z), 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 };
if (typeof slideSnd === 'function') slideSnd();
}
function tickSlide(p, dt) {
const s = p.slide; if (!s) return;
s.t += dt;
const dir = s.dir, k = s.t / s.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;
if (typeof stealSnd === 'function') stealSnd();
p.lock = p.isControlled ? { name: 'lift', t: 0, dur: 0.55 } : null;
p.kickCd = p.isControlled ? 0.55 : 0.3;
p.vel.set(0, 0, 0); return;
}
}
if (p.slide && p.slide.t >= p.slide.dur) p.slide = null;
}
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 };
if (typeof kickSnd === 'function') kickSnd();
}
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 };
if (typeof shootSnd === 'function') shootSnd();
}
function goalGapPoint(team) {
const gk = otherTeam(team).players.find(o => o.role === 'GK' && !o.sentOff);
const gkx = gk ? gk.pos.x : 0;
return new THREE.Vector3((gkx >= 0 ? -1 : 1) * (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);
const power = clamp(Math.sqrt(2 * FRIC * dist) * 1.15, 15, 38);
const lift = dist > 25 ? 1.5 : 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 };
if (typeof kickSnd === 'function') kickSnd();
}
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.5) return false;
}
}
return true;
}
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;
}
function updateAI(dt) {
if (G.state === 'pk_shootout') return; // PK中は専用AI
const len = HALF_LEN; const timeRemaining = len - G.halfElapsed;
G.teams.forEach(t => {
if (!t.manualTactic) {
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) return;
if (p.slide) { tickSlide(p, dt); return; }
if (p.lock && (p.lock.name === 'stun' || p.lock.name === 'lift')) { p.vel.set(0, 0, 0); return; }
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), attacking = (ownerTeam === team);
p.aiState = p.role === 'GK' ? 'GK_PROCESS' : (isOwner ? 'BALL_OWNER' : (ball.passRoute && ball.passRoute[0] === p ? 'RECEIVER' : (!attacking && p === team.presser ? 'PRESS' : (attacking ? 'SUPPORT' : 'DEFEND'))));
let target = null, sp = AI_SPD;
const difLvl = team.isHuman ? 2 : (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2);
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);
if (typeof gkKickSnd === 'function') gkKickSnd();
}
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 = dist > HALF_Z ? 0.05 : (dist > HALF_Z * 0.6 ? 0.15 : (dist > HALF_Z * 0.3 ? 0.30 : 1.0));
if (difLvl === 0) goalProb = Math.min(1.0, goalProb + 0.35); // EASYは抜けやすい
else if (difLvl === 1) goalProb = Math.min(1.0, goalProb + 0.15);
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 dx = p.diveTargetX - p.pos.x;
sp = 50; target = new THREE.Vector3(p.diveTargetX, 0, ownGoalZ - defSign * 0.8);
p.lock = { name: 'dive', t: 0, dur: 0.6, side: Math.sign(dx) };
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;
if (typeof gkCatchSnd === 'function') gkCatchSnd();
}
} 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), 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.45 && clear) { aiKick(p, new THREE.Vector3(goalGapPoint(team).x - ball.pos.x, 0, goalGapPoint(team).z - ball.pos.z).normalize(), BIG_KICK, 4); if (typeof shootSnd === 'function') shootSnd(); return; }
let route = calculatePassRoute(p, false);
if (route && route.length > 0) {
const tgt = route[0], adv = (tgt.pos.z - p.pos.z) * sign, open = opennessOf(team, tgt.pos);
// EASYの場合パスを出しやすくする
const passProb = difLvl === 0 ? 0.7 : 0.4;
if (pressed || ((adv > 6 || distGoal < HALF_Z * 0.6) && open > 4 && Math.random() < passProb)) { 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 + 4.0; break;
case 'PRESS':
target = ball.pos.clone();
const spMultPress = difLvl === 0 ? 0.75 : difLvl === 1 ? 0.9 : 1.0;
sp = (AI_SPD + 2.5) * spMultPress;
const dToBall = Math.hypot(ball.pos.x - p.pos.x, ball.pos.z - p.pos.z);
const slideR = difLvl === 0 ? 0.0 : difLvl === 1 ? SLIDE_RANGE * 0.7 : SLIDE_RANGE;
if (ball.owner && dToBall < slideR && (p.slideCd || 0) <= 0) startSlide(p);
break;
case 'SUPPORT':
case 'DEFEND':
const role = p.role, 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 threatPull = 0;
if (!attacking) { const intrusion = (HALF_Z * 0.95 - ballAdv) / (HALF_Z * 0.95); if (intrusion > 0) { threatPull = HALF_Z * 0.5 * intrusion; lo = Math.min(lo, HALF_Z * 0.05); } }
let adv = clamp(baseAdv + push + tacticPush + (ballAdv - baseAdv) * follow - threatPull, lo, hi);
let tz = ownGoalZ + sign * adv, 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') { tz = clamp(ball.pos.z + sign * 14, ownGoalZ + sign * HALF_Z * 1.25, ownGoalZ + sign * HALF_Z * 1.82); sx = clamp(ball.pos.x * 0.3 + (p.homePos.x > 0 ? 1 : -1) * (GOAL_HALF + 4), -HALF_X + 6, HALF_X - 6); }
else if (role === 'MID') { sx = clamp(sx + (opp.players.some(o => !o.sentOff && Math.abs(o.pos.z - tz) < 4 && Math.abs(o.pos.x - sx) < 5) ? (sx > 0 ? -8 : 8) : 0), -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; sx = clamp(ball.pos.x * 0.6 + (p.homePos.x > 0 ? 1 : -1) * 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 || 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 = difLvl === 0 ? 0.05 : difLvl === 1 ? 0.2 : 0.4; 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 spMult = difLvl === 0 ? 0.75 : difLvl === 1 ? 0.88 : 1.0;
const adjustedSp = sp * spMult;
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(adjustedSp, 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, 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 tryDeflect() {
if (ball.owner) return false;
const s = Math.hypot(ball.vel.x, ball.vel.z);
if (s < 6) return false;
for (const p of allPlayers()) {
if (p.sentOff || p.role === 'GK' || p.team === ball.lastTouch || (ball.passRoute && ball.passRoute[0] === p) || p.kickCd > 0 || (p.lock && (p.lock.name === 'lift' || p.lock.name === 'stun'))) continue;
const difLvl = p.team.isHuman ? 2 : (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2);
const deflectR = difLvl === 0 ? 0.2 : difLvl === 1 ? 0.5 : 0.85; // EASYならカットしにくい
const d = Math.hypot(p.pos.x - ball.pos.x, p.pos.z - ball.pos.z);
if (d < deflectR && ball.pos.y < 1.7) {
const nx = (ball.pos.x - p.pos.x) / (d || 1), nz = (ball.pos.z - p.pos.z) / (d || 1);
const dot = ball.vel.x * nx + ball.vel.z * nz;
if (dot < 0) { ball.vel.x -= 2 * dot * nx; ball.vel.z -= 2 * dot * nz; }
ball.vel.x *= 0.45; ball.vel.z *= 0.45; ball.vel.y = Math.max(ball.vel.y, 1.6);
ball.lastTouch = p.team; ball.passRoute = null;
if (typeof kickSnd === 'function') kickSnd();
return true;
}
}
return false;
}
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' || p.lock.name === 'stun'))) return;
if (ball.passRoute && ball.passRoute.length > 0 && 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), 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 difLvlSteal = st.team.isHuman ? 2 : (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2);
const stealMult = difLvlSteal === 0 ? 0.1 : difLvlSteal === 1 ? 0.4 : 1.0;
const ch = (st.isControlled ? STEAL_HUMAN : STEAL_AI * stealMult) * dt;
if (Math.random() < ch) { ball.owner = st; st.possGrace = GRACE; ball.lastTouch = st.team; ball.passRoute = null; if (typeof stealSnd === 'function') stealSnd(); }
}
}
} 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();
if (G.state !== 'pk_shootout') {
const handled = checkBounds(); if (!handled) { if (!tryDeflect()) tryPickup(); }
} else {
checkPKBounds(); // PK用判定
}
}
}
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 placeRestart(att, bx >= 0 ? HALF_X - 1.2 : -(HALF_X - 1.2), 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; allPlayers().forEach(pl => { pl.autoPassTgt = null; pl.autoPassDelay = 0; });
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.lock = 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;
if (typeof restartSnd === 'function') restartSnd(kind);
}
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) {
if (G.state === 'pk_shootout') return;
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) { if (p.vel.x * dx + p.vel.z * dz > 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; } }
/* ================== PK戦システム ================== */
function startPK() {
G.state = 'pk_shootout';
G.pkRound = 1; G.pkScore1 = 0; G.pkScore2 = 0; G.pkTurn = 0; // 0 = Home kiks, 1 = Away kicks
G.home.pkHistory = []; G.away.pkHistory = [];
allPlayers().forEach(p => { p.pos.set(HALF_X + 10, 0, 0); p.vel.set(0, 0, 0); p.slide = null; p.lock = null; p.isControlled = false; }); // 一時退避
setupPKTurn();
if (typeof pkStartUI === 'function') pkStartUI();
}
function setupPKTurn() {
G.pkPhase = 'setup';
const kickingTeam = G.pkTurn === 0 ? G.home : G.away;
const defendingTeam = G.pkTurn === 0 ? G.away : G.home;
const atkSign = kickingTeam.dir;
const kicker = kickingTeam.players.find(p => p.role === 'FWD') || kickingTeam.players[0];
const gk = defendingTeam.players.find(p => p.role === 'GK') || defendingTeam.players[0];
G.pkKicker = kicker; G.pkGk = gk;
// ゴールとボールのセットアップ
const targetGoalZ = kickingTeam.attackZ;
ball.pos.set(0, BALLR, targetGoalZ - atkSign * PK_SPOT);
ball.vel.set(0, 0, 0); ball.owner = null; ball.lastTouch = null;
// キッカーのセット
kicker.pos.set(0, 0, ball.pos.z - atkSign * 3.0); // ボールから3m後ろ
kicker.facing = Math.atan2(0, targetGoalZ - kicker.pos.z);
// GKのセット
gk.pos.set(0, 0, targetGoalZ - atkSign * 0.2); // ゴールライン上
gk.facing = Math.atan2(0, kicker.pos.z - gk.pos.z);
// プレイヤーの操作対象を強制設定
humans.forEach(h => {
if (h.team === kickingTeam) h.controlled = kicker;
else if (h.team === defendingTeam) h.controlled = gk;
});
G.pkTimer = 1.0; // セットアップからAIM移行までの猶予
}
function updatePK(dt) {
const atkSign = G.pkTurn === 0 ? G.home.dir : G.away.dir;
const targetGoalZ = (G.pkTurn === 0 ? G.home.attackZ : G.away.attackZ);
if (G.pkPhase === 'setup') {
G.pkTimer -= dt;
if (G.pkTimer <= 0) G.pkPhase = 'aim';
}
else if (G.pkPhase === 'aim') {
// キッカーAI
if (!G.pkKicker.team.isHuman) {
if (Math.random() < 0.02) executePKKickAI(); // AIM完了までのランダムディレイ
}
// GK操作・AIはKick時に処理
}
else if (G.pkPhase === 'runup') {
const k = G.pkKicker;
const toBall = new THREE.Vector3(ball.pos.x - k.pos.x, 0, ball.pos.z - k.pos.z);
const d = toBall.length();
if (d > 0.8) {
toBall.normalize();
k.pos.x += toBall.x * RUN_SPD * dt; k.pos.z += toBall.z * RUN_SPD * dt;
k.facing = Math.atan2(toBall.x, toBall.z); k.vel.set(toBall.x * RUN_SPD, 0, toBall.z * RUN_SPD);
} else {
// 蹴る
k.vel.set(0,0,0);
ball.vel.copy(k.pkAimVec);
ball.lastTouch = k.team;
k.lock = { name: 'kick', t: 0, dur: 0.4 };
if (typeof shootSnd === 'function') shootSnd();
G.pkPhase = 'shoot';
G.pkTimer = 3.5; // 結果判定までの猶予
// GKダイブ判定
executePKDiving();
}
}
else if (G.pkPhase === 'shoot') {
G.pkTimer -= dt;
// GKの動き更新
const gk = G.pkGk;
if (gk.lock && gk.lock.name === 'dive') {
const sw = Math.sin(Math.min(1, gk.lock.t / gk.lock.dur) * Math.PI);
gk.pos.x = gk.pkDiveStartX + gk.pkDiveTargetX * sw;
}
// セーブ判定(シンプルに距離で判定)
const db = Math.hypot(gk.pos.x - ball.pos.x, gk.pos.z - ball.pos.z);
if (db < 2.0 && ball.pos.y < 2.5) {
ball.vel.x *= -0.5; ball.vel.y *= 0.5; ball.vel.z *= -0.5;
if (typeof gkCatchSnd === 'function') gkCatchSnd();
endPKTurn(false);
}
if (G.pkTimer <= 0) endPKTurn(false); // 時間切れはミス扱い
}
}
function checkPKBounds() {
const bx = ball.pos.x, by = ball.pos.y, bz = ball.pos.z;
const absZ = Math.abs(bz);
const targetZ = G.pkTurn === 0 ? Math.abs(G.home.attackZ) : Math.abs(G.away.attackZ);
if (absZ >= targetZ) {
if (Math.abs(bx) < GOAL_HALF - 0.22 && by < GOAL_H) {
endPKTurn(true); // ゴール
} else {
endPKTurn(false); // 外れ
}
}
}
function executePKKick(aimX, powerLift) {
if (G.pkPhase !== 'aim') return;
const k = G.pkKicker;
const atkSign = k.team.dir;
const power = PK_KICK_POWER;
k.pkAimVec = new THREE.Vector3(aimX * power, powerLift * power, atkSign * power);
G.pkPhase = 'runup';
}
function executePKKickAI() {
const k = G.pkKicker;
const difLvl = k.team.isHuman ? 2 : (G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2);
const range = GOAL_HALF - 0.5;
// 難易度が低いほど中央寄りに甘くなる
const spread = difLvl === 0 ? 0.4 : difLvl === 1 ? 0.7 : 0.9;
const aimX = (Math.random() * 2 - 1) * range * spread;
const lift = 0.1 + Math.random() * 0.15;
executePKKick(aimX / PK_KICK_POWER, lift);
}
function executePKDiving() {
const gk = G.pkGk;
gk.pkDiveStartX = gk.pos.x;
let diveX = 0;
if (gk.team.isHuman) {
// Human GK: Input determines dive direction at the moment of kick
const h = humans.find(hm => hm.team === gk.team);
if (h && h.pkDiveInput) diveX = h.pkDiveInput * (GOAL_HALF - 1);
} else {
// AI GK
const difLvl = G.difficulty === 'easy' ? 0 : G.difficulty === 'normal' ? 1 : 2;
// 確率で正しい方向へ飛ぶ。Easyは逆を突きやすい
const correctSide = Math.sign(ball.vel.x);
const readProb = difLvl === 0 ? 0.2 : difLvl === 1 ? 0.5 : 0.8;
diveX = (Math.random() < readProb ? correctSide : -correctSide) * (GOAL_HALF - 1);
}
gk.pkDiveTargetX = diveX;
gk.lock = { name: 'dive', t: 0, dur: 0.8, side: Math.sign(diveX) };
}
function endPKTurn(isGoal) {
if (G.pkPhase === 'result') return;
G.pkPhase = 'result';
if (G.pkTurn === 0) {
if (isGoal) G.pkScore1++;
G.home.pkHistory.push(isGoal ? 'O' : 'X');
} else {
if (isGoal) G.pkScore2++;
G.away.pkHistory.push(isGoal ? 'O' : 'X');
}
if (typeof pkUpdateUI === 'function') pkUpdateUI();
if (isGoal && typeof cheer === 'function') cheer();
setTimeout(() => {
// 勝利判定
const rem1 = 5 - G.pkRound;
const rem2 = G.pkTurn === 0 ? 5 - G.pkRound + 1 : 5 - G.pkRound;
let decided = false;
if (G.pkRound <= 5) {
if (G.pkScore1 > G.pkScore2 + rem2) decided = true;
if (G.pkScore2 > G.pkScore1 + rem1) decided = true;
if (G.pkRound === 5 && G.pkTurn === 1 && G.pkScore1 === G.pkScore2) decided = false; // サドンデスへ
} else {
if (G.pkTurn === 1 && G.pkScore1 !== G.pkScore2) decided = true; // サドンデス決着
}
if (decided) {
G.pkWinner = G.pkScore1 > G.pkScore2 ? G.home : G.away;
if (typeof pkEndUI === 'function') pkEndUI(G.pkWinner);
} else {
// 次のターンへ
if (G.pkTurn === 1) G.pkRound++;
G.pkTurn = G.pkTurn === 0 ? 1 : 0;
setupPKTurn();
}
}, 2000);
}main.js
"use strict";
/* =====================================================================
main.js — 入力、HUD更新、メインループ、トーナメント&PK管理
===================================================================== */
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.25, 60); }
function shootSnd() { tone(110, 0.15, 'square', 0.35, 40); setTimeout(() => tone(80, 0.12, 'sine', 0.2, 50), 12); }
function gkKickSnd() { tone(110, 0.14, 'square', 0.26, 50); }
function stealSnd() { tone(540, 0.07, 'square', 0.2, 360); setTimeout(() => tone(780, 0.06, 'square', 0.16, 520), 45); }
function gkCatchSnd() { tone(190, 0.12, 'sine', 0.24, 110); }
function refWhistle() { tone(2050, 0.13, 'square', 0.09, 2250); }
function feintSnd() { tone(800, 0.15, 'sawtooth', 0.2, 1200); }
function slideSnd() {
if (!ac) return; if (ac.state === 'suspended') ac.resume();
const b = ac.createBuffer(1, ac.sampleRate * 0.26, 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, 2) * 0.5;
const s = ac.createBufferSource(); s.buffer = b;
const f = ac.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 1300;
const g = ac.createGain(); g.gain.value = 0.32;
s.connect(f); f.connect(g); g.connect(ac.destination); s.start();
}
function restartSnd(kind) { if (kind === 'throw') tone(880, 0.05, 'sine', 0.12); else refWhistle(); }
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 = [];
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 = [];
$('pkUI').classList.remove('on');
}
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, feintCd: 0, feintBoostT: 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; h.feintFired = false; });
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(); updateTacticsUI();
}
function newMatch() {
clearMatch();
const hc = G.colorChoice === 'random' ? (Math.random() < 0.5 ? 'red' : 'blue') : G.colorChoice;
const homeColor = hc === 'red' ? RED : BLUE, awayColor = hc === 'red' ? BLUE : RED;
let pool = [...MULTIVERSE_NATIONS];
pool.sort(() => Math.random() - 0.5);
let hNat = pool[0]; let aNat = pool[1];
const home = makeTeam(homeColor, hNat.name, true);
const away = makeTeam(awayColor, aNat.name, 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();
humans = [{ team: home, scheme: 'P1', cam: cam1, controlled: null, aim: buildAim(home.color), stamina: 1, currentPassRoute: null }];
if (G.mode === 'multi') humans.push({ 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');
kickoff(home);
}
// 【トーナメント画面と進行の管理】
function showTourneyBracket() {
showScreen('scrTitle', false); showScreen('scrFull', false); showScreen('scrTourney', true);
const t = G.tourney;
const w_qf = [ t.round > 0 ? t.playerNation : null, t.round > 0 ? t.teams[2] : null, t.round > 0 ? t.teams[4] : null, t.round > 0 ? t.teams[6] : null ];
const w_sf = [ t.round > 1 ? t.playerNation : null, t.round > 1 ? t.teams[4] : null ];
const w_f = t.round > 2 ? t.playerNation : null;
const buildMatch = (t1, t2, winner, isTargetMatch) => {
const mClass = isTargetMatch ? 'b-match active' : 'b-match';
const c1 = t1 ? (t1 === t.playerNation ? 'b-team player' : 'b-team') : 'b-team';
const c2 = t2 ? (t2 === t.playerNation ? 'b-team player' : 'b-team') : 'b-team';
const d1 = (winner && winner !== t1) ? ' dead' : '';
const d2 = (winner && winner !== t2) ? ' dead' : '';
return `<div class="${mClass}"><div class="${c1}${d1}">${t1 ? t1.name : '???'}</div><div class="${c2}${d2}">${t2 ? t2.name : '???'}</div></div>`;
};
let html = `<div class="b-col">`;
html += buildMatch(t.teams[0], t.teams[1], w_qf[0], t.round === 0); html += buildMatch(t.teams[2], t.teams[3], w_qf[1], false);
html += buildMatch(t.teams[4], t.teams[5], w_qf[2], false); html += buildMatch(t.teams[6], t.teams[7], w_qf[3], false);
html += `</div><div class="b-col">`;
html += buildMatch(w_qf[0], w_qf[1], w_sf[0], t.round === 1); html += buildMatch(w_qf[2], w_qf[3], w_sf[1], false);
html += `</div><div class="b-col" style="justify-content: center;">`;
html += buildMatch(w_sf[0], w_sf[1], w_f, t.round === 2);
html += `</div><div class="b-col" style="justify-content: center;">`;
html += w_f ? `<div class="b-champ">${w_f.name}<br>🏆 CHAMPION 🏆</div>` : `<div class="b-champ" style="background:#333; color:#777; box-shadow:none;">???</div>`;
html += `</div>`;
$('bracketUI').innerHTML = html;
if (t.round > 2) {
$('btnTourneyNext').textContent = 'タイトルへ戻る';
$('btnTourneyNext').onclick = () => { showScreen('scrTourney', false); G.started = false; G.state = 'menu'; clearMatch(); showScreen('scrTitle', true); cam1.position.set(0, 30, HALF_Z + 34); cam1.lookAt(0, 0, 0); };
} else {
$('btnTourneyNext').textContent = 'NEXT MATCH ▶';
$('btnTourneyNext').onclick = () => { showScreen('scrTourney', false); startTourneyMatch(); };
}
}
function startTourneyMatch() {
clearMatch();
const pNat = G.tourney.playerNation;
let eNat;
if (G.tourney.round === 0) eNat = G.tourney.teams[1];
else if (G.tourney.round === 1) eNat = G.tourney.teams[2];
else if (G.tourney.round === 2) eNat = G.tourney.teams[4];
const hc = G.colorChoice === 'random' ? pNat.color : (G.colorChoice === 'red' ? RED : BLUE);
const home = makeTeam(hc, pNat.name, true);
const away = makeTeam(eNat.color, eNat.name, false);
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();
humans = [{ team: home, scheme: 'P1', cam: cam1, controlled: null, aim: buildAim(home.color), stamina: 1, currentPassRoute: null }];
G.half = 1; G.halfElapsed = 0; G.firstKicker = home; G.started = true; G.state = 'play';
audioInit(); setHelp(); $('hud').classList.add('on');
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', 'KeyA', 'KeyH']);
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(); } }
keys.add(e.code);
});
window.addEventListener('keyup', e => keys.delete(e.code));
function axis(scheme) {
let up, dn, lf, rt, sm, bg, smHold, bgHold, feint = false;
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');
if (smHold && bgHold) { if (!window._feintP1) { feint = true; window._feintP1 = true; } } else { window._feintP1 = false; }
} 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');
if (smHold && bgHold) { if (!window._feintP2) { feint = true; window._feintP2 = true; } } else { window._feintP2 = false; }
}
return { fwd: (up ? 1 : 0) - (dn ? 1 : 0), right: (rt ? 1 : 0) - (lf ? 1 : 0), small: sm, big: bg, smHold, bgHold, feint };
}
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 / PK UI ---------- */
function toggleTactic(team, uiId) {
const arr = ['NORMAL', 'ATTACK', 'DEFEND']; team.tactic = arr[(arr.indexOf(team.tactic) + 1) % 3]; team.manualTactic = true; updateTacticsUI();
}
function updateTacticsUI() {
if (humans[0]) { $('tacP1').textContent = `P1 TACTIC (A): ${humans[0].team.tactic}`; $('tacP1').className = 'tac-btn ' + (humans[0].team.tactic === 'ATTACK' ? 'atk' : humans[0].team.tactic === 'DEFEND' ? 'def' : ''); }
if (humans[1]) { $('tacP2').style.display = 'block'; $('tacP2').textContent = `P2 TACTIC (H): ${humans[1].team.tactic}`; $('tacP2').className = 'tac-btn ' + (humans[1].team.tactic === 'ATTACK' ? 'atk' : humans[1].team.tactic === 'DEFEND' ? 'def' : ''); } else $('tacP2').style.display = 'none';
}
function setHelp() {
const k = s => `<span class="k">${s}</span>`; let html;
if (G.mode === 'single' || G.mode === 'tournament') {
html = `<h4>CONTROLS</h4>移動 ${k('←↑↓→')} パス/自動攻撃 ${k('Z')} シュート ${k('X')}<br>`
+ `<span style="opacity:.7"><b>フェイント: ZとX同時押し</b>(突破&スタン)<br>`
+ `非保持中… <b>Z/X</b>で敵へダッシュ追跡&自動スライディング(押すだけ/長押し可)<br>`
+ `戦術変更: <b>Aキー</b> または右下ボタン</span>`;
} else {
html = `<h4>CONTROLS</h4><span style="color:var(--red)">P1(左)</span> 移動 ${k('←↑↓→')} 小 ${k('Z')} 大 ${k('X')} 戦術 ${k('A')}<br>`
+ `<span style="color:var(--blue)">P2(右)</span> 移動 テンキー ${k('8')}${k('4')}${k('5')}${k('6')}${k('2')} 小 ${k('N')} 大 ${k('M')} 戦術 ${k('H')}<br>`
+ `<span style="opacity:.7">フェイントはZ+X(P2はN+M)同時押し。</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 + "'";
let hstr = G.half === 1 ? '1ST HALF' : '2ND HALF';
if (G.mode === 'tournament') { const rname = ['QUARTER FINAL', 'SEMI FINAL', 'FINAL MATCH'][G.tourney.round]; hstr = rname + ' | ' + hstr; }
$('hlf').textContent = hstr;
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() {
if (G.state === 'pk_shootout') return;
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 p1 = humans && humans[0], flip = !!(p1 && p1.team && p1.team.dir > 0);
const mx = v => { const t = (v + HALF_X) / (2 * HALF_X); return 8 + (flip ? 1 - t : t) * (W - 16); };
const mz = v => { const t = (v + HALF_Z) / (2 * HALF_Z); return 8 + (flip ? 1 - t : t) * (H - 16); };
x.fillStyle = 'rgba(255,255,255,.7)';
const gxl = Math.min(mx(-GOAL_HALF), mx(GOAL_HALF)), gw = Math.abs(mx(GOAL_HALF) - mx(-GOAL_HALF));
x.fillRect(gxl, H - 12, gw, 8); x.fillRect(gxl, 4, gw, 8);
if (!G.home) return;
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(isPkEnd = false) {
showScreen('scrFull', true); $('hud').classList.remove('on'); $('pkUI').classList.remove('on');
if (G.mode === 'tournament') {
if (G.home.score === G.away.score && !isPkEnd) {
$('ftScore').textContent = G.home.score + ' – ' + G.away.score;
$('ftDesc').textContent = "DRAW - 決着はPK戦へ!";
$('ftAgain').textContent = "PK戦開始 ▶";
$('ftAgain').onclick = () => { showScreen('scrFull', false); startPK(); };
return;
}
if (isPkEnd) $('ftScore').textContent = G.pkScore1 + ' (PK) ' + G.pkScore2;
else $('ftScore').textContent = G.home.score + ' – ' + G.away.score;
const winner = isPkEnd ? G.pkWinner : (G.home.score > G.away.score ? G.home : G.away);
if (winner === G.home) {
if (G.tourney.round >= 2) {
$('ftDesc').textContent = "🏆 マルチバース制覇!世界一! 🏆";
$('ftAgain').textContent = "最終結果へ ▶";
$('ftAgain').onclick = () => { showScreen('scrFull', false); G.tourney.round++; showTourneyBracket(); };
} else {
$('ftDesc').textContent = "見事な勝利!次のラウンドへ進出";
$('ftAgain').textContent = "トーナメント表へ ▶";
$('ftAgain').onclick = () => { showScreen('scrFull', false); G.tourney.round++; showTourneyBracket(); };
}
} else {
$('ftDesc').textContent = "敗北... 歴史の闇に飲まれた";
$('ftAgain').textContent = "タイトルへ戻る";
$('ftAgain').onclick = () => { showScreen('scrFull', false); showScreen('scrTitle', true); };
}
} else {
$('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;
$('ftAgain').textContent = "もう一度 ↺";
$('ftAgain').onclick = () => { showScreen('scrFull', false); newMatch(); };
}
}
// ---- PK UI Functions ----
function pkStartUI() {
$('pkUI').classList.add('on'); $('hud').classList.add('on');
$('pkNameL').textContent = G.home.name; $('pkNameR').textContent = G.away.name;
pkUpdateUI();
}
function pkUpdateUI() {
const drawHist = (hist, el) => {
el.innerHTML = '';
const max = Math.max(5, G.pkRound);
for(let i=0; i<max; i++) {
let dot = document.createElement('div');
dot.className = 'pk-dot ' + (hist[i] === 'O' ? 'o' : (hist[i] === 'X' ? 'x' : ''));
dot.textContent = hist[i] || ''; el.appendChild(dot);
}
};
drawHist(G.home.pkHistory, $('pkHistL')); drawHist(G.away.pkHistory, $('pkHistR'));
const kickingTeam = G.pkTurn === 0 ? G.home : G.away;
if (G.pkPhase === 'setup') $('pkAimHint').textContent = "セットアップ中...";
else if (G.pkPhase === 'aim') {
if (kickingTeam.isHuman) $('pkAimHint').textContent = "キッカー: Z/Xでシュート、矢印でコース指定";
else $('pkAimHint').textContent = "キーパー: 左右矢印でダイブ予測";
} else {
$('pkAimHint').textContent = "シュート!";
}
}
function pkEndUI(winner) {
showFulltime(true);
}
/* ---------- 描画 ---------- */
function drawAim(h) {
if (G.state === 'pk_shootout') {
h.aim.sel.visible = false; h.aim.marker.visible = false; h.aim.whites.forEach(hideArrow); hideArrow(h.aim.yellow); return;
}
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);
const feinting = (p.feintBoostT || 0) > 0;
if (ball.owner === p) {
A.marker.visible = !feinting || (Date.now() % 100 < 50);
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);
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(52.0);
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) {
if (G.state === 'pk_shootout') return; // 別処理
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;
if (p.lock && p.lock.name === 'stun') {
p.parts.torso.rotation.x = 0.3; p.parts.head.rotation.x = Math.sin(Date.now() / 20) * 0.2;
p.parts.armL.rotation.x = 0.5; p.parts.armR.rotation.x = 0.5;
p.shadow.position.set(p.pos.x, 0.02, p.pos.z); return;
}
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);
if (typeof updateBallTrail === 'function') updateBallTrail(dt);
}
/* ---------- ループ ---------- */
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); p.feintCd = Math.max(0, (p.feintCd || 0) - dt); p.feintBoostT = Math.max(0, (p.feintBoostT || 0) - dt);
if (p.lock) { p.lock.t += dt; if (p.lock.t >= p.lock.dur) p.lock = null; }
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 && G.state === 'play') {
if (G.half === 1) { G.state = 'halftime'; G.htTimer = 3.6; whistle(); showHalftime(); }
else { G.state = 'fulltime'; whistle(); showFulltime(false); }
}
}
function step(dt) {
if (justPressed.has('KeyA') && humans[0]) toggleTactic(humans[0].team, 'tacP1');
if (justPressed.has('KeyH') && humans[1]) toggleTactic(humans[1].team, 'tacP2');
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();
}
function stepPK(dt) {
allPlayers().forEach(p => tickPlayer(p, dt));
updatePK(dt);
// 入力処理
humans.forEach(h => {
const a = axis(h.scheme);
if (h.team === G.pkKicker.team && h.controlled === G.pkKicker) {
if (G.pkPhase === 'aim') {
const powerLift = a.big ? 0.3 : 0.12; // シュートの浮かせ具合
if (a.small || a.big) {
let aimX = a.right * 0.55;
executePKKick(aimX, powerLift);
pkUpdateUI();
}
}
} else if (h.team === G.pkGk.team && h.controlled === G.pkGk) {
h.pkDiveInput = a.right; // GKのダイブ予測 (-1, 0, 1)
}
});
// 専用カメラ
humans.forEach(h => {
const atkSign = G.pkKicker.team.dir;
h.cam.position.set(G.pkKicker.pos.x * 0.3, PK_CAM_H, G.pkKicker.pos.z - atkSign * PK_CAM_BACK);
h.cam.lookAt(0, PK_CAM_LOOK_Y, G.pkKicker.team.attackZ);
});
ballUpdate(dt);
}
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 === 'pk_shootout') {
stepPK(dt);
} else if (G.state === 'celebrate') {
G.celebr -= dt; if (G.celebr <= 0) { hideGoalBanner(); G.state = 'play'; kickoff(G.kickNext); }
} else if (G.state === 'halftime') {
G.htTimer -= dt; if (G.htTimer <= 0) { swapSides(); G.half = 2; G.halfElapsed = 0; hideHalftime(); G.state = 'play'; kickoff(G.firstKicker === G.home ? G.away : G.home); }
}
if (G.started) { animateAll(dt); humans.forEach(h => drawAim(h)); updateHUD(); }
updateConfetti(dt); updateCrowd(dt); renderScene();
justPressed.clear();
}
function renderScene() {
const W = window.innerWidth, H = window.innerHeight;
if (G.mode === 'multi' && G.started) {
renderer.setScissorTest(true);
renderer.setViewport(0, 0, W / 2, H); renderer.setScissor(0, 0, W / 2, H); cam1.aspect = (W / 2) / H; cam1.updateProjectionMatrix(); renderer.render(scene, cam1);
renderer.setViewport(W / 2, 0, W / 2, H); renderer.setScissor(W / 2, 0, W / 2, H); cam2.aspect = (W / 2) / H; cam2.updateProjectionMatrix(); renderer.render(scene, cam2);
renderer.setScissorTest(false);
} else {
renderer.setViewport(0, 0, W, H); renderer.setScissorTest(false); cam1.aspect = W / H; cam1.updateProjectionMatrix(); renderer.render(scene, cam1);
}
}
/* ---------- メニュー・起動 ---------- */
function startMatch() {
if (G.mode === 'tournament') {
let pool = [...MULTIVERSE_NATIONS]; pool.sort(() => Math.random() - 0.5);
const pNat = pool[0]; const enemies = pool.slice(1, 8);
G.tourney = { round: 0, playerNation: pNat, teams: [pNat, ...enemies] };
showTourneyBracket();
} else 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-diff]').forEach(el => el.addEventListener('click', () => { document.querySelectorAll('[data-diff]').forEach(x => x.classList.remove('act')); el.classList.add('act'); G.difficulty = el.dataset.diff; }));
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(); });
$('tacP1').addEventListener('click', () => { if (humans[0]) toggleTactic(humans[0].team, 'tacP1'); });
$('tacP2').addEventListener('click', () => { if (humans[1]) toggleTactic(humans[1].team, 'tacP2'); });
$('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);


コメント