public/index.html

ゲーム画面全体。HTML/CSS・入力・投石物理・当たり判定・通信・描画・音・検品口

1094行 · 51,323 bytes · SHA-256 ee47bb0b20b31375359bd2e16c1c7c97b9672245ee86cfa9310d26e218ccebd8

分割 1/6 分割 2/6 分割 3/6 分割 4/6 分割 5/6 分割 6/6
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="robots" content="noindex,nofollow,noarchive">
<title>石とゾンビ</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;
  -webkit-touch-callout:none;-webkit-user-select:none;user-select:none}
html,body{height:100%}
/* 遊ぶ画面はスクロールしない(五月雨開発の決まり) */
body{background:#05070b;color:#f2ead6;overflow:hidden;overscroll-behavior:none;
  font-family:"Hiragino Kaku Gothic ProN","Yu Gothic",sans-serif}
#app{height:100dvh;display:flex;flex-direction:column;
  padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom)}
#stage{flex:1 1 auto;min-height:0;position:relative;overflow:hidden;touch-action:none}
#cv{display:block;width:100%;height:100%}
/* 着せ替えとお店だけDOM。ここは縦に流してよい(遊ぶ画面ではないので) */
.sheet{position:absolute;inset:0;z-index:20;background:#0d131a;display:none;flex-direction:column}
.sheet.on{display:flex}
.sheet .top{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:10px 12px;
  background:#16202b;border-bottom:2px solid #2b3b4a}
.sheet .top b{font-size:15px}
.sheet .top .sc{margin-left:auto;font-size:12px;color:#ffce6a;font-weight:bold}
.sheet .top .x{font-size:12px;font-weight:bold;color:#f2ead6;background:#22303d;
  border:1.5px solid #3a4d5e;border-radius:9px;padding:7px 13px}
.sheet .tabs{flex:0 0 auto;display:flex;gap:6px;padding:8px 12px 0}
.sheet .tabs button{flex:1;font-size:12px;font-weight:bold;color:#b9c6d2;background:#16202b;
  border:1.5px solid #2b3b4a;border-radius:9px 9px 0 0;padding:8px 0}
.sheet .tabs button.on{color:#0d131a;background:#ffce6a;border-color:#ffce6a}
.sheet .body{flex:1 1 auto;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:10px 12px 24px}
.row{display:flex;align-items:center;gap:10px;background:#16202b;border:1.5px solid #263442;
  border-radius:11px;padding:8px 10px;margin-bottom:7px}
.row canvas{flex:0 0 auto;background:#0a1016;border-radius:8px}
.row .nm{font-size:13px;font-weight:bold}
.row .pr{font-size:11px;color:#9fb0c0;margin-top:2px}
.row .sp{flex:1 1 auto}
.row button{flex:0 0 auto;font-size:12px;font-weight:bold;border-radius:9px;
  padding:9px 12px 17px;border:1.5px solid #3a4d5e;color:#f2ead6;background:#22303d;min-width:76px}
.row button.buy{background:#ffce6a;border-color:#ffce6a;color:#0d131a}
.row button.on{background:#3f7d52;border-color:#3f7d52;color:#fff}
.row button.ng{background:#1b232c;border-color:#2b3b4a;color:#6d7d8c;position:relative}
.row button.ng i{position:absolute;left:0;right:0;bottom:3px;color:#c8604f;font-style:normal;
  font-size:9px;font-weight:bold;text-align:center}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}
.grid button{background:#16202b;border:2px solid #263442;border-radius:10px;padding:7px 0;
  color:#f2ead6;font-size:11px}
.grid button.on{border-color:#ffce6a}
.lab{font-size:11px;color:#9fb0c0;font-weight:bold;margin:12px 2px 6px}
</style>
</head>
<body>
<div id="app"><div id="stage">
  <canvas id="cv"></canvas>
  <div class="sheet" id="shAv">
    <div class="top"><b>アバター変更</b><span class="sc" id="avSc"></span><button class="x" id="avX">とじる</button></div>
    <div class="body" id="avBody"></div>
  </div>
  <div class="sheet" id="shShop">
    <div class="top"><b>ショップ</b><span class="sc" id="spSc"></span><button class="x" id="spX">とじる</button></div>
    <div class="tabs" id="spTabs"></div>
    <div class="body" id="spBody"></div>
  </div>
</div></div>
<script>window.HZG_BUILD='';</script>
<script src="lib/matter.min.js"></script>
<script src="lib/shurechara.js?v=20260913b"></script>
<script src="lib/world.js?v=20260913b"></script>
<script src="lib/wardrobe.js?v=20260913b"></script>
<script>
'use strict';
/* ===== 石とゾンビ =====
   正本: docs/K1仕様書_20260913.md(芯) と docs/K1仕様書_磨き込みとマルチ_20260913.md(磨き込みとマルチ)

   芯(立ち上げの日から1文字も変えていない): **上から落とす軌道を作れるか**の遊び。
   横から当てても押し戻すだけ。頭の上から急な角度で落ちてきた石だけが倒せる。

   絵は1枚も使わない。人は共有財産のシュールキャラ(lib/shurechara.js)。
   世界の中身(ゾンビ・カラス・HP・スクラップ)は lib/world.js。
   **サーバーと端末が同じ1本を回す。**石だけはここ(投げた本人の端末)で飛ばす——
   通信を待たせないため(K1「投石は操作レスポンス最優先」)。 */
var VER='v20260913b';

/* ===== 音(合成。音のファイルは持たない) ===== */
var actx=null;
function ac(){
  if(!actx){ var C=window.AudioContext||window.webkitAudioContext; if(!C) return null; actx=new C(); }
  if(actx.state==='suspended'){ try{ actx.resume(); }catch(e){} }
  return actx;
}
function tone(f1,f2,vol,dur,type){
  var c=ac(); if(!c) return;
  var o=c.createOscillator(), g=c.createGain(); o.type=type||'sine';
  o.frequency.setValueAtTime(f1,c.currentTime);
  o.frequency.exponentialRampToValueAtTime(Math.max(24,f2),c.currentTime+dur);
  g.gain.setValueAtTime(vol,c.currentTime);
  g.gain.exponentialRampToValueAtTime(0.0001,c.currentTime+dur);
  o.connect(g); g.connect(c.destination); o.start(); o.stop(c.currentTime+dur+0.03);
}
function nz(vol,dur,type,f1,f2,q){
  var c=ac(); if(!c) return;
  var n=Math.max(1,Math.floor(c.sampleRate*dur)), b=c.createBuffer(1,n,c.sampleRate), d=b.getChannelData(0);
  for(var i=0;i<n;i++) d[i]=Math.random()*2-1;
  var s=c.createBufferSource(); s.buffer=b;
  var f=c.createBiquadFilter(); f.type=type||'bandpass';
  f.frequency.setValueAtTime(f1,c.currentTime);
  if(f2) f.frequency.exponentialRampToValueAtTime(Math.max(24,f2),c.currentTime+dur);
  f.Q.value=(q==null?1:q);
  var g=c.createGain(); g.gain.setValueAtTime(vol,c.currentTime);
  g.gain.exponentialRampToValueAtTime(0.0001,c.currentTime+dur);
  s.connect(f); f.connect(g); g.connect(c.destination); s.start();
}
function seThrow(){ nz(.055,.10,'highpass',1700,3200); }
function seKill(){ tone(190,62,.20,.15,'square'); nz(.16,.20,'bandpass',430,170,.8);
  setTimeout(function(){ nz(.11,.34,'lowpass',320,90,.6); },70); }
function seKnock(){ tone(124,84,.09,.09,'triangle'); nz(.06,.10,'bandpass',820,460,1.3); }
function seGround(v){ nz(Math.min(.09,.012+v*0.00010),.07,'bandpass',1500,700,1.4); }
function seLose(){ tone(240,46,.20,.95,'sawtooth'); setTimeout(function(){ nz(.12,.6,'lowpass',260,70,.6); },90); }
function seStart(){ tone(520,790,.10,.12,'triangle'); }
function seScrap(){ tone(880,1320,.045,.07,'triangle'); }
function seRock(){ tone(70,38,.26,.55,'sawtooth'); nz(.20,.45,'lowpass',260,70,.6); }
function seRockRdy(){ tone(420,720,.07,.10,'triangle'); setTimeout(function(){ tone(720,980,.06,.10,'triangle'); },90); }
function seCrow(){ tone(1250,760,.09,.13,'sawtooth'); setTimeout(function(){ tone(980,620,.07,.11,'sawtooth'); },110); }
function seGrab(){ tone(150,96,.12,.30,'sawtooth'); nz(.09,.30,'lowpass',500,180,.7); }
function seSwap(){ tone(560,820,.08,.09,'square'); }
function seJoin(){ tone(640,960,.10,.11,'triangle'); setTimeout(function(){ tone(960,1240,.09,.12,'triangle'); },110); }

/* ===== 端末に覚えておく物 ===== */
var SAVE={ scrap:0, av:{h:0,hc:0,sk:0,w:0}, own:{c:[0],ht:[0],st:[0]}, eq:{c:0,ht:0,st:0}, best:0 };
function loadSave(){
  try{
    var j=JSON.parse(localStorage.getItem('ishizombie:v1')||'null');
    if(j){ for(var k in SAVE) if(j[k]!=null) SAVE[k]=j[k]; }
  }catch(e){}
  if(!SAVE.own) SAVE.own={c:[0],ht:[0],st:[0]};
  if(!SAVE.own.c||!SAVE.own.c.length) SAVE.own.c=[0];
  if(!SAVE.own.ht||!SAVE.own.ht.length) SAVE.own.ht=[0];
  if(!SAVE.own.st||!SAVE.own.st.length) SAVE.own.st=[0];
}
function putSave(){ try{ localStorage.setItem('ishizombie:v1',JSON.stringify(SAVE)); }catch(e){} }
loadSave();
function devId(){
  var k='ishizombie:dev';
  try{ var v=localStorage.getItem(k); if(v) return v;
    v='d'+Date.now().toString(36)+Math.random().toString(36).slice(2,7);
    localStorage.setItem(k,v); return v; }catch(e){ return 'd0'; }
}

/* ===== ゾンビの死人の肌と汚れた服 =====
   共有財産のシュールキャラの色表へ**後ろから足すだけ**。
   lib/shurechara.js そのものは1文字も直していない(あれは共有財産なので) */
var ZSKIN=['#93a97b','#a6b78e','#82996f'];
var ZSK0=SHURE.SKIN_COL.length;
SHURE.SKIN_COL.push.apply(SHURE.SKIN_COL,ZSKIN);
SHURE.WEAR_COL.push.apply(SHURE.WEAR_COL,['#5a5348','#6b6256','#4a5250','#7a6f5c']);

/* ===== ショップの品(lib/wardrobe.js)。届いていなくても遊べるようにする ===== */
var WR=window.WARDROBE||null;
if(!WR||!WR.STONES||!WR.STONES.length){
  WR={ COSTUMES:[{id:'plain',nm:'そのまま',price:0,tone:'neutral',draw:function(){}}],
       HATS:[{id:'none',nm:'なし',price:0,draw:function(){}}],
       STONES:[{id:'plain',nm:'ふつうの石',price:0,draw:function(g,r){
         g.fillStyle='#8d8e86'; g.beginPath();
         for(var i=0;i<7;i++){ var a=i/7*Math.PI*2, rr=r*(0.82+((i*5)%3)*0.13);
           if(i===0) g.moveTo(Math.cos(a)*rr,Math.sin(a)*rr); else g.lineTo(Math.cos(a)*rr,Math.sin(a)*rr); }
         g.closePath(); g.fill();
         g.fillStyle='rgba(255,255,240,.30)'; g.beginPath(); g.arc(-r*0.28,-r*0.30,r*0.34,0,7); g.fill();
       }}] };
}
function wrGet(list,i){ return list[Math.max(0,Math.min(list.length-1,i|0))]; }

/* ===== 盤面の寸法 =====
   ★長さはメートル。画面の左端が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);
      sendLook();
      return;
    }
    if(d.y==='s') applySnap(d);
  };
  ws.onclose=function(e){
    NET.on=false;
    if(NET.ping){ clearInterval(NET.ping); NET.ping=0; }
    if(e&&e.code===4001) return;
    if(scr!=='play'){ NET.mode='off'; return; }
    NET.tries++;
    if(NET.tries<3) setTimeout(netOpen,700*NET.tries);
    else{ NET.mode='off'; world.sim='full'; netMsg='ひとりで続けます'; joinMsg=2.2; }
  };
}
function netStop(){
  NET.on=false; NET.mode='off';
  if(NET.ping){ clearInterval(NET.ping); NET.ping=0; }
  if(NET.ws){ try{ NET.ws.close(1000,'bye'); }catch(e){} NET.ws=null; }
}
function applySnap(s){
  var before=world.ps.length;
  var ev=world.apply(s,meId);
  for(var i=0;i<ev.length;i++) onEvent(ev[i]);
  if(world.ps.length>before && before>0){ seJoin(); netMsg='相方が来た'; joinMsg=2.2; }
  else if(world.ps.length<before){ netMsg='相方が抜けた'; joinMsg=2.2; }
  var p=myP();
  if(world.over || !p || !p.alive) endGame();
}
function onEvent(e){
  var z,G;
  if(e.e==='zkill'){
    z=world.zById(e.id); G=z?zGeo(z):{x:MX(e.x),hy:MY(1.42),shY:MY(1.2),bot:L.floorY};
    if(!NET.on){ burst(G.x,G.hy,16,'#9fbf83'); burst(G.x,G.hy,8,'#d8cf9e'); seKill(); }
    if(e.rock) seRock();
    floatUp(G.x,G.hy-14,'+'+e.n); pop=1; seScrap();
  }else if(e.e==='ckill'){
    if(!NET.on){ burst(MX(e.x),MY(e.y),12,'#3a3f46'); seCrow(); }
    floatUp(MX(e.x),MY(e.y)-12,'+'+e.n,'#9fd8ff'); pop=1; seScrap();
  }else if(e.e==='zknock'){
    if(!NET.on){ z=world.zById(e.id); if(z){ G=zGeo(z); dust(G.x-L.hr*0.4,(G.shY+G.bot)/2,5); } seKnock(); }
  }else if(e.e==='grab'){ seGrab(); if(e.p===meId) shake=1; }
  else if(e.e==='peck'){ if(e.p===meId) shake=0.7; seCrow(); }
  else if(e.e==='free'){ if(e.p===meId) floatUp(MX(myX()),MY(2.1),'はなれた','#9fd8ff'); }
  else if(e.e==='swap'){ seSwap(); }
  else if(e.e==='rock'){ if(e.p!==meId) seRock(); }
  else if(e.e==='down'){ if(e.p===meId) endGame(); }
  else if(e.e==='over'){ endGame(); }
  else if(e.e==='join'){ if(e.p!==meId&&NET.on){ seJoin(); netMsg='相方が来た'; joinMsg=2.2; } }
  else if(e.e==='left'){ if(e.p!==meId&&NET.on){ netMsg='相方が抜けた'; joinMsg=2.2; } }
}

/* ===== 指: 引いて離す(フリースローと同じ手触り。数字も同じ) ===== */
var V0=4.6, VR=4.6, EXP=1.15;
var aiming=false, sx=0, sy=0, ax=0, ay=0, pull=0, ang=52, arm=62, armT=0, cool=0;
var rockArm=false, shake=0;
function speedMS(){ return V0+VR*Math.pow(pull/L.maxPull,EXP); }
function aimFrom(px,py){
  var dx=sx-px, dy=sy-py;
  pull=Math.min(L.maxPull,Math.hypot(dx,dy));
  var a=Math.atan2(-dy,dx)*180/Math.PI;
  if(dx<=0 && Math.abs(a)>90) a=(a>0?180-a:-180-a);
  ang=Math.max(14,Math.min(84,a));
  ax=px; ay=py;
}
function rockLeft(){ var p=myP(); return p?Math.max(0,p.bT-world.t):WC.BOULDER_CD; }
function shoot(){
  var rock=rockArm&&rockLeft()<=0;
  if(rock){ if(NET.on) netSend({y:'rock'}); else world.useRock(meId); rockArm=false; seRock(); }
  else if(rockArm){ rockArm=false; }
  var rr=L.stoneR*(rock?3:1);
  var v=speedMS()*L.ppm*(rock?0.82:1), r=ang*Math.PI/180;
  var b=Mt.Bodies.circle(handX(),handY(),rr,Object.assign({label:'stone'},STONE));
  b.zgRock=rock?1:0;
  Mt.Body.setVelocity(b,{ x:Math.cos(r)*v/60, y:-Math.sin(r)*v/60 });
  Mt.Body.setAngularVelocity(b,rock?-0.07:-0.16);
  Mt.Composite.add(eng.world,b);
  S.push({ b:b, t:0, rest:0, cool:0, rock:rock, r:rr, px:b.position.x, py:b.position.y });
  throws++; armT=1; cool=rock?0.35:0.14; pull=0; seThrow();
}
function dropStone(s){
  try{ Mt.Composite.remove(eng.world,s.b); }catch(e){}
  var i=S.indexOf(s); if(i>=0) S.splice(i,1);
}
function canThrow(){
  var p=myP();
  return scr==='play' && cool<=0 && S.length<3 && !!p && p.alive;
}

/* ===== 押せる所(canvasの上のボタン) ===== */
var BTN=[];
function btn(id,x,y,w,h){ BTN.push({id:id,x:x,y:y,w:w,h:h}); }
function btnAt(px,py){
  for(var i=BTN.length-1;i>=0;i--){ var b=BTN[i];
    if(px>=b.x&&px<=b.x+b.w&&py>=b.y&&py<=b.y+b.h) return b.id; }
  return null;
}
function down(p){
  ac();
  var hit=btnAt(p.x,p.y);
  if(scr==='title'){
    if(hit==='start') startGame();
    else if(hit==='shop') openShop();
    else if(hit==='avatar') openAvatar();
    return;
  }
  if(scr==='result'){
    if(overT>0.5){ if(hit==='again') startGame(); else if(hit==='top') toTitle(); }
    return;
  }
  if(hit==='rock'){ if(rockLeft()<=0){ rockArm=!rockArm; seSwap(); } return; }
  if(hit==='swap'){ if(world.canSwap()){ if(NET.on) netSend({y:'swap'}); else world.swap(); } return; }
  if(!canThrow()) return;
  aiming=true; sx=p.x; sy=p.y; ax=p.x; ay=p.y; pull=0;
}
function move(p){ if(aiming) aimFrom(p.x,p.y); }
function up(){
  if(!aiming) return;
  aiming=false;
  if(pull<18){ pull=0; return; }
  if(canThrow()) shoot(); else pull=0;
}
(function(){
  var id=null;
  function pt(e){ var r=stage.getBoundingClientRect(); return { x:e.clientX-r.left, y:e.clientY-r.top }; }
  stage.addEventListener('pointerdown',function(e){
    if(id!==null) return; id=e.pointerId;
    try{ stage.setPointerCapture(id); }catch(err){}
    down(pt(e));
  });
  stage.addEventListener('pointermove',function(e){ if(e.pointerId===id) move(pt(e)); });
  function end(e){ if(e.pointerId!==id) return; up(); id=null; }
  stage.addEventListener('pointerup',end);
  stage.addEventListener('pointercancel',end);
})();

/* ===== 1コマ ===== */
function physics(dt){
  acc+=dt*1000; var n=0;
  while(acc>=STEP && n<4){
    for(var i=0;i<S.length;i++){ S[i].px=S[i].b.position.x; S[i].py=S[i].b.position.y; }
    Mt.Engine.update(eng,STEP);
    for(var j=S.length-1;j>=0;j--) hitTest(S[j]);
    acc-=STEP; n++;
  }
  if(acc>STEP*4) acc=0;
  for(var k=S.length-1;k>=0;k--){
    var s=S[k]; s.t+=dt; if(s.cool>0) s.cool-=dt;
    var p=s.b.position, v=s.b.velocity, sp=Math.hypot(v.x,v.y)*60;
    if(p.x<-40||p.x>W+80||p.y>H+90||s.t>7){ dropStone(s); continue; }
    if(sp<44 && p.y+s.r>L.floorY-3){ s.rest+=dt; if(s.rest>1.0) dropStone(s); }
    else s.rest=0;
  }
}
function stepP(dt){
  var i,q;
  for(i=P.length-1;i>=0;i--){
    q=P[i]; q.t+=dt;
    if(q.t>q.life){ P.splice(i,1); continue; }
    q.vy+=900*dt; q.x+=q.vx*dt; q.y+=q.vy*dt;
    if(q.y>L.floorY){ q.y=L.floorY; q.vy*=-0.3; q.vx*=0.6; }
  }
  for(i=FL.length-1;i>=0;i--){ FL[i].t+=dt; FL[i].y-=26*dt; if(FL[i].t>1.0) FL.splice(i,1); }
}
function step(dt){
  if(cool>0) cool-=dt;
  if(shake>0) shake-=dt*2.4;
  if(joinMsg>0) joinMsg-=dt;
  physics(dt);
  if(scr==='play'&&!FREEZE){
    var wasR=rockLeft();
    world.step(dt);
    if(!NET.on){ var ev=world.ev.slice(); world.ev.length=0; for(var i=0;i<ev.length;i++) onEvent(ev[i]); }
    else world.ev.length=0;
    if(wasR>0 && rockLeft()<=0) seRockRdy();
  }else if(scr==='result'){ overT+=dt; world.step(dt); world.ev.length=0; }
  stepP(dt);
  if(pop>0) pop-=dt*2.2;
  var want=(aiming?ang:62);
  if(armT>0){ armT-=dt*3.2; want=8; }
  arm+=(want-arm)*Math.min(1,dt*(armT>0?26:9));
}

/* ===== 絵(全部コードで描く) ===== */
function drawBG(){
  var sky=g2.createLinearGradient(0,0,0,L.floorY);
  sky.addColorStop(0,'#080c14'); sky.addColorStop(0.62,'#141d29'); sky.addColorStop(1,'#232b31');
  g2.fillStyle=sky; g2.fillRect(-20,-20,W+40,L.floorY+20);
  var mx=W*0.80, my=L.floorY*0.20, mr=Math.max(12,L.ppm*0.42);
  var gl=g2.createRadialGradient(mx,my,mr*0.5,mx,my,mr*4.6);
  gl.addColorStop(0,'rgba(226,226,200,.22)'); gl.addColorStop(1,'rgba(226,226,200,0)');
  g2.fillStyle=gl; g2.beginPath(); g2.arc(mx,my,mr*4.6,0,7); g2.fill();
  g2.fillStyle='#e8e4cc'; g2.beginPath(); g2.arc(mx,my,mr,0,7); g2.fill();
  g2.fillStyle='#141d29'; g2.beginPath(); g2.arc(mx+mr*0.42,my-mr*0.30,mr*0.88,0,7); g2.fill();
  var i;
  for(i=0;i<stars.length;i++){ var s=stars[i];
    g2.fillStyle='rgba(240,240,215,'+s.a+')'; g2.beginPath(); g2.arc(s.x,s.y,s.r,0,7); g2.fill(); }
  for(i=0;i<cloud.length;i++){ var cl=cloud[i];
    g2.fillStyle='rgba(190,200,215,'+cl.a+')';
    for(var ci=0;ci<3;ci++){ g2.beginPath();
      g2.ellipse(cl.x+(ci-1)*cl.w*0.26, cl.y+(ci===1?-cl.h*0.30:0),
        cl.w*(ci===1?0.30:0.24), cl.h*(ci===1?0.62:0.44),0,0,7); g2.fill(); } }
  for(var j=0;j<bldg.length;j++){ var b=bldg[j];
    g2.fillStyle='#0e1419'; g2.fillRect(b.x,L.floorY-b.h,b.w,b.h);
    g2.fillStyle='rgba(210,170,90,.18)';
    for(var k=0;k<3;k++) for(var m=0;m<2;m++){ if((j+k+m)%3) continue;
      g2.fillRect(b.x+6+m*(b.w*0.45),L.floorY-b.h+7+k*(b.h*0.28),b.w*0.20,b.h*0.13); } }
  g2.fillStyle='#181a14'; g2.fillRect(-20,L.floorY,W+40,H-L.floorY+20);
  g2.strokeStyle='rgba(220,210,170,.38)'; g2.lineWidth=2;
  g2.beginPath(); g2.moveTo(0,L.floorY); g2.lineTo(W,L.floorY); g2.stroke();
  g2.fillStyle='rgba(200,190,150,.10)';
  for(var n=0;n<26;n++){ var gx=(n*137)%W, gy=L.floorY+6+((n*61)%Math.max(8,(H-L.floorY)-10));
    g2.fillRect(gx,gy,3+((n*7)%9),1.5); }
}
function drawStoneAt(x,y,rot,r,rock){
  g2.save(); g2.translate(x,y); g2.rotate(rot);
  if(rock){
    g2.fillStyle='#6f6a60';
    g2.beginPath();
    for(var i=0;i<9;i++){ var a=i/9*Math.PI*2, rr=r*(0.84+((i*7)%4)*0.09);
      if(i===0) g2.moveTo(Math.cos(a)*rr,Math.sin(a)*rr); else g2.lineTo(Math.cos(a)*rr,Math.sin(a)*rr); }
    g2.closePath(); g2.fill();
    g2.fillStyle='rgba(255,250,230,.22)'; g2.beginPath(); g2.arc(-r*0.30,-r*0.32,r*0.36,0,7); g2.fill();
    g2.strokeStyle='rgba(20,16,10,.5)'; g2.lineWidth=Math.max(1,r*0.08);
    g2.beginPath(); g2.moveTo(-r*0.5,r*0.1); g2.lineTo(r*0.3,-r*0.2); g2.stroke();
  }else{
    wrGet(WR.STONES,SAVE.eq.st).draw(g2,r,rot);
  }
  g2.restore();
}
function drawPerson(p,isMe){
  var r=L.hr, feet=L.floorY, x=MX(world.xOf(p.id));
  var hy=feet-4.85*r, shY=hy+1.30*r, bot=shY+2.40*r;
  var dead=!p.alive, av=SHURE.fix(p.av||{});
  g2.save();
  if(dead) g2.globalAlpha=0.32;
  g2.strokeStyle='#33323a'; g2.lineWidth=r*0.28; g2.lineCap='round';
  g2.beginPath(); g2.moveTo(x,bot-r*0.2); g2.lineTo(x-r*0.34,feet+r*1.12); g2.stroke();
  g2.beginPath(); g2.moveTo(x,bot-r*0.2); g2.lineTo(x+r*0.42,feet+r*1.12); g2.stroke();
  SHURE.draw(g2,x,hy,r,1,av,{},bot);
  var lk=p.look||{c:0,ht:0,st:0};
  var cst=wrGet(WR.COSTUMES,lk.c); if(cst&&cst.draw) cst.draw(g2,x,shY,bot,r,SHURE.WEAR_COL[av.w],1);
  var hat=wrGet(WR.HATS,lk.ht); if(hat&&hat.draw) hat.draw(g2,x,hy,r,1,SHURE.HAIR_COL[av.hc]);
  var skin=SHURE.SKIN_COL[av.sk], ex, ey;
  if(isMe&&!dead){
    var a=arm*Math.PI/180;
    ex=x+Math.cos(-a)*2.2*r; ey=shY+Math.sin(-a)*2.2*r;
    if(canThrow()||aiming){ ex=handX(); ey=handY()+r*0.10; }
  }else{ ex=x+1.5*r; ey=shY+1.5*r; }
  g2.strokeStyle=skin; g2.lineWidth=r*0.28; g2.lineCap='round';
  g2.beginPath(); g2.moveTo(x,shY+r*0.12); g2.lineTo(ex,ey); g2.stroke();
  g2.fillStyle=skin; g2.beginPath(); g2.arc(ex,ey,r*0.20,0,7); g2.fill();
  if(isMe&&canThrow()) drawStoneAt(handX(),handY(),0,L.stoneR*(rockArm&&rockLeft()<=0?3:1),rockArm&&rockLeft()<=0);
  g2.restore();
  if(!dead){
    var bw=r*2.6, bx=x-bw/2, by=hy-r*2.0;
    g2.fillStyle='rgba(10,14,18,.75)'; g2.fillRect(bx-1,by-1,bw+2,6);
    var f=Math.max(0,Math.min(1,p.hp/WC.MAXHP));
    g2.fillStyle=(f>0.5?'#6fc07a':(f>0.22?'#e0b64a':'#d2604c'));
    g2.fillRect(bx,by,bw*f,4);
    if(isMe){ g2.strokeStyle='rgba(255,206,106,.9)'; g2.lineWidth=1; g2.strokeRect(bx-1.5,by-1.5,bw+3,7); }
  }
}
function drawZombie(z){
  var G=zGeo(z), r=G.r, x=G.x;
  g2.save();
  g2.translate(x,G.feet);
  if(z.dead){ g2.rotate(-Math.min(1.48,z.dt*4.2)); g2.globalAlpha=Math.max(0,Math.min(1,(1.5-z.dt)/0.45)); }
  else g2.rotate(z.tilt+Math.sin(z.ph*2.0)*0.035);
  g2.translate(-x,-G.feet);
  var av=SHURE.fix({h:z.look.h,hc:z.look.hc,sk:ZSK0+(z.look.sk%3),w:z.look.w});
  var skin=SHURE.SKIN_COL[av.sk];
  var sw=Math.sin(z.ph*3.0);
  g2.strokeStyle='#2f2f28'; g2.lineWidth=r*0.28; g2.lineCap='round';
  g2.beginPath(); g2.moveTo(x,G.bot-r*0.2); g2.lineTo(x-r*(0.26+0.42*Math.max(0,sw)),G.feet+r*1.10); g2.stroke();
  g2.beginPath(); g2.moveTo(x,G.bot-r*0.2); g2.lineTo(x+r*0.80,G.feet+r*1.02); g2.stroke();
  SHURE.draw(g2,x,G.hy,r,-1,av,{},G.bot);
  g2.strokeStyle=skin; g2.lineWidth=r*0.25; g2.lineCap='round';
  for(var k=0;k<2;k++){
    var dy=r*(0.06+k*0.26), hxx=x-r*(2.00+0.16*Math.sin(z.ph*2.4+k*1.7));
    g2.beginPath(); g2.moveTo(x-r*0.62,G.shY+dy); g2.lineTo(hxx,G.shY+dy+r*0.14); g2.stroke();
    g2.fillStyle=skin; g2.beginPath(); g2.arc(hxx,G.shY+dy+r*0.14,r*0.18,0,7); g2.fill();
  }
  if(!z.dead){
    g2.lineCap='butt';
    g2.strokeStyle='rgba(20,16,10,.75)'; g2.lineWidth=Math.max(3,r*0.30);
    g2.beginPath(); g2.arc(x,G.hy,r*1.16,Math.PI*1.24,Math.PI*1.76); g2.stroke();
    g2.strokeStyle=z.gr?'rgba(255,120,90,.95)':'rgba(255,214,96,.92)'; g2.lineWidth=Math.max(1.6,r*0.16);
    g2.beginPath(); g2.arc(x,G.hy,r*1.16,Math.PI*1.24,Math.PI*1.76); g2.stroke();
    g2.lineCap='round';
  }
  g2.restore();
}
function drawCrow(c){
  var G=cGeo(c), r=G.r;
  g2.save(); g2.translate(G.x,G.y);
  if(c.dead){ g2.rotate(c.dt*7); g2.globalAlpha=Math.max(0,(1.4-c.dt)/0.5); }
  var flap=Math.sin(c.ph)*0.9;
  /* 夜空に沈まないよう、体の後ろに薄い縁を敷く */
  g2.strokeStyle='rgba(150,170,190,.34)'; g2.lineWidth=2.6;
  g2.beginPath(); g2.ellipse(0,0,r*0.92,r*0.60,0,0,7); g2.stroke();
  g2.fillStyle='#2c313a';
  g2.beginPath(); g2.ellipse(0,0,r*0.85,r*0.52,0,0,7); g2.fill();
  g2.beginPath(); g2.arc(-r*0.78,-r*0.18,r*0.40,0,7); g2.fill();
  g2.fillStyle='#c8a23a';
  g2.beginPath(); g2.moveTo(-r*1.12,-r*0.16); g2.lineTo(-r*1.62,-r*0.02); g2.lineTo(-r*1.10,r*0.12);
  g2.closePath(); g2.fill();
  g2.fillStyle='#3a414b';
  g2.beginPath(); g2.moveTo(-r*0.1,-r*0.2); g2.lineTo(r*0.5,-r*0.2-flap*r*1.05); g2.lineTo(r*0.95,r*0.1);
  g2.closePath(); g2.fill();
  g2.beginPath(); g2.moveTo(-r*0.1,-r*0.2); g2.lineTo(r*0.3,-r*0.2+flap*r*0.95); g2.lineTo(r*0.75,r*0.16);
  g2.closePath(); g2.fill();
  if(!c.dead){ g2.fillStyle='#ffd24a'; g2.beginPath(); g2.arc(-r*0.86,-r*0.24,r*0.10,0,7); g2.fill(); }
  g2.restore();
}
function guide(){
  if(!aiming||pull<18) return;
  var rock=rockArm&&rockLeft()<=0;
  var v=speedMS()*L.ppm*(rock?0.82:1), r=ang*Math.PI/180;
  var vx=Math.cos(r)*v, vy=-Math.sin(r)*v, x=handX(), y=handY(), h=1/120;
  g2.fillStyle=rock?'rgba(255,190,120,.72)':'rgba(255,238,205,.60)';
  for(var i=0;i<72;i++){
    vy+=9.8*L.ppm*h; x+=vx*h; y+=vy*h;
    if(i%7===6){ g2.beginPath(); g2.arc(x,y,rock?2.8:2.0,0,7); g2.fill(); }
  }
  g2.strokeStyle='rgba(255,238,205,.28)'; g2.lineWidth=2; g2.setLineDash([4,4]);
  g2.beginPath(); g2.moveTo(handX(),handY()); g2.lineTo(handX()-(sx-ax),handY()-(sy-ay)); g2.stroke();
  g2.setLineDash([]);
  var pf=pull/L.maxPull;
  g2.fillStyle='rgba(255,244,220,.16)'; g2.fillRect(10,L.floorY+8,W-20,4);
  g2.fillStyle=rock?'#ffb05a':'#ff8a3a'; g2.fillRect(10,L.floorY+8,(W-20)*pf,4);
}
function roundRect(x,y,w,h,r){
  g2.beginPath();
  g2.moveTo(x+r,y); g2.lineTo(x+w-r,y); g2.quadraticCurveTo(x+w,y,x+w,y+r);
  g2.lineTo(x+w,y+h-r); g2.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
  g2.lineTo(x+r,y+h); g2.quadraticCurveTo(x,y+h,x,y+h-r);
  g2.lineTo(x,y+r); g2.quadraticCurveTo(x,y,x+r,y); g2.closePath();
}
/* 押せない時も**大きさは変えない**。グレーアウトして赤字を重ねる(UI憲法) */
function uiBtn(id,x,y,w,h,label,sub,state){
  btn(id,x,y,w,h);
  g2.save();
  roundRect(x,y,w,h,9);
  g2.fillStyle=(state==='off')?'rgba(24,30,38,.92)':(state==='hot'?'rgba(255,206,106,.96)':'rgba(34,48,61,.94)');
  g2.fill();
  g2.lineWidth=1.6;
  g2.strokeStyle=(state==='off')?'rgba(60,74,88,.9)':(state==='hot'?'rgba(255,206,106,1)':'rgba(92,120,144,.95)');
  g2.stroke();
  g2.textAlign='center'; g2.textBaseline='middle';
  g2.font='bold 13px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle=(state==='off')?'#6d7d8c':(state==='hot'?'#0d131a':'#f2ead6');
  g2.fillText(label,x+w/2,y+h/2-(sub?7:0));
  if(sub){
    g2.font='bold 10px "Hiragino Kaku Gothic ProN",sans-serif';
    g2.fillStyle=(state==='off')?'#c8604f':(state==='hot'?'#0d131a':'rgba(242,234,214,.72)');
    g2.fillText(sub,x+w/2,y+h/2+9);
  }
  g2.restore();
  g2.textAlign='left'; g2.textBaseline='alphabetic';
}
function drawHUD(){
  var p=myP();
  g2.textAlign='left'; g2.textBaseline='alphabetic';
  var s=1+Math.max(0,pop)*0.22;
  g2.save(); g2.translate(12,28); g2.scale(s,s);
  g2.font='bold 16px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='#f4ecd6'; g2.fillText('倒した '+world.kills,0,0);
  g2.restore();
  /* 右上: 今の区間で貯めたスクラップと生存倍率(K1「右上などに常時表示」) */
  g2.textAlign='right';
  g2.font='bold 16px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='#ffce6a'; g2.fillText('スクラップ '+(p?p.base:0),W-12,28);
  g2.font='bold 11px "Hiragino Kaku Gothic ProN",sans-serif';
  var mu=world.mult();
  g2.fillStyle=(mu>1?'#9fd8ff':'rgba(244,236,214,.5)');
  g2.fillText('生存 '+Math.floor(world.t)+'秒 ×'+mu.toFixed(1),W-12,45);
  g2.font='10px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='rgba(244,236,214,.30)';
  g2.fillText(VER+(NET.on?(world.ps.length>=2?' 2人':' 相手待ち'):''),W-12,60);
  g2.textAlign='left';
  var by=H-52, bw=Math.min(104,(W-36)/2), bh=40;
  var left=rockLeft();
  uiBtn('rock',12,by,bw,bh,'大岩',left>0?Math.ceil(left)+'秒':(rockArm?'かまえた':'いける'),
    left>0?'off':(rockArm?'hot':'on'));
  if(world.ps.length>=2){
    uiBtn('swap',12+bw+8,by,bw,bh,'交代',world.canSwap()?'前後を入れ替え':'取り付かれ中',
      world.canSwap()?'on':'off');
  }
  if(joinMsg>0&&netMsg){
    g2.save(); g2.globalAlpha=Math.min(1,joinMsg*1.4);
    g2.textAlign='center'; g2.font='bold 14px "Hiragino Kaku Gothic ProN",sans-serif';
    g2.fillStyle='#9fd8ff'; g2.fillText(netMsg,W/2,84);
    g2.restore(); g2.textAlign='left';
  }
}
function drawTitle(){
  g2.fillStyle='rgba(6,9,14,.66)'; g2.fillRect(0,0,W,H);
  g2.textAlign='center';
  g2.font='bold 34px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='#f4ecd6'; g2.fillText('石とゾンビ',W/2,H*0.24);
  g2.font='13px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='rgba(244,236,214,.80)';
  g2.fillText('どこでも引いて離すと石が飛ぶ',W/2,H*0.24+32);
  g2.fillText('頭の上から落とした石だけが倒せる',W/2,H*0.24+54);
  g2.fillText('横から当てても押し戻すだけ',W/2,H*0.24+76);
  g2.font='11px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='rgba(159,216,255,.85)';
  g2.fillText('はじめると、誰か来た時にそのまま2人になります',W/2,H*0.24+104);
  g2.textAlign='left';
  var bw=Math.min(250,W-60), bx=(W-bw)/2, y=H*0.50;
  uiBtn('start',bx,y,bw,52,'はじめる','','hot');
  uiBtn('shop',bx,y+62,bw,44,'ショップ','スクラップ '+SAVE.scrap,'on');
  uiBtn('avatar',bx,y+114,bw,44,'アバター変更','','on');
  g2.textAlign='center';
  g2.font='11px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='rgba(244,236,214,.4)';
  g2.fillText('さいこう '+SAVE.best+'体 '+VER,W/2,y+186);
  g2.textAlign='left';
}
function drawResult(){
  g2.fillStyle='rgba(18,8,8,.74)'; g2.fillRect(0,0,W,H);
  var r=result||{base:0,mult:1,total:0,kills:0,t:0};
  g2.textAlign='center';
  g2.font='bold 28px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='#f0d2c4'; g2.fillText('やられた',W/2,H*0.20);
  g2.font='13px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='rgba(244,236,214,.72)';
  g2.fillText('倒した '+r.kills+'体 / 生き残り '+Math.floor(r.t)+'秒',W/2,H*0.20+26);
  /* ★増えた理由を並べて見せる(K1「基礎→生存ボーナス×1.5→最終獲得、のように明示する」) */
  var y=H*0.33, bw=Math.min(284,W-44), bx=(W-bw)/2;
  g2.textAlign='left';
  roundRect(bx,y,bw,118,12); g2.fillStyle='rgba(12,18,24,.92)'; g2.fill();
  g2.strokeStyle='rgba(92,120,144,.6)'; g2.lineWidth=1.5; g2.stroke();
  g2.font='13px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='rgba(244,236,214,.8)'; g2.fillText('基礎スクラップ',bx+16,y+30);
  g2.textAlign='right'; g2.fillStyle='#f4ecd6';
  g2.font='bold 15px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillText(''+r.base,bx+bw-16,y+30);
  g2.textAlign='left';
  g2.font='13px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='rgba(159,216,255,.9)'; g2.fillText('生存ボーナス',bx+16,y+58);
  g2.textAlign='right'; g2.font='bold 15px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillText('×'+r.mult.toFixed(1),bx+bw-16,y+58);
  g2.textAlign='left';
  g2.strokeStyle='rgba(92,120,144,.45)'; g2.lineWidth=1;
  g2.beginPath(); g2.moveTo(bx+14,y+72); g2.lineTo(bx+bw-14,y+72); g2.stroke();
  g2.font='bold 14px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='#ffce6a'; g2.fillText('もらった',bx+16,y+100);
  g2.textAlign='right'; g2.font='bold 22px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillText(''+r.total,bx+bw-16,y+102);
  g2.textAlign='center';
  g2.font='11px "Hiragino Kaku Gothic ProN",sans-serif';
  g2.fillStyle='rgba(244,236,214,.5)';
  g2.fillText('ぜんぶで '+SAVE.scrap+' スクラップ',W/2,y+140);
  g2.textAlign='left';
  if(overT>0.5){
    var by=y+156;
    uiBtn('again',bx,by,bw*0.56-4,46,'もう一度','','hot');
    uiBtn('top',bx+bw*0.56+4,by,bw*0.44-4,46,'トップへ','','on');
  }
}
function draw(){
  BTN.length=0;
  g2.save();
  if(shake>0) g2.translate((Math.random()*2-1)*shake*5,(Math.random()*2-1)*shake*5);
  drawBG();
  var i;
  for(i=0;i<world.zs.length;i++) if(world.zs[i].dead) drawZombie(world.zs[i]);
  for(i=0;i<world.ps.length;i++) if(!world.ps[i].alive) drawPerson(world.ps[i],world.ps[i].id===meId);
  for(i=0;i<world.zs.length;i++) if(!world.zs[i].dead) drawZombie(world.zs[i]);
  for(i=0;i<world.ps.length;i++) if(world.ps[i].alive) drawPerson(world.ps[i],world.ps[i].id===meId);
  for(i=0;i<world.cs.length;i++) drawCrow(world.cs[i]);
  for(i=0;i<S.length;i++) drawStoneAt(S[i].b.position.x,S[i].b.position.y,S[i].b.angle,S[i].r,S[i].rock);
  for(i=0;i<P.length;i++){
    var q=P[i];
    g2.globalAlpha=Math.max(0,1-q.t/q.life);
    g2.fillStyle=q.col; g2.beginPath(); g2.arc(q.x,q.y,q.r,0,7); g2.fill();
  }
  g2.globalAlpha=1;
  guide();
  g2.restore();
  g2.textAlign='center';
  for(i=0;i<FL.length;i++){
    var f=FL[i];
    g2.globalAlpha=Math.max(0,1-f.t);
    g2.font='bold 15px "Hiragino Kaku Gothic ProN",sans-serif';
    g2.fillStyle=f.col; g2.fillText(f.s,f.x,f.y);
  }
  g2.globalAlpha=1; g2.textAlign='left';
  if(scr==='play') drawHUD();
  else if(scr==='title') drawTitle();
  else if(scr==='result'){ drawHUD(); drawResult(); }
}

/* ===== 着せ替えとお店(ここだけDOM。縦に流してよい) ===== */
var shAv=document.getElementById('shAv'), shShop=document.getElementById('shShop');
function miniCanvas(w,h,fn){
  var c=document.createElement('canvas');
  var dpr=Math.min(3,window.devicePixelRatio||1);
  c.width=w*dpr; c.height=h*dpr; c.style.width=w+'px'; c.style.height=h+'px';
  var g=c.getContext('2d'); g.setTransform(dpr,0,0,dpr,0,0);
  fn(g,w,h);
  return c;
}
function drawMiniPerson(g,w,h,av0,cIdx,htIdx){
  var av=SHURE.fix(av0);
  var r=h*0.135, x=w/2, hy=h*0.30, shY=hy+1.30*r, bot=shY+2.40*r;
  g.strokeStyle='#33323a'; g.lineWidth=r*0.28; g.lineCap='round';
  g.beginPath(); g.moveTo(x,bot-r*0.2); g.lineTo(x-r*0.34,bot+r*1.3); g.stroke();
  g.beginPath(); g.moveTo(x,bot-r*0.2); g.lineTo(x+r*0.42,bot+r*1.3); g.stroke();
  SHURE.draw(g,x,hy,r,1,av,{},bot);
  var cst=wrGet(WR.COSTUMES,cIdx); if(cst&&cst.draw) cst.draw(g,x,shY,bot,r,SHURE.WEAR_COL[av.w],1);
  var hat=wrGet(WR.HATS,htIdx); if(hat&&hat.draw) hat.draw(g,x,hy,r,1,SHURE.HAIR_COL[av.hc]);
  var skin=SHURE.SKIN_COL[av.sk];
  g.strokeStyle=skin; g.lineWidth=r*0.28;
  g.beginPath(); g.moveTo(x,shY+r*0.12); g.lineTo(x+1.5*r,shY+1.5*r); g.stroke();
  g.fillStyle=skin; g.beginPath(); g.arc(x+1.5*r,shY+1.5*r,r*0.20,0,7); g.fill();
}
function openAvatar(){ shAv.classList.add('on'); renderAvatar(); }
function openShop(){ shShop.classList.add('on'); renderShop(); }
document.getElementById('avX').onclick=function(){ shAv.classList.remove('on'); };
document.getElementById('spX').onclick=function(){ shShop.classList.remove('on'); };

function renderAvatar(){
  document.getElementById('avSc').textContent='スクラップ '+SAVE.scrap;
  var b=document.getElementById('avBody');
  b.innerHTML='';
  var prev=document.createElement('div');
  prev.style.cssText='display:flex;justify-content:center;padding:6px 0 10px';
  prev.appendChild(miniCanvas(150,190,function(g,w,h){
    g.fillStyle='#0a1016'; g.fillRect(0,0,w,h);
    drawMiniPerson(g,w,h,SAVE.av,SAVE.eq.c,SAVE.eq.ht);
  }));
  b.appendChild(prev);
  var parts=[
    ['髪型','h',SHURE.HAIR_N,function(i){ return SHURE.HAIR_NM[i]; },null],
    ['髪の色','hc',8,null,SHURE.HAIR_COL],
    ['肌','sk',4,null,SHURE.SKIN_COL],
    ['服の色','w',8,null,SHURE.WEAR_COL]
  ];
  parts.forEach(function(pt){
    var lab=document.createElement('div'); lab.className='lab';
    lab.textContent=pt[0]+'(ぜんぶ無料)';
    b.appendChild(lab);
    var gd=document.createElement('div'); gd.className='grid';
    for(var i=0;i<pt[2];i++) (function(i){
      var bt=document.createElement('button');
      if(SAVE.av[pt[1]]===i) bt.className='on';
      if(pt[4]){ bt.style.background=pt[4][i]; bt.style.height='34px'; bt.textContent=''; }
      else bt.textContent=pt[3]?pt[3](i):(''+(i+1));
      bt.onclick=function(){ SAVE.av[pt[1]]=i; putSave(); renderAvatar(); sendLook(); };
      gd.appendChild(bt);
    })(i);
    b.appendChild(gd);
  });
}
var SPTAB='c';
function renderShop(){
  document.getElementById('spSc').textContent='スクラップ '+SAVE.scrap;
  var tabs=document.getElementById('spTabs');
  tabs.innerHTML='';
  [['c','衣装'],['ht','帽子'],['st','石']].forEach(function(t){
    var b=document.createElement('button'); b.textContent=t[1];
    if(SPTAB===t[0]) b.className='on';
    b.onclick=function(){ SPTAB=t[0]; renderShop(); };
    tabs.appendChild(b);
  });
  var list=(SPTAB==='c')?WR.COSTUMES:(SPTAB==='ht'?WR.HATS:WR.STONES);
  var body=document.getElementById('spBody');
  body.innerHTML='';
  list.forEach(function(it,i){
    var row=document.createElement('div'); row.className='row';
    row.appendChild(miniCanvas(54,60,function(g,w,h){
      g.fillStyle='#0a1016'; g.fillRect(0,0,w,h);
      if(SPTAB==='st'){ g.save(); g.translate(w/2,h/2); it.draw(g,16,0); g.restore(); }
      else drawMiniPerson(g,w,h,SAVE.av,SPTAB==='c'?i:SAVE.eq.c,SPTAB==='ht'?i:SAVE.eq.ht);
    }));
    var tx=document.createElement('div');
    var nm=document.createElement('div'); nm.className='nm'; nm.textContent=it.nm;
    var pr=document.createElement('div'); pr.className='pr';
    var own=SAVE.own[SPTAB].indexOf(i)>=0;
    pr.textContent=own?'持っている':(it.price+' スクラップ');
    tx.appendChild(nm); tx.appendChild(pr); row.appendChild(tx);
    var sp=document.createElement('div'); sp.className='sp'; row.appendChild(sp);
    var bt=document.createElement('button');
    if(SAVE.eq[SPTAB]===i){ bt.className='on'; bt.textContent=(SPTAB==='st')?'使用中':'着ている'; }
    else if(own){ bt.textContent=(SPTAB==='st')?'使う':'着る';
      bt.onclick=function(){ SAVE.eq[SPTAB]=i; putSave(); renderShop(); sendLook(); }; }
    else if(SAVE.scrap>=it.price){ bt.className='buy'; bt.textContent='買う';
      bt.onclick=function(){ SAVE.scrap-=it.price; SAVE.own[SPTAB].push(i); SAVE.eq[SPTAB]=i;
        putSave(); renderShop(); sendLook(); }; }
    else{ bt.className='ng'; bt.innerHTML='買う<i>たりない</i>'; }
    row.appendChild(bt);
    body.appendChild(row);
  });
}
function sendLook(){
  if(NET.ws&&NET.ws.readyState===1) netSend({y:'me',h:SAVE.av.h,hc:SAVE.av.hc,sk:SAVE.av.sk,w:SAVE.av.w,
    c:SAVE.eq.c,ht:SAVE.eq.ht,st:SAVE.eq.st});
  var p=myP(); if(p&&!NET.on){ p.av=SAVE.av; p.look=SAVE.eq; }
}

/* ===== 毎コマ ===== */
function loop(fn){
  var last=performance.now(), on=true;
  function tick(now){
    if(!on) return;
    var dt=Math.min(0.05,(now-last)/1000); last=now;
    fn(dt); requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
  document.addEventListener('visibilitychange',function(){
    if(document.hidden) on=false;
    else if(!on){ on=true; last=performance.now(); requestAnimationFrame(tick); }
  });
}
layout();
window.addEventListener('resize',function(){ layout(); });
loop(function(dt){ step(dt); if(RENDER) draw(); });

/* ===== 検品の口(tools/*.mjs から叩く。遊びには出てこない) ===== */
window.ZG={
  ver:VER,
  state:function(){ var p=myP(); return { mode:scr, scr:scr,
    kills:world.kills, throws:throws, stones:S.length, zombies:world.zs.length, crows:world.cs.length,
    players:world.ps.length, hp:p?Math.round(p.hp):0, base:p?p.base:0, mult:world.mult(),
    t:+world.t.toFixed(1), net:NET.mode, on:NET.on?1:0, room:NET.room, me:meId,
    rock:+rockLeft().toFixed(1), canSwap:world.canSwap()?1:0,
    W:W, H:H, ppm:L.ppm, floorY:L.floorY, hx:handX(), ry:handY(), px:MX(myX()),
    maxPull:L.maxPull, hr:L.hr, stoneR:L.stoneR, over:world.over?1:0,
    scrap:SAVE.scrap, result:result, wardrobe:[WR.COSTUMES.length,WR.HATS.length,WR.STONES.length] }; },
  start:function(net){ startGame(); if(net===false){ netStop(); NET.mode='off'; } },
  title:toTitle,
  offline:function(){ netStop(); NET.mode='off'; world.sim='full'; },
  freeze:function(b){ FREEZE=!!b; },
  render:function(b){ RENDER=b!==false; },
  clearZ:function(){ world.zs.length=0; world.cs.length=0; },
  spawn:function(m,spd){ world.spawnZ(m,spd==null?0:spd); return true; },
  spawnCrow:function(m,y){ var c=world.spawnC(); c.x=m; if(y!=null){ c.y=y; c.y0=y; } return true; },
  throwAt:function(a,pf){ ang=Math.max(14,Math.min(84,a)); pull=Math.max(0,Math.min(1,pf))*L.maxPull;
    cool=0; shoot(); },
  armRock:function(b){ var p=myP(); if(p) p.bT=0; rockArm=(b!==false); },
  swap:function(){ return world.canSwap()? (NET.on?(netSend({y:'swap'}),true):world.swap()) : false; },
  step:function(dt){ step(dt==null?1/60:dt); },
  last:function(){ return LASTEV; },
  can:function(){ return canThrow(); },
  zs:function(){
    var a=[];
    for(var i=0;i<world.zs.length;i++){ var z=world.zs[i]; if(z.dead) continue; var G=zGeo(z);
      a.push({ id:z.id, x:G.x, m:z.x, hy:G.hy, hr:G.hr, spd:z.spd, gr:z.gr?1:0 }); }
    a.sort(function(p,q){ return p.x-q.x; });
    return a;
  },
  /* 一番手前の1体へ、当たったことにして知らせる。石は飛ばさない。
     **本物と同じ道(claim)を通す**ので、2人で繋がっている時はサーバーへ出て返ってくる */
  hitNearest:function(kind){
    var b=null;
    for(var i=0;i<world.zs.length;i++){ var z=world.zs[i];
      if(!z.dead && (!b||z.x<b.x)) b=z; }
    if(!b) return false;
    claim(kind||'kill',b.id,zGeo(b));
    return true;
  },
  hurt:function(n){ var p=myP(); if(p){ p.hp-=n; if(p.hp<=0){ p.hp=0; p.alive=false; } } },
  /* 1回だけ撃って何が起きたかを返す(大根おろし検査) */
  trial:function(distM,a,pf,rock){
    scr='play'; FREEZE=true; netStop(); NET.on=false; NET.mode='off';
    world=new ZWORLD.World({sim:'full'}); meId='solo';
    world.addPlayer(meId,SAVE.av,SAVE.eq); world.ev.length=0;
    S.length=0; P.length=0; FL.length=0; LASTEV=''; LASTB=0; cool=0; acc=0;
    world.spawnZ(world.xOf(meId)+distM,0);
    rockArm=!!rock; if(rock) world.find(meId).bT=0;
    this.throwAt(a,pf);
    for(var i=0;i<700;i++){ step(1/60); if(LASTEV) break; if(!S.length) break; }
    FREEZE=false; rockArm=false;
    return { r:LASTEV||'miss', b:LASTB };
  }
};
</script>
<!-- 🐛報告の箱だけ相続する。fps/メモリの計器板は盤面に被るので既定で消す -->
<script>window.SETTINGS={memMeter:false};</script>
<script src="dev_tools.js?v=20260906a"></script>
</body>
</html>