Anime.js v4: staggered, looping animations in a few lines.
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>SWALLOWS BASEBALL 3D</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#08111b;color:#fff;font-family:Arial,sans-serif;overflow:hidden}
#game{position:fixed;inset:0;width:100%;height:100%}
#top{position:fixed;top:8px;left:8px;right:8px;z-index:3;background:#07101be8;border:1px solid #526278;border-radius:12px;padding:10px}
.brand{font-weight:900;letter-spacing:1px;color:#54c8a1;font-size:17px}
.sub{font-size:10px;color:#b8c8d8;margin-top:3px}
#score{display:flex;justify-content:space-between;gap:6px;align-items:center;margin-top:8px}
.scorebox{background:#1a293a;border-radius:7px;padding:7px 9px;flex:1;text-align:center}
.big{font-size:21px;font-weight:bold}
.small{font-size:10px;color:#b9c7d8}
#counts{font-size:13px;margin-top:8px;display:flex;justify-content:space-between;gap:6px}
#controls{position:fixed;bottom:12px;left:10px;right:10px;z-index:4;display:grid;grid-template-columns:1fr 1fr;gap:8px}
button,select{border:1px solid #52667e;background:#1b2b40;color:white;border-radius:10px;padding:13px;font-size:15px;font-weight:bold}
button:active{transform:scale(.97);background:#355b70}
.pitch{background:#087d65;border-color:#25b99a}
.swing{background:#b23d45;border-color:#ff7272}
#extra{position:fixed;top:155px;right:8px;z-index:3;display:flex;flex-direction:column;gap:6px}
#extra button{font-size:11px;padding:9px}
#message{position:fixed;z-index:2;top:43%;left:50%;transform:translate(-50%,-50%);font-size:clamp(25px,6vw,48px);font-weight:900;text-align:center;text-shadow:0 3px 8px #000;pointer-events:none;width:90%}
#help{position:fixed;bottom:112px;left:10px;z-index:2;font-size:11px;color:#e2eaf1;text-shadow:0 1px 5px #000}
#park{width:100%;font-size:12px;padding:8px;margin-top:7px}
@media(min-width:700px){#top{max-width:430px}#controls{left:50%;transform:translateX(-50%);width:420px}#extra{top:20px;left:auto;right:12px}}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="top">
<div class="brand">⚾ SWALLOWS BASEBALL 3D</div>
<div class="sub">オリジナル3D野球ゲーム / 神宮球場風スタジアム</div>
<select id="park">
<option value="jingu">明治神宮野球場風</option>
<option value="dome">ドーム球場風</option>
<option value="night">ナイター球場</option>
</select>
<div id="score">
<div class="scorebox"><div class="small">HOME</div><div class="big" id="home">0</div><div class="small">SWALLOWS</div></div>
<div class="scorebox"><div class="small" id="inning">1回表</div><div class="big" id="outs">0 OUT</div><div class="small" id="result">PLAY BALL</div></div>
<div class="scorebox"><div class="small">AWAY</div><div class="big" id="away">0</div><div class="small">OPPONENT</div></div>
</div>
<div id="counts"><span id="bso">B 0 S 0 O 0</span><span id="bases">走者なし</span></div>
</div>
<div id="extra">
<button id="view">📺 中継視点</button>
<button id="reset">↻ 試合リセット</button>
</div>
<div id="message">PLAY BALL!</div>
<div id="help">① 球種を選ぶ → ② 投球 → ③ タイミングよくスイング</div>
<div id="controls">
<select id="pitchType">
<option value="fast">ストレート</option>
<option value="slider">スライダー</option>
<option value="fork">フォーク</option>
</select>
<button class="pitch" id="pitch">⚾ 投球</button>
<button class="swing" id="swing">🏏 スイング!</button>
<button id="next">次の打者</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
const $=id=>document.getElementById(id);
if(!window.THREE){$("message").textContent="3Dライブラリを読み込めません";return}
const THREE=window.THREE;
const scene=new THREE.Scene();
scene.background=new THREE.Color(0x101c30);
scene.fog=new THREE.Fog(0x101c30,100,260);
const camera=new THREE.PerspectiveCamera(53,innerWidth/innerHeight,.1,700);
const renderer=new THREE.WebGLRenderer({canvas:$("game"),antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,1.7));
renderer.setSize(innerWidth,innerHeight);
renderer.outputEncoding=THREE.sRGBEncoding;
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=.85;
scene.add(new THREE.HemisphereLight(0x9db7d9,0x26291f,1.1));
const sun=new THREE.DirectionalLight(0xffe3bc,1.35);
sun.position.set(-45,75,35);scene.add(sun);
const fill=new THREE.DirectionalLight(0x8ab7ff,.35);
fill.position.set(45,30,-45);scene.add(fill);
const stadium=new THREE.Group();scene.add(stadium);
const field=new THREE.Group();stadium.add(field);
const players=new THREE.Group();scene.add(players);
const ballGroup=new THREE.Group();scene.add(ballGroup);
const material=(c,r=.85,m=0)=>new THREE.MeshStandardMaterial({color:c,roughness:r,metalness:m});
const grass=material(0x26703e),grass2=material(0x317a43);
const dirt=material(0x9a6545),chalk=material(0xf0eadc);
const concrete=material(0x626e7c),seats=material(0x244e53);
const navy=material(0x14243c),white=material(0xf2f0e8);
const teal=material(0x00a77a),skin=material(0xc99472);
const black=material(0x15191f),metal=material(0x8995a0,.38,.6);
const ballmat=material(0xffffff,.45);
const batmat=material(0x9d6333,.45);
function box(parent,x,y,z,sx,sy,sz,mat){
const o=new THREE.Mesh(new THREE.BoxGeometry(sx,sy,sz),mat);
o.position.set(x,y,z);parent.add(o);return o;
}
function sphere(parent,x,y,z,r,mat){
const o=new THREE.Mesh(new THREE.SphereGeometry(r,12,10),mat);
o.position.set(x,y,z);parent.add(o);return o;
}
function cyl(parent,x,y,z,r1,r2,h,mat,seg=12){
const o=new THREE.Mesh(new THREE.CylinderGeometry(r1,r2,h,seg),mat);
o.position.set(x,y,z);parent.add(o);return o;
}
function line(parent,a,b,r,mat){
const A=new THREE.Vector3(...a),B=new THREE.Vector3(...b),d=B.clone().sub(A);
const o=new THREE.Mesh(new THREE.CylinderGeometry(r,r,d.length(),6),mat);
o.position.copy(A.clone().add(B).multiplyScalar(.5));
o.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),d.normalize());
parent.add(o);return o;
}
function clear(g){while(g.children.length){let o=g.children[0];g.remove(o);if(o.geometry)o.geometry.dispose()}}
function makeTextBoard(parent,x,y,z,w,h,text){
box(parent,x,y,z,w,h,.5,black);
const panel=box(parent,x,y+.02,z+.28,w*.94,h*.83,.08,material(0x102b30));
const c=document.createElement("canvas");c.width=512;c.height=128;
const ctx=c.getContext("2d");ctx.fillStyle="#071b20";ctx.fillRect(0,0,512,128);
ctx.fillStyle="#d9f8d8";ctx.font="bold 38px Arial";ctx.textAlign="center";
ctx.fillText(text,256,77);
const tex=new THREE.CanvasTexture(c);tex.encoding=THREE.sRGBEncoding;
const screen=new THREE.Mesh(new THREE.PlaneGeometry(w*.9,h*.72),new THREE.MeshBasicMaterial({map:tex}));
screen.position.set(x,y,z+.34);parent.add(screen);
}
let playerParts={};
function makePlayer(x,z,team,role){
const g=new THREE.Group();players.add(g);g.position.set(x,0,z);
const jersey=team==="home"?white:material(0x26364c);
const trim=team==="home"?teal:material(0xe8e9ee);
// legs, baseball pants
const leg1=cyl(g,-.13,.46,0,.105,.13,.82,jersey);
const leg2=cyl(g,.13,.46,0,.105,.13,.82,jersey);
box(g,-.13,.06,.04,.25,.12,.38,black);
box(g,.13,.06,.04,.25,.12,.38,black);
// torso and neck
const torso=box(g,0,1.13,0,.57,.66,.32,jersey);
box(g,0,1.13,.17,.06,.62,.035,trim);
cyl(g,0,1.52,0,.12,.12,.16,skin);
sphere(g,0,1.75,0,.23,skin);
// hair / cap
cyl(g,0,1.96,0,.22,.24,.13,team==="home"?navy:black);
box(g,0,1.91,.18,.29,.045,.18,team==="home"?navy:black);
// arms and gloves
const armL=cyl(g,-.39,1.13,0,.09,.11,.51,skin);
const armR=cyl(g,.39,1.13,0,.09,.11,.51,skin);
armL.rotation.z=-.28;armR.rotation.z=.28;
sphere(g,-.42,.84,.04,.105,black);
sphere(g,.42,.84,.04,.105,black);
// cap logo-like plain accent, no official logo
sphere(g,0,1.97,.12,.045,trim);
g.userData={role,torso,armL,armR,leg1,leg2};
if(role==="batter"){
const bat=line(g,[.43,1.08,.05],[.76,1.64,.05],.045,batmat);
g.userData.bat=bat;
}
if(role==="pitcher"){
const glove=sphere(g,.44,1.05,.08,.14,batmat);
g.userData.glove=glove;
}
return g;
}
function arcStand(radius,y,thickness,depth,mat,open=1.55){
// Curved seating bowl assembled from individual sections
for(let i=0;i<44;i++){
const a=-Math.PI*.82+i*(Math.PI*1.64/44);
const da=Math.PI*1.64/44*.92;
const geo=new THREE.BoxGeometry(radius*da,thickness,depth);
const o=new THREE.Mesh(geo,mat);
o.position.set(Math.sin(a)*radius,y,Math.cos(a)*radius-18);
o.rotation.y=a;
stadium.add(o);
}
}
function buildStadium(type){
clear(stadium);clear(field);clear(players);clear(ballGroup);
const dome=type==="dome";
const roofType=type==="dome";
scene.background=new THREE.Color(type==="night"||dome?0x080f20:0x24364a);
scene.fog.color.copy(scene.background);
// ground and outfield
box(field,0,-.5,-18,210,1,210,material(0x17241f));
const lawn=new THREE.Mesh(new THREE.CircleGeometry(66,80),grass);
lawn.rotation.x=-Math.PI/2;lawn.position.set(0,.01,-18);field.add(lawn);
// mowing stripes
for(let i=-6;i<=6;i++){
box(field,i*8,.025,-18,4,.015,116,grass2);
}
// foul territory and infield dirt
const diamond=new THREE.Mesh(new THREE.CircleGeometry(34,4),dirt);
diamond.rotation.x=-Math.PI/2;
diamond.rotation.z=Math.PI/4;
diamond.position.set(0,.05,15);field.add(diamond);
// grass inside diamond
const inner=new THREE.Mesh(new THREE.CircleGeometry(23,4),grass);
inner.rotation.x=-Math.PI/2;inner.rotation.z=Math.PI/4;
inner.position.set(0,.08,15);field.add(inner);
// mound
cyl(field,0,.26,0,3.1,3.5,.45,dirt,32);
cyl(field,0,.5,0,.5,.5,.07,white,16);
// home plate and bases
box(field,0,.14,28,1.2,.1,1.2,chalk);
box(field,-19,.15,9,1.1,.12,1.1,chalk);
box(field,0,.15,-10,1.1,.12,1.1,chalk);
box(field,19,.15,9,1.1,.12,1.1,chalk);
// base paths
line(field,[0,.12,28],[-19,.12,9],.08,chalk);
line(field,[-19,.12,9],[0,.12,-10],.08,chalk);
line(field,[0,.12,-10],[19,.12,9],.08,chalk);
line(field,[19,.12,9],[0,.12,28],.08,chalk);
// foul lines
line(field,[0,.13,28],[-62,.13,-37],.09,chalk);
line(field,[0,.13,28],[62,.13,-37],.09,chalk);
// outfield wall approximated by connected segments
const wallH=type==="jingu"?2.8:3.8;
for(let i=0;i<32;i++){
const a=-Math.PI*.78+i*(Math.PI*1.56/31);
const r=type==="jingu"?59:63;
const x=Math.sin(a)*r,z=-18-Math.cos(a)*r;
const wall=box(stadium,x,wallH/2,z,5,wallH,.8,material(i%2?0x08735d:0x12664f));
wall.rotation.y=a;
box(stadium,x,wallH+.1,z,5,.15,.9,white).rotation.y=a;
}
// seating tiers
for(let tier=0;tier<5;tier++){
const r=78+tier*5.3,y=3+tier*3.0;
for(let i=0;i<54;i++){
const a=-Math.PI*.84+i*(Math.PI*1.68/53);
const o=box(stadium,Math.sin(a)*r,y,-18+Math.cos(a)*r,8,2.5,5,tier%2?seats:concrete);
o.rotation.y=a;
// rows of seats as small colored dots
for(let row=0;row<2;row++){
box(stadium,Math.sin(a)*r,y+1.25,-18+Math.cos(a)*r+row*1.1,7,.35,.45,
row%2?material(0x315a67):material(0x315049)).rotation.y=a;
}
}
}
// upper deck / roof
for(let i=0;i<22;i++){
const a=-Math.PI*.8+i*(Math.PI*1.6/21);
const r=99;
const x=Math.sin(a)*r,z=-18+Math.cos(a)*r;
box(stadium,x,23,z,11,1.2,7,material(0x374453)).rotation.y=a;
}
if(roofType){
// curved roof ribs: a series of high crossbeams and translucent roof panels
for(let i=-5;i<=5;i++){
line(stadium,[i*11,25,-95],[i*11,31,52],.38,metal);
}
const roof=new THREE.Mesh(new THREE.SphereGeometry(107,32,16,0,Math.PI*2,0,Math.PI*.34),
new THREE.MeshStandardMaterial({color:0x9aafc2,transparent:true,opacity:.22,side:THREE.DoubleSide,metalness:.15,roughness:.35}));
roof.scale.set(1,.7,1);roof.position.set(0,0,-18);stadium.add(roof);
}else{
// open-air stadium: light towers
for(const x of [-83,-49,49,83]){
const z=-40;
line(stadium,[x,0,z],[x,29,z],.45,metal);
box(stadium,x,29,z,7,2.5,1.5,lightMat);
for(let j=0;j<4;j++)box(stadium,x-2.4+j*1.6,29,z+.8,.7,.6,.2,lightMat);
}
// covered main stand
box(stadium,0,21,52,100,1.8,15,material(0x263748));
for(let x=-45;x<=45;x+=10)line(stadium,[x,13,46],[x,22,48],.25,metal);
}
// score board
makeTextBoard(stadium,0,18,-102,32,9,"SWALLOWS BASEBALL");
// dugouts
box(stadium,-33,1.5,28,17,3,7,material(0x33434c));
box(stadium,33,1.5,28,17,3,7,material(0x33434c));
// bullpens
box(stadium,-43,.1,-42,12,.1,8,grass);
box(stadium,43,.1,-42,12,.1,8,grass);
// batters / pitcher / fielders
makePlayer(0,33,"home","batter").rotation.y=Math.PI;
makePlayer(0,-.3,"away","pitcher");
makePlayer(-19,.2,"away","fielder");
makePlayer(19,.2,"away","fielder");
makePlayer(-25,-23,"away","fielder");
makePlayer(25,-23,"away","fielder");
makePlayer(0,-39,"away","fielder");
makePlayer(-40,-38,"away","fielder");
makePlayer(40,-38,"away","fielder");
makePlayer(0,9,"home","umpire");
// ball
sphere(ballGroup,0,1.7,0,.23,ballmat);
ballGroup.children[0].visible=false;
// ambience lights
for(const p of [[-55,30,-30],[55,30,-30],[-75,28,15],[75,28,15]]){
const l=new THREE.PointLight(0xd7e5ff,1.5,100);
l.position.set(...p);stadium.add(l);
}
setCamera();
}
function setCamera(){
if(view==="batter"){
camera.position.set(4,4,42);camera.lookAt(0,1,-2);
}else if(view==="wide"){
camera.position.set(0,120,155);camera.lookAt(0,0,-15);
}else{
camera.position.set(0,53,105);camera.lookAt(0,0,-12);
}
}
let b=0,s=0,o=0,inning=1,home=0,away=0,bases=[false,false,false];
let pitching=false,ballLive=false,swinging=false,swung=false,view="tv";
let pitchStart=0,pitchDuration=950,pitchKind="fast",ballPath=null;
function updateUI(){
$("home").textContent=home;$("away").textContent=away;
$("inning").textContent=inning+"回表";
$("outs").textContent=o+" OUT";
$("bso").textContent="B "+b+" S "+s+" O "+o;
$("bases").textContent=bases.some(Boolean)?
(bases[0]?"一塁 ":"")+(bases[1]?"二塁 ":"")+(bases[2]?"三塁":""):"走者なし";
}
function message(t){$("message").textContent=t}
function resetCount(){b=0;s=0;updateUI()}
function nextBatter(){
pitching=false;ballLive=false;swinging=false;swung=false;
ballGroup.children[0].visible=false;
resetCount();message("NEXT BATTER");$("result").textContent="NEXT BATTER";
}
function nextInning(){
o=0;bases=[false,false,false];inning++;
if(inning>9)inning=1;
message("CHANGE SIDES");updateUI();
}
function walk(){
if(bases[0]){
if(bases[1]){
if(bases[2])home++;
bases[2]=true;
}
bases[1]=true;
}
bases[0]=true;message("WALK");nextBatter();
}
function scoreRun(){home++}
function hitBall(){
const n=Math.random();
if(n<.14){
message("HOME RUN!");
home++;
bases.forEach(v=>{if(v)home++});
bases=[false,false,false];
}else if(n<.40){
message("DOUBLE!");
if(bases[2])scoreRun();
if(bases[1])scoreRun();
if(bases[0])scoreRun();
bases=[false,true,false];
}else if(n<.70){
message("SINGLE!");
if(bases[2])scoreRun();
if(bases[1])scoreRun();
if(bases[0]){scoreRun();bases[0]=false}
bases=[true,bases[0],false];
}else{
message("IN PLAY!");
o++;if(o>=3)nextInning();
}
nextBatter();updateUI();
}
function pitch(){
if(pitching||ballLive){message("投球中!");return}
pitching=true;swung=false;pitchKind=$("pitchType").value;
pitchStart=performance.now();pitchDuration=1050;
const ball=ballGroup.children[0];ball.visible=true;
ball.position.set(0,1.9,-1);
ballPath={start:new THREE.Vector3(0,1.9,-1),end:new THREE.Vector3(.15,1.35,26)};
if(pitchKind==="slider")ballPath.end.x=1.1;
if(pitchKind==="fork")ballPath.end.y=.45;
message("PITCH!");
}
function swing(){
if(!pitching){message("先に投球してね!");return}
if(swung)return;
swung=true;
// Timing-based batting outcome
const elapsed=performance.now()-pitchStart;
const t=elapsed/pitchDuration;
const good=t>.63&&t<.91;
if(good){
message("CONTACT!");
const ball=ballGroup.children[0];
ball.position.set(0,1.6,29);
ball.userData.fly=true;ball.userData.start=performance.now();
ball.userData.origin=ball.position.clone();
hitBall();
}else{
s++;
message(t<.63?"SWING EARLY!":"SWING LATE!");
if(s>=3){o++;s=0;b=0;message("STRIKEOUT!");if(o>=3)nextInning()}
updateUI();
}
pitching=false;
}
$("pitch").onclick=pitch;
$("swing").onclick=swing;
$("next").onclick=nextBatter;
$("view").onclick=()=>{
view=view==="tv"?"batter":view==="batter"?"wide":"tv";
$("view").textContent=view==="tv"?"📺 中継視点":view==="batter"?"🏏 バッター視点":"🏟 球場全景";
setCamera();
};
$("reset").onclick=()=>{
home=0;away=0;inning=1;o=0;b=0;s=0;bases=[false,false,false];
pitching=false;ballLive=false;ballGroup.children[0].visible=false;
updateUI();message("PLAY BALL!");
};
$("park").onchange=()=>{buildStadium($("park").value);message("球場変更!")};
$("night").onclick=()=>{};
$("game").addEventListener("pointerdown",e=>{
if(e.clientY>innerHeight*.55 && e.button===0 && e.pointerType==="touch")return;
});
let down=false,px=0,py=0;
canvas.addEventListener("pointerdown",e=>{down=true;px=e.clientX;py=e.clientY});
canvas.addEventListener("pointerup",()=>down=false);
canvas.addEventListener("pointermove",e=>{
if(!down||view==="batter")return;
const dx=e.clientX-px,dy=e.clientY-py;px=e.clientX;py=e.clientY;
camera.position.x-=dx*.15;camera.position.y=Math.max(8,Math.min(130,camera.position.y+dy*.15));
camera.lookAt(0,0,-15);
});
canvas.addEventListener("wheel",e=>{
camera.position.multiplyScalar(e.deltaY>0?1.06:.94);
camera.position.y=Math.max(4,Math.min(150,camera.position.y));
},{passive:true});
function animate(now){
requestAnimationFrame(animate);
if(pitching&&ballPath){
let t=Math.min(1,(now-pitchStart)/pitchDuration);
const ball=ballGroup.children[0];
ball.position.lerpVectors(ballPath.start,ballPath.end,t);
if(pitchKind==="fork")ball.position.y-=Math.sin(t*Math.PI)*.8;
if(t>=1){
pitching=false;
if(!swung){
if(Math.random()<.7){b++;message("BALL!")}
else{s++;message("STRIKE!")}
if(b>=4){walk();}else if(s>=3){o++;s=0;b=0;message("STRIKEOUT!");if(o>=3)nextInning()}
updateUI();
}
ball.visible=false;
}
}
// animated ball flight on contact
const ball=ballGroup.children[0];
if(ball.userData.fly){
const t=(now-ball.userData.start)/1200;
if(t>1){ball.userData.fly=false;ball.visible=false}
else{
ball.visible=true;
ball.position.set(ball.userData.origin.x+Math.sin(t*5)*t*30,
ball.userData.origin.y+Math.sin(t*Math.PI)*18,
ball.userData.origin.z-t*90);
}
}
// gentle stadium lighting
renderer.render(scene,camera);
}
buildStadium("jingu");updateUI();setCamera();animate(0);
addEventListener("resize",()=>{
camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();
renderer.setSize(innerWidth,innerHeight);
});
})();
</script>
</body>
</html>