public/lib/world.js
分割 1 / 2 · 元ファイル 1〜265行付近
/* ===== 石とゾンビ・世界の中身(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{