石とゾンビ — ChatGPT(メープル)向け最新コード一式・完全収録版
書き出し日時: 2026-09-13T20:27:28+09:00(日本時間)
正本フォルダ: C:\石とゾンビ
納品ファイル: C:\石とゾンビ_受け渡し\石とゾンビ_最新コード一式_完全収録版_20260913.txt
本番URL(K1指定): https://ishi-to-zombie.kaiten-sushi-online.workers.dev/
ローカルコードの表示版: v20260913b
収録: 現行コード・設定・仕様 21ファイル + 参考検品スクリプト1ファイル = 22ファイル全文
ソース総容量: 405,742 bytes(案内・区切りを除く)
【この資料の目的と範囲】
K1からの症状報告: 「石がゾンビに当たり、ノックバックするときの表示がラグく/カクついて見える」。
原因調査・原因の断定・修正案の評価は今回行っていない。現在のソースと関連仕様を渡すための資料。
対象端末・発生頻度・通信状態・ソロ/2人の別など、上記以外の再現条件は今回の依頼文に記載されていない。
正本は書き出し時点のローカル実ファイル。既存まとめからの転載ではなく、C:\石とゾンビから読み直した。
本番サーバーの配備コードとのバイト一致・稼働状態は照合していない。表示版だけで同一と断定しない。
コード編集・git操作・デプロイ・ゲームの再現試験は行っていない。
【読む順序】
1. この案内と索引 → public/index.html → public/lib/world.js → src/worker.js。
2. docs/K1仕様書_磨き込みとマルチ_20260913.md と docs/決めたこと.md の後半で関連仕様を確認。
3. 必要に応じて描画ライブラリ・開発道具・検品コード・Matter.js本体を読む。
各 BEGIN FILE ブロックは元ファイル全文。コードへ行番号・省略記号・整形・書換えを加えていない。
索引の「元ファイル:行」は元ファイル内の1始まりの行番号。目次の「本文開始行」はこのテキスト内の行番号。
各ブロックの Source-Bytes は原文部分のUTF-8バイト数。原文末尾の後に区切り用LFを1個だけ追加する。
改行形式・BOM・コメント・圧縮済みの長い行も原文のまま保持。原文バイト列のSHA-256を併記した。
このまとめ全体の文字コードはUTF-8(先頭BOM付き)。Matter.jsの長い圧縮行も途中省略していない。
【仕様の時点を取り違えないための案内】
・初期仕様のPages公開先は旧情報。現行配置設定はwrangler.tomlのWorkers + Durable Objects + public/配信。
・初期の「負けライン到達で即終了」は、後続仕様で個別HP・取り付き・前衛/後衛へ変更されている。
・docs/決めたこと.mdには初期と移行後の記録が同居する。記録内の実測・完了表記は開発担当による過去の記録。
・以下の読み取りメモは今回の現行ソースで確認できる実装経路。設計要望の達成度・不具合原因の判定ではない。
【当たり判定・ノックバック・物理の索引】
・画角・m↔px変換: public/index.html:180 / public/index.html:188 / public/index.html:189
・Matter.js設定・床との衝突イベント: public/index.html:212 / public/index.html:216
・ゾンビの頭/胴/足の形状: public/index.html:242
・線分対円/矩形判定: public/index.html:252 / public/index.html:263
・石対ゾンビ/カラスの全判定と石の衝突後速度: public/index.html:279 / public/index.html:280
・命中のローカル/オンライン分岐と即時演出: public/index.html:319 / public/index.html:324
・投げる入力・初速・石の生成: public/index.html:463 / public/index.html:467 / public/index.html:476
・物理の固定ステップと更新順: public/index.html:547 / public/index.html:574
・ノックバック定数: public/lib/world.js:29
・命中結果の適用・ノックバック量・取り付き解除: public/lib/world.js:144
・世界の毎フレーム更新・ノックバック減衰・よろけ・前進: public/lib/world.js:194
・ゾンビ描画: public/index.html:673 / 描画ループ: public/index.html:1014
・物理エンジン配布物全文: public/lib/matter.min.js(版・ライセンスはファイル先頭にも記載)
【通信・同期・補間の索引】
・開始/接続/再接続/切断: public/index.html:353 / public/index.html:385 / public/index.html:393 / public/index.html:425
・スナップショット受信とイベント処理: public/index.html:430 / public/index.html:439
・サーバーの更新間隔: src/worker.js:150
・サーバーの命中等メッセージ処理: src/worker.js:218
・サーバーの世界更新と全端末への送信: src/worker.js:264
・送信パケットの生成: public/lib/world.js:287
・受信データの反映・位置補正: public/lib/world.js:310
・実オンライン協力の既存検品コード: tools/two.mjs / 端末内の検品口: public/index.html:1032
【現行コードの経路を辿るための読み取りメモ】
・石はpublic/index.html内のMatter.jsで動く。ゾンビはWorldのオブジェクトとして動き、石との判定はhitTestの線分判定。
・通常石の頭頂部判定は vy>0、ey<=G.hy-G.hr*0.25、vy>=abs(vx)*0.60 の全条件。大岩とカラスは別扱い。
・ノックバック適用値は kb=min(2.6,kb+1.7)、stag=0.40。World.stepでx+=kb*dt、kb*=pow(0.015,dt)などを実行する。
・投石の固定ステップは1000/60 ms、1回のphysics呼出しで最大4ステップ。描画ループのdt上限は0.05秒。
・接続前/通信なしの世界はsim='full'。接続成功のwelcome受信時はsim='motion'へ切り替える。
・オンライン命中の経路: hitTest → claim → {y:'hit',k:kind,i:tid}送信+fxHit → Game.onMsg → World.hit → tick/World.snap → applySnap → World.apply/onEvent。
・通信なしの命中経路: hitTest → claim → World.hit。同じ端末のWorld.stepとonEventが続く。
・サーバーのTICK_MS=80(設定上12.5回/秒)。tickでは実経過時間を最大0.25秒に制限してWorld.stepを呼び、snapを全接続へ送る。
・World.snapのトップレベルは y,t,q,k,o,m,z,c,p,e。各配列の列順はsnap全文を参照すること。
・World.applyのゾンビx補正は差が0.75m超なら代入、それ以外は差の34%を加算。実行箇所は受信時のapplyであり、描画フレームごとではない。
・上記は処理位置と式を記載したもの。カクつきの原因・改善策や計測結果は記載していない。
【補助ファイルと収録対象外】
・public/全8ファイル、src/全1ファイル、docs/全4ファイル、tools/全5ファイル、ルート設定3ファイルを全文収録。
・tmp/near.mjsは近距離/大岩の検品コードなので参考付録に収録。tmp/のPNGは過去の検品画像であり、画像バイナリは収録しない。
・.deploy/は.gitignore対象の旧配備用コピーで、現行のwrangler.tomlが参照するmain/assetsではない。最新版へ混在させないため対象外。
・.git/、.wrangler/、node_modules/などの履歴・生成物・キャッシュ・依存インストール先は対象外。
・正本のassets/に収録対象ファイルは無い。ゲームの描画/音生成コード自体は全文収録した。
・検品コードにはPlaywrightの共有インストール先 C:\平成ゾンビ学園\node_modules\playwright\index.mjs へのフォールバックがある。外部インストール本体は同梱しない。
・tools/play.mjs・tools/sweep.mjsには既定URLとして旧Pages先が残っている。原文を保持したため、現行配備先と同一と読まないこと。
・参考まとめ C:\石とゾンビ_受け渡し\石とゾンビ_最新コード一式_20260913.txt は保持したまま、別ファイルとしてこの完全収録版を作成した。
・参考まとめのSHA-256: 943b7ab7c91e7c81a853775e0750e64526a071f0540ee6bcefe737407f2ada41
【ファイル目次・照合情報】
01. public/index.html | 1,094行 | 51,323 bytes | 本文開始行 0000161
更新日時(JST): 2026-09-13T19:59:37+09:00 | SHA-256: ee47bb0b20b31375359bd2e16c1c7c97b9672245ee86cfa9310d26e218ccebd8
内容: ゲーム画面全体。HTML/CSS・入力・投石物理・当たり判定・通信・描画・音・検品口
02. public/lib/world.js | 358行 | 14,485 bytes | 本文開始行 0001267
更新日時(JST): 2026-09-13T19:55:11+09:00 | SHA-256: 24dcc8b97f3be7ae17e83bbe6d2728d4834776bfe526e37cdde8410aa23cfa07
内容: クライアント/サーバー共用の世界。ノックバック・移動・HP・報酬・状態送受信
03. src/worker.js | 349行 | 15,352 bytes | 本文開始行 0001637
更新日時(JST): 2026-09-13T19:56:46+09:00 | SHA-256: 39727b7c8659e443ef66a0fa2a52f3ec2945df5c8298d9749de8cb5f36b74365
内容: Workerルーティング・Game/Lobby/Meter/Bug Durable Objects・WebSocket・tick
04. wrangler.toml | 41行 | 1,274 bytes | 本文開始行 0001998
更新日時(JST): 2026-09-13T19:33:29+09:00 | SHA-256: d47da1693a33398fb6d93cb1965f0276eea541da92abc86a219cf07301e26367
内容: 現行Workers配置設定・静的配信・Durable Objects・ガード設定
05. package.json | 13行 | 337 bytes | 本文開始行 0002051
更新日時(JST): 2026-09-13T19:45:28+09:00 | SHA-256: f22af21fa27825a529313dd204061e2ccff3f0eb0ac58be78644043a9c83b3a2
内容: 開発/配備/検品コマンド定義
06. .gitignore | 4行 | 39 bytes | 本文開始行 0002076
更新日時(JST): 2026-09-13T11:38:23+09:00 | SHA-256: debe9e6f288f82479412a140e3be7be4012f6d834a7e06f4e29942e0956105fa
内容: 管理対象から除外するディレクトリの定義
07. docs/K1仕様書_20260913.md | 32行 | 1,891 bytes | 本文開始行 0002092
更新日時(JST): 2026-09-13T11:09:37+09:00 | SHA-256: 78ee3d3aefdd05c81d2fa26154526d94ce0091dea0d4def692a4237148d71299
内容: 最初のK1仕様原文。後続仕様で変更された内容も原文のまま収録
08. docs/K1仕様書_磨き込みとマルチ_20260913.md | 229行 | 12,147 bytes | 本文開始行 0002136
更新日時(JST): 2026-09-13T19:25:14+09:00 | SHA-256: e14c116c3719b903e79ef1cd5243e686f78af189122c888e2963c0a975ffcc66
内容: 磨き込み・2人協力・HP・ラグ対策等のK1追加仕様原文
09. docs/決めたこと.md | 146行 | 12,287 bytes | 本文開始行 0002377
更新日時(JST): 2026-09-13T20:01:15+09:00 | SHA-256: 16dbf9bc2ef6f7f75a7cd5a4784d61d8e79764574ab47cce1a3c5b65268e9224
内容: 初期判断と、後半の磨き込み/マルチ移行時の実装判断・数値・過去の実測記録
10. docs/担当のやることリスト.md | 30行 | 2,140 bytes | 本文開始行 0002535
更新日時(JST): 2026-09-13T20:00:19+09:00 | SHA-256: 92c326f2f13a776f0dca579fdbdb73c0eca2aa6e91383dbd178c0b926eb622c6
内容: 開発担当が記録した作業状況。今回Codexが再検証した結果ではない
11. public/lib/shurechara.js | 158行 | 7,373 bytes | 本文開始行 0002577
更新日時(JST): 2026-09-13T11:14:36+09:00 | SHA-256: a012d11798a0e45c6af30f1f58339d3b96129be5cc80bbaea7c66d03726eb548
内容: キャラクター描画ライブラリ
12. public/lib/wardrobe.js | 1,003行 | 59,103 bytes | 本文開始行 0002747
更新日時(JST): 2026-09-13T19:53:21+09:00 | SHA-256: 215bc7c69944497987aa8e600d49517e397535d564bad08da10683d1b209cfae
内容: 衣装・帽子・石スキンのデータ/描画
13. public/wardrobe_sheet.html | 126行 | 5,602 bytes | 本文開始行 0003762
更新日時(JST): 2026-09-13T19:51:40+09:00 | SHA-256: a86d0c7f6c83d4f4d2037f316aee2c0e214ea181dbdac94fe751f920a3a2491f
内容: 衣装見本ページ
14. public/sfx_volume.js | 40行 | 1,733 bytes | 本文開始行 0003900
更新日時(JST): 2026-08-29T06:41:00+09:00 | SHA-256: 1c12200425de2c6090d7bf4e531874f46afad48fd0930c752cdb6d62a8c2a14b
内容: 効果音音量設定コード
15. public/dev_tools.js | 1,712行 | 102,814 bytes | 本文開始行 0003952
更新日時(JST): 2026-09-12T03:07:54+09:00 | SHA-256: c1ae188b528f8c7420d83e149ff6a0cd2a551d59d18e3d1b489a6c32d8339cf6
内容: 共通開発道具・バグ報告等。省略せず全文収録
16. tools/shot.mjs | 128行 | 6,538 bytes | 本文開始行 0005676
更新日時(JST): 2026-09-13T19:45:08+09:00 | SHA-256: e2e2117487f6c103b7f9f1b5440181d6dafa92fc2ec6a59c557f8f8be46bcb6b
内容: 既存の検品コード。今回の作業では実行していない
17. tools/sweep.mjs | 129行 | 6,297 bytes | 本文開始行 0005816
更新日時(JST): 2026-09-13T19:45:57+09:00 | SHA-256: 0fa11506fd3cf44f0850249f81305055057df4299271f60153898b8ed8a9e21f
内容: 既存の検品コード。今回の作業では実行していない
18. tools/play.mjs | 150行 | 6,716 bytes | 本文開始行 0005957
更新日時(JST): 2026-09-13T19:56:07+09:00 | SHA-256: 15e4c72ff3cf301a7e046af037fc89ed3354c900302ef46ed393c756e39ad1b6
内容: 既存の検品コード。今回の作業では実行していない
19. tools/photo.mjs | 127行 | 5,525 bytes | 本文開始行 0006119
更新日時(JST): 2026-09-13T19:59:37+09:00 | SHA-256: b4df12a4e2852e490ac6f2e3a720c0b9ea94592d0264ae0018369c235d9e3519
内容: 既存の検品コード。今回の作業では実行していない
20. tools/two.mjs | 145行 | 7,075 bytes | 本文開始行 0006258
更新日時(JST): 2026-09-13T19:56:57+09:00 | SHA-256: cdd77076c4c19077c1da8850d409fa2b32af130a36a1306ef0198bb9f2f9f02b
内容: 既存の検品コード。今回の作業では実行していない
21. public/lib/matter.min.js | 6行 | 83,476 bytes | 本文開始行 0006415
更新日時(JST): 2026-09-13T11:13:51+09:00 | SHA-256: 72d30be0f579eb02ce1e0b6f9d359a4f392e6837e5a26ba8be5dbee7f88e24ae
内容: 実際に配置されているMatter.js 0.20.0配布物の全文(MIT表記も保持)
22. tmp/near.mjs | 37行 | 2,215 bytes | 本文開始行 0006432
更新日時(JST): 2026-09-13T19:47:50+09:00 | SHA-256: 4cad95c7ff108f57cc7fdcd03c4011404ad9f6d342f818eb4c52faa5c728ddb3
内容: 参考付録。tmp内の近距離投石/大岩検品スクリプト。現行配信コードではない
【原文全文】
===== BEGIN FILE 01: public/index.html =====
Source-Path: C:\石とゾンビ\public\index.html
Source-Bytes: 51323
Source-Lines: 1094
Source-LastWriteTime-JST: 2026-09-13T19:59:37+09:00
Source-SHA256: ee47bb0b20b31375359bd2e16c1c7c97b9672245ee86cfa9310d26e218ccebd8
Role: ゲーム画面全体。HTML/CSS・入力・投石物理・当たり判定・通信・描画・音・検品口
----- BEGIN ORIGINAL CONTENT -----
石とゾンビ
----- END ORIGINAL CONTENT -----
===== END FILE 01: public/index.html =====
===== BEGIN FILE 02: public/lib/world.js =====
Source-Path: C:\石とゾンビ\public\lib\world.js
Source-Bytes: 14485
Source-Lines: 358
Source-LastWriteTime-JST: 2026-09-13T19:55:11+09:00
Source-SHA256: 24dcc8b97f3be7ae17e83bbe6d2728d4834776bfe526e37cdde8410aa23cfa07
Role: クライアント/サーバー共用の世界。ノックバック・移動・HP・報酬・状態送受信
----- BEGIN ORIGINAL CONTENT -----
/* ===== 石とゾンビ・世界の中身(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=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.t0){
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.dive0) 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;i0?1:0]);
}
for(i=0;i0.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;i0.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= MONTH_LIMIT, n };
return gate.busy;
}
const J = (o, st) => new Response(JSON.stringify(o), {
status: st||200, headers: { 'content-type':'application/json; charset=utf-8' } });
export default {
async fetch(req, env, ctx){
const u = new URL(req.url);
hits++;
if (hits >= SAVE_EVERY){ const n = hits; hits = 0; ctx.waitUntil(saveHits(env, n)); }
if (u.pathname === '/api/meter'){
let n=0; try{
const r = await env.METER.get(env.METER.idFromName('m')).fetch('https://do/get?m='+ym());
n = (await r.json()).n||0;
}catch(e){}
return J({ 月:ym(), 今いくつ:n, 上限:MONTH_LIMIT, 残り:Math.max(0,MONTH_LIMIT-n) });
}
/* ★門をかけるのは「新しく繋ぐ入口」だけ */
const isNew = (u.pathname === '/api/join' || u.pathname === '/ws' || u.pathname === '/api/bug');
if (isNew){
if (env.RL){
try{
const ip = req.headers.get('CF-Connecting-IP') || 'x';
const { success } = await env.RL.limit({ key: ip });
if (!success) return J({ busy:1, why:'rate', msg:'ただいま混み合っています' }, 429);
}catch(e){}
}
if (await tooMuch(env)) return J({ busy:1, why:'month', msg:'ただいま混み合っています' }, 503);
}
if (u.pathname === '/api/join'){
const r = await env.LOBBY.get(env.LOBBY.idFromName('hall')).fetch('https://do/claim');
return new Response(r.body, { status:r.status, headers:{'content-type':'application/json; charset=utf-8'} });
}
if (u.pathname === '/ws'){
const room = (u.searchParams.get('r')||'').replace(/[^a-z0-9]/gi,'').slice(0,24);
if (!room) return new Response('no room', { status:400 });
return env.GAME.get(env.GAME.idFromName(room)).fetch(req);
}
if (u.pathname === '/api/bug'){
return env.BUG.get(env.BUG.idFromName('box')).fetch(req);
}
/* 部屋の様子を覗く窓(点検用)。
★ここは**人の気配を更新しない**。見ているこちらが部屋を延命させないため
(DoSガードの落とし穴。C:\五月雨開発\DoSガード.md に書いてある) */
if (u.pathname === '/api/room'){
const room = (u.searchParams.get('r')||'').replace(/[^a-z0-9]/gi,'').slice(0,24);
if (!room) return J({ ok:0 }, 400);
return env.GAME.get(env.GAME.idFromName(room)).fetch('https://do/count');
}
if (env.ASSETS) return env.ASSETS.fetch(req);
return new Response('not found', { status:404 });
}
};
/* ===== 使った回数を数えるだけの入れ物 ===== */
export class Meter {
constructor(state){ this.s = state; }
async fetch(req){
const u = new URL(req.url), m = u.searchParams.get('m')||ym();
const key = 'n:'+m;
if (u.pathname === '/add'){
const add = parseInt(u.searchParams.get('n')||'0',10)||0;
const cur = (await this.s.storage.get(key))||0;
await this.s.storage.put(key, cur+add);
return J({ n: cur+add });
}
return J({ n: (await this.s.storage.get(key))||0 });
}
}
/* ===== 相手探し(1個だけ) =====
持っているのは「空いている部屋 1件」だけ。
★案内する前に必ず今の人数を確かめる(回転寿司の実測コメントの通り。
確かめないと、強制終了で席が埋まったままの部屋へ送ってしまう) */
export class Lobby {
constructor(state, env){ this.s = state; this.env = env; }
async fetch(req){
const u = new URL(req.url), now = Date.now();
if (u.pathname === '/report'){
const r = u.searchParams.get('r')||'', n = parseInt(u.searchParams.get('n')||'0',10);
const open = u.searchParams.get('o') === '1';
const w = await this.s.storage.get('waiting');
if (open && n === 1) await this.s.storage.put('waiting', { id:r, at:now });
else if (w && w.id === r) await this.s.storage.delete('waiting');
return J({ ok:1 });
}
/* /claim */
const w = await this.s.storage.get('waiting');
if (w && now - w.at < 5*60*1000){
let n = 2, open = false;
try{
const r = await this.env.GAME.get(this.env.GAME.idFromName(w.id)).fetch('https://do/count');
const j = await r.json(); n = j.n; open = !!j.open;
}catch(e){}
if (open && n === 1){ await this.s.storage.delete('waiting'); return J({ room:w.id, join:1 }); }
/* 取った直後でまだ誰も繋いでいない部屋だけ、20秒間は使い回す */
if (n === 0 && now - w.at < 20000) return J({ room:w.id, join:0 });
await this.s.storage.delete('waiting');
}
const id = 'g' + now.toString(36) + Math.random().toString(36).slice(2,6);
await this.s.storage.put('waiting', { id, at:now });
return J({ room:id, join:0 });
}
}
/* ===== 1プレイ = 1部屋 ===== */
const TICK_MS = 80; /* 1秒に12.5回 */
const IDLE_MS = 180000; /* ★誰も繋がっていない状態がこれだけ続いたら畳む(3分) */
const STALE_MS = 60000; /* 合図が来なくなった接続を切る */
const MSG_MIN = 45; /* 1本の繋がりが1秒に受け付ける通の上限の元(45通/秒) */
export class Game {
constructor(state, env){
this.s = state; this.env = env;
this.socks = new Set();
this.w = null; this.timer = null; this.last = 0; this.emptyAt = Date.now();
this.reported = -1;
/* ★自分の部屋の名前。**内側の番号(state.id)ではなく名前**を相手探しへ伝える。
2026-09-13、ここで番号を伝えていたせいで、相手探しが
`idFromName(番号)` という**別の空っぽの部屋**へ案内していた
(2人で開いても永久に合流しなかった。実測で捕まえた) */
this.name = '';
}
world(){
if (!this.w) this.w = new ZW.World({ sim:'full' });
return this.w;
}
info(ws){ return ws.__zg || (ws.__zg = {}); }
send(ws, o){ try{ ws.send(JSON.stringify(o)); }catch(e){} }
all(o, except){ for (const w of this.socks) if (w !== except) this.send(w, o); }
players(){ let n=0; for (const w of this.socks) if (this.info(w).pid) n++; return n; }
isOpen(){ return this.w ? (!this.w.over && this.players() < 2) : true; }
async fetch(req){
const u = new URL(req.url);
const rn = (u.searchParams.get('r')||'').replace(/[^a-z0-9]/gi,'').slice(0,24);
if (rn) this.name = rn;
/* tick=時計が回っているか。**人が0人なら必ずfalse**でなければならない(空の部屋が回り続けない証拠) */
if (u.pathname === '/count') return J({ n: this.players(), open: this.isOpen(), tick: !!this.timer });
if (req.headers.get('Upgrade') !== 'websocket') return new Response('ws only', { status:426 });
const dev = (u.searchParams.get('id')||'').replace(/[^a-z0-9]/gi,'').slice(0,40);
const pair = new WebSocketPair();
const client = pair[0], server = pair[1];
/* ★hibernationは使わない。時計(setInterval)を回し続けたいので普通のacceptにする */
server.accept();
/* 同じ端末の古い繋がりを先に追い出す(幽霊が席を持ったまま満席になるのを防ぐ) */
for (const w of Array.from(this.socks)){
if (dev && this.info(w).dev === dev){ try{ w.close(4001,'same-device'); }catch(e){} this.gone(w); }
}
if (!this.isOpen()){
this.send(server, { y:'full' });
try{ server.close(1000,'full'); }catch(e){}
return new Response(null, { status:101, webSocket: client });
}
const pid = 'p' + (Date.now()%100000).toString(36) + Math.random().toString(36).slice(2,5);
server.__zg = { pid, dev, t: Date.now(), n:0, nt:0 };
this.socks.add(server);
this.emptyAt = 0;
server.addEventListener('message', (ev) => this.onMsg(server, ev.data));
server.addEventListener('close', () => this.gone(server));
server.addEventListener('error', () => this.gone(server));
const w = this.world();
w.addPlayer(pid, null, null);
this.send(server, { y:'w', me:pid, snap:w.snap(), C:{ } });
this.start();
this.report();
return new Response(null, { status:101, webSocket: client });
}
onMsg(ws, raw){
const a = this.info(ws);
/* 1本の繋がりの連打を断る(盾1の内側の守り) */
const now = Date.now();
if (now - a.nt > 1000){ a.nt = now; a.n = 0; }
if (++a.n > MSG_MIN) return;
a.t = now;
let d; try{ d = JSON.parse(raw); }catch(e){ return; }
const w = this.world(), p = w.find(a.pid);
if (!p) return;
switch (d.y){
case 'ping': this.send(ws, { y:'pong', t:d.t }); break;
case 'me':
/* 見た目(アバターと買った物)。数字しか受け取らない */
p.av = { h:(d.h|0)&7, hc:(d.hc|0)&7, sk:(d.sk|0)%4, w:(d.w|0)%8 };
p.look = { c:Math.max(0,Math.min(80,d.c|0)), ht:Math.max(0,Math.min(40,d.ht|0)),
st:Math.max(0,Math.min(20,d.st|0)) };
break;
case 'hit': {
const k = (d.k==='kill'||d.k==='knock'||d.k==='crow'||d.k==='rock') ? d.k : null;
if (k) w.hit(a.pid, k, d.i|0);
break;
}
case 'swap': w.swap(); break;
case 'rock': w.useRock(a.pid); break;
}
}
gone(ws){
if (!this.socks.has(ws)) return;
const a = this.info(ws);
this.socks.delete(ws);
if (this.w && a.pid) this.w.rmPlayer(a.pid);
if (this.socks.size === 0){ this.emptyAt = Date.now(); this.stop(); }
this.report();
}
start(){
if (this.timer) return;
this.last = Date.now();
this.timer = setInterval(() => this.tick(), TICK_MS);
/* ★目覚ましは「畳むため」だけに使う。遊びの進行には使わない */
this.s.storage.setAlarm(Date.now() + IDLE_MS + 5000).catch(()=>{});
}
stop(){ if (this.timer){ clearInterval(this.timer); this.timer = null; } }
tick(){
/* ★人が0人なら1コマも進めない */
if (this.socks.size === 0){ this.stop(); return; }
const now = Date.now();
const dt = Math.min(0.25, (now - this.last)/1000);
this.last = now;
/* 合図の来なくなった繋がりを切る */
for (const w of Array.from(this.socks)){
if (now - (this.info(w).t||now) > STALE_MS){ try{ w.close(1001,'stale'); }catch(e){} this.gone(w); }
}
if (this.socks.size === 0){ this.stop(); return; }
const w = this.world();
w.step(dt);
this.all(w.snap());
if (w.over){
/* 終わったら部屋は使い回さない。相手探しからも外す */
this.report(true);
}
}
async report(force){
if (!this.name) return;
const n = this.players(), open = this.isOpen();
const key = n + (open?'o':'x');
if (!force && key === this.reported) return;
this.reported = key;
try{
await this.env.LOBBY.get(this.env.LOBBY.idFromName('hall'))
.fetch('https://do/report?r='+this.name+'&n='+n+'&o='+(open?1:0));
}catch(e){}
}
/* ★誰も繋がっていない部屋を畳む門。ここが無いと空の部屋が回り続ける */
async alarm(){
if (this.socks.size > 0){
await this.s.storage.setAlarm(Date.now() + IDLE_MS + 5000).catch(()=>{});
return;
}
if (this.emptyAt && Date.now() - this.emptyAt >= IDLE_MS){
this.stop(); this.w = null;
await this.s.storage.deleteAll().catch(()=>{});
await this.s.storage.deleteAlarm().catch(()=>{});
return;
}
await this.s.storage.setAlarm(Date.now() + 30000).catch(()=>{});
}
}
/* ===== バグ報告の箱(平成ゾンビ学園から相続した🐛の受け先) ===== */
const BUG_MAX = 900*1024, BUG_KEEP = 200;
export class Bug {
constructor(state){ this.s = state; }
async fetch(req){
const u = new URL(req.url);
if (req.method === 'POST'){
const body = await req.text();
if (body.length > BUG_MAX) return J({ ok:0, why:'big' }, 413);
const id = Date.now().toString(36) + Math.random().toString(36).slice(2,6);
await this.s.storage.put('b:'+id, { at:Date.now(), body });
const list = (await this.s.storage.get('list'))||[];
list.unshift(id);
while (list.length > BUG_KEEP){ const old = list.pop(); await this.s.storage.delete('b:'+old); }
await this.s.storage.put('list', list);
return J({ ok:1, id });
}
const id = u.searchParams.get('id');
if (id){
if (u.searchParams.get('del') === '1'){
await this.s.storage.delete('b:'+id);
const l = ((await this.s.storage.get('list'))||[]).filter(x=>x!==id);
await this.s.storage.put('list', l);
return J({ ok:1 });
}
const r = await this.s.storage.get('b:'+id);
if (!r) return J({ ok:0 }, 404);
return new Response(r.body, { headers:{'content-type':'application/json; charset=utf-8'} });
}
const list = (await this.s.storage.get('list'))||[];
const out = [];
for (const x of list.slice(0,60)){
const r = await this.s.storage.get('b:'+x);
if (r) out.push({ id:x, at:r.at, bytes:r.body.length });
}
return J(out);
}
}
----- END ORIGINAL CONTENT -----
===== END FILE 03: src/worker.js =====
===== BEGIN FILE 04: wrangler.toml =====
Source-Path: C:\石とゾンビ\wrangler.toml
Source-Bytes: 1274
Source-Lines: 41
Source-LastWriteTime-JST: 2026-09-13T19:33:29+09:00
Source-SHA256: d47da1693a33398fb6d93cb1965f0276eea541da92abc86a219cf07301e26367
Role: 現行Workers配置設定・静的配信・Durable Objects・ガード設定
----- BEGIN ORIGINAL CONTENT -----
name = "ishi-to-zombie"
main = "src/worker.js"
compatibility_date = "2026-09-01"
account_id = "e2b1d2a75fb3d207171ffbfab30ef1c1"
workers_dev = true
# 静的ファイルも同じWorkerから出す。
# ★Pagesではクラス(Durable Object)を持てないので、回転寿司オンラインと同じ形にした。
[assets]
directory = "./public"
binding = "ASSETS"
# 1プレイ=1部屋(Game)。相手探しは1個だけのLobby。使った回数を数えるのがMeter。
[[durable_objects.bindings]]
name = "GAME"
class_name = "Game"
[[durable_objects.bindings]]
name = "LOBBY"
class_name = "Lobby"
[[durable_objects.bindings]]
name = "METER"
class_name = "Meter"
[[durable_objects.bindings]]
name = "BUG"
class_name = "Bug"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Game", "Lobby", "Meter", "Bug"]
# ===== DoSガード(C:\五月雨開発\DoSガード.md) =====
# 盾3: 1回の呼び出しで使えるCPUの上限
[limits]
cpu_ms = 100
# 盾1: 連打を断る。workers.devにはWAFが使えないのでWorkers標準のレート制限で代用。
# namespace_idは他のアプリと数えを分けるため専用の番号(回転寿司1001・フリースロー観戦1101)
[[ratelimits]]
name = "RL"
namespace_id = "1201"
simple = { limit = 20, period = 10 }
----- END ORIGINAL CONTENT -----
===== END FILE 04: wrangler.toml =====
===== BEGIN FILE 05: package.json =====
Source-Path: C:\石とゾンビ\package.json
Source-Bytes: 337
Source-Lines: 13
Source-LastWriteTime-JST: 2026-09-13T19:45:28+09:00
Source-SHA256: f22af21fa27825a529313dd204061e2ccff3f0eb0ac58be78644043a9c83b3a2
Role: 開発/配備/検品コマンド定義
----- BEGIN ORIGINAL CONTENT -----
{
"name": "ishi-to-zombie",
"private": true,
"scripts": {
"deploy": "npx --yes wrangler deploy",
"dev": "npx --yes wrangler dev",
"test": "node tools/shot.mjs index.html",
"sweep": "node tools/sweep.mjs",
"play": "node tools/play.mjs",
"photo": "node tools/photo.mjs",
"two": "node tools/two.mjs"
}
}
----- END ORIGINAL CONTENT -----
===== END FILE 05: package.json =====
===== BEGIN FILE 06: .gitignore =====
Source-Path: C:\石とゾンビ\.gitignore
Source-Bytes: 39
Source-Lines: 4
Source-LastWriteTime-JST: 2026-09-13T11:38:23+09:00
Source-SHA256: debe9e6f288f82479412a140e3be7be4012f6d834a7e06f4e29942e0956105fa
Role: 管理対象から除外するディレクトリの定義
----- BEGIN ORIGINAL CONTENT -----
tmp/
.deploy/
.wrangler/
node_modules/
----- END ORIGINAL CONTENT -----
===== END FILE 06: .gitignore =====
===== BEGIN FILE 07: docs/K1仕様書_20260913.md =====
Source-Path: C:\石とゾンビ\docs\K1仕様書_20260913.md
Source-Bytes: 1891
Source-Lines: 32
Source-LastWriteTime-JST: 2026-09-13T11:09:37+09:00
Source-SHA256: 78ee3d3aefdd05c81d2fa26154526d94ce0091dea0d4def692a4237148d71299
Role: 最初のK1仕様原文。後続仕様で変更された内容も原文のまま収録
----- BEGIN ORIGINAL CONTENT -----
# 石とゾンビ K1仕様書(2026-09-13)
**K1の言葉をそのまま置く紙。ここに無い条件を足さない。**
プロジェクト名は **石とゾンビ**(K1命名)。公開先 https://ishi-to-zombie.pages.dev/
## K1の言葉(原文のまま)
> 子セッションで新ゲーム石とゾンビってゲーム作って欲しい
>
> イメージとしてはフリースローのあのエンジンを流用しつつキャラをもう少し小さくした引きの画角にした右からゾンビが迫ってくる
>
> それにボールを頭頂部に当てた倒すというゲーム
> つまり横なげしてもゾンビは致命傷にならず少しノックバックするだけ
> ゾンビは複数こちらに向かってくる
> プロジェクト名は石とゾンビ
## 読み取れる芯
- **フリースローの投げる手触り(物理)をそのまま流用する**。`C:\フリースロー` は**読むだけ**
- 画角は**引き**。キャラはフリースローより**小さく**
- ゾンビは**右から**、**複数**こちらへ迫ってくる
- **頭頂部に当てると倒せる**。横から当ててもノックバックするだけで致命傷にならない
= 「上から落とす」軌道を作れるかどうかの遊び
## 親が決めた事(2026-09-13)
- 置き場は **Cloudflare Pages**(`ishi-to-zombie` / https://ishi-to-zombie.pages.dev/ )。新しいサービスは足さない
- 公開は `npm run deploy`、検品は `npm test`(Playwright WebKit・iPhone実寸)
- 素材が要る所はCodexへ発注(親が窓口)。**届くまでは色の四角で面白さを先に立てる**
- 音はElevenLabs(`C:\平成ゾンビ学園\tools\make_sfx.mjs` を写して使う。鍵は環境変数)
- 決まり: **遊ぶ画面はスクロールしない / 画面に物を詰め込まない / iPhone実寸390×797と390×664で崩れない**
----- END ORIGINAL CONTENT -----
===== END FILE 07: docs/K1仕様書_20260913.md =====
===== BEGIN FILE 08: docs/K1仕様書_磨き込みとマルチ_20260913.md =====
Source-Path: C:\石とゾンビ\docs\K1仕様書_磨き込みとマルチ_20260913.md
Source-Bytes: 12147
Source-Lines: 229
Source-LastWriteTime-JST: 2026-09-13T19:25:14+09:00
Source-SHA256: e14c116c3719b903e79ef1cd5243e686f78af189122c888e2963c0a975ffcc66
Role: 磨き込み・2人協力・HP・ラグ対策等のK1追加仕様原文
----- BEGIN ORIGINAL CONTENT -----
# 石とゾンビ — 磨き込み・マルチプレイ追加仕様書(2026-09-13 K1)
**K1の言葉をそのまま置く紙。ここに無い条件を足さない。**
## K1の言葉(原文のまま)
> 石とゾンビ — 磨き込み・マルチプレイ追加仕様書
> 作成日: 2026-09-13
>
> 【大前提】
> 既存「石とゾンビ」を土台に磨き込む。別ゲームにしない。
> 既存の芯は維持する。
> ・右から複数ゾンビが迫る
> ・石を物理挙動で投げる
> ・通常石は横から当ててもノックバックのみ
> ・頭頂部へ上から急角度で落ちた石だけが通常ゾンビを倒す
> ・「上から落とす軌道を作れるか」が遊びの中心
> ・石は無限
> ・縦画面を維持
> ・現在のコード描画中心の見た目と手触りを土台にする
>
> 【今回追加する柱】
> 1. シームレスな2人協力マルチ
> 2. HP制と前衛/後衛の交代
> 3. カラス
> 4. 30秒クールダウンの大岩
> 5. スクラップ報酬+生存時間倍率
> 6. アバター/ショップ
> 7. ラグを感じにくいネットワーク表示
>
> 【モード選択を作らない】
> トップ画面の主要導線:
> ・はじめる
> ・ショップ
> ・アバター変更
>
> 「シングル」「マルチ」の選択は作らない。
> 「はじめる」で即ゲーム開始。最初は1人で遊び、同時に裏でマッチング対象になる。
> 別端末のプレイヤーが「はじめる」を押し、参加可能なプレイがあれば途中参加して自然に2人プレイへ移行する。
> 誰も来なければそのままソロ。
> 最大2人。
> ロビー、部屋作成、準備完了等は初期仕様では不要。
> 途中参加・途中退出を許容し、退出ペナルティは設けない。相方が抜ければ残った人はソロで続行。
>
> 【前衛/後衛】
> 2人は画面左側に横並びで配置し、右側(ゾンビに近い側)が前衛、左側が後衛。
> ゾンビの攻撃は基本的に前衛が受ける。
> 1操作で2人の前後を交代できる。
> HPが減った人を後ろへ下げ、余裕のある相方が前へ出られる。
> ゾンビに取り付かれている最中は交代不可。相方が石でノックバックさせて救出する。
>
> 【HP制】
> 既存の「負けライン到達で即ゲームオーバー」はマルチ向けに変更。
> 各プレイヤーに個別HPを持たせる。
> ゾンビが前衛に取り付くとHPが継続減少。
> 相方が石を当ててゾンビをノックバックさせれば取り付きを解除できる。
> HP0のプレイヤーはそのプレイから脱落。
> 相方が生存していればゲームは続行し、1人状態へ戻る。
> 最大HPや秒間ダメージは調整しやすい定数にしてプレイテストで決める。
>
> 【協力の自然発生】
> 役職選択は作らない。2人とも基本操作は石投げ。
> ルールから自然に役割分担を発生させる。
> ・片方が横から当ててゾンビをノックバック/密集させる
> ・片方が上から落として密集したゾンビをまとめて倒す
> ・取り付かれた相方を救出する
> ・片方が地上、片方がカラスを担当する
> ・HPを見て前後交代する
> 重なった複数ゾンビを一投でまとめて倒せる現在の気持ちよさは活かす。
>
> 【新敵: カラス】
> 空中から来る敵。
> 単なる敵数増加ではなく、2人の役割分担を生む目的で追加。
> 「自分はカラス、相方は地上」という分担が効率的になるようにする。
> 画面上部を利用するが、通常石の山なり軌道を邪魔しすぎない。
> HP、倒し方、攻撃方法は最初は簡潔なプロトタイプで実装し、触って調整する。
>
> 【特殊技: 大岩】
> 初期版の特殊技は1種類のみ。
> ・通常石のおよそ3倍の大きさ
> ・クールダウン約30秒
> ・スクラップ消費なし
> ・通常石と違い、頭頂部へ上から当てなくても通常ゾンビを倒せる
> ・危険な状況をひっくり返す救済/爽快手段
> ・重量感、衝突感を出す
> 特殊技を最初から大量に増やさない。
>
> 【スクラップ】
> ゾンビ撃破でゲーム内通貨「スクラップ」を獲得。
> 倒すたびに「+1」「+3」等を視覚表示。
> 右上などに、そのプレイヤーが現在の参加区間で獲得している基礎スクラップ合計を常時表示する。
> マルチでは参加タイミングが違うため、2人の表示値は異なり得る。
>
> 【マルチ時のスクラップ共有】
> 「参加している時間帯のチーム撃破報酬は共有」「参加前の過去報酬は共有しない」。
>
> 例:
> Aがソロ中に500相当を稼ぐ。
> その後Bが途中参加。
> Bは過去の500を受け取らない。
> B参加後は、Aが倒した敵のスクラップもBへ入り、Bが倒した敵のスクラップもAへ入る。
> キルの取り合いを発生させない。
>
> 【生存時間倍率】
> プレイ世界/セッションが長く続くほど、最終精算時のスクラップ倍率を上げる。
> 倍率は基本的に「個人の参加時間」ではなく「そのゲーム自体が何秒生き残っているか」を基準にする。
> 途中参加者も、参加後に獲得した基礎スクラップについては現在の高い生存時間倍率を受けられる。
>
> 仮例:
> 序盤 ×1.0
> 60秒 ×1.2
> 90秒 ×1.3
> 120秒 ×1.5
> 以降さらに上昇
> 正確な数値はプレイテストで調整。
>
> 【精算】
> 死亡、途中退出、セッション終了時に精算。
> 基本式:
> 最終獲得スクラップ = 参加中の基礎スクラップ × 精算時点の生存時間倍率
>
> 「基礎スクラップ → 生存ボーナス×1.5 → 最終獲得」のように増えた理由を明示する。
> 途中退出でもその時点までの正当な報酬を受け取れる。
>
> 【スクラップで能力強化しない】
> スクラップは基本的に見た目の解放用。
> 投石速度、攻撃力、HP、大岩威力等の恒久強化には使わない。
> このゲームの上達軸はプレイヤー自身の投球技術。
> 長期プレイヤーの差は腕前と見た目のコレクションで表現する。
>
> 【アバター変更】
> 既存shurechara.jsの基本要素は最初から利用可能。
> ・髪型8
> ・髪色8
> ・肌色4
> ・基本服色8
> トップ画面の「アバター変更」で自由に基本アバターを変更可能。
> これらはショップ商品にしない。
>
> 【ショップ】
> 初期カテゴリは3つ。
> A. 特別衣装 約50種類
> B. 帽子/頭装備 約20種類
> C. 石スキン 約10種類
> 眼鏡等のアクセサリーは初期版では入れない。
> 全商品は見た目だけで性能差なし。
>
> 【衣装 約50種類】
> 既存の色違いではなく形そのものが違う衣装。
> 約70%をかわいい/女性向け寄り、約30%を男性向け/中性的にする。
> 例: フリル、メイド服風、アイドル風、リボン、ワンピース風、セーラー風、パーカー、作業着、スーツ、スポーツユニフォーム、オーバーオール、ジャケット等。
> 実在ブランドや既存キャラクターの模倣はしない。
> 凝った衣装ほどスクラップ価格を高くするが、性能差はない。
> 既存の素朴なコード描画の味を維持する。
>
> 【帽子 約20種類】
> 例: キャップ、ニット帽、麦わら帽子、ハット、ベレー帽、リボン、猫耳風頭装備、王冠風、ヘルメット等。
> 髪型との組み合わせで破綻しないようにする。
>
> 【石スキン 約10種類】
> 物理性能・当たり判定は通常石と同一。見た目のみ変更。
> 例: 通常石、色違い、ハート型、少し光る石、丸い石、角張った石、模様入り等。
> 季節限定は現段階では不要。
>
> 【ネットワーク/ラグ対策】
> 目標は「通信しているから操作が重い」と感じさせないこと。
>
> 自分の操作:
> ローカルで即時反映。投石時に通信往復を待たない。
>
> 相方:
> 受信位置をそのままカクカク切り替えず補間して滑らかに表示。
>
> ゾンビ:
> 同期情報間を補間し、必要に応じて予測して滑らかに表示。
>
> HP、死亡、ゾンビ撃破、スクラップ、大岩使用、前後交代、参加/退出等の重要状態は、プレイヤー間で食い違わない権威的な状態管理を行う。
>
> 投石は操作レスポンス最優先。本人端末では即座に飛ばし、その後必要な同期・補正を行う。
> 協力ゲームなので対戦FPSほどの公平性は不要だが、「自分では明らかに当たったのに何も起きない」は極力避ける。
>
> 具体的な通信方式・サーバー構成は既存の五月雨開発環境を確認して選択する。
> 新しい外部サービスを勝手に追加しない。
>
> 【既存難易度】
> 現状は主に撃破数に応じてゾンビ速度が上がり、出現間隔も短くなる。
> この「続けるほど苦しくなる」性質は生存時間倍率と相性が良いため、まず残して検証する。
> 時間制クリアにはせず、どこまで生き残れるかを基本とする。
>
> 【実装優先順位】
> 既存の投石物理と頭頂部判定を壊さないことが最優先。
>
> 順序の目安:
> 1. 既存シングルの挙動を保持
> 2. HP制
> 3. 2人参加/退出と前後交代
> 4. マルチ同期と補間
> 5. スクラップ+共有+生存倍率+精算
> 6. 大岩
> 7. カラス
> 8. アバター変更
> 9. ショップと衣装/帽子/石スキン
> 10. iPhone実寸で検品・調整
>
> 【完成条件】
> ・「はじめる」だけで即遊べる
> ・誰か来れば途中から自然に2人になる
> ・抜けてもゲームが壊れない
> ・2人でノックバック、まとめ撃破、救出、前後交代、対カラスの分担が自然に起こる
> ・投石操作が通信待ちで重くならない
> ・ゾンビと相方が通信でガクガクしない
> ・撃破のたびスクラップ獲得が視覚的に分かる
> ・死亡/退出時に生存倍率がかかり、報酬が明快
> ・スクラップを貯めて見た目を集めたくなる
> ・課金や能力強化ではなく、腕前と見た目が継続動機になる
> ・何より既存「上から落とす軌道を作る」面白さを損なわない
## 親が決めた環境(2026-09-13)
マルチが入るので、置き場が変わります。**新しい外部サービスは足しません。**
| 決めた事 | 中身 |
|---|---|
| 置き場 | **Cloudflare Workers + Durable Objects + WebSocket**。**Pagesではクラスを持てない**ので、回転寿司オンラインとぷっちーずと同じ形にする(`[assets]` で静的ファイルも同じWorkerから出す) |
| 部屋 | 1プレイ=1 Durable Object。待合・部屋作成は作らない(「はじめる」で即・空きがあれば途中参加) |
| 正 | **HP・死亡・撃破・スクラップ・大岩・前後交代・参加退出はサーバーが正。**投石の見た目は自分の端末で即時、後から合わせる |
| 守り | **DoSガード3盾を必ず入れる**(`C:\五月雨開発\DoSガード.md`)。①連打を断る ②月の上限で止める ③CPU上限 |
| ★目覚まし | **人が誰もいない部屋が回り続けないこと。**誰も繋がっていない状態が数分続いたら畳む。止める条件を「全滅」のような遊びの中の出来事にしない(2026-09-13にぷっちーずで実際に起きた) |
| 見た目 | **今のコード描画のまま。**絵は足さない(衣装・帽子・石スキンもコードで描く) |
----- END ORIGINAL CONTENT -----
===== END FILE 08: docs/K1仕様書_磨き込みとマルチ_20260913.md =====
===== BEGIN FILE 09: docs/決めたこと.md =====
Source-Path: C:\石とゾンビ\docs\決めたこと.md
Source-Bytes: 12287
Source-Lines: 146
Source-LastWriteTime-JST: 2026-09-13T20:01:15+09:00
Source-SHA256: 16dbf9bc2ef6f7f75a7cd5a4784d61d8e79764574ab47cce1a3c5b65268e9224
Role: 初期判断と、後半の磨き込み/マルチ移行時の実装判断・数値・過去の実測記録
----- BEGIN ORIGINAL CONTENT -----
# 決めたこと(石とゾンビ)
**K1が決めた事は `docs/K1仕様書_20260913.md`。この紙は私(子セッション)が決めた事の記録。**
1行ずつ、決めた事と理由。後から「なぜこうなっているか」を探させないため。
## 2026-09-13(立ち上げの日)
### 絵と素材
- **絵は1枚も使わない。全部コードで描く**(K1「フリースローはそもそもイラストないやん」)。`docs/asset_prompts.md` は作らない・Codexへの絵の発注もしない
- **人は共有財産のシュールキャラを流用**(`C:\五月雨開発\シュールキャラ\shurechara.js` を `lib/shurechara.js` へ写した)。**元のファイルは1文字も直していない**——あれは共有財産で、直すなら回転寿司側にも入れる決まりだから
- **ゾンビはシュールキャラを崩した形**。崩し方は4つだけ: ①死人の肌 ②体を傾ける ③片足を引きずる ④腕を前へ出す。新しい絵は作らない
- **死人の肌と汚れた服は、シュールキャラの色表へ**実行時に後ろから足す**だけにした**。元の8色(町の人の服)もゾンビに使う——**町の人がそのまま変わり果てた**方が、全員が同じ死人の制服を着ているより気味が悪い
- ゾンビの見た目は**1体ずつバラバラに引く**。`SHURE.fromSeed` に連番を渡すと服の色が `n/256` で決まるので、**4体並べたら4体とも黄色**になった(実測して直した)
- 石・空・月・雲・校舎・地面も全部コード。画像の読み込みはゼロ
### 遊びの芯
- **倒せるのは「頭の丸へ・上から・急な角度で入った石」だけ。**判定は3つ同時: ①落ちている(vy>0) ②入った所が頭の中心より上 ③落ちる速さ >= 横の速さ×0.60(=水平から31度より急)
- **判定は「投げた角度」ではなく「当たる時に落ちている角度」で見る。**だから低い山なりでも、届く頃に急降下していれば倒せる。跳ね返らせて上から落とすのも同じ理屈で通る(K1の言う「上から落とす軌道を作れるか」はそういう意味だと読んだ)
- 体・足・頭の横に当たったら**ノックバックだけ**。右へ押し戻し(最大2.6m/s相当)+0.40秒よろける。連打しても積み上がらないよう上限を付けた
- **石は無限・同時に3個まで・投げた後0.14秒で次**。弾数を資源にしない(狙いの遊びであって節約の遊びではない)。同時3個が上限なので横なげ連打では押さえきれない
- **1体でも負ける線(投げる人から0.80m)まで来たら即負け。**体力・残機は作らない。短く何度も遊ぶ形
### 画角と寸法
- **横に7.8m入れる引きの画角**(フリースローは4.3m)。シュールキャラの頭の半径は15px=フリースローの7割。K1の「もう少し小さく」
- 9.0m(頭の半径13px)も作って撮ったが、**頭が25px径まで落ちて的が読めなくなった**ので7.8mへ戻した
- 投げる物理の数字は**フリースローそのまま**(引いた長さ→速さ 4.6〜9.2m/s・角度14〜84度・重力9.8・空気抵抗0.0022)。跳ね返りだけ石らしく0.62→0.46へ落とした
- **頭のてっぺんに金色の弧を描く。**「どこを狙うか」を文字で説明しないため。最初は薄い線にしたら夜の髪に沈んで1本も見えなかったので、暗い線を敷いてから明るい線を乗せた
### やってみて捨てた物
- **地平線をぞろぞろ歩く遠い群れ**(「まだいくらでも来る」の気配)。校舎より奥へ置くと1体も見えず、手前へ置くと近くのゾンビと取り違える。どちらでも値打ちが出ないので**消した**
- fps・メモリの計器板(dev_tools.js の物)。盤面に被るので `window.SETTINGS={memMeter:false}` で既定オフ。**🐛報告の箱は残す**(あれは財産)
### 音
- **音のファイルを持たない。**全部その場で合成する(フリースローと同じ作り)。倒した「ゴッ」・押した「ドス」・投げる息・石が地面を打つ音・負けた時の唸り
- **iOSのマナーモードでは効果音が黙る。**合成した音は着信音の仲間で、曲(`