public/lib/world.js
クライアント/サーバー共用の世界。ノックバック・移動・HP・報酬・状態送受信
/* ===== 石とゾンビ・世界の中身(2026-09-13) =====
★この1本を**サーバー(Durable Object)と端末の両方が回す。**二重に書かない。
・サーバー: `sim:'full'` で全部回す。**ここが正**(HP・死亡・撃破・スクラップ・大岩・前後交代・参加退出)
・端末 : `sim:'motion'` で**動きだけ**回す。ゾンビは受け取った位置の間をなめらかに進み、
中身はサーバーから届いた物で上書きする
・ひとりで遊ぶ時(通信が繋がらない時): 端末が `sim:'full'` で回す。中身は同じ
★長さは全部**メートル**。画面の幅が端末で違っても同じ世界になる(マルチの前提)。
画面の左端が0m、右端が VIEW_M(7.8m)。px への直しは絵を描く側の仕事。
★石はここに入っていない。石は**投げた本人の端末**で飛ばす(通信を待たない)。
当たったかどうかも本人の端末が決めて、結果だけをここへ渡す(`hit`)。
K1「投石は操作レスポンス最優先」「自分では明らかに当たったのに何も起きない、は極力避ける」 */
(function(root){
'use strict';
var C={
VIEW_M:7.8, /* 画面に入る横幅(m) */
SLOT_X:[1.48,0.60], /* 0=前衛 / 1=後衛 の立ち位置(m)。
1.30/0.58だと2人の体が触れて頭のHP帯が重なった(2026-09-13 実測) */
HAND_DX:0.30, /* 手を離す所(立ち位置からの前) */
HAND_Y:1.58, /* 手を離す高さ(足もとから) */
BODY_H:1.72, /* 身長 */
GRAB_GAP:0.44, /* 前衛のこれだけ前まで来たら取り付く */
MAXHP:100,
DRAIN:11, /* 取り付かれている間、1体につき毎秒これだけ減る */
KB:1.7, KBMAX:2.6, STAG:0.40, /* ノックバックの強さ・上限・よろける秒数 */
SPAWN0:4.6, SPAWNMIN:1.25, SPAWNK:0.11,
TWOP:0.68, /* 2人いる時は湧く間隔をこの割合にする(私が決めた) */
SPD0:0.34, SPDK:0.013, SPDMAX:0.95,
SPAWNX:[8.15,9.05],
BOULDER_CD:30, /* 大岩のクールダウン(秒) */
CROW_FIRST:28, /* 最初のカラスが来るまで(秒) */
CROW_INT0:15, CROW_INTMIN:5.5, CROW_INTK:0.10,
CROW_Y:[2.5,3.6], /* 飛ぶ高さ(m)。山なりの邪魔をしない所 */
CROW_SPD:0.95,
CROW_DIVE_DMG:7, /* 突いた時のダメージ */
CROW_DIVE_CD:3.2,
/* 生存時間倍率。K1の仮例(60秒1.2 / 90秒1.3 / 120秒1.5 / 以降さらに上昇)をそのまま段にした */
MULT:[[0,1.0],[60,1.2],[90,1.3],[120,1.5],[150,1.7],[180,1.9],[210,2.1],[240,2.3],[300,2.6],[360,3.0]],
SCRAP_Z:1, /* ゾンビ1体 */
SCRAP_C:3 /* カラス1羽 */
};
function rndFn(seed){
var s=(seed|0)||12345;
return function(){ s^=s<<13; s^=s>>>17; s^=s<<5; s|=0; return ((s>>>0)%100000)/100000; };
}
function World(o){
o=o||{};
this.sim=o.sim||'full';
this.seed=(o.seed|0)||((Math.random()*1e9)|0);
this.rnd=rndFn(this.seed);
this.t=0; this.nid=1; this.seq=0;
this.zs=[]; this.cs=[]; this.ps=[];
this.kills=0; this.spawnT=1.1; this.crowT=C.CROW_FIRST;
this.over=false; this.ev=[];
}
World.C=C;
/* ---------- 人 ---------- */
World.prototype.addPlayer=function(id,av,look){
if(this.find(id)) return this.find(id);
var p={ id:id, hp:C.MAXHP, alive:true, base:0, joinT:this.t,
av:av||{h:0,hc:0,sk:0,w:0}, look:look||{c:0,ht:0,st:0},
bT:0, grabs:0, ping:0 };
this.ps.push(p);
this.ev.push({e:'join',p:id});
return p;
};
World.prototype.rmPlayer=function(id){
var i=this.idx(id); if(i<0) return null;
var p=this.ps.splice(i,1)[0];
for(var j=0;j<this.zs.length;j++) if(this.zs[j].gr===id) this.zs[j].gr=null;
this.ev.push({e:'left',p:id});
return p;
};
World.prototype.idx=function(id){ for(var i=0;i<this.ps.length;i++) if(this.ps[i].id===id) return i; return -1; };
World.prototype.find=function(id){ var i=this.idx(id); return i<0?null:this.ps[i]; };
/* 前衛=並びの先頭で生きている人。ゾンビはこの人を狙う */
World.prototype.front=function(){
for(var i=0;i<this.ps.length;i++) if(this.ps[i].alive) return this.ps[i];
return null;
};
World.prototype.slotOf=function(id){
var n=0;
for(var i=0;i<this.ps.length;i++){ if(this.ps[i].id===id) return n; n++; }
return 0;
};
World.prototype.xOf=function(id){
var s=this.slotOf(id);
return C.SLOT_X[Math.min(s,C.SLOT_X.length-1)];
};
/* 1操作で前後を入れ替える。取り付かれている人がいる間は交代できない */
World.prototype.canSwap=function(){
if(this.ps.length<2) return false;
for(var i=0;i<this.zs.length;i++) if(this.zs[i].gr) return false;
return true;
};
World.prototype.swap=function(){
if(!this.canSwap()) return false;
this.ps.reverse();
this.ev.push({e:'swap'});
return true;
};
/* ---------- ゾンビ ---------- */
World.prototype.zSpd=function(){ return Math.min(C.SPDMAX, C.SPD0+this.kills*C.SPDK); };
World.prototype.spawnZ=function(x,spd){
var r=this.rnd;
var z={ id:this.nid++, x:(x==null? C.SPAWNX[0]+r()*(C.SPAWNX[1]-C.SPAWNX[0]) : x),
spd:(spd==null? this.zSpd()*(0.86+r()*0.28) : spd),
kb:0, stag:0, gr:null, dead:0, dt:0,
look:{ h:(r()*8)|0, hc:(r()*8)|0, sk:(r()*3)|0, w:(r()*12)|0 },
ph:r()*6.28, tilt:(r()*2-1)*0.09 };
this.zs.push(z);
return z;
};
World.prototype.zById=function(id){ for(var i=0;i<this.zs.length;i++) if(this.zs[i].id===id) return this.zs[i]; return null; };
World.prototype.cById=function(id){ for(var i=0;i<this.cs.length;i++) if(this.cs[i].id===id) return this.cs[i]; return null; };
/* ---------- カラス ---------- */
World.prototype.spawnC=function(){
var r=this.rnd;
var c={ id:this.nid++, x:C.VIEW_M+0.6, y:C.CROW_Y[0]+r()*(C.CROW_Y[1]-C.CROW_Y[0]),
y0:0, dead:0, dt:0, dive:0, cd:1.6, ph:r()*6.28 };
c.y0=c.y;
this.cs.push(c);
return c;
};
/* ---------- 当たった、の受け口 ----------
石が当たったかどうかは**投げた本人の端末**が決める(頭頂部の判定はそちら)。
ここは結果を受けて、世界の中身(倒れた・押された・スクラップ)を動かすだけ。
kind: 'kill'(頭頂部) / 'knock'(横から) / 'crow'(カラスに当てた) / 'rock'(大岩) */
World.prototype.hit=function(pid,kind,tid){
if(this.sim!=='full') return false;
if(kind==='crow'){
var c=this.cById(tid);
if(!c||c.dead) return false;
c.dead=1; c.dt=0;
this.award(C.SCRAP_C);
this.ev.push({e:'ckill',id:c.id,x:c.x,y:c.y,n:C.SCRAP_C});
return true;
}
var z=this.zById(tid);
if(!z||z.dead) return false;
if(kind==='kill'||kind==='rock'){
z.dead=1; z.dt=0; z.gr=null; this.kills++;
this.award(C.SCRAP_Z);
this.ev.push({e:'zkill',id:z.id,x:z.x,n:C.SCRAP_Z,rock:kind==='rock'?1:0});
return true;
}
/* 横から当たった: 押し戻すだけ。**取り付きはここで外れる**(相方の救出) */
z.kb=Math.min(C.KBMAX,z.kb+C.KB); z.stag=C.STAG;
if(z.gr){ this.ev.push({e:'free',p:z.gr,id:z.id}); z.gr=null; }
this.ev.push({e:'zknock',id:z.id,x:z.x});
return true;
};
/* スクラップは**その時いる人全員へ同じだけ**入る。キルの取り合いを作らない(K1指示)。
参加前の分は入らない——入るのは「今いる人」だけなので、それで足りている */
World.prototype.award=function(n){
for(var i=0;i<this.ps.length;i++) if(this.ps[i].alive) this.ps[i].base+=n;
};
World.prototype.mult=function(t){
t=(t==null?this.t:t);
var m=1.0;
for(var i=0;i<C.MULT.length;i++) if(t>=C.MULT[i][0]) m=C.MULT[i][1];
return m;
};
World.prototype.payout=function(p){
var m=this.mult();
return { base:p.base, mult:m, total:Math.floor(p.base*m) };
};
/* 大岩。30秒に1回・スクラップは要らない */
World.prototype.rockReady=function(p){ return this.t>=p.bT; };
World.prototype.useRock=function(pid){
var p=this.find(pid); if(!p||!p.alive) return false;
if(this.sim==='full'){ if(this.t<p.bT) return false; p.bT=this.t+C.BOULDER_CD; }
else { p.bT=this.t+C.BOULDER_CD; }
this.ev.push({e:'rock',p:pid});
return true;
};
/* ---------- 1コマ ---------- */
World.prototype.step=function(dt){
this.t+=dt;
var full=(this.sim==='full');
var i,z,c,p;
/* 湧き(サーバー側だけ。受信側は届いた物を並べる) */
if(full && !this.over){
var np=0; for(i=0;i<this.ps.length;i++) if(this.ps[i].alive) np++;
this.spawnT-=dt;
if(this.spawnT<=0 && np>0){
this.spawnZ();
var iv=Math.max(C.SPAWNMIN, C.SPAWN0-this.kills*C.SPAWNK);
if(np>=2) iv*=C.TWOP;
this.spawnT=iv;
}
this.crowT-=dt;
if(this.crowT<=0 && np>0 && this.t>=C.CROW_FIRST){
this.spawnC();
this.crowT=Math.max(C.CROW_INTMIN, C.CROW_INT0-this.kills*C.CROW_INTK);
}
}
var fr=this.front();
var frX=fr?this.xOf(fr.id):C.SLOT_X[0];
/* ゾンビ */
for(i=this.zs.length-1;i>=0;i--){
z=this.zs[i];
if(z.dead){ z.dt+=dt; if(z.dt>1.5) this.zs.splice(i,1); continue; }
z.ph+=dt*(1.5+z.spd*2.2);
if(z.kb>0.05){ z.x+=z.kb*dt; z.kb*=Math.pow(0.015,dt); }
if(z.gr){
/* 取り付いている。HPを削り続ける(サーバー側だけ) */
if(full){
p=this.find(z.gr);
if(!p||!p.alive){ z.gr=null; }
else{
p.hp-=C.DRAIN*dt;
if(p.hp<=0){ p.hp=0; p.alive=false; this.ev.push({e:'down',p:p.id}); z.gr=null; }
}
}
}else if(z.stag>0){ z.stag-=dt; }
else{
z.x-=z.spd*dt;
if(full && fr && z.x<=frX+C.GRAB_GAP){
z.x=frX+C.GRAB_GAP; z.gr=fr.id;
this.ev.push({e:'grab',p:fr.id,id:z.id});
}
if(!full && z.x<=frX+C.GRAB_GAP) z.x=frX+C.GRAB_GAP;
}
if(z.x>C.VIEW_M+2.2) z.x=C.VIEW_M+2.2;
}
/* カラス。左へ流れながら、下の人めがけて突きに降りる */
for(i=this.cs.length-1;i>=0;i--){
c=this.cs[i];
if(c.dead){ c.dt+=dt; c.y-=0; c.y+=4.2*dt; if(c.dt>1.4) this.cs.splice(i,1); continue; }
c.ph+=dt*9;
if(c.dive>0){
c.dive-=dt;
var k=1-Math.abs(c.dive/C.CROW_DIVE_CD*2-1); /* 0→1→0 で降りて戻る */
c.y=c.y0-(c.y0-1.42)*Math.max(0,k);
if(full && c.dive>C.CROW_DIVE_CD*0.45 && c.dive<C.CROW_DIVE_CD*0.55){
p=this.front();
if(p&&p.alive&&Math.abs(c.x-this.xOf(p.id))<0.55&&!c.bit){
c.bit=1; p.hp-=C.CROW_DIVE_DMG;
this.ev.push({e:'peck',p:p.id,x:c.x,y:c.y});
if(p.hp<=0){ p.hp=0; p.alive=false; this.ev.push({e:'down',p:p.id}); }
}
}
if(c.dive<=0){ c.y=c.y0; c.cd=C.CROW_DIVE_CD; c.bit=0; }
}else{
c.x-=C.CROW_SPD*dt;
c.y=c.y0+Math.sin(c.ph*0.3)*0.10;
if(c.cd>0) c.cd-=dt;
p=this.front();
if(full && c.cd<=0 && p && p.alive && Math.abs(c.x-this.xOf(p.id))<0.35){
c.dive=C.CROW_DIVE_CD; c.bit=0;
}
if(c.x<-0.8) this.cs.splice(i,1);
}
}
/* 終わり。生きている人が1人もいなくなったら世界は終わる */
if(full && !this.over && this.ps.length>0){
var any=false;
for(i=0;i<this.ps.length;i++) if(this.ps[i].alive) any=true;
if(!any){ this.over=true; this.ev.push({e:'over'}); }
}
};
/* ---------- サーバー→端末に送る中身 ----------
数だけの短い形にする。1秒に12回送る物なので、名前の長い鍵を使わない */
World.prototype.snap=function(){
var z=[],c=[],p=[],i;
for(i=0;i<this.zs.length;i++){
var a=this.zs[i];
z.push([a.id, +a.x.toFixed(3), +a.spd.toFixed(3), a.dead?1:0, a.gr?1:0,
a.look.h, a.look.hc, a.look.sk, a.look.w, +a.tilt.toFixed(2)]);
}
for(i=0;i<this.cs.length;i++){
var b=this.cs[i];
c.push([b.id, +b.x.toFixed(3), +b.y.toFixed(3), b.dead?1:0, b.dive>0?1:0]);
}
for(i=0;i<this.ps.length;i++){
var q=this.ps[i];
p.push([q.id, Math.round(q.hp), q.alive?1:0, q.base, +Math.max(0,q.bT-this.t).toFixed(1),
q.av.h,q.av.hc,q.av.sk,q.av.w, q.look.c,q.look.ht,q.look.st]);
}
var e=this.ev; this.ev=[];
return { y:'s', t:+this.t.toFixed(2), q:++this.seq, k:this.kills, o:this.over?1:0,
m:this.mult(), z:z, c:c, p:p, e:e };
};
/* 端末側: 届いた中身を今の世界へ合わせる。
ゾンビの横位置は**飛ばさずに寄せる**(カクカクさせない。K1「ガクガクしない」)。
離れすぎている時だけ飛ばす(入ってきた直後・裏に回って戻った時) */
World.prototype.apply=function(s,meId){
var i,j,seen={},z;
this.t=s.t; this.kills=s.k; this.over=!!s.o; this.seq=s.q;
for(i=0;i<s.z.length;i++){
var a=s.z[i]; seen[a[0]]=1;
z=this.zById(a[0]);
if(!z){
z={ id:a[0], x:a[1], spd:a[2], kb:0, stag:0, gr:null, dead:0, dt:0,
look:{h:a[5],hc:a[6],sk:a[7],w:a[8]}, ph:(a[0]*0.7)%6.28, tilt:a[9] };
this.zs.push(z);
}
z.spd=a[2];
if(Math.abs(z.x-a[1])>0.75) z.x=a[1]; else z.x+=(a[1]-z.x)*0.34;
if(a[3]&&!z.dead){ z.dead=1; z.dt=0; }
z.gr=a[4]?'x':null;
}
for(i=this.zs.length-1;i>=0;i--) if(!seen[this.zs[i].id] && !this.zs[i].dead) this.zs.splice(i,1);
seen={};
for(i=0;i<s.c.length;i++){
var b=s.c[i]; seen[b[0]]=1;
var c=this.cById(b[0]);
if(!c){ c={ id:b[0], x:b[1], y:b[2], y0:b[2], dead:0, dt:0, dive:0, cd:0, ph:(b[0]*1.3)%6.28 }; this.cs.push(c); }
if(Math.abs(c.x-b[1])>0.75) c.x=b[1]; else c.x+=(b[1]-c.x)*0.34;
c.y+=(b[2]-c.y)*0.34;
c.dive=b[4]?1:0;
if(b[3]&&!c.dead){ c.dead=1; c.dt=0; }
}
for(i=this.cs.length-1;i>=0;i--) if(!seen[this.cs[i].id] && !this.cs[i].dead) this.cs.splice(i,1);
/* 人は並び順がそのまま前衛/後衛。届いた順に作り直す */
var np=[];
for(i=0;i<s.p.length;i++){
var q=s.p[i], old=this.find(q[0]);
var pl=old||{ id:q[0], av:{h:0,hc:0,sk:0,w:0}, look:{c:0,ht:0,st:0} };
pl.hp=q[1]; pl.alive=!!q[2]; pl.base=q[3]; pl.bT=this.t+q[4];
pl.av={h:q[5],hc:q[6],sk:q[7],w:q[8]};
pl.look={c:q[9],ht:q[10],st:q[11]};
np.push(pl);
}
this.ps=np;
return s.e||[];
};
/* 端末側の毎コマ: 動きだけ進める(湧かない・HPを削らない・倒さない) */
var M={ World:World, C:C };
if(typeof module!=='undefined'&&module.exports) module.exports=M;
root.ZWORLD=M;
})(typeof globalThis!=='undefined'?globalThis:this);