public/lib/world.js

クライアント/サーバー共用の世界。ノックバック・移動・HP・報酬・状態送受信

358行 · 14,485 bytes · SHA-256 24dcc8b97f3be7ae17e83bbe6d2728d4834776bfe526e37cdde8410aa23cfa07

分割 1/2 分割 2/2
/* ===== 石とゾンビ・世界の中身(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);