public/index.html
ゲーム画面全体。HTML/CSS・入力・投石物理・当たり判定・通信・描画・音・検品口
<!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>