public/index.html
分割 2 / 6 · 元ファイル 176〜409行付近
★長さはメートル。画面の左端が0m、右端が7.8m。px へ直すのはここだけの仕事 */
var stage=document.getElementById('stage'), cv=document.getElementById('cv'), g2=cv.getContext('2d');
var WC=ZWORLD.C;
var W=390,H=700,L={},stars=[],bldg=[],cloud=[];
function calcL(){
var floorY=H-Math.max(64,H*0.17);
var ppm=W/WC.VIEW_M;
return { ppm:ppm, floorY:floorY,
hr:0.294*ppm, /* シュールキャラの頭の半径(身長1.72m=5.85r) */
stoneR:Math.max(3.2,0.11*ppm),
maxPull:Math.min(205,H*0.34) };
}
function MX(m){ return m*L.ppm; }
function MY(m){ return L.floorY-m*L.ppm; }
function layout(){
W=stage.clientWidth||390; H=stage.clientHeight||700;
var dpr=Math.min(3,window.devicePixelRatio||1);
cv.width=Math.round(W*dpr); cv.height=Math.round(H*dpr);
g2.setTransform(dpr,0,0,dpr,0,0);
L=calcL(); build(); makeBG();
}
function makeBG(){
stars=[];
for(var i=0;i<64;i++) stars.push({ x:Math.random()*W, y:Math.random()*L.floorY*0.74,
r:0.3+Math.random()*1.1, a:0.18+Math.random()*0.55 });
bldg=[];
var x=-10;
while(x<W+20){ var w=40+Math.random()*70, h=(0.9+Math.random()*1.9)*L.ppm;
bldg.push({x:x,w:w,h:h}); x+=w+2+Math.random()*8; }
cloud=[];
for(var j=0;j<5;j++) cloud.push({ x:Math.random()*W, y:L.floorY*(0.16+Math.random()*0.42),
w:70+Math.random()*130, h:10+Math.random()*16, a:0.035+Math.random()*0.045 });
}
/* ===== 石の物理(フリースローの流用。ここは1文字も作り変えていない) ===== */
var Mt=window.Matter;
var STONE={ restitution:0.46, friction:0.30, frictionAir:0.0022, density:0.0016,
collisionFilter:{group:-1} };
var eng=null, acc=0, STEP=1000/60;
var S=[];
function build(){
if(eng){ try{ Mt.Events.off(eng); Mt.Composite.clear(eng.world,false); Mt.Engine.clear(eng); }catch(e){} }
eng=Mt.Engine.create();
eng.gravity.y=(9.8*L.ppm)/1000;
var floor=Mt.Bodies.rectangle(W/2,L.floorY+200,W*6,400,
{ isStatic:true, label:'floor', restitution:0.46, friction:0.30 });
Mt.Composite.add(eng.world,floor);
Mt.Events.on(eng,'collisionStart',function(ev){
for(var i=0;i<ev.pairs.length;i++){
var p=ev.pairs[i];
var st=(p.bodyA.label==='stone')?p.bodyA:((p.bodyB.label==='stone')?p.bodyB:null);
if(!st) continue;
var v=Math.abs(st.velocity.y)*60;
st.zgB=(st.zgB||0)+1;
if(v>55){ seGround(v); dust(st.position.x,L.floorY,st.zgRock?7:3); }
}
});
S.length=0;
}
/* ===== 世界 ===== */
var world=new ZWORLD.World({sim:'full'});
var meId='solo';
var NET={ on:false, ws:null, room:'', tries:0, mode:'off', ping:0 };
/* ===== 当たり(メートル→px) ===== */
function zGeo(z){
var r=L.hr, feet=L.floorY, x=MX(z.x);
var hy=feet-4.85*r, shY=hy+1.30*r, bot=shY+2.40*r;
return { r:r, x:x, feet:feet, hy:hy, shY:shY, bot:bot, hr:r*1.04,
bx0:x-1.16*r, bx1:x+1.16*r, by0:shY-0.10*r, by1:bot,
lx0:x-0.66*r, lx1:x+0.80*r, ly0:bot, ly1:feet+r*0.10 };
}
/* カラスの当たりと大きさ。0.95だと夜空で小石と見分けが付かなかったので1.28にした(2026-09-13 実測) */
function cGeo(c){ return { x:MX(c.x), y:MY(c.y), r:L.hr*1.28 }; }
function segCircle(x0,y0,x1,y1,cx,cy,r){
var dx=x1-x0, dy=y1-y0, fx=x0-cx, fy=y0-cy;
var a=dx*dx+dy*dy, b=2*(fx*dx+fy*dy), c=fx*fx+fy*fy-r*r;
if(a<1e-9) return c<=0?0:-1;
var d=b*b-4*a*c; if(d<0) return -1;
d=Math.sqrt(d);
var t1=(-b-d)/(2*a), t2=(-b+d)/(2*a);
if(t1>=0&&t1<=1) return t1;
if(t1<0&&t2>=0) return 0;
return -1;
}
function segRect(x0,y0,x1,y1,ax,ay,bx,by){
var dx=x1-x0, dy=y1-y0, t0=0, t1=1;
var p=[-dx,dx,-dy,dy], q=[x0-ax,bx-x0,y0-ay,by-y0];
for(var i=0;i<4;i++){
if(p[i]===0){ if(q[i]<0) return -1; }
else{ var t=q[i]/p[i];
if(p[i]<0){ if(t>t1) return -1; if(t>t0) t0=t; }
else{ if(t<t0) return -1; if(t<t1) t1=t; } }
}
return t0;
}
/* ★この関数がゲームの芯★
倒せるのは「頭の丸へ、上から、急な角度で入った石」だけ。
それ以外(体・足・頭の横・寝た角度)は全部ノックバックで終わる。
ただし**大岩だけは例外**で、どこに当たっても倒せる(K1指示)。
カラスは空の敵なので、どこに当たっても落ちる(地上と別の仕事にして2人の分担を作る)。 */
var TOP_STEEP=0.60, TOP_BAND=0.25;
function hitTest(s){
if(s.cool>0) return;
var x0=s.px, y0=s.py, b=s.b, x1=b.position.x, y1=b.position.y;
var vx=b.velocity.x*60, vy=b.velocity.y*60;
var bt=2, bz=null, bk='', bc=null, i;
for(i=0;i<world.cs.length;i++){
var c=world.cs[i]; if(c.dead) continue;
var G2=cGeo(c);
var tcc=segCircle(x0,y0,x1,y1,G2.x,G2.y,G2.r+s.r);
if(tcc>=0&&tcc<bt){ bt=tcc; bc=c; bz=null; bk='crow'; }
}
for(i=0;i<world.zs.length;i++){
var z=world.zs[i]; if(z.dead) continue;
var G=zGeo(z);
var tc=segCircle(x0,y0,x1,y1,G.x,G.hy,G.hr+s.r);
if(tc>=0 && tc<bt){
var ey=y0+(y1-y0)*tc;
var top=(vy>0 && ey<=G.hy-G.hr*TOP_BAND && vy>=Math.abs(vx)*TOP_STEEP);
bt=tc; bz=z; bc=null; bk=(s.rock||top)?'kill':'knock';
}
var tb=segRect(x0,y0,x1,y1,G.bx0-s.r,G.by0-s.r,G.bx1+s.r,G.by1+s.r);
if(tb>=0 && tb<bt){ bt=tb; bz=z; bc=null; bk=s.rock?'kill':'knock'; }
var tl=segRect(x0,y0,x1,y1,G.lx0-s.r,G.ly0-s.r,G.lx1+s.r,G.ly1+s.r);
if(tl>=0 && tl<bt){ bt=tl; bz=z; bc=null; bk=s.rock?'kill':'knock'; }
}
if(!bz&&!bc) return;
s.cool=s.rock?0.05:0.10; LASTB=s.b.zgB||0;
var kind=(bk==='crow')?'crow':(bk==='kill'?(s.rock?'rock':'kill'):'knock');
claim(kind,bc?bc.id:bz.id,bc?cGeo(bc):zGeo(bz));
if(s.rock){ Mt.Body.setVelocity(b,{ x:b.velocity.x*0.86, y:b.velocity.y*0.86 }); }
else if(bk==='kill'||bk==='crow'){
Mt.Body.setVelocity(b,{ x:b.velocity.x*0.35, y:Math.abs(b.velocity.y)*0.30 });
}else{
Mt.Body.setVelocity(b,{ x:-Math.abs(b.velocity.x)*0.30, y:b.velocity.y*0.25-0.5 });
}
}
/* 当たったと**自分の端末が決めた**結果を、その場で見せつつサーバーへ渡す。
K1「自分では明らかに当たったのに何も起きない、は極力避ける」——だから先に見せる。
通信している時はサーバーが正なので、次に届いた中身で静かに揃う */
function claim(kind,tid,G){
LASTEV=(kind==='knock')?'knock':'kill';
if(NET.on){ netSend({y:'hit',k:kind,i:tid}); fxHit(kind,G); }
else world.hit(meId,kind,tid);
}
function fxHit(kind,G){
if(kind==='knock'){ dust(G.x-L.hr*0.4,(G.shY+G.bot)/2,5); seKnock(); }
else if(kind==='crow'){ burst(G.x,G.y,12,'#3a3f46'); burst(G.x,G.y,6,'#8f98a2'); seCrow(); }
else{ burst(G.x,G.hy,16,'#9fbf83'); burst(G.x,G.hy,8,'#d8cf9e'); seKill(); }
}
/* ===== 粒と浮き文字 ===== */
var P=[], FL=[];
function burst(x,y,n,col){
for(var i=0;i<n;i++) P.push({ x:x, y:y, vx:(Math.random()*2-1)*200, vy:-Math.random()*250-40,
t:0, life:0.75+Math.random()*0.35, col:col, r:1+Math.random()*2.3 });
}
function dust(x,y,n){
for(var i=0;i<n;i++) P.push({ x:x, y:y, vx:(Math.random()*2-1)*70, vy:-Math.random()*70,
t:0, life:0.35+Math.random()*0.25, col:'rgba(190,180,150,0.7)', r:1+Math.random()*1.6 });
}
function floatUp(x,y,s,col){ FL.push({ x:x, y:y, s:s, t:0, col:col||'#ffce6a' }); }
/* ===== 遊びの流れ ===== */
var scr='title';
var throws=0, overT=0, pop=0, result=null;
var FREEZE=false, RENDER=true, LASTEV='', LASTB=0;
var joinMsg=0, netMsg='';
function myP(){ return world.find(meId); }
function myX(){ var p=myP(); return p?world.xOf(p.id):WC.SLOT_X[0]; }
function handX(){ return MX(myX()+WC.HAND_DX); }
function handY(){ return MY(WC.HAND_Y); }
function startGame(){
scr='play'; throws=0; overT=0; pop=0; result=null;
P.length=0; FL.length=0; S.length=0; build();
world=new ZWORLD.World({sim:'full'});
meId='solo';
world.addPlayer(meId,SAVE.av,SAVE.eq);
world.ev.length=0;
rockArm=false; arm=62; armT=0; cool=0; shake=0;
seStart();
netStart();
}
function toTitle(){
netStop();
scr='title'; result=null;
world=new ZWORLD.World({sim:'full'});
}
function endGame(){
if(scr!=='play') return;
var p=myP();
var pay=p?world.payout(p):{base:0,mult:world.mult(),total:0};
result={ base:pay.base, mult:pay.mult, total:pay.total, kills:world.kills, t:world.t };
SAVE.scrap+=pay.total;
if(world.kills>SAVE.best) SAVE.best=world.kills;
putSave();
scr='result'; overT=0; seLose();
netStop();
}
/* ===== 通信 =====
「はじめる」で**即**遊び始め、裏で相手を探す。繋がったらその場で世界を預ける。
繋がらなければそのままひとりで遊ぶ(遊びは絶対に止めない) */
function netSend(o){ if(NET.ws&&NET.ws.readyState===1){ try{ NET.ws.send(JSON.stringify(o)); }catch(e){} } }
function netStart(){
NET.mode='finding'; NET.tries=0; netMsg='';
if(location.protocol==='file:'){ NET.mode='off'; return; }
fetch('/api/join',{method:'POST'}).then(function(r){ return r.json(); }).then(function(j){
if(!j||j.busy||!j.room){ NET.mode='off'; if(j&&j.busy){ netMsg='ただいま混み合っています'; joinMsg=2.5; } return; }
NET.room=j.room; netOpen();
}).catch(function(){ NET.mode='off'; });
}
function netOpen(){
if(scr!=='play') return;
var pr=(location.protocol==='https:')?'wss:':'ws:';
var ws;
try{ ws=new WebSocket(pr+'//'+location.host+'/ws?r='+NET.room+'&id='+encodeURIComponent(devId())); }
catch(e){ NET.mode='off'; return; }
NET.ws=ws;
ws.onopen=function(){ NET.tries=0; sendLook();
if(NET.ping) clearInterval(NET.ping);
NET.ping=setInterval(function(){ netSend({y:'ping',t:Date.now()}); },8000); };
ws.onmessage=function(ev){
var d; try{ d=JSON.parse(ev.data); }catch(e){ return; }
if(d.y==='full'){ NET.on=false; NET.mode='finding'; setTimeout(netStart,700); return; }
if(d.y==='w'){
meId=d.me; NET.on=true; NET.mode='online';
world.sim='motion';
applySnap(d.snap);